Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
46 changes: 44 additions & 2 deletions .github/workflows/qwen-autofix.yml
Original file line number Diff line number Diff line change
Expand Up @@ -1669,10 +1669,10 @@ jobs:
else
gh pr list --repo "${REPO}" --state open --author "${AUTOFIX_BOT}" \
--base main \
--limit 100 --json number,headRefName,isCrossRepository,labels,author,maintainerCanModify > "${WORKDIR}/bot-prs.json"
--limit 100 --json number,headRefName,isCrossRepository,labels,author,maintainerCanModify,updatedAt > "${WORKDIR}/bot-prs.json"
gh pr list --repo "${REPO}" --state open --label "${TAKEOVER_LABEL}" \
--base main \
--limit 100 --json number,headRefName,isCrossRepository,labels,author,maintainerCanModify > "${WORKDIR}/takeover-prs.json"
--limit 100 --json number,headRefName,isCrossRepository,labels,author,maintainerCanModify,updatedAt > "${WORKDIR}/takeover-prs.json"
# Skip-labeled PRs are excluded HERE, not only at the address
# gate: that gate discards without writing a marker, so the
# watermark never advances and an unfiltered scan would re-emit
Expand Down Expand Up @@ -1815,6 +1815,40 @@ jobs:
)
[[ "${BUSY_PRS}" != ' ' ]] && echo "🚧 address in flight/queued for PR(s):${BUSY_PRS}"

