Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 38 additions & 10 deletions .github/workflows/qwen-autofix.yml
Original file line number Diff line number Diff line change
Expand Up @@ -164,14 +164,27 @@ env:
INFRA_FAILURE_SIGNATURES: 'lost communication with the server|No space left on device|ENOSPC|received a shutdown signal|The runner has received|Failed to initialize container|runner (was|has been) (lost|terminated)|invalid index-pack output|RPC failed'
# Upper bound on review targets emitted per scan (fan-out defense-in-depth;
# excess is logged and deferred to the next scan).
MAX_TARGETS_PER_SCAN: '10'
# TUNABLE WITHOUT A CODE CHANGE: set the repository variable to re-size the
# loop as the takeover pool grows — Settings → Variables, no PR, no deploy.
# The literal here is only the fallback when the variable is unset.
# Why 30: 37 PRs carried autofix/takeover on 2026-08-08, so the previous
# budget of 10 emitted at most 27% of the eligible set per tick and pushed
# the rest a scan further out every time. It must also stay strictly above
# the address matrix's max-parallel or the matrix can never fill, and the
# same-repo candidate pool was 51, so 30 still bounds a pathological
# backlog. RAISE BOTH TOGETHER: this must stay above QWEN_AUTOFIX_MAX_PARALLEL.
MAX_TARGETS_PER_SCAN: '${{ vars.QWEN_AUTOFIX_MAX_TARGETS_PER_SCAN || 30 }}'
# Upper bound on candidates INSPECTED per scan: idle candidates consume
# serial API calls even when they emit nothing, and takeover widens the
# candidate pool. Candidates are inspected NEWEST-first; past the budget
# the oldest tail defers — old quiet PRs are the least likely to hold new
# feedback, and a deferred PR with a live conflict is still picked up by
# the shepherd's conflict lever.
MAX_CANDIDATE_INSPECTIONS: '60'
# TUNABLE WITHOUT A CODE CHANGE, for the same reason as the two above: this
# one gates whether a PR is even LOOKED AT, so it has to grow ahead of the
# candidate pool or the oldest PRs starve. The pool was 51 same-repo open
# PRs on 2026-08-08, still under the 60 fallback.
MAX_CANDIDATE_INSPECTIONS: '${{ vars.QWEN_AUTOFIX_MAX_CANDIDATE_INSPECTIONS || 60 }}'
# Maintainer-facing engagement labels (applying labels requires GitHub
# triage+, so the permission gate is GitHub's own): TAKEOVER opts a PR —
# including a human-authored one — into the loop; SKIP opts any PR out
Expand Down Expand Up @@ -3277,14 +3290,29 @@ jobs:
# Measured at 3, on the scan that selected 7 PRs: the legs started
# 3-at-a-time and each new one began 3-4s after a slot freed, so the
# 7th PR waited 81 minutes for a slot it could have had immediately.
# 5 halves that tail while staying a real bound — the point of the cap
# is that a backlog cannot open an unbounded number of agent runs at
# once, not the specific number. The 300-minute job cap raises the
# worst-case hold to 5 runner-hours per slot (25 across the fleet) and
# holds the per-PR head-write concurrency group for the same window.
# The cap itself does not lengthen the queueing tail above; the raised
# 120-minute budget does, for the PRs that exhaust it.
max-parallel: 5
# 5 halved that tail — the point of the cap is that a backlog cannot
# open an unbounded number of agent runs at once, not the specific
# number.
# TUNABLE WITHOUT A CODE CHANGE: set QWEN_AUTOFIX_MAX_PARALLEL in
# Settings → Variables to re-size the fleet as the takeover pool grows;
# the literal below is only the fallback when the variable is unset.
# Verified on a live runner that `max-parallel` accepts this expression
# and schedules by it — a 6-leg matrix at 3 started 3, then began the
# 4th only once a slot freed.
# Why 20: 37 PRs carried the label on 2026-08-08, so 5 slots served
# ~14% of them at a time and the tail measured at 3 simply reappeared
# at a larger scale. The ecs-qwen fleet is 84 runners, so 20 concurrent
# legs occupy under a quarter of it, and the executed legs sampled that
# day finished in 3-28 minutes.
# The 300-minute job cap puts the worst case at 5 runner-hours per slot
# (100 across the fleet at 20) and holds the per-PR head-write
# concurrency group for the same window. Different PRs never share that
# group, so raising this does not add push contention.
# RAISE BOTH TOGETHER: this must stay strictly below
# MAX_TARGETS_PER_SCAN, or the scan cannot emit enough legs to fill the
# matrix and the extra slots sit idle. A test pins that for the
# fallbacks; for the variables it is an operator invariant.
max-parallel: '${{ fromJSON(vars.QWEN_AUTOFIX_MAX_PARALLEL || 20) }}'
matrix:
target: '${{ fromJSON(needs.review-scan.outputs.targets) }}'
# Serialises every writer of THIS PR's head branch, across workflows.
Expand Down
23 changes: 19 additions & 4 deletions scripts/tests/qwen-autofix-workflow.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -413,11 +413,19 @@ describe('qwen-autofix workflow', () => {
// that it exists AND still binds below MAX_TARGETS_PER_SCAN is pinned by
// 'bounds fleet-wide simultaneity below the per-scan target budget'.
// Asserting the literal number here only detected edits, not breakage.
expect(workflow).toMatch(/max-parallel: \d+/);
expect(workflow).toMatch(
/max-parallel: '\$\{\{ fromJSON\(vars\.QWEN_AUTOFIX_MAX_PARALLEL \|\| \d+\) \}\}'/,
);
// Pathological-backlog bound: the budget BREAKS the candidate loop (so it
// bounds runtime and API usage, not just matrix size), the deferral is
// LOGGED, and the next scan picks up the remainder.
expect(workflow).toContain("MAX_TARGETS_PER_SCAN: '10'");
// Operator-tunable via a repository variable so the loop can be re-sized
// as the takeover pool grows without a code change; the literal is the
// fallback. Both halves are asserted so the knob cannot silently lose
// either its variable or its default.
expect(workflow).toMatch(
/MAX_TARGETS_PER_SCAN: '\$\{\{ vars\.QWEN_AUTOFIX_MAX_TARGETS_PER_SCAN \|\| \d+ \}\}'/,
);
expect(reviewScanJob).toContain(
'deferring the remaining candidates to the next scan',
);
Expand Down Expand Up @@ -1387,10 +1395,17 @@ describe('qwen-autofix workflow', () => {
// raising it to the target budget, both let one backlog open every agent
// run at once — which is the thing the cap exists to prevent, and neither
// would fail any other test.
// Both are repository variables now, so what is checkable here is the
// FALLBACK pair — the values that apply until an operator sets them.
// Keeping the relation true for the fallbacks means an unconfigured repo
// is still correctly bounded; for configured ones it is an operator
// invariant, stated at both definitions.
expect(reviewAddressJob).toContain('matrix:');
const parallel = Number(reviewAddressJob.match(/max-parallel: (\d+)/)?.[1]);
const parallel = Number(
reviewAddressJob.match(/max-parallel:.*?\|\|\s*(\d+)/)?.[1],
);
const targetBudget = Number(
workflow.match(/MAX_TARGETS_PER_SCAN: '(\d+)'/)?.[1],
workflow.match(/MAX_TARGETS_PER_SCAN:.*?\|\|\s*(\d+)/)?.[1],
);
expect(Number.isInteger(parallel)).toBe(true);
expect(parallel).toBeGreaterThan(0);
Expand Down
Loading