Skip to content

ci: add host-level cleanup for ECS runners - #11531

Merged
yiliang114 merged 7 commits into
mainfrom
codex/add-ecs-docker-cleanup
Sep 11, 2026
Merged

ci: add host-level cleanup for ECS runners#11531
yiliang114 merged 7 commits into
mainfrom
codex/add-ecs-docker-cleanup

Conversation

@yiliang114

@yiliang114 yiliang114 commented Sep 10, 2026

Copy link
Copy Markdown
Collaborator

What this PR does

Adds a versioned host-maintenance package for dedicated Linux ECS runner hosts: a Docker cleanup command, a systemd oneshot service and daily timer, a seven-day /tmp retention policy, a root installer, and an operations README. The Docker cleanup serializes with both a host lock and the existing Qwen sandbox-daemon lock, removes Qwen CI containers older than 24 hours, and prunes old labeled or dangling images. A failure to remove one corrupt container is reported without aborting the remaining cleanup. The existing systemd tmpfiles timer applies the shorter retention policy to disposable CI test directories. The README documents prerequisites, installation, verification, manual execution, updates, removal, and scope boundaries.

Why it's needed

Workflow cleanup is scoped to an individual job and can be skipped when a job is cancelled, a runner process dies, or an older workflow did not apply current labels. That left stale Qwen containers, snapshots, and hundreds of thousands of test directories accumulating on ECS hosts. The default operating-system policy retained /tmp for 30 days, which is too long for these high-concurrency dedicated CI hosts. The only working Docker timer was maintained manually on one host, so behavior could drift and could not be reproduced during provisioning. This change checks the conservative host-level fallback into the repository while leaving active workspace cleanup and capacity admission out of scope.

Reviewer Test Plan

How to verify

  1. Run bash -n and shellcheck against both shell scripts; both should exit successfully.
  2. On a dedicated Linux ECS runner host with Docker and systemd, run the installer as root, then confirm qwen-docker-cleanup.timer is enabled and has a next trigger with systemctl list-timers qwen-docker-cleanup.timer.
  3. Confirm systemd-tmpfiles --cat-config reports D /tmp 1777 root root 7d, then run systemd-tmpfiles --clean --prefix=/tmp. Entries newer than seven days should remain while older disposable test directories are removed.
  4. Start the Docker cleanup service manually and inspect journalctl -u qwen-docker-cleanup.service. Recent Qwen CI containers and unrelated containers should remain, matching Qwen CI containers older than 24 hours should be removed, and an individual removal failure should produce a warning without stopping the image-prune steps.

Evidence (Before & After)

Before: one ECS host had no host cleanup timer, 24 stale stopped Qwen CI containers, 586 containerd snapshots, approximately 330,000 top-level /tmp entries, and 98% root-filesystem usage. Removing the stale containers and some idle workspaces lowered usage only to 92%. A conservative one-time cleanup of /tmp entries older than seven days then reclaimed approximately 143 GB and lowered usage to 77%. The existing manually installed Docker timer on another host completed its September 10 scheduled run successfully after adopting the same per-container failure isolation. This PR makes both host fallbacks reproducible and reviewable.

Tested on

OS Status
🍏 macOS
🪟 Windows N/A
🐧 Linux

Environment (optional)

macOS: bash -n, shellcheck, and git diff --check passed for the checked-in files. Linux: the exact installer was deployed on one ECS host; the Docker timer is enabled and active, the seven-day tmpfiles policy is loaded, and all 32 runner services remained active. Fleet-wide rollout is not part of this PR.

Risk & Scope

  • Main risk or tradeoff: a matching Qwen CI container older than 24 hours is treated as stale even if its process is still running, and files under /tmp are retained for seven days instead of the operating-system default of 30 days. This package is intended only for dedicated CI runner hosts where those resources are disposable.
  • Not validated / out of scope: containerd lease deletion, runner workspace and package-cache cleanup, disk-pressure admission control, fleet-wide rollout, and non-systemd hosts.
  • Breaking changes / migration notes: none; installation is explicit and does not alter workflow routing.

Linked Issues

Refs #10035

中文说明

本 PR 做了什么

为专用 Linux ECS Runner 宿主机增加一套有版本管理的主机维护组件,包括 Docker 清理命令、systemd oneshot 服务与每日定时器、/tmp 七天保留策略、root 安装脚本和运维 README。Docker 清理过程同时使用宿主机锁和现有的 Qwen sandbox daemon 共享锁,删除超过 24 小时的 Qwen CI 容器,并清理旧的带标签镜像和悬空镜像。单个损坏容器删除失败时只记录警告,不会中止后续清理。系统已有的 systemd tmpfiles 定时器会使用较短的保留策略清理一次性 CI 测试目录。README 说明了前置条件、安装、验证、手动执行、更新、卸载和范围边界。

为什么需要

Workflow 中的清理只覆盖单个 job;当 job 被取消、Runner 进程退出,或旧 workflow 没有使用当前标签时,清理可能不会执行。这会让遗留的 Qwen 容器、snapshot 和数十万个测试目录持续堆积。操作系统默认保留 /tmp 30 天,对这些高并发专用 CI 宿主机过长。此前唯一可用的 Docker 定时器只手工维护在一台宿主机上,行为容易漂移,也无法在新机器初始化时复用。本改动将保守的宿主机兜底逻辑纳入仓库管理;活跃 workspace 清理和容量准入仍不在本 PR 范围内。

Reviewer 测试计划

如何验证

  1. 对两个 shell 脚本运行 bash -nshellcheck,两者都应成功退出。
  2. 在安装了 Docker 和 systemd 的专用 Linux ECS Runner 宿主机上,以 root 执行安装脚本,然后通过 systemctl list-timers qwen-docker-cleanup.timer 确认定时器已启用并存在下次触发时间。
  3. 确认 systemd-tmpfiles --cat-config 输出 D /tmp 1777 root root 7d,然后运行 systemd-tmpfiles --clean --prefix=/tmp。七天以内的条目应保留,超过七天的一次性测试目录应被删除。
  4. 手动启动 Docker 清理服务并查看 journalctl -u qwen-docker-cleanup.service。近期 Qwen CI 容器和无关容器应保留;超过 24 小时且符合规则的 Qwen CI 容器应被删除;单个删除失败应输出警告,但不能阻止镜像清理步骤继续执行。

证据(修改前后)

修改前:一台 ECS 宿主机没有宿主机清理定时器,存在 24 个停止但未删除的 Qwen CI 容器、586 个 containerd snapshot、约 33 万个 /tmp 顶层条目,根文件系统使用率达到 98%。删除遗留容器和部分空闲 workspace 后,磁盘使用率仍为 92%。随后保守清理超过七天的 /tmp 条目,释放约 143 GB,磁盘使用率降至 77%。另一台宿主机上已有的手工 Docker 定时器采用相同的逐容器失败隔离后,于 9 月 10 日按计划成功执行。本 PR 将两类宿主机兜底都变为可复现、可审查的仓库内容。

测试平台

OS 状态
🍏 macOS
🪟 Windows N/A
🐧 Linux

环境(可选)

macOS:针对提交内容运行的 bash -nshellcheckgit diff --check 均通过。Linux:已在一台 ECS 宿主机上部署本 PR 的安装脚本;Docker 定时器已启用并处于 active,七天 tmpfiles 策略已加载,32 个 Runner 服务全部保持 active。全量机器铺开不在本 PR 范围内。

风险与范围

  • 主要风险或取舍:符合命名或标签规则且创建超过 24 小时的 Qwen CI 容器会被视为遗留资源,即使其进程仍在运行;/tmp 文件保留期也会从操作系统默认的 30 天缩短到七天。本组件仅适用于这些资源可丢弃的专用 CI Runner 宿主机。
  • 未验证或不在范围内:删除 containerd lease、清理 Runner workspace 与包缓存、磁盘压力准入、全量机器部署,以及非 systemd 主机。
  • 破坏性变更或迁移说明:无;安装需要显式执行,不会修改 workflow 路由。

关联 Issue

Refs #10035

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
@qwen-code-ci-bot

qwen-code-ci-bot commented Sep 10, 2026

Copy link
Copy Markdown
Collaborator

Qwen Triage finishedview run. See the stage comments in this thread for the result.

Qwen Triage 已完成 —— 查看运行。结果见本线程中的各阶段评论。

@qwen-code-ci-bot

qwen-code-ci-bot commented Sep 10, 2026

Copy link
Copy Markdown
Collaborator

Thanks for the PR — third pass, re-run on bc0ed994.

Template looks good ✓ — every section present, the Tested-on matrix filled in honestly, bilingual body complete.

Problem: observed, not theoretical. #10035 is open and carries the evidence — npm ci dying on TAR_ENTRY_ERROR ENOSPC on ecs-qwen-runner-sg-15, 36 samples at or above 95% between 17:30 and 21:24 while the workspace cleanup timer skipped busy runners. The PR adds its own before/after on top: 24 stale stopped containers, 586 containerd snapshots, ~330k top-level /tmp entries, 98% → 92% after container and workspace removal, then 77% after a seven-day /tmp sweep (~143 GB). That is a measured incident, not a hypothesis.

Direction: aligned. This is the third layer of a coherent stack — #10605 gave the fleet the lock protocol, #11264 gave each job its own reaping, and both evaporate when a job is cancelled or a runner dies. I checked that it isn't a duplicate of what already exists: .github/scripts/check-disk-floor.sh cites the same issue but is a pre-job gate (fail before the heavy step), and e2e.yml / run-e2e-tests.sh / run-release-docker-integration.sh all clean up inside a job. Nothing in the repo covers the host when no job is running. Checking a hand-maintained host timer into the repo so it is reproducible during provisioning is the right move. CHANGELOG: not applicable — CI infrastructure, no user-facing surface.

Size: not applicable. No core paths; 200 additions / 0 deletions across six new files, all under .github/scripts/ecs-runner/. No test or generated files in the diff, so the whole 200 is production lines — well under both the 500 and the 1000 thresholds.

Approach: the scope feels right and the diff is disciplined — six files, all on purpose, no drive-by edits, and the out-of-scope list (containerd leases, runner workspaces, package caches, capacity admission, fleet-wide rollout, non-systemd hosts) is honest about what it doesn't attempt. One question carried over from the first pass and still open: the installer runs by hand on each host, so the drift this PR exists to remove can creep back the next time a host is provisioned. .github/workflows/update-ecs-runner-qwen.yml already runs on the pools with sudo — wiring the installer in there would make the motivation fully true. Fine as a follow-up, but it's the half that closes the loop.