# Idle backoff, from the list's own updatedAt (no API call): a
# candidate with no activity for >24h gets inspected on roughly
# every 4th scan instead of every one. The pool doubled in two
# days (28 takeover PRs, 8 of them idle in "nothing new" state
# for 10+ hours), and every idle inspection costs twice: a unit
# of the SHARED MAX_CANDIDATE_INSPECTIONS budget, and a slice of
# the serial API walk over the candidate list — the walk is what
# delayed #8002's first pickup (admitted at 09:09, six minutes
# into the 09:03 scan's serial reads). Idle PRs never reach the
# 10-target budget (the "nothing new" branch continues before
# the TARGETS append), so that cap is NOT what this relieves —
# the win is inspection budget plus scan-walk latency for fresh
# engagements. Safe because comments, reviews, labels, and
# pushes all bump updatedAt or route in real time; the two
# scan-only signals that do NOT bump it — a base conflict
# appearing when main moves, and still-red checks awaiting the
# redcheck marker — wait at most the backoff gap (~30 min) on a
# PR nobody touched in a day, and self-correct (the eventual
# address run comments/pushes). Slotting is deterministic — one
# eligible 10-minute tick in every four, keyed by PR number, the
# same 600s quantum as ROT_OFF — so the gap is bounded at ~30
# minutes and no PR is unlucky forever. The forced-dispatch path
# never builds the list files, so a forced PR is always
# inspected (fail-open, like a PR missing from the set).
IDLE_PRS=' '
if [[ -f "${WORKDIR}/bot-prs.json" && -f "${WORKDIR}/takeover-prs.json" ]]; then
IDLE_CUTOFF="$(date -u -d '24 hours ago' +%Y-%m-%dT%H:%M:%SZ)"
IDLE_PRS=" $(jq -rs --arg cut "${IDLE_CUTOFF}" 'add | unique_by(.number)
| map(select((.updatedAt // "") != "" and .updatedAt < $cut) | .number | tostring)
| join(" ")' \
"${WORKDIR}/bot-prs.json" "${WORKDIR}/takeover-prs.json" 2> /dev/null || echo '') "
fi
IDLE_SLOT_NOW="$(( ($(date -u +%s) / 600) % 4 ))"

TARGETS='[]'
INSPECTED=0
for PR in ${CANDIDATES}; do
Expand All @@ -1825,6 +1859,14 @@ jobs:
fleet_row "${PR}" 'busy' 'address run in flight'
continue
fi
# The idle-backoff skip is free too (a bash substring test on
# the precomputed set, same idiom as the busy skip) — it must
# not consume inspection budget either.
if [[ "${IDLE_PRS}" == *" ${PR} "* && "$(( PR % 4 ))" != "${IDLE_SLOT_NOW}" ]]; then
echo "😴 #${PR}: idle >24h — deferring to its rotation slot (slot $(( PR % 4 )), current ${IDLE_SLOT_NOW}, next eligible tick ≤30m away)"
fleet_row "${PR}" 'idle-backoff' 'idle >24h; inspected roughly every 4th scan (gap ≤30m)'
continue
fi
INSPECTED=$(( INSPECTED + 1 ))
if [[ "${INSPECTED}" -gt "${MAX_CANDIDATE_INSPECTIONS}" ]]; then
echo "🧮 candidate-inspection budget (${MAX_CANDIDATE_INSPECTIONS}) reached — deferring the rest to the next scan"
Expand Down
117 changes: 116 additions & 1 deletion scripts/tests/qwen-autofix-workflow.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -2398,9 +2398,124 @@ describe('qwen-autofix workflow', () => {
// starve the oldest tail forever once the pool exceeds the budget.
expect(reviewScanJob).toContain('ROT_OFF=');
expect(reviewScanJob).toContain('.[$o:] + .[:$o]');
// The free skips (busy, idle-backoff) both live in the loop head,
// BEFORE the budget increment — assert on the slice, not a byte
// distance (comment growth must not red-light CI, and arbitrary code
// between the skips and the increment must).
const loopHead = reviewScanJob.slice(
reviewScanJob.indexOf('for PR in ${CANDIDATES}'),
reviewScanJob.indexOf('INSPECTED=$(( INSPECTED + 1 ))'),
);
expect(loopHead).toContain("'busy'");
expect(loopHead).toContain("'idle-backoff'");
expect(loopHead).not.toContain('INSPECTED=');
});

it('backs off idle candidates without spending inspection budget', () => {
// Measured 2026-07-29: 28 open takeover PRs, 8 idle in "nothing new"
// state for 10+ hours. Every idle inspection costs a unit of the
// shared MAX_CANDIDATE_INSPECTIONS budget and a slice of the serial
// API walk (the walk is what delayed #8002's first pickup by ~6
// minutes). Candidates idle >24h are inspected on roughly every 4th
// scan. The staleness signal is the list's own updatedAt — no API
// call, and no per-candidate process fork either: one jq builds the
// idle SET, and the loop tests membership like the busy skip does.
expect(
(reviewScanJob.match(/--json [^\n]*\bupdatedAt\b/g) ?? []).length,
).toBe(2);
expect(reviewScanJob).toContain(`IDLE_CUTOFF="$(date -u -d '24 hours ago'`);
// Slotting quantum is the SCAN TICK (600s, same quantum as ROT_OFF),
// not the hour: an hourly slot against a */10 cron means 6 back-to-back
// inspections then a ~3h blind window — same 25% average, terrible
// shape. With 600s the gap is bounded at ~30 minutes, which is what
// the operator-facing strings promise.
expect(reviewScanJob).toContain(
'IDLE_SLOT_NOW="$(( ($(date -u +%s) / 600) % 4 ))"',
);
expect(reviewScanJob).not.toMatch(/IDLE_SLOT_NOW[^\n]*\/ 3600/);
expect(reviewScanJob).toContain('gap ≤30m');
// Fail-open on the forced-dispatch path: it never builds the list
// files, so the set stays empty and a forced PR is always inspected.
expect(reviewScanJob).toContain("IDLE_PRS=' '");
expect(reviewScanJob).toMatch(
/BUSY_PRS[\s\S]{0,240}INSPECTED=\$\(\( INSPECTED \+ 1 \)\)/,
/if \[\[ -f "\$\{WORKDIR\}\/bot-prs\.json" && -f "\$\{WORKDIR\}\/takeover-prs\.json" \]\]; then\n\s+IDLE_CUTOFF=/,
);

// Behavioral replay of the set builder + skip predicate (the string
// pins alone cannot catch a flipped comparison or slot equality):
// run the real jq and the real predicate over four candidate shapes.
const setSrc = reviewScanJob.match(
/IDLE_PRS=" \$\(jq -rs[\s\S]*?\) "/,
)?.[0];
expect(setSrc).toBeTruthy();
const runBackoff = ({ pr, updatedAt, slotNow, listed = true }) => {
const dir = mkdtempSync(join(tmpdir(), 'idle-backoff-'));
try {
const row = listed
? [{ number: Number(pr), updatedAt }]
: [{ number: 99999, updatedAt: '2026-01-01T00:00:00Z' }];
writeFileSync(join(dir, 'bot-prs.json'), JSON.stringify(row));
writeFileSync(join(dir, 'takeover-prs.json'), '[]');
return execFileSync(
'bash',
[
'-c',
[
'set -uo pipefail',
`WORKDIR='${dir}'`,
`IDLE_CUTOFF='2026-07-28T12:00:00Z'`,
setSrc,
`IDLE_SLOT_NOW='${slotNow}'`,
`PR='${pr}'`,
'if [[ "${IDLE_PRS}" == *" ${PR} "* && "$(( PR % 4 ))" != "${IDLE_SLOT_NOW}" ]]; then printf skip; else printf inspect; fi',
].join('\n'),
],
{ encoding: 'utf8' },
);
} finally {
rmSync(dir, { recursive: true, force: true });
}
};
// Idle and out of slot → deferred (8001 % 4 = 1).
expect(
runBackoff({ pr: '8001', updatedAt: '2026-07-27T00:00:00Z', slotNow: 0 }),
).toBe('skip');
// Idle but IN its slot → inspected (the gap is bounded).
expect(
runBackoff({ pr: '8001', updatedAt: '2026-07-27T00:00:00Z', slotNow: 1 }),
).toBe('inspect');
// Fresh activity → inspected regardless of slot.
expect(
runBackoff({ pr: '8001', updatedAt: '2026-07-28T13:00:00Z', slotNow: 0 }),
).toBe('inspect');
// Missing from the lookup (forced-dispatch shape) → inspected.
expect(
runBackoff({
pr: '8001',
updatedAt: '2026-07-27T00:00:00Z',
slotNow: 0,
listed: false,
}),
).toBe('inspect');

// Null/empty updatedAt (defensive guard) → inspected, not idle.
expect(runBackoff({ pr: '8001', updatedAt: null, slotNow: 0 })).toBe(
'inspect',
);

// Visible in the fleet dashboard, like the busy skip.
expect(reviewScanJob).toContain("'idle-backoff'");
// The workflow comment must not claim the 10-target cap is relieved —
// idle candidates hit `continue` before the TARGETS append, so they
// never contend for it; the real win is inspection budget + walk
// latency, and the comment says so.
expect(reviewScanJob).toContain(
'Idle PRs never reach the\n # 10-target budget',
);
// The two scan-only signals updatedAt cannot see are named, not
// papered over by an absolute invariant.
expect(reviewScanJob).toContain('base conflict');
expect(reviewScanJob).toContain('still-red checks');
});

it('behaviorally replays the takeover-command toggle across all four paths', () => {
Expand Down
Loading