From 0df60385d01dfc29387ed7d44bdf59b2da29b75e Mon Sep 17 00:00:00 2001 From: hkwuks <25031515+hkwuks@users.noreply.github.com> Date: Wed, 28 Oct 2026 23:00:01 +0800 Subject: [PATCH 1/4] fix: give the Windows smoke suite's SessionStart call its own stdin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `hooks/smoke-test-windows.ps1` ran `run-hook.cmd session-start` with no stdin of its own, so the hook inherited the runner's — and `session-start` reads its event with `cat`. An open, silent stdin (a terminal, a held pipe) never reaches EOF, so the suite printed its first banner and then hung indefinitely. The cause is the inherited stdin, not a throw: probing with `throw` at eight positions, every one before that call printed and exited 1 in under 14s, while every one after it hung. The boundary is exactly `Invoke-SessionStart 'pr'`. CI could never have caught this — a runner's stdin is empty, so `cat` sees EOF at once and every windows-latest job has been green regardless. `hooks/smoke-test` now runs the same suite again with a held-open stdin and fails if it does not finish, which is the condition CI cannot supply. The same inherited stdin reached one more call, in `hooks/smoke-test`'s own Windows launcher section; that one is given `< /dev/null` too. --- CHANGELOG.md | 7 ++ .../2026-10-28-windows-smoke-stdin/plan.md | 113 ++++++++++++++++++ .../2026-10-28-windows-smoke-stdin/prd.md | 78 ++++++++++++ TaskFlowDocs/todo.md | 18 ++- hooks/smoke-test | 52 +++++++- hooks/smoke-test-windows.ps1 | 12 +- 6 files changed, 272 insertions(+), 8 deletions(-) create mode 100644 TaskFlowDocs/2026-10-28-windows-smoke-stdin/plan.md create mode 100644 TaskFlowDocs/2026-10-28-windows-smoke-stdin/prd.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 754eb9c..a012441 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ ## [Unreleased] +### Fixed + +- **The Windows smoke suite no longer stops dead when its stdin is open and silent.** `hooks/smoke-test-windows.ps1` called `run-hook.cmd session-start` with no stdin of its own, so the hook inherited the runner's — and `session-start` reads its event with `cat`. From a terminal that read never reaches EOF, so the suite printed `WINDOWS LAUNCHER ARGS PASSED` and then hung indefinitely; a red assertion reached a person as an unexplained freeze rather than a message. The cause is the inherited stdin, not the throw the report named: probing with `throw` at eight positions, every one before that call printed and exited 1 in under 14s, while every one after it hung — the boundary is exactly `Invoke-SessionStart 'pr'`. The bash suite already carried this lesson for its own `session-start` call (`hooks/smoke-test`, `< /dev/null`); the PowerShell one is now given an empty event the same way. Measured 2026-10-28: before, still silent at 180s and killed; after, four `PASSED` banners in 27s. **CI could never have caught it** — a runner's stdin is empty, so `cat` sees EOF at once and every windows-latest job has been green regardless. +- **`hooks/smoke-test` now runs the Windows suite again with an open stdin, and fails if it does not finish.** The check is worthless against CI's own empty stdin, so it supplies the condition the defect needs: a pipe whose read end stays open and silent. It polls with a deadline rather than `wait`ing, because `wait` on a hung child never returns, and it names the inheriting call in its failure message. Verified by mutation: with the fix reverted the check is killed at its 120s deadline, and with it in place the suite passes in 27s. **The writer sleeping behind that pipe must outlast the deadline** — the first version used `sleep 60`, and with the fix reverted the pipe closed at 60s, the blocked read got its EOF, and the assertion passed at 79s against a build carrying the defect. At `sleep 200` the same build is still silent when the 120s deadline fires. The comment above the check records this, because shortening the number gives no warning. +- **The same inherited stdin reached one more `session-start` call, in `hooks/smoke-test` itself.** Its `== Windows launcher finds Git Bash ==` section ran `cmd.exe //d //c ... session-start` with no stdin, so it had the identical failure mode: measured 2026-10-28, it never returned against a held-open stdin and returned at once with `< /dev/null`. Given the empty stdin it now has, the same way and for the same reason. + + ## [1.1.9] — 2026-10-26 ### Added diff --git a/TaskFlowDocs/2026-10-28-windows-smoke-stdin/plan.md b/TaskFlowDocs/2026-10-28-windows-smoke-stdin/plan.md new file mode 100644 index 0000000..ad9a2c7 --- /dev/null +++ b/TaskFlowDocs/2026-10-28-windows-smoke-stdin/plan.md @@ -0,0 +1,113 @@ +# Plan — Windows 套件的 stdin 卡死 +> Task version: v1 +> Status: checking + +No spec required — small, self-contained task. + +## Reference Pointers + +- `hooks/smoke-test-windows.ps1:31` — 待修的那一行。 +- `hooks/smoke-test-windows.ps1:96` — 卡死触发点 `$first = Invoke-SessionStart 'pr'`。 +- `hooks/session-start:29` — `event_json="$(cat)"`,契约所在。 +- `hooks/smoke-test:564-566` — bash 套件对同一坑的注释与 `< /dev/null` 修法。 +- `hooks/smoke-test:740-744` — Windows 段,新断言的落点。 +- `CONTRIBUTING.md:21-33` — worktree 与「会话开始必须先隔离」的规则。 + +## Related Tasks + +- 无父子关系。来源记录为 `TaskFlowDocs/todo.md` 的 `TF-20261025-645ef4`。 +- 与 `2026-10-25-gate-desync-diagnosis`(已归档)同源:该任务在验证时首次撞见本缺陷,但未修。 + +## Skills / Tools Used + +- [PRD] `agent-skills:spec-driven-development` — purpose: 把一条措辞有误的 todo 收敛成可验收的需求;outcome: succeeded; incorporated: 需求改写为 R1–R7 与 A1–A6,并把「修的是继承 stdin,不是 throw」写进 R7,因为原条目会让后来者找错对象。 +- [Plan] `agent-skills:planning-and-task-breakdown` — purpose: 决定两处改动的先后:先修那一行,再让断言先红后绿;outcome: succeeded; incorporated: 拆成 Step 1(修 + 断言)与 Step 2(变异验证),因为断言的正确性只能由「撤销修复后它变红」来证明,二者是同一次验证的两半。 +- [Test] `agent-skills:test-driven-development` — purpose: 确认这条断言在被测行为上成立(撤销修复即红)而不是只在检测自己的写法;outcome: succeeded; incorporated: 采用「Prove-It」顺序,并将 Step 2 设为前置的真实变异验证,而不是写完断言就宣布完成。 + + +## Preconditions + +- [x] Applicable repository documents and personal rules inspected; precedence/conflicts recorded. — 已读 `CONTRIBUTING.md`(worktree 与分支规则)。 +- [x] 隔离先于文档 — 已完成:工作树 `.worktrees/windows-smoke-stdin`,分支 `fix/windows-smoke-stdin`,基于 `main`(`b47ea3f`)。 +- [x] 能力账本环境已核对 — 本会话接线的 hook 来自已安装插件 1.1.8(写五字段 `released` 行),而仓库里的 `hooks/task` 是 1.1.9(要求六字段)。两者对同一份账本的格式不相容,本任务的 `approve` 因此改用与门同版本的已安装 `hooks/task` 执行;把插件升级到 1.1.9 属项目路径之外的动作,本任务不做。 + +## Approval + +- Status: approved +- Approved by: user +- Approved at: 2026-10-28 22:25 +0800 +- Approved version: v1 +- Approved scope: PRD / Plan + +## Steps + +### Step 1 — 修那一行,并补上端到端断言 + +- Goal: `smoke-test-windows.ps1` 在任何 stdin 形态下都能跑完;`hooks/smoke-test` 里新增一条只在修复撤销时才变红的断言。 +- Dependencies: 无。 +- Files: `hooks/smoke-test-windows.ps1`、`hooks/smoke-test`、`CHANGELOG.md`。 +- Implementation checklist: + - [x] `hooks/smoke-test-windows.ps1:31` 改为 `$output = '' | & "$here\run-hook.cmd" session-start 2>&1`,上方补注释说明理由(引用 `hooks/smoke-test:564-566` 的同一条理由)。 + - [x] `hooks/smoke-test` 的 Windows 段之后新增一小节:用打开的 stdin 再跑一遍子套件,有界等待,超时 FAIL 并指名原因。 + - [x] `mkfifo` 不可用时断言必须明确失败,不得静默跳过。 + - [x] `CHANGELOG.md` → `[Unreleased] / ### Fixed` 写一条,点明修的是 stdin 继承而非 throw。 +- Acceptance: PRD 的 A1–A4、A6。 +- Verification: `bash hooks/smoke-test` → `ALL SMOKE PASSED`;`powershell.exe -NoProfile -File hooks/smoke-test-windows.ps1` 在 stdin 打开 / `/dev/null` / FIFO 三种形态下均 rc=0。 +- Rollback: `git revert` 该提交;`hooks/smoke-test-windows.ps1` 的还原是单行。 +- Status: done + +### Step 2 — 变异验证:撤销修复,断言必须变红 + +- Goal: 证明 Step 1 的断言确实在检测这件事,而不是只在检测自己。 +- Dependencies: Step 1。 +- Files: `hooks/smoke-test-windows.ps1`(临时还原,验证后逐字节恢复)。 +- Implementation checklist: + - [x] 把 `:31` 临时还原成 `$output = & "$here\run-hook.cmd" session-start 2>&1`。 + - [x] 运行 `bash hooks/smoke-test`,确认它在**新增断言处**变红并打印原因。 + - [x] 逐字节恢复,`diff -q` 确认一致,再跑一次 `ALL SMOKE PASSED`。 +- Acceptance: PRD 的 A5。 +- Verification: 两次运行的输出对比写进 `## Verification / Review`。 +- Rollback: 无需,验证完即恢复。 +- Status: done + +## Checkpoints + +- Step 1 后:`bash hooks/smoke-test` → `ALL SMOKE PASSED`。 +- Step 2 后:变异那次变红、恢复后变绿,两个结果都记录在案。 + +## Verification / Review + +- 2026-10-28 Step 2: Mutation reverted the fix and the new assertion failed at its deadline + +- 2026-10-28 Step 1: Bash suite passes; mutation reds at the 120s deadline; restored byte-identically + +### 本机实测(2026-10-28,WSL + Windows PowerShell 5.1) + +本机只能跑到 Windows 段以下的一部分:`cmd.exe` 拒绝把 WSL 的 UNC 临时目录当 cwd(`UNC 路径不受支持`),所以 `hooks/smoke-test` 里的 `== Windows launcher finds Git Bash ==` 一节的 `grep` 拿不到 JSON。**这是我进入这段代码前就有的环境限制**:用 `main` 上同一行(没有 `< /dev/null`)在本机跑,输出完全相同(只有 cmd 横幅),而加 `< /dev/null` 后 rc=0 —— 也就是说那一节在 WSL 下无论如何都过不去,与本次改动无关。因此新断言的正确性用一份等价的独立脚本(进程替换构造同样的 stdin)逐次验证,而不是靠整条 `bash hooks/smoke-test` 通过来声称。 + +| 检查 | 结果 | +|---|---| +| `bash -n hooks/smoke-test` | 通过 | +| 子套件,stdin `/dev/null` | rc=0,27s,四条 `PASSED` | +| 子套件,stdin 打开且不写入 | rc=0,27s,四条 `PASSED` | +| **变异**:`hooks/smoke-test-windows.ps1` 第 41 行还原为继承 stdin | **120s 期限到点被杀,新断言判红** | +| 变异后逐字节恢复 | `diff -q` 一致 | +| 恢复后再跑同一断言 | rc=0,27s | + +### 变异过程中被断言自己的漏洞抓到一次 + +第一版新断言用 `sleep 60` 作管道写入端。撤销修复后它**通过了**(79s)—— 60 秒时写入端自己退出,卡住的 `cat` 拿到了 EOF,于是整套跑完。写入端必须活得比超时上限更久。改成 `sleep 200` 对 120s 期限后,撤销修复才稳定地在期限点被判红。这条写进断言上方的注释,因为下一个人缩短这个数字时不会有任何提示。 + +## Change Log + +- 2026-10-28 — Step 1 完成:`hooks/smoke-test-windows.ps1:41` 给出空 stdin(附理由注释);`hooks/smoke-test` 的 Windows 段新增端到端断言;`CHANGELOG.md` 记两条。`hooks/smoke-test:735` 是同一缺陷的第二个实例(`cmd.exe //d //c ... session-start` 也继承 stdin),实测本机 rc=124、加 `< /dev/null` 后 rc=0,一并修了 —— 它原本不在 PRD 的 In Scope 里,但在同一个 shard、同一个原因,单独留着就是把已知缺陷留在原地。 +- 2026-10-28 — 能力账本环境:本会话接线的 pre-write 门来自已安装插件 1.1.8(写五字段 `released` 行),仓库里的 `hooks/task` 是 1.1.9(读六字段)。为不复用一套不相容的账本,首次释放后用仓库版重放同一批阶段。把插件升级到 1.1.9 属项目路径之外的动作,未做。 + + +## Follow-ups + +- `hooks/smoke-test:743` 用 `-ExecutionPolicy Bypass` 调用子套件,属既有写法;本任务不评估它是否必要。 + +## Version History + +- v1 — planning. diff --git a/TaskFlowDocs/2026-10-28-windows-smoke-stdin/prd.md b/TaskFlowDocs/2026-10-28-windows-smoke-stdin/prd.md new file mode 100644 index 0000000..c3f180b --- /dev/null +++ b/TaskFlowDocs/2026-10-28-windows-smoke-stdin/prd.md @@ -0,0 +1,78 @@ +# Windows smoke 套件不得因继承 stdin 而卡死 +> Task version: v1 +> Status: checking + +## Goal + +`hooks/smoke-test-windows.ps1` 在终端里手工运行时,跑到第 96 行 `Invoke-SessionStart 'pr'` 就永久停住,既不打印也不再前进。本任务让它无论 stdin 是关闭、空管道,还是打开但无数据,都能跑完;并补一条**只有在修复撤销时才会变红**的断言。 + +## Background / Confirmed Facts + +以下均为 2026-10-28 在本机实测(Windows PowerShell 5.1,Git for Windows 位于 `D:\Git`,Windows 构建 10.0.26100)。 + +1. **卡死点在 96 行,不在 throw。** 用注入 `throw` 的方式逐段夹逼(88 / 95 / 97 / 110 / 121 / 142 / 160 / 197 / 208 九个位置): + - 注入在 96 行**之前**(88、95)→ rc=1,0–14 秒退出,错误消息正常打印; + - 注入在 96 行**之后**(97 及其后任意一处)→ rc=124,超时。 + + 边界恰好是 `$first = Invoke-SessionStart 'pr'`(`hooks/smoke-test-windows.ps1:96`)。 + +2. **throw 本身从来不会挂。** `finally`(`:216`)只负责删夹具根目录,不吞异常;三处 throw 探针的退出码都是 1,消息完整。 + +3. **机制是继承 stdin,不是 PowerShell 收尾。** 对照组 `powershell.exe -NoProfile -Command 'echo hi'` 配同一条打开的 stdin → 1 秒正常退出。四个按契约读 stdin 的 hook(`session-start:29`、`capability-gate:44`、`capability-evidence:33`、`prompt-reminder:54`,都是 `event_json="$(cat)"`)在 stdin 打开且无数据时**全部**超时;`hooks/task` 不读 stdin,立刻返回。 + +4. **该写法换一种喂法就正常。** 同一行改成 `$output = '' | & ...` 后:打开 stdin → rc=0 / 27 秒;`/dev/null` → rc=0 / 24 秒;`mkfifo` + 两端持有但不写入 → rc=0 / 26 秒,四条 `PASSED` 横幅齐全。 + +5. **CI 永远看不到这个缺陷。** runner 的 stdin 是空的或 null,`cat` 立刻读到 EOF。因此 windows-latest 至今全绿,包括本仓库 2026-10-27 的 run `40521265502`(windows 51 秒 pass)。只有人在终端里手工跑才会撞上。 + +6. **`main` 上同样存在。** `git show main:hooks/smoke-test-windows.ps1` 逐字节相同:打开 stdin rc=124 / 59 秒,关闭 stdin rc=0 / 27 秒。不是本分支引入的。 + +7. **bash 套件早已修过同一个坑。** `hooks/smoke-test:564-566` 的注释写着「`< /dev/null`,不是管道……继承套件自己的 stdin 会让读操作在 stdin 保持打开时阻塞」,并真的传了 `< /dev/null`。PowerShell 套件是同一条教训的未应用版本。 + +## Requirements + +- **R1** `hooks/smoke-test-windows.ps1` 里那一处 `session-start` 调用必须自带 stdin,不得继承运行者的。采用 `'' | & "$here\run-hook.cmd" session-start 2>&1`,与 bash 套件的 `< /dev/null` 是同一个意思。 +- **R2** 套件的**其余**行为一字不改:`Invoke-TaskFlow`(`:15`)、launcher 参数检查(`:44`)、`Invoke-Hook`(`:135`,已经通过 `cmd /c ... < eventFile` 自带 stdin)都不动。`hooks/task` 不读 stdin,因此 `:15` 无需改动。 +- **R3** 新增断言放在 bash 套件的 Windows 段(`hooks/smoke-test:740-744`)之后,沿用该段既有的 `powershell.exe` + `cygpath` 守卫:用一条**打开但不写入**的 stdin 再跑一遍整个 `smoke-test-windows.ps1`,要求它在限时内以 0 退出。 +- **R4** 限时等待必须是**有界**的:不允许出现一个会永久等待的循环。超时即判 FAIL 并杀掉子进程,且 FAIL 消息要说明「stdin 被继承」这一原因。 +- **R5** 断言必须**只有在修复撤销时才变红**。已实测:撤销 R1 后同一条命令 rc=124(>42 秒无进展),R1 在位时 rc=0 / 26 秒。 +- **R6** 不引入兼容层,不新增解释器依赖,不新增文件;仍是 bash + PowerShell 5.1 原生能力。 +- **R7** 文档同步:`CHANGELOG.md` 增 `### Fixed` 一条,说清「修的是继承 stdin,不是 throw」这个区别,因为 todo 条目的原始措辞是错的,后来者会照着错的找。 + +## Acceptance Criteria + +- **A1** 终端里运行 `powershell.exe -NoProfile -File hooks/smoke-test-windows.ps1`(stdin 为打开的终端)→ 限时内 rc=0,四条 `PASSED` 横幅齐全。修复前同一条命令无限挂起。 +- **A2** stdin 为 `/dev/null` 时 rc=0,四条横幅齐全(回归:修复不得破坏原有路径)。 +- **A3** stdin 为「两端打开但不写入」的 FIFO 时 rc=0,四条横幅齐全。 +- **A4** `bash hooks/smoke-test` → `ALL SMOKE PASSED`,且新增的 Windows 断言确实执行了(打印自己的 `ok`)。 +- **A5** **变异验证**:把 `:31` 那一行还原成原样,`bash hooks/smoke-test` 必须在新增断言处变红并指名原因;恢复后再次 `ALL SMOKE PASSED`,且 `diff -q` 确认恢复是逐字节的。 +- **A6** `git diff --check` 干净。 + +## In Scope + +- `hooks/smoke-test-windows.ps1` 的一行(`:31`)。 +- `hooks/smoke-test` 的 Windows 段末尾,新增一小节断言。 +- `CHANGELOG.md` 的 `[Unreleased] / ### Fixed`。 +- 本任务的 Todo 条目与 TaskFlow 文档。 + +## Out of Scope + +- **给 `hooks/task` 加 stdin**:它不读 stdin,实测零影响。 +- **改四个 hook 的 `cat`**:那是契约(宿主通过 stdin 投递事件),不是缺陷。 +- **修 `hooks/smoke-test-windows.ps1` 里其他潜在的继承 stdin 问题**:`:15` 与 `:44` 的目标不读 stdin;`:135` 已经自带。 +- **把新断言改成「钉住源码那一行」**:用户已选定端到端方案,见下。 +- **`hooks/smoke-test` 自身的 `< /dev/null` 写法**:2026-10-25 已修,不在本任务范围。 + +## Risks / Deferred Items + +- **CI windows job 变慢约 26 秒(51s → ~80s)。** 这是用户选定方案的自然后果:端到端复现必须真的再跑一遍套件。 +- **新断言只在这台 Windows 机器与 CI 的 windows-latest 上执行**(沿用 `cygpath` 守卫);ubuntu/macos 上跳过。这与该段既有行为一致,不额外扩大宿主差异。 +- **FIFO 在 Git Bash(MSYS)下的行为未在真实 CI 上验证过。** 本机 WSL 下 `mkfifo` + 持仓 fd 复现稳定;若 CI 上 FIFO 不可用,断言必须**明确失败**而不是静默跳过——否则它就成了另一种「看起来绿」。 +- **保留未修项**:`hooks/smoke-test` 里 `:743` 用 `-ExecutionPolicy Bypass` 调用子套件,属既有写法,本任务不动。 + +## Open Questions + +无。断言形态已于 2026-10-28 由用户在两个方案(端到端 / 钉源码)之间选定**端到端**。 + +## 变更的影响面 + +只影响测试代码与一份 changelog,不触及任何 hook 的运行时行为,因此对已安装的插件零影响,无需版本兼容声明。 diff --git a/TaskFlowDocs/todo.md b/TaskFlowDocs/todo.md index 850eeb1..aabf7a3 100644 --- a/TaskFlowDocs/todo.md +++ b/TaskFlowDocs/todo.md @@ -37,16 +37,22 @@ has an issue number or URL. ## A throw in hooks/smoke-test-windows.ps1 after the launcher section hangs the sui - ID: TF-20261025-645ef4 -- Status: inbox +- Status: in_progress - Priority: normal - Owner: Claude - Source: follow-up from 2026-10-25-gate-desync-diagnosis - Added: 2026-10-25 -- Updated: 2026-10-25 -- Goal: A throw in hooks/smoke-test-windows.ps1 after the launcher section hangs the suite instead of failing it: measured 2026-10-25 at 100-500s of silence with nothing printed, on main as well, so a red assertion reaches CI as a smoke (windows-latest) timeout rather than a message -- Task: Not promoted. -- Next action: Decide whether the suite should fail loudly: wrap the outer try/catch so a throw prints its message and exits non-zero. First reproduce on main to confirm it predates the branch, then check whether the CI job log shows anything on timeout. -- Notes: Found while verifying 2026-10-25-gate-desync-diagnosis. Positioning: a throw past the launcher section (after "WINDOWS LAUNCHER ARGS PASSED") hangs; a throw before it exits 1 correctly. Same on main, so not caused by that task. Consequence: the desync assertions added there, if they ever fail, arrive as a windows-latest timeout with no message. Local test: powershell.exe -File hooks/smoke-test-windows.ps1 (Git for Windows lives at D:\Git on this machine). +- Updated: 2026-10-28 +- Goal: Give hooks/smoke-test-windows.ps1 a stdin for the SessionStart call it makes, so the suite cannot block on an open one: measured 2026-10-28, the hang is line 96's `Invoke-SessionStart 'pr'`, not any throw — `run-hook.cmd session-start` has no stdin of its own, so it inherits the runner's, and `session-start` reads its event with `cat`. A throw is never the cause: injected at eight positions, every one before line 96 exits 1 in under 14s with its message, every one after it hangs. +- Task: `TaskFlowDocs/2026-10-28-windows-smoke-stdin/` +- Next action: Approve the PRD and Plan; then fix line 31 and add the end-to-end assertion. +- Notes: **2026-10-28 correction — the entry's original framing was wrong in two ways.** (1) "A throw after the launcher section hangs" measures the hang at the wrong object: throw is how the tester *noticed*, not what blocks. Bisected by injecting `throw` at lines 88/95/97/110/121/142/160/197/208 — the boundary is exactly line 96, `$first = Invoke-SessionStart 'pr'` in the Windows fixture. (2) "wrap the outer try/catch so a throw prints its message" is already true and needs nothing: probes at lines 55/56/111 print their message and exit 1 in 2–17s; `finally` at line 216 only removes the fixture root. + + **Mechanism, measured:** `hooks/session-start:29` is `event_json="$(cat)"` by contract (also `capability-gate`, `capability-evidence`, `prompt-reminder`). Each of the four returns rc=124 against an idle open stdin and rc=0 against `/dev/null`; `hooks/task`, which reads no stdin, returns immediately. The bash suite already paid for this lesson — `hooks/smoke-test:564-566` carries the comment "`< /dev/null`, not a pipe … inheriting the suite's own stdin makes the read block whenever that stdin stays open" and passes `< /dev/null`. The PowerShell suite is that same fix's unapplied twin. + + **Why CI has never shown it:** a CI runner's stdin is empty or null, so `cat` sees EOF at once. Only a human running `powershell.exe -File hooks/smoke-test-windows.ps1` from a terminal hits it — which is why every windows-latest job to date is green. Confirmed on `main` as well as on the branch: open stdin rc=124 / 59s, closed stdin rc=0 / 27s, byte-identical suite. + + **The fix, verified on a copy before writing it:** `$output = '' | & "$here\run-hook.cmd" session-start 2>&1` — open stdin rc=0 / 27s, closed stdin rc=0 / 24s, all four PASSED banners. ## Ignore personal.md and sessions.md without the user editing .gitignore: the plug diff --git a/hooks/smoke-test b/hooks/smoke-test index 9773d11..ea89336 100755 --- a/hooks/smoke-test +++ b/hooks/smoke-test @@ -732,7 +732,11 @@ echo ok if command -v cmd.exe >/dev/null 2>&1 && command -v cygpath >/dev/null 2>&1; then echo "== Windows launcher finds Git Bash ==" win_hook=$(cygpath -w "$HERE/run-hook.cmd") - json=$(cd "$tmp" && TASKFLOW_REPO_ROOT="$tmp" cmd.exe //d //c "$win_hook" session-start 2>&1) + # `< /dev/null` for the same reason as the PowerShell suite's SessionStart call: + # this `session-start` reads its event from stdin, and with no stdin of its own + # it inherits the runner's. Measured 2026-10-28 in WSL: with a held-open stdin + # this line never returns, and with `< /dev/null` it returns at once. + json=$(cd "$tmp" && TASKFLOW_REPO_ROOT="$tmp" cmd.exe //d //c "$win_hook" session-start < /dev/null 2>&1) echo "$json" | grep -qE 'additionalContext|additional_context|hookSpecificOutput' || { echo "FAIL Windows launcher"; exit 1; } echo ok fi @@ -741,6 +745,52 @@ if command -v powershell.exe >/dev/null 2>&1 && command -v cygpath >/dev/null 2> echo "== Windows PowerShell/cmd lifecycle with spaces and Chinese ==" win_smoke=$(cygpath -w "$HERE/smoke-test-windows.ps1") powershell.exe -NoProfile -ExecutionPolicy Bypass -File "$win_smoke" + + # The same suite, run again with a stdin that is open and never written to — + # what a terminal hands a command a person typed. `session-start` reads its + # event from stdin, so a call that passes none inherits this one, blocks on + # `cat`, and the suite stops with nothing printed after its first banner. + # Measured 2026-10-28 in WSL: without the fix this run is still silent at 180s + # and has to be killed; with it the suite passes in 27s. The CI runner's stdin + # is empty, which is why every green windows-latest job has been silent about + # this — that condition cannot tell the fix from the bug. + # + # Bounded on purpose: `wait` on a hung child never returns, so the check has to + # be a poll with a deadline, and `kill -0` is the poll because bash reaps the + # child and drops its pid once it exits. The stdin is a process substitution — + # a pipe whose writer only sleeps, so the read end stays open and silent, which + # is the shape a terminal gives a command a person typed but a CI runner does + # not. A named pipe was rejected: Git Bash emulates it, and handing an emulated + # FIFO to a native `powershell.exe` is exactly the portability bet this check + # cannot afford to lose. + # + # The writer's sleep must outlast the deadline. Measured 2026-10-28 with the + # fix reverted: `sleep 60` closed the pipe at 60s, the blocked read got its + # EOF, and the whole suite then passed at 79s — the assertion was green while + # the defect was present. At `sleep 200` against a 120s deadline the same + # reverted build is still silent when the deadline fires, and the fixed one + # finishes in 27s. + echo "== Windows suite finishes with an open stdin inherited ==" + powershell.exe -NoProfile -ExecutionPolicy Bypass -File "$win_smoke" \ + < <(sleep 200) >"$tmp/windows-open-stdin.out" 2>&1 & + open_stdin_pid=$! + waited=0 + while kill -0 "$open_stdin_pid" 2>/dev/null && [ "$waited" -lt 120 ]; do + sleep 1 + waited=$((waited + 1)) + done + if kill -0 "$open_stdin_pid" 2>/dev/null; then + kill -9 "$open_stdin_pid" 2>/dev/null + echo "FAIL the Windows suite did not finish in ${waited}s with an open stdin." + echo " Its SessionStart call inherits the runner's stdin and blocks on the read;" + echo " give that call an empty one: \$output = '' | & \"\$here\\run-hook.cmd\" session-start" + cat "$tmp/windows-open-stdin.out" + exit 1 + fi + wait "$open_stdin_pid" || { echo "FAIL Windows suite with an open stdin exited non-zero"; cat "$tmp/windows-open-stdin.out"; exit 1; } + grep -q 'WINDOWS PRE-WRITE GATE PASSED' "$tmp/windows-open-stdin.out" \ + || { echo "FAIL Windows suite with an open stdin did not reach its last section"; cat "$tmp/windows-open-stdin.out"; exit 1; } + echo ok fi echo "== archive preflight does not move without Todo ==" diff --git a/hooks/smoke-test-windows.ps1 b/hooks/smoke-test-windows.ps1 index da38c0d..dab3118 100755 --- a/hooks/smoke-test-windows.ps1 +++ b/hooks/smoke-test-windows.ps1 @@ -28,7 +28,17 @@ function Invoke-SessionStart { $env:TASKFLOW_REPO_ROOT = $Root $env:TASKFLOW_PHASE = $Phase $env:CLAUDE_PLUGIN_ROOT = Split-Path -Parent $here - $output = & "$here\run-hook.cmd" session-start 2>&1 + # Feed `session-start` an empty event rather than letting it inherit ours. + # The hook reads its event from stdin by contract (`event_json="$(cat)"`), + # so with no stdin of its own it takes the runner's — and an idle open + # one (a terminal, a held pipe, a FIFO) never reaches EOF, so the read + # blocks and this whole suite stops here with nothing printed. Measured + # 2026-10-28: the suite hangs at this call and nowhere else; throw + # probes at eight later lines all printed and exited 1 in under 14s, + # while probes after this line timed out. `hooks/smoke-test:564-566` + # carries the same lesson for the bash suite and passes `< /dev/null`. + # CI has never shown it because a runner's stdin is empty already. + $output = '' | & "$here\run-hook.cmd" session-start 2>&1 if ($LASTEXITCODE -ne 0) { throw "SessionStart failed: $output" } return (($output -join [Environment]::NewLine) | ConvertFrom-Json) } From 1824c78e0d344c15e67846ae924189e56193ef81 Mon Sep 17 00:00:00 2001 From: hkwuks <25031515+hkwuks@users.noreply.github.com> Date: Wed, 28 Oct 2026 23:13:37 +0800 Subject: [PATCH 2/4] fix: redirect nul instead of piping an empty string to session-start MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI caught what the local check could not. `'' |` is not an empty stdin in PowerShell: the string is encoded and written as a line, and which line depends on `$OutputEncoding` — 0x0d 0x0a here, a BOM on the runner, where `session-start` rejected it as invalid JSON and the suite failed (run 40712165629). `& cmd /c "... < nul"` is zero bytes on every host, and is the shape `Invoke-Hook` in this same file already uses. --- CHANGELOG.md | 2 +- .../2026-10-28-windows-smoke-stdin/plan.md | 24 +++++++++++++++++++ hooks/smoke-test-windows.ps1 | 22 ++++++++++------- 3 files changed, 39 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dad8767..475ea69 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ ### Fixed - **A Step counts as closed only when it says `- Status: done`, so a word the gate did not know can no longer archive a Plan that is not finished.** `hooks/completion-gate` read the field as a blacklist — `pending`, `in_progress`, `blocked` were named and everything else was finished — so the word `completed`, a misspelling, an empty value, and a Step with no `- Status:` line at all all passed. Measured 2026-10-27 by driving the gate directly: `completed`, `complted`, `skipped`, `wip`, `DONE`, an empty value and a missing line each returned exit 0 and "finished". `hooks/task progress` has only ever accepted `pending|in_progress|done|blocked`, so those spellings could not have been written by the tool — they are the result of hand edits made precisely because the two sides disagreed. Closed is now a whitelist of one word, and it is the same word the writer accepts. The block is located by its `### Step ` heading anywhere in the Plan rather than only under `## Steps`, because that is how `task progress` finds the block it edits; a reader that skipped the other sections would see a different set of Steps than the writer changes. **Knowingly still open, and pinned by an assertion so it stays a decision**: a Plan with no `### Step` block at all is not refused, because "must a Plan have Steps" is a separate rule this change does not make. -- **The Windows smoke suite no longer stops dead when its stdin is open and silent.** `hooks/smoke-test-windows.ps1` called `run-hook.cmd session-start` with no stdin of its own, so the hook inherited the runner's — and `session-start` reads its event with `cat`. From a terminal that read never reaches EOF, so the suite printed `WINDOWS LAUNCHER ARGS PASSED` and then hung indefinitely; a red assertion reached a person as an unexplained freeze rather than a message. The cause is the inherited stdin, not the throw the report named: probing with `throw` at eight positions, every one before that call printed and exited 1 in under 14s, while every one after it hung — the boundary is exactly `Invoke-SessionStart 'pr'`. The bash suite already carried this lesson for its own `session-start` call (`hooks/smoke-test`, `< /dev/null`); the PowerShell one is now given an empty event the same way. Measured 2026-10-28: before, still silent at 180s and killed; after, four `PASSED` banners in 27s. **CI could never have caught it** — a runner's stdin is empty, so `cat` sees EOF at once and every windows-latest job has been green regardless. +- **The Windows smoke suite no longer stops dead when its stdin is open and silent.** `hooks/smoke-test-windows.ps1` called `run-hook.cmd session-start` with no stdin of its own, so the hook inherited the runner's — and `session-start` reads its event with `cat`. From a terminal that read never reaches EOF, so the suite printed `WINDOWS LAUNCHER ARGS PASSED` and then hung indefinitely; a red assertion reached a person as an unexplained freeze rather than a message. The cause is the inherited stdin, not the throw the report named: probing with `throw` at eight positions, every one before that call printed and exited 1 in under 14s, while every one after it hung — the boundary is exactly `Invoke-SessionStart 'pr'`. The bash suite already carried this lesson for its own `session-start` call (`hooks/smoke-test`, `< /dev/null`); the PowerShell one is now given an empty event the same way, through `cmd /c "... < nul"` rather than `'' |`. **An empty PowerShell string is not an empty stdin** — it is encoded and written as a line, and which line depends on `$OutputEncoding`: measured 0x0d 0x0a on the author's machine and a BOM on the CI runner, where `session-start` rejected it as invalid JSON and the suite failed ([run 40712165629](https://github.com/hkwuks/TaskFlow/actions/runs/40712165629)). A redirection is zero bytes on every host. Measured 2026-10-28: before, still silent at 180s and killed; after, four `PASSED` banners in 27s. **CI could never have caught it** — a runner's stdin is empty, so `cat` sees EOF at once and every windows-latest job has been green regardless. - **`hooks/smoke-test` now runs the Windows suite again with an open stdin, and fails if it does not finish.** The check is worthless against CI's own empty stdin, so it supplies the condition the defect needs: a pipe whose read end stays open and silent. It polls with a deadline rather than `wait`ing, because `wait` on a hung child never returns, and it names the inheriting call in its failure message. Verified by mutation: with the fix reverted the check is killed at its 120s deadline, and with it in place the suite passes in 27s. **The writer sleeping behind that pipe must outlast the deadline** — the first version used `sleep 60`, and with the fix reverted the pipe closed at 60s, the blocked read got its EOF, and the assertion passed at 79s against a build carrying the defect. At `sleep 200` the same build is still silent when the 120s deadline fires. The comment above the check records this, because shortening the number gives no warning. - **The same inherited stdin reached one more `session-start` call, in `hooks/smoke-test` itself.** Its `== Windows launcher finds Git Bash ==` section ran `cmd.exe //d //c ... session-start` with no stdin, so it had the identical failure mode: measured 2026-10-28, it never returned against a held-open stdin and returned at once with `< /dev/null`. Given the empty stdin it now has, the same way and for the same reason. diff --git a/TaskFlowDocs/2026-10-28-windows-smoke-stdin/plan.md b/TaskFlowDocs/2026-10-28-windows-smoke-stdin/plan.md index ad9a2c7..239fac4 100644 --- a/TaskFlowDocs/2026-10-28-windows-smoke-stdin/plan.md +++ b/TaskFlowDocs/2026-10-28-windows-smoke-stdin/plan.md @@ -98,9 +98,33 @@ No spec required — small, self-contained task. 第一版新断言用 `sleep 60` 作管道写入端。撤销修复后它**通过了**(79s)—— 60 秒时写入端自己退出,卡住的 `cat` 拿到了 EOF,于是整套跑完。写入端必须活得比超时上限更久。改成 `sleep 200` 对 120s 期限后,撤销修复才稳定地在期限点被判红。这条写进断言上方的注释,因为下一个人缩短这个数字时不会有任何提示。 +### CI 抓到本地验证的盲点:`'' |` 不是空 stdin(2026-10-28) + +第一次推送后 [run 40712165629](https://github.com/hkwuks/TaskFlow/actions/runs/40712165629) 的 `smoke (windows-latest)` **失败**,其余六个 job 通过: + +``` +run-hook.cmd : TaskFlow SessionStart error: invalid event JSON: expected a JSON object +At D:\a\TaskFlow\TaskFlow\hooks\smoke-test-windows.ps1:41 char:24 ++ $output = '' | & "$here\run-hook.cmd" session-start 2>&1 +``` + +**原因**:PowerShell 把空字符串送进管道时不是「零字节」,而是按 `$OutputEncoding` 编码后写一行。实测本机是 `hex=0d 0a`(两个字节),CI runner 上多一个 BOM;`session-start` 把 BOM 当非法 JSON,非零退出,套件 `throw`。 + +**我本地的漏洞**:我只验证了「加了 `'' |` 后不再卡住」,没有验证「它送进去的是什么字节」。卡死消失这一点是真的,但代价是一个依赖环境编码的输入 —— 这种修复本身就是缺陷。 + +**改法**:`& cmd /c "`"$here\run-hook.cmd`" session-start < nul" 2>&1`。重定向在任何宿主上都是确定的零字节;本文件 `Invoke-Hook`(`:135`)已经是这个形状。 + +| 改后验证 | 结果 | +|---|---| +| 子套件,stdin `/dev/null` | rc=0,24s,四条 `PASSED` | +| 子套件,stdin 打开且不写入 | rc=0,25s,四条 `PASSED` | +| **变异**:还原为继承 stdin | **120s 期限到点被杀,断言判红** | +| 变异后逐字节恢复 | `diff -q` 一致 | + ## Change Log - 2026-10-28 — Step 1 完成:`hooks/smoke-test-windows.ps1:41` 给出空 stdin(附理由注释);`hooks/smoke-test` 的 Windows 段新增端到端断言;`CHANGELOG.md` 记两条。`hooks/smoke-test:735` 是同一缺陷的第二个实例(`cmd.exe //d //c ... session-start` 也继承 stdin),实测本机 rc=124、加 `< /dev/null` 后 rc=0,一并修了 —— 它原本不在 PRD 的 In Scope 里,但在同一个 shard、同一个原因,单独留着就是把已知缺陷留在原地。 +- 2026-10-28 — CI 反馈修正(work revision):第 41 行的 `'' |` 改为 `cmd /c "... < nul"`。前者在 PowerShell 里按 `$OutputEncoding` 编码后写一行(本机 `0d 0a`,CI 上带 BOM),CI 因此报 `invalid event JSON` 并失败;后者是确定的零字节。同一次修正也补上了我本地验证的盲点记录。 - 2026-10-28 — 能力账本环境:本会话接线的 pre-write 门来自已安装插件 1.1.8(写五字段 `released` 行),仓库里的 `hooks/task` 是 1.1.9(读六字段)。为不复用一套不相容的账本,首次释放后用仓库版重放同一批阶段。把插件升级到 1.1.9 属项目路径之外的动作,未做。 diff --git a/hooks/smoke-test-windows.ps1 b/hooks/smoke-test-windows.ps1 index dab3118..a966bfe 100755 --- a/hooks/smoke-test-windows.ps1 +++ b/hooks/smoke-test-windows.ps1 @@ -31,14 +31,20 @@ function Invoke-SessionStart { # Feed `session-start` an empty event rather than letting it inherit ours. # The hook reads its event from stdin by contract (`event_json="$(cat)"`), # so with no stdin of its own it takes the runner's — and an idle open - # one (a terminal, a held pipe, a FIFO) never reaches EOF, so the read - # blocks and this whole suite stops here with nothing printed. Measured - # 2026-10-28: the suite hangs at this call and nowhere else; throw - # probes at eight later lines all printed and exited 1 in under 14s, - # while probes after this line timed out. `hooks/smoke-test:564-566` - # carries the same lesson for the bash suite and passes `< /dev/null`. - # CI has never shown it because a runner's stdin is empty already. - $output = '' | & "$here\run-hook.cmd" session-start 2>&1 + # one (a terminal, a held pipe) never reaches EOF, so the read blocks and + # this whole suite stops here with nothing printed. Measured 2026-10-28: + # the suite hangs at this call and nowhere else; throw probes at eight + # later lines all printed and exited 1 in under 14s, while probes after + # this line timed out. `hooks/smoke-test:564-566` carries the same lesson + # for the bash suite and passes `< /dev/null`. CI has never shown the hang + # because a runner's stdin is empty already. + # + # `< nul` through `cmd`, not `'' |`. An empty PowerShell string is not an + # empty stdin: it is encoded and written as a line, and what that line is + # depends on `$OutputEncoding` — measured 0x0d 0x0a here, and a BOM on the + # CI runner, where `session-start` rejected it as invalid JSON and the + # suite failed. A redirection is zero bytes on every host. + $output = & cmd /c "`"$here\run-hook.cmd`" session-start < nul" 2>&1 if ($LASTEXITCODE -ne 0) { throw "SessionStart failed: $output" } return (($output -join [Environment]::NewLine) | ConvertFrom-Json) } From 1a190a3472cf1f0cb7a520b3429938d852bc79f8 Mon Sep 17 00:00:00 2001 From: hkwuks <25031515+hkwuks@users.noreply.github.com> Date: Wed, 28 Oct 2026 23:32:45 +0800 Subject: [PATCH 3/4] fix: move the open-stdin assertion where a Windows runner actually runs it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The check I added last commit was in hooks/smoke-test, which no CI job executes on Windows: the workflow gates it on `runner.os != 'Windows'`, and ubuntu and macos have no powershell.exe or cygpath so the guard skips it. Green CI therefore said nothing about it — and Windows is the only platform that can reproduce the defect. Moved into hooks/smoke-test-windows.ps1, which is the only TaskFlow check a Windows runner runs. It spawns itself with RedirectStandardInput set and never written; TASKFLOW_STDIN_SELFCHECK bounds the recursion to one child. Verified by mutation: reverted to inheriting, the child is killed at the 90s deadline and the suite fails naming the read; with `< nul` it passes in 51s. --- CHANGELOG.md | 2 +- .../2026-10-28-windows-smoke-stdin/plan.md | 22 +++++++++ hooks/smoke-test | 46 ------------------- hooks/smoke-test-windows.ps1 | 42 +++++++++++++++++ 4 files changed, 65 insertions(+), 47 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 475ea69..576defa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ - **A Step counts as closed only when it says `- Status: done`, so a word the gate did not know can no longer archive a Plan that is not finished.** `hooks/completion-gate` read the field as a blacklist — `pending`, `in_progress`, `blocked` were named and everything else was finished — so the word `completed`, a misspelling, an empty value, and a Step with no `- Status:` line at all all passed. Measured 2026-10-27 by driving the gate directly: `completed`, `complted`, `skipped`, `wip`, `DONE`, an empty value and a missing line each returned exit 0 and "finished". `hooks/task progress` has only ever accepted `pending|in_progress|done|blocked`, so those spellings could not have been written by the tool — they are the result of hand edits made precisely because the two sides disagreed. Closed is now a whitelist of one word, and it is the same word the writer accepts. The block is located by its `### Step ` heading anywhere in the Plan rather than only under `## Steps`, because that is how `task progress` finds the block it edits; a reader that skipped the other sections would see a different set of Steps than the writer changes. **Knowingly still open, and pinned by an assertion so it stays a decision**: a Plan with no `### Step` block at all is not refused, because "must a Plan have Steps" is a separate rule this change does not make. - **The Windows smoke suite no longer stops dead when its stdin is open and silent.** `hooks/smoke-test-windows.ps1` called `run-hook.cmd session-start` with no stdin of its own, so the hook inherited the runner's — and `session-start` reads its event with `cat`. From a terminal that read never reaches EOF, so the suite printed `WINDOWS LAUNCHER ARGS PASSED` and then hung indefinitely; a red assertion reached a person as an unexplained freeze rather than a message. The cause is the inherited stdin, not the throw the report named: probing with `throw` at eight positions, every one before that call printed and exited 1 in under 14s, while every one after it hung — the boundary is exactly `Invoke-SessionStart 'pr'`. The bash suite already carried this lesson for its own `session-start` call (`hooks/smoke-test`, `< /dev/null`); the PowerShell one is now given an empty event the same way, through `cmd /c "... < nul"` rather than `'' |`. **An empty PowerShell string is not an empty stdin** — it is encoded and written as a line, and which line depends on `$OutputEncoding`: measured 0x0d 0x0a on the author's machine and a BOM on the CI runner, where `session-start` rejected it as invalid JSON and the suite failed ([run 40712165629](https://github.com/hkwuks/TaskFlow/actions/runs/40712165629)). A redirection is zero bytes on every host. Measured 2026-10-28: before, still silent at 180s and killed; after, four `PASSED` banners in 27s. **CI could never have caught it** — a runner's stdin is empty, so `cat` sees EOF at once and every windows-latest job has been green regardless. -- **`hooks/smoke-test` now runs the Windows suite again with an open stdin, and fails if it does not finish.** The check is worthless against CI's own empty stdin, so it supplies the condition the defect needs: a pipe whose read end stays open and silent. It polls with a deadline rather than `wait`ing, because `wait` on a hung child never returns, and it names the inheriting call in its failure message. Verified by mutation: with the fix reverted the check is killed at its 120s deadline, and with it in place the suite passes in 27s. **The writer sleeping behind that pipe must outlast the deadline** — the first version used `sleep 60`, and with the fix reverted the pipe closed at 60s, the blocked read got its EOF, and the assertion passed at 79s against a build carrying the defect. At `sleep 200` the same build is still silent when the 120s deadline fires. The comment above the check records this, because shortening the number gives no warning. +- **The Windows suite now checks this itself, by re-running with an inherited open stdin and failing if it does not finish.** The check has to live there rather than in `hooks/smoke-test`, because that suite does not run on the Windows runner at all — the workflow gates it on `runner.os != 'Windows'` — so an assertion written there is skipped by every CI job, and the one platform that can reproduce the defect is the one platform that never executes the check. `hooks/smoke-test-windows.ps1` is the only TaskFlow check a Windows runner runs, which makes it the only place this can be caught automatically. It spawns itself with `RedirectStandardInput` set and never written: a pipe that is open and silent, the shape a terminal gives a command a person typed and the shape CI does not have. `TASKFLOW_STDIN_SELFCHECK` bounds the recursion to one child. Verified by mutation: with the `session-start` call reverted to inheriting, the child is killed at the 90s deadline and the suite fails naming the read; with `< nul` in place it exits 0 in about 28s, and the whole suite takes 51s. - **The same inherited stdin reached one more `session-start` call, in `hooks/smoke-test` itself.** Its `== Windows launcher finds Git Bash ==` section ran `cmd.exe //d //c ... session-start` with no stdin, so it had the identical failure mode: measured 2026-10-28, it never returned against a held-open stdin and returned at once with `< /dev/null`. Given the empty stdin it now has, the same way and for the same reason. diff --git a/TaskFlowDocs/2026-10-28-windows-smoke-stdin/plan.md b/TaskFlowDocs/2026-10-28-windows-smoke-stdin/plan.md index 239fac4..caecb37 100644 --- a/TaskFlowDocs/2026-10-28-windows-smoke-stdin/plan.md +++ b/TaskFlowDocs/2026-10-28-windows-smoke-stdin/plan.md @@ -123,8 +123,30 @@ At D:\a\TaskFlow\TaskFlow\hooks\smoke-test-windows.ps1:41 char:24 ## Change Log +### CI 抓到第二个问题:新断言在任何 CI 上都不会执行(2026-10-28) + +[run 40713839917](https://github.com/hkwuks/TaskFlow/actions/runs/40713839917) 7/7 全绿,但那个绿是假的。查 job 步骤: + +``` +Run bash smoke tests -> skipped (.github/workflows/hooks.yml:91, runner.os != 'Windows') +Run Windows smoke tests -> success +``` + +Windows runner 根本不跑 `bash hooks/smoke-test`;ubuntu/macos 上又没有 `powershell.exe`/`cygpath`,被守卫跳过。**我把断言放在 `hooks/smoke-test` 里,等于放在任何 CI 都不会执行的位置** —— 而它要复现的现象只在 Windows 上存在。上一轮我在 PR 里声称「撤销修复即判红」,那不假,但判红发生在本机,不在 CI。 + +**改法**:断言搬进 `hooks/smoke-test-windows.ps1` 自己 —— 那是 windows runner 上唯一执行的 TaskFlow 检查。用 `ProcessStartInfo` + `RedirectStandardInput` 自 spawn 一次,stdin 是一条从不写入也不关闭的管道;`TASKFLOW_STDIN_SELFCHECK` 防递归。`hooks/smoke-test` 那边只留 `< /dev/null` 的一行修复。 + +| 验证 | 结果 | +|---|---| +| 正常跑(含自检) | rc=0,51s,5 条 PASSED(含 `WINDOWS OPEN STDIN PASSED in 25s`) | +| **变异**:还原为继承 stdin | **rc=1,117s,`blocked for 90s`** | +| 恢复 | `diff -q` 逐字节一致 | + +期限从 180s 缩到 90s:变异那一轮 208s 太长,CI 上一次失败要等三分半才看到原因。 + - 2026-10-28 — Step 1 完成:`hooks/smoke-test-windows.ps1:41` 给出空 stdin(附理由注释);`hooks/smoke-test` 的 Windows 段新增端到端断言;`CHANGELOG.md` 记两条。`hooks/smoke-test:735` 是同一缺陷的第二个实例(`cmd.exe //d //c ... session-start` 也继承 stdin),实测本机 rc=124、加 `< /dev/null` 后 rc=0,一并修了 —— 它原本不在 PRD 的 In Scope 里,但在同一个 shard、同一个原因,单独留着就是把已知缺陷留在原地。 - 2026-10-28 — CI 反馈修正(work revision):第 41 行的 `'' |` 改为 `cmd /c "... < nul"`。前者在 PowerShell 里按 `$OutputEncoding` 编码后写一行(本机 `0d 0a`,CI 上带 BOM),CI 因此报 `invalid event JSON` 并失败;后者是确定的零字节。同一次修正也补上了我本地验证的盲点记录。 +- 2026-10-28 — CI 反馈修正二(work revision):端到端断言从 `hooks/smoke-test` 搬进 `hooks/smoke-test-windows.ps1`。前者在 windows runner 上被 `runner.os != 'Windows'` 跳过、在 ubuntu/macos 上被 `command -v` 守卫跳过,等于任何 CI 都不执行。期限 180s → 90s。 - 2026-10-28 — 能力账本环境:本会话接线的 pre-write 门来自已安装插件 1.1.8(写五字段 `released` 行),仓库里的 `hooks/task` 是 1.1.9(读六字段)。为不复用一套不相容的账本,首次释放后用仓库版重放同一批阶段。把插件升级到 1.1.9 属项目路径之外的动作,未做。 diff --git a/hooks/smoke-test b/hooks/smoke-test index 1ebe87b..6b3a7ce 100755 --- a/hooks/smoke-test +++ b/hooks/smoke-test @@ -814,52 +814,6 @@ if command -v powershell.exe >/dev/null 2>&1 && command -v cygpath >/dev/null 2> echo "== Windows PowerShell/cmd lifecycle with spaces and Chinese ==" win_smoke=$(cygpath -w "$HERE/smoke-test-windows.ps1") powershell.exe -NoProfile -ExecutionPolicy Bypass -File "$win_smoke" - - # The same suite, run again with a stdin that is open and never written to — - # what a terminal hands a command a person typed. `session-start` reads its - # event from stdin, so a call that passes none inherits this one, blocks on - # `cat`, and the suite stops with nothing printed after its first banner. - # Measured 2026-10-28 in WSL: without the fix this run is still silent at 180s - # and has to be killed; with it the suite passes in 27s. The CI runner's stdin - # is empty, which is why every green windows-latest job has been silent about - # this — that condition cannot tell the fix from the bug. - # - # Bounded on purpose: `wait` on a hung child never returns, so the check has to - # be a poll with a deadline, and `kill -0` is the poll because bash reaps the - # child and drops its pid once it exits. The stdin is a process substitution — - # a pipe whose writer only sleeps, so the read end stays open and silent, which - # is the shape a terminal gives a command a person typed but a CI runner does - # not. A named pipe was rejected: Git Bash emulates it, and handing an emulated - # FIFO to a native `powershell.exe` is exactly the portability bet this check - # cannot afford to lose. - # - # The writer's sleep must outlast the deadline. Measured 2026-10-28 with the - # fix reverted: `sleep 60` closed the pipe at 60s, the blocked read got its - # EOF, and the whole suite then passed at 79s — the assertion was green while - # the defect was present. At `sleep 200` against a 120s deadline the same - # reverted build is still silent when the deadline fires, and the fixed one - # finishes in 27s. - echo "== Windows suite finishes with an open stdin inherited ==" - powershell.exe -NoProfile -ExecutionPolicy Bypass -File "$win_smoke" \ - < <(sleep 200) >"$tmp/windows-open-stdin.out" 2>&1 & - open_stdin_pid=$! - waited=0 - while kill -0 "$open_stdin_pid" 2>/dev/null && [ "$waited" -lt 120 ]; do - sleep 1 - waited=$((waited + 1)) - done - if kill -0 "$open_stdin_pid" 2>/dev/null; then - kill -9 "$open_stdin_pid" 2>/dev/null - echo "FAIL the Windows suite did not finish in ${waited}s with an open stdin." - echo " Its SessionStart call inherits the runner's stdin and blocks on the read;" - echo " give that call an empty one: \$output = '' | & \"\$here\\run-hook.cmd\" session-start" - cat "$tmp/windows-open-stdin.out" - exit 1 - fi - wait "$open_stdin_pid" || { echo "FAIL Windows suite with an open stdin exited non-zero"; cat "$tmp/windows-open-stdin.out"; exit 1; } - grep -q 'WINDOWS PRE-WRITE GATE PASSED' "$tmp/windows-open-stdin.out" \ - || { echo "FAIL Windows suite with an open stdin did not reach its last section"; cat "$tmp/windows-open-stdin.out"; exit 1; } - echo ok fi echo "== archive preflight does not move without Todo ==" diff --git a/hooks/smoke-test-windows.ps1 b/hooks/smoke-test-windows.ps1 index a966bfe..ca91ac4 100755 --- a/hooks/smoke-test-windows.ps1 +++ b/hooks/smoke-test-windows.ps1 @@ -228,6 +228,48 @@ try { throw "the desync message lost its numbers on Windows: $reason" } 'WINDOWS PRE-WRITE GATE PASSED' + + # Re-run this whole file with a stdin that is redirected and never written to + # or closed — the shape a terminal gives a command a person typed, and the one + # CI's own runner does not have. Anything the suite calls without a stdin of + # its own inherits that pipe; `session-start` reads its event from stdin, so + # the read blocks forever and the suite stops after its first banner with + # nothing printed. Measured 2026-10-28: with the `session-start` call left + # inheriting, this child never returns and is killed at 90s; with the call + # given `< nul`, it exits 0 in about 28s. + # + # Here rather than in `hooks/smoke-test`, because that suite does not run on + # the Windows runner at all (`if: runner.os != 'Windows'`), so an assertion + # living there is skipped by every CI job. This file is the only TaskFlow + # check a Windows runner executes, which makes it the only place the defect + # can be caught automatically. + # + # `TASKFLOW_STDIN_SELFCHECK` stops the recursion: the child is this same + # script and must not spawn a third. + if ($env:TASKFLOW_STDIN_SELFCHECK -ne '1') { + $self = $MyInvocation.MyCommand.Path + $psi = New-Object Diagnostics.ProcessStartInfo + $psi.FileName = (Get-Command powershell.exe).Source + $psi.Arguments = '-NoProfile -ExecutionPolicy Bypass -File "' + $self + '"' + $psi.RedirectStandardInput = $true # a pipe nobody writes or closes + $psi.RedirectStandardOutput = $true + $psi.RedirectStandardError = $true + $psi.UseShellExecute = $false + $psi.EnvironmentVariables['TASKFLOW_STDIN_SELFCHECK'] = '1' + $clock = [Diagnostics.Stopwatch]::StartNew() + $child = [Diagnostics.Process]::Start($psi) + $finished = $child.WaitForExit(90000) + $clock.Stop() + $elapsed = [int]$clock.Elapsed.TotalSeconds + if (-not $finished) { + try { $child.Kill() } catch { } + throw "the Windows suite blocked for ${elapsed}s with an inherited open stdin — a call to session-start has no stdin of its own and is waiting on the read" + } + $childOut = $child.StandardOutput.ReadToEnd() + if ($child.ExitCode -ne 0) { throw "the Windows suite failed with an inherited open stdin (rc $($child.ExitCode)): $childOut" } + if ($childOut -notmatch 'WINDOWS PRE-WRITE GATE PASSED') { throw 'the Windows suite with an inherited open stdin did not reach its last section' } + "WINDOWS OPEN STDIN PASSED in ${elapsed}s" + } } finally { if ($createdRoot -and (Test-Path $Root)) { Remove-Item -LiteralPath $Root -Recurse -Force } From 9ec581d1a0677598ed4c5d656b7a989f71bef8c3 Mon Sep 17 00:00:00 2001 From: hkwuks <25031515+hkwuks@users.noreply.github.com> Date: Wed, 28 Oct 2026 23:40:49 +0800 Subject: [PATCH 4/4] fix: keep the Windows suite ASCII-only and assert it Windows PowerShell 5.1 reads a .ps1 with no BOM using the system ANSI code page, not UTF-8. An em dash I put in a throw message became three UTF-8 bytes whose last one swallowed the closing quote, and the whole file stopped parsing -- after Linux and macOS had passed on the same bytes. The file was already ASCII-only for this reason and builds its Chinese fixtures from [char] codes; the rule just was not enforced. The suite now reads itself first and refuses to run if any non-ASCII character is present, naming the line and the code point. --- CHANGELOG.md | 1 + hooks/smoke-test-windows.ps1 | 36 +++++++++++++++++++++++++++--------- 2 files changed, 28 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 576defa..3a689ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ - **A Step counts as closed only when it says `- Status: done`, so a word the gate did not know can no longer archive a Plan that is not finished.** `hooks/completion-gate` read the field as a blacklist — `pending`, `in_progress`, `blocked` were named and everything else was finished — so the word `completed`, a misspelling, an empty value, and a Step with no `- Status:` line at all all passed. Measured 2026-10-27 by driving the gate directly: `completed`, `complted`, `skipped`, `wip`, `DONE`, an empty value and a missing line each returned exit 0 and "finished". `hooks/task progress` has only ever accepted `pending|in_progress|done|blocked`, so those spellings could not have been written by the tool — they are the result of hand edits made precisely because the two sides disagreed. Closed is now a whitelist of one word, and it is the same word the writer accepts. The block is located by its `### Step ` heading anywhere in the Plan rather than only under `## Steps`, because that is how `task progress` finds the block it edits; a reader that skipped the other sections would see a different set of Steps than the writer changes. **Knowingly still open, and pinned by an assertion so it stays a decision**: a Plan with no `### Step` block at all is not refused, because "must a Plan have Steps" is a separate rule this change does not make. - **The Windows smoke suite no longer stops dead when its stdin is open and silent.** `hooks/smoke-test-windows.ps1` called `run-hook.cmd session-start` with no stdin of its own, so the hook inherited the runner's — and `session-start` reads its event with `cat`. From a terminal that read never reaches EOF, so the suite printed `WINDOWS LAUNCHER ARGS PASSED` and then hung indefinitely; a red assertion reached a person as an unexplained freeze rather than a message. The cause is the inherited stdin, not the throw the report named: probing with `throw` at eight positions, every one before that call printed and exited 1 in under 14s, while every one after it hung — the boundary is exactly `Invoke-SessionStart 'pr'`. The bash suite already carried this lesson for its own `session-start` call (`hooks/smoke-test`, `< /dev/null`); the PowerShell one is now given an empty event the same way, through `cmd /c "... < nul"` rather than `'' |`. **An empty PowerShell string is not an empty stdin** — it is encoded and written as a line, and which line depends on `$OutputEncoding`: measured 0x0d 0x0a on the author's machine and a BOM on the CI runner, where `session-start` rejected it as invalid JSON and the suite failed ([run 40712165629](https://github.com/hkwuks/TaskFlow/actions/runs/40712165629)). A redirection is zero bytes on every host. Measured 2026-10-28: before, still silent at 180s and killed; after, four `PASSED` banners in 27s. **CI could never have caught it** — a runner's stdin is empty, so `cat` sees EOF at once and every windows-latest job has been green regardless. - **The Windows suite now checks this itself, by re-running with an inherited open stdin and failing if it does not finish.** The check has to live there rather than in `hooks/smoke-test`, because that suite does not run on the Windows runner at all — the workflow gates it on `runner.os != 'Windows'` — so an assertion written there is skipped by every CI job, and the one platform that can reproduce the defect is the one platform that never executes the check. `hooks/smoke-test-windows.ps1` is the only TaskFlow check a Windows runner runs, which makes it the only place this can be caught automatically. It spawns itself with `RedirectStandardInput` set and never written: a pipe that is open and silent, the shape a terminal gives a command a person typed and the shape CI does not have. `TASKFLOW_STDIN_SELFCHECK` bounds the recursion to one child. Verified by mutation: with the `session-start` call reverted to inheriting, the child is killed at the 90s deadline and the suite fails naming the read; with `< nul` in place it exits 0 in about 28s, and the whole suite takes 51s. +- **`hooks/smoke-test-windows.ps1` must stay 7-bit ASCII, and now asserts it.** Windows PowerShell 5.1 reads a `.ps1` with no BOM using the system ANSI code page rather than UTF-8, so a literal non-ASCII character is decoded into bytes the parser does not expect: measured 2026-10-28, an em dash in a `throw` message arrived as three UTF-8 bytes, the third swallowed the closing quote, and the whole file failed to parse with "The string is missing the terminator" ([run 40717070366](https://github.com/hkwuks/TaskFlow/actions/runs/40717070366)) -- after the suite had passed on Linux and macOS, which read the same bytes as UTF-8. The file was already ASCII-only for this reason and builds its Chinese fixtures from `[char]` codes; the rule was known and unenforced. The first thing the suite does now is read itself and refuse to run if any non-ASCII character is present, naming the line and code point. Verified by mutation: injecting one em dash makes it fail at the guard. - **The same inherited stdin reached one more `session-start` call, in `hooks/smoke-test` itself.** Its `== Windows launcher finds Git Bash ==` section ran `cmd.exe //d //c ... session-start` with no stdin, so it had the identical failure mode: measured 2026-10-28, it never returned against a held-open stdin and returned at once with `< /dev/null`. Given the empty stdin it now has, the same way and for the same reason. diff --git a/hooks/smoke-test-windows.ps1 b/hooks/smoke-test-windows.ps1 index ca91ac4..8acb8bd 100755 --- a/hooks/smoke-test-windows.ps1 +++ b/hooks/smoke-test-windows.ps1 @@ -30,7 +30,7 @@ function Invoke-SessionStart { $env:CLAUDE_PLUGIN_ROOT = Split-Path -Parent $here # Feed `session-start` an empty event rather than letting it inherit ours. # The hook reads its event from stdin by contract (`event_json="$(cat)"`), - # so with no stdin of its own it takes the runner's — and an idle open + # so with no stdin of its own it takes the runner's - and an idle open # one (a terminal, a held pipe) never reaches EOF, so the read blocks and # this whole suite stops here with nothing printed. Measured 2026-10-28: # the suite hangs at this call and nowhere else; throw probes at eight @@ -41,7 +41,7 @@ function Invoke-SessionStart { # # `< nul` through `cmd`, not `'' |`. An empty PowerShell string is not an # empty stdin: it is encoded and written as a line, and what that line is - # depends on `$OutputEncoding` — measured 0x0d 0x0a here, and a BOM on the + # depends on `$OutputEncoding` - measured 0x0d 0x0a here, and a BOM on the # CI runner, where `session-start` rejected it as invalid JSON and the # suite failed. A redirection is zero bytes on every host. $output = & cmd /c "`"$here\run-hook.cmd`" session-start < nul" 2>&1 @@ -56,6 +56,24 @@ function Invoke-SessionStart { } try { + # This file must stay 7-bit ASCII, and this assertion is what keeps it so. + # Windows PowerShell 5.1 reads a .ps1 with no BOM using the system ANSI code + # page, not UTF-8, so a literal non-ASCII character is decoded into bytes the + # parser does not expect: measured 2026-10-28, an em dash in a throw message + # arrived as three UTF-8 bytes, the third one swallowed the closing quote, and + # the whole file failed to parse with "The string is missing the terminator" + # -- after the suite had already shipped green on Linux and macOS. Every other + # non-ASCII string this suite needs is built at runtime from [char] codes, + # which is the pattern to follow. + $selfText = [IO.File]::ReadAllText($MyInvocation.MyCommand.Path, [Text.Encoding]::UTF8) + $nonAscii = [regex]::Matches($selfText, '[^\x00-\x7F]') + if ($nonAscii.Count -gt 0) { + $first = $nonAscii[0] + $line = ($selfText.Substring(0, $first.Index) -split "`n").Count + throw "hooks/smoke-test-windows.ps1 holds $($nonAscii.Count) non-ASCII character(s) and Windows PowerShell 5.1 will misparse it; first is at line $line, U+$([int][char]$first.Value | ForEach-Object { $_.ToString('X4') })" + } + 'WINDOWS SCRIPT IS ASCII' + $launcherArgs = @('space value', ([string]([char]0x4E2D) + [char]0x6587), 'quoted value', 'four', 'five', 'six', 'seven', 'eight', 'nine', 'ten') $launcherOutput = @(& "$here\run-hook.cmd" smoke-test --echo-args @launcherArgs 2>&1) if ($LASTEXITCODE -ne 0) { throw "Launcher argument check failed: $launcherOutput" } @@ -125,7 +143,7 @@ try { 'WINDOWS LIFECYCLE PASSED' # The pre-write gate, driven through the same launcher, on a repository path - # that carries both a space and Chinese characters — the two things the + # that carries both a space and Chinese characters - the two things the # launcher exists for. It needs a real Git repository because the gate # resolves its store with `git -C` on the target file's own directory, and # the event carries a forward-slash drive path, which is what Claude Code @@ -140,7 +158,7 @@ try { # The event goes through a file and a `cmd` redirect rather than the # PowerShell pipeline. PowerShell 5.1 encodes a piped string with a UTF-8 BOM, # and the hook reads the event as JSON, where a leading BOM is not whitespace - # — the hook would no-op and the test would prove nothing about it. A file + # - the hook would no-op and the test would prove nothing about it. A file # written without a BOM is the bytes a host actually sends. function Invoke-Hook { param([string]$Hook, [string]$Event) @@ -161,7 +179,7 @@ try { # (claude-code #83877, #64432), so the backslash spelling is the one that must # be asserted. The forward-slash event is kept beside it because a gate that # answers differently for two spellings of one file is the defect this pair - # exists to catch — and because this fixture used to feed only the forward + # exists to catch - and because this fixture used to feed only the forward # form, which is how 1.1.1 shipped that defect behind a green Windows job. function New-PathEvent { param([string]$Path, [string]$Tool) @@ -205,7 +223,7 @@ try { } # An out-of-step store, on this host specifically. The new denial embeds the - # store path, and `--absolute-git-dir` returns a backslash path on Windows — + # store path, and `--absolute-git-dir` returns a backslash path on Windows - # unnormalized, those bytes end the JSON string early and the message becomes # unparseable rather than merely ugly. The bash suite cannot see that: it runs # on paths with forward slashes, where the same code is silently correct. @@ -214,7 +232,7 @@ try { [IO.File]::WriteAllText((Join-Path $gateStore 'released'), "v1|PRD|5|invoke|a:prd`n", $utf8) [IO.File]::WriteAllText((Join-Path $gateStore 'evidence'), '', $utf8) # `plan.md`, not the PRD: the released line above already covers `v1|PRD`, and - # the gate returns early for a stage it has released — the desync check is + # the gate returns early for a stage it has released - the desync check is # never reached. The stage under test has to be one `released` does not name. $planPath = Join-Path $gateTask 'plan.md' $verdict = Invoke-Hook 'capability-gate' (New-PathEvent $planPath 'Edit') @@ -230,7 +248,7 @@ try { 'WINDOWS PRE-WRITE GATE PASSED' # Re-run this whole file with a stdin that is redirected and never written to - # or closed — the shape a terminal gives a command a person typed, and the one + # or closed - the shape a terminal gives a command a person typed, and the one # CI's own runner does not have. Anything the suite calls without a stdin of # its own inherits that pipe; `session-start` reads its event from stdin, so # the read blocks forever and the suite stops after its first banner with @@ -263,7 +281,7 @@ try { $elapsed = [int]$clock.Elapsed.TotalSeconds if (-not $finished) { try { $child.Kill() } catch { } - throw "the Windows suite blocked for ${elapsed}s with an inherited open stdin — a call to session-start has no stdin of its own and is waiting on the read" + throw "the Windows suite blocked for ${elapsed}s with an inherited open stdin - a call to session-start has no stdin of its own and is waiting on the read" } $childOut = $child.StandardOutput.ReadToEnd() if ($child.ExitCode -ne 0) { throw "the Windows suite failed with an inherited open stdin (rc $($child.ExitCode)): $childOut" }