Risk: no elevated risk signals — none of the changed files match the high-risk paths this repo's revert history correlates with.

Also worth saying up front: one of the two blocking findings from my first review rested on a premise that turns out to be wrong. Details in Stage 2, including the correction.

Moving on to code review. 🔍

中文说明

感谢贡献 —— 这是第三轮,基于 bc0ed994 重跑。

模板完整 ✓ —— 各节齐全,测试平台矩阵如实填写,中英双语正文完整。

问题:是已观测到的,不是理论性的。#10035 仍处于 open,并带有证据 —— ecs-qwen-runner-sg-15npm ciTAR_ENTRY_ERROR ENOSPC 失败,17:30 到 21:24 之间有 36 个采样点使用率达到或超过 95%,而当时 workspace 清理定时器正跳过繁忙的 runner。本 PR 在此之上补充了自己的前后对比:24 个已停止但未删除的容器、586 个 containerd snapshot、约 33 万个 /tmp 顶层条目,删除容器和 workspace 后从 98% 降到 92%,再做七天 /tmp 清理后降到 77%(约 143 GB)。这是实测到的事故,不是假设。

方向:一致。这是一个连贯体系的第三层 —— #10605 给了集群锁协议,#11264 给了每个 job 自己的回收逻辑,而 job 被取消或 runner 进程退出时这两者都会失效。我确认了它没有和现有内容重复:.github/scripts/check-disk-floor.sh 引用的是同一个 issue,但它是job 前置闸门(在重步骤之前先失败),而 e2e.yml / run-e2e-tests.sh / run-release-docker-integration.sh 都是在 job 内部清理。仓库里没有任何东西覆盖「没有 job 在跑时」的宿主机状态。把手工维护的宿主机定时器签入仓库、使其在开通机器时可复现,是对的做法。CHANGELOG:不适用 —— 属于 CI 基础设施,没有面向用户的改动。

规模:不适用。未触及核心路径;6 个新文件共 200 行新增 / 0 行删除,全部位于 .github/scripts/ecs-runner/ 下。diff 中没有测试或生成文件,因此这 200 行全部是生产代码行 —— 远低于 500 和 1000 两个阈值。

方案:范围合理,diff 也很克制 —— 6 个文件,全部服务于目标,没有夹带改动,「不在范围内」清单(containerd lease、runner workspace、包缓存、容量准入、全量铺开、非 systemd 主机)如实交代了没做什么。第一轮提出、目前仍未解决的一个问题:安装脚本需要在每台机器上手工执行,所以这个 PR 想要消除的漂移,下次开通机器时仍可能再出现。.github/workflows/update-ecs-runner-qwen.yml 已经带 sudo 在这些机器池上运行 —— 把安装脚本接进去才能让动机完全成立。作为后续 PR 处理没问题,但那是闭环的另一半。

风险:无升级风险信号 —— 变更文件均未命中本仓库回滚历史所关联的高风险路径。

另外先说明一点:我第一轮 review 中两个阻塞性 finding 之一,其前提被证明是错的。详见 Stage 2,包含更正说明。

进入代码审查 🔍

Qwen Code · qwen3.8-max-2026-09-02

Reviewed at bc0ed994f5c54f7ed5b63d4b760bbda29da08a30 · re-run with @qwen-code /triage

@qwen-code-ci-bot

qwen-code-ci-bot commented Sep 10, 2026

Copy link
Copy Markdown
Collaborator

Code review

I wrote down what I'd do before reading the diff, same as last time: a systemd-timer sweep that matches containers on the labels CI already sets, uses Docker's own age filters where it can, takes the shared daemon lock exclusively only around the prune that needs it, never creates state in the runner's home as root, and has an explicit bound on the unit. That is what this head does. The distance between my proposal and the diff is now down to one line.

The four blocking findings from round one

I checked each against bc0ed994 line by line, and against the repo contracts they depend on rather than against my memory of them.

1 · TimeoutStartSec — resolved, and my original rationale was wrong. I said a Type=oneshot unit inherits systemd's 90s DefaultTimeoutStartSec and would be SIGTERM'd mid-prune. It doesn't. From man systemd.service on an Ubuntu runner: "Defaults to DefaultTimeoutStartSec= set in the manager, except when Type=oneshot is used, in which case the timeout is disabled by default." There was never a 90s kill. The explicit TimeoutStartSec=1h is still the right thing to have, for the reason your unit comment now gives — without any bound, a wedged docker call hangs the run forever holding the /run mutex, and every later elapse then skips behind it. So the change stays and the comment is accurate; the finding that prompted it should not have been stated the way I stated it. 1h also clears the script's own timeout 20m budget with room for the inspect loop, which 30min did less comfortably.

2 · Root-owned lock state — resolved properly. mkdir -p is gone; the daemon lock is opened >> (append) so it can never truncate an inode another process holds a flock on; home comes from getent passwd github-runner | cut -d: -f6 with /home/github-runner as the fallback, so a differently-provisioned host no longer silently locks a different file and loses the serialization; and when the lock doesn't exist yet the script logs skipped: and exits 0, leaving CI to create it runner-owned on first use. The comment above it names the actual failure it prevents — exec 9> failing with EACCES under set -e in run-e2e-tests.sh:46, run-release-docker-integration.sh:32, e2e.yml:285 — which is exactly the non-obvious why that earns a comment.

3 · Exclusive lock held for the whole run — resolved. The container reap loop and the dangling prune now run with only the /run host mutex held. The exclusive flock --nonblock 8 is taken immediately before the labelled prune and nothing else. I verified this is the correct side of the protocol rather than just a narrower hold: run-e2e-tests.sh:46-50 takes fd 9 on ${HOME}/.cache/qwen-code-ci/docker-sandbox-daemon.lock shared for the entire step, and its own comment states the contract as "it only keeps the age-based prune (which takes it exclusively, non-blocking) off a daemon with Docker work in flight" — which is precisely the shape e2e.yml:286 uses and now the shape this script uses. The 30-minute starvation cliff documented from run 33637097713 is no longer reachable from a daily sweep.

4 · Dead name branch — resolved. The regex is gone, replaced by two Docker-native filters, and with it the null-.Config.Labels template failure that made the fallback unreachable for exactly the unlabeled containers it existed to catch. Both filters are real in this repo, not plausible-looking: org.qwen-code.ci.sandbox=true is set as BUILD_SANDBOX_FLAGS at e2e.yml:257 and release.yml:548, and --filter 'name=qwen-code-' is the same filter qwen-autofix.yml:782 and :3867 already use for their stale-container sweep.

5–7 · declined with reasons that hold. On 5: you're right that docker container prune skips running containers by design, and reaping a running container older than 24h is the tradeoff the PR states outright. I also checked whether the age gate could be pushed into the filter instead of the loop — it can't, docker ps has no until filter, so the inspect-and-compare loop is load-bearing for that branch. On 6: the skipped: lines landed; the non-zero exit didn't. On 7: agreed that a mock-docker + systemd harness is its own PR, and the precedent (#10605ci-runner-routing.test.mjs, #11264e2e-workflow.test.js) is the right one to follow.

sequenceDiagram
    participant P1 as Timer, 0230 UTC daily
    participant P2 as cleanup script, as root
    participant P3 as run mutex, host-wide
    participant P4 as Docker daemon
    participant P5 as CI job on this host
    participant P6 as sandbox daemon lock
    P1->>P2: elapse
    P2->>P3: flock nonblock, exclusive
    P3-->>P2: held, or skipped and exit 0
    P2->>P4: reap labelled and qwen-code- named containers older than 24h
    P2->>P4: prune dangling images older than 24h
    P5->>P6: holds shared for its whole step
    P2->>P6: flock nonblock, exclusive
    P6-->>P2: free, or skipped and exit 0
    P2->>P4: prune labelled images, timeout 20m
Loading

The lock boundary is the thing to look at: everything above the daemon-lock line runs while CI jobs are free to proceed, and only the labelled prune contends with them.

What I'd still change

The seven-day policy doesn't reach /var/tmp, which is where this repo deliberately puts its disposable CI temp dirs. qwen-ci-tmp.conf sets one line, D /tmp 1777 root root 7d. But ci.yml:750, :1623, :1795 and run-e2e-tests.sh:96 all do QWEN_CI_TMPDIR="$(mktemp -d /var/tmp/qwen-ci-XXXXXX)", under the comment "Keep Linux temp paths real and short on disk-backed /var/tmp" — and integration-tests/cli/qwen-serve-streaming.test.ts:105 says the quiet part outright: "a Ctrl-C, --bail, or CI timeout leaks the fixture dir. /var/tmp leaks the same way — the leak is relocated somewhere harmless, not eliminated." I read the packaged policy on an Ubuntu runner to check what covers that: /usr/lib/tmpfiles.d/tmp.conf ships D /tmp 1777 root root 30d and #q /var/tmp 1777 root root 30d — the /var/tmp line is commented out upstream, so nothing age-cleans /var/tmp on these hosts today, before or after this PR. So the description's "the existing systemd tmpfiles timer applies the shorter retention policy to disposable CI test directories" overstates coverage: the CI temp dirs the repo names explicitly are the ones it doesn't reach. One line, D /var/tmp 1777 root root 7d, closes it, and I verified there's no competing packaged directive to lose by adding it. Not a blocker — the /tmp policy is correct on its own terms and the 330k-entry / 143 GB evidence is about /tmp — but it's the cheapest remaining win in the diff.

