diff --git a/CHANGELOG.md b/CHANGELOG.md index e9389a9ff..c90a95e7a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,7 +15,7 @@ - **Gate edit (size budgets):** `test/helpers/carve-guards.ts` raises ship's `maxSkeletonBytes` 81,000 → 81,600 and `maxSizeRatio` 1.6690 → 1.6740 for Step 22 and the Step 18 routing, offset in part by tightening Steps 14, 16, 18 and 20 prose (measured skeleton 81,535, union 314,027; the 2026-10-14 tightening had left 2 bytes of headroom). No threshold, trial count or case kind changed. ### Tests -- `test/ship-queue-train.test.ts`: READY, train build (dry run and live) and verify --train refuse a missing, invalid, other-head and other-PR receipt and admit the current one; the pinned-head receipt admits a stamp commit; verify refuses `QUEUE_PROOF_MISSING` when gh cannot read the PR; `--screen` refuses a lagging or missing merge ref and never follows a 0-case screen. The train fixture's `refs/pull//merge` is now a real merge commit (`mergeRefCommit`), as GitHub keeps it. `test/gstack-ship-queue.test.ts`: `ready` at a stamp commit (slot 1, and slot 2 with main merged in) accepts the pin's receipt. `test/gstack-ship-receipt.test.ts`: the `bindReceipt` head-matching table (12-character floor, other PR, one-line `SHIP_RECEIPT:`), `result --body` refusals, `--head`, a non-git cwd and gh failures. The train fixture's `openPr` now writes a receipt for the head it opens (`shipped: false` leaves the body empty). `test/install-verdict.test.ts` pins the GSTA-85 fix in both twins; `test/gstack-skill-start.test.ts` pins the new unattended /ship block. +- `test/ship-queue-train.test.ts`: READY, train build (dry run and live) and verify --train refuse a missing, invalid, other-head and other-PR receipt and admit the current one; the pinned-head receipt admits a stamp commit; verify refuses `QUEUE_PROOF_MISSING` when gh cannot read the PR; `--screen` refuses a lagging or missing merge ref and never follows a 0-case screen. The train fixture's `refs/pull//merge` is now a real merge commit (`mergeRefCommit`), as GitHub keeps it. `test/gstack-ship-queue.test.ts`: `ready` at a stamp commit (slot 1, and slot 2 with main merged in) accepts the pin's receipt; a bare merge of main with no stamp on top, or an edit on the pin side under the slot-2 stamp, is `QUEUE_RECEIPT_STALE`. `test/auq-error-fallback-hook.test.ts`: the unattended directive names the same `GSTACK_RESULT` writers as docs/unattended.md and `gstack-skill-start` (GSTA-91). `test/gstack-ship-receipt.test.ts`: the `bindReceipt` head-matching table (12-character floor, other PR, one-line `SHIP_RECEIPT:`), `result --body` refusals, `--head`, a non-git cwd and gh failures. The train fixture's `openPr` now writes a receipt for the head it opens (`shipped: false` leaves the body empty). `test/install-verdict.test.ts` pins the GSTA-85 fix in both twins; `test/gstack-skill-start.test.ts` pins the new unattended /ship block. ## [1.91.224.0] - 2026-10-25 diff --git a/bin/gstack-ship-receipt b/bin/gstack-ship-receipt index 774fd20d4..e5aef15ca 100755 --- a/bin/gstack-ship-receipt +++ b/bin/gstack-ship-receipt @@ -211,7 +211,7 @@ function cmdResult(rest: string[], json: boolean): number { return EXIT.refused; }; if (!head) { process.stderr.write('gstack-ship-receipt result: --head is required outside a git repository\n'); return EXIT.usage; } - if (!/^[0-9a-f]{7,64}$/i.test(head)) { process.stderr.write(`gstack-ship-receipt result: --head must be a hex commit id, got ${JSON.stringify(head.slice(0, 40))}\n`); return EXIT.usage; } + if (!/^[0-9a-f]{12,64}$/i.test(head)) { process.stderr.write(`gstack-ship-receipt result: --head must be a hex commit id of at least 12 characters, got ${JSON.stringify(head.slice(0, 40))}\n`); return EXIT.usage; } const incomplete = (why: string) => { process.stderr.write(`gstack-ship-receipt result: ${why}\n`); process.stdout.write(json ? JSON.stringify({ skill: 'ship', status: 'incomplete', run: '-', message: why }) + '\n' : 'GSTACK_RESULT: skill=ship status=incomplete run=-\n'); diff --git a/docs/ship-policy.md b/docs/ship-policy.md index 4e9add1be..a390144c5 100644 --- a/docs/ship-policy.md +++ b/docs/ship-policy.md @@ -334,8 +334,10 @@ on it: the receipt written for the pinned head stays the binding one. `verify --train` accepts a receipt whose `head` is either the slot's pinned head or the current head, and the re-READY after the stamp push (`ready` at the stamp commit) accepts the receipt for the pin when HEAD is the pin plus stamp files, -or (from slot 2 on) the pin merged with main plus stamp files; any other head -is `QUEUE_RECEIPT_STALE`. +or (from slot 2 on) the pin merged with main plus stamp files on top of the +merge; any other head is `QUEUE_RECEIPT_STALE`, including a merge of main with +no stamp above it (a lane's own push under rule 14) and a merge whose pin side +carries an edit made after /ship. This is a process gate, not an authenticity check: the receipt is a block in the PR body, which the PR author writes, so the codes prove that /ship was run diff --git a/hosts/claude/hooks/auq-error-fallback-hook.ts b/hosts/claude/hooks/auq-error-fallback-hook.ts index 28f4328a3..7fe74ef74 100755 --- a/hosts/claude/hooks/auq-error-fallback-hook.ts +++ b/hosts/claude/hooks/auq-error-fallback-hook.ts @@ -152,8 +152,10 @@ export function directiveFor(kind: SessionKind): string { 'SESSION_KIND=unattended — auto-choose the `(recommended)` option and append it to decisions.jsonl ' + 'with kind "auto"; do not emit prose, do not BLOCK. A question with no recommendation, a consent, ' + 'a destructive or irreversible option, or an approval gate is appended with kind "approval" and status ' + - '"pending" — never chosen, never approved — and the skill ends at its next gate with ' + - 'GSTACK_RESULT status=gate_pending (Unattended session block).' + '"pending" — never chosen, never approved — and the skill ends at its next gate. Only /autoplan and ' + + '/eval-plan print `GSTACK_RESULT: skill= status=gate_pending run=` there, and /ship prints its ' + + 'own line at Step 22 (`gstack-ship-receipt result`); every other skill prints no GSTACK_RESULT line, so do ' + + 'not invent one (Unattended session block; docs/unattended.md names each skill\'s record).' ); case 'headless': // #2733 review (multi-specialist): a spawned-marked subagent under a diff --git a/lib/ship-queue-train-github.ts b/lib/ship-queue-train-github.ts index 39d47f363..c9072efe5 100644 --- a/lib/ship-queue-train-github.ts +++ b/lib/ship-queue-train-github.ts @@ -5,6 +5,7 @@ * full-gate check run. Dispatch itself goes through scripts/eval-bg.ts. */ import * as fs from 'node:fs'; +import * as os from 'node:os'; import * as path from 'node:path'; import type { Exec, RunSummary } from '../scripts/eval-bg'; import type { QueueContext } from './ship-queue'; @@ -68,22 +69,24 @@ export function downloadJson(ctx: QueueContext, slug: string, run: number, name: } catch { return null; } finally { fs.rmSync(dir, { recursive: true, force: true }); } } -/** The named check run on a commit (the newest when several); null when absent or unreadable. */ /** * 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. */ -export function hollowScreen(ctx: QueueContext, slug: string, run: number, artifact: string, scratch: string): string | null { +export function hollowScreen(ctx: QueueContext, slug: string, run: number, artifact: string): string | null { const view = runView(ctx, slug, run); if (!view || view.status !== 'completed') return null; - const rec = downloadJson(ctx, slug, run, artifact, scratch) as { census?: { planned?: { rule?: unknown; judge?: unknown } } } | 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; } +/** 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 }> }>( ctx.gh(['api', `repos/{owner}/{repo}/commits/${sha}/check-runs?check_name=${encodeURIComponent(name)}`])); diff --git a/lib/ship-queue-train-ready.ts b/lib/ship-queue-train-ready.ts index 6fa321b81..9ea2799d1 100644 --- a/lib/ship-queue-train-ready.ts +++ b/lib/ship-queue-train-ready.ts @@ -11,8 +11,6 @@ * replay (lib/gate-cost.ts) and printed as pending until it lands. */ import { randomBytes } from 'node:crypto'; -import * as os from 'node:os'; -import * as path from 'node:path'; import { DISPATCH_INPUTS, awaitNonceRun, dedupeKey, dispatchWorkflow, findDuplicateRun, listDispatchRuns, makeNonce, type WorkflowDispatch } from '../scripts/eval-bg'; import { EXIT } from './headless-artifacts'; import { ready, type QueueContext } from './ship-queue'; @@ -87,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, path.join(os.tmpdir(), 'gstack-screen-scratch')) : null; + const hollow = existing ? hollowScreen(env.ctx, slug, existing.databaseId, env.train.record_artifact) : 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 0b16c957e..24b842eb1 100644 --- a/lib/ship-queue-train.ts +++ b/lib/ship-queue-train.ts @@ -15,8 +15,6 @@ * scripts/eval-bg.ts's nonce. */ import { randomBytes } from 'node:crypto'; -import * as os from 'node:os'; -import * as path from 'node:path'; import { DISPATCH_INPUTS, awaitNonceRun, dispatchWorkflow, listDispatchRuns, makeNonce, runForNonce, type WorkflowDispatch } from '../scripts/eval-bg'; import { changelogDamage, mergeChangelogs, parseChangelog, type ChangelogSection } from './changelog-check'; import type { ResultStatus } from './headless-artifacts'; @@ -304,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, path.join(os.tmpdir(), 'gstack-screen-scratch')) : null; + const hollow = screen.choice === 'screen' && screen.run && slug ? hollowScreen(ctx, slug, screen.run, env.train.record_artifact) : 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/lib/ship-queue.ts b/lib/ship-queue.ts index 788e984a6..f533c9baf 100644 --- a/lib/ship-queue.ts +++ b/lib/ship-queue.ts @@ -66,8 +66,9 @@ function treeReceipt(ctx: QueueContext, policy: LoadedPolicy, gated: string, cur * slot's stamp commit, docs/ship-policy.md#ship-before-ready), or, from slot * 2 on, the stamp over a merge of the base into it: /ship ran on the pin, the * stamp changes nothing it checked, and verify --train binds the same pair - * against the train's own prefix. Any other receipt head stays - * QUEUE_RECEIPT_STALE. + * against the train's own prefix. A merge with no stamp on top is a lane's + * own push (rule 14), so it reruns /ship like any other head. Any other + * receipt head stays QUEUE_RECEIPT_STALE. */ function acceptedReceiptHeads(ctx: QueueContext, policy: LoadedPolicy, head: string, body: string): string[] { const ex = extractReceipt(body); @@ -80,13 +81,12 @@ function acceptedReceiptHeads(ctx: QueueContext, policy: LoadedPolicy, head: str if (stampOnly(full.out, head)) return [full.out, head]; const baseSha = git(root, ['rev-parse', `origin/${ctx.base}`]).out; const onBase = (sha: string) => !!sha && git(root, ['merge-base', '--is-ancestor', sha, baseSha]).ok; - for (const merge of [head, git(root, ['rev-parse', '--verify', '--quiet', `${head}^1`]).out]) { - if (!merge || !stampOnly(merge, head)) continue; - const parents = git(root, ['rev-list', '--parents', '-n', '1', merge]).out.split(' ').slice(1); - if (parents.length !== 2) continue; - const pinSide = onBase(parents[0]!) ? parents[1]! : onBase(parents[1]!) ? parents[0]! : ''; - if (pinSide && git(root, ['merge-base', '--is-ancestor', full.out, pinSide]).ok && stampOnly(full.out, pinSide)) return [full.out, head]; - } + const merge = git(root, ['rev-parse', '--verify', '--quiet', `${head}^1`]).out; + if (!merge || !stampOnly(merge, head)) return [head]; + const parents = git(root, ['rev-list', '--parents', '-n', '1', merge]).out.split(' ').slice(1); + if (parents.length !== 2) return [head]; + const pinSide = onBase(parents[0]!) ? parents[1]! : onBase(parents[1]!) ? parents[0]! : ''; + if (pinSide && git(root, ['merge-base', '--is-ancestor', full.out, pinSide]).ok && stampOnly(full.out, pinSide)) return [full.out, head]; return [head]; } diff --git a/lib/ship-receipt.ts b/lib/ship-receipt.ts index df6c346b7..c543edbfd 100644 --- a/lib/ship-receipt.ts +++ b/lib/ship-receipt.ts @@ -76,7 +76,7 @@ export function bindReceipt(body: string, pr: string | undefined, accepted: read const head = accepted.find(h => h === r.head || (r.head.length >= 12 && h.startsWith(r.head))); if (!head) { const want = accepted.map(h => h.slice(0, 12)).join(' or '); - return { ok: false, code: 'QUEUE_RECEIPT_STALE', message: `${subject} ship receipt is for ${r.head.slice(0, 12)}, not the head ${want} being merged`, line }; + return { ok: false, code: 'QUEUE_RECEIPT_STALE', message: `${subject} ship receipt is for ${oneLine(r.head.slice(0, 12))}, not the head ${want} being merged`, line }; } if (pr !== undefined && r.pr !== undefined && String(r.pr) !== pr) return { ok: false, code: 'QUEUE_RECEIPT_STALE', message: `${subject} ship receipt names pr=${r.pr}, not this PR`, line }; return { ok: true, receipt: r, head, line }; @@ -104,6 +104,9 @@ export function missingIssueLines(body: string, issues: NonNullable s.replace(/[\x00-\x1f\x7f]+/g, ' '); + export function receiptSummaryLine(r: ShipReceipt): string { const parts = [`pr=${r.pr ?? '-'}`, `head=${r.head.slice(0, 12)}`, `version=${r.version ?? '-'}`, `base=${r.base}`]; if (r.gated_tree) parts.push(`gated_tree=${r.gated_tree.slice(0, 12)}`); @@ -113,7 +116,7 @@ export function receiptSummaryLine(r: ShipReceipt): string { if (r.session_kind) parts.push(`session_kind=${r.session_kind}`); if (r.artifacts_consumed) parts.push(`artifacts_consumed=${r.artifacts_consumed}`); if (r.approval) parts.push(`approval=${approvalSummary(r.approval)}`); - return `SHIP_RECEIPT: ${parts.join(' ').replace(/[\x00-\x1f\x7f]+/g, ' ')}`; + return `SHIP_RECEIPT: ${oneLine(parts.join(' '))}`; } // --------------------------------------------------------------------------- diff --git a/test/auq-error-fallback-hook.test.ts b/test/auq-error-fallback-hook.test.ts index 88f256304..b851f1c70 100644 --- a/test/auq-error-fallback-hook.test.ts +++ b/test/auq-error-fallback-hook.test.ts @@ -15,6 +15,7 @@ */ import { describe, test, expect } from 'bun:test'; import { spawnSync } from 'child_process'; +import { readFileSync } from 'fs'; import * as path from 'path'; import { isErrorResponse, directiveFor } from '../hosts/claude/hooks/auq-error-fallback-hook.ts'; import { SPAWNED_ESCAPE_SENTENCE } from '../hosts/claude/hooks/spawned-directive.ts'; @@ -85,6 +86,18 @@ describe('directiveFor — per-session-kind instruction', () => { expect(d).not.toMatch(/BLOCKED/); }); + test('unattended directive names the same GSTACK_RESULT writers as docs/unattended.md and gstack-skill-start (GSTA-91)', () => { + const d = directiveFor('unattended'); + expect(d).toMatch(/Only \/autoplan and \/eval-plan print `GSTACK_RESULT: skill= status=gate_pending run=`/); + expect(d).toMatch(/\/ship prints its own line at Step 22/); + expect(d).toMatch(/every other skill prints no GSTACK_RESULT line/); + const doc = readFileSync(path.join(import.meta.dir, '..', 'docs', 'unattended.md'), 'utf8'); + expect(doc).toContain('**`/autoplan`**'); + expect(doc).toContain('**`/ship`**'); + expect(doc).toMatch(/`\/eval-plan` asks for a `gate_pending` line/); + expect(doc).toMatch(/\*\*Every other workflow skill\*\*[\s\S]*prints the line/); + }); + test('spawned directive carries a self-contained destructive carve-out (#2733 review)', () => { // The "Spawned session block" it defers to exists only when a gstack // preamble ran; an AUQ error outside a skill still needs the exception. diff --git a/test/gstack-ship-queue.test.ts b/test/gstack-ship-queue.test.ts index 931306540..92961196f 100644 --- a/test/gstack-ship-queue.test.ts +++ b/test/gstack-ship-queue.test.ts @@ -299,7 +299,7 @@ describe('ready', () => { // Value: protects=the re-READY after a slot's stamp push accepts the receipt /ship wrote for the pinned head, and only then; // fails_when=ready binds the receipt to HEAD alone (every stamped READY turns QUEUE_RECEIPT_STALE) or accepts any ancestor's receipt; // why_new=the READY test above binds receipts to the exact head only; seam=none; no_claim=real gh and CI (shims; unit only) - test('ready at the stamp commit accepts the receipt written for the pinned head; a real edit on top of the pin is stale', () => { + test('ready at the stamp commit accepts the receipt written for the pinned head; a real edit on top of the pin, a bare merge of main, or an edit on the pin side under the slot-2 stamp is stale', () => { const repo = fixture(); const pin = makePr(repo, 'pr-s'); fs.writeFileSync(path.join(repo.work, 'VERSION'), '1.2.4.0\n'); @@ -324,6 +324,22 @@ describe('ready', () => { const merged = queue(repo, ['ready', '--pr', '1', '--pregate', pregateFor(repo, slot2)], shim(slot2, shippedBody('1', pin))); expect([merged.status, merged.err]).toEqual([EXIT.ok, '']); expect(merged.out).toContain(`SHIP_RECEIPT: pr=1 head=${pin.slice(0, 12)} version=- base=main`); + const staleRe = (h: string) => new RegExp(`PR #1's ship receipt is for ${pin.slice(0, 12)}, not the head ${h.slice(0, 12)} being merged.*\\(QUEUE_RECEIPT_STALE\\)`); + const bareMerge = repo.git('rev-parse', 'HEAD^1'); + repo.git('reset', '-q', '--hard', bareMerge); + const bare = queue(repo, ['ready', '--pr', '1', '--pregate', pregateFor(repo, bareMerge)], shim(bareMerge, shippedBody('1', pin))); + expect(bare.status).toBe(EXIT.refused); + expect(bare.err).toMatch(staleRe(bareMerge)); + repo.git('reset', '-q', '--hard', pin); + fs.appendFileSync(path.join(repo.work, 'pr-s.txt'), 'real change before the merge\n'); + repo.git('commit', '-qam', 'edit on the pin side'); + repo.git('merge', '-q', '--no-edit', 'origin/main'); + fs.writeFileSync(path.join(repo.work, 'VERSION'), '1.2.5.0\n'); + repo.git('commit', '-qam', 'v1.2.5.0 stamp'); + const editedPin = repo.git('rev-parse', 'HEAD'); + const pinEdit = queue(repo, ['ready', '--pr', '1', '--pregate', pregateFor(repo, editedPin)], shim(editedPin, shippedBody('1', pin))); + expect(pinEdit.status).toBe(EXIT.refused); + expect(pinEdit.err).toMatch(staleRe(editedPin)); repo.git('reset', '-q', '--hard', stamped); fs.appendFileSync(path.join(repo.work, 'pr-s.txt'), 'real change after /ship\n'); repo.git('commit', '-qam', 'more'); diff --git a/test/gstack-ship-receipt.test.ts b/test/gstack-ship-receipt.test.ts index ff1b4550f..ebdfb1deb 100644 --- a/test/gstack-ship-receipt.test.ts +++ b/test/gstack-ship-receipt.test.ts @@ -229,7 +229,10 @@ describe('gstack-ship-receipt result (/ship terminal line, GBRA-108)', () => { expect(JSON.parse(fs.readFileSync(path.join(repo.root, 'pinned-out', 'ship-run.json'), 'utf8')).head).toBe('c'.repeat(40)); const badHead = runBin(repo, 'gstack-ship-receipt', ['result', '--body', pinned, '--head', '../../etc'], env); expect([badHead.status, badHead.out]).toEqual([EXIT.usage, '']); - expect(badHead.err).toContain('--head must be a hex commit id'); + expect(badHead.err).toContain('--head must be a hex commit id of at least 12 characters'); + const shortHead = runBin(repo, 'gstack-ship-receipt', ['result', '--body', pinned, '--head', 'abcdef1'], env); + expect([shortHead.status, shortHead.out]).toEqual([EXIT.usage, '']); + expect(shortHead.err).toContain('--head must be a hex commit id of at least 12 characters'); 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/ship-queue-train.test.ts b/test/ship-queue-train.test.ts index 36ac86991..c301172f3 100644 --- a/test/ship-queue-train.test.ts +++ b/test/ship-queue-train.test.ts @@ -239,6 +239,14 @@ describe('ready under a train policy: the screen choice', () => { 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, '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: [] } }; }); + 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/); + 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); }, 120_000); });