修复bootstrap注册崩溃:StartWhenAvailable属任务设置而非触发器

现场:用户以管理员跑 -SetEnv -StartNow 时脚本抛
SetValueInvocationException(触发器上没有 StartWhenAvailable 属性),
而它是在"已注销旧任务、尚未注册新任务"之间抛的,等于把 runner 任务删没了。

- StartWhenAvailable 改设到 settings(MSFT_TaskSettings 继承属性,
  CIM 属性列表里看不见但可直接赋值,实测 StartWhenAvailable=True)
- 任务对象改由 New-WatchdogTaskPlan 统一构建,先构建、后注销旧任务
- Register-ScheduledTask 失败时显式告警"旧任务已删、CI 裸奔"并 exit 1
- 新增 -DryRun:免管理员即可全量校验 action/trigger/settings
- -Status 增补上次运行时间与返回码,并提示"非管理员看不见任务"

同时更正根因:旧计划任务其实一直存在(非管理员会话看不见,先前误判为
"从未注册"),但它跑的是单次 run_runner.cmd——daemon 属"正常退出"而非
"失败",计划任务的失败重启根本接不到,必须靠看门狗循环重拉。
This commit is contained in:
2026-10-12 12:52:27 +08:00
parent 20ee35b76a
commit 0f0ea03ff4
3 changed files with 123 additions and 25 deletions
+1 -1
View File
@@ -36,7 +36,7 @@ STM32F407ZGTx @168MHz(HSE 8MHz → PLL M=4/N=168/P=2);**仅 Keil MDK-ARM v
- 全库扫描 `pre-commit run --all-files`;详见 `docs/代码规范/pre-commit使用说明.md`
- **CI(Gitea Actions,2026-10-01 T-6)**:`.gitea/workflows/ci.yml` 在 push/PR 跑 **L0 静态门禁 + gitlint 提交信息门禁 + L0.5 主机侧单测**(Windows runner);**不含 L1/L2/L3**(需真机+ST-LINK)、**刻意不跑 `@build`**(需 Keil 授权)、无 `.map` 时自适应跳过 `mem-numbers`。本地等价自查:`& '.venv\Scripts\python.exe' scripts/ci_local.py`(**runner 未就绪时这是 L0 的唯一保障**,不能因"已配 CI"放宽本地门禁)。详见 `docs/测试说明/测试开发标准流程.md` §9
- **`pre-commit run --all-files` 跑不到 `gitlint`**(2026-10-11 实测):该钩子声明 `stages: [commit-msg]`,而 `--all-files` 只跑 `pre-commit` stage → **17 个钩子定义、CI 只报 16 个、且表现为全绿**(提交信息门禁曾在 CI 完全空转)。现由 `scripts/ci_gitlint_range.ps1`(CI)与 `ci_local.py` 的 **L0b** 段(本地)逐条校验;**增删钩子后必须核对"定义数 == 日志条数 + gitlint"**
- **CI runner 必须有看门狗**(2026-10-11 实测):`gitea-runner` v4 的 `daemon` **无服务支持、首次 Declare 失败就直接退出进程**(Gitea 重启一次即停摆,run 16~21 共 12 个 job 零日志;叠加 `cancel-in-progress` 后症状是"任务凭空消失"而非失败)。体检 `& '.\scripts\ci_runner_bootstrap.ps1' -Status`;部署 `-SetEnv -StartNow`(看门狗 + 开机计划任务 + MACHINE 级 `DTU_PRECOMMIT_PYTHON`/`DTU_CPPCHECK`/`DTU_MSVC_CL`——runner 以 SYSTEM 跑,看不见用户级 PATH/site)
- **CI runner 必须有看门狗**(2026-10-11 实测):`gitea-runner` v4 的 `daemon` **无服务支持、首次 Declare 失败就直接退出进程**(Gitea 重启一次即停摆,run 16~21 共 12 个 job 零日志;叠加 `cancel-in-progress` 后症状是"任务凭空消失"而非失败)。⚠️ **计划任务的"失败重启"救不了它**——daemon 是**正常退出**不是失败,旧任务跑单次 `daemon` 会"成功结束"后再也不拉,必须**看门狗循环重拉**。体检 `& '.\scripts\ci_runner_bootstrap.ps1' -Status`(**须管理员**:非管理员会话可能看不见该任务)、干跑 `-DryRun`(免管理员校验任务对象)、部署 `-SetEnv -StartNow`(看门狗 + 开机计划任务 + MACHINE 级 `DTU_PRECOMMIT_PYTHON`/`DTU_CPPCHECK`/`DTU_MSVC_CL`——runner 以 SYSTEM 跑,看不见用户级 PATH/site)。改该脚本时守住两条:`StartWhenAvailable` 属**任务设置**(触发器上没这属性,误设抛 `SetValueInvocationException`)、且必须**先构建对象后注销旧任务**(否则旧任务被删、新任务没建成)
- **写 `GITHUB_ENV`/`GITHUB_PATH`/`GITHUB_OUTPUT` 必须显式 `-Encoding ascii`**(2026-10-11 实测):PS 5.1 的 `Out-File -Append` **默认 UTF-16LE**,会把 `42 00 42 00 3D 00` 这种带 NUL 的行追加进 runner 以 UTF-8 建的同一文件里
## 平台约束
+28 -1
View File
@@ -180,6 +180,14 @@ cache:
> (16~20 = `cancelled`、21 = 长期 `queued`)——症状看起来是"任务凭空消失",而不是"任务失败",
> 极难从 UI 反推根因。
> 🔴 **为什么"已经有计划任务"仍然会死**(2026-10-11 实测,别以为注册过就安全):
> 旧任务跑的是 `run_runner.cmd`,里面是**单次** `gitea-runner.exe daemon ...`(无循环、无重启语义)。
> 开机时 Gitea 往往还没起来 → daemon 首次 Declare 失败**立刻退出** → 任务"成功结束" →
> **计划任务认为一切正常,永远不会再拉**。日志里 10-09 18:50 连续三次
> `Starting runner daemon` + 三次 Declare 失败,就是三次"起了就死、死了没人管"。
> 所以必须换成**看门狗**(等就绪 + 循环重拉),单靠计划任务的"失败重启"救不了
> ——daemon 是"正常退出",不是"失败"。
**正确做法:一条命令装好"等 Gitea 就绪 + 崩溃自拉起 + 开机自启"**(管理员权限,仓库根目录):
```powershell
@@ -194,13 +202,30 @@ powershell -ExecutionPolicy Bypass -File scripts\ci_runner_bootstrap.ps1 -SetEnv
| ② 看门狗启动器 | 生成 `D:\Docker\act_runner\run_runner_watchdog.ps1`:先轮询 `<GiteaUrl>/api/v1/version` 直到 Gitea 应答,再起 `daemon`;daemon 退出 → 记日志 → 10s 后重拉,**永不放弃** |
| ③ 计划任务 | `GiteaActionsRunner`:开机 +1 分钟触发、以 **SYSTEM** + 最高权限运行、**运行时限=不限**、失败每 1 分钟重启(×999)、不允许多实例 |
**先干跑,再真装**(不需要管理员,把 action/trigger/settings 全构建一遍并打印,什么都不写):
```powershell
powershell -ExecutionPolicy Bypass -File scripts\ci_runner_bootstrap.ps1 -DryRun
```
随时体检(只读,不改任何东西):
```powershell
powershell -ExecutionPolicy Bypass -File scripts\ci_runner_bootstrap.ps1 -Status
```
输出会告诉你:计划任务是否存在、daemon 是否在跑、Gitea 是否可达、三个机器级变量是否已设。
输出会告诉你:计划任务是否存在(含上次运行时间与返回码)、daemon 是否在跑、Gitea 是否可达、
三个机器级变量是否已设。
> ⚠️ **`-Status` 要用管理员跑**:非管理员会话**不一定看得见** `GiteaActionsRunner`
> (本次排障就被这一点误导过一次,以为任务从未注册)。脚本在非管理员下会额外提示这一点。
> 🔴 **改这个脚本时守住两条**(都是踩出来的):
> ① `StartWhenAvailable` 是**任务设置**属性(`MSFT_TaskSettings`,被 `MSFT_TaskSettings3` 继承),
> **触发器上没有这个属性**——写 `$trigger.StartWhenAvailable` 会抛 `SetValueInvocationException`
> 中断脚本(实测,且 CIM 属性列表里看不到它,只能直接赋值);
> ② **必须"先构建全部对象、后注销旧任务"**——早期版本先 `Unregister` 再构建,一旦构建阶段抛异常,
> 旧任务已被删、新任务没建成,CI 直接裸奔。
注册成功后,回到 **仓库 → Settings → Actions → Runners**,应看到 `win-stm32-ci`
状态为 **Idle / 绿色**。
@@ -443,6 +468,8 @@ github.com 会在 checkout 步骤卡住/失败。三种处置:
| 日期 | 变更 |
|---|---|
| 2026-10-11 | **bootstrap 自身修复**:`StartWhenAvailable` 误设到**触发器**上(该属性属**任务设置**,触发器没有)→ 脚本在"已注销旧任务、尚未注册新任务"之间抛 `SetValueInvocationException`,把 runner 任务删没了。现改为①先构建 action/trigger/settings、**后**注销旧任务,②`Register-ScheduledTask` 失败时显式告警"旧任务已删、CI 裸奔",③新增 **`-DryRun`**(免管理员即可全量校验任务对象),④`-Status` 在非管理员下额外提示"看不见任务 ≠ 任务不存在"。同时更正根因:旧任务其实存在,但跑的是**单次** `daemon`——daemon 属"正常退出"而非"失败",计划任务的失败重启**救不到**它,必须用看门狗循环重拉 |
|---|---|
| 2026-10-11 | **修"很多任务不执行"(runner 静默停摆)+ 补 gitlint 门禁**:① §2.4 重写——`gitea-runner` v4 的 `daemon` **无服务支持、首次 Declare 失败即退出进程**(日志实证),Gitea/机器重启一次 CI 就停摆,**run 16~21 共 12 个 job 零日志**(`cancelled`/`queued`,叠加 `cancel-in-progress` 后表现为"任务凭空消失"而非失败);新增 **`scripts/ci_runner_bootstrap.ps1`**(等 Gitea 就绪 + 崩溃自拉起的看门狗 + 开机计划任务 + `-SetEnv` 写机器级 `DTU_*` + `-Status` 只读体检)。② §3.1 重写——SYSTEM 可见性三重坑(PATH python 是 msys64 无 pre-commit / 用户级 site 对 SYSTEM 不可见 / `.venv` 已 gitignore)是 run 14~15 L0 全灭根因(`33a4aa4` 把候选收窄成这两个、又删了 cppcheck 目录兜底,而机器级 `DTU_*` 从未设置);探测收进 **`scripts/ci_find_python.ps1`**(两 job 共用、可离线自测、失败列出全部候选)并恢复机器级安装兜底。③ 新增 **gitlint 提交信息门禁**:钩子定义 **17** 个而 CI 只报 **16** 个,缺的正是声明 `stages: [commit-msg]` 的 gitlint(`--all-files` 跑不到,且表现为全绿 → 长期无人发现)→ **`scripts/ci_gitlint_range.ps1`** 逐条校验本次 push/PR 范围(消息文件须写在仓库内 + UTF-8 无 BOM)。④ `GITHUB_ENV/PATH/OUTPUT` 写入统一显式 `-Encoding ascii`(PS 5.1 `Out-File -Append` 默认 **UTF-16LE**,实测落盘 `42 00 42 00 42 00 3D 00 31 00`)。§5 清单 8→10 条、§6 排障表 +5 行、§7 维护项重写 |
|---|---|
| 2026-10-02 | **S3 + S14**:新增 §0.0「CI 不覆盖的体积门禁」醒目声明;新增 §4.4「产物存档与结果汇总」(零依赖 `Tee-Object` + `$GITHUB_STEP_SUMMARY` + `::notice::` 汇总行,不用 `actions/upload-artifact`;记录 `$ErrorActionPreference='Stop'` 与 `2>&1 \| Tee-Object` 的陷阱及处置)。§5 验证清单(7→8 条)与 §6 排障表同步更新 |
+94 -23
View File
@@ -57,6 +57,11 @@
.PARAMETER Status
Only report: task state, daemon process, Gitea reachability.
.PARAMETER DryRun
Build and validate the action / trigger / settings objects and print what
WOULD be done, writing and registering nothing. This is also the only way
to verify this script without elevation.
.PARAMETER Force
With -SetEnv, overwrite existing machine-level values.
@@ -74,6 +79,7 @@ param(
[switch]$SetEnv,
[switch]$StartNow,
[switch]$Status,
[switch]$DryRun,
[switch]$Force
)
@@ -154,13 +160,63 @@ function Find-MsvcCl {
return $null
}
# Builds the task objects in ONE place so -DryRun validates exactly what the
# real run registers.
#
# MEASURED TRAP (2026-10-11, hit on the user's machine): `StartWhenAvailable`
# is a TASK SETTINGS property (MSFT_TaskSettings, inherited by
# MSFT_TaskSettings3) -- it does NOT exist on triggers. Assigning it to the
# trigger aborts the script with a SetValueInvocationException along the lines
# of "setting StartWhenAvailable threw an exception: property
# 'StartWhenAvailable' is not found on this object" (message localized),
# and, worse, that happened AFTER the old task had already been unregistered,
# leaving CI with no task at all. Hence: build everything first, only then
# unregister the old task.
function New-WatchdogTaskPlan {
param([string]$Launcher, [string]$WorkDir)
$action = New-ScheduledTaskAction `
-Execute 'powershell.exe' `
-Argument "-NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File `"$Launcher`"" `
-WorkingDirectory $WorkDir
# Boot trigger + a 1 minute grace period: Gitea may still be starting when
# the session-0 boot fires (and the watchdog waits for it anyway).
$trigger = New-ScheduledTaskTrigger -AtStartup
$trigger.Delay = 'PT1M'
$settings = New-ScheduledTaskSettingsSet `
-AllowStartIfOnBatteries -DontStopIfGoingOnBatteries `
-ExecutionTimeLimit ([TimeSpan]::Zero) `
-RestartCount 999 -RestartInterval (New-TimeSpan -Minutes 1) `
-MultipleInstances IgnoreNew
# StartWhenAvailable also covers "the machine was off when this was missed".
$settings.StartWhenAvailable = $true
return [pscustomobject]@{ Action = $action; Trigger = $trigger; Settings = $settings }
}
# ---------------------------------------------------------------------------
# -Status: read-only report
# ---------------------------------------------------------------------------
if ($Status) {
Info "task $TaskName"
$t = Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
if ($t) { Ok ("task state: {0}" -f $t.State) } else { Warn 'task not registered' }
if ($t) {
Ok ("task state: {0}" -f $t.State)
$li = Get-ScheduledTaskInfo -TaskName $TaskName -ErrorAction SilentlyContinue
if ($li) {
Info ("last run: {0} | last result: {1} | next run: {2}" -f `
$li.LastRunTime, ('0x{0:X}' -f $li.LastTaskResult), $li.NextRunTime)
}
}
else {
Warn "task $TaskName not found"
if (-not (Test-Admin)) {
Warn ' this session is NOT elevated: a non-admin session cannot always see'
Warn ' every task, so re-run -Status elevated before concluding it is missing'
}
}
$proc = Get-Process | Where-Object { $_.ProcessName -match 'gitea-runner|act_runner' }
if ($proc) { Ok ("daemon running: pid(s) " + ($proc.Id -join ',')) } else { Warn 'daemon not running' }
@@ -179,8 +235,9 @@ if ($Status) {
# ---------------------------------------------------------------------------
# Preconditions
# ---------------------------------------------------------------------------
if (-not (Test-Admin)) {
if (-not $DryRun -and -not (Test-Admin)) {
Fail 'not elevated -- rerun from an elevated PowerShell (this writes a scheduled task and machine env vars)'
Fail 'use -DryRun to validate the whole plan (action/trigger/settings) without elevation'
exit 2
}
@@ -210,6 +267,27 @@ if ($registeredAddress -and $registeredAddress.TrimEnd('/') -ne $GiteaUrl.TrimEn
$GiteaUrl = $registeredAddress
}
# ---------------------------------------------------------------------------
# 0. build the task plan up front; -DryRun stops here without writing anything
# ---------------------------------------------------------------------------
$plan = New-WatchdogTaskPlan -Launcher $launcher -WorkDir $RunnerDir
if ($DryRun) {
Info 'dry run -- writing and registering NOTHING'
Info ("would write launcher : {0}" -f $launcher)
Info ("would run : {0} {1}" -f $plan.Action.Execute, $plan.Action.Arguments)
Info ("would trigger : {0} delay={1}" -f $plan.Trigger.CimClass.CimClassName, $plan.Trigger.Delay)
Info ("would settings : StartWhenAvailable={0} RestartCount={1} RestartInterval={2} ExecTimeLimit={3} MultipleInstances={4}" -f `
$plan.Settings.StartWhenAvailable, $plan.Settings.RestartCount, $plan.Settings.RestartInterval,
$plan.Settings.ExecutionTimeLimit, $plan.Settings.MultipleInstances)
Info 'would register as : user SYSTEM, RunLevel Highest'
if (Test-Admin) { Ok 'elevation: OK (admin)' }
else { Warn 'elevation: NOT admin -- the real run must be started from an elevated shell' }
if (Test-Gitea $GiteaUrl) { Ok "Gitea reachable at $GiteaUrl" }
else { Warn "Gitea not reachable at $GiteaUrl -- the watchdog would keep waiting" }
exit 0
}
# ---------------------------------------------------------------------------
# 1. machine-level toolchain (SYSTEM cannot see the interactive user's PATH)
# ---------------------------------------------------------------------------
@@ -313,27 +391,20 @@ if ($existing) {
Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false
}
$action = New-ScheduledTaskAction `
-Execute 'powershell.exe' `
-Argument "-NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File `"$launcher`"" `
-WorkingDirectory $RunnerDir
# -AtStartup needs the "System" account; a boot trigger plus StartWhenAvailable
# also covers "machine was off when the task was created".
$trigger = New-ScheduledTaskTrigger -AtStartup
$trigger.Delay = 'PT1M'
$trigger.StartWhenAvailable = $true
$settings = New-ScheduledTaskSettingsSet `
-AllowStartIfOnBatteries -DontStopIfGoingOnBatteries `
-ExecutionTimeLimit ([TimeSpan]::Zero) `
-RestartCount 999 -RestartInterval (New-TimeSpan -Minutes 1) `
-MultipleInstances IgnoreNew
Register-ScheduledTask -TaskName $TaskName -Action $action -Trigger $trigger `
-Settings $settings -User 'SYSTEM' -RunLevel Highest `
-Description 'Gitea Actions runner watchdog (STM32F4-Base CI). Supervises gitea-runner daemon: waits for Gitea, restarts the daemon when it exits. Generated by scripts/ci_runner_bootstrap.ps1.' |
Out-Null
try {
Register-ScheduledTask -TaskName $TaskName -Action $plan.Action -Trigger $plan.Trigger `
-Settings $plan.Settings -User 'SYSTEM' -RunLevel Highest `
-Description 'Gitea Actions runner watchdog (STM32F4-Base CI). Supervises the gitea-runner daemon: waits for Gitea, restarts the daemon when it exits. Generated by scripts/ci_runner_bootstrap.ps1.' |
Out-Null
}
catch {
Fail "Register-ScheduledTask failed: $($_.Exception.Message)"
if ($existing) {
Fail "the previous task was ALREADY removed -- fix the cause and re-run this script immediately"
}
Fail 'until this succeeds the runner has no auto-start and CI stays dead'
exit 1
}
Ok "scheduled task registered: $TaskName (at boot +1m, as SYSTEM, unlimited runtime, restart x999/1m)"
# ---------------------------------------------------------------------------