mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-08-05 14:52:00 +00:00
feat(autofix): re-armable round windows, cap raised to 100, visible cap pause
The round counter is DERIVED state, stored nowhere but in the bot's eval-marker comments on the PR — and counting is now windowed by the latest '<!-- takeover-ack engaged -->' comment. Re-engaging (label off→on, or repeating the takeover command on an already-managed PR, which now posts a re-arm ack instead of a silent no-op) starts a fresh window: a PR that exhausted its rounds continues under management with one human action, auditable in the PR timeline. The WATERMARK stays global across windows — feedback already addressed is never replayed — and a PR never taken over has no ack, so strict lifetime counting is unchanged. The prepare-side live round is windowed identically, so pre-reset markers can neither trip the cap nor look like same-ts round-advance duplicates (replay-proven). TAKEOVER_MAX_ROUNDS rises to 100 per maintainer sizing, and pausing at the cap is now VISIBLE on managed PRs: a bilingual notice with re-arm guidance, once per counting window (marker-deduped past the latest re-arm; a failed post retries next scan). 58/58: rearm windowing replay (no ack → lifetime; ack → round 0 with watermark preserved; new rounds count from 1; latest ack wins), the stale-gate re-arm interplay case, cap-selection at 100, and cap-notice dedup pins.
This commit is contained in:
parent
90c37ca53c
commit
1497a7e6fe
2 changed files with 185 additions and 22 deletions
77
.github/workflows/qwen-autofix.yml
vendored
77
.github/workflows/qwen-autofix.yml
vendored
|
|
@ -114,8 +114,9 @@ env:
|
|||
# need dozens of feedback rounds — that is the point of takeover — so the
|
||||
# unattended cap (MAX_ROUNDS) would strangle it. The circuit breaker stays
|
||||
# (a bot↔review-bot ping-pong is still bounded), it is just sized for
|
||||
# explicitly delegated work; removing the label restores the strict cap.
|
||||
TAKEOVER_MAX_ROUNDS: '50'
|
||||
# explicitly delegated work; removing the label restores the strict cap,
|
||||
# and re-engaging opens a fresh counting window (see REARM_TS below).
|
||||
TAKEOVER_MAX_ROUNDS: '100'
|
||||
# Do not claim more issues when too many existing autofix PRs are still open.
|
||||
MAX_OPEN_AUTOFIX_PRS: '5'
|
||||
|
||||
|
|
@ -1154,7 +1155,16 @@ jobs:
|
|||
fi
|
||||
if [[ "${CMD}" == 'add' ]]; then
|
||||
if [[ "${HAS}" == 'true' ]]; then
|
||||
echo "ℹ️ #${PR} already carries ${TAKEOVER_LABEL} — nothing to do"
|
||||
# Already managed: repeating the command is the ROUND-COUNTER
|
||||
# RESET. A fresh engage ack starts a new counting window (only
|
||||
# markers newer than the latest ack count toward the cap), so
|
||||
# a PR that exhausted its rounds continues under management —
|
||||
# no label churn needed. The watermark is untouched: feedback
|
||||
# already addressed is never replayed.
|
||||
gh pr comment "${PR}" --repo "${REPO}" --body "🔄 Takeover re-armed: the round counter starts a fresh window (previous rounds no longer count toward the cap); management continues. / 已重新武装:轮次计数开启新窗口(此前轮次不再计入上限),托管继续。
|
||||
|
||||
<!-- takeover-ack engaged -->"
|
||||
echo "🔄 re-armed ${TAKEOVER_LABEL} window on #${PR}"
|
||||
else
|
||||
gh pr edit "${PR}" --repo "${REPO}" --add-label "${TAKEOVER_LABEL}"
|
||||
echo "🏷️ applied ${TAKEOVER_LABEL} to #${PR}"
|
||||
|
|
@ -1384,14 +1394,27 @@ jobs:
|
|||
# Inserting or reordering a field here or at any write site silently
|
||||
# corrupts round tracking; keep the `ts= acted= round=` order in lockstep.
|
||||
MARKERS="$(jq -c --arg ab "${AUTOFIX_BOT}" '
|
||||
[ .[] | select((.user.login // "") == $ab) | (.body // "")
|
||||
[ .[] | select((.user.login // "") == $ab)
|
||||
| .created_at as $at | (.body // "")
|
||||
| [ scan("<!-- autofix-eval ts=([^ ]+) acted=([^ ]+) round=([0-9]+) -->") ] | .[]
|
||||
| {ts: .[0], round: (.[2] | tonumber)} ]' "${WORKDIR}/ic.json")"
|
||||
| {ts: .[0], round: (.[2] | tonumber), at: $at} ]' "${WORKDIR}/ic.json")"
|
||||
# The watermark is GLOBAL across all markers — feedback already
|
||||
# evaluated stays evaluated, no matter how counting windows move.
|
||||
EVAL_WM="$(jq -r 'map(.ts) | max // ""' <<< "${MARKERS}")"
|
||||
# Highest round across markers, not last-by-ts: a terminal handoff
|
||||
# marker (round = MAX_ROUNDS) must make the scan skip regardless of its
|
||||
# timestamp, and normal rounds increase monotonically anyway.
|
||||
ROUND="$(jq -r 'map(.round) | max // 0' <<< "${MARKERS}")"
|
||||
# ROUND counting is windowed by the latest takeover engage ack:
|
||||
# re-engaging (label off→on, or the command on an already-managed
|
||||
# PR) posts a fresh '<!-- takeover-ack engaged -->' comment, and
|
||||
# only eval markers NEWER than it count toward the cap — that ack
|
||||
# IS the round-counter reset, human-visible and auditable. A PR
|
||||
# never taken over has no ack, so every marker counts (strict
|
||||
# lifetime cap, unchanged behavior). Within a window the highest
|
||||
# round wins, not last-by-ts: a terminal handoff marker must make
|
||||
# the scan skip regardless of its timestamp.
|
||||
REARM_TS="$(jq -r --arg ab "${AUTOFIX_BOT}" '
|
||||
[ .[] | select((.user.login // "") == $ab)
|
||||
| select((.body // "") | contains("<!-- takeover-ack engaged -->"))
|
||||
| .created_at ] | max // ""' "${WORKDIR}/ic.json")"
|
||||
ROUND="$(jq -r --arg rt "${REARM_TS}" 'map(select((.at // "") > $rt)) | map(.round) | max // 0' <<< "${MARKERS}")"
|
||||
|
||||
# Effective watermark = what the agent has actually evaluated (its last
|
||||
# eval marker's newest-feedback timestamp), NOT the last push. A bot
|
||||
|
|
@ -1408,6 +1431,24 @@ jobs:
|
|||
|
||||
if [[ "${ROUND}" -ge "${EFF_MAX_ROUNDS}" ]]; then
|
||||
echo "🚧 #${PR}: hit the round cap (${ROUND}/${EFF_MAX_ROUNDS}) — leaving for a human"
|
||||
# A MANAGED PR pausing at its cap deserves a visible reminder —
|
||||
# maintainers otherwise learn about it only from workflow logs.
|
||||
# Once per counting window: re-arming opens a fresh window and,
|
||||
# if the cap is hit again, a fresh reminder. A failed post
|
||||
# retries naturally on the next scan (marker still absent).
|
||||
if [[ "${HAS_TAKEOVER}" == "true" ]]; then
|
||||
CAP_NOTICED="$(jq -r --arg ab "${AUTOFIX_BOT}" --arg rt "${REARM_TS}" '
|
||||
[ .[] | select((.user.login // "") == $ab)
|
||||
| select((.body // "") | contains("<!-- takeover-cap-reached -->"))
|
||||
| select((.created_at // "") > $rt) ] | length' "${WORKDIR}/ic.json")"
|
||||
if [[ "${CAP_NOTICED}" == "0" ]]; then
|
||||
if ! gh pr comment "${PR}" --repo "${REPO}" --body "⏸️ Takeover paused: this PR reached its round cap (${ROUND}/${EFF_MAX_ROUNDS}). Comment \\`${TAKEOVER_COMMAND}\\` to re-arm a fresh window and continue management, or \\`${TAKEOVER_COMMAND} stop\\` to release. / 托管已暂停:本 PR 达到轮次上限(${ROUND}/${EFF_MAX_ROUNDS})。评论 \\`${TAKEOVER_COMMAND}\\` 可重新武装、开启新窗口继续托管;或评论 \\`${TAKEOVER_COMMAND} stop\\` 释放。
|
||||
|
||||
<!-- takeover-cap-reached -->"; then
|
||||
echo "::warning::cap-paused notice failed for #${PR}; will retry next scan"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
continue
|
||||
fi
|
||||
N_FAILED_CHECKS="$(jq --arg wm "${EFF_WM}" '
|
||||
|
|
@ -1700,10 +1741,20 @@ jobs:
|
|||
# is a stale duplicate and discards itself.
|
||||
STALE='false'
|
||||
LIVE_MARKS="$(jq -r --arg ab "${AUTOFIX_BOT}" '
|
||||
[ .[] | select((.user.login // "") == $ab) | (.body // "")
|
||||
| [ scan("<!-- autofix-eval ts=([^ ]+) acted=([^ ]+) round=([0-9]+) -->") ] | .[] ]' "${WORKDIR}/ic.json")"
|
||||
LIVE_EVAL_WM="$(jq -r 'map(.[0]) | max // ""' <<< "${LIVE_MARKS}")"
|
||||
LIVE_MAX_ROUND="$(jq -r 'map(.[2] | tonumber) | max // 0' <<< "${LIVE_MARKS}")"
|
||||
[ .[] | select((.user.login // "") == $ab)
|
||||
| .created_at as $at | (.body // "")
|
||||
| [ scan("<!-- autofix-eval ts=([^ ]+) acted=([^ ]+) round=([0-9]+) -->") ] | .[]
|
||||
| {ts: .[0], round: (.[2] | tonumber), at: $at} ]' "${WORKDIR}/ic.json")"
|
||||
LIVE_EVAL_WM="$(jq -r 'map(.ts) | max // ""' <<< "${LIVE_MARKS}")"
|
||||
# Round counting mirrors the scan: windowed by the latest takeover
|
||||
# engage ack (the round-counter reset), so a re-engaged PR's stale
|
||||
# pre-reset markers can neither trip the cap nor inflate the live
|
||||
# round here.
|
||||
LIVE_REARM_TS="$(jq -r --arg ab "${AUTOFIX_BOT}" '
|
||||
[ .[] | select((.user.login // "") == $ab)
|
||||
| select((.body // "") | contains("<!-- takeover-ack engaged -->"))
|
||||
| .created_at ] | max // ""' "${WORKDIR}/ic.json")"
|
||||
LIVE_MAX_ROUND="$(jq -r --arg rt "${LIVE_REARM_TS}" 'map(select((.at // "") > $rt)) | map(.round) | max // 0' <<< "${LIVE_MARKS}")"
|
||||
if [[ -n "${LIVE_EVAL_WM}" && "${CONFLICT}" != "true" ]] \
|
||||
&& { [[ "${LIVE_EVAL_WM}" > "${WATERMARK}" ]] || [[ "${LIVE_MAX_ROUND}" -gt "${ROUND}" ]]; }; then
|
||||
LIVE_NEW="$(jq -rs \
|
||||
|
|
|
|||
|
|
@ -313,18 +313,29 @@ describe('qwen-autofix workflow', () => {
|
|||
)?.[1];
|
||||
expect(staleGate).toBeTruthy();
|
||||
const W = '2026-07-18T08:00:00Z';
|
||||
const runStaleGate = ({ marks, conflict, round, reviews = [] }) => {
|
||||
const runStaleGate = ({
|
||||
marks,
|
||||
conflict,
|
||||
round,
|
||||
reviews = [],
|
||||
acks = [],
|
||||
}) => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'autofix-stale-'));
|
||||
try {
|
||||
writeFileSync(
|
||||
join(dir, 'ic.json'),
|
||||
JSON.stringify(
|
||||
marks.map((m) => ({
|
||||
JSON.stringify([
|
||||
...marks.map((m) => ({
|
||||
user: { login: 'qwen-code-dev-bot' },
|
||||
created_at: '2026-07-18T09:00:00Z',
|
||||
created_at: m.at ?? '2026-07-18T09:00:00Z',
|
||||
body: `eval <!-- autofix-eval ts=${m.ts} acted=${m.acted ?? 'true'} round=${m.round} -->`,
|
||||
})),
|
||||
),
|
||||
...acks.map((at) => ({
|
||||
user: { login: 'qwen-code-dev-bot' },
|
||||
created_at: at,
|
||||
body: '🤝 … <!-- takeover-ack engaged -->',
|
||||
})),
|
||||
]),
|
||||
);
|
||||
writeFileSync(join(dir, 'rv.json'), JSON.stringify(reviews));
|
||||
writeFileSync(join(dir, 'rc.json'), '[]');
|
||||
|
|
@ -410,6 +421,18 @@ describe('qwen-autofix workflow', () => {
|
|||
],
|
||||
}),
|
||||
).toBe(false);
|
||||
// Re-armed window: a pre-reset capped marker sits at ts=W, but a later
|
||||
// takeover engage ack opens a fresh counting window — the windowed live
|
||||
// round is 0, so the re-engaged PR's first round (matrix round 0) must
|
||||
// NOT be discarded as a same-ts round-advance duplicate.
|
||||
expect(
|
||||
runStaleGate({
|
||||
marks: [{ ts: W, round: 50, at: '2026-07-18T09:00:00Z' }],
|
||||
acks: ['2026-07-18T10:00:00Z'],
|
||||
conflict: 'false',
|
||||
round: 0,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('falls back to existing issue backlog only when review has no target', () => {
|
||||
|
|
@ -650,7 +673,16 @@ describe('qwen-autofix workflow', () => {
|
|||
// the point of takeover — so the unattended MAX_ROUNDS would strangle
|
||||
// it. The circuit breaker stays, sized for delegated work; removing the
|
||||
// label restores the strict cap on the next scan.
|
||||
expect(workflow).toContain("TAKEOVER_MAX_ROUNDS: '50'");
|
||||
expect(workflow).toContain("TAKEOVER_MAX_ROUNDS: '100'");
|
||||
// Pausing at the cap is VISIBLE on a managed PR — once per counting
|
||||
// window (deduped by marker newer than the latest re-arm), with re-arm
|
||||
// guidance in the body.
|
||||
expect(reviewScanJob).toContain('<!-- takeover-cap-reached -->');
|
||||
expect(reviewScanJob).toContain('Takeover paused');
|
||||
expect(reviewScanJob).toMatch(
|
||||
/CAP_NOTICED=[\s\S]*?contains\("<!-- takeover-cap-reached -->"\)[\s\S]*?> \$rt/,
|
||||
);
|
||||
expect(reviewScanJob).toContain('"${CAP_NOTICED}" == "0"');
|
||||
expect(reviewScanJob).toContain('"${ROUND}" -ge "${EFF_MAX_ROUNDS}"');
|
||||
// The effective cap travels in the matrix target and SHADOWS the
|
||||
// workflow-level MAX_ROUNDS inside the address job, so every round
|
||||
|
|
@ -673,7 +705,7 @@ describe('qwen-autofix workflow', () => {
|
|||
env: {
|
||||
...process.env,
|
||||
MAX_ROUNDS: '5',
|
||||
TAKEOVER_MAX_ROUNDS: '50',
|
||||
TAKEOVER_MAX_ROUNDS: '100',
|
||||
TAKEOVER_LABEL: 'autofix/takeover',
|
||||
},
|
||||
encoding: 'utf8',
|
||||
|
|
@ -681,12 +713,92 @@ describe('qwen-autofix workflow', () => {
|
|||
)
|
||||
.split('\n')
|
||||
.at(-1);
|
||||
expect(cap(['autofix/takeover'])).toBe('50');
|
||||
expect(cap(['autofix/takeover', 'unrelated'])).toBe('50');
|
||||
expect(cap(['autofix/takeover'])).toBe('100');
|
||||
expect(cap(['autofix/takeover', 'unrelated'])).toBe('100');
|
||||
expect(cap([])).toBe('5');
|
||||
expect(cap(['unrelated'])).toBe('5');
|
||||
});
|
||||
|
||||
it('behaviorally resets round counting at the latest takeover engage ack', () => {
|
||||
// The round "counter" is not stored anywhere — it is DERIVED from the
|
||||
// bot's eval-marker comments on the PR, and counting is windowed by the
|
||||
// latest '<!-- takeover-ack engaged -->' comment: re-engaging starts a
|
||||
// fresh window, so a PR that exhausted its rounds continues under
|
||||
// management. The WATERMARK stays global across windows — feedback
|
||||
// already addressed is never replayed. Extract the scan's
|
||||
// MARKERS/REARM_TS/ROUND trio VERBATIM and replay it.
|
||||
const trio = reviewScanJob.match(
|
||||
/(MARKERS="\$\(jq -c[\s\S]*?ROUND="\$\(jq -r --arg rt "\$\{REARM_TS\}"[^\n]*)/,
|
||||
)?.[1];
|
||||
expect(trio).toBeTruthy();
|
||||
const roundOf = (comments) => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'autofix-rearm-'));
|
||||
try {
|
||||
writeFileSync(join(dir, 'ic.json'), JSON.stringify(comments));
|
||||
const out = execFileSync(
|
||||
'bash',
|
||||
[
|
||||
'-c',
|
||||
`WORKDIR='${dir}'\n${trio.replace(/\n {12}/g, '\n')}\nprintf '\\n%s %s' "$ROUND" "$EVAL_WM"`,
|
||||
],
|
||||
{
|
||||
env: { ...process.env, AUTOFIX_BOT: 'qwen-code-dev-bot' },
|
||||
encoding: 'utf8',
|
||||
},
|
||||
);
|
||||
const [round, wm] = out.split('\n').at(-1).split(' ');
|
||||
return { round, wm };
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
};
|
||||
const marker = (round, ts, at) => ({
|
||||
user: { login: 'qwen-code-dev-bot' },
|
||||
created_at: at,
|
||||
body: `<!-- autofix-eval ts=${ts} acted=true round=${round} -->`,
|
||||
});
|
||||
const engageAck = (at) => ({
|
||||
user: { login: 'qwen-code-dev-bot' },
|
||||
created_at: at,
|
||||
body: '🤝 … <!-- takeover-ack engaged -->',
|
||||
});
|
||||
const W = '2026-07-18T08:00:00Z';
|
||||
// No ack → lifetime counting (unchanged strict behavior).
|
||||
expect(roundOf([marker(5, W, '2026-07-18T09:00:00Z')]).round).toBe('5');
|
||||
// Ack after the capped marker → fresh window, round 0 — but the
|
||||
// watermark still carries the old evaluation.
|
||||
const reset = roundOf([
|
||||
marker(5, W, '2026-07-18T09:00:00Z'),
|
||||
engageAck('2026-07-18T10:00:00Z'),
|
||||
]);
|
||||
expect(reset.round).toBe('0');
|
||||
expect(reset.wm).toBe(W);
|
||||
// New rounds inside the window count from 1 again.
|
||||
expect(
|
||||
roundOf([
|
||||
marker(5, W, '2026-07-18T09:00:00Z'),
|
||||
engageAck('2026-07-18T10:00:00Z'),
|
||||
marker(1, '2026-07-18T11:00:00Z', '2026-07-18T11:30:00Z'),
|
||||
]).round,
|
||||
).toBe('1');
|
||||
// The LATEST ack wins: a second re-arm re-opens the window again.
|
||||
expect(
|
||||
roundOf([
|
||||
marker(5, W, '2026-07-18T09:00:00Z'),
|
||||
engageAck('2026-07-18T10:00:00Z'),
|
||||
marker(50, '2026-07-18T11:00:00Z', '2026-07-18T11:30:00Z'),
|
||||
engageAck('2026-07-18T12:00:00Z'),
|
||||
]).round,
|
||||
).toBe('0');
|
||||
// The command job posts the re-arm ack when the label is already
|
||||
// present, and the prepare-side live counting is windowed identically.
|
||||
expect(workflow).toContain('re-armed ${TAKEOVER_LABEL} window');
|
||||
// Marker sites: ack-job body, command re-arm body, scan REARM_TS jq,
|
||||
// prepare LIVE_REARM_TS jq, and the scan's explanatory comment.
|
||||
expect(workflow.split('<!-- takeover-ack engaged -->').length - 1).toBe(5);
|
||||
expect(prepareBranchAndFeedbackStep).toContain('LIVE_REARM_TS');
|
||||
});
|
||||
|
||||
it('behaviorally validates forced targets against author, takeover, and skip', () => {
|
||||
// Extract the forced-PR OK predicate VERBATIM and replay it: the bot's
|
||||
// own PRs pass; a human PR passes only with the takeover label; skip
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue