From da8bdb428990aaf51af98c6b9c0f86e8cb2479ac Mon Sep 17 00:00:00 2001 From: garrytan Date: Mon, 26 Oct 2026 03:10:05 +0000 Subject: [PATCH] fix: detect a hollow screen from the plan it uploads (no paid-plan artifact or nothing planned), correct two troubleshooting facts, and normalise result --pr Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- CHANGELOG.md | 2 +- bin/gstack-ship-receipt | 5 ++-- docs/ship-policy.md | 9 +++---- docs/troubleshooting.md | 28 ++++++++++++++-------- lib/result-codes-release.ts | 2 +- lib/ship-queue-train-github.ts | 30 ++++++++++++++++-------- lib/ship-queue-train-ready.ts | 2 +- lib/ship-queue-train.ts | 2 +- test/helpers/ship-queue-train-fixture.ts | 5 ++++ test/ship-queue-train.test.ts | 17 ++++++++------ 10 files changed, 65 insertions(+), 37 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 50fefcca3..a0f3ff068 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ ### Changed - **`gstack-ship-queue ready`** refuses with `QUEUE_RECEIPT_MISSING` when the PR body has no valid ```gstack-ship-receipt``` block and with `QUEUE_RECEIPT_STALE` when the receipt's `head` is not the head being READY'd (or its `pr` is another PR). The re-READY after a slot's stamp push accepts the receipt written for the pin when HEAD is the pin plus stamp files, or (from slot 2 on) the pin merged with main plus stamp files. The informational `SHIP_RECEIPT:` line still prints in the block, and it is one line whatever the receipt's strings contain (control characters are collapsed, so a crafted `base` cannot add a forged `MERGE_CONDITION:` line to `verify --train`'s output). -- **`gstack-ship-queue ready --screen` refuses a stale merge ref** with the new `QUEUE_SCREEN_STALE_REF` (GSTA-95 on #3431): GitHub stops recomputing `refs/pull//merge` while a PR conflicts with the base (a CHANGELOG-only conflict after a stamp counts), so a screen dispatched there planned 0 cases, reported success and `ready` printed `SCREEN_DISPATCH:` as if a screen ran. `ready` now compares the merge ref's head parent with the PR head before dispatching (a missing merge ref carries the same code), never adopts a finished screen whose record says it planned 0 cases, and `train build` prints `SCREEN: # run planned 0 cases; counted as not-run` and seats that slot unscreened. +- **`gstack-ship-queue ready --screen` refuses a stale merge ref** with the new `QUEUE_SCREEN_STALE_REF` (GSTA-95 on #3431): GitHub stops recomputing `refs/pull//merge` while a PR conflicts with the base (a CHANGELOG-only conflict after a stamp counts), so a screen dispatched there planned 0 cases, reported success and `ready` printed `SCREEN_DISPATCH:` as if a screen ran. `ready` now compares the merge ref's head parent with the PR head before dispatching (a missing merge ref carries the same code), never adopts a finished screen that planned no cases (a superseded run skips plan-slices and uploads no `paid-plan` artifact; a manifest with nothing planned counts the same), and `train build` prints `SCREEN: # run planned no cases …; counted as not-run` and seats that slot unscreened. - **`gstack-ship-queue train build`** refuses a pin whose PR has no current receipt with the same codes, naming the PR, before any write, push or dispatch, `--dry-run` included. **`verify --train`** refuses the slot the same way before `MERGE_CONDITION`, and accepts a receipt written for the slot's pinned head when the current head is its stamp commit (the stamp changes only stamp files, or merges main into the pin from slot 2 on), since no /ship runs on a stamp. - **`gstack-ship-receipt result`** (new) is /ship's terminal step: it binds the posted receipt to HEAD, writes `/ship/runs/-/{ship-run.json,ship-receipt.md}` (a /ship record, not a `run` v1 manifest: `gstack-artifact validate` does not apply) and prints `GSTACK_RESULT: skill=ship status=complete run=`, `status=refused run=-` with the receipt code, or `status=incomplete run=-` when gh cannot read the PR, so a parent's merge check reads `ship-run.json` instead of parsing the PR body. The ship skill's new Step 22 runs it after the pr-body and handoff sections. - **Unattended /ship may update an existing PR's body.** `gstack-skill-start`'s unattended block and `UNATTENDED_SHIP:` line, the ship skill's Step 18 and `docs/unattended.md` now say: when an open PR for the branch exists and HEAD is its pushed head, Step 18 takes the pr-body section's existing-PR path (body with the receipt first, and title); it never runs `pr-create` and never pushes a commit; anything else takes the handoff section. Before this the block said the receipt lived only in `.gstack/tmp/ship-receipt.md` while rule 22 and `ready` read it from the PR body. diff --git a/bin/gstack-ship-receipt b/bin/gstack-ship-receipt index ffe697f90..a8a1c87db 100755 --- a/bin/gstack-ship-receipt +++ b/bin/gstack-ship-receipt @@ -202,8 +202,9 @@ function cmdRead(rest: string[], json: boolean): number { */ function cmdResult(rest: string[], json: boolean): number { const bodyFile = flag(rest, '--body'); - const pr = flag(rest, '--pr'); - if (pr !== undefined && !/^\d+$/.test(pr)) { process.stderr.write(`gstack-ship-receipt result: --pr must be a PR number, got ${JSON.stringify(pr.slice(0, 40))}\n`); return EXIT.usage; } + const prFlag = flag(rest, '--pr'); + if (prFlag !== undefined && !/^[1-9]\d*$/.test(prFlag)) { process.stderr.write(`gstack-ship-receipt result: --pr must be a PR number, got ${JSON.stringify(prFlag.slice(0, 40))}\n`); return EXIT.usage; } + const pr = prFlag; const repoRoot = git(['rev-parse', '--show-toplevel']); const head = flag(rest, '--head') ?? (repoRoot ? git(['rev-parse', 'HEAD'], repoRoot) : undefined); const refused = (code: 'QUEUE_RECEIPT_MISSING' | 'QUEUE_RECEIPT_STALE', message: string) => { diff --git a/docs/ship-policy.md b/docs/ship-policy.md index a390144c5..689ed936d 100644 --- a/docs/ship-policy.md +++ b/docs/ship-policy.md @@ -373,10 +373,11 @@ it. Before dispatching, `ready` fetches the merge ref and requires its second parent to be the PR head: a missing or lagging merge ref (GitHub stops recomputing it while the PR conflicts with the base, CHANGELOG-only conflicts included) is `QUEUE_SCREEN_STALE_REF`, because a screen dispatched there plans -no cases and reports success. A finished screen whose record planned 0 cases -is never followed as a screen: `ready --screen` dispatches a fresh one and -`train build` prints `SCREEN: # run planned 0 cases; counted as -not-run` and seats the slot unscreened. +no cases and reports success. A finished screen that planned no cases (no +`paid-plan` artifact, which a superseded run never uploads, or a plan manifest +with nothing planned) is never followed as a screen: `ready --screen` +dispatches a fresh one and `train build` prints `SCREEN: # run planned +no cases …; counted as not-run` and seats the slot unscreened. **2. Coordinator: build.** Name the pins exactly as READY reported them: diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index a32a2ec97..36b812eb9 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -1604,8 +1604,9 @@ message lists. **Meaning.** On a Capy machine (`GSTACK_CAPY=1`, set by the `GSTACK_LAUNCH` line), an unattended or spawned workflow starts only with a passing install -verdict (`/install-verdicts/.json`, -named as `GSTACK_INSTALL_VERDICT`) for this checkout, its current HEAD and a +verdict (`/install-verdicts//.json`, named as `GSTACK_INSTALL_VERDICT`) +for this checkout, its current HEAD and a workflow at least as wide as the one starting: a verdict for `--for autoplan` does not cover `qa`'s browser, and a planning-only pins SKIP does not cover a workflow that runs the repo's tests. `gstack-skill-start` printed this row and @@ -1613,8 +1614,10 @@ workflow that runs the repo's tests. `gstack-skill-start` printed this row and an unattended run whose Bun on PATH is below the supported minimum (`INSTALL: fail runtime ... (RUNTIME_BELOW_MINIMUM)`) on any host. -**Fix.** Run the Capy skill installer with `--for ` on this machine -and launch with the `GSTACK_LAUNCH` line it prints. +**Fix.** Run the Capy skill installer (`scripts/install.sh --for `) +from the project directory the workflow will run in, since the verdict is +keyed by that directory (GSTA-85), and launch with the `GSTACK_LAUNCH` line it +prints. `GSTACK_ALLOW_UNSUPPORTED_RUNTIME=1` admits anyway (printed as `INSTALL_VERDICT: overridden`); record it as a deviation. @@ -4035,8 +4038,11 @@ before merging PRs; [ship-policy.md](ship-policy.md#ship-before-ready)). **Fix.** Run /ship on the branch. Unattended, it ends by writing the receipt into the PR body (`gstack-ship-receipt write --pr … --body `, then -`gstack-post pr-body`). Then rerun `gstack-ship-queue ready --pr ---screen|--no-screen` and send the new READY block. +`gstack-post pr-body`) when an open PR exists for the branch at HEAD; +otherwise it leaves the receipt in `.gstack/tmp/ship-receipt.md` and the +parent posts it ([unattended.md](unattended.md)). Then rerun +`gstack-ship-queue ready --pr --screen|--no-screen` and send the new READY +block. ### `QUEUE_RECEIPT_STALE` @@ -4064,10 +4070,12 @@ counts), so a screen dispatched there checks out the old merge, sees itself superseded, plans 0 cases and reports success. GSTA-95 hit this on #3431: `ready` printed `SCREEN_DISPATCH:` as if a screen ran. `ready` now compares the merge ref's second parent with the PR head before dispatching, and a -finished screen whose record says it planned 0 cases is never adopted as a -screen: `ready --screen` dispatches a fresh one and `train build` prints -`SCREEN: # run planned 0 cases; counted as not-run` and seats the slot -unscreened. +finished screen that planned no cases is never adopted as a screen. A screen +run uploads no train record, so the evidence is its plan: a superseded run +skips `plan-slices` and uploads no `paid-plan` artifact, and a manifest with +nothing planned counts the same. `ready --screen` dispatches a fresh one and +`train build` prints `SCREEN: # run planned no cases (no paid-plan +artifact; …); counted as not-run` and seats the slot unscreened. **Fix.** Resolve the conflict with the base branch (or wait for GitHub to recompute the merge ref), push, and run `gstack-ship-queue ready --pr diff --git a/lib/result-codes-release.ts b/lib/result-codes-release.ts index 01eebe8ec..d44dbf65b 100644 --- a/lib/result-codes-release.ts +++ b/lib/result-codes-release.ts @@ -136,7 +136,7 @@ export const RELEASE_RESULT_CODES = { QUEUE_RECEIPT_MISSING: { anchor: 'queue-receipt-missing', summary: 'the PR body carries no valid /ship receipt, so the PR was never shipped through gstack and cannot be READY’d, admitted to a train or merged', - fix: 'run /ship on the branch (unattended it writes the receipt with gstack-ship-receipt write --pr --body …), then gstack-ship-queue ready again; there is no flag to skip this', + fix: 'run /ship on the branch (unattended it writes the receipt into an open PR at HEAD with gstack-ship-receipt write --pr --body …, else leaves it in .gstack/tmp/ship-receipt.md for the parent to post), then gstack-ship-queue ready again; there is no flag to skip this', }, QUEUE_RECEIPT_STALE: { anchor: 'queue-receipt-stale', diff --git a/lib/ship-queue-train-github.ts b/lib/ship-queue-train-github.ts index c9072efe5..f437161fa 100644 --- a/lib/ship-queue-train-github.ts +++ b/lib/ship-queue-train-github.ts @@ -70,22 +70,32 @@ export function downloadJson(ctx: QueueContext, slug: string, run: number, name: } /** - * A finished screen run that planned no cases is not a screen: evals.yml plans - * 0 cases and reports success when the merge ref it was dispatched for was - * superseded (GSTA-95 on #3431). Returns the reason when the run is hollow, - * null when it is live, still running, or its census cannot be read. + * A finished screen run that planned no cases is not a screen: when the merge + * ref it was dispatched for was superseded, evals.yml skips plan-slices and + * concludes success (GSTA-95 on #3431). A screen run uploads no train record, + * so the evidence is the plan itself: no `paid-plan` artifact, or a manifest + * with nothing planned. Returns the reason when the run is hollow, null when + * it is live, still running, or its plan cannot be read. */ -export function hollowScreen(ctx: QueueContext, slug: string, run: number, artifact: string): string | null { +export function hollowScreen(ctx: QueueContext, slug: string, run: number): string | null { const view = runView(ctx, slug, run); if (!view || view.status !== 'completed') return null; + const list = json<{ artifacts?: Array<{ name?: string; expired?: boolean }> }>(ctx.gh(['api', `repos/${slug}/actions/runs/${run}/artifacts?per_page=100`])); + if (!Array.isArray(list?.artifacts)) return null; + const plan = list.artifacts.find(a => a.name === PLAN_ARTIFACT); + if (!plan) return `run ${run} planned no cases (no ${PLAN_ARTIFACT} artifact; concluded ${view.conclusion ?? 'unknown'})`; + if (plan.expired) return null; const scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-screen-')); - let rec: { census?: { planned?: { rule?: unknown; judge?: unknown } } } | null; - try { rec = downloadJson(ctx, slug, run, artifact, scratch) as typeof rec; } finally { fs.rmSync(scratch, { recursive: true, force: true }); } - const planned = rec?.census?.planned; - if (!planned || typeof planned.rule !== 'number' || typeof planned.judge !== 'number') return null; - return planned.rule + planned.judge === 0 ? `run ${run} planned 0 cases (concluded ${view.conclusion ?? 'unknown'})` : null; + let manifest: { entries?: Array<{ status?: unknown }> } | null; + try { manifest = downloadJson(ctx, slug, run, PLAN_ARTIFACT, scratch) as typeof manifest; } finally { fs.rmSync(scratch, { recursive: true, force: true }); } + if (!Array.isArray(manifest?.entries)) return null; + const planned = manifest.entries.filter(e => e?.status === 'planned').length; + return planned === 0 ? `run ${run} planned 0 cases (concluded ${view.conclusion ?? 'unknown'})` : null; } +/** The plan manifest every evals.yml run that planned anything uploads (plan-slices); a superseded screen never reaches that job. */ +const PLAN_ARTIFACT = 'paid-plan'; + /** The named check run on a commit (the newest when several); null when absent or unreadable. */ export function checkRun(ctx: QueueContext, sha: string, name: string): CheckInfo | null { const r = json<{ check_runs?: Array<{ name?: string; status?: string; conclusion?: string | null }> }>( diff --git a/lib/ship-queue-train-ready.ts b/lib/ship-queue-train-ready.ts index 2dda070d0..1ed8c46a3 100644 --- a/lib/ship-queue-train-ready.ts +++ b/lib/ship-queue-train-ready.ts @@ -85,7 +85,7 @@ async function dispatchScreen(env: TrainEnv, o: ReadyOptions, mergeSha: string, if (!slug) return outRefused({ code: 'QUEUE_PROOF_MISSING', message: 'gh repo view could not name the repository' }, [], `${Q} ready --pr ${o.pr} --screen`, 'none'); const exec = execFor(env.ctx); const existing = findDuplicateRun(listDispatchRuns(dispatch.workflow, exec, slug) ?? [], dedupeKey('pr', mergeSha, baseSha, scope)); - const hollow = existing ? hollowScreen(env.ctx, slug, existing.databaseId, env.train.record_artifact) : null; + const hollow = existing ? hollowScreen(env.ctx, slug, existing.databaseId) : null; if (existing && !hollow) return { choice: { choice: 'screen', policy, nonce: /eval-bg (\S+?)\]/.exec(existing.displayTitle)?.[1] ?? nonce, run: existing.databaseId, merge_sha: mergeSha }, line: `SCREEN_DISPATCH: following run ${existing.databaseId} (same merge SHA, base and policy; no duplicate)` }; const found = dispatchWorkflow(dispatch, env.ctx.base, exec, slug) ? await awaitNonceRun(dispatch.workflow, nonce, exec, slug) : null; if (!found) return outPending({ code: 'QUEUE_TRAIN_DISPATCH_UNKNOWN', message: `no ${dispatch.workflow} run named ${nonce} appeared after the screen dispatch` }, [], `${Q} ready --pr ${o.pr} --screen${o.broad ? ' --broad' : ''} (adopts the run by its key once it appears)`, 'none'); diff --git a/lib/ship-queue-train.ts b/lib/ship-queue-train.ts index 24b842eb1..8066bf75d 100644 --- a/lib/ship-queue-train.ts +++ b/lib/ship-queue-train.ts @@ -302,7 +302,7 @@ function admitSlots(env: TrainEnv, pins: Array<{ pr: string; sha: string }>): { if (!ready) return outRefused({ code: 'QUEUE_NOT_READY', message: `no READY receipt for #${p.pr} at ${info.headRefOid.slice(0, 12)} (local state or ${'refs/notes/gstack-trains'})` }, [], `the lane runs ${Q} ready --pr ${p.pr} --screen|--no-screen`, 'none'); let screen = ready.screen; if (screen.choice === 'screen' && screen.run && slug === undefined) slug = repoSlug(ctx); - const hollow = screen.choice === 'screen' && screen.run && slug ? hollowScreen(ctx, slug, screen.run, env.train.record_artifact) : null; + const hollow = screen.choice === 'screen' && screen.run && slug ? hollowScreen(ctx, slug, screen.run) : null; if (hollow) { lines.push(`SCREEN: #${p.pr} ${hollow}; counted as not-run, the slot rides unscreened`); screen = { ...screen, choice: 'no-screen' }; } slots.push({ pr: p.pr, head: info.headRefOid, ready_at: ready.ready_at, screen }); } diff --git a/test/helpers/ship-queue-train-fixture.ts b/test/helpers/ship-queue-train-fixture.ts index db7e47365..92c20d25d 100644 --- a/test/helpers/ship-queue-train-fixture.ts +++ b/test/helpers/ship-queue-train-fixture.ts @@ -27,6 +27,8 @@ export const TRAIN_POLICY = { ...BASE_POLICY, train: TRAIN_BLOCK, min_gstack_ver export interface Rule { fail?: boolean; silent?: boolean; failButCreate?: boolean; status?: string; conclusion?: string | null; event?: string; headSha?: string; record?: { panels: unknown[]; census?: unknown; verdict?: 'GREEN' | 'RED'; nonce?: string }; + /** The run's paid-plan manifest: planned entries (default 1); null uploads no paid-plan artifact, as a superseded screen does. */ + plan?: { planned: number } | null; check?: string | null; bisect?: { prefixes: string[]; patterns: Record; costUsd?: number }; } @@ -155,6 +157,7 @@ function shimSource(): string { " const run = { databaseId: id, workflow: w, headBranch: ref, headSha: head, status: rule.status || 'completed', conclusion: rule.conclusion === undefined ? 'success' : rule.conclusion,", " url: 'https://example.invalid/runs/' + id, event: rule.event || 'workflow_dispatch', displayTitle: inputs.nonce ? 'E2E Evals [eval-bg ' + inputs.nonce + ']' : w, inputs };", " d.runs.push(run); d.artifacts[id] = {};", + " if (rule.plan !== null) d.artifacts[id]['paid-plan'] = { tier: 'gate', sliceCount: 1, entries: Array.from({ length: rule.plan ? rule.plan.planned : 1 }, (_, i) => ({ file: 'test/case-' + i + '.test.ts', status: 'planned' })) };", " if (rule.record && !inputs.prefix_shas) d.artifacts[id][d.artifact] = { schema: 1, kind: 'train-gate', nonce: rule.record.nonce || inputs.nonce, head_sha: head, tree: rev(head + '^{tree}'),", " verdict: rule.record.verdict || 'GREEN', census: rule.record.census, panels: rule.record.panels };", " if (rule.check && !inputs.prefix_shas) (d.checks[head] = d.checks[head] || []).push({ name: 'gstack/full-gate', status: 'completed', conclusion: rule.check });", @@ -176,6 +179,8 @@ function shimSource(): string { " fs.mkdirSync(opt('-D'), { recursive: true }); fs.writeFileSync(path.join(opt('-D'), 'record.json'), JSON.stringify(art)); save(); process.exit(0);", "}", "if (a[0] === 'api') {", + " const arts = /actions\\/runs\\/(\\d+)\\/artifacts/.exec(a[1] || '');", + " if (arts) { const names = Object.keys(d.artifacts[arts[1]] || {}); out({ total_count: names.length, artifacts: names.map(name => ({ name, expired: false })) }); }", " const named = /commits\\/([0-9a-f]+)\\/check-runs\\?check_name=([^&]+)/.exec(a[1] || '');", " if (named) { const list = (d.checks[named[1]] || []).filter(c => c.name === decodeURIComponent(named[2])); out({ total_count: list.length, check_runs: list }); }", " const paged = /commits\\/([0-9a-f]+)\\/(check-runs|status)\\?.*page=(\\d+)/.exec(a[1] || '');", diff --git a/test/ship-queue-train.test.ts b/test/ship-queue-train.test.ts index 3a3c48cbe..9f2df7289 100644 --- a/test/ship-queue-train.test.ts +++ b/test/ship-queue-train.test.ts @@ -238,26 +238,29 @@ describe('ready under a train policy: the screen choice', () => { // fails_when=the census is not read (a hollow run is followed, or ORDER says screened); why_new=no test reads a screen run's record; seam=none; no_claim=evals.yml's real artifact (the shim's record stands in) test('a screen run that planned 0 cases is not followed by ready and is counted as not-run by train build', () => { const { fx, head, pregate } = readyFixture(); - fx.write(d => { d.rules['evals.yml'] = { status: 'completed', conclusion: 'success', record: { verdict: 'GREEN', census: { complete: true, planned: { rule: 0, judge: 0 }, executed: { rule: 0, judge: 0 } }, panels: [] } }; }); + fx.write(d => { d.rules['evals.yml'] = { status: 'completed', conclusion: 'success', plan: null }; }); const first = queue(fx, ['ready', '--pr', '1', '--pregate', pregate, '--screen']); expect(first.status).toBe(EXIT.ok); expect(line(first.out, 'SCREEN_DISPATCH:')).toMatch(/^SCREEN_DISPATCH: evals\.yml run 100 /); const again = queue(fx, ['ready', '--pr', '1', '--pregate', pregate, '--screen']); expect(again.status).toBe(EXIT.ok); - expect(line(again.out, 'SCREEN_DISPATCH:')).toMatch(/^SCREEN_DISPATCH: evals\.yml run 101 .* \(not following run 100 planned 0 cases \(concluded success\): a screen that planned nothing is not a screen\)$/); + expect(line(again.out, 'SCREEN_DISPATCH:')).toMatch(/^SCREEN_DISPATCH: evals\.yml run 101 .* \(not following run 100 planned no cases \(no paid-plan artifact; concluded success\): a screen that planned nothing is not a screen\)$/); expect(dispatches(fx, 'evals.yml').length).toBe(2); const build = queue(fx, ['train', 'build', '--prs', `1@${head}`, '--dry-run']); expect(build.status).toBe(EXIT.ok); - expect(line(build.out, 'SCREEN:')).toBe('SCREEN: #1 run 101 planned 0 cases (concluded success); counted as not-run, the slot rides unscreened'); + expect(line(build.out, 'SCREEN:')).toBe('SCREEN: #1 run 101 planned no cases (no paid-plan artifact; concluded success); counted as not-run, the slot rides unscreened'); expect(line(build.out, 'ORDER:')).toBe('ORDER: 1:#1(unscreened)'); - fx.write(d => { d.rules['evals.yml'] = { status: 'completed', conclusion: 'success', record: { verdict: 'GREEN', census: { complete: true, planned: { rule: 0, judge: 3 }, executed: { rule: 0, judge: 3 } }, panels: [] } }; }); + fx.write(d => { d.rules['evals.yml'] = { status: 'completed', conclusion: 'success', plan: { planned: 0 } }; }); + const emptyPlan = queue(fx, ['ready', '--pr', '1', '--pregate', pregate, '--screen']); + expect(line(emptyPlan.out, 'SCREEN_DISPATCH:')).toMatch(/^SCREEN_DISPATCH: evals\.yml run 102 .*not following run 101 planned no cases/); + fx.write(d => { d.rules['evals.yml'] = { status: 'completed', conclusion: 'success', plan: { planned: 3 } }; }); const live = queue(fx, ['ready', '--pr', '1', '--pregate', pregate, '--screen']); expect(live.status).toBe(EXIT.ok); - expect(line(live.out, 'SCREEN_DISPATCH:')).toMatch(/^SCREEN_DISPATCH: evals\.yml run 102 .*not following run 101 planned 0 cases/); + expect(line(live.out, 'SCREEN_DISPATCH:')).toMatch(/^SCREEN_DISPATCH: evals\.yml run 103 .*not following run 102 planned 0 cases \(concluded success\)/); const followed = queue(fx, ['ready', '--pr', '1', '--pregate', pregate, '--screen']); expect(followed.status).toBe(EXIT.ok); - expect(line(followed.out, 'SCREEN_DISPATCH:')).toBe('SCREEN_DISPATCH: following run 102 (same merge SHA, base and policy; no duplicate)'); - expect(dispatches(fx, 'evals.yml').length).toBe(3); + expect(line(followed.out, 'SCREEN_DISPATCH:')).toBe('SCREEN_DISPATCH: following run 103 (same merge SHA, base and policy; no duplicate)'); + expect(dispatches(fx, 'evals.yml').length).toBe(4); const liveBuild = queue(fx, ['train', 'build', '--prs', `1@${head}`, '--dry-run']); expect([liveBuild.status, line(liveBuild.out, 'ORDER:'), line(liveBuild.out, 'SCREEN:')]).toEqual([EXIT.ok, 'ORDER: 1:#1(screened)', '']); fx.write(d => { d.rules['evals.yml'] = { status: 'in_progress', conclusion: null }; });