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'); });