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:
wenshao 2026-07-18 21:48:14 +08:00
parent 90c37ca53c
commit 1497a7e6fe
2 changed files with 185 additions and 22 deletions

View file

@ -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 \

View file

@ -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