Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
ebcc88d
feat(autofix): keep the round status comment live during long rounds
wenshao Aug 23, 2026
7fc3fd5
fix(autofix): harden the heartbeat self-exit and pin its behavior
wenshao Aug 23, 2026
e0c4c9c
fix(autofix): fail fast on a token-less heartbeat launch; correct orp…
wenshao Aug 23, 2026
32ac987
fix(autofix): close the heartbeat's token paths at the kill and the g…
wenshao Aug 23, 2026
114af68
fix(autofix): degrade pre-merge rounds past the absent heartbeat script
wenshao Aug 23, 2026
b34aae0
fix(autofix): pin the heartbeat's command resolution off the plantabl…
wenshao Aug 24, 2026
ae2c682
fix(autofix): clear the staged heartbeat plant and re-verify at the l…
wenshao Aug 24, 2026
ccbd641
fix(autofix): clear directory plants at staging; gate the launch witn…
wenshao Aug 24, 2026
0b0279e
Merge branch 'main' into feat/autofix-round-heartbeat
wenshao Aug 24, 2026
3c12d96
Merge branch 'main' into feat/autofix-round-heartbeat
wenshao Aug 24, 2026
895ea67
chore: merge origin/main into feat/autofix-round-heartbeat
wenshao Aug 24, 2026
6cb68dd
fix(ci): record ci.yml's accumulated growth in the size baseline
wenshao Aug 24, 2026
190847f
chore: integrate remote branch fixes (heartbeat hardening rounds)
wenshao Aug 24, 2026
a36c121
fix(ci): exact-size the qwen-autofix.yml baseline line after integration
wenshao Aug 24, 2026
edeb7ed
fix(ci): close round-8 heartbeat findings R8-1/R8-2/R8-3
wenshao Aug 24, 2026
aac8a2a
fix(ci): close round-9 finding R9-1 with an env -i finalize child
wenshao Aug 25, 2026
6022bd4
Merge remote-tracking branch 'origin/main' into feat/autofix-round-he…
wenshao Aug 25, 2026
8375fca
fix(autofix): bound the heartbeat pid-identity read (R10-3)
wenshao Aug 25, 2026
81b311a
fix(autofix): close R10-1/R10-2 on the gate kill block and the finali…
wenshao Aug 25, 2026
9f78012
fix(autofix): mint the hermetic gh config per call (R11-1)
wenshao Aug 25, 2026
31065a6
Merge remote-tracking branch 'origin/main' into feat/autofix-round-he…
wenshao Aug 25, 2026
a9f04a5
fix(autofix): gate the gh-config witness on the env-log it reads (R12-1)
wenshao Aug 25, 2026
538f2db
Merge branch 'main' into feat/autofix-round-heartbeat
wenshao Aug 25, 2026
578748b
Merge branch 'main' into feat/autofix-round-heartbeat
wenshao Aug 26, 2026
62c3bbd
fix(autofix): confirm heartbeat pid lifecycle and drain in-flight tic…
wenshao Aug 26, 2026
9bdf37f
Merge branch 'main' into feat/autofix-round-heartbeat
qwen-code-dev-bot Aug 26, 2026
b287dbb
fix(autofix): env-route heartbeat kill targets, cap override magnitud…
qwen-code-dev-bot Aug 27, 2026
dd5cb59
Merge remote-tracking branch 'origin/main' into feat/autofix-round-he…
qwen-code-dev-bot Aug 27, 2026
7ee39a0
fix(autofix): byte-bound pid/stamp reads, digit-bound overrides, clos…
qwen-code-dev-bot Aug 27, 2026
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
195 changes: 195 additions & 0 deletions .github/scripts/autofix-status-heartbeat.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
#!/usr/bin/env bash
# Live-progress heartbeat for the autofix round status comment.
#
# A review-address round can run for hours (130-minute agent step, 330-
# minute job) while the PR's status comment stays frozen at "working" —
# a healthy long round and a dead one look identical on the PR page.
# 'Post autofix status comment' starts this script as a detached loop;
# every interval it re-PATCHes the SAME status comment with elapsed time
# and last agent activity, and 'Finalize autofix status comment' kills it
# before writing the terminal text. Full rationale → qwen-autofix.md#af-148.
#
# Subcommands:
# body — print the full bilingual working-state comment body to stdout.
# Used for the initial post AND by every loop tick, so the two
# can never drift apart.
# loop — sleep–compose–PATCH until killed or a self-exit bound trips.
#
# Environment (both): HB_ROUND (display round, already +1'd by the step),
# HB_CAP, HB_URL, HB_WORKDIR, HB_START_EPOCH; NOW_EPOCH overrides the
# clock for tests. loop additionally needs: HB_REPO, HB_COMMENT_ID,
# GITHUB_TOKEN for gh, and TRUSTED_PATH (the launcher's stage-time PATH
# capture the tick re-pins; a launch without it fails fast);
# HB_INTERVAL_SECONDS (default 600) and HB_MAX_AGE_SECONDS (default
# 20400) bound the pulse.
#
# Kill contract: the loop writes heartbeat.pid (diagnostics + its own
# self-exit check), checks heartbeat-stop, and exits on either signal or
# when its own age cap trips. The killers target the pid the launch
# recorded in EXPRESSION CONTEXT (steps.post_status.outputs.heartbeat_pid)
# — WORKDIR is sandbox-writable, so no WORKDIR file is ever read as a kill
# target — and kill the pid, its process group, AND its whole session:
# each tick's `timeout 60 gh` subtree runs in its OWN process group
# (coreutils timeout default) under the loop's setsid session, so a
# group/pid kill alone leaves it alive holding the PAT for up to 60s. The
# round's verification gate kills the loop before running any branch code
# on the host; finalize and the always() cleanup kill again.
#
# PAT note: the loop holds the bot PAT in its environment. Its lifetime is
# bounded to the sandboxed agent phase — the agent executes PR content only
# inside the docker sandbox there, so no fork code runs on the host beside
# this loop; the verification gate ends the loop BEFORE the first step that
# runs branch code on the host. Every gh call additionally runs under the
# af-112 hermetic pins (pinned GH_HOST, dropped GH_TOKEN/GH_ENTERPRISE_TOKEN,
# fresh GH_CONFIG_DIR), so a transport reroute planted in the shared HOME's
# gh config cannot intercept the token. See af-148 for the trade.

# -e is deliberately absent: the (( ... < 0 )) clamp guards exit non-zero
# on a false test and are load-bearing here. pipefail matches the sibling
# scripts' house line.
set -uo pipefail

MARKER='<!-- autofix-status -->'

require() {
local name
for name in "$@"; do
if [[ -z "${!name:-}" ]]; then
echo "autofix-status-heartbeat: ${name} is required" >&2
exit 2
fi
done
}

emit_body() {
require HB_ROUND HB_CAP HB_URL HB_WORKDIR HB_START_EPOCH
local now elapsed_min mtime active_min line_en line_zh
now="${NOW_EPOCH:-$(date +%s)}"
Comment thread
wenshao marked this conversation as resolved.
Outdated
elapsed_min=$(( (now - HB_START_EPOCH) / 60 ))
(( elapsed_min < 0 )) && elapsed_min=0
if [[ -f "${HB_WORKDIR}/agent.log" ]]; then
# date -r FILE reads the file's mtime on both GNU and BSD date.
mtime="$(date -r "${HB_WORKDIR}/agent.log" +%s 2>/dev/null || echo "${now}")"
active_min=$(( (now - mtime) / 60 ))
(( active_min < 0 )) && active_min=0
line_en="⏱ Running for ${elapsed_min} min · agent active ${active_min} min ago"
line_zh="⏱ 已运行 ${elapsed_min} 分钟 · agent 最近活动在 ${active_min} 分钟前"
else
line_en="⏱ Running for ${elapsed_min} min · agent starting"
line_zh="⏱ 已运行 ${elapsed_min} 分钟 · agent 准备中"
fi
printf '%s\n\n🔄 **AutoFix is working on this PR** — round %s/%s. [Watch live progress](%s); this round posts its report here when it finishes.\n%s\n\n<details>\n<summary>中文说明</summary>\n\n🔄 **AutoFix 正在处理此 PR** —— 第 %s/%s 轮。[查看实时进度](%s);本轮结束后会在此发布报告。\n%s\n\n</details>' \
"${MARKER}" "${HB_ROUND}" "${HB_CAP}" "${HB_URL}" "${line_en}" \
"${HB_ROUND}" "${HB_CAP}" "${HB_URL}" "${line_zh}"
}

run_loop() {
# Validate EVERYTHING a tick needs, not just the loop's own three: a
# launch missing a body var would otherwise produce an immortal loop
# that never pulses — the exact "healthy round looks dead" failure this
# feature eliminates. Fail fast instead.
require HB_REPO HB_COMMENT_ID HB_WORKDIR HB_ROUND HB_CAP HB_URL HB_START_EPOCH TRUSTED_PATH
# gh auth rides on the step-level GITHUB_TOKEN only: the hermetic pins
# below drop any planted GH_TOKEN/GH_ENTERPRISE_TOKEN (a planted channel
# must not outrank the inline token), so accepting them here would admit
# a launch the pins then leave credential-less — an immortal loop logging
# "PATCH failed" every tick and never pulsing. Fail fast instead.
[[ -n "${GITHUB_TOKEN:-}" ]] || {
echo "autofix-status-heartbeat: GITHUB_TOKEN is required" >&2
exit 2
}
# Binary-resolution channel: the tick resolves its externals (gh,
# timeout, sleep, date, cat — and the mktemp below) by name, and the
# ambient PATH carries same-UID-writable dirs ahead of the system ones
# (the job's own $GITHUB_PATH append puts ${RUNNER_TEMP}/qwen-bin
# there), so a plant in one of them would be resolved by the next tick
# with the PAT in env. Pin PATH from the launcher's step-level
# TRUSTED_PATH instead — the R6-3 doctrine: expression-context
# derived, and step env outranks $GITHUB_ENV plants. post_status pins
# its own PATH the same way before the launch; the loop re-pins so no
# future launcher can hand it an ambient PATH.
# Full rationale → qwen-autofix.md#af-148
export PATH="${TRUSTED_PATH}"
# Hermetic pins for every gh call this loop makes (the af-112 doctrine):
# pinned host, planted tokens dropped, and a fresh empty GH_CONFIG_DIR
# instead of the default ~/.config/gh on the shared attacker-writable
# HOME — its config.yml can carry http_unix_socket, which would deliver
# the tick's Authorization header (the bot PAT) to a planted listener.
local gh_config_dir
if ! gh_config_dir="$(mktemp -d "${RUNNER_TEMP:-/tmp}/autofix-gh-config.XXXXXX")"; then
Comment thread
qwen-code-dev-bot marked this conversation as resolved.
Outdated
echo "autofix-status-heartbeat: could not create a gh config dir" >&2
exit 2
fi
export GH_HOST=github.com
unset GH_ENTERPRISE_TOKEN GH_TOKEN
export GH_CONFIG_DIR="${gh_config_dir}"
# Self-detach from the launching step: log to WORKDIR and never hold the
# step's pipes, or the step would never report completion.
exec >> "${HB_WORKDIR}/heartbeat.log" 2>&1 < /dev/null
echo "$$" > "${HB_WORKDIR}/heartbeat.pid"
local interval="${HB_INTERVAL_SECONDS:-600}"
# Just past the 330-minute job envelope: a live round's loop dies at the
# gate or finalize well inside the job, so only a crash-leftover orphan
# ever reaches the cap — and the cap bounds how long that orphan holds
# the PAT in /proc/<pid>/environ, so it stays tight.
local max_age="${HB_MAX_AGE_SECONDS:-20400}"
# Numeric guards: a malformed or zero override must degrade to the
# defaults, never into a sleep-less busy loop hammering the API.
[[ "${interval}" =~ ^[1-9][0-9]*$ ]] || interval=600

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] R16-2: The HB_INTERVAL_SECONDS / HB_MAX_AGE_SECONDS override guards validate shape only; magnitude is unbounded, and the loop sleeps BEFORE its age check — so a well-formed planted magnitude defeats the pulse and, on the crash-orphan path, the documented PAT-exposure cap. No production launcher sets either variable (launch site, step env and job env all checked: zero setters), which is exactly the carrier profile the script's own NOW_EPOCH guard exists for — per its own comment, such a value can only arrive through an env plant, the same-UID channel this workflow's doctrine pins against elsewhere. Concretely: HB_INTERVAL_SECONDS=99999999999 passes the regex, GNU sleep accepts it, and the loop never wakes again — zero pulses for the whole round, so the status comment stays frozen at "working" (silently reproducing the exact failure this feature exists to eliminate), and the 20400s cap the header documents as the bound on an orphan's PAT window becomes unreachable, so a crash-orphan holds the bot PAT in /proc/<pid>/environ indefinitely. Mirror corner: HB_MAX_AGE_SECONDS=1 passes line 159 and silently kills the pulse after the first 600s sleep. Recorded non-blocking in the round-15 deferral list; this round's probe escalates it.

Witness (real script, both arms identical except the interval; cap=1s, start epoch already 10s past):

plant arm: {"interval":"99999999999","childStdout":"ALIVE_AFTER_3S","selfExitFired":false}
flip arm:  {"interval":"1","childStdout":"EXITED","heartbeatLog":"…self-exit: age 11s exceeds 1s","selfExitFired":true}
regex accepts 99999999999; GNU sleep rc=124 on it

Bound magnitude after the shape guards (or, doctrine-consistently, pin both command-scoped at the launch like the other HB_* vars and keep the script-side caps as depth):

  [[ "${interval}" =~ ^[1-9][0-9]*$ ]] || interval=600
  (( interval <= 3600 )) || interval=600
  [[ "${max_age}" =~ ^[1-9][0-9]*$ ]] || max_age=20400
  (( max_age <= 21600 )) || max_age=20400

Extend the existing degrades malformed interval and age-cap overrides to defaults case in .github/scripts/autofix-status-heartbeat.test.mjs with HB_INTERVAL_SECONDS: '99999999999' and HB_MAX_AGE_SECONDS: '1', asserting the same default-fallback log line — removing the magnitude guard must turn it red.

中文说明

HB_INTERVAL_SECONDS / HB_MAX_AGE_SECONDS 覆盖守卫只校验形状,数值大小不受限,而循环先睡眠再检查年龄上限——因此一个形状合法的植入值就能废掉脉搏,并在崩溃孤儿路径上废掉文档承诺的 PAT 暴露上限。生产环境没有任何启动者设置这两个变量(已检查启动点、步骤 env 与作业 env:零处设置),这正是脚本自己的 NOW_EPOCH 守卫所针对的载体画像——按其自身注释,此类值只能经环境植入到达,即本工作流教义在别处钉防的同 UID 通道。具体地:HB_INTERVAL_SECONDS=99999999999 通过正则、GNU sleep 接受它,循环从此不再醒来——整轮零脉搏,状态评论冻结在 "working"(静默复现本功能要消除的那个失败);文件头承诺的 20400 秒孤儿 PAT 窗口上限变得不可达,崩溃孤儿将在 /proc/<pid>/environ 中无限期持有 bot PAT。镜像角:HB_MAX_AGE_SECONDS=1 通过第 159 行守卫,在第一次 600 秒睡眠后静默杀死脉搏。该项在第 15 轮延后清单中记录为非阻断;本轮探针将其升级。

证据(真实脚本,两臂除 interval 外完全一致;cap=1s,起始 epoch 已超 10 秒):植入臂 ALIVE_AFTER_3SselfExitFired:false;翻转移臂 EXITED、日志 self-exit: age 11s exceeds 1s;正则接受该值,GNU sleep rc=124。

修复:在形状守卫之后加上数值上限(见上方代码块),或按教义一致地在启动处像其他 HB_* 变量一样命令作用域钉住两者、脚本侧上限作为纵深。把 .github/scripts/autofix-status-heartbeat.test.mjs 中现有的 "degrades malformed interval and age-cap overrides to defaults" 用例扩展 HB_INTERVAL_SECONDS: '99999999999'HB_MAX_AGE_SECONDS: '1',断言相同的回退默认日志行——移除数值守卫必须使其变红。

— qwen3.8-max via Qwen Code /review (v0.22.2)

[[ "${max_age}" =~ ^[1-9][0-9]*$ ]] || max_age=20400
local start="${HB_START_EPOCH}"
echo "$(date -u +%FT%TZ) heartbeat started: comment ${HB_COMMENT_ID} interval ${interval}s max_age ${max_age}s"
while :; do
sleep "${interval}"
local now age body
now="$(date +%s)"
age=$(( now - start ))
if (( age > max_age )); then
echo "$(date -u +%FT%TZ) self-exit: age ${age}s exceeds ${max_age}s"
exit 0
fi
# IDENTITY, not existence: WORKDIR is PR-scoped (/tmp/autofix-review-<pr>),
# so after a crashed round's reset the NEXT round recreates heartbeat.pid
# at the same path. An existence check would let the orphaned old loop
# pass and keep PATCHing with its stale launch env, alternating with the
# new round's body on the same comment. The file must still hold THIS
# loop's own pid — removed OR replaced (by a newer round) ends the loop.
# This reads the file to self-identify only; it never kills anything.
if [[ "$(cat "${HB_WORKDIR}/heartbeat.pid" 2> /dev/null)" != "$$" ]]; then
echo "$(date -u +%FT%TZ) self-exit: pid file removed or replaced"
Comment thread
wenshao marked this conversation as resolved.
Outdated
exit 0
fi
if [[ -f "${HB_WORKDIR}/heartbeat-stop" ]]; then
echo "$(date -u +%FT%TZ) self-exit: stop marker present"
exit 0
fi
if ! body="$(emit_body)"; then
echo "$(date -u +%FT%TZ) body composition failed; skipping this tick"
continue
fi
# Best-effort: a transient API failure skips one tick, never the pulse.
# `timeout` bounds the request itself — a black-holed connection must
# not stall the loop past the age cap, which only runs between ticks
# (a stuck gh would hold the PAT forever). `timeout` is coreutils on
# the Linux pool; hosts without it (macOS dev runs) fall back to the
# unbounded call.
GH_PATCH=(gh)
if command -v timeout > /dev/null 2>&1; then
GH_PATCH=(timeout 60 gh)
fi
Comment thread
wenshao marked this conversation as resolved.
Comment thread
wenshao marked this conversation as resolved.
if ! "${GH_PATCH[@]}" api --method PATCH \
"repos/${HB_REPO}/issues/comments/${HB_COMMENT_ID}" \
-f body="${body}" > /dev/null 2>&1; then
echo "$(date -u +%FT%TZ) PATCH failed; continuing"
fi
done
}

case "${1:-}" in
body) emit_body ;;
loop) run_loop ;;
*)
echo "usage: $(basename "$0") {body|loop}" >&2
exit 2
;;
esac
Loading
Loading