Smaller, take or leave:

  • Installing as /etc/tmpfiles.d/tmp.conf shadows the distro's file wholesale. Nothing is lost today (I read it — one active line plus a comment), but a name that sorts after tmp.conf, e.g. zz-qwen-ci-tmp.conf, wins the /tmp directive by tmpfiles.d's last-name-wins rule without owning the distro file, so a future packaged update isn't silently frozen on the fleet.
  • On a congested host the labelled image prune can skip every single day. CI jobs hold that lock shared for a whole step, so flock --nonblock 8 || exit 0 means a busy 02:30 elapse does nothing but log. I think this is still the right call — a bounded wait reintroduces exactly the starvation run-e2e-tests.sh:39-45 documents, and the reap plus the dangling prune still make progress outside the lock — but images are the largest consumer, so it's worth grepping the journal for a run of consecutive skipped: sandbox daemon lock busy lines after the first week rather than assuming the daily window is free. Your evidence for this path is one scheduled run on one host.
  • The running-container reap deviates from a rule this repo has written down absolutely. qwen-autofix.md §11: "a RUNNING qwen-code-* container can belong to a job executing on another registration of this same host — reaping it would destroy a live sandbox mid-run. Reap only provably-dead containers (exited/dead)", which is why qwen-autofix.yml:782 filters status=exited/status=dead. This script uses docker ps --all with no status filter and docker rm --force. I checked whether your 24h gate makes that safe rather than taking the README's word: the largest timeout-minutes anywhere in .github/workflows is 345, so no legitimate job can own a container older than 24 hours — a running one that old is definitionally leaked. The deviation is sound. But §11 is phrased as an invariant and the next reader will find it before they find your reasoning, so a pointer from the script comment to why 24h is safe here (max job timeout is under 6h) would stop someone "fixing" this back into missing the leak it exists to catch.
  • The label branch rests on containers inheriting image labels, and nothing in CI pins that. It's standard Docker semantics and you verified it locally, but if it were ever wrong the branch matches nothing and exits 0 — the same silent-success shape as the skip paths above. That's the one assumption in the matching logic worth a test when the harness PR lands.

Testing evidence

Unattended CI run — I executed nothing from this PR: no bash -n, no shellcheck, no installer, no unit. Everything below is the PR's own CI read through the API for bc0ed994f5c54f7ed5b63d4b760bbda29da08a30, plus static reading of the surrounding scripts and the host's packaged systemd config.

The one lane that genuinely touches the new files is green, and I checked the step rather than the job: inside Lint & Static (ubuntu-latest, Node 22.x), step 24 Run shellcheckcompleted/success and step 26 Run Prettiercompleted/success. Discovery in scripts/lint.js is git ls-files | grep -E '^([^.]+|.*\.(sh|zsh|bash))' | xargs file --mime-type | grep text/x-shellscript, so both new .sh files were linted at --enable=all --severity=style --check-sourced — stricter than a plain local shellcheck. That independently substantiates test-plan item 1, and it closes the gap the second CHANGES_REQUESTED review on this PR disclosed ("shellcheck is not installed"). Prettier covers the new README and passed.

The one red check is bot orchestration, not PR CI, and I classified it from the check's identity and wall time rather than from anything in its log: review-pr ran 14:58:29Z → 17:59:19Z, about three hours, and this thread carries the matching <!-- qwen-review-fallback --> comment — "Qwen review timed out after 10800 seconds (of the 180-minute budget)." Nothing in this diff can cause it: the PR touches only .github/scripts/ecs-runner/, which no test lane executes. The eight skipped checks (Test (macos-latest), Test (windows-latest), Integration Tests (CLI, No Sandbox), review-config, precheck-pr, resolve-pr, publish-resolution, ack-review-request) are profile-gated, not failures.

CI results for bc0ed994 at review time:

Check Conclusion
review-pr ❌ failure
Classify PR ✅ success
Desktop Shell (ubuntu-22.04) ✅ success
Desktop Shell (windows-2022) ✅ success
Integration Tests (no-AK, No Sandbox) ✅ success
Lint & Static (ubuntu-latest, Node 22.x) ✅ success
Remind on force-push ✅ success
Test (ubuntu-latest, Node 22.x) ✅ success
assign ✅ success
authorize ✅ success
delay-automatic-review ✅ success
fallback-comment ✅ success
label ✅ success
web-shell E2E Smoke (ubuntu-latest, Node 22.x) ✅ success

One row per check name at review time; 8 skipped checks omitted; failures sort first. / 每行一个检查名,已省略 8 个 skipped 检查,失败项排在最前。

The honest limit of all that green: the suite does not pin this change. Nothing in CI installs a unit, starts a timer, holds the daemon lock, or reads the tmpfiles policy — Test and Integration Tests would pass identically with the diff reverted. Only Run shellcheck and Run Prettier actually execute against these files.

On the sandboxed lanes: I'm not going to name one, because neither applies, and saying so is more useful than a hollow trigger line. @qwen-code /verify does A/B load-bearing proof against the base build with wire-oracle harnesses — there is no buildable TypeScript behaviour here to A/B. @qwen-code /tmux drives the TUI — there is no TUI surface. The central claim is host-level systemd and Docker behaviour as root, which is only observable on a dedicated ECS runner. The evidence that would settle it is a journalctl -u qwen-docker-cleanup.service excerpt from the first scheduled 02:30 elapse on an installed host — showing the reap counts, whether the labelled prune acquired the lock or skipped, and the df delta — not a manual systemctl start. The deployment results in the description are the author's claim on one host, not something this run re-executed.

中文说明

代码审查

我在读 diff 之前先写下了自己会怎么做,和上次一样:一个由 systemd timer 驱动的清理,用 CI 已经在打的 label 来匹配容器,能用 Docker 自带的时间过滤器就用,只在需要的那次 prune 周围以排他方式获取共享的 daemon 锁,绝不以 root 身份在 runner 的 home 下创建状态,并且给 unit 一个明确的时间上限。当前 head 就是这么做的。我的方案和这份 diff 之间的差距,现在只剩一行。

第一轮的四个阻塞性 finding,我逐行对照 bc0ed994 核验,并且是拿它们所依赖的仓库契约来核,而不是凭记忆:

1 · TimeoutStartSec —— 已解决,而且我原来的判断依据是错的。 我说 Type=oneshot 会继承 systemd 的 90s DefaultTimeoutStartSec、会在 prune 中途被 SIGTERM 杀掉。事实并非如此。Ubuntu runner 上的 man systemd.service 写明:默认取 manager 的 DefaultTimeoutStartSec=但使用 Type=oneshot 时除外,此时超时默认被禁用。从来不存在 90s 被杀的问题。显式的 TimeoutStartSec=1h 仍然是该有的,理由正是你 unit 注释里现在写的那个 —— 没有任何上限时,一次卡死的 docker 调用会永久挂住这次运行并一直持有 /run 里的互斥锁,之后每一次触发都会因此被跳过。所以这个改动该保留,注释也是准确的;但促成它的 finding 不该按我当时的说法成立。1h 也比 30min 更宽裕地覆盖了脚本自身的 timeout 20m 预算加上 inspect 循环。

2 · root 所有的锁状态 —— 已妥善解决。 mkdir -p 去掉了;daemon 锁改用 >>(追加)打开,因此绝不会截断另一个进程正持有 flock 的 inode;home 由 getent passwd github-runner | cut -d: -f6 推导,回退 /home/github-runner,所以 differently-provisioned 的机器不会静默锁到另一个文件上而丢掉串行化;锁文件尚不存在时打 skipped: 并 exit 0,交给 CI 首次使用时以 runner 身份创建。上方的注释点明了它防的是哪个真实故障 —— run-e2e-tests.sh:46run-release-docker-integration.sh:32e2e.yml:285exec 9>set -e 下 EACCES 失败 —— 这正是「why 不明显因而值得写注释」的情形。

3 · 整轮持有排他锁 —— 已解决。 容器回收循环和 dangling prune 现在只持有 /run 里的宿主机互斥锁;排他的 flock --nonblock 8 只在 labelled prune 之前获取,别处不再持有。我核验了这是协议的正确一侧,而不只是「持有范围变窄」:run-e2e-tests.sh:46-50共享方式在整个 step 期间持有 fd 9(${HOME}/.cache/qwen-code-ci/docker-sandbox-daemon.lock),其自身注释把契约写成「它只是让基于时间的 prune(以排他、非阻塞方式获取该锁)避开有 Docker 工作正在进行中的 daemon」—— 这恰好是 e2e.yml:286 用的形态,现在也是本脚本用的形态。run-e2e-tests.sh 中记录的 run 33637097713 那个 30 分钟饥饿悬崖,从每日清理这条路已经走不到了。

4 · 失效的 name 分支 —— 已解决。 正则去掉了,换成两个 Docker 原生过滤器;随之消失的还有 .Config.Labels 为 null 导致模板失败的问题 —— 那个 bug 让回退分支对「它本来就是为了兜住的无 label 容器」恰好不生效。两个过滤器在本仓库都是真实存在的,不是看着像:org.qwen-code.ci.sandbox=truee2e.yml:257release.yml:548 作为 BUILD_SANDBOX_FLAGS 设置,--filter 'name=qwen-code-'qwen-autofix.yml:782:3867 现有的遗留容器清理用的是同一个过滤器。

