From e39ae62643e4fbd7862ff02a67c50c2e3f99e90e Mon Sep 17 00:00:00 2001 From: garrytan Date: Mon, 26 Oct 2026 03:24:29 +0000 Subject: [PATCH] test(hollow-screen): expired and unreadable run rows, uppercase --head; docs name every SCREEN: literal Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- CHANGELOG.md | 2 +- docs/ship-policy.md | 4 +++- docs/troubleshooting.md | 8 +++++--- test/gstack-ship-receipt.test.ts | 2 ++ test/helpers/ship-queue-train-fixture.ts | 6 ++++-- test/ship-queue-train.test.ts | 6 ++++++ 6 files changed, 21 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a0f3ff068..c1c3226ee 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 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 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 (a plan it cannot read keeps the slot screened and prints `… counted as screened, unverified`). - **`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/docs/ship-policy.md b/docs/ship-policy.md index 689ed936d..08c65920e 100644 --- a/docs/ship-policy.md +++ b/docs/ship-policy.md @@ -377,7 +377,9 @@ 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. +no cases …; counted as not-run` and seats the slot unscreened. A plan the +queue cannot read keeps the slot screened, with `SCREEN: # run …; +counted as screened, unverified`. **2. Coordinator: build.** Name the pins exactly as READY reported them: diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 326554489..22e61e42e 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -4076,9 +4076,11 @@ 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; …)` or `… planned no cases (an empty plan manifest; …)`, each -ending `; counted as not-run`, and seats the slot unscreened. A plan it cannot -read (an expired artifact, an unreadable manifest) keeps the slot screened and -prints `SCREEN: # run … unreadable; counted as screened, unverified`. +ending `; counted as not-run, the slot rides unscreened`. A plan it cannot +read (`… paid-plan artifact has expired`, `… manifest.json is unreadable`, an +unreadable run or artifact list) keeps the slot screened and prints +`SCREEN: # run …; counted as screened, unverified`; `ready --screen` +follows such a run. **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/test/gstack-ship-receipt.test.ts b/test/gstack-ship-receipt.test.ts index 35c783381..84939eb9c 100644 --- a/test/gstack-ship-receipt.test.ts +++ b/test/gstack-ship-receipt.test.ts @@ -251,6 +251,8 @@ describe('gstack-ship-receipt result (/ship terminal line, GBRA-108)', () => { const shortHead = runBin(repo, 'gstack-ship-receipt', ['result', '--body', pinned, '--head', head.slice(0, 12)], env); expect([shortHead.status, shortHead.out]).toEqual([EXIT.usage, '']); expect(shortHead.err).toContain('--head must be a full hex commit id'); + const upper = runBin(repo, 'gstack-ship-receipt', ['result', '--body', pinned, '--head', 'C'.repeat(40), '--out', path.join(repo.root, 'upper-out')], env); + expect([upper.status, upper.out.trim()]).toEqual([EXIT.ok, `GSTACK_RESULT: skill=ship status=complete run=${path.join(repo.root, 'upper-out')}`]); const noGit = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-receipt-nogit-')); const outside = runBin(repo, 'gstack-ship-receipt', ['result', '--body', pinned], env, noGit); expect([outside.status, outside.err.trim()]).toEqual([EXIT.usage, 'gstack-ship-receipt result: --head is required outside a git repository']); diff --git a/test/helpers/ship-queue-train-fixture.ts b/test/helpers/ship-queue-train-fixture.ts index f5d0ec199..87ac2286c 100644 --- a/test/helpers/ship-queue-train-fixture.ts +++ b/test/helpers/ship-queue-train-fixture.ts @@ -29,6 +29,8 @@ export interface Rule { record?: { panels: unknown[]; census?: unknown; verdict?: 'GREEN' | 'RED'; nonce?: string }; /** The run's paid-plan manifest: planned entries (default 1), or a raw manifest object; null uploads no paid-plan artifact, as a superseded screen does. */ plan?: { planned: number } | { manifest: unknown } | null; + /** Marks every artifact of runs dispatched under this rule expired in the artifacts listing. */ + expired?: boolean; check?: string | null; bisect?: { prefixes: string[]; patterns: Record; costUsd?: number }; } @@ -156,7 +158,7 @@ function shimSource(): string { " const id = d.nextId++; const head = rule.headSha || inputs.expected_sha || rev('refs/heads/' + ref) || rev(ref);", " 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] = {};", + " d.runs.push(run); d.artifacts[id] = {}; if (rule.expired) (d.expired = d.expired || {})[id] = true;", " if (rule.plan !== null) d.artifacts[id]['paid-plan'] = rule.plan && rule.plan.manifest ? rule.plan.manifest : { 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 };", @@ -181,7 +183,7 @@ function shimSource(): string { "}", "if (a[0] === 'api') {", " const arts = /actions\\/runs\\/(\\d+)\\/artifacts(?:\\?name=([^&]+))?/.exec(a[1] || '');", - " if (arts) { const names = Object.keys(d.artifacts[arts[1]] || {}).filter(n => !arts[2] || n === decodeURIComponent(arts[2])); out({ total_count: names.length, artifacts: names.map(name => ({ name, expired: false })) }); }", + " if (arts) { const names = Object.keys(d.artifacts[arts[1]] || {}).filter(n => !arts[2] || n === decodeURIComponent(arts[2])); out({ total_count: names.length, artifacts: names.map(name => ({ name, expired: !!(d.expired || {})[arts[1]] })) }); }", " 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 0818d4b29..c8e88a2d2 100644 --- a/test/ship-queue-train.test.ts +++ b/test/ship-queue-train.test.ts @@ -271,6 +271,12 @@ describe('ready under a train policy: the screen choice', () => { expect([unreadable.status, line(unreadable.out, 'ORDER:'), line(unreadable.out, 'SCREEN:')]).toEqual([EXIT.ok, 'ORDER: 1:#1(screened)', "SCREEN: #1 run 103's paid-plan manifest.json is unreadable; counted as screened, unverified"]); const stillFollowed = queue(fx, ['ready', '--pr', '1', '--pregate', pregate, '--screen']); expect(line(stillFollowed.out, 'SCREEN_DISPATCH:')).toBe('SCREEN_DISPATCH: following run 103 (same merge SHA, base and policy; no duplicate)'); + fx.write(d => { (d as any).expired = { '103': true }; }); + const expired = queue(fx, ['train', 'build', '--prs', `1@${head}`, '--dry-run']); + expect([expired.status, line(expired.out, 'ORDER:'), line(expired.out, 'SCREEN:')]).toEqual([EXIT.ok, 'ORDER: 1:#1(screened)', "SCREEN: #1 run 103's paid-plan artifact has expired; counted as screened, unverified"]); + fx.write(d => { (d as any).expired = {}; d.runs = d.runs.filter(r => (r as any).databaseId !== 103); }); + const gone = queue(fx, ['train', 'build', '--prs', `1@${head}`, '--dry-run']); + expect([gone.status, line(gone.out, 'ORDER:'), line(gone.out, 'SCREEN:')]).toEqual([EXIT.ok, 'ORDER: 1:#1(screened)', 'SCREEN: #1 run 103 is unreadable; counted as screened, unverified']); }, 120_000); });