Files
ersatztv/web/scripts/orchestration-workflow-loop.test.mjs
T
timothyandClaude Fable 5.1 a083c851b3
Build ErsatzTV Image / CI toolchain image resolves (pull_request) Successful in 8s
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 14s
Build ErsatzTV Image / Delimiter ban (release path) (pull_request) Successful in 20s
PR Gates / Docs update reminder (pull_request) Successful in 18s
PR Gates / decisions lifecycle (pull_request) Successful in 21s
review-verdict/h10 Review-verdict: MERGEABLE @ a083c85 (base: main)
PR Gates / Fix proofs (Proves trailers) (pull_request) Successful in 15s
Review verdict / Set review-verdict status (pull_request_target) Successful in 23s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 9m13s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 6m23s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Skipped
PR Gates / Script lint and tests (ruff + pytest) (pull_request) Successful in 19m38s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 5m52s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 8s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 5s
fix(916): the harness counts lens completions per round, so the runner assertion can fail
The runner observer reset the shared counter to zero one line before reading
it, so its assertion held under the very mutant it existed to reject and the
fallback assertion caught that mutant for the wrong reason. Completions are
now keyed by the round in each agent's own label; no stub resets shared
state. Measured: re-serialising the runner reddens the runner assertion
(expected [2] to equal [0]) in both scripts; moving the fallback beside the
lenses reddens the fallback assertion and the round-one-failure case.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV
2026-09-05 14:13:56 +02:00

222 lines
13 KiB
JavaScript