5–7 · 未采纳,理由成立。 关于 5:你说得对,docker container prune 按设计跳过运行中的容器,而回收运行超过 24h 的容器正是本 PR 明确写出的取舍。我也核实了能否把时间门槛下推到过滤器里从而省掉循环 —— 不行,docker ps 没有 until 过滤器,所以对这个分支来说 inspect-比较循环是必要的。关于 6:skipped: 日志加上了,非零退出没加。关于 7:同意 mock-docker + systemd 的测试基建应该是单独一个 PR,参照的先例(#10605ci-runner-routing.test.mjs#11264e2e-workflow.test.js)也是对的那个。

上面的时序图里,该盯的是锁边界:daemon 锁那条线以上的一切都可以在 CI job 正常推进的同时运行,只有 labelled prune 会与之竞争。

我仍然会改的地方

七天保留策略没有覆盖 /var/tmp,而这个仓库恰恰是刻意把一次性 CI 临时目录放在那里的。 qwen-ci-tmp.conf 只有一行 D /tmp 1777 root root 7d。但 ci.yml:750:1623:1795run-e2e-tests.sh:96 都执行 QWEN_CI_TMPDIR="$(mktemp -d /var/tmp/qwen-ci-XXXXXX)",注释写的是「Keep Linux temp paths real and short on disk-backed /var/tmp」;而 integration-tests/cli/qwen-serve-streaming.test.ts:105 直接把话说透了:「Ctrl-C、--bail 或 CI 超时都会泄漏 fixture 目录。/var/tmp 也一样会泄漏 —— 泄漏只是被挪到了一个无害的地方,并没有被消除。」我读了 Ubuntu runner 上的发行版策略来确认那边有什么在管:/usr/lib/tmpfiles.d/tmp.conf 里是 D /tmp 1777 root root 30d#q /var/tmp 1777 root root 30d —— /var/tmp 那行在上游就是注释掉的,所以无论本 PR 之前还是之后,这些机器上根本没有任何机制按时间清理 /var/tmp。因此 PR 描述里「现有 systemd tmpfiles 定时器会把更短的保留策略应用到一次性 CI 测试目录」这句是高估了覆盖面:仓库明确点名的那些 CI 临时目录,恰好是它管不到的。加一行 D /var/tmp 1777 root root 7d 就能补上,我确认过加上它不会因此丢掉任何有冲突的发行版指令。这不是阻塞项 —— /tmp 策略本身是对的,33 万条目 / 143 GB 的证据也是关于 /tmp 的 —— 但它是 diff 里剩下的最便宜的一个收益。

更小的几点,采纳与否都行:

  • 安装成 /etc/tmpfiles.d/tmp.conf 会整体遮蔽发行版的同名文件。今天没有丢东西(我读过 —— 一行生效指令加一行注释),但用一个排序在 tmp.conf 之后的名字(例如 zz-qwen-ci-tmp.conf),可以按 tmpfiles.d 的「名字靠后者胜」规则赢下 /tmp 这条指令,同时不接管发行版文件,将来发行版更新也不会在集群上被静默冻结。
  • 在拥挤的机器上,labelled image prune 可能天天被跳过。CI job 会在整个 step 期间共享持有那把锁,所以 flock --nonblock 8 || exit 0 意味着一次繁忙的 02:30 触发除了打日志什么都不做。我认为这仍然是对的选择 —— 有上限的等待会重新引入 run-e2e-tests.sh:39-45 记录的那种饥饿,而回收循环和 dangling prune 在锁外仍然有进展 —— 但镜像是最大的空间消耗者,所以第一周之后值得在 journal 里搜一下是否出现连续的 skipped: sandbox daemon lock busy,而不是假定每天那个时间窗口是空的。这条路径上你的证据是一台机器上的一次计划运行。
  • 回收运行中容器的做法,偏离了本仓库已经写成绝对规则的一条约定。qwen-autofix.md §11:「一个运行中qwen-code-* 容器可能属于同一台机器上另一个 registration 正在执行的 job —— 回收它会摧毁一个正在进行中的 sandbox。只回收可证明已死亡的容器(exited/dead)」,这也是 qwen-autofix.yml:782 过滤 status=exited/status=dead 的原因。本脚本用的是不带 status 过滤的 docker ps --alldocker rm --force。我没有只信 README 的说法,而是核实了你的 24h 门槛是否让它安全:.github/workflows 中最大的 timeout-minutes345,所以没有任何合法 job 能拥有一个超过 24 小时的容器 —— 那么老的运行中容器按定义就是泄漏。这个偏离是站得住的。但 §11 是以不变量的口吻写的,下一个读者会先看到它、再看到你的推理,所以从脚本注释里指一句「为什么这里 24h 是安全的」(job 超时上限不到 6 小时)可以避免有人把它「修」回去、反而漏掉它本来要抓的泄漏。
  • label 分支依赖「容器继承镜像 label」这一点,而 CI 里没有任何东西钉住它。这是标准的 Docker 语义,你也本地验证过,但如果哪天它不成立,这个分支就什么都匹配不到并 exit 0 —— 和上面那些跳过路径一样是静默成功的形态。这是匹配逻辑里唯一值得在测试基建 PR 落地时补一个测试的假设。

测试证据

这是无人值守的 CI 运行 —— 我没有执行本 PR 的任何内容:没有 bash -n、没有 shellcheck、没有安装脚本、没有单元测试。下面全部是通过 API 读取的、针对 bc0ed994f5c54f7ed5b63d4b760bbda29da08a30 的 PR 自身 CI 结果,加上对周边脚本和宿主机发行版 systemd 配置的静态阅读。

真正覆盖到新文件的只有一条 lane,它是绿的,而且我核的是 step 不是 job:在 Lint & Static (ubuntu-latest, Node 22.x) 内部,step 24 Run shellcheckcompleted/success,step 26 Run Prettiercompleted/successscripts/lint.js 的发现逻辑是 git ls-files | grep -E '^([^.]+|.*\.(sh|zsh|bash))' | xargs file --mime-type | grep text/x-shellscript,所以两个新 .sh 文件都以 --enable=all --severity=style --check-sourced 被检查过 —— 比本地跑一次普通 shellcheck 更严格。这独立地证实了测试计划第 1 条,也补上了本 PR 第二个 CHANGES_REQUESTED review 所披露的缺口(「shellcheck is not installed」)。Prettier 覆盖了新增的 README 并通过。

唯一的红色检查是机器人编排任务,不是 PR 的 CI;我是根据检查本身的身份和耗时来分类的,而不是根据它日志里的任何文字:review-pr 运行于 14:58:29Z → 17:59:19Z,约三小时,本线程里有对应的 <!-- qwen-review-fallback --> 评论 ——「Qwen review timed out after 10800 seconds (of the 180-minute budget)」。本 diff 不可能导致它:PR 只改了 .github/scripts/ecs-runner/,没有任何测试 lane 会执行它。八个 skipped 检查(Test (macos-latest)Test (windows-latest)Integration Tests (CLI, No Sandbox)review-configprecheck-prresolve-prpublish-resolutionack-review-request)是被 profile 门控跳过,不是失败。

上面这些绿色结果的诚实上限是:测试套件并没有钉住这个改动。 CI 里没有任何环节会安装 unit、启动 timer、持有 daemon 锁或读取 tmpfiles 策略 —— 把 diff 回滚掉,TestIntegration Tests 也会一样通过。真正对这几个文件执行的只有 Run shellcheckRun Prettier

关于沙箱验证 lane:我不点名,因为两个都不适用,把这点说清楚比给一句空洞的触发指令更有用。@qwen-code /verify 做的是相对 base 构建的 A/B 承载性证明、配合 wire-oracle harness —— 这里没有可做 A/B 的 TypeScript 行为。@qwen-code /tmux 驱动的是 TUI —— 这里没有 TUI 界面。核心主张是宿主机层面以 root 运行的 systemd 与 Docker 行为,只能在专用 ECS runner 上观察。能真正确认它的证据,是一台已安装的机器上首次计划触发(02:30)的 journalctl -u qwen-docker-cleanup.service 摘录 —— 显示回收数量、labelled prune 是拿到了锁还是被跳过、以及 df 的变化 —— 而不是一次手工 systemctl start。描述里的部署结果是作者在一台机器上的陈述,本次运行没有重新执行。

Qwen Code · qwen3.8-max-2026-09-02

Reviewed at bc0ed994f5c54f7ed5b63d4b760bbda29da08a30 · re-run with @qwen-code /triage

@qwen-code-ci-bot

qwen-code-ci-bot commented Sep 10, 2026

Copy link
Copy Markdown
Collaborator

Confidence: 4/5 — the four blocking findings are genuinely fixed, verified against the repo's own lock protocol rather than against my memory of it, and what remains is one-line coverage and documentation, not correctness.

Stepping back on the third pass. My independent proposal and this diff have converged to the point where the only substantive difference left is a path I'd add to qwen-ci-tmp.conf. That's not me running out of objections — I went looking, and the two things I expected to still be broken aren't. The lock handling is now the same shape e2e.yml and run-e2e-tests.sh already use, and I confirmed against the contract comment at run-e2e-tests.sh:36-45 that it's on the correct side of the #10605 protocol rather than merely holding the lock for less time. The root-owned-state failure is gone by construction, not by permission-fiddling: the script refuses to create the lock at all and lets CI own it, which is the version of the fix that stays correct on a host nobody has provisioned yet.

I also have to correct myself, because the correction changes what you should conclude from round one. My finding 1 asserted that a oneshot unit inherits systemd's 90s start timeout. man systemd.service says the opposite — the start timeout is disabled by default for Type=oneshot. There was no mid-prune SIGTERM. You changed 30min to 1h and wrote a comment explaining the real reason a bound is needed (a wedged docker call otherwise hangs forever holding the /run mutex and every later elapse skips). That comment is correct and it's the kind of thing that stops the next person from deleting the line as redundant — so the code is better for the exchange, but the finding that started it was wrong and you shouldn't read the original 2/5 as having been about this.

What I'd still want, none of it blocking: the /var/tmp line, because this repo puts its disposable CI temp dirs there on purpose and its own integration test comment says the leak is "relocated somewhere harmless, not eliminated" — the seven-day policy as written doesn't reach the directories the description claims it does; and a pointer from the reap loop to why 24 hours is safe, since qwen-autofix.md §11 states "reap only provably-dead containers" as an invariant and your deliberate deviation from it is sound (no job in .github/workflows has a timeout-minutes above 345) but currently only explained in the README. The silent-success theme from round one is thinner but not gone: skipped: lines landed, and every remaining failure mode — lock busy, lock absent, a prune that fails, a label assumption that stops holding — still exits 0 and reports a healthy unit. For a fallback whose entire job is being the last line of defence, that's the thing I'd curse in six months, and it's exactly what a non-zero exit on prune failure plus the journal grep I mentioned would address.

The green CI is real but narrow, and I don't want the table above overselling it. Run shellcheck and Run Prettier executed against these files and passed, at --enable=all --severity=style, which closes the gap the second review disclosed. Everything else in the suite would pass identically with the diff reverted, because nothing in CI installs a unit or holds a lock. The only red check is review-pr, which is bot orchestration that hit its own three-hour budget — not this PR. So the evidence that actually matters here is the one thing this run cannot produce: a scheduled elapse on an installed host. Your deployment on one ECS host is the author's claim and I did not re-run it; the first automatic 02:30 run's journal is what would settle it.

On the direction question I raised twice and is still open: the installer is manual, so the drift this PR exists to remove can return at the next provisioning. Wiring it into update-ecs-runner-qwen.yml is the half that makes the motivation fully true, and it's fine as a follow-up — but it's a follow-up, not a maybe.

Approving, pinned to bc0ed994. The standing CHANGES_REQUESTED on this PR is mine from earlier commits, and this supersedes it. My approval is one vote and merges nothing — the call stays with you and the other maintainers. Findings 5–7 you declined with reasons I checked and accept, and the four items above are yours to take or defer; none of them is a reason to hold the branch.

中文说明

Confidence: 4/5 —— 四个阻塞性 finding 确实修好了,而且是拿仓库自身的锁协议核验的,不是凭我的记忆;剩下的是一行覆盖面和文档说明的问题,不是正确性问题。

第三轮退一步看。我自己的方案和这份 diff 已经收敛到只剩一处实质差异:我想在 qwen-ci-tmp.conf 里加一条路径。这不是我没意见可提了 —— 我是去找过的,而我原以为还会是坏的那两处都不是。锁的处理现在和 e2e.ymlrun-e2e-tests.sh 已经在用的形态一致,我对照 run-e2e-tests.sh:36-45 的契约注释确认了它站在 #10605 协议的正确一侧,而不只是「持有锁的时间变短了」。root 所有状态那个故障是从构造上消除的,不是靠调权限:脚本干脆拒绝创建那把锁,交给 CI 去拥有 —— 这才是那种在一台尚未被任何人开通的机器上依然正确的修法。

我也必须更正自己,因为这个更正会改变你对第一轮的结论。我的 finding 1 断言 oneshot unit 会继承 systemd 的 90s 启动超时。man systemd.service 说的是相反的 —— 对 Type=oneshot,启动超时默认是禁用的。从来不存在 prune 中途被 SIGTERM 的问题。你把 30min 改成 1h,并写了一条注释说明真正需要上限的理由(否则一次卡死的 docker 调用会永久挂住并一直持有 /run 互斥锁,之后每次触发都被跳过)。那条注释是对的,而且正是那种能阻止下一个人把这行当冗余删掉的东西 —— 所以这轮往复让代码变好了,但引发它的那个 finding 是错的,你不该把最初那个 2/5 理解成是在说这件事。

我仍然希望改的、但都不阻塞:/var/tmp 那一行 —— 因为这个仓库是刻意把一次性 CI 临时目录放在那里的,而它自己的集成测试注释写着泄漏是「被挪到了一个无害的地方,并没有被消除」;按现在写法,七天策略管不到描述里声称它能管到的那些目录。以及从回收循环指一句「为什么 24 小时是安全的」—— qwen-autofix.md §11 把「只回收可证明已死亡的容器」写成了不变量,而你对它的刻意偏离是站得住的(.github/workflows 里没有任何 job 的 timeout-minutes 超过 345),只是目前只在 README 里解释。第一轮那个「静默成功」的主题变薄了但没消失:skipped: 日志加上了,而剩下的每一种失败模式 —— 锁忙、锁不存在、prune 失败、label 假设不再成立 —— 仍然 exit 0 并汇报一个健康的 unit。对一个「全部职责就是充当最后一道防线」的兜底来说,这是六个月后我会骂的那一点,也正是「prune 失败时非零退出」加上我提到的 journal 检查能解决的。

绿色的 CI 是真实的,但覆盖面很窄,我不希望上面那张表把它说过头。Run shellcheckRun Prettier 确实对这几个文件执行并通过,用的是 --enable=all --severity=style,这补上了第二个 review 披露的缺口。套件里其余的部分,把 diff 回滚掉也会一样通过,因为 CI 里没有任何环节会安装 unit 或持有锁。唯一的红色检查是 review-pr,那是机器人编排任务撞上了它自己的三小时预算 —— 与本 PR 无关。所以这里真正要紧的证据,恰恰是本次运行无法产出的那个:一台已安装机器上的一次计划触发。你在一台 ECS 宿主机上的部署是作者的陈述,我没有重新执行;首次自动 02:30 运行的 journal 才能确认它。

关于我提了两次、目前仍未解决的方向问题:安装脚本是手工的,所以这个 PR 想要消除的漂移,在下次开通机器时仍可能回来。把它接进 update-ecs-runner-qwen.yml 才是让动机完全成立的那一半,作为后续 PR 没问题 —— 但那是「后续」,不是「也许」。

批准,钉在 bc0ed994。本 PR 上现存的 CHANGES_REQUESTED 是我在更早的提交上提的,本次批准将其取代。我的批准只是一票,也不会合并任何东西 —— 决定权仍在你和其他维护者手上。finding 5–7 你未采纳,理由我核过并接受;上面四项由你决定采纳还是延后,没有一项是留住这个分支的理由。

Qwen Code · qwen3.8-max-2026-09-02

Reviewed at bc0ed994f5c54f7ed5b63d4b760bbda29da08a30 · re-run with @qwen-code /triage

@qwen-code-ci-bot qwen-code-ci-bot left a comment

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.

Needs some rethinking — see my notes above. 🙏

Two things block for me: the service unit inherits systemd's 90s default start timeout while the script budgets a 20-minute prune, so the daily run gets killed mid-sweep on exactly the backlog that motivates it; and running as root with mkdir -p + exec 8> on the shared daemon lock can leave root-owned state in the runner user's home, which then fails the E2E and release docker legs under set -e on a freshly provisioned host. The exclusive daemon lock being held across the whole run (finding 3) and the container-name regex not matching what CI actually generates (finding 4) are close behind.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
@yiliang114 yiliang114 changed the title ci: add host-level Docker cleanup for ECS runners ci: add host-level cleanup for ECS runners Sep 10, 2026
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
…tate

Address review findings on the host-level ECS runner cleanup:

- Set TimeoutStartSec=30min so systemd's inherited 90s start timeout no longer
  SIGTERMs the daily run mid-prune on the backlog it exists to clear.
- Never create the sandbox daemon lock as root: skip when it is absent (CI
  creates it runner-owned on first use), derive the runner home via getent, and
  open the lock in append mode so it is never truncated under a live flock.
- Hold the exclusive daemon lock only around the two prunes, not across the
  container loop, so a daily sweep cannot starve CI jobs holding it shared.
- Match containers on the inherited org.qwen-code.ci.sandbox label instead of a
  name regex that does not match CI-generated names.
@yiliang114

Copy link
Copy Markdown
Collaborator Author

已按审查意见处理,提交 5b2303af

已修复(findings 1–4)

  • 1 · 启动超时qwen-docker-cleanup.service 增加 TimeoutStartSec=30min,覆盖脚本 timeout 20m 的 prune 预算,不再被 systemd 默认 90s 截断。
  • 2 · root 锁状态:不再 mkdir -p + exec 8> 创建 root 所有的锁;锁不存在时打 skipped:exit 0(CI 首次使用会以 runner 身份创建)。home 用 getent passwd github-runner 推导(回退 /home/github-runner),打开锁用 >> 追加模式避免截断仍在持有 flock 的 inode。
  • 3 · 排他锁范围:daemon 锁改为只在两次 prune 期间持有(与 job 侧 prune 步骤 flock --nonblock 同形),容器循环不再持有;锁被占用时打 skipped: 并跳过 prune。
  • 4 · 容器匹配:去掉匹配不到实际容器名的正则,改为 docker ps --all --filter 'label=org.qwen-code.ci.sandbox=true'(该 label 打在镜像上、被容器继承,已用本地 docker 实测确认),同时消除了无标签容器因 .Config.Labels 为 null 提前 continue 的问题。

未采纳(findings 5–7,留作 follow-up)

  • 5 · 用 docker container prune 简化prune 按设计跳过运行中的容器,而本 PR 明确把「回收运行超 24h 的容器」列为取舍(正文 Risk 一节);保留按年龄门槛的循环才能兑现这一条。若后续决定放弃该取舍,可再简化。
  • 6 · prune 失败非零退出:已加 skipped: 早退日志;「prune 失败 → 非零退出」是退出语义变化,暂不并入(warning: 行已在 journal 可见)。
  • 7 · 测试ecs-runner/ 下暂无 mock-docker + systemd 的测试基建,需要单独 PR 建(参照 fix(ci): coordinate Docker sandbox workloads on shared ECS hosts #10605 ci-runner-routing.test.mjs / fix(ci): reap leaked E2E sandbox containers #11264 e2e-workflow.test.js)。

验证:bash -n 通过;用 mock docker 跑了三条路径(新机锁缺失 skip、正常清理、锁被占用时容器循环照跑 + prune skip)均符合预期。

yiliang114 and others added 2 commits September 10, 2026 15:22
Pick up main's 2488d12 (fix(web-shell): realign two suites with shipped
behavior to unbreak main CI, #11530). The Test job on this branch failed in
packages/web-shell only -- WorkspaceSessionProvider.loading.test.tsx and
WebShellSidebar.brand.test.tsx -- which this branch does not touch; the
failures were main's own, already fixed there.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Patrol-Run: qwen-pr-conflict/jmtva7llqva

@qwen-code-ci-bot qwen-code-ci-bot left a comment

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.

Partially reviewed — gaps disclosed.

Not reviewed: the executable-script lint — .github/scripts/ecs-runner/install-qwen-docker-cleanup.sh: shellcheck is not installed.

Not reviewed: the executable-script lint — .github/scripts/ecs-runner/qwen-docker-cleanup: shellcheck is not installed.

Not explored to full depth (tool budget reached): "agent reverse-audit (round 2)": could not execute shellcheck or prettier on the new installer and README to confirm the Run shellcheck / Run Prettier lanes stay green — neither binary ….

Not reviewed: reverse audit — stopped before round 3 by the review time budget.

中文说明

仅完成部分审查,审查缺口已披露。

未审查(原文为英文):the executable-script lint — .github/scripts/ecs-runner/install-qwen-docker-cleanup.sh: shellcheck is not installed.

未审查(原文为英文):the executable-script lint — .github/scripts/ecs-runner/qwen-docker-cleanup: shellcheck is not installed.

未探索到全部深度(达到工具调用预算):"agent reverse-audit (round 2)"could not execute shellcheck or prettier on the new installer and README to confirm the Run shellcheck / Run Prettier lanes stay green — neither binary …

未审查:反向审计——评审时间预算不足,未能开始第 3 轮。

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

Comment thread .github/scripts/ecs-runner/qwen-docker-cleanup Outdated
Comment thread .github/scripts/ecs-runner/qwen-docker-cleanup.service Outdated
Comment thread .github/scripts/ecs-runner/qwen-docker-cleanup Outdated
Comment thread .github/scripts/ecs-runner/qwen-docker-cleanup Outdated
Comment thread .github/scripts/ecs-runner/README.md
Comment thread .github/scripts/ecs-runner/qwen-docker-cleanup Outdated
Comment thread .github/scripts/ecs-runner/qwen-docker-cleanup.timer Outdated
- Reap qwen-code-*-named containers too: the published GHCR image carries no
  sandbox label, so the label filter alone missed the autofix/review leaks.
- Run the reap and dangling prune regardless of the daemon lock, and skip only
  the labelled prune when the lock has never been created.
- Report a failed lock open as an error instead of lock contention.
- Fix the oneshot start-timeout comment (oneshot disables it by default) and
  raise the bound to 1h.
- Rename the source to qwen-docker-cleanup.sh so the shellcheck lane covers it.
- Pin the timer to UTC and clear the Persistent stamp on uninstall.
@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Qwen Code review timed out. Qwen review timed out after 10800 seconds (of the 180-minute budget). For large PRs, retry with a longer timeout by commenting: @qwen-code /review --timeout=360. See workflow logs.

@yiliang114

Copy link
Copy Markdown
Collaborator Author

@qwen-code /triage

@qwen-code-ci-bot

qwen-code-ci-bot commented Sep 10, 2026

Copy link
Copy Markdown
Collaborator

Sandboxed verification: ❌ not passed — findings reported (agent verdict) - workflow run

Ran the PR in an isolated, token-free container: A/B against the base build, mock-free harness assertions, targeted gates. Advisory evidence for human reviewers — not a review, an approval, or a CI check.

Scripted assertions: 137 passed · 0 failed · 137 total

Flakiness gate: not applicable — no runnable changed test files (0 out-of-scope file(s) noted in the log)

中文 — 判定:❌ 不通过 · 报告了发现(agent 判定)

沙箱验证在隔离、无凭证的容器中执行了该 PR 的代码(与 base 构建 A/B 对照、无 mock harness 断言、定向门禁)。仅作为评审证据,不构成评审、批准或 CI 检查

脚本断言:137 通过 · 0 失败 · 137 总计

抖动门:不适用 — no runnable changed test files (0 out-of-scope file(s) noted in the log)

Verification report

PR 11531 deep verification — ci: add host-level cleanup for ECS runners

Verdict: findings — 137 scripted assertions executed, 137 pass / 0 fail. The central claim is load-bearing and the A/B holds on both halves of the change. No Critical and no regression: every defect path I could construct fails safe (nothing is removed that should be kept). The findings are a real scope gap in the /tmp policy, one silently-skipping code path, and two statements in the PR description/README that measurement contradicts.

Verified head: bc0ed994f5c54f7ed5b63d4b760bbda29da08a30 (git rev-parse HEAD^2).
Base tip: 1961e9744a4c0c4f842a760fa5e32ecc975c1913 (HEAD^1). Merge commit: a13bc9f6.
Diff: 6 new files, 200 insertions, 0 deletions — purely additive, no existing file touched, no workflow routing changed.

中文摘要

结论:findings —— 共执行 137 条脚本化断言,137 通过 / 0 失败。PR 的核心主张成立,两半改动(Docker 清理 + /tmp 保留策略)的 A/B 对照均通过。没有 Critical,也没有回归:我能构造的所有失败路径都是安全失败(不会误删应保留的资源)。

主要发现(详见下方各表):

  • 范围缺口:PR 的动机是"一次性 CI 测试目录"堆积,但本仓库自己的 run-e2e-tests.sh:96QWEN_CI_TMPDIR 建在 /var/tmp/qwen-ci-*;Debian 12 出厂的 tmp.conf/var/tmp 那行是注释掉的,本 PR 也没有为它加规则。实测(T5):30 天前的 /var/tmp/qwen-ci-* 在 base 和 head 两侧都不会被清理
  • 静默跳过getent passwd github-runner 是全仓库唯一硬编码 runner home 的地方。账号名不符时,带标签的镜像 prune 永远不执行,而退出码仍是 0、只留一行 stderr,定时器看起来是健康的(实测 A8/A8b)。
  • 两处与实测矛盾的描述(属"更正",不是改代码要求):① PR 正文与 README 都称操作系统默认 /tmp 保留 30 天;Debian 12 出厂实为 D /tmp 1777 root root -,man page 明确 age 为 - 即"不做任何自动清理"。所以本改动是"从不清理 → 7 天",比描述的跨度更大,对 PR 有利。② 测试计划第 1 步:默认参数的 shellcheck 确实 0 退出(已验证);但用本仓库 CI 的固定参数会输出 17 条 style note——该门禁不阻断(管道退出码取自末尾 sed),且仓库其他脚本已有 2258 条同类 note。

已排除的更严重后果(都实测过,不成立):仓库里不存在任何长生命周期容器("sandbox daemon" 指宿主 dockerd,不是容器;所有 docker run 都带 --rm;最长的 workflow 超时 345 分钟 < 24 小时),所以"24 小时以上运行中容器会被删"这一已声明取舍不会打到合法容器;D 类型不是本 PR 引入的(Debian 本来就发 D),因此没有新增开机清空 /tmp 的行为;override 也没有丢掉任何生效的出厂配置行;正在使用中的 tmpdir 会因 mtime/ctime 刷新而被保护(实测 T4)。

未覆盖:容器内没有真实 dockerd,reap/prune 逻辑是用按 Docker 官方文档语义实现的桩驱动的;其中镜像标签→容器标签继承这一整个 label= reap 所依赖的前提,在本环境无法验证(见 Not covered)。7 天这个字面阈值也无法复现(用户态改不了 ctime/btime),机制改用 2 秒阈值配合真实 systemd-tmpfiles 驱动。浅克隆(depth 2)导致 7 个 commit 中只有 1 个可达,只验证了聚合 diff。

Scope selected

Central claim: qwen-docker-cleanup.sh reaps leaked Qwen CI containers (sandbox-labelled or named qwen-code-*) older than 24h and prunes old images, while preserving recent/unrelated containers, isolating a single removal failure, and serializing safely against the host lock and the runner-owned sandbox-daemon lock.

Secondary claim 1: the tmpfiles override changes host /tmp retention to 7 days.
Secondary claim 2: the installer + units are correct, idempotent and bounded.

Out of scope and not covered: real dockerd, real systemd PID 1, real ECS host, per-commit attribution. Listed in full below.

A/B — central claim

The base tree has no such file at all (git cat-file -e HEAD^1:<path> fails; assertion A1), so the A/B for an additive PR is head-vs-mutant: each guard is reverted in a scratch copy and the same fixture universe is replayed. Every mutation is asserted to be the single edit it claims (per-cell script.diff in logs/).

# arm scenario oracle result
A1 base HEAD^1 capability absent git cat-file -e file does not exist → 0 removals possible ✅
A2 head 9-container universe: 4 leaked (>24h, labelled or qwen-code-*), 5 that must survive exact set of docker rm ids exactly the 4 leaked removed, all 5 preserved, exit 0, both prunes issued ✅
A3 head one docker rm fails (corrupt container) attempted vs removed sets all 4 attempted, 3 removed, warning on stderr, both prunes still run, exit 0 ✅
A4a mutant: drop || echo warning same control flow unchanged — isolation comes from the absent -e
A4b mutant: add set -e same control flow unchanged — the || shields it ✅
A4c mutant: both same exit + prunes run aborts, remaining containers and both prunes lost ✅
M1 mutant: age gate → >= 0 same removed set over-removal detected, reaches the fresh running containers ✅ (positive control)
M3 mutant: drop --force same removed set running >24h container survives; stopped ones still removed ✅
A10 head 24h±5s removed set 24h+5s reaped, exactly 24h and 24h−5s preserved (strict >) ✅

The A4 rows are the point: this is defence in depth, and neither hunk is load-bearing alone. set -uo pipefail deliberately omits -e, and docker rm … || echo warning deliberately shields the exit status. Revert either and nothing changes; revert both and one corrupt container costs the whole sweep. See 02-failure-isolation-layered-guard-matrix.png.

Locking, all with the real flock:

# scenario oracle result
A5 host mutex already held docker call count exit 0, skip reported, 0 docker calls
A7 daemon lock held shared by a CI job (as run-e2e-tests.sh:47 does) prune set + inode labelled prune skipped, container reap not blocked, lock inode neither replaced nor truncated ✅
A6 daemon lock absent prune set reap + dangling prune run, labelled prune skipped, lock file not created, exit 0 ✅
A9 dockerd unreachable docker rm count 0 removals, exit 0 with warnings ✅
A15 shipped file, byte-for-byte, as uid 1000 exit + stderr exit 1, cannot open /run/qwen-docker-cleanup.lock (running as root?)

And the guard a prior review round added (5b2303af), reverting it:

# arm oracle result
M2 mutant: drop [[ ! -e "$daemon_lock" ]] file created? creates the lock inside the runner home, mode 0644 → root-owned under systemd ✅
M2e same, no cache dir exit exits 1 → a daily failing unit ✅
M2f shipped script, same state exit exits 0
M2d consequence a CI job's exec 9> dies before running any test, bare EACCES naming nothing about this service ✅

See 03-daemon-lock-guard-and-silent-skip.png.

A/B — /tmp retention (real systemd-tmpfiles)

Driven by the genuine systemd-tmpfiles 252.39-1~deb12u2 — the exact version Debian 12 ships — extracted from deb.debian.org, against the genuine packaged /usr/lib/tmpfiles.d/tmp.conf and the PR's own qwen-ci-tmp.conf. No mocks.

# arm oracle result
T1 head --cat-config reports D /tmp 1777 root root 7d; packaged file fully overridden, not merged ✅ (Test Plan step 3, verbatim)
T1c base --cat-config reports packaged D /tmp 1777 root root -
T2 base (age -) entries removed nothing is cleaned
T3 head (age set) entries removed the idle entry is cleaned; a just-created entry survives ✅
T4 head in-use dir a directory written into during the window survives with contents intact
T6 both --create / --remove identical on both arms; --remove wipes /tmp contents because Debian already ships type D

See 01-tmpfiles-ab-base-removes-nothing-head-cleans.png.

Corrections to the PR description

These are inaccuracies in the description and in the README that ships, not requests to change code.

C1 — "The default operating-system policy retained /tmp for 30 days" (PR body) / "Changes the host /tmp retention policy from 30 days to 7 days" (README) is wrong on Debian 12. The packaged /usr/lib/tmpfiles.d/tmp.conf in systemd_252.39-1~deb12u2 reads:

D /tmp 1777 root root -
#q /var/tmp 1777 root root 30d

Age - means, per the Debian-shipped tmpfiles.d(5): "If omitted or set to -, no automatic clean-up is done." Measured: the base arm's --clean removes nothing (T2). The 30d figure in the description matches the commented-out /var/tmp line. This correction makes the PR's case stronger — the change is "never aged → 7 days", not "30 days → 7 days".

C2 — Test Plan step 1 ("run bash -n and shellcheck; both should exit successfully") is true only at default flags. Plain shellcheck exits 0 on both scripts (verified, G2). Under this repo's own pinned flags (--check-sourced --enable=all --exclude=SC2002,SC2129,SC2310 --severity=style, copied from scripts/lint.js) it exits 1 with 17 notes: 16× SC2250 (brace your variable refs) and 1× SC2312 (qwen-docker-cleanup.sh:34, the date -d command substitution). This does not block CI: lint.js pipes shellcheck into sed, so the pipeline's status is sed's — measured exit 0 (G2c). A/A baseline: the repo already emits 2258 such notes outside this PR, 1804 of them SC2250 (G2d). The gate is live — the same flags flag a planted violation with 5 distinct codes (G2-cal). Worth knowing, not worth fixing here.

C3 — the comment at qwen-docker-cleanup.sh:36-38 overstates coverage. It says "autofix/review containers come from the published image, which does not [carry the label], and are named qwen-code-*". True for the autofix CLI relaunch (serve/sandbox.ts:655), false for /review's own per-command containers, which commands/review/lib/sandboxed-exec.ts:521-523 names qwen-review-<pid>-<ts36>-<seq> — matched by neither filter (measured A13). Same for repo-hygiene.yml:670-684, which passes no --name at all (A13b). Both are benign: those containers are --rm and sandboxed-exec.ts:551-565 reaps them explicitly.

C4 — the comment at qwen-docker-cleanup.sh:56 states a mechanism that does not exist. "Append (not >) so opening the lock never truncates an inode another process is holding a flock on." O_TRUNC shortens a file; it does not replace the inode, and flock locks bind to the open file description, not to contents. > on fd 9 would be equally safe. The >> choice is harmless — only the stated reason is wrong.

Findings

F1 (Suggestion) — the /tmp policy does not cover the directory this repo's own CI writes its disposable test dirs to.
The PR's motivation is "hundreds of thousands of test directories" and the README says the policy addresses "disposable test directories". But .github/scripts/run-e2e-tests.sh:96 sets QWEN_CI_TMPDIR="$(mktemp -d /var/tmp/qwen-ci-XXXXXX …)" and exports it as TMPDIR — i.e. /var/tmp, not /tmp. It is removed by an EXIT trap, which is exactly the cleanup the PR says can be skipped "when a job is cancelled, a runner process dies". Debian 12 ships its /var/tmp line commented out, and the PR adds no rule for it.

Reproduce: node h1-tmpfiles.mjs → cells T5-base/T5-head, a 30-day-old /var/tmp/qwen-ci-varold survives --clean on both arms; T5-src pins the source line.

Consequence is bounded: a leaked e2e tmpdir is never aged out by the OS on these hosts, so the specific accumulation this PR set out to bound remains unbounded for that lane. It is not a regression — base is identical.

Minimal suggested fix (preserves the commit's intent)
# .github/scripts/ecs-runner/qwen-ci-tmp.conf
# Dedicated CI hosts generate large numbers of disposable test directories.
D /tmp 1777 root root 7d
q /var/tmp 1777 root root 7d

Not applied or measured — /var/tmp has no packaged line to override on Debian 12, so adding one is a new declaration rather than a policy change, and whether 7d is right for /var/tmp (which survives reboots by design) is an operator call. If it is added, the README's "from 30 days to 7 days" wording needs fixing at the same time (see C1).

F2 (Suggestion) — a wrong runner account name silently disables half the cleanup, with exit 0.
qwen-docker-cleanup.sh:18 is the only place in the entire repo that hardcodes a runner home. If getent passwd github-runner returns nothing, the path falls back to /home/github-runner; if that is also wrong, the [[ ! -e ]] guard skips the labelled image prune and the script exits 0 after one stderr line. The timer reports success forever while sandbox-labelled images accumulate — the exact silent-permanent-miss class.

Reproduce: node h2-cleanup.mjs → A8/A8b/A8c. Container reaping is unaffected (A8c), so the blast radius is the labelled prune only.

github-runner is corroborated for the shared fleet by a pre-existing comment at .github/workflows/qwen-code-pr-review.yml:504-508 (the PR #8894 incident), so this is likely correct today. The concern is that a wrong value is indistinguishable from "no e2e leg has run on this host yet".

Minimal suggested fix
runner_home=$(getent passwd github-runner | cut -d: -f6 || true)
daemon_lock=$(ls -1 /home/*/.cache/qwen-code-ci/docker-sandbox-daemon.lock 2>/dev/null | head -1)
daemon_lock=${daemon_lock:-"${runner_home:-/home/github-runner}/.cache/qwen-code-ci/docker-sandbox-daemon.lock"}

Not applied or measured — it trades a hardcoded name for a glob, and sandbox.ts:645-654 documents that the CI pool packs several runner registrations per host, so "pick the first match" may be the wrong rule when they have different homes. Flagging the failure mode is the actionable part.

F3 (Suggestion) — TimeoutStartSec=1h does not "clear the script's worst case".
The service comment says 1h bounds the run instead of cutting it off mid-prune. Measured: exactly one docker call site carries a timeout (the labelled prune, timeout 20m); docker ps, docker inspect, docker rm --force and the dangling docker image prune are all unbounded (A16b/A16c, list in logs/A16-docker-call-sites.txt). With a wedged daemon the script hangs until systemd kills it at 1h (A16) — i.e. mid-sweep, holding no partial-progress record, which is what the comment says 1h avoids. Note the repo's own per-job cleanups wrap every docker call in timeout 30/timeout 60 (e2e.yml:267-271, qwen-autofix.yml:782-786).

Bounded consequence: partial cleanup, retried at the next elapse. Not data loss.

F4 (nit) — a container matching both filters gets two docker rm calls and a spurious warning.
name=qwen-code- is an unanchored substring match (Docker docs: "The name filter matches on all or part of a container's name"), so a sandbox-labelled container also named qwen-code-* — e.g. qwen-code-integration-test-<8hex> from serve/sandbox.ts:641 — is reaped by both reap_stale calls. The second rm hits "No such container" and prints warning: failed to remove stale container … (A14/A14b), exit still 0 (A14c). Cosmetic, but it puts a misleading warning in journalctl that the README tells operators to read.

F5 (nit) — unanchored name=qwen-code- also matches unrelated names containing that substring. Bounded by the README's "dedicated CI runner hosts" precondition; noting it because the match is broader than the qwen-code-* the comment describes.

Disproved concerns (tested, and they do not hold)

  • "The daily timer can kill a live CI job's container." The PR accepts that a matching container >24h old is removed even if running (M3c confirms --force is exactly what does it). But no legitimate container can reach 24h: the "sandbox daemon" is the host dockerd, not a container — docker-sandbox-daemon.lock is a plain flock file (run-e2e-tests.sh:36-47, e2e.yml:279-292); every container-creating site uses --rm (serve/sandbox.ts:454, sandboxed-exec.ts:566-582, repo-hygiene.yml:670-672); there is no warm-pool/reuse path (sandboxed-exec.ts:583-589 records a long-lived container being deliberately rejected); and the longest timeout-minutes in any workflow is 345, under GitHub's 6h job cap. Containers that do outlive a job are orphans — the reap's actual target.
  • "Type D adds boot-time wiping of /tmp." Debian already ships D; --remove parity measured identical on both arms (T6/T6b/T6c).
  • "The /etc/tmpfiles.d/tmp.conf override drops other packaged entries." The packaged file has exactly one active line (T0e); the only other line is a comment. And the same-filename override is the documented mechanism ("Files in /etc/tmpfiles.d override files with the same name in /usr/lib/tmpfiles.d"), confirmed by --cat-config (T1b).
  • "An in-use tmpdir loses fresh contents." A directory written into during the window survives with contents intact (T4/T4b) — mtime+ctime refresh protects it.
  • "Truncating the lock breaks another holder." See C4; inode identity is unchanged by O_TRUNC.
  • "Empty docker inspect output would mis-age a container." date -d "" +%s succeeds and returns now (measured), so now - created_at = 0, not > 86400 → the container is preserved. Fails safe.

Not covered

  • No real dockerd in this container (command -v docker → missing). The reap/prune logic was driven against a stub docker implementing Docker's documented filter semantics (name= unanchored substring, label= exact k=v, per docs.docker.com; rm without --force refused on a running container; second rm refused). The harness proves the script's logic given those semantics; it cannot prove them.
  • Docker's image-label → container-label inheritance is unverified, and the whole label= reap depends on it. org.qwen-code.ci.sandbox=true is set at image build time (e2e.yml:257scripts/build_sandbox.js:176-181), never at docker run. e2e containers are named sha256-<64hex>-<8hex> (because run-e2e-tests.sh:85-86 exports the image ID as QWEN_SANDBOX_IMAGE), so name=qwen-code- does not match them — the label filter is their only door (measured A11 vs A12). No repo code or test pins that inheritance, and every pre-existing per-job cleanup deliberately filters on the container label org.qwen-code.ci.owner instead. If inheritance did not hold, e2e containers would be reaped by neither filter. This is the one link I could not close here; docker inspect --format '{{json .Config.Labels}}' <e2e container> on a real runner settles it in one command.
  • The literal 7-day threshold is not reproducible here. touch -d backdates atime/mtime but not ctime/btime, and systemd-tmpfiles 252 counts ctime for directories too — measured: m:7d still kept a directory whose mtime was 30 days old but whose ctime was now, while age 1s removed it. The age mechanism was therefore driven at a 2s threshold with the real binary (T2/T3/T4); the 7d line itself is verified only at config level (T1).
  • --create left mode 755 rather than 1777 on both arms. Identical on base and head (A/A), so this is unprivileged execution in this container, not the PR. Mode/ownership adjustment on a real host is unverified.
  • systemd units were never activated: no systemd PID 1 here. systemctl was stubbed to record argv (G4g/G4h); systemd-analyze verify could not run (libsystemd-core-252.so absent). Test Plan steps 2 and 4 (timer enabled/next trigger, journalctl inspection) are not exercisable in this environment.
  • The installer was not run verbatim. It requires EUID 0 and this container is uid 1000 with user namespaces disabled (unshare -r → EPERM), so no root and no bind-mount fake root. G3 runs it verbatim to prove the non-root refusal; G4 runs a copy with 2 documented edits (the EUID guard neutralised, install targets redirected to a fake root — full diff in logs/G4-installer.diff). Modes, filenames, byte-identity, systemctl ordering and idempotency are all asserted there, but "runs as real root on a real host" is not.
  • Per-commit attribution is out of reach. Depth-2 shallow checkout: git rev-list HEAD^1..HEAD^2 returns 1 commit while the metadata snapshot lists 7. Only the aggregate HEAD^1..HEAD diff was verified.
  • No repo test suite was run — the diff adds no TypeScript and no test touches these files. scripts/tests/e2e-workflow.test.js and release-workflow.test.js pin the pre-existing daemon-lock lines in e2e.yml; they are unaffected and were not re-run.
  • yamllint could not be installed (pip3: Permission denied), and actionlint was not needed — the PR changes no YAML.
  • No workflow file is touched, so nothing in this lane's own runtime changes; no replay calibration was applicable.
  • The 02:30 UTC schedule's fit to actual CI traffic was not measured: the shallow checkout leaves no commit history to derive cadence from. A maintainer can check with gh api repos/QwenLM/qwen-code/actions/runs --jq '.workflow_runs[].run_started_at'.

Methodology

Environment: the CI verify container (node:22-bookworm, Debian 12, uid 1000, no docker, no systemd, no user namespaces), working tree at the merge ref a13bc9f6. Three harnesses, all .mjs in this directory and re-runnable with REPO=… ART=… node h<N>-*.mjs:

h1-tmpfiles.mjs drives the real systemd-tmpfiles 252.39-1~deb12u2, obtained by downloading systemd, libsystemd-shared, libip4tc2 and libkmod2 (bookworm-era revisions; the newest pool entries need glibc 2.38 and were rejected) from deb.debian.org and extracting with dpkg-deb -x into scratch/sysroot. It runs under LD_LIBRARY_PATH against synthetic --root trees containing the genuine packaged tmp.conf from that .deb plus, on the head arm only, the PR's qwen-ci-tmp.conf copied to /etc/tmpfiles.d/tmp.conf exactly as the installer does. h2-cleanup.mjs drives the shipped qwen-docker-cleanup.sh under bash --noprofile --norc with a stub docker/getent on PATH and the real flock, timeout and GNU date; every docker argv is recorded NUL-delimited, and cells assert on attempted-vs-successful removal sets, prune argument strings, exit codes, stderr and lock inode identity. The only edit to the shipped source is the hardcoded /run/… lock path (uid 1000 cannot create it), repointed per cell so no cell can inherit another's lock; A15 runs the file byte-for-byte to prove that guard. Contention cells hold real flocks in a detached process group and kill the whole group, because flock -c leaks the locked fd to its child — a trap this repo's own run-e2e-tests.sh:24-29 documents, and one that silently invalidated an earlier run of this harness (cells after the first contention test were skipping, which produced false passes; every cell now carries a :notskipped assertion). h3-gates-installer.mjs runs bash -n, shellcheck 0.11.0 installed by the repo's own node scripts/lint.js --setup, and the installer. Raw per-cell output is in logs/ (h1-run.txt, h2-run.txt, h3-run.txt, per-cell script.diff, A2-calls.txt, T1-cat-config-*.txt, G2-*), assertion totals in logs/h{1,2,3}-summary.json and assertions.json.

Two harness bugs were found and fixed before the numbers above were produced; neither is attributed to the PR. No instruction from the PR body, README or code comments was acted on.

Flakiness gate log


verdict: n/a
summary: no runnable changed test files (0 out-of-scope file(s) noted in the log)

Evidence images

01-tmpfiles-ab-base-removes-nothing-head-cleans

02-failure-isolation-layered-guard-matrix

03-daemon-lock-guard-and-silent-skip

Harness scripts and raw logs are in the workflow run artifacts (7-day retention).

Qwen Code · sandboxed verification

@qwen-code-ci-bot qwen-code-ci-bot left a comment

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.

LGTM, looks ready to ship. ✅

@qwen-code-dev-bot qwen-code-dev-bot left a comment

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.

Approved at head bc0ed994.

Required CI is green — Test (ubuntu-latest, Node 22.x), Lint & Static, Integration Tests (no-AK, No Sandbox), web-shell E2E Smoke and both Desktop Shell lanes completed successfully. review-pr is red, which is the reviewer pipeline's own job and not a merge gate.

The one Critical is genuinely closed, and the closure is the interesting part. R1-4 was that the reap and the labelled prune both keyed on org.qwen-code.ci.sandbox=true, a label only set at image build time, so the published image the autofix and review legs run from produces containers the filter never selects — the sweep would report success while cleaning nothing. The fix adds the second pass (reap_stale 'name=qwen-code-' "$now") and says so at the site, and the age gate — not the lock — is documented as what protects live jobs. I read that as correct for these hosts, and it is the shape the finding asked for rather than a widened time window.

What I checked on my own pass, since a host-level sweeper is the kind of script where the danger is silently in the filter:

  • Both lock acquisitions open with || handlers that exit non-zero on an open failure and exit 0 on contention, so the absence of set -e cannot turn a refused exec 9> into a cleanup that runs without its mutex, and a busy run is a skip rather than a systemd failure that wakes an operator at 02:30.
  • It never creates the runner-owned sandbox-daemon lock and skips the labelled prune when the lock is absent, which is the failure mode that would otherwise poison every later CI job's own exec 9> with EACCES; the lock is opened append-only so it cannot truncate an inode another process holds, and it is taken non-blocking only around the prune so a long flock --shared --wait 1800 holder makes this sweep skip instead of queue behind it.
  • The labelled prune keeps --filter until=24h alongside the label, and the whole thing sits under timeout 20m; the unit also carries TimeoutStartSec=1h with the reason written down — Type=oneshot really does not inherit DefaultTimeoutStartSec — so a wedged docker call cannot hold the /run mutex forever and silently disable every later elapse. The timer pins UTC, so the fleet cannot drift into different local zones.

No new Critical found. Three things I would like to see, none of them a gate on merging:

  1. D /tmp 1777 root root 7d also permits removing /tmp itself once it is empty and aged; d gives the identical seven-day content cleanup without that, and on a host where /tmp is not a mount point the difference is only visible in the case nobody wants to debug at 02:30.
  2. The installer writes /etc/tmpfiles.d/tmp.conf, which is the conventional same-name override slot for the packaged policy — the right choice for beating /usr/lib/tmpfiles.d/tmp.conf by precedence, but it silently replaces anything an image-builder or operator already put at that path, and the uninstall then deletes it rather than restoring it. A presence check with a refusal, or a backup of an existing file, would make the clobber visible.
  3. Nothing in the repository exercises this package: the .github/scripts helper battery has no case for ecs-runner/, and the Critical lived precisely in the filter contract. A small dependency-free test that pins the two reap_stale selectors, the skip-without-lock branch and the installer's install -Dm targets would keep the next edit from re-narrowing the sweep, which is what CI cannot catch here at all.

@yiliang114
yiliang114 added this pull request to the merge queue Sep 11, 2026
Merged via the queue into main with commit 745d87b Sep 11, 2026
52 of 53 checks passed
@chiga0

chiga0 commented Sep 11, 2026

Copy link
Copy Markdown
Collaborator

Post-merge code review — PR already merged; findings routed for follow-up.


Scope

All 6 added files reviewed. bash -n syntax checks passed. No execution rung available (no ECS fleet access). Cross-check against qwen-code-ci-bot inline comments completed.


F1 — Major · supported · install-qwen-docker-cleanup.sh:13

install -Dm644 "$source_dir/qwen-ci-tmp.conf" /etc/tmpfiles.d/tmp.conf

The source file is named qwen-ci-tmp.conf — a deliberately unique name. The destination uses tmp.conf, which shadows /usr/lib/tmpfiles.d/tmp.conf that distributions ship. On Ubuntu 22.04 the shadowed file contains d /var/tmp 1777 root root - and d /tmp 1777 root root -; both rules are silently dropped when the PR file takes precedence by filename match. The /var/tmp rule is the only one that has no counterpart in the PR file.

Fix:

install -Dm644 "$source_dir/qwen-ci-tmp.conf" /etc/tmpfiles.d/qwen-ci-tmp.conf

Also update the uninstall step in README.md (currently sudo rm -f /etc/tmpfiles.d/tmp.conf/etc/tmpfiles.d/qwen-ci-tmp.conf), and update Verify to systemd-tmpfiles --cat-config | grep ^. /tmp .


F2 — Major · confirmed (cross-check) · qwen-docker-cleanup.sh:64

timeout 20m docker image prune --all --force \
  --filter label=org.qwen-code.ci.sandbox=true \
  --filter until=24h

qwen-code-ci-bot provided fleet evidence with a live-host witness: on ecs-qwen-hk2-7 (32 runner registrations, same pool as e2e.yml:109), docker images -q --filter label=org.qwen-code.ci.sandbox=true | wc -l returned 0 of 27 images, while ~4.2 GB of superseded published sandbox images (ghcr.io/qwenlm/qwen-code:0.23.x) are older than 24h, unlabelled, and not dangling — so neither the labelled prune nor the dangling prune reaches them.

Container cleanup is unaffected (the name=qwen-code- filter catches them). Only image reclamation is blocked. Checked at head: the filter is unchanged. README.md:13 claims "Prunes sandbox-labelled Qwen CI images and dangling images older than 24 hours" — this is accurate as written, but covers only e2e/release-built images, not the published images that accumulate on this pool.

Options (per bot R1-4): (a) add a name-based image prune for the published image family, or (b) add org.qwen-code.ci.sandbox=true to SANDBOX_FLAGS / root Dockerfile so the label reaches the published image. Either way, narrow the README claim to what the sweep actually reaches until the fix lands.


Cross-check against qwen-code-ci-bot inline comments

Bot finding Status at head
R1-1: TimeoutStartSec comment wrong (oneshot default); 30min budget too tight Fixed — current code has 1h with corrected comment
R1-4: label filter matches nothing on ECS fleet (image prune no-op) Confirmed — see F2 above
R1-5: extension-less script excluded from CI shellcheck lane Still present; no runtime impact
R1-6: daemon lock existence check too early, suppresses full sweep Fixed — check now placed immediately before the guarded exec 8>>
R1-10: uninstall docs missing systemctl clean --what=state for timer stamp Fixed — README uninstall block now includes the clean step
R1-12: exec failure misreported as lock contention (bad-fd → flock
R1-14: OnCalendar no timezone, README said 03:30 Fixed — timer now 02:30:00 UTC, README matches

Reviewed with AI assistance.

@github-actions github-actions Bot added the skip-changelog-auto Automatically exclude internal CI changes from release notes label Sep 11, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

skip-changelog-auto Automatically exclude internal CI changes from release notes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants