fix(ci): port the verify gate's remaining hardening from #8765 (#8878)

#8816's branch accidentally carried #8765's early commits, and the
takeover loop evolved the gate further there (subset identity via
comm -23, the retryable third arg, subset fixtures) — so #8765 closes
as subsumed, and this PR ports what main still lacks: the two
improvements its reviewers named for porting, plus the open round-6/7
findings that survive on main's gate.

- Pre-detach short-circuit: an empty head signature (vite/esbuild/
  crash — the KNOWN LIMIT class) fails closed regardless of the
  baseline, so decide it BEFORE paying the detach + full baseline
  re-run + restore.
- Build-dirt guard: the A/B'd build REWRITES a tracked file (the
  vscode companion settings schema), and the undiscarded rewrite makes
  either checkout refuse — degrading a real verdict into the crash
  path. `git restore -- .` before both checkouts; tracked-only, and
  the tree was asserted clean before the deterministic checks.
- Restore-failure semantics: a plain outcome=failed is an EVALUATED
  rejection — the watermark advances and a transient git failure
  strands the item as a permanent human handoff. The gate now leaves
  outcome unset (the gate-crashed path retries next scan) and still
  writes the detail document so the crash comment explains itself.
- The dist-rebuilt steering note seeds the repair feedback on both
  retryable A/B exits — the repair agent's only warning that dist/
  holds baseline-built artifacts.
- The stale-base retry handoff prefixes its embedded rejection with a
  the-base-has-moved note, so the retry agent is not steered toward
  no-action by framing written before the auto-update.
- The two A/B side logs joined the repair step's cleanup list.
- Tests: identity-less short-circuit, tracked-dirt survival,
  verdict-less restore crash, long-preamble render cap, PREEXISTING
  clause selection through the executable report harness, and the
  stale-framing note pin.

Mutation-tested, 5 of 5 caught: short-circuit dropped, restore guards
dropped, restore-failure reverted to the evaluated rejection, dist
note dropped, stale-framing note dropped.

Co-authored-by: verify <verify@local>
This commit is contained in:
Shaojin Wen 2026-08-10 21:58:27 +08:00 committed by GitHub
parent 95e17691a9
commit 5dc98240c7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 152 additions and 17 deletions

View file