// Pins the review-loop contract of the orchestration Workflow scripts (ersatztv#907, #911):
// a round carrying any blocking OR should-fix finding goes back to the fixer whatever the lens's
// verdict word; nits alone end the loop; a round in which every lens failed is an error, never a
// clean pass; the recorded history binds each fix to the round it answered and names only the
// commits that fix added; and the post-rebase round applies the same filter.
//
// The scripts run inside the Workflow tool, which supplies agent/parallel/phase/log and top-level
// await/return. This harness compiles each script body as an AsyncFunction with stubbed versions of
// those, so it executes the COMMITTED control flow rather than a re-description of it.
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, resolve } from 'node:path';
import { describe, expect, it } from 'vitest';
const here = dirname(fileURLToPath(import.meta.url));
const scriptsDir = resolve(here, '../../.claude/workflows');
const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor;
function compile(name) {
const src = readFileSync(resolve(scriptsDir, name), 'utf8');
const body = src.replace(/^export const meta = \{[\s\S]*?\n\}\n/, '');
return new AsyncFunction('args', 'agent', 'parallel', 'phase', 'log', body);
}
const TRAILER = 'Co-Authored-By: t <t@example>\nClaude-Session: https://example/session';
const buildArgs = {
issues: [1], slug: 's', title: 't', body_summary: 'b', done_condition: 'd', files_likely: [],
area: 'docs', size: 'small', risk: 'routine', needs_e2e: false, port: 8440, avoid: [], trailer: TRAILER,
};
const resumeArgs = {
issues: [1], branch: 'b', wt: '/tmp/wt', pr: 42, mode: 'fix', title: 't', risk: 'routine',
needs_e2e: false, port: 8440, trailer: TRAILER, brief: '/tmp/brief.json',
};
const IMPL_COMMITS = 'aaaaaaa feat(1): the implementer commit';
const finding = (severity) => ({ severity, file: 'f', summary: `${severity} finding`, evidence: 'e' });
// Every stub lens returns the same findings, so a finding counts once per lens (two lenses → 2).
// reviewRounds: array indexed by round-1 of {findings, lensesNull?, runnerRan?} — every review-stage stub reads
// its round from its own label (review:<lens>:rN), never from a shared counter; landOverrides: extra fields on
// the finisher report; postRebase: findings returned by the review after a patch-changing rebase, or 'lensesNull';
// fixerNull / fixerDone / fixerNoCommit: fixer shapes; fallbackNull: true, or an array of rounds in which the
// fallback agent dies.
function run(script, args, { reviewRounds, landOverrides = {}, postRebase = null, fixerNull = false, fixerDone = true, fixerNoCommit = false, fallbackNull = false }) {
const calls = [];
let fixCount = 0;
// Lens completions are counted PER ROUND, keyed by the round in each agent's label (review:<lens>:rN),
// so an observer never depends on another stub resetting shared state.
const lensesReturnedByRound = new Map();
const roundOf = (l) => Number(/:r(\d+)$/.exec(l)[1]);
const returnedIn = (l) => lensesReturnedByRound.get(roundOf(l)) || 0;
const lensesReturnedWhenFallbackStarted = [];
const lensesReturnedWhenRunnerStarted = [];
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:')) {
lensesReturnedWhenRunnerStarted.push(returnedIn(label));
const spec = reviewRounds[roundOf(label) - 1] || {};
return { verdict: 'merge', findings: [], ran: spec.runnerRan === true };
}
if (label.startsWith('review:fallback:')) {
lensesReturnedWhenFallbackStarted.push(returnedIn(label));
if (fallbackNull === true || (Array.isArray(fallbackNull) && fallbackNull.includes(roundOf(label)))) return null;
const spec = reviewRounds[roundOf(label) - 1] || { findings: [] };
return { verdict: 'merge', findings: spec.findings };
}
if (label.startsWith('review:')) {
const spec = reviewRounds[roundOf(label) - 1] || { findings: [] };
await new Promise((resolve) => globalThis.setTimeout(resolve, 5));
lensesReturnedByRound.set(roundOf(label), returnedIn(label) + 1);
if (spec.lensesNull) return null;
return { verdict: 'merge', findings: spec.findings };
}
if (label.startsWith('fix:r')) {
fixCount += 1;
if (fixerNull) return null;
if (!fixerDone) return { done: false, summary: 'stopped', verified: '', left: 'blocked', commits: IMPL_COMMITS };
return { done: true, summary: '', verified: '', left: '', commits: fixerNoCommit ? IMPL_COMMITS : `f${fixCount}xxxxx fix round ${fixCount}\n${IMPL_COMMITS}`, head_sha: fixerNoCommit ? 'aaaaaaa' : `f${fixCount}xxxxx` };
}
if (label.startsWith('land:')) return { done: true, summary: '', verified: '', left: '', commits: '', pr_url: 'http://pr/1', head_sha: 'deadbeef', patch_changed: false, ...landOverrides };
throw new Error(`unexpected agent label ${label}`);
};
const parallel = async (thunks) => {
if (postRebase !== null && calls.some((c) => c.label.startsWith('land:'))) {
return Promise.all(thunks.map(() => Promise.resolve(postRebase === 'lensesNull' ? null : { verdict: 'merge', findings: postRebase })));
}
return Promise.all(thunks.map((t) => t().catch(() => null)));
};
return script(args, agent, parallel, () => {}, () => {}).then((result) => ({ result, calls, fixCount, lensesReturnedWhenFallbackStarted, lensesReturnedWhenRunnerStarted }));
}
const cases = [
['ersatztv-issue-build.js', buildArgs],
['ersatztv-resume-branch.js', resumeArgs],
];
describe.each(cases)('%s review loop', (name, args) => {
const script = compile(name);
const finisherPrompt = (calls) => calls.find((c) => c.label.startsWith('land:')).prompt;
it('a clean first round lands with zero fix rounds and says so', async () => {
const { result, calls, fixCount } = await run(script, args, { reviewRounds: [{ findings: [finding('nit')] }] });
expect(result.error).toBeUndefined();
expect(fixCount).toBe(0);
expect(finisherPrompt(calls)).toContain('round 1: 2 lens(es); 0 blocking, 0 should-fix, 2 nit; clean — loop ended');
expect(finisherPrompt(calls)).toContain('no fix commit exists');
});
it('a should-fix finding sends the round back even though the lens said merge', async () => {
const { result, calls, fixCount } = await run(script, args, {
reviewRounds: [{ findings: [finding('should-fix')] }, { findings: [] }],
});
expect(result.error).toBeUndefined();
expect(fixCount).toBe(1);
const hist = finisherPrompt(calls);
expect(hist).toContain('round 1: 2 lens(es); 0 blocking, 2 should-fix, 0 nit; answered by the fix commit(s) in git log --oneline aaaaaaa..f1xxxxx');
expect(hist).toContain('round 2: 2 lens(es); 0 blocking, 0 should-fix, 0 nit; clean — loop ended');
expect(hist).not.toContain(IMPL_COMMITS);
expect(hist).toContain('for exactly the commits git log --oneline lists in these ranges: aaaaaaa..f1xxxxx');
});
it('a blocking finding that survives two fix rounds stops before the push', async () => {
const { result, calls, fixCount } = await run(script, args, {
reviewRounds: [{ findings: [finding('blocking')] }, { findings: [finding('blocking')] }, { findings: [finding('blocking')] }],
});
expect(fixCount).toBe(2);
expect(result.error).toMatch(/^blocking findings after two fix rounds/);
expect(calls.some((c) => c.label.startsWith('land:'))).toBe(false);
});
it('a should-fix finding that survives two fix rounds stops before the push', async () => {
const { result, calls, fixCount } = await run(script, args, {
reviewRounds: [{ findings: [finding('should-fix')] }, { findings: [finding('should-fix')] }, { findings: [finding('should-fix')] }],
});
expect(fixCount).toBe(2);
expect(result.error).toMatch(/^should-fix findings still open after two fix rounds/);
expect(calls.some((c) => c.label.startsWith('land:'))).toBe(false);
});
it('a round in which every lens failed is an error, not a clean round', async () => {
const { result, calls, fixCount } = await run(script, args, { reviewRounds: [{ lensesNull: true }] });
expect(fixCount).toBe(0);
expect(result.error).toMatch(/^review round 1 produced no reviews/);
expect(calls.some((c) => c.label.startsWith('land:'))).toBe(false);
});
it('a round in which every lens failed AFTER a fix round is an error, not a clean round', async () => {
const { result, calls, fixCount } = await run(script, args, {
reviewRounds: [{ findings: [finding('should-fix')] }, { lensesNull: true }],
});
expect(fixCount).toBe(1);
expect(result.error).toMatch(/^review round 2 produced no reviews/);
expect(calls.some((c) => c.label.startsWith('land:'))).toBe(false);
});
it('a fixer that dies is an error, not a clean round', async () => {
const { result, calls } = await run(script, args, { reviewRounds: [{ findings: [finding('should-fix')] }], fixerNull: true });
expect(result.error).toMatch(/^fixer for round 1 returned nothing/);
expect(calls.some((c) => c.label.startsWith('land:'))).toBe(false);
});
it('a fixer that stops (done=false) is an error, not a clean round', async () => {
const { result, calls } = await run(script, args, { reviewRounds: [{ findings: [finding('should-fix')] }], fixerDone: false });
expect(result.error).toMatch(/^fixer for round 1 stopped/);
expect(calls.some((c) => c.label.startsWith('land:'))).toBe(false);
});
it('findings answered without a new commit are described as such, never as a clean round one', async () => {
const { calls } = await run(script, args, { reviewRounds: [{ findings: [finding('should-fix')] }, { findings: [] }], fixerNoCommit: true });
const p = finisherPrompt(calls);
expect(p).toContain('answered without a new commit');
expect(p).toContain('every finding was answered without a new commit');
expect(p).not.toContain('round one was clean');
});
it('a post-rebase round in which every lens failed is an error, never a verdict', async () => {
const { result } = await run(script, args, { reviewRounds: [{ findings: [] }], landOverrides: { patch_changed: true }, postRebase: 'lensesNull' });
expect(result.error).toMatch(/^the post-rebase review round produced no reviews/);
});
it('a substitute failure in round one does not doom a run whose landing round had a real cross-family review', async () => {
const rubricArgs = { ...args, risk: 'rubric' };
const { result, calls } = await run(script, rubricArgs, {
reviewRounds: [{ findings: [finding('should-fix')] }, { findings: [], runnerRan: true }],
fallbackNull: [1],
});
expect(result.error).toBeUndefined();
expect(result.cross_family).toBe('codex');
expect(finisherPrompt(calls)).not.toContain('substitute ALSO failed');
expect(calls.filter((c) => c.label.startsWith('review:codex:')).length).toBe(2);
});
it('a rubric round whose runner and substitute both fail is an error, never a landed PR', async () => {
const rubricArgs = { ...args, risk: 'rubric' };
const { result, calls } = await run(script, rubricArgs, { reviewRounds: [{ findings: [] }], fallbackNull: true });
expect(result.error).toMatch(/^the cross-family runner and its substitute both failed in round 1/);
expect(calls.some((c) => c.label.startsWith('land:'))).toBe(false);
});
it('on a rubric-class change the fallback reviewer starts only after both lenses have returned, and the runner beside them', async () => {
const rubricArgs = { ...args, risk: 'rubric' };
const { result, calls, lensesReturnedWhenFallbackStarted, lensesReturnedWhenRunnerStarted } = 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(lensesReturnedWhenFallbackStarted).toEqual([2]);
expect(lensesReturnedWhenRunnerStarted).toEqual([0]);
expect(finisherPrompt(calls)).toContain('substituted a cold same-family review-only agent');
});
it('a should-fix on the pushed head after a patch-changing rebase is an error', async () => {
const { result } = await run(script, args, {
reviewRounds: [{ findings: [] }],
landOverrides: { patch_changed: true },
postRebase: [finding('should-fix')],
});
expect(result.error).toMatch(/^blocking or should-fix findings on the pushed head/);
});
});