diff --git a/CHANGELOG.md b/CHANGELOG.md index f4b41fb8d..488f8fa9c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## [1.91.340.0] - 2026-10-30 + +### Changed +- **A stamped train counts a slot as merged only when GitHub reports its PR MERGED** (`lib/ship-queue-train-verify.ts`, #3603): for a stamped train, `train land` refuses to confirm a PR GitHub reports open even when main's tree is its S_i (`QUEUE_PROOF_MISSING`), and the merged count, the freeze decision and the freeze record read every slot's GitHub state instead of trusting the record's `merged` entries. Plain trains are unchanged. + ## [1.91.339.0] - 2026-10-30 **Merge trains can stamp every slot themselves under `changelog_fragments`: `train build` builds a stamped prefix for each slot and `train land` publishes it, so no lane stamps a PR by hand.** gstack's own policy still leaves the key off (docs/ship-policy.md#changelog-fragments). diff --git a/VERSION b/VERSION index 6536d3b7f..b2a0c2857 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.91.339.0 +1.91.340.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index 62d8b8fe8..53be166c2 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -1,4 +1,4 @@ -# gstack digest v1.91.339.0 — regenerate/re-copy after upgrading gstack +# gstack digest v1.91.340.0 — regenerate/re-copy after upgrading gstack Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed for agent hosts without a full skill install. The full skills add workflows, diff --git a/docs/ship-policy.md b/docs/ship-policy.md index 13cbba4f9..99c2a5dac 100644 --- a/docs/ship-policy.md +++ b/docs/ship-policy.md @@ -414,6 +414,10 @@ Slots merge with `train land --train --pr `, never a slot call: 4. It refuses if main moved during the checks, merges with `--match-head-commit`, and confirms main's tree is S_i exactly. Any other main is the freeze path above (`QUEUE_TRAIN_TREE_MISMATCH`, backstop). + A slot counts as merged only when GitHub reports its PR MERGED. Main at + S_i with the PR still open refuses (`QUEUE_PROOF_MISSING`), and the + train's merged count, its freeze decision and the freeze record read + GitHub's state for every slot, never the record's `merged` list alone. `verify --train` refuses a stamped train and names `train land`. The backstop covers main only when its exact tree is some S_i of a green record (plain diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index dfb5e5f18..6322cfe66 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -4273,7 +4273,10 @@ and whether a slot merged on GitHub cannot be read (so whether the queue must freeze is unknown; it never answers "nothing merged" from an unreadable state), `train land` when the base or the train branch cannot be fetched or the record's T0 and S_1..S_k are not the gated head's first-parent chain -(each S_i's second parent slot i's pin), and `ready --screen` when the +(each S_i's second parent slot i's pin), `train land` when main's tree is the +slot's S_i but GitHub does not report the PR as MERGED (a record whose slot +numbers or merged entries disagree with GitHub), +and `ready --screen` when the scratch merge branch cannot be pushed. Nothing was written unless the `QUEUE_STAMP_REFUSED:` line says `committed=yes`. diff --git a/lib/ship-queue-train-verify.ts b/lib/ship-queue-train-verify.ts index 927656c00..4df842888 100644 --- a/lib/ship-queue-train-verify.ts +++ b/lib/ship-queue-train-verify.ts @@ -113,24 +113,29 @@ function published(env: TrainEnv, t: TrainRecord, i: number, main: string): { sa * Freeze the queue: the record goes to local state and the notes ref before * anything remote, then the backstop gate is dispatched on main's sha. * `states` is the one GitHub read of every unverified slot, made by the - * caller: a slot that read MERGED is merged, verified tree or not, and a slot + * caller (for a stamped train every slot: its record entries never count on + * their own): a slot that read MERGED is merged, verified tree or not, and a slot * that could not be read is left out of `unmerged`, recorded under * `unreadable` and named in the reason and in the NEXT after the repair (the * operator seats it by hand once gh answers), so the rebuild after the repair * never seats a PR that may already be in. */ +/** A record entry that counts as merged without a GitHub read: plain trains only; a stamped train counts only what GitHub reports MERGED (#3603). */ +const recordedMerged = (t: TrainRecord, pr: string): boolean => !t.stamped && t.merged.some(m => m.pr === pr); + async function freezeQueue(env: TrainEnv, t: TrainRecord, reason: string, mainSha: string, states: ReadonlyMap): Promise { const now = nowIso(); - const unverified = t.slots.filter(s => !t.merged.some(m => m.pr === s.pr)); + const unverified = t.slots.filter(s => !recordedMerged(t, s.pr)); const landed = unverified.filter(s => states.get(s.pr) === 'MERGED').map(s => s.pr); + const untreed = landed.filter(pr => !t.merged.some(m => m.pr === pr)); const unreadable = unverified.filter(s => states.get(s.pr) === 'unreadable').map(s => s.pr); const prs = (list: string[]) => list.map(p => '#' + p).join(', '); const f: FreezeRecord = { - schema: 1, kind: 'freeze', repo_key: env.repo.key, train: t.nonce, merged: [...t.merged.map(m => m.pr), ...landed], + schema: 1, kind: 'freeze', repo_key: env.repo.key, train: t.nonce, merged: [...t.slots.filter(s => recordedMerged(t, s.pr)).map(s => s.pr), ...landed], unmerged: unverified.filter(s => !landed.includes(s.pr) && !unreadable.includes(s.pr)).map(s => ({ pr: s.pr, head: s.head })), unreadable: unverified.filter(s => unreadable.includes(s.pr)).map(s => ({ pr: s.pr, head: s.head })), main: { sha: mainSha, tree: git(env.ctx.repoRoot, ['rev-parse', `${mainSha}^{tree}`]).out }, - reason: [reason, landed.length ? `${prs(landed)} merged on GitHub without a verified tree` : '', unreadable.length ? `${prs(unreadable)} unreadable on GitHub, left out of unmerged; seat it in a train by hand once gh pr view answers` : ''].filter(Boolean).join('; '), + reason: [reason, untreed.length ? `${prs(untreed)} merged on GitHub without a verified tree` : '', unreadable.length ? `${prs(unreadable)} unreadable on GitHub, left out of unmerged; seat it in a train by hand once gh pr view answers` : ''].filter(Boolean).join('; '), backstop: null, repair: null, created_at: now, cleared_at: null, cleared_by: null, updated_at: now, }; @@ -173,12 +178,12 @@ function verifiedElsewhere(env: TrainEnv, t: TrainRecord, mainSha: string): stri export async function stopped(env: TrainEnv, t: TrainRecord, code: 'QUEUE_TRAIN_TREE_MISMATCH' | 'QUEUE_TRAIN_MOVED', message: string, mainSha: string, lines: string[], fetched: boolean): Promise { const run = trainFile(t.nonce); // One GitHub read per unverified slot decides both the freeze and its record. - const states = new Map(t.slots.filter(s => !t.merged.some(m => m.pr === s.pr)).map(s => [s.pr, prState(env.ctx, s.pr)] as const)); - const landed = t.merged.length > 0 || [...states.values()].includes('MERGED'); + const states = new Map(t.slots.filter(s => !recordedMerged(t, s.pr)).map(s => [s.pr, prState(env.ctx, s.pr)] as const)); + const landed = t.slots.some(s => recordedMerged(t, s.pr)) || [...states.values()].includes('MERGED'); const unreadable = landed ? [] : [...states].filter(([, state]) => state === 'unreadable').map(([pr]) => `#${pr}`); if (unreadable.length) return outRefused({ code: 'QUEUE_PROOF_MISSING', message: `${message}; whether ${unreadable.join(', ')} merged on GitHub could not be read, so whether the queue must freeze is unknown` }, lines, `${Q} verify --pr ${t.slots[0]!.pr} --train ${t.nonce} (once gh pr view answers)`, run); if (!landed) return outFailed({ code, message: `${message}; nothing of train ${t.nonce} merged, so the queue is not frozen` }, lines, `${Q} train build ${prsArgs(t.slots, t.explicit_order)} (a new train on current main)`, run); - const later = fetched && t.slots.every(s => t.merged.some(m => m.pr === s.pr) || states.get(s.pr) === 'MERGED') ? verifiedElsewhere(env, t, mainSha) : null; + const later = fetched && t.slots.every(s => recordedMerged(t, s.pr) || states.get(s.pr) === 'MERGED') ? verifiedElsewhere(env, t, mainSha) : null; if (later) { const held = activeFreeze(env.repo.keys); return outRefused({ code: 'QUEUE_TRAIN_STALE', message: `${message}; every slot of train ${t.nonce} merged on GitHub and main ${mainSha.slice(0, 12)} is ${later}, so this record is stale and ${held ? `freezes nothing (the queue is already frozen by train ${held.train})` : 'the queue is not frozen'}` }, held ? [...lines, ...frozenLines(held)] : lines, `${Q} train prune --stale --train ${t.nonce}`, run); @@ -233,11 +238,14 @@ export async function verifyTrain(ctx: QueueContext, o: { pr: string; train: str export function confirmMerged(env: TrainEnv, t: TrainRecord, pr: string, main: string, index: number, lines: string[]): TrainOut { const run = trainFile(t.nonce); + const state = t.stamped ? prState(env.ctx, pr) : 'MERGED'; + if (state !== 'MERGED') return outRefused({ code: 'QUEUE_PROOF_MISSING', message: `main's tree is S${index} of train ${t.nonce}, but GitHub reports #${pr} as ${state}, not MERGED; only a merge GitHub reports counts` }, lines, `gh pr view ${pr} --json state,headRefOid; rerun once GitHub reports #${pr} MERGED`, run); if (!t.merged.some(m => m.pr === pr)) t.merged.push({ pr, sha: main, tree: git(env.ctx.repoRoot, ['rev-parse', `${main}^{tree}`]).out, at: nowIso() }); - t.state = t.merged.length === t.slots.length ? 'merged' : 'merging'; + const merged = t.slots.filter(s => s.pr === pr || recordedMerged(t, s.pr) || (t.merged.some(m => m.pr === s.pr) && prState(env.ctx, s.pr) === 'MERGED')); + t.state = merged.length === t.slots.length ? 'merged' : 'merging'; effect(t, `verified #${pr} merged as T${index}`); save(env, t); - lines.push(`VERIFY_TRAIN: #${pr} merged; main's tree is T${index} of train ${t.nonce} (${t.merged.length}/${t.slots.length} merged)`); + lines.push(`VERIFY_TRAIN: #${pr} merged; main's tree is T${index} of train ${t.nonce} (${merged.length}/${t.slots.length} merged)`); const freeze = activeFreeze(env.repo.keys); if (freeze && freeze.repair?.train === t.nonce && t.state === 'merged') { freeze.cleared_at = nowIso(); diff --git a/package.json b/package.json index 05a22f13b..24270bc37 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gstack", - "version": "1.91.339", + "version": "1.91.340", "description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.", "license": "MIT", "type": "module", diff --git a/test/ship-queue-train-land.test.ts b/test/ship-queue-train-land.test.ts index dbc240bec..262d82b64 100644 --- a/test/ship-queue-train-land.test.ts +++ b/test/ship-queue-train-land.test.ts @@ -251,3 +251,50 @@ describe('stamped trains: build and land', () => { expect(r.err).toMatch(/was built without stamped prefixes.*\(QUEUE_GATE_MISSING\)/); }, 180_000); }); + +describe('train land counts only merges GitHub reports (#3603)', () => { + const trainPath = (fx: TrainFixture, nonce: string) => path.join(fx.state, 'ship-queue', 'trains', nonce, 'train.json'); + const landFirst = (fx: TrainFixture, nonce: string) => { land(fx, nonce, '1'); expect(land(fx, nonce, '1').status).toBe(EXIT.ok); }; + + test('a record with its slot PRs swapped never confirms the open PR as merged', () => { + const { fx, nonce } = stampedTrain(); + expect(queue(fx, ['train', 'verdict', '--train', nonce]).status).toBe(EXIT.ok); + landFirst(fx, nonce); + const t = trainJson(fx, nonce); + fs.writeFileSync(trainPath(fx, nonce), JSON.stringify({ ...t, slots: [{ ...t.slots[0], pr: '2' }, { ...t.slots[1], pr: '1' }] })); + const r = land(fx, nonce, '2'); + expect(r.status).toBe(EXIT.refused); + expect(r.err).toMatch(/GitHub reports #2 as OPEN, not MERGED.*\(QUEUE_PROOF_MISSING\)/); + expect(r.out).not.toMatch(/^VERIFY_TRAIN: #2 merged/m); + expect(fx.read().prs['2']).not.toMatchObject({ state: 'MERGED' }); + expect(trainJson(fx, nonce).merged.map((m: any) => m.pr)).toEqual(['1']); + }, 300_000); + + test('a forged merged entry for an open slot does not mark the train merged', () => { + const { fx, nonce } = stampedTrain(); + expect(queue(fx, ['train', 'verdict', '--train', nonce]).status).toBe(EXIT.ok); + const t = trainJson(fx, nonce); + fs.writeFileSync(trainPath(fx, nonce), JSON.stringify({ ...t, merged: [{ pr: '2', sha: t.prefixes[0].commit, tree: t.prefixes[0].tree, at: '2026-10-29T00:00:00Z' }] })); + landFirst(fx, nonce); + const rerun = land(fx, nonce, '1'); + expect(rerun.out).toContain('(1/2 merged)'); + expect(trainJson(fx, nonce).state).toBe('merging'); + }, 300_000); + + test('a forged merged entry does not turn a pre-landing mismatch into a freeze', () => { + const { fx, nonce } = stampedTrain(); + expect(queue(fx, ['train', 'verdict', '--train', nonce]).status).toBe(EXIT.ok); + const t = trainJson(fx, nonce); + fs.writeFileSync(trainPath(fx, nonce), JSON.stringify({ ...t, merged: [{ pr: '1', sha: t.prefixes[0].commit, tree: t.prefixes[0].tree, at: '2026-10-29T00:00:00Z' }] })); + fx.repo.git('fetch', '-q', 'origin'); + fx.repo.git('checkout', '-q', '--detach', 'origin/main'); + fs.writeFileSync(path.join(fx.repo.work, 'outside.txt'), 'outside\n'); + fx.repo.git('add', 'outside.txt'); + fx.repo.git('commit', '-q', '-m', 'outside'); + fx.repo.git('push', '-q', 'origin', 'HEAD:refs/heads/main'); + const r = land(fx, nonce, '1'); + expect(r.err).toMatch(/nothing of train \S+ merged, so the queue is not frozen.*\(QUEUE_TRAIN_TREE_MISMATCH\)/); + expect(r.out).not.toMatch(/^QUEUE_TRAIN_FROZEN:/m); + }, 300_000); +}); +