diff --git a/.github/workflows/qwen-autofix.yml b/.github/workflows/qwen-autofix.yml index 57018b1e17..6ea5e3e2de 100644 --- a/.github/workflows/qwen-autofix.yml +++ b/.github/workflows/qwen-autofix.yml @@ -165,6 +165,15 @@ env: # value falls back to its default at the read site. GROWTH_BUDGET_SRC_LINES: '${{ vars.QWEN_AUTOFIX_GROWTH_BUDGET_SRC_LINES || 400 }}' GROWTH_BUDGET_TEST_LINES: '${{ vars.QWEN_AUTOFIX_GROWTH_BUDGET_TEST_LINES || 400 }}' + # Non-convergence handoff: once the growth brake has been over budget for + # this many PRIOR rounds in the window AND the diff has not shrunk from the + # most recent over-budget round, the round is DIVERGING (the fixes keep + # growing the diff, so + # Critical-only β€” which only trims non-Criticals β€” cannot help). At that + # point the round escalates to a maintainer-decision handoff (split / accept + # core + track the tail / redesign) instead of patching again. Same tunable + # contract as the budgets above (malformed β†’ default at the read site). + GROWTH_DIVERGENCE_ROUNDS: '${{ vars.QWEN_AUTOFIX_GROWTH_DIVERGENCE_ROUNDS || 2 }}' # An auth/access model error (401/402/403, "no access"/"does not exist") # never self-heals - only a maintainer can fix the key - and every retry # costs an agent run AND a PR comment. Cap those attempts far below @@ -4573,6 +4582,11 @@ jobs: # round's pushed growth would escape the budget for the rest of # the live window. echo "growth_base_win=${LIVE_REARM_KEY}" + # The round's own growth + over-budget flag, so the report step can + # write this round's autofix-growth-now marker (the per-round + # history the divergence read above consumes). + echo "growth_src=${GROWTH_SRC}" + echo "growth_test=${GROWTH_TEST}" } >> "${GITHUB_OUTPUT}" echo "πŸ“ net diff src ${NET_SRC} / test ${NET_TEST} lines (window baseline ${BASE_SRC}/${BASE_TEST}, growth ${GROWTH_SRC}/${GROWTH_TEST}, budgets ${GROWTH_BUDGET_SRC_LINES}/${GROWTH_BUDGET_TEST_LINES})" @@ -4587,6 +4601,74 @@ jobs: CRITICAL_ONLY='true' CRITICAL_ONLY_GROWTH='true' fi + # Divergence: Critical-only only trims non-Criticals, so when the + # GROWTH that trips the brake is Critical-driven the diff keeps + # climbing anyway. Read this window's prior per-round growth markers + # (written by the report step): count the rounds that were over + # budget, and take the MOST RECENT prior over-budget run's growth + # SUM (highest run_id β€” see below; NOT the window-wide max, which a + # one-off spike would raise forever). The round is DIVERGING when it + # is over budget now, the brake has already fired for + # >= GROWTH_DIVERGENCE_ROUNDS prior rounds, and the diff has NOT + # shrunk from that most-recent sum β€” the fixes are not converging, so + # the round must escalate to a human decision instead of patching + # again. A diff that is over budget but SHRINKING (agent removing + # code) or a one-off overshoot stays in ordinary Critical-only. + if [[ ! "${GROWTH_DIVERGENCE_ROUNDS}" =~ ^([1-9][0-9]{0,3})$ ]]; then + echo "::warning::GROWTH_DIVERGENCE_ROUNDS='${GROWTH_DIVERGENCE_ROUNDS}' is not a positive count; using 2" + GROWTH_DIVERGENCE_ROUNDS=2 + fi + # Count runs whenever the net is measured (not only over budget), so + # the trajectory clause below is accurate even on a round that pulled + # back under budget. markers: + # + # Deduped and ordered by run=GITHUB_RUN_ID, the per-workflow-run id: + # the report post's bounded retry re-posts one run's marker (same + # run_id β†’ collapses to the LATEST by created_at, so a re-run's + # fresher attempt wins over its own stale first post), while every + # distinct address run has a fresh, monotonically increasing run_id. round=/eval-watermark are NOT a + # safe identity β€” a state-triggered lane (a persistent merge conflict + # selects the PR every scan with no new evaluable feedback) freezes + # both NEWEST and ROUND, so distinct over-budget runs would share them + # and collapse, stalling the count. Filtered to markers AFTER any + # mid-window base update (BASE_UPD_AT) β€” the same re-anchoring the + # growth-base read guards against. The "not shrinking" test compares + # against the MOST RECENT prior over-budget run's sum (highest + # run_id), not the window-wide max: a single transient spike would + # otherwise raise the bar forever and a genuine plateau-over-budget + # runaway (the exact case to escalate) would never clear it. The + # CURRENT run's own markers are excluded (run != GITHUB_RUN_ID): a + # re-run of a failed job keeps the same run id and its failed + # attempt already posted a marker, so counting it would over-report + # the round's own attempt as a PRIOR one. + GROWTH_DIVERGED='false' + OVER_ROUNDS_PRIOR=0 + PREV_SUM=0 + if [[ "${NET_MEASURED}" == 'true' ]]; then + read -r OVER_ROUNDS_PRIOR PREV_SUM < <(jq -r --arg ab "${AUTOFIX_BOT}" --arg key "${LIVE_REARM_KEY}" --arg baseupd "${BASE_UPD_AT}" --arg curr "${GITHUB_RUN_ID}" ' + [ .[] | select((.user.login // "") == $ab) | . as $c | ($c.body // "") + | [ scan("") ] | .[] + | {sum: ((.[0] | tonumber) + (.[1] | tonumber)), over: .[2], round: (.[3] | tonumber), run: (.[4] | tonumber), win: .[5], at: ($c.created_at // "")} ] + | map(select(.win == $key and .over == "true")) + | map(select(.run != ($curr | tonumber))) + | map(select($baseupd == "" or (.at > $baseupd))) + | group_by(.run) | map(max_by(.at)) + | sort_by(.run) + | "\(length) \((last.sum) // 0)"' "${WORKDIR}/ic.json" 2> /dev/null || echo "0 0") + [[ "${OVER_ROUNDS_PRIOR}" =~ ^[0-9]+$ ]] || OVER_ROUNDS_PRIOR=0 + [[ "${PREV_SUM}" =~ ^-?[0-9]+$ ]] || PREV_SUM=0 + if [[ "${CRITICAL_ONLY_GROWTH}" == 'true' \ + && "${OVER_ROUNDS_PRIOR}" -ge "${GROWTH_DIVERGENCE_ROUNDS}" \ + && $(( GROWTH_SRC + GROWTH_TEST )) -ge "${PREV_SUM}" ]]; then + GROWTH_DIVERGED='true' + fi + fi + # The over-budget flag feeds the report's per-round marker; the + # handoff itself is enforced by the feedback.md text below, so + # GROWTH_DIVERGED needs no step output. + echo "critical_only_growth=${CRITICAL_ONLY_GROWTH}" >> "${GITHUB_OUTPUT}" + [[ "${GROWTH_DIVERGED}" == 'true' ]] && + echo "πŸ›‘ diff not converging: over budget now, ${OVER_ROUNDS_PRIOR} prior over-budget round(s) in this window, growth not shrinking β€” escalating to a maintainer decision instead of patching." # Which trusted humans have exhausted their per-window regular # feedback budget (see CRITICAL_ONLY_HUMAN_BATCHES). A batch is # COUNTED only when a Critical-only round actually consumed it: @@ -4765,6 +4847,26 @@ jobs: echo "Only feedback newer than the last evaluation (${WATERMARK}) from" echo "trusted maintainers or the automated reviewer is listed." echo + # Diff-growth trajectory: the agent sees how big this PR has grown + # so it can prefer minimal/subtractive fixes and recognise a + # non-converging spiral, rather than reflexively adding code for + # every finding. + if [[ "${NET_MEASURED}" == 'true' ]]; then + echo "## Diff growth this window" + echo + echo "Net diff vs this counting window's baseline: source ${GROWTH_SRC} / test ${GROWTH_TEST} lines (budgets ${GROWTH_BUDGET_SRC_LINES}/${GROWTH_BUDGET_TEST_LINES}; ${OVER_ROUNDS_PRIOR} prior round(s) already over budget). Prefer minimal, root-cause, subtractive fixes. If closing a finding would grow the diff materially AND the same class of gap keeps reappearing on code earlier rounds added, that is a signal to escalate for a split β€” not to keep adding guards." + echo + fi + # Non-convergence handoff (the diff keeps growing past budget across + # rounds): a maintainer-decision item the agent must NOT settle by + # patching. Framed as defer-to-human so the address run stops with a + # handoff (SKILL: "Stop BLOCKED when any defer-to-human item remains"). + if [[ "${GROWTH_DIVERGED}" == 'true' ]]; then + echo "## Needs a maintainer's decision β€” this PR is not converging" + echo + echo "This PR's diff has stayed over the growth budget for ${OVER_ROUNDS_PRIOR}+ rounds in this window and is still not shrinking (source ${GROWTH_SRC} / test ${GROWTH_TEST} net lines vs budgets ${GROWTH_BUDGET_SRC_LINES}/${GROWTH_BUDGET_TEST_LINES}). The review findings are themselves driving the growth, so continuing to patch will not converge β€” Critical-only mode cannot help because the Criticals are the growth. Do NOT apply more code fixes this round. Treat this as a \`defer-to-human\` item: STOP with a handoff that names the decision and lays out the options β€” split the PR (land the core, track the remaining findings as follow-up issues), redesign the approach, or accept the current state with the tail deferred β€” plus your recommendation. Leave the call to the maintainer; declining or implementing one direction yourself IS deciding." + echo + fi echo "## Reviews" jq -r --arg wm "${WATERMARK}" --arg rb "${REVIEW_BOT}" --arg ab "${AUTOFIX_BOT}" \ --argjson critical_only "${CRITICAL_ONLY}" --argjson trust "${TRUSTED_ASSOC}" \ @@ -5361,6 +5463,12 @@ jobs: # can report under a stale WINDOW after a re-arm; the marker must # land under the key later reads will use. GROWTH_BASE_WIN: '${{ steps.prepare.outputs.growth_base_win }}' + # This round's own growth + over-budget flag, written as the + # per-round autofix-growth-now marker so later rounds can measure + # divergence (growth still climbing over budget = not converging). + GROWTH_SRC: '${{ steps.prepare.outputs.growth_src }}' + GROWTH_TEST: '${{ steps.prepare.outputs.growth_test }}' + CRITICAL_ONLY_GROWTH: '${{ steps.prepare.outputs.critical_only_growth }}' run: |- # gh has its own $GITHUB_ENV-injectable channels: pin the host and # drop any planted token BEFORE the identity check below, so a @@ -5730,6 +5838,10 @@ jobs: if [[ "${GROWTH_BASE_NEW}" == 'true' ]]; then echo "" fi + # Per-round growth history the next round's divergence read + # counts; run=GITHUB_RUN_ID is the dedup+order identity (a retry + # re-posts the same run, distinct runs get fresh run ids). + echo "" } > "${WORKDIR}/report.md" STATUS="pushed (round ${NEXT_ROUND}/${MAX_ROUNDS})" else @@ -5754,6 +5866,10 @@ jobs: if [[ "${GROWTH_BASE_NEW}" == 'true' ]]; then echo "" fi + # Per-round growth history the next round's divergence read + # counts; run=GITHUB_RUN_ID is the dedup+order identity (a retry + # re-posts the same run, distinct runs get fresh run ids). + echo "" } > "${WORKDIR}/report.md" STATUS="no action needed" fi @@ -5900,6 +6016,16 @@ jobs: EFFECTIVE_ROUND: '${{ steps.prepare.outputs.effective_round }}' MODEL: '${{ vars.QWEN_AUTOFIX_MODEL || vars.QWEN_PR_REVIEW_MODEL }}' CHECKED_OUT_HEAD: '${{ steps.prepare.outputs.checked_out_head }}' + # This step also posts a round report (timeout / gate-rejection / + # abort), so it writes the per-round growth-now marker too β€” else an + # over-budget round that never reaches 'Push and report' leaves a + # history gap and the divergence count under-reports. Empty outputs + # (prepare never ran) fall through the :-0/:-false marker fallbacks + # to an inert over=false entry. + GROWTH_SRC: '${{ steps.prepare.outputs.growth_src }}' + GROWTH_TEST: '${{ steps.prepare.outputs.growth_test }}' + CRITICAL_ONLY_GROWTH: '${{ steps.prepare.outputs.critical_only_growth }}' + GROWTH_BASE_WIN: '${{ steps.prepare.outputs.growth_base_win }}' run: |- # The head the agent actually evaluated β€” captured in prepare before # any mutation, not the report-time remote head (which can move @@ -6405,6 +6531,11 @@ jobs: echo "🧠 Handled by **Qwen Code** Β· model/ζ¨‘εž‹ \`${MODEL_DISPLAY}\`" echo echo "" + # Per-round growth history the divergence read counts β€” same + # marker the push/no-op report paths write, so an over-budget + # round that timed out or was gate-rejected is not a gap. run= + # (per-workflow-run) is the dedup/order identity. + echo "" # A sentinel ts means the agent evaluated NOTHING (crash, API # error, gate crash) and the next scan must retry. Recording a # judged head here would make RED_HEAD == LIVE_HEAD, so the diff --git a/.qwen/skills/autofix/SKILL.md b/.qwen/skills/autofix/SKILL.md index 40052293cb..4280f07f9a 100644 --- a/.qwen/skills/autofix/SKILL.md +++ b/.qwen/skills/autofix/SKILL.md @@ -319,6 +319,24 @@ implement β€” satisfying a nit is never a reason to bloat the code. identity). A maintainer writing "fix X before merge" after round five means exactly that when it reaches you β€” plus failed checks and the requested base-conflict resolution. +- Diff-growth trajectory: `feedback.md` opens with a `Diff growth this window` + section (source/test net lines vs budget, and how many prior rounds were + already over budget) whenever growth is measured. Use it: prefer minimal, + root-cause, subtractive fixes over additive guards, and read a rising + trajectory as a signal β€” if closing a finding would grow the diff materially + AND the same class of gap keeps reappearing on code an earlier round added, + the right response is to escalate for a split, not to add another guard. +- Not converging (the diff keeps growing past budget): when `feedback.md` + contains a `Needs a maintainer's decision β€” this PR is not converging` + section, the growth brake has been over budget across rounds and the diff is + still not shrinking β€” the findings themselves are driving the growth, so + Critical-only cannot help (the Criticals ARE the growth). Do NOT apply more + code fixes this round. This is a `defer-to-human` item: STOP `BLOCKED` with a + handoff that names the decision and lays out the options β€” split the PR (land + the core, track the remaining findings as follow-up issues), redesign, or + accept the current state with the tail deferred β€” plus your recommendation. + Continuing to patch, or deciding the split yourself, is exactly the wrong + move; the call is the maintainer's. - Needs a maintainer's decision: a finding that turns on a judgment that is NOT yours to make β€” a product or scope tradeoff (is this acceptable for v1? should the PR be split?), two reviewers asking for opposite things, or whether diff --git a/scripts/tests/qwen-autofix-workflow.test.js b/scripts/tests/qwen-autofix-workflow.test.js index 0caa373b01..2906e4e009 100644 --- a/scripts/tests/qwen-autofix-workflow.test.js +++ b/scripts/tests/qwen-autofix-workflow.test.js @@ -6201,6 +6201,391 @@ exit 1 expect(skill).toContain('Deferred non-Critical feedback'); }); + it('escalates to a maintainer-decision handoff when the diff keeps growing past budget (non-convergence)', () => { + // Critical-only only trims non-Criticals, so a Critical-driven diff keeps + // growing anyway. The divergence detector reads this window's prior + // per-round growth markers and, once the brake has been over budget for + // >= GROWTH_DIVERGENCE_ROUNDS rounds and the diff is still not shrinking, + // flags the round to STOP and hand off β€” not patch again. + expect(workflow).toContain( + "GROWTH_DIVERGENCE_ROUNDS: '${{ vars.QWEN_AUTOFIX_GROWTH_DIVERGENCE_ROUNDS || 2 }}'", + ); + // Extract the divergence block and run it against fixture history. + const divBlock = prepareBranchAndFeedbackStep.match( + /(if \[\[ ! "\$\{GROWTH_DIVERGENCE_ROUNDS\}"[\s\S]*?GROWTH_DIVERGED='true'\n\s+fi\n\s+fi)/, + )?.[1]; + expect(divBlock).toBeTruthy(); + const dir = mkdtempSync(join(tmpdir(), 'autofix-diverge-')); + // Markers carry round= (informational) and run= (GITHUB_RUN_ID) β€” deduped + // and ordered on run=, the per-workflow-run id: a retry re-posts one run's + // marker (same run β†’ collapses) while distinct address runs have distinct, + // increasing run ids. round=/eval-watermark are NOT a safe identity β€” a + // state-triggered lane freezes both β€” so two distinct runs can share + // round= yet must still count twice. Each row also carries an author + + // created_at (the read filters to the bot and to post-base-update markers). + const marker = ( + src, + test, + over, + round, + key = 'W1', + { + login = 'qwen-code-dev-bot', + at = '2026-01-01T00:00:00Z', + // Default run advances with the round so sort_by(.run) is + // deterministic; distinct runs that share round= override it. + run = 1000 + round, + } = {}, + ) => ({ + user: { login }, + created_at: at, + body: ``, + }); + const diverge = ({ + src, + test, + criticalOnlyGrowth = 'true', + history, + div = 2, + baseUpdAt = '', + // Distinct from every default fixture run id (1000+round), so existing + // cases see no self-exclusion; a case can set a fixture marker's run to + // this to prove the current run's own attempt is excluded. + currentRun = 9999, + }) => { + writeFileSync(join(dir, 'ic.json'), JSON.stringify(history)); + // The printf result is the FINAL line; a malformed-div round also emits + // a `::warning::` annotation to stdout first, so take the last line. + return execFileSync( + 'bash', + [ + '-c', + `set -e\nAUTOFIX_BOT=qwen-code-dev-bot\nLIVE_REARM_KEY=W1\nWORKDIR=${dir}\n` + + `NET_MEASURED=true\nCRITICAL_ONLY_GROWTH=${criticalOnlyGrowth}\n` + + `BASE_UPD_AT='${baseUpdAt}'\nGITHUB_RUN_ID=${currentRun}\n` + + `GROWTH_SRC=${src}\nGROWTH_TEST=${test}\nGROWTH_DIVERGENCE_ROUNDS=${div}\n` + + `${divBlock}\nprintf '\\n%s %s' "$GROWTH_DIVERGED" "$OVER_ROUNDS_PRIOR"`, + ], + { encoding: 'utf8' }, + ) + .trim() + .split('\n') + .pop(); + }; + const climbing = [ + marker(300, 200, 'true', 1), + marker(400, 250, 'true', 2), + marker(500, 300, 'true', 3), + ]; + // 3 prior over-budget rounds, still climbing β†’ diverged. + expect(diverge({ src: 550, test: 300, history: climbing })).toBe('true 3'); + // Same history but the diff SHRANK below the previous round's sum β†’ not. + expect(diverge({ src: 100, test: 100, history: climbing })).toBe('false 3'); + // EXACTLY at the threshold (2 prior rounds, div=2), still climbing β†’ diverged. + expect( + diverge({ + src: 500, + test: 300, + history: [marker(300, 200, 'true', 1), marker(400, 250, 'true', 2)], + }), + ).toBe('true 2'); + // Current sum EQUAL to the previous round's sum (not shrinking) β†’ diverged. + expect(diverge({ src: 500, test: 300, history: climbing })).toBe('true 3'); + // A transient SPIKE does not raise the bar forever: after sums 350, 1150 + // (spike), 400, a plateau at 400 is still >= the PREVIOUS round (400), so a + // real runaway escalates β€” the window-wide max (1150) would have suppressed + // it for the rest of the window. + expect( + diverge({ + src: 250, + test: 150, + history: [ + marker(200, 150, 'true', 1), + marker(700, 450, 'true', 2), + marker(250, 150, 'true', 3), + ], + }), + ).toBe('true 3'); + // Only 1 prior over-budget round (< threshold) β†’ not diverged yet. + expect( + diverge({ src: 999, test: 999, history: [marker(500, 300, 'true', 1)] }), + ).toBe('false 1'); + // The CURRENT run's own markers (run == GITHUB_RUN_ID) are excluded: a + // re-run of a failed job keeps the run id and its failed attempt already + // posted a marker, which must not count as a PRIOR over-budget round. Here + // run 9999 is the current run; only the genuine prior (run 1001) counts. + expect( + diverge({ + src: 999, + test: 999, + currentRun: 9999, + history: [ + marker(500, 300, 'true', 1, 'W1', { run: 1001 }), + marker(600, 400, 'true', 1, 'W1', { run: 9999 }), + ], + }), + ).toBe('false 1'); + // A retry-doubled marker (same run id) counts ONCE. + expect( + diverge({ + src: 999, + test: 999, + history: [ + marker(500, 300, 'true', 1, 'W1', { run: 1001 }), + marker(500, 300, 'true', 1, 'W1', { run: 1001 }), + ], + }), + ).toBe('false 1'); + // When a run was re-run and posted two markers with DIFFERENT sums, the + // LATEST attempt (by created_at) wins, not jq's stale first: run 1002's + // fresh attempt (sum 300 @ T2) is PREV_SUM, so current 400 >= 300 β†’ + // diverged. Keeping the stale first (sum 900) would read 400 >= 900 β†’ not. + expect( + diverge({ + src: 250, + test: 150, + history: [ + marker(300, 200, 'true', 1, 'W1', { run: 1001 }), + marker(600, 300, 'true', 2, 'W1', { + run: 1002, + at: '2026-01-01T00:01:00Z', + }), + marker(150, 150, 'true', 2, 'W1', { + run: 1002, + at: '2026-01-01T00:02:00Z', + }), + ], + }), + ).toBe('true 2'); + // Two DISTINCT runs that share round= AND a frozen eval watermark (the + // state-triggered conflict lane: a push stamps NEXT_ROUND, the following + // no-op re-stamps the same ROUND, neither NEWEST nor ROUND advances) are + // counted SEPARATELY by their distinct run ids β€” round=/wm alone (the + // pre-fix key) would have collapsed them and stalled the handoff forever. + expect( + diverge({ + src: 500, + test: 300, + history: [ + marker(300, 200, 'true', 2, 'W1', { run: 1001 }), + marker(400, 250, 'true', 2, 'W1', { run: 1002 }), + marker(500, 300, 'true', 2, 'W1', { run: 1003 }), + ], + }), + ).toBe('true 3'); + // "Most recent" is the highest RUN id, not the max sum and not the first: + // prior over-budget sums 900 (run 1) then 500 (run 2, agent shrank), a + // partial regrow to 700 is >= the most-recent 500 β†’ diverged. Comparing + // against the first/max (900) would wrongly suppress it (700 < 900). + expect( + diverge({ + src: 400, + test: 300, + history: [ + marker(600, 300, 'true', 1, 'W1', { run: 1001 }), + marker(300, 200, 'true', 2, 'W1', { run: 1002 }), + ], + }), + ).toBe('true 2'); + // Not over budget THIS round β†’ still counts (accurate trajectory) but no handoff. + expect( + diverge({ + src: 999, + test: 999, + criticalOnlyGrowth: 'false', + history: climbing, + }), + ).toBe('false 3'); + // Prior markers under a DIFFERENT window key don't count. + expect( + diverge({ + src: 999, + test: 999, + history: [ + marker(500, 300, 'true', 1, 'W2'), + marker(600, 400, 'true', 2, 'W2'), + ], + }), + ).toBe('false 0'); + // Markers from a non-bot author don't count. + expect( + diverge({ + src: 999, + test: 999, + history: climbing.map((m) => ({ ...m, user: { login: 'attacker' } })), + }), + ).toBe('false 0'); + // Markers BEFORE a mid-window base update are excluded (re-anchoring makes + // pre-update sums incomparable) β€” only the post-update round remains. + expect( + diverge({ + src: 999, + test: 999, + baseUpdAt: '2026-01-01T12:00:00Z', + history: [ + marker(500, 300, 'true', 1, 'W1', { at: '2026-01-01T06:00:00Z' }), + marker(600, 400, 'true', 2, 'W1', { at: '2026-01-01T06:30:00Z' }), + marker(200, 100, 'true', 3, 'W1', { at: '2026-01-01T18:00:00Z' }), + ], + }), + ).toBe('false 1'); + // A malformed GROWTH_DIVERGENCE_ROUNDS falls back to 2 (the sanitize guard + // at the top of the block) instead of crashing the `-ge` arithmetic: two + // prior over-budget rounds still climbing β†’ diverged. + expect( + diverge({ + src: 500, + test: 300, + div: 'abc', + history: [marker(300, 200, 'true', 1), marker(400, 250, 'true', 2)], + }), + ).toBe('true 2'); + // over=false markers (rounds that pulled back under budget) count neither + // toward OVER_ROUNDS_PRIOR nor as PREV_SUM β€” a one-off overshoot that + // recovered must NOT escalate. Pins the `.over == "true"` filter. + expect( + diverge({ + src: 999, + test: 999, + history: [ + marker(500, 300, 'true', 1), + marker(100, 50, 'false', 2), + marker(90, 40, 'false', 3), + ], + }), + ).toBe('false 1'); + // Writerβ†’reader round-trip: expand EACH real writer echo through bash and + // feed the marker it produces back through the extracted reader, so a + // format drift between writer and reader (the marker is encoded four + // independent times β€” the reader regex + three writers) fails here instead + // of silently inerting the feature. Every writer path is covered, not just + // the push path (a drift in only the no-op or failure suffix would + // otherwise survive green). + const roundTrip = (roundVar) => { + const line = workflow.match( + new RegExp( + `echo ""`, + ), + )?.[0]; + expect(line, `writer for round=${roundVar}`).toBeTruthy(); + const produced = execFileSync( + 'bash', + [ + '-c', + `GROWTH_SRC=500\nGROWTH_TEST=300\nCRITICAL_ONLY_GROWTH=true\n` + + `${roundVar}=2\nGITHUB_RUN_ID=1002\nGROWTH_BASE_WIN=W1\nWINDOW=none\n${line}`, + ], + { encoding: 'utf8' }, + ).trim(); + // Counted as 1 prior over-budget run β€” 0 is what a format mismatch or a + // dead key= (e.g. key=${WINDOW} after a re-arm) would yield. + return diverge({ + src: 999, + test: 999, + history: [ + { + user: { login: 'qwen-code-dev-bot' }, + created_at: '2026-01-01T00:00:00Z', + body: produced, + }, + ], + }); + }; + expect(roundTrip('NEXT_ROUND')).toBe('false 1'); // push path + expect(roundTrip('ROUND')).toBe('false 1'); // no-op path + expect(roundTrip('MARK_ROUND')).toBe('false 1'); // failure/handoff path + rmSync(dir, { recursive: true, force: true }); + + // The report writes the per-round growth-now marker on ALL THREE report + // paths so the history is complete (a no-op round records its size, and a + // timeout/gate-rejection/abort round is not a gap either), each with a + // run= identity the reader dedupes on β€” push stamps NEXT_ROUND, no-op + // ROUND, the failure/handoff report MARK_ROUND. + expect( + workflow.match(/