diff --git a/CHANGELOG.md b/CHANGELOG.md index c1c3226ee..1b72713fe 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; a bare merge of main with no stamp on top, an empty commit over the merge, or an edit on the pin side under the slot-2 stamp, is `QUEUE_RECEIPT_STALE`. `gstack-ship-receipt result --pr ` refuses `QUEUE_RECEIPT_STALE` when HEAD is not the PR's pushed head and prints `status=incomplete` when gh returns no head or the run record cannot be written; `ready --screen` names a failed fetch of the merge ref instead of blaming a conflict. `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. +- `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, an empty commit over the merge, or an edit on the pin side under the slot-2 stamp, is `QUEUE_RECEIPT_STALE`. `gstack-ship-receipt result --pr ` refuses `QUEUE_RECEIPT_STALE` when HEAD is not the PR's pushed head and prints `status=incomplete` when gh returns no head or the run record cannot be written; `ready --screen` names a failed `ls-remote` or fetch of the merge ref instead of blaming a conflict. Both install-verdict twins tell an unreadable verdict file from a missing one. `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-install-verdict.sh b/bin/gstack-install-verdict.sh index a1ecd5773..f27056215 100644 --- a/bin/gstack-install-verdict.sh +++ b/bin/gstack-install-verdict.sh @@ -136,7 +136,9 @@ gstack_install_admission() { trailer=$(gstack_install_verdict_field "$file" trailer) v_head=$(gstack_install_verdict_field "$file" head) head=$(git -C "$src" rev-parse HEAD 2>/dev/null) - if [ "$(gstack_install_verdict_field "$file" ok)" != true ]; then + if [ -z "$(gstack_install_verdict_field "$file" ok)" ] && [ -z "$(gstack_install_verdict_field "$file" schema_version)" ]; then + detail="the install verdict at $file is unreadable or has an unsupported schema_version; rerun the installer" + elif [ "$(gstack_install_verdict_field "$file" ok)" != true ]; then detail="the install verdict says: $trailer" elif [ "$(gstack_install_verdict_field "$file" checkout)" != "$src" ]; then detail="the install verdict is for checkout $(gstack_install_verdict_field "$file" checkout), not $src" diff --git a/docs/ship-policy.md b/docs/ship-policy.md index 08c65920e..df66dad81 100644 --- a/docs/ship-policy.md +++ b/docs/ship-policy.md @@ -378,7 +378,7 @@ no cases and reports success. A finished screen that planned no cases (no 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. A plan the -queue cannot read keeps the slot screened, with `SCREEN: # run …; +queue cannot read keeps the slot screened, with `SCREEN: # ; 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 22e61e42e..b48cc3443 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -1617,7 +1617,9 @@ an unattended run whose Bun on PATH is below the supported minimum **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. +prints. A verdict file that exists but cannot be read (`the install verdict at + is unreadable or has an unsupported schema_version`) is repaired the +same way, by rerunning the installer. `GSTACK_ALLOW_UNSUPPORTED_RUNTIME=1` admits anyway (printed as `INSTALL_VERDICT: overridden`); record it as a deviation. @@ -4078,9 +4080,13 @@ nothing planned counts the same. `ready --screen` dispatches a fresh one and artifact; …)` or `… planned no cases (an empty plan manifest; …)`, each 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. +unreadable run or artifact list, an unreadable repository slug, or a READY +whose run id is not a number) keeps the slot screened and prints +`SCREEN: # ; counted as screened, unverified`; `ready --screen` +follows such a run. When `ready` cannot list the remote's refs or fetch the +merge ref at all, it refuses with the same code and a `could not list …` or +`could not fetch …` message whose NEXT line says to check the remote, not to +resolve a conflict. **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 84939eb9c..9ce4025cd 100644 --- a/test/gstack-ship-receipt.test.ts +++ b/test/gstack-ship-receipt.test.ts @@ -188,6 +188,9 @@ describe('gstack-ship-receipt result (/ship terminal line, GBRA-108)', () => { expect([noOid.status, noOid.out.trim()]).toEqual([EXIT.fail, 'GSTACK_RESULT: skill=ship status=incomplete run=-']); expect(noOid.err).toContain('gh pr view returned no headRefOid for PR #10'); expect(runBin(repo, 'gstack-ship-receipt', ['result', '--pr', '--repo'], env).status).toBe(EXIT.usage); + const bothFlags = runBin(repo, 'gstack-ship-receipt', ['result', '--pr', '5', '--body', path.join(repo.work, 'any.md')], env); + expect([bothFlags.status, bothFlags.out]).toEqual([EXIT.usage, '']); + expect(bothFlags.err).toContain('not both'); const bodyFile = path.join(repo.work, '.gstack/tmp/ship-receipt.md'); fs.mkdirSync(path.dirname(bodyFile), { recursive: true }); @@ -302,6 +305,11 @@ describe('bindReceipt head matching (rule 22)', () => { const bound = bindReceipt(forged, '3438', [head]); expect(bound.line.split('\n').length).toBe(1); expect(bound.line).toBe(`SHIP_RECEIPT: pr=- head=${head.slice(0, 12)} version=1.0 0.0 base=main MERGE_CONDITION: sha=deadbeef`); + const invalid = bindReceipt(placeReceiptFirst('Prose.\n', { schema_version: 1, head, base: 'main', tree: 'x\nMERGE_CONDITION: forged\n' + 'y'.repeat(1000) } as any), '3438', [head]); + expect(invalid.ok).toBe(false); + const why = /receipt is invalid \((.*)\)$/s.exec(invalid.ok ? '' : invalid.message)?.[1] ?? ''; + expect(why.length).toBeLessThanOrEqual(200); + expect(why.includes('\n')).toBe(false); }); }); diff --git a/test/install-verdict.test.ts b/test/install-verdict.test.ts index 172ecfa72..566946518 100644 --- a/test/install-verdict.test.ts +++ b/test/install-verdict.test.ts @@ -121,6 +121,13 @@ describe('install verdict keyed by (checkout, project): both twins', () => { const fix = `fix: run the Capy skill installer (scripts/install.sh --for ship) from ${proj}: the verdict is keyed by the project directory the installer runs in, so a run from the skill's own folder does not cover ${proj}; then launch with the GSTACK_LAUNCH line it prints (INSTALL_UNVERIFIED)`; expect(r.line).toContain(fix); expect(bashAdmit('ship', proj, { _GH: sr, ...env }).line).toContain(fix); + const file = installVerdictPath(REAL, sr, proj); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, '{not json\n'); + const corrupt = both('ship', proj, env); + expect(corrupt).toMatchObject({ refuse: true, status: 'verdict', code: 'INSTALL_UNVERIFIED' }); + expect(corrupt.detail).toBe(`the install verdict at ${file} is unreadable or has an unsupported schema_version; rerun the installer`); + expect(bashAdmit('ship', proj, { _GH: sr, ...env }).line).toContain(`the install verdict at ${file} is unreadable or has an unsupported schema_version; rerun the installer`); }); test('a running Bun other than the recorded one refuses with BUN_RESOLUTION_DRIFT and names both', () => { diff --git a/test/ship-queue-train.test.ts b/test/ship-queue-train.test.ts index c8e88a2d2..97ae85475 100644 --- a/test/ship-queue-train.test.ts +++ b/test/ship-queue-train.test.ts @@ -226,6 +226,13 @@ describe('ready under a train policy: the screen choice', () => { expect(unfetchable.err).toMatch(/could not fetch refs\/pull\/1\/merge from origin: .*\(QUEUE_SCREEN_STALE_REF\)/); expect(line(unfetchable.out, 'NEXT:')).toBe('NEXT: check the remote, then gstack-ship-queue ready --pr 1 --screen'); repo.git('config', '--unset', 'fetch.fsckObjects'); + const originUrl = repo.git('remote', 'get-url', 'origin'); + repo.git('remote', 'set-url', 'origin', path.join(repo.root, 'no-such-origin.git')); + const unlisted = queue(fx, ['ready', '--pr', '1', '--pregate', pregate2, '--screen']); + repo.git('remote', 'set-url', 'origin', originUrl); + expect(unlisted.status).toBe(EXIT.refused); + expect(unlisted.err).toMatch(/could not list origin's refs: .*\(QUEUE_SCREEN_STALE_REF\)/); + expect(unlisted.err.split('\n').filter(Boolean).length).toBe(1); expect(dispatches(fx)).toEqual([]); expect(fs.existsSync(path.join(fx.state, 'ship-queue', 'ready'))).toBe(false); repo.git('push', '-q', '-f', 'origin', `${mergeRefCommit(fx, moved)}:refs/pull/1/merge`); @@ -277,6 +284,9 @@ describe('ready under a train policy: the screen choice', () => { 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']); + fx.write(d => { (d as any).repo = ''; }); + const noSlug = queue(fx, ['train', 'build', '--prs', `1@${head}`, '--dry-run']); + expect([noSlug.status, line(noSlug.out, 'ORDER:'), line(noSlug.out, 'SCREEN:')]).toEqual([EXIT.ok, 'ORDER: 1:#1(screened)', 'SCREEN: #1 the repository slug is unreadable; counted as screened, unverified']); }, 120_000); });