From 22bacfe249dd35cba7771571eaaf9d3af5d46973 Mon Sep 17 00:00:00 2001 From: Shaojin Wen Date: Fri, 14 Aug 2026 09:04:49 +0000 Subject: [PATCH] feat(autofix): escalate a non-converging diff to a maintainer handoff (#9104) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #8981's growth brake trims non-Critical feedback once a window's diff grows past budget, but when the growth is Critical-driven (a complex feature whose every fix opens the next fail-open gap the reviewer then flags — e.g. PR #8777, 8 rounds, 13k additions) Critical-only cannot help: the Criticals ARE the growth, so the diff keeps climbing and the agent keeps patching. Two additions on the autofix side: - Feed the growth trajectory to the agent. feedback.md now opens with a "Diff growth this window" section (net src/test vs budget + how many prior rounds were over budget) whenever growth is measured, telling the agent to prefer minimal/subtractive fixes and to read a rising trajectory as a signal to escalate for a split, not add another guard. - Detect divergence and hand off. A new per-round autofix-growth-now marker records each round's growth + over-budget flag; prepare reads the window's history and, once the brake has been over budget for >= GROWTH_DIVERGENCE_ROUNDS prior rounds (default 2, tunable) AND the diff has not shrunk from its worst, injects a "Needs a maintainer's decision — this PR is not converging" block. It is framed as a defer-to-human item, so the address run stops BLOCKED with a handoff (split / accept core + track the tail / redesign) instead of patching again. A diff that is over budget but shrinking, or a one-off overshoot, stays in ordinary Critical-only. SKILL.md documents both blocks. Contract tests pin the knob, run the extracted divergence detector against fixture history (climbing → diverged, shrinking → not, sub-threshold → not, wrong-window → not), and assert the growth-now marker is written on both report paths. --- .github/workflows/qwen-autofix.yml | 131 +++++++ .qwen/skills/autofix/SKILL.md | 18 + scripts/tests/qwen-autofix-workflow.test.js | 385 ++++++++++++++++++++ 3 files changed, 534 insertions(+) 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(/