From 929dff835a0bda0bb832356ee131efce500b0a9c Mon Sep 17 00:00:00 2001 From: Timothy Date: Sat, 5 Sep 2026 12:42:36 +0200 Subject: [PATCH] fix(916): the Codex runner runs beside the lenses, only the fallback waits; head_sha described everywhere; the docs keep their thresholds The runner builds nothing, so serialising it only added its wait to the critical path; the worktree-isolated fallback is what must follow the lenses, and the harness case now records lens count at the FALLBACK's start alone. setTimeout in the harness is globalThis.setTimeout (the .mjs lint config has ES builtins only). head_sha carries the same description in both scripts and every fixer/implementer prompt asks for the worktree HEAD, not a PR head. The mechanics page says why the cap stays at one after the serialisation and restores the 20%/10% RAM thresholds by key; the record says "several", not "three". Decisions-Edit: yes Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV --- .claude/workflows/ersatztv-issue-build.js | 25 ++++++++++------- .claude/workflows/ersatztv-resume-branch.js | 27 +++++++++++-------- .../records/process/orchestrated-session.md | 3 +-- docs/handoffs/orchestration.md | 16 ++++++----- docs/handoffs/orchestrator-prompt.md | 2 +- .../orchestration-workflow-loop.test.mjs | 20 +++++++------- 6 files changed, 52 insertions(+), 41 deletions(-) diff --git a/.claude/workflows/ersatztv-issue-build.js b/.claude/workflows/ersatztv-issue-build.js index 7f4c6b379..b5fa31668 100644 --- a/.claude/workflows/ersatztv-issue-build.js +++ b/.claude/workflows/ersatztv-issue-build.js @@ -118,7 +118,7 @@ ${CLAIM} ${recon ? `Recon (verify what you rely on):\nPLAN: ${recon.plan}\nFACTS: ${recon.facts}\nRISKS: ${recon.risks}\nTEST PLAN: ${recon.test_plan}\n` : ''} You are the implementer. Close ${REF} completely: pin the behaviour with tests named for the branch they protect, update the docs the change obligates, commit. Then git fetch origin and rebase onto origin/main if it moved (never merge main in; regenerate generated artifacts), run the LOCAL GATE and STOP — do not push; reviewers read your worktree first, and a finisher pushes once after the review loop is clean. ${GATE} -Report done=true with the gate output when the worktree is ready for review, with pr_url empty.`, +Report done=true with the gate output when the worktree is ready for review, with pr_url empty and head_sha = git rev-parse HEAD.`, { label: `impl:${REF}`, model: implModel, effort: implEffort, schema: REPORT_SCHEMA }) if (!impl) return { issues, error: 'implementer returned nothing' } if (!impl.done) return { issues, error: 'implementer stopped', impl } @@ -134,12 +134,14 @@ const LENSES = [ { key: 'conformance', model: 'sonnet', prompt: 'repo conformance: docs-update obligations met in this diff (endpoint → api-conventions + regenerated v1.json/endpoint-index; screen/route → blazor-route-parity + domain-model; convention → decision record + regenerated catalog; new doc → README index); no narrative in docs; every new script or hook has its inventory row; CPM respected; both-provider migration if the model changed; tests are NUnit/vitest in the existing projects; no BOM in touched .cs; no edit to a file another slot owns (listed above); commit trailers present; branch rebased on current origin/main; nothing pushed yet.' }, ] let xfamily = rubric ? 'codex' : 'not required (routine risk class under process.independent-review-rubric)' -async function codexReview(round) { +async function codexRunner(round) { const r = await agent(`${reviewCommon(Number(args.port) + 3)} You run the cross-family review — the diff touches a class where process.independent-review-rubric requires a reviewer from another model family, and you are only the runner. Write a prompt file under a directory you create in /private/tmp asking for an adversarial correctness and security review of the diff of branch ${BRANCH} against origin/main in ${WT} for issue(s) ${REF} with done condition "${args.done_condition}", listing findings as blocking / should-fix / nit with file and evidence, ending with a line VERDICT: merge or VERDICT: send-back. Run it EXACTLY like this, in the background, output to a file, stdin from /dev/null (it hangs otherwise): codex exec -C ${WT} -s read-only "$(cat )" < /dev/null > 2>&1 — then wait for the process to exit (poll pgrep on its PID with Monitor; measured 2026-07-28 in the #672 session, a real review took ~35 minutes for a 7-file diff) and read the file. Return its findings faithfully in the schema with ran=true; if the file has no VERDICT line the run failed (quota, tool error) — return ran=false, verdict merge, no findings, and put the file's tail in a single nit finding so the failure is visible; never invent a verdict.`, { label: `review:codex:r${round}`, phase: 'Review', model: 'sonnet', effort: 'low', schema: RUNNER_SCHEMA }) - if (r && r.ran === true) return r + return r +} +async function codexFallback(round, r) { xfamily = `codex could not run in round ${round} (${r ? 'no VERDICT line' : 'runner returned nothing'}); substituted a cold same-family review-only agent per process.independent-review-rubric — retry cross-family next window` log(`${REF}: ${xfamily}`) return agent(`${reviewCommon(Number(args.port) + 2)} @@ -148,17 +150,20 @@ You are a COLD, review-only substitute for a cross-family reviewer that could no { label: `review:fallback:r${round}`, phase: 'Review', model: 'opus', effort: 'high', isolation: 'worktree', schema: FINDINGS_SCHEMA }) } async function review(round) { - // The two lenses run together; the cross-family step (the Codex runner, then its worktree-isolated - // fallback) runs AFTER they return. Two worktree-isolated reviewers building .NET at once from one - // slot saturated swap on the 16 GB host (#916), and the fallback is one of them. + // The Codex runner builds nothing, so it may run beside the lenses; the FALLBACK is a second + // worktree-isolated .NET reviewer and starts only after both lenses have returned. + const runnerPromise = rubric ? codexRunner(round).catch(() => null) : Promise.resolve(null) const lenses = (await parallel(LENSES.map(l => () => agent(`${reviewCommon(Number(args.port) + 1)} Review round ${round} of the branch for ${REF}. Lens: ${l.prompt} Be adversarial; report only what you verified, with evidence. blocking = done condition or a repo rule violated, or a test that passes for the wrong reason; should-fix = real defect; nit = style. Verdict send-back if any blocking.`, { label: `review:${l.key}:r${round}`, phase: 'Review', model: l.model, effort: 'high', isolation: l.isolation, schema: FINDINGS_SCHEMA })))).filter(Boolean) if (!rubric) return lenses - const xf = await codexReview(round) - return xf ? lenses.concat([xf]) : lenses + const r = await runnerPromise + if (r && r.ran === true) return lenses.concat([r]) + let fb = null + try { fb = await codexFallback(round, r) } catch (e) { log(`${REF}: fallback reviewer threw: ${e && e.message}`) } + return fb ? lenses.concat([fb]) : lenses } let round = 1 @@ -179,7 +184,7 @@ ${WORKTREE} You are the fixer. Reviewers found these problems in the unpushed branch; fix every blocking and should-fix one as new commits, or show with evidence why a finding is wrong: ${JSON.stringify(reviews.flatMap(r => r.findings.filter(f => f.severity !== 'nit')), null, 1)} Then re-run the LOCAL GATE and STOP without pushing; the reviewers read the worktree again. ${GATE} -Report.`, +Report, with head_sha = git rev-parse HEAD of the worktree after your last commit.`, { label: `fix:r${round}`, phase: 'Fix', model: implModel, effort: implEffort, schema: REPORT_SCHEMA }) if (!fix || !fix.done) return { issues, error: `fixer for round ${round} ${fix ? 'stopped' : 'returned nothing'}; not pushed`, fix, history } history[history.length - 1].fix = fix @@ -203,7 +208,7 @@ Then ONE push: git push -u origin ${BRANCH}. Open the PR with the Gitea API (POS <> -${FIX_RANGES.length ? `followed by what each fix commit changed, read from git show and not from memory, for exactly the commits git log --oneline lists in these ranges: ${FIX_RANGES.join(' | ')}` : (history.some(h => h.fix) ? 'and a sentence saying every finding was answered without a new commit, as the history block records' : 'and a sentence saying no fix commit exists because round one was clean')}, then the cross-family review status verbatim — "${xfamily}" — and every deliberately-left item with an issue number (file follow-up issues where needed). End the body with: +${FIX_RANGES.length ? `followed by what each fix commit changed, read from git show and not from memory, for exactly the commits git log --oneline lists in these ranges: ${FIX_RANGES.join('; ')}` : (history.some(h => h.fix) ? 'and a sentence saying every finding was answered without a new commit, as the history block records' : 'and a sentence saying no fix commit exists because round one was clean')}, then the cross-family review status verbatim — "${xfamily}" — and every deliberately-left item with an issue number (file follow-up issues where needed). End the body with: 🤖 Generated with [Claude Code](https://claude.com/claude-code) ${SESSION_URL} diff --git a/.claude/workflows/ersatztv-resume-branch.js b/.claude/workflows/ersatztv-resume-branch.js index 4f205e55a..47b75d672 100644 --- a/.claude/workflows/ersatztv-resume-branch.js +++ b/.claude/workflows/ersatztv-resume-branch.js @@ -80,12 +80,12 @@ let work if (args.mode === 'implement') { work = await agent(`${COMMON} -You are the implementer, continuing a paused session. Read git log and git show for the branch's commits first; a WIP checkpoint commit is the paused implementer's partial edit. ${REBASE} The brief's recon is a plan; verify what you rely on. Finish the done condition completely, with a regression test that reddens against the unfixed code. Run the LOCAL GATE and STOP without pushing; reviewers read the worktree first. ${GATE}`, +You are the implementer, continuing a paused session. Read git log and git show for the branch's commits first; a WIP checkpoint commit is the paused implementer's partial edit. ${REBASE} The brief's recon is a plan; verify what you rely on. Finish the done condition completely, with a regression test that reddens against the unfixed code. Run the LOCAL GATE and STOP without pushing; reviewers read the worktree first; report head_sha = git rev-parse HEAD of the worktree after your last commit. ${GATE}`, { label: `impl:${REF}`, model: 'opus', effort: 'high', schema: REPORT_SCHEMA }) } else { work = await agent(`${COMMON} -You are the fixer, continuing a paused session. The PR is #${args.pr}. ${REBASE} Then the brief's findings are the last review round's: fix every blocking and should-fix one as new commits, or show with evidence why a finding is wrong. Run the LOCAL GATE and STOP without pushing; reviewers read the worktree first. ${GATE}`, +You are the fixer, continuing a paused session. The PR is #${args.pr}. ${REBASE} Then the brief's findings are the last review round's: fix every blocking and should-fix one as new commits, or show with evidence why a finding is wrong. Run the LOCAL GATE and STOP without pushing; reviewers read the worktree first; report head_sha = git rev-parse HEAD of the worktree after your last commit. ${GATE}`, { label: `fix:${REF}`, model: 'opus', effort: 'high', schema: REPORT_SCHEMA }) } if (!work) return { issues, error: 'work agent returned nothing' } @@ -102,12 +102,14 @@ const LENSES = [ { key: 'conformance', model: 'sonnet', prompt: 'repo conformance: docs-update obligations met; no narrative in docs; inventory rows for new scripts/hooks; CPM respected; both-provider migration if the model changed; no BOM in touched .cs; no WIP commit left in history; branch rebased on current origin/main; commit trailers present; PR body will carry fixes #N for each issue.' }, ] let xfamily = rubric ? 'codex' : 'not required (routine risk class under process.independent-review-rubric)' -async function codexReview(round) { +async function codexRunner(round) { const r = await agent(`${reviewCommon(Number(args.port) + 3)} You run the cross-family review required by process.independent-review-rubric; you are only the runner. Write a prompt file under a directory you create in /private/tmp asking for an adversarial correctness and security review of branch ${BRANCH} against origin/main in ${WT} for ${REF} with done condition from the brief, findings as blocking / should-fix / nit with file and evidence, ending with VERDICT: merge or VERDICT: send-back. Run EXACTLY: codex exec -C ${WT} -s read-only "$(cat )" < /dev/null > 2>&1 in the background, wait for the PID to exit (Monitor; measured 2026-07-28 in the #672 session, ~35 minutes for a 7-file diff), read the file, return its findings faithfully with ran=true; no VERDICT line means the run failed — return ran=false, verdict merge, no findings, and the file's tail in one nit finding; never invent a verdict.`, { label: `review:codex:r${round}`, phase: 'Review', model: 'sonnet', effort: 'low', schema: RUNNER_SCHEMA }) - if (r && r.ran === true) return r + return r +} +async function codexFallback(round, r) { xfamily = `codex could not run in round ${round} (${r ? 'no VERDICT line' : 'runner returned nothing'}); substituted a cold same-family review-only agent per process.independent-review-rubric — retry cross-family next window` log(`${REF}: ${xfamily}`) return agent(`${reviewCommon(Number(args.port) + 2)} @@ -116,17 +118,20 @@ You are a COLD, review-only substitute for a cross-family reviewer that could no { label: `review:fallback:r${round}`, phase: 'Review', model: 'opus', effort: 'high', isolation: 'worktree', schema: FINDINGS_SCHEMA }) } async function review(round) { - // The two lenses run together; the cross-family step (the Codex runner, then its worktree-isolated - // fallback) runs AFTER they return. Two worktree-isolated reviewers building .NET at once from one - // slot saturated swap on the 16 GB host (#916), and the fallback is one of them. + // The Codex runner builds nothing, so it may run beside the lenses; the FALLBACK is a second + // worktree-isolated .NET reviewer and starts only after both lenses have returned. + const runnerPromise = rubric ? codexRunner(round).catch(() => null) : Promise.resolve(null) const lenses = (await parallel(LENSES.map(l => () => agent(`${reviewCommon(Number(args.port) + 1)} Review round ${round} of the branch for ${REF}. Lens: ${l.prompt} Be adversarial; report only what you verified, with evidence. blocking = done condition or a repo rule violated, or a test that passes for the wrong reason; should-fix = real defect; nit = style. Verdict send-back if any blocking.`, { label: `review:${l.key}:r${round}`, phase: 'Review', model: l.model, effort: 'high', isolation: l.isolation, schema: FINDINGS_SCHEMA })))).filter(Boolean) if (!rubric) return lenses - const xf = await codexReview(round) - return xf ? lenses.concat([xf]) : lenses + const r = await runnerPromise + if (r && r.ran === true) return lenses.concat([r]) + let fb = null + try { fb = await codexFallback(round, r) } catch (e) { log(`${REF}: fallback reviewer threw: ${e && e.message}`) } + return fb ? lenses.concat([fb]) : lenses } let round = 1 @@ -144,7 +149,7 @@ while (sendBack.length && round < 3) { You are the fixer. Reviewers found these problems in the unpushed, rebased branch; fix every blocking and should-fix one as new commits, or show with evidence why a finding is wrong: ${JSON.stringify(reviews.flatMap(r => r.findings.filter(f => f.severity !== 'nit')), null, 1)} -Re-run the LOCAL GATE and STOP without pushing. ${GATE}`, +Re-run the LOCAL GATE and STOP without pushing; report head_sha = git rev-parse HEAD of the worktree after your last commit. ${GATE}`, { label: `fix:r${round}`, phase: 'Fix', model: 'opus', effort: 'high', schema: REPORT_SCHEMA }) if (!fix || !fix.done) return { issues, error: `fixer for round ${round} ${fix ? 'stopped' : 'returned nothing'}; not pushed`, fix, history } history[history.length - 1].fix = fix @@ -168,7 +173,7 @@ Then git push --force-with-lease origin ${BRANCH}. ${args.pr ? `Update PR #${arg <> -${FIX_RANGES.length ? `followed by what each fix commit changed, read from git show and not from memory, for exactly the commits git log --oneline lists in these ranges: ${FIX_RANGES.join(' | ')}` : (history.some(h => h.fix) ? 'and a sentence saying every finding was answered without a new commit, as the history block records' : 'and a sentence saying no fix commit exists because round one was clean')}, then the cross-family review status verbatim — "${xfamily}" — every deliberately-left item with an issue number, and end with: +${FIX_RANGES.length ? `followed by what each fix commit changed, read from git show and not from memory, for exactly the commits git log --oneline lists in these ranges: ${FIX_RANGES.join('; ')}` : (history.some(h => h.fix) ? 'and a sentence saying every finding was answered without a new commit, as the history block records' : 'and a sentence saying no fix commit exists because round one was clean')}, then the cross-family review status verbatim — "${xfamily}" — every deliberately-left item with an issue number, and end with: 🤖 Generated with [Claude Code](https://claude.com/claude-code) ${SESSION_URL} diff --git a/docs/decisions/records/process/orchestrated-session.md b/docs/decisions/records/process/orchestrated-session.md index f7e932182..1fe9a9528 100644 --- a/docs/decisions/records/process/orchestrated-session.md +++ b/docs/decisions/records/process/orchestrated-session.md @@ -10,8 +10,7 @@ signals: 'orchestrator · referee · slots · parallel issues · worktree per is mechanics: '`docs/handoffs/orchestration.md` owns roles, isolation and the landing order; the Workflow scripts under `.claude/workflows/` encode it. Box-ticking: the implementer writes the evidence per box into the `## Closing record`; the orchestrator ticks after reading it and the review evidence, then `scripts/post-review-verdict.sh`.' --- -The kickoff (`docs/handoffs/chicorytv-issue-queue.md`) binds a session that closes one issue. Running -three at once does not relax any of it; what it adds is a referee, and two places where the +The kickoff (`docs/handoffs/chicorytv-issue-queue.md`) binds a session that closes one issue. Running several at once does not relax any of it; what it adds is a referee, and two places where the single-session rules needed a stated scope rather than a contradiction. **Force-with-lease.** `process.pr-routine-sequence` forbids amending or force-pushing a pushed branch; diff --git a/docs/handoffs/orchestration.md b/docs/handoffs/orchestration.md index 4282084c8..07b1cb29c 100644 --- a/docs/handoffs/orchestration.md +++ b/docs/handoffs/orchestration.md @@ -52,14 +52,18 @@ script and returns a path for later runs. lockfiles match, otherwise `npm ci`. The shared copy is kept current by `scripts/refresh-shared-checkout.sh` at session end. - **One .NET-building slot at a time on this host; docs and Python slots may run beside it.** A - slot's correctness reviewer builds .NET in its own worktree, and on a rubric-class change the - cross-family fallback is a second such reviewer, so the review round of ONE .NET slot is two + slot's correctness reviewer builds .NET in its own worktree, and on a rubric-class change whose + Codex runner cannot run the fallback is a second such reviewer, so a review round can be two build-and-test pipelines on top of the implementer's lingering MSBuild node servers. Measured 2026-09-05 on the 16 GB Mac: three slots in review reached load 82 and swap exhaustion; one slot's - round alone took swap from 6.8 GB to 10.8 GB in three minutes. The scripts therefore run the - cross-family step after the lenses return, never beside them (`process.build-concurrency-limits` - is the standing rule; the Agent hook's RAM gate does not see a workflow's agents, so the - orchestrator holds the count itself). + round alone took swap from 6.8 GB to 10.8 GB in three minutes with the two reviewers concurrent. + The scripts therefore start the fallback reviewer only after the lenses return (the Codex runner + builds nothing and runs beside them). The cap stays at one even so: the implementer's own build, + one reviewer pipeline and the MSBuild node servers that linger after each build already fill the + budget beside the host's other sessions; re-measure before raising it. + `process.build-concurrency-limits` is the standing rule (under 20% free RAM launch nothing that + builds, under 10% pause); the Agent hook's RAM gate does not see a workflow's agents, so the + orchestrator holds the count itself. - **Live-E2E runs per worktree on the slot's own port.** `scripts/e2e-local.sh` refuses concurrent runs *within one repo root* because each run re-copies that root's `wwwroot`; across worktrees there is nothing shared but the ports, so every slot gets a distinct `port` argument, one run at a diff --git a/docs/handoffs/orchestrator-prompt.md b/docs/handoffs/orchestrator-prompt.md index 66475a6ce..8f6dcf847 100644 --- a/docs/handoffs/orchestrator-prompt.md +++ b/docs/handoffs/orchestrator-prompt.md @@ -10,7 +10,7 @@ Way of working: - Keep at most one .NET-building issue in flight (a docs or Python issue may run beside it), each in its own worktree under `~/orca/workspaces/ersatztv/wt-` on branch `-`, cut from a fresh `origin/main` by absolute path, never under `/tmp`, and each with its own E2E port passed as `port`. Refill a slot as soon as one merges. Pass every running issue's files as the avoid list so two agents never edit one file; a pick that needs a file another slot holds waits for that slot. - Each implementer claims its own issue after the four-way check (open PRs, remote branches, comments predating the label, a fresh fetch), with the `in-progress` label and a claiming comment; rebases onto a fresh `origin/main`, runs the local gate and sits inside the two-lens review loop BEFORE its single push; its finisher opens a PR whose body says `fixes #N` for every issue in the bundle and states the cross-family review status, and posts the `## Closing record` on each issue with the evidence per `## Done-when` box, ticking nothing — a box ticked by the agent it certifies is self-consent. - You referee: read the verdicts and the evidence, send a PR back for anything that lets a route or test pass having done nothing, tick each Done-when box whose evidence holds, re-read the head sha, then post the verdict with `scripts/post-review-verdict.sh MERGEABLE ` and tick the review box. Merge only through the Gitea merge tool with the full head sha, so the consent hook derives consent from the ticked boxes, the sha-bound verdict and green CI. A rebase voids the verdict: the finisher rebases once before the push and reports whether the patch changed; a changed patch gets one more review round before you post. After the merge clear the `in-progress` label and remove the worktree. -- One .NET slot means two build pipelines in its review round (the correctness lens and, on a rubric change, the fallback reviewer, run one after the other); measure `memory_pressure -Q` and swap before every launch; live-E2E runs one at a time per worktree on the slot's own port, and a busy port is reported, never taken over. Kill only PIDs you started, gently. A `dotnet test` or E2E run silent for ten minutes is a hang. +- One .NET slot means two build pipelines in its review round (the correctness lens and, on a rubric change whose Codex runner cannot run, the fallback reviewer, one after the other); `process.build-concurrency-limits` holds — under 20% free RAM launch nothing that builds, under 10% pause — so read `memory_pressure -Q` and swap before every launch; live-E2E runs one at a time per worktree on the slot's own port, and a busy port is reported, never taken over. Kill only PIDs you started, gently. A `dotnet test` or E2E run silent for ten minutes is a hang. - Reviewers never delete anything outside a scratch directory they created under `/private/tmp` and never build a path with `..` segments; the auto-mode classifier is the last line, not the first. - Under an API incident (watch `https://status.claude.com/api/v2/status.json`), move mechanical finishers and probe-driven reviews to whichever model is healthy and park large implementation until it clears; back off before retrying. - Report to the user only what changes what they would do next: merged and closed issues, a send-back and why, a hang, an incident. Before any stop: the H12 audit (`process.issue-qualification-audit`), `scripts/refresh-shared-checkout.sh`, and a handoff memory naming every open branch, its worktree, its PR and its next step, so a resumed session finishes those before picking anything new. diff --git a/web/scripts/orchestration-workflow-loop.test.mjs b/web/scripts/orchestration-workflow-loop.test.mjs index 606875375..dc14d5d28 100644 --- a/web/scripts/orchestration-workflow-loop.test.mjs +++ b/web/scripts/orchestration-workflow-loop.test.mjs @@ -42,26 +42,24 @@ function run(script, args, { reviewRounds, landOverrides = {}, postRebase = null const calls = []; let round = 0; let fixCount = 0; - let lensesInFlight = 0; - const xfamilyStartedWithLensesInFlight = []; + let lensesReturned = 0; + const lensesReturnedWhenFallbackStarted = []; const agent = async (prompt, opts) => { const label = (opts && opts.label) || ''; calls.push({ label, prompt, opts }); if (label.startsWith('impl:') || label.startsWith('fix:#')) return { done: true, summary: '', verified: '', left: '', commits: IMPL_COMMITS, head_sha: 'aaaaaaa' }; if (label.startsWith('review:codex:')) { - xfamilyStartedWithLensesInFlight.push(lensesInFlight); return { verdict: 'merge', findings: [], ran: false }; } if (label.startsWith('review:fallback:')) { - xfamilyStartedWithLensesInFlight.push(lensesInFlight); + lensesReturnedWhenFallbackStarted.push(lensesReturned); const spec = reviewRounds[round - 1] || { findings: [] }; return { verdict: 'merge', findings: spec.findings }; } if (label.startsWith('review:')) { const spec = reviewRounds[round - 1] || { findings: [] }; - lensesInFlight += 1; - await new Promise((resolve) => setTimeout(resolve, 5)); - lensesInFlight -= 1; + await new Promise((resolve) => globalThis.setTimeout(resolve, 5)); + lensesReturned += 1; if (spec.lensesNull) return null; return { verdict: 'merge', findings: spec.findings }; } @@ -81,7 +79,7 @@ function run(script, args, { reviewRounds, landOverrides = {}, postRebase = null } return Promise.all(thunks.map((t) => t().catch(() => null))); }; - return script(args, agent, parallel, () => {}, () => {}).then((result) => ({ result, calls, fixCount, xfamilyStartedWithLensesInFlight })); + return script(args, agent, parallel, () => {}, () => {}).then((result) => ({ result, calls, fixCount, lensesReturnedWhenFallbackStarted })); } const cases = [ @@ -173,13 +171,13 @@ describe.each(cases)('%s review loop', (name, args) => { expect(result.error).toMatch(/^the post-rebase review round produced no reviews/); }); - it('on a rubric-class change the cross-family step starts only after both lenses have returned', async () => { + it('on a rubric-class change the worktree-isolated fallback reviewer starts only after both lenses have returned', async () => { const rubricArgs = { ...args, risk: 'rubric' }; - const { result, calls, xfamilyStartedWithLensesInFlight } = await run(script, rubricArgs, { reviewRounds: [{ findings: [] }] }); + const { result, calls, lensesReturnedWhenFallbackStarted } = await run(script, rubricArgs, { reviewRounds: [{ findings: [] }] }); expect(result.error).toBeUndefined(); expect(calls.filter((c) => c.label.startsWith('review:codex:')).length).toBe(1); expect(calls.filter((c) => c.label.startsWith('review:fallback:')).length).toBe(1); - expect(xfamilyStartedWithLensesInFlight).toEqual([0, 0]); + expect(lensesReturnedWhenFallbackStarted).toEqual([2]); expect(finisherPrompt(calls)).toContain('substituted a cold same-family review-only agent'); });