@ -102,8 +102,25 @@ baseline_also_fails() {
# is A/B-eligible) — the baseline IS the tree under test; nothing to
# compare.
[[ "${baseline}" != "${current}" ]] || return 1
# The head transcript is already complete, and an empty head signature
# fails closed regardless of what the baseline would say — so decide it
# BEFORE paying the detach + full re-run + restore for a verdict that was
# never in question (esbuild/vite/crash failures, the KNOWN LIMIT class).
local sig_head
sig_head="$(fail_signature "${GATE_LOG}.check")" || true
if [[ -z "${sig_head}" ]]; then
echo "🔁 no failure identity in the head transcript — charged to the round" \
| tee -a "${GATE_LOG}"
return 1
fi
echo "🔁 Baseline A/B: re-running the failed check at origin/${BRANCH}" \
"(${baseline})" | tee -a "${GATE_LOG}"
# The build under test may have REWRITTEN tracked artifacts (the vscode
# companion settings schema is regenerated by scripts/build.js): discard
# build dirt or the checkout refuses and a real verdict degrades into the
# restore-failure crash below. Tracked-only, and the tree was asserted
# clean before the deterministic checks — anything here is build output.
git restore -- . 2>> "${GATE_LOG}" || true
git checkout --quiet --detach "${baseline}" 2>> "${GATE_LOG}" || return 1
# The baseline transcript goes to a SIDE log: gate-rejection.md renders
# the dynamic `tail_budget` tail of GATE_LOG as the evidence window, and
@ -116,16 +133,33 @@ baseline_also_fails() {
if ! "$@" >> "${ab_log}" 2>&1; then
rc=1
fi
git restore -- . 2>> "${GATE_LOG}" || true
if ! git checkout --quiet "${BRANCH}" 2>> "${GATE_LOG}"; then
# The tree is no longer the one under verification and nothing after
# this point may trust it. Not retryable either: the repair agent works
# in this very checkout and performs no git recovery, so on a detached
# tree its commit would land on the baseline and be orphaned. The round
# ends here; the next one starts clean from the trusted checkout.
reject_fix 'could not restore the verification tree after the baseline check' \
false false
# this point may trust it — including the repair agent (its commit would
# orphan on the detached baseline). But a transient git-state failure is
# NOT a verdict about the failure's origin, and a plain outcome=failed
# is an EVALUATED rejection: the watermark advances and the item is
# handed off for good. Leave outcome UNSET so the report's gate-crashed
# path retries on the next scan's fresh checkout — and write the detail
# document so the crash comment still explains itself.
echo "❌ could not restore the verification tree after the baseline check"
{
echo '**could not restore the verification tree after the baseline check**'
echo
echo '````'
tail -c 3000 "${GATE_LOG}" 2> /dev/null
echo '````'
} > "${WORKDIR}/gate-rejection.md" || true
exit 1
fi
if [[ "${rc}" -ne 1 ]]; then
# Both retryable exits below hand the tree to the repair agent with
# dist/ REBUILT FROM BASELINE SOURCES (the restore checkout brings back
# tracked files only) — the mirror of the dist confound that exempted
# typecheck from the A/B. The note seeds the repair feedback so the
# agent rebuilds before it trusts any dist-consuming check.
echo "⚠️ the baseline leg rebuilt dist/ from baseline sources — run npm run build before typecheck/tests" >> "${GATE_LOG}"
echo "🔁 baseline is green — the failure belongs to this round" \
| tee -a "${GATE_LOG}"
return 1
@ -143,13 +177,13 @@ baseline_also_fails() {
# failure sets to NO-MATCH. No diagnostics on either side means identity
# cannot be established, and the rejection stays charged to the round
# (fail closed).
local sig_head sig_base new_in_round
local sig_base new_in_round
# `|| true`: grep exits 1 on the NORMAL no-match case, and these
# assignments only survive `set -e` today because this function is called
# from an `if` condition (which suspends errexit). A future unconditional
# call site would otherwise turn the documented fail-closed path into a
# verdict-less gate crash.
sig_head="$(fail_signature "${GATE_LOG}.check")" || true
# (sig_head was extracted before the detach.)
sig_base="$(fail_signature "${ab_log}")" || true
new_in_round="$(comm -23 <(printf '%s\n' "${sig_head}") <(printf '%s\n' "${sig_base}"))" ||
return 1

View file

@ -4600,6 +4600,8 @@ jobs:
"${WORKDIR}/failure.md" \
"${WORKDIR}/handoff.md" \
"${WORKDIR}/gate-output.log" \
"${WORKDIR}/gate-output.log.check" \
"${WORKDIR}/gate-output.log.baseline" \
"${WORKDIR}/gate-rejection.md" \
"${WORKDIR}/agent-api-error" \
"${WORKDIR}/agent-api-error-kind" \
@ -5608,6 +5610,16 @@ jobs:
echo '<!-- autofix-gate-rejection-start -->'
echo "**Why it was not pushed:**"
echo
# The rejection text below was written BEFORE the stale-base
# auto-update: on that path its framing (pre-existing,
# unreachable, cured by a base update) is already stale — the
# base HAS been updated, and the next round re-measures and
# may charge the round. Say so, or the retry agent is steered
# toward no-action on the one round designed to re-measure.
if [[ "${STALE_BASE_RETRY:-false}" == 'true' ]]; then
echo "_Note: the base has since been auto-updated; the verdict below predates that update, and the next round's re-measurement may charge the round._"
echo
fi
# reject_fix sizes its own document: the evidence tail is
# dynamic (budget 3300 minus the preamble, floored at 500),
# so the finished file tops out ≈3.3 KB. 3900 is headroom,

View file

@ -7721,6 +7721,11 @@ exit 1
/PUSH_RACE_MERGED='false'\n\s+for push_attempt in 1 2 3; do/,
);
expect(pushAndReportStep).toContain('verification predates that merge');
// The STALE_BASE_RETRY handoff embeds a rejection written BEFORE the
// auto-update; the note un-poisons its framing for the retry agent.
expect(reviewAddressReportStep).toContain(
'the base has since been auto-updated',
);
// Bounded: the loop gives up after the last attempt instead of spinning.
// The structural pin connects the guard value to the error exit — a
// mutation of == 3 to == 4 survives presence-only checks: the loop
@ -8738,6 +8743,28 @@ exit 1
expect(staleConflict.split('|')[0]).toBe(NEWEST);
expect(staleConflict).toContain('Could not produce a passing fix');
// Pre-existing verdicts pick their remedy from the compare the step
// already ran — swapping the two clause bodies must fail here, not ship
// a headline prescribing a merge that changes nothing.
const preAhead = run(
{ OUTCOME: 'failed', PREEXISTING: 'true' },
{ gateRejection: true },
);
expect(preAhead).toContain('PRE-EXISTING failure');
expect(preAhead).toContain('own pre-round code needs attention');
expect(preAhead).not.toContain('base update (merge main)');
const preBehindConflict = run(
{
OUTCOME: 'failed',
PREEXISTING: 'true',
CMP_STATUS_STUB: 'behind',
UPDATE_OK_STUB: '0',
},
{ gateRejection: true },
);
expect(preBehindConflict).toContain('PRE-EXISTING failure');
expect(preBehindConflict).toContain('base update (merge main)');
// Gate crash (no verdict): keep the feedback live and retry.
const crashed = run({ OUTCOME: '' });
expect(crashed.split('|')[0]).toBe(SENTINEL);
@ -11323,6 +11350,8 @@ describe('review verification gate: baseline A/B on deterministic rejection', ()
extraBaselineDiag = false,
restoreClash = false,
hugeFail = false,
noIdentity = false,
trackedDirt = false,
}) => {
const dir = mkdtempSync(join(tmpdir(), 'gate-ab-'));
try {
@ -11400,6 +11429,16 @@ describe('review verification gate: baseline A/B on deterministic rejection', ()
' if [[ -n "${HEAD_MSG:-}" ]]; then msg="${HEAD_MSG}"; fi',
' fi',
' echo "stub build FAILED at $head"',
' if [[ "${NO_IDENTITY:-}" == "1" ]]; then',
// vite/esbuild shape: a red build with no tsc diagnostic at all.
' echo "error during build: something exploded"; exit 1',
' fi',
' if [[ "$head" == "${BASELINE_SHA:-}" && "${TRACKED_DIRT:-}" == "1" ]]; then',
// The build rewrites a TRACKED file (the settings-schema shape):
// f.txt differs across refs, so an undiscarded rewrite makes the
// restore checkout refuse.
' echo dirt > f.txt',
' fi',
' echo "src/f.ts${pos}: error TS${code}: ${msg}"',
' if [[ "${HUGE_FAIL:-}" == "1" ]]; then',
' for i in $(seq 1 200); do echo "verbose failure context line $i ****************************************"; done',
@ -11471,6 +11510,8 @@ describe('review verification gate: baseline A/B on deterministic rejection', ()
EXTRA_ROUND_DIAG: extraRoundDiag ? '1' : '',
EXTRA_BASELINE_DIAG: extraBaselineDiag ? '1' : '',
RESTORE_CLASH: restoreClash ? '1' : '',
NO_IDENTITY: noIdentity ? '1' : '',
TRACKED_DIRT: trackedDirt ? '1' : '',
SCHEMA_FAIL: schemaFail ? '1' : '',
TYPECHECK_FAIL: typecheckFail ? '1' : '',
NOISY_SUCCESS: noisySuccess ? '1' : '',
@ -11500,6 +11541,10 @@ describe('review verification gate: baseline A/B on deterministic rejection', ()
expect(r.status).toBe(1);
expect(r.outputs).toContain('outcome=failed');
expect(r.outputs).toContain('retryable=true');
// The repair agent's only warning that dist/ now holds baseline-built
// artifacts — dropped, it burns its budget on phantom dist-consuming
// failures.
expect(r.rejection).toContain('run npm run build before typecheck/tests');
expect(r.outputs).not.toContain('preexisting=true');
// The A/B genuinely ran — the verdict is measured, not assumed.
expect(r.stdout).toContain('Baseline A/B');
@ -11537,23 +11582,67 @@ describe('review verification gate: baseline A/B on deterministic rejection', ()
expect(r.stdout).toContain('DIFFERENT reason');
});
it('rejects WITHOUT retry when the baseline leg breaks the restore', () => {
// The baseline run recreates (untracked) a file the branch tracks, so
// `git checkout` back refuses — the tree can no longer be trusted. No
// pre-existing label (a transient git failure is not a verdict about
// the failure's origin) and no retry either: the repair agent works in
// this very checkout and performs no git recovery, so on the detached
// tree its commit would land on the baseline and be orphaned. The next
// round starts clean from the trusted checkout instead.
it('crashes verdict-less when the baseline leg breaks the restore (retry, not handoff)', () => {
// The baseline run recreates (untracked) a file the branch tracks
// (`git restore -- .` touches tracked files only), so the checkout back
// refuses — the tree can no longer be trusted, and the repair must not
// run in it (its commit would orphan on the detached baseline). But a
// transient git-state failure is NOT a verdict either: a plain
// outcome=failed is an EVALUATED rejection — the watermark advances and
// the item is handed off for good. The gate therefore leaves outcome
// UNSET (the report's gate-crashed path retries next scan) while still
// writing the detail document so the crash comment explains itself.
const r = runGate({
failAt: ['feature', 'origin/feature'],
restoreClash: true,
});
expect(r.status).toBe(1);
expect(r.outputs).toContain('outcome=failed');
expect(r.outputs).not.toContain('outcome=');
expect(r.outputs).not.toContain('retryable=true');
expect(r.outputs).not.toContain('preexisting=true');
expect(r.stdout).toContain('could not restore the verification tree');
expect(r.rejection).toContain('could not restore the verification tree');
});
it('short-circuits before the detach when the head has no failure identity', () => {
// vite/esbuild/crash failures carry no tsc diagnostic: an empty head
// signature fails closed REGARDLESS of the baseline, so the gate must
// decide before paying the detach + full baseline re-run + restore.
const r = runGate({ failAt: ['feature'], noIdentity: true });
expect(r.status).toBe(1);
expect(r.outputs).toContain('retryable=true');
expect(r.outputs).not.toContain('preexisting=true');
expect(r.stdout).toContain('no failure identity in the head transcript');
expect(r.stdout).not.toContain('Baseline A/B');
});
it('discards tracked build dirt so a real verdict survives the restore', () => {
// The baseline build REWRITES a tracked file (the settings-schema
// shape): without the pre-checkout `git restore -- .` the restore
// refuses and a clean pre-existing verdict degrades into the
// verdict-less crash.
const r = runGate({
failAt: ['feature', 'origin/feature'],
trackedDirt: true,
});
expect(r.status).toBe(1);
expect(r.outputs).toContain('preexisting=true');
expect(r.stdout).not.toContain('could not restore the verification tree');
});
it('caps the LONG-preamble (pre-existing) rejection under the render window', () => {
// The short-preamble flood is pinned above; the pre-existing path adds
// ~490 bytes of preamble, and the ${#preamble} subtraction is what
// keeps THIS document under the cap — a constant would pass the short
// case and truncate this one's closing fence.
const r = runGate({
failAt: ['feature', 'origin/feature'],
hugeFail: true,
});
expect(r.status).toBe(1);
expect(r.outputs).toContain('preexisting=true');
expect(r.rejection.length).toBeLessThanOrEqual(3900);
expect(r.rejection.endsWith('````\n')).toBe(true);
});
it('keeps the full message past the first n (the bracket class ate it)', () => {