@zhuxixi/pi-agent-board 0.9.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.md +10 -2
  3. package/VERIFY.md +2 -1
  4. package/docs/superpowers/plans/2026-09-29-issue-145-holding-semantic-state.md +1183 -0
  5. package/docs/superpowers/plans/2026-09-29-issue-145-user-verification.md +66 -0
  6. package/docs/superpowers/plans/2026-09-30-board-delete-key-x-y-confirm.md +649 -0
  7. package/docs/superpowers/plans/2026-09-30-issue-153-runner-stop-boot-window.md +618 -0
  8. package/docs/superpowers/plans/2026-09-30-issue-95-test-discipline.md +555 -0
  9. package/docs/superpowers/plans/2026-09-30-publish-dispatch-tag-check.md +259 -0
  10. package/docs/superpowers/specs/2026-09-29-issue-145-holding-semantic-state-design.md +394 -0
  11. package/docs/superpowers/specs/2026-09-30-board-delete-key-x-y-confirm-design.md +86 -0
  12. package/docs/superpowers/specs/2026-09-30-issue-153-runner-stop-boot-window-design.md +75 -0
  13. package/docs/superpowers/specs/2026-09-30-issue-95-test-discipline-design.md +226 -0
  14. package/docs/superpowers/specs/2026-09-30-publish-dispatch-tag-check-design.md +129 -0
  15. package/package.json +1 -1
  16. package/runner/job-runner.mjs +56 -22
  17. package/runner/pty-runner-legacy.mjs +1 -1
  18. package/runner/state-coordinator.mjs +1 -1
  19. package/runner/state-runner.mjs +2 -2
  20. package/scripts/budget-audit.mjs +135 -0
  21. package/src/core/auto-state.mjs +12 -10
  22. package/src/core/coordinator-protocol.mjs +3 -1
  23. package/src/core/derive.mjs +3 -0
  24. package/src/core/evidence.mjs +4 -1
  25. package/src/core/host-probe.mjs +12 -1
  26. package/src/core/pty-attach-reconnect.mjs +12 -1
  27. package/src/core/rows.mjs +4 -0
  28. package/src/core/state-commands.mjs +62 -8
  29. package/src/core/stop-latch.mjs +41 -0
  30. package/src/core/store.mjs +8 -1
  31. package/src/core/terminal-query-sequences.mjs +26 -0
  32. package/src/core/test-knobs.mjs +28 -0
  33. package/src/core/types.mjs +5 -2
  34. package/src/core/warm-host-sweeper.mjs +13 -0
  35. package/src/runtime/service.mjs +149 -28
  36. package/src/ui/dashboard.ts +46 -1
  37. package/src/ui/pty-attach.ts +46 -3
@@ -0,0 +1,226 @@
1
+ # Spec — issue #95: real-process tests must not flake (runner stop-safety + budget/diagnosis discipline)
2
+
3
+ Status: **branch spec (refreshed 2026-09-30 late): tonight's scope = F3 → F2 → F4 only** · Base: `main@43dca95` (2026-09-30) · Route: bug → systematic-debugging
4
+
5
+ ## Delivery status (2026-09-30)
6
+
7
+ - **F1 delivered by #153 / PR #155** (squash `43dca95`, merged 23:34): stop latch + `stop_latch_replayed`; A1–A4 and A10 all green there, CR two-stream Round-1 PASS.
8
+ - **A10's convergence fix also landed in #153** (`loadRow` recorded-pid-wins-over-mirror).
9
+ - Acceptance matrix below is narrowed to what remains on THIS branch: **A5, A6, A7, A8** (+ A9 stays open as the 14-day CI observation; A11 N/A while D4 = defer; A12 N/A while D1 = O3).
10
+ - F5 (serialization) stays deferred per D1 = O3 — the observation window is running; tonight's three flake observations are its first baseline samples.
11
+ - F7 (issue trail) is complete: research conclusions + falsified-claim corrections are on the issue thread.
12
+ Research: `research/2026-09-30-runner-stop-window-race.md`, `research/2026-09-30-flake-inventory-and-cost.md`
13
+
14
+ ## 1. Root-cause revision (this round's main result)
15
+
16
+ The issue's previous framing — "CI machine is slow, the tests need bigger wall-clock budgets"
17
+ (comment 1 §三) — is **falsified for the highest-frequency failure**
18
+ (`stopping the runner finalizes the run as stopped`).
19
+
20
+ `runner/job-runner.mjs` publishes an observable `working` state (via the coordinator's
21
+ `run_started`, which creates `status.json`) **before** it spawns the worker and **before** it
22
+ installs `process.on("SIGTERM", stop)` (`:95-112` vs `:356-371`). A stop request that lands in
23
+ that window kills the runner with Node's default action: no `finalizeRun`, no `run_finalized`,
24
+ no `endedAt`, no diagnostics. Proven by a controlled experiment (inject the window → 3/3
25
+ deterministic failures; add an early stop latch → 3/3 passes; unmodified runner under load →
26
+ 2/2 failures locally, and the same signature appears in CI on 09-25 and 09-30 with
27
+ `duration_ms ≈ 15200` against a 15 s internal wait).
28
+
29
+ Sample-size honesty: each experiment arm is n = 2–3. The claim rests on the mechanism closing
30
+ the loop — widen the window and the failure becomes deterministic, arm the latch and it
31
+ disappears — not on the counts themselves.
32
+
33
+ This is reachable in production: `archiveView()` kills the current run pid with SIGTERM
34
+ (`src/runtime/service.mjs:726-735` → `killProcess`), so archiving/stopping a **just-started**
35
+ run can leave the row pinned at `working/alive`. Whether anything then converges such a row
36
+ (reconcile / pid-liveness watching) is **unverified** — promoted to acceptance item A10.
37
+
38
+ The other three known signatures (the host-concurrency `A10` resolver burn,
39
+ `subscribe_terminal` 15 s, `A5 burst` 30 s) were **not** re-proven; they stay in scope as "unexplained until the failure can name its
40
+ own blocked step" (→ F3).
41
+
42
+ ## 2. Deliverables
43
+
44
+ ### F1 — Runner stop-safety (production fix) — *primary*
45
+
46
+ Invariant: **no non-terminal state may be observable before the process can handle a stop, and
47
+ a SIGTERM/SIGINT delivered at any time after the module is loaded must produce a terminal
48
+ state.**
49
+
50
+ Platform scope: the latch/handler mechanism is **Unix-only**. On Windows, Node's `process.kill`
51
+ ignores the signal name and hard-kills the target — no handler can ever run — so the
52
+ boot-window loss is unfixable at the signal layer there. Either Windows stays out of scope
53
+ (current behaviour unchanged, recorded in Non-goals) or D2 option (d) is chosen, which does not
54
+ depend on signals being catchable.
55
+
56
+ Design (testability split):
57
+
58
+ | Layer | Artifact | Kind |
59
+ |---|---|---|
60
+ | Pure state machine | `src/core/stop-latch.mjs` — `createStopLatch()` → `{ note(signal), pending(), take() }` (idempotent note, single take, SIGINT/SIGTERM collapse) | pure |
61
+ | I/O shell | `runner/job-runner.mjs` — handlers installed at module scope; `take()` replayed into the existing `stop()` once it exists; emits a `stop_latch_armed` diagnostic as the run's **first** diagnostic, before the coordinator bootstrap | side-effectful |
62
+ | Test-only window | `AGENT_BOARD_TEST_BOOT_WINDOW_MS` delaying the runner between `run_started` and the spawn, so the window can be hit deterministically instead of by luck | side-effectful, test-only |
63
+
64
+ The test knob lives in a production file, so it carries the same guard as F4's knobs:
65
+ `AGENT_BOARD_TEST_*` naming, a unit test proving it is inert when unset, and a behaviour test
66
+ proving it actually widens the window (it must not be able to rot into a no-op).
67
+
68
+ Replay semantics (this is what D2 weighs): with the latch alone, a stop landing in the window
69
+ is *remembered*, the runner continues, spawns the worker, and the replayed `stop()` kills it
70
+ immediately after the handlers are wired — i.e. a doomed worker really is spawned once.
71
+ D2 (b) avoids that wasted spawn by publishing `run_started` only after spawn + handler wiring,
72
+ at the cost of reordering the bootstrap (the coordinator's `run_progress` needs a materialized
73
+ status).
74
+
75
+ ### F2 — Budget audit gate (mechanical discipline)
76
+
77
+ `scripts/budget-audit.mjs` with pure exports `parseTestBudgets(source)` / `auditBudgets(files)`:
78
+ - rule (a): a test declaring `{ timeout: T }` must satisfy `T ≥ max(single explicit wait in the
79
+ body) + margin`, where **`margin = max(5 000 ms, wait / 3)`** (a named constant in the audit
80
+ script, unit-tested). Summing all waits would demand 120 s+ timeouts for multi-wait files
81
+ (`terminal-snapshot` alone has four 30 s waits) — re-inflating the very budgets this issue
82
+ retires;
83
+ - rule (b): a test that drives an app-level deadline ≥ 30 s must declare a timeout ≥ that
84
+ deadline **unless** it goes through an F4 knob, in which case `// budget: knob` exempts it
85
+ (static analysis cannot know a runtime knob compressed the deadline);
86
+ - escape hatch: an inline `// budget: <reason>` comment exempts the line (audited by the unit test).
87
+
88
+ Known tension, stated openly: derived budgets are still wall-clock numbers. The audit buys
89
+ *accountability* — no unsatisfiable nesting, every number carries a recorded reason — not
90
+ immunity to slow machines; that part is F1/F3/F4's job.
91
+
92
+ Amends the two inversions the spec predicted rather than fixing them as written:
93
+ `test/runner.integration.test.mjs:282` already satisfies rule (a) under max+margin
94
+ (20000 ≥ 15000 + 5000) and needed no change; the delivered fixes are the two rule-derived
95
+ widenings (`test/pty-attach-cold-start-e2e.test.mjs`'s slow-boot test 30000 → 35000 and
96
+ `test/runner.integration.test.mjs:489` 30000 → 35000) plus `host-concurrency A10`'s explicit
97
+ `// budget:` escape. Wired as `test/budget-audit.test.mjs` so `npm test` enforces it, plus
98
+ unit fixtures that must fail.
99
+
100
+ ### F3 — Failure self-diagnosis (the next red names its own blocked step)
101
+
102
+ `test-support/flake-postmortem.mjs`: pure `formatPostmortem({ status, state, host, diagnosticsTail, journalTail })`
103
+ → human-readable "last observed" block; I/O shell `capturePostmortem(root, viewId, runId)`; the
104
+ four flaky helpers' `waitFor` wrappers attach it to the assertion error on timeout.
105
+ Unit tested with fixtures (missing file, truncated tail, absent diagnostics).
106
+
107
+ ### F4 — Test-only ladder knobs (stop paying production grace)
108
+
109
+ Expose test-scoped overrides for the wall-clock ladders that tests currently have to wait out:
110
+ `HOST_START_GRACE_MS`, `HOST_RECOVERY_GRACE_MS`, `HOST_RECOVERY_POLL_MS`,
111
+ `ATTACH_RESOLVE_TIMEOUT_MS` (`src/runtime/service.mjs:61-69`), `HOST_PROBE_TIMEOUT_MS`,
112
+ `ATTACH_RECONNECT_TIMEOUT_MS`. Names are `AGENT_BOARD_TEST_*`-scoped; production defaults stay
113
+ byte-identical when unset (unit-tested), and a test asserts the knob actually changes behaviour
114
+ (so it can never rot into a no-op).
115
+
116
+ Testability split: each constant gains a pure reader beside its definition — e.g.
117
+ `resolveHostStartGraceMs(env = process.env)` returning override-or-default — and the I/O shell
118
+ is reduced to passing `process.env` at the call site. Unit tests: unset ⇒ default byte-identical;
119
+ set ⇒ override used; invalid value ⇒ default plus one warn diagnostic. The integration proof
120
+ (A7) drives the host-concurrency `A10` recovery chain under the knob with a shortened budget.
121
+
122
+ ### F5 — Serialization (only if D1 says so)
123
+
124
+ - O1: two-phase runner (`scripts/run-tests.mjs`) used by `test` / `test:coverage`: phase 1 =
125
+ real-process files (concurrency 1 or 2), phase 2 = the rest. No `ci.yml` change.
126
+ - O2: split the CI step into two steps (`ci.yml` change).
127
+ - O3: defer; re-measure after F1–F4 (the issue's own "若再红则升级串行方案" clause).
128
+ Measurement plan so O3 is falsifiable: count in-scope CI reds (failures matching the research
129
+ note's §3 signatures, or any new real-process red) per week, recorded in the issue thread;
130
+ observation window 14 days. Zero in-scope reds ⇒ close the issue; any in-scope red ⇒
131
+ revisit D1 with the accumulated evidence.
132
+ Cost, recomputed from the same TAP model with honest makespan arithmetic (today's 67.7 s ≈
133
+ max(45 s largest file, 321 s ÷ 3); the two phases are **sequential, they do not overlap**):
134
+ two-lane ≈ 110 s (phase 1 at c=2) + 35–50 s (phase 2 at c=3) = 150–175 s → **+80–105 s
135
+ (≈ +1.5 min) per CI job**; fully serial ≈ 260 s → **+3–3.5 min per job**. The same delta lands
136
+ on every local `npm test` run. CI re-measurement stays mandatory before these numbers are
137
+ treated as final.
138
+
139
+ ### F6 (optional) — Test hygiene
140
+
141
+ 924 leaked `/tmp/agentview-*` roots (9.7 MB) and one observed leaked detached coordinator.
142
+ Scope if chosen: remove the roots in the leaking suites, add a static "no unremoved mkdtemp root"
143
+ check, and a post-run assertion that no detached coordinator survives a test file.
144
+
145
+ ### F7 — Issue trail & fact correction (documentation, no code)
146
+
147
+ Two actions inside the plan (not commit content): (1) post this round's research conclusions —
148
+ the root-cause experiment table and the revised framing — as comments on issue #95; (2) correct
149
+ the now-falsified claims in the thread, chiefly comment 1 §三's "环境问题而不是生产缺陷" and the
150
+ budget-widening direction, so a future reader cannot re-derive the wrong design from the
151
+ thread. This recovers the "comment-fact correction" decision point referenced in issue comment 3
152
+ (the 09-25 spec draft it pointed to never landed on disk and was lost).
153
+
154
+ ## 3. Acceptance matrix
155
+
156
+ | ID | Item | Verification type | Concrete check | Pass criterion |
157
+ |---|---|---|---|---|
158
+ | A1 | A stop signal inside the boot window still finalizes the run | integration (deterministic) | new `test/runner-stop-window.integration.test.mjs`: inject `AGENT_BOARD_TEST_BOOT_WINDOW_MS`, SIGTERM inside the window, and a second leg without injection | both legs: `status.json.endedAt != null` and `semanticState === "stopped"` |
159
+ | A2 | Stop latch state machine | unit | `test/stop-latch.test.mjs` | note/take/repeat/no-signal paths covered; no double-fire |
160
+ | A3 | "Nothing observable before stop is handleable" ordering | integration | assert `stop_latch_armed` is the **first entry** of the run's `diagnostics.jsonl` (ahead of the existing `runner_start`) — a single-file observable, no cross-file timestamp comparison | any other first entry fails the test |
161
+ | A4 | The regression case survives load | integration (repeat) | 20 consecutive local runs of `test/runner.integration.test.mjs` while the full suite runs in parallel — **one-time acceptance procedure, not a recurring CI test** | 20/20 green (baseline: 2–3 failures in the first few runs before the fix) |
162
+ | A5 | Budget audit gate | unit + static | `test/budget-audit.test.mjs` over fixtures **and** the real `test/` tree | fixtures with known inversions are reported; real tree reports zero violations |
163
+ | A6 | Failure self-diagnosis | unit + integration | `formatPostmortem` fixtures (missing file, truncated tail) + the wrapped `waitFor` exercised with a **stubbed always-false predicate and a tiny real timeout** — no 15 s burn inside `npm test` | the failure message contains status/state/host/diagnostics excerpts and a "last observed state" line |
164
+ | A7 | Ladder knobs | unit + integration | knob set/unset comparison, then the host-concurrency `A10` chain re-driven under the knob | unset ⇒ production defaults unchanged; set ⇒ same assertions pass with a shorter budget |
165
+ | A8 | Full gate | static + build | `npm run verify` (typecheck, perf gate, tests, coverage thresholds, pack:dry) | exit 0 |
166
+ | A9 | CI observation | automated E2E (CI) | ≥5 consecutive CI runs **or ≥2 days**, whichever comes first, after merge (matches the issue's original acceptance direction) | no in-scope red (other issues excluded explicitly) |
167
+ | A10 | A dead-runner row is converged | integration | construct the boot-window death (runner killed after `run_started`, no `endedAt`); **locate** whichever convergence path actually exists (reconcile / refresh / sweeper) and drive it | a located path converges the row within 15 s; no path found, or no convergence ⇒ **blocking finding** and F1's scope extends with the convergence fix — U1 depends on it |
168
+ | A11 | No leaked roots / processes (enforced only if D4 = yes) | static + integration | leak audit over `test/` + post-run process check | zero unremoved temp roots; zero surviving detached coordinators |
169
+ | A12 | Two-phase runner is faithful (enforced only if D1 ≠ O3) | static + integration | one local run of the two-phase wrapper: aggregated test count vs the single-phase run; `npm run test:coverage`; record the wall-clock delta | identical test count (no double-run, no skip); coverage thresholds still pass; measured wall-clock delta within the bound declared in F5 |
170
+ | U1 | User-observed: stop/delete a just-started run | user | on an installed build, archive/stop a row seconds after its run starts | the row reaches a terminal state (stopped/archived), never stuck at `working` |
171
+ | U2 | User-observed: two days of normal use | user | daily board use | no row stuck at `working` without an error and without a converge/reconcile step |
172
+
173
+ Automated items must be executed and recorded with their real commands in the PR; U1/U2 stay
174
+ `pending` until the user runs them (they cannot be replaced by A-items).
175
+
176
+ ## 4. Decisions requested
177
+
178
+ - **D1 (scope of serialization)**: O1 two-phase local-runner (no `ci.yml`) / O2 CI split /
179
+ O3 defer until F1–F4 are measured. Cost: see F5. **Recommendation: O3** — the proven defect
180
+ is F1, and the measurement plan makes revisiting D1 cheap and evidence-based.
181
+ - **D2 (F1 shape)**: (a) module-scope latch replaying into `stop()` — a stop in the window
182
+ results in spawn-then-immediate-kill of a doomed worker (cheap, experimentally proven);
183
+ (b) publish `run_started` only after spawn + handler wiring — no wasted spawn, but reorders
184
+ the bootstrap and must re-prove the progress-beat path; (c) both; (d) durable stop-intent
185
+ marker (file / coordinator command) instead of a signal — the only option that also fixes
186
+ Windows, at materially higher design cost. **Recommendation: (a) now** — cheap and
187
+ experimentally proven; (d) stays parked as the long-term open question (§7).
188
+ - **D3 (packaging)**: does F1 ship as **its own issue/PR** (it is a production defect found via
189
+ the test) with #95 keeping only the test-discipline work F2–F5, or does everything ride one
190
+ branch? Recommendation: split — F1's review question ("can a stop be lost?") is unrelated to
191
+ #95's review question ("is the suite stable?"), and F1 can merge first. If split: A1–A4 and
192
+ A10 travel with the F1 issue (they verify F1 alone); A5–A8, A11 stay here; ordering
193
+ constraint — **F2's budget fix for `runner.integration.test.mjs:282` must land after F1**, or
194
+ the budget change papers over the race again; and F7's comments cover both issues.
195
+ - **D4 (optional F6 hygiene)**: include or defer. **Recommendation: defer** — cheap but
196
+ orthogonal; fold it into whichever branch lands last.
197
+
198
+ ## 5. Non-goals
199
+
200
+ - No further wall-clock widening (40 → 90 → 150 s is the pattern being retired).
201
+ - `ci.yml` untouched unless D1 chooses O2.
202
+ - No reopening of #121 / #132 / #140; no changes to the attach subsystem's behaviour.
203
+ - If D2 ≠ (d), Windows stop-safety is explicitly out of scope: signals are uncatchable there,
204
+ current Windows behaviour is unchanged, and that limitation is documented.
205
+ - Serialization is not allowed to double-run tests or to break coverage aggregation.
206
+
207
+ ## 6. Risks
208
+
209
+ | Risk | Mitigation |
210
+ |---|---|
211
+ | Residuum: a SIGTERM during module *import* (before the latch exists) still exits by default — but nothing has been published yet, so no observable state is lost | Documented as a known window; A3 pins "no observable state before the latch" |
212
+ | F2's source regex mis-parses an exotic test shape | Minimal rules + explicit `// budget:` escape hatch + fixtures; audit is unit-tested |
213
+ | F4 knobs leaking into production semantics | `AGENT_BOARD_TEST_*` naming, unit test asserting defaults are unchanged when unset, and a behaviour-change test |
214
+ | F1 changes runner startup order (D2 b/c) and breaks the progress beat | Only considered if D2 = b/c; the coordinator's `run_progress` requires a materialized status, so the bootstrap order must be re-verified by the integration suite before landing |
215
+ | Windows: signals cannot be caught at all (Node hard-kills on `process.kill`), so F1's mechanism cannot restore stop-safety there | D2 (d) is the only complete fix; with D2 = a/b/c the Windows behaviour is unchanged and recorded as a Non-goal |
216
+ | The boot-window knob is a test hook inside a production file | Same guard as F4: `AGENT_BOARD_TEST_*` naming + inert-when-unset unit test + behaviour test (see F1) |
217
+ | "Reconcile converges the stuck row" turns out to be false | A10 verifies it explicitly and is a blocking gate for U1; a negative result extends F1 rather than shipping a silent gap |
218
+
219
+ ## 7. Open questions (explicitly not scope)
220
+
221
+ - Do `runner/pty-runner.mjs` (handlers at `:530`/`:754`) and `runner/state-coordinator.mjs`
222
+ (`:199`) have the same boot window — an observable state published before their handlers
223
+ exist? Not audited this round; F1 covers `job-runner` only. If the audit finds the same
224
+ pattern, that is a follow-up issue, not scope growth here.
225
+ - Should the stop path migrate to a durable stop-intent marker for all platforms long-term
226
+ (supersedes D2 (d))? Parked until F1 lands and the residual failure modes are known.
@@ -0,0 +1,129 @@
1
+ # Spec:publish.yml 手动恢复后门的 tag 来源修复(issue #154)
2
+
3
+ 日期:2026-09-30 · 状态:**approved(用户确认方案 A,范围 A1–A4 + U1 全量)· 勘误(同日双审后):A1 拆分为 A1a/A1b、A2 重写为探针 tag 法、A3 断言加严为计数 ≥2,改动点在各条目内注明**
4
+ 基线:`main @ 444199d`
5
+ 调研:issue #154 评论「调研:根因三源交叉验证」/ `~/.claude/github-issue-driven/zhuxixi/pi-agent-board/issue-154/research/root-cause.md`
6
+
7
+ ## 1. 问题陈述
8
+
9
+ `docs/RELEASE.md:117-118` 记载的恢复路径——「Actions → **Publish to npm** → **Run workflow** → enter the tag」——按字面操作 100% 失败:从 `main` 触发手动恢复时,workflow 在第一个自检步骤退出 1,typecheck / 测试 / 发布全部未执行。
10
+
11
+ 后果是「文档承诺的自救路径是死的」:必须知道文档外的 `--ref <tag>` 绕法才能恢复一次发布,而失败信息(`tag: main`)看起来像发布内容出错,会把操作者引向错误方向。
12
+
13
+ ## 2. 根因(systematic-debugging 结论)
14
+
15
+ ### 2.1 一句话根因
16
+
17
+ `.github/workflows/publish.yml:57` 把 **ref 名**当成 **本次要发布的 tag** 使用:
18
+
19
+ ```yaml
20
+ TAG_VERSION="${GITHUB_REF_NAME#v}"
21
+ ```
22
+
23
+ `GITHUB_REF_NAME` 的含义随触发方式变化,而该步骤按 release 事件的形状写死:
24
+
25
+ | 事件 | ref | `GITHUB_REF_NAME` | `${GITHUB_REF_NAME#v}` | 与 `package.json` 版本比对 |
26
+ | --- | --- | --- | --- | --- |
27
+ | `release: published` | `refs/tags/v0.9.0` | `v0.9.0` | `0.9.0` | 通过 |
28
+ | `workflow_dispatch`(从 main 触发) | `refs/heads/main` | `main` | `main` | **恒失败** |
29
+
30
+ ### 2.2 同文件内的正确范例(决定修复形态)
31
+
32
+ checkout 步骤(`:32`)用事件感知表达式同时覆盖两种触发——手动触发时真正要发布的 tag 在 `inputs.tag` 里:
33
+
34
+ ```yaml
35
+ ref: ${{ github.event.release.tag_name || inputs.tag }}
36
+ ```
37
+
38
+ 即:同一概念("当前发布的 tag")在本文件有两处实现,checkout 处对、自检处错。修复应让二者同源,而不是引入第三种解析机制。
39
+
40
+ ### 2.3 溯源:为什么潜伏了 5 天
41
+
42
+ - `git log -S 'GITHUB_REF_NAME'` 只命中 `d982724`(#144,2026-09-25)——该自检步骤自引入起**从未被改动**,不是回归而是「引入即存在」。
43
+ - #144 的 commit body 明确把手动恢复列为设计目标("a `workflow_dispatch` input re-runs a tag whose publish failed"),但自检只按 release 事件形状写。
44
+ - #144 的 PR body 把 npm 侧 trusted-publisher 配置列为 "Required follow-up (web action, not code)"。该配置滞后 → 09-25 发 0.9.0 时 release 事件发布 404 失败 → 0.9.0 卡在 npm 之外 4 天 → 09-30 配好配置后必须用恢复后门补发 → **首次使用后门,bug 当场暴露**。
45
+ - #144 的 comments/reviews 均为 0 条,无 CR 讨论可复用。
46
+
47
+ ### 2.4 复现性(确定性,不依赖时序)
48
+
49
+ 9-30 两次真实 run 构成单变量对照,唯一变量是 dispatch 的 ref:
50
+
51
+ | 实验 | 触发命令 | 自检步骤结果 |
52
+ | --- | --- | --- |
53
+ | 失败腿 | `gh workflow run publish.yml -f tag=v0.9.0 --ref main` | `package.json: 0.9.0, tag: main` → exit 1([run 36726074913](https://github.com/zhuxixi/pi-agent-board/actions/runs/36726074913)) |
54
+ | 通过腿 | `gh workflow run publish.yml -f tag=v0.9.0 --ref v0.9.0` | 自检通过,一路 publish 成功([run 36726192171](https://github.com/zhuxixi/pi-agent-board/actions/runs/36726192171)) |
55
+
56
+ ### 2.5 影响面
57
+
58
+ - 只影响手动恢复路径;release 事件路径不受影响,故潜伏至今。
59
+ - 失败 fail-closed 且发生在最早一步:不会误发布、不污染注册表,代价是一次约 20 秒的无效 run。
60
+ - 人因代价是主要危害:文档写的操作失败、文档外的操作可行。
61
+
62
+ ### 2.6 测试覆盖现状(决定可测性边界)
63
+
64
+ - `test/release-flow.test.mjs` 的 13 个测试全部针对 `scripts/release.mjs`。
65
+ - 全仓 grep `publish.yml|GITHUB_REF_NAME`:`test/` 下零命中——**本仓目前没有任何测试触碰 workflow YAML**。
66
+
67
+ ## 3. 方案决策
68
+
69
+ | 候选 | 做法 | 评价 | 结论 |
70
+ | --- | --- | --- | --- |
71
+ | **A(采用)** | 自检步骤经 `env:` 接收 `github.event.release.tag_name \|\| inputs.tag`,与 checkout 同源 | 3 行改动;与既有正确写法一致;两种事件都覆盖;不引入新机制 | **采用** |
72
+ | B | 改为 `git describe --tags --exact-match`(对已 checkout 的提交取 tag) | 也能对,但引入新失败面(轻量/附注 tag、一提交多 tag),且与 checkout 已解析的 ref 重复表达同一事实 | 否 |
73
+ | C | 保留 `GITHUB_REF_NAME`,改为要求操作者必须用 `--ref <tag>` 触发 | 等于把 bug 固化进文档(文档已写 "enter the tag"),恢复路径继续反直觉 | 否 |
74
+ | D | 自检改为只校验 `inputs.tag` 存在性、不比对版本 | 削弱护栏(版本错配护栏是 #144 的明确设计目标之一) | 否 |
75
+
76
+ **改动内容**(`.github/workflows/publish.yml:54-59`):
77
+
78
+ ```yaml
79
+ - name: Check the tag matches package.json
80
+ env:
81
+ RELEASE_TAG: ${{ github.event.release.tag_name || inputs.tag }}
82
+ run: |
83
+ PKG_VERSION=$(node -p "require('./package.json').version")
84
+ TAG_VERSION="${RELEASE_TAG#v}"
85
+ echo "package.json: ${PKG_VERSION}, tag: ${TAG_VERSION}"
86
+ [ "${PKG_VERSION}" = "${TAG_VERSION}" ]
87
+ ```
88
+
89
+ 用 `env:` 中转而非把 `${{ }}` 直接写进 `run` 脚本,遵循 GitHub Actions 惯例(避免表达式拼接进 shell)。
90
+
91
+ ## 4. 契约与边界
92
+
93
+ - **不变量**:`publish.yml` 中任何需要「本次要发布的 tag」的位置,都必须从 `github.event.release.tag_name || inputs.tag` 派生,**不得**从 ref 名派生。静态测试钉死 `GITHUB_REF_NAME`(见 A3);`GITHUB_REF` 在语义上同属错误的派生源,但它存在合法用途(如日志),故不做文本级封禁。
94
+ - **数据流**:`release.tag_name`(release 事件)或 `inputs.tag`(手动触发)→ `env.RELEASE_TAG` → 去 `v` 前缀 → 与 `package.json.version` 比对。
95
+ - **降级**:若未来新增第三种触发方式(如 `push: tags`),该表达式需同步扩展;checkout 与自检必须一起改,二者共用同一表达式是这条降级的唯一保障。
96
+ - **非目标**:不改 release 事件路径行为;不改 checkout 表达式;不改 `scripts/release.mjs`;不增加「workflow 是否已重跑成功」类护栏;不引入 workflow 级 lint 基础设施。
97
+
98
+ ## 5. 验收矩阵
99
+
100
+ | ID | 功能点 | 验收方式 | 具体验证 | 通过标准 |
101
+ | --- | --- | --- | --- | --- |
102
+ | A1a | 手动触发恢复不再红(合并前验证修复本身) | 自动化验证(automated E2E,真实 dispatch) | `gh workflow run publish.yml -f tag=v0.9.0 --ref issue-154-publish-workflow-dispatch-tag-check`——**ref 必须指向修复分支**:`--ref` 决定运行哪份 workflow 定义,合并前从 main 触发跑的是未修复版,自检必红,会把正确的修复误判为失败。随后 `gh run view <id> --log` 取自检步骤输出 | 自检步骤打出 `package.json: 0.9.0, tag: 0.9.0` 并通过;命中「already published — skipping」分支;整 run `success`。选 v0.9.0 因为它已在 npm 上,**零重复发布风险**。注意:本条只验证到 skip 分支,恢复路径的 `npm publish` 环节本次不执行(与 release 路径共用、未改动) |
103
+ | A1b | 文档承诺的路径按字面成立(**合并后**补验) | 自动化验证(automated E2E,真实 dispatch,合并后执行) | `docs/RELEASE.md:117-118` 的字面操作:Actions → Publish to npm → Run workflow → 填 `v0.9.0`(等价命令 `gh workflow run publish.yml -f tag=v0.9.0 --ref main`) | 同 A1a 的日志与结论。这一步验证的不是修复本身,而是**文档承诺的路径从用户视角成立**(ref=main、只填 tag) |
104
+ | A2 | 版本错配护栏仍然有效(负向) | 自动化验证(automated E2E,负向) | **勘误**:原设计 `-f tag=v0.8.0 --ref main` 不成立——checkout 跟随 `inputs.tag`(`publish.yml:32`),工作区 `package.json` 也随之变成 0.8.0,比对恒等、无法触发失败;更糟的是若该版本不在 npm 上,run 会一路走到 `npm publish` **真实发布旧版本**。错配必须人为制造:在分支 HEAD 打探针 tag `issue-154-mismatch-probe`(**不带 `v` 前缀**,避开 `release.mjs:207` / `release_helper.mjs:182` 的 `v*` 基线通配)并 push(需用户许可),随后 `gh workflow run publish.yml -f tag=issue-154-mismatch-probe --ref issue-154-publish-workflow-dispatch-tag-check`;验后删除本地与远端 tag,`git ls-remote --tags origin` 确认远端无残留 | 自检步骤 exit 1,日志显示 `package.json: 0.9.0, tag: issue-154-mismatch-probe`,run `failure`(证明护栏没被改成「永远通过」) |
105
+ | A3 | `GITHUB_REF_NAME` 不再出现在 publish.yml,且 tag 来源表达式在 checkout 与自检两处同源 | 自动化验证(static) | 新增 `test/publish-workflow.test.mjs`:读 `.github/workflows/publish.yml` 文本(**剥掉整行注释**,避免文档性注释误伤),断言不含 `GITHUB_REF_NAME`,且 `github.event.release.tag_name \|\| inputs.tag`(`\|\|` 两侧空白归一)出现 **≥2 次**(checkout + 自检;删掉自检的 `env:` 块必须红) | `npm test` 通过;修复前两条断言都红(`GITHUB_REF_NAME` 存在、表达式只出现 1 次),先写红再修 |
106
+ | A4 | 既有代码不被破坏 | 自动化验证(unit + build + static) | `npm run verify`(typecheck + perf gate + 全量测试 + 覆盖率阈值 + pack dry-run) | 全绿,覆盖率不低于阈值(lines 85 / funcs 80 / branches 70) |
107
+ | U1 | release 事件自动路径回归 | 用户实测(下个真实发版自然覆盖) | 发布 0.10.0 时走 `prepare → open-pr → merge → finish`,观察 publish.yml 由 release 事件触发的那次 run | 自检步骤通过(`tag: 0.10.0`),publish 步骤成功。本次修复不改变该路径的输入来源,属回归观察项而非新增验证 |
108
+
109
+ ### 5.1 可测性拆分设计
110
+
111
+ 本改动是 YAML 流程编排,逻辑量近零,可测性设计主要落在「把不变量钉死在可断言的形式上」:
112
+
113
+ - 修复逻辑本身(表达式替换)无法在本地执行,**其唯一有效验证是真实 dispatch(A1/A2)**——这是本 issue 的核心验收,不设更廉价的替代品,也不因此降级为「用户实测」。
114
+ - 唯一可静态化的产物是**不变量 A3**:纯文本断言,无副作用、无外部依赖。做成最小纯函数 `readWorkflowSource()`(读文件)+ 两条字符串断言;副作用(读文件)隔离在单个 helper 内,断言部分保持纯函数式,符合本仓「纯函数 + 薄 I/O 壳」的既有约定(参照 `scripts/release_helper.mjs` 的拆分方式)。
115
+ - 该静态测试断言**否定式不变量**(剥整行注释后不含 `GITHUB_REF_NAME`)+ **正向表达式计数 ≥2**(钉住「checkout 与自检同源」——删掉自检的 `env:` 块必须红),不断言 YAML 结构或步骤名。剥注释与归一空白是为了让文档性注释、无害格式调整不触发假红;而计数断言的有意严苛(把表达式上提成 job 级 `env:` 会红)正是守卫测试的期望行为——改架构就必须重审本不变量。
116
+
117
+ ### 5.2 测试边界
118
+
119
+ | 边界 | 覆盖 | 不覆盖(明确) |
120
+ | --- | --- | --- |
121
+ | workflow YAML 文本不变量 | A3(static,新增) | YAML 语法正确性(由 GitHub 解析器在 run 时校验,A1 覆盖) |
122
+ | workflow 运行时行为 | A1a、A2(真实 dispatch);A1b(合并后) | release 事件路径(U1,下个真实发版覆盖) |
123
+ | 发布脚本逻辑 | A4(既有 13 个 release-flow 测试) | 本次不改 `scripts/release.mjs`,无新增 |
124
+
125
+ ## 6. 风险
126
+
127
+ - **A1a/A1b 各消耗一次真实 run**:约 1 分钟、无副作用、不产生发布(版本已在注册表 → skip 分支)。可接受。
128
+ - **A2 的错配必须人为制造**:checkout 跟随 `inputs.tag`,dispatch 路径下 tag 与工作区 `package.json` 恒同源,用既有 tag 造不出错配;更危险的是若误选未发布过的版本号,run 会一路走到 `npm publish` 真实发布。因此探针 tag 指向分支 HEAD(其 `package.json` 与 tag 名必然不一致),且**不带 `v` 前缀**——`release.mjs` / `release_helper.mjs` 以 `v*` 通配 tag 定 changelog 基线,`v*` 探针泄漏会污染下一次发版。
129
+ - **静态测试(A3)的假红风险**:剥整行注释 + 空白归一已规避文档性假红;保留的假红面(计数 ≥2 对上提重构敏感)是守卫测试的期望行为,见 §5.1。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhuxixi/pi-agent-board",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "description": "Agent-board dashboard for Pi: dispatch, monitor, peek/reply, and attach to background Pi sessions.",
5
5
  "type": "module",
6
6
  "main": "./index.ts",
@@ -18,7 +18,8 @@ import { fileURLToPath } from "node:url";
18
18
  import { appendLine, readJson } from "../src/core/atomic.mjs";
19
19
  import { createRunStatus, finalizeRun, reduceEvent } from "../src/core/events.mjs";
20
20
  import { encodePromptForCliArg } from "../src/core/prompt-transport.mjs";
21
- import { applyAutoStateToStatus, autoStateEnabled, autoStateFromModelOrHeuristic, autoStateModel, buildAutoStatePrompt, heuristicAutoState, isManualCompletion } from "../src/core/auto-state.mjs";
21
+ import { createStopLatch } from "../src/core/stop-latch.mjs";
22
+ import { applyAutoStateToStatus, autoStateEnabled, autoStateFromModelOrHeuristic, autoStateModel, buildAutoStatePrompt, heuristicAutoState, isManualVerdict } from "../src/core/auto-state.mjs";
22
23
  import { appendDiagnostic } from "../src/core/diagnostics.mjs";
23
24
  import { emptyEvidenceSnapshot, finalizeEvidence, reduceEvidence, summarizeEvidence, writeEvidence, writeRunEvidence } from "../src/core/evidence.mjs";
24
25
  import { updateCodeRefsFromEvidence } from "../src/core/code-refs-store.mjs";
@@ -35,6 +36,17 @@ import { buildApprovePlanPrompt, buildPlanChangesPrompt, buildPlanRequestPrompt
35
36
 
36
37
  const WRITE_THROTTLE_MS = 250;
37
38
 
39
+ // Stop-intent latch (issue #153): a stop signal that arrives before the real
40
+ // stop path exists (config read → run_started → worker spawn → handler
41
+ // wiring) must not kill this process via Node's default action — the run
42
+ // would never reach a terminal state. The latch records it; bootstrapRun
43
+ // replays it into stop() once the handlers are wired. A signal during module
44
+ // import (before these handlers exist) still exits by default, but nothing
45
+ // has been published at that point, so no observable state is lost.
46
+ const stopLatch = createStopLatch();
47
+ process.on("SIGTERM", () => stopLatch.note("SIGTERM"));
48
+ process.on("SIGINT", () => stopLatch.note("SIGINT"));
49
+
38
50
  /** @param {string[]} args */
39
51
  function redactWorkerArgs(args) {
40
52
  return args.map((arg, idx) => {
@@ -72,6 +84,12 @@ function main() {
72
84
  // queued frame flickering through run_started's first materialization.
73
85
  status.semanticState = "working";
74
86
  let evidence = emptyEvidenceSnapshot({ viewId, runId, source: "json-runner" });
87
+ // First observable write BY THE RUNNER (issue #153, A3): the stop latch was
88
+ // armed at module scope, before the runner publishes anything of its own.
89
+ // (The parent's mark_queued/pid.json predate the child; that pre-module shape
90
+ // is converged by service.reconcile() — A10.) Pinned by the stop-window
91
+ // integration test asserting this is diagnostics.jsonl's first entry.
92
+ appendDiagnostic(root, viewId, { source: "runner", runId, code: "stop_latch_armed", message: "Stop latch armed before any observable state", details: { signals: ["SIGTERM", "SIGINT"] } });
75
93
  appendDiagnostic(root, viewId, { source: "runner", runId, code: "runner_start", message: "Runner started", details: { kind: config.kind, cwd: config.cwd, model: config.model } });
76
94
  writeRunEvidence(root, evidence);
77
95
  writeEvidence(root, evidence);
@@ -108,6 +126,12 @@ async function bootstrapRun({ root, viewId, runId, config, status, meta, evidenc
108
126
  }
109
127
  }
110
128
 
129
+ // Test-only boot-window knob (issue #153): deterministically widen the gap
130
+ // between the observable run_started and the handler wiring so a stop can
131
+ // be delivered inside the window without timing luck. Inert when unset.
132
+ const bootWindowMs = Number(process.env.AGENT_BOARD_TEST_BOOT_WINDOW_MS || 0);
133
+ if (bootWindowMs > 0) await new Promise((resolveDelay) => setTimeout(resolveDelay, bootWindowMs));
134
+
111
135
  /**
112
136
  * One transient progress beat: ship the full in-memory status as a sparse
113
137
  * patch — the coordinator merges it onto the materialized status and lets
@@ -177,14 +201,14 @@ async function bootstrapRun({ root, viewId, runId, config, status, meta, evidenc
177
201
  };
178
202
 
179
203
  /**
180
- * Persist only if the user hasn't marked the row done manually since the last
204
+ * Persist only if the row hasn't taken a manual verdict since the last
181
205
  * persist. projectViewState() overwrites the row state unconditionally, so a
182
206
  * post-exit model pass must never persist its stale in-memory status over a
183
- * fresh manual completion.
207
+ * fresh manual verdict.
184
208
  */
185
209
  const persistUnlessManual = (force = false) => {
186
210
  const latestView = readState(root, viewId);
187
- if (isManualCompletion(latestView)) return false;
211
+ if (isManualVerdict(latestView)) return false;
188
212
  persist(force);
189
213
  return true;
190
214
  };
@@ -204,8 +228,8 @@ async function bootstrapRun({ root, viewId, runId, config, status, meta, evidenc
204
228
  * Refresh the evidence mirrors (status.evidenceSummary / state.review)
205
229
  * through the coordinator: `patch_fields` carries only whitelisted mirror
206
230
  * fields, and the coordinator's generic manual fence (source != user on a
207
- * manually-completed row) is the authoritative guard — the old fresh-read
208
- * + isManualCompletion pre-checks are no longer needed. Designed fences
231
+ * row carrying a manual verdict) is the authoritative guard — the old
232
+ * fresh-read pre-checks are no longer needed. Designed fences
209
233
  * (manual_fence / no_change) are informational; ambiguous outcomes never
210
234
  * fall back to a direct write.
211
235
  */
@@ -232,12 +256,12 @@ async function bootstrapRun({ root, viewId, runId, config, status, meta, evidenc
232
256
  if (result.reason === "coordinator_disabled") {
233
257
  // Legacy escape hatch: fresh-read + fence, pre-coordinator semantics.
234
258
  const freshStatus = readStatus(root, viewId, runId);
235
- if (freshStatus && !isManualCompletion(freshStatus)) {
259
+ if (freshStatus && !isManualVerdict(freshStatus)) {
236
260
  freshStatus.evidenceSummary = status.evidenceSummary;
237
261
  legacyWriteStatus(root, viewId, runId, freshStatus);
238
262
  }
239
263
  const freshState = readState(root, viewId);
240
- if (freshState && !isManualCompletion(freshState)) {
264
+ if (freshState && !isManualVerdict(freshState)) {
241
265
  freshState.review = status.evidenceSummary;
242
266
  legacyWriteState(root, viewId, freshState);
243
267
  }
@@ -370,6 +394,15 @@ async function bootstrapRun({ root, viewId, runId, config, status, meta, evidenc
370
394
  process.on("SIGTERM", stop);
371
395
  process.on("SIGINT", stop);
372
396
 
397
+ // Replay a stop observed before the real handlers existed (issue #153).
398
+ // take() clears, so this fires at most once and cannot double-fire with a
399
+ // later real signal; stop() itself is worker.killed-guarded. The diagnostic
400
+ // is emitted first so the latch path stays observable even if stop() no-ops.
401
+ if (stopLatch.take() != null) {
402
+ appendDiagnostic(root, viewId, { source: "runner", runId, code: "stop_latch_replayed", message: "Replaying a stop observed before the handlers were wired", details: {} });
403
+ stop();
404
+ }
405
+
373
406
  worker.on("error", async (err) => {
374
407
  // Cancel any in-flight throttled flush before finalizing: if the timer
375
408
  // callback lands during the sendStateCommand await below, the stale
@@ -465,10 +498,11 @@ async function bootstrapRun({ root, viewId, runId, config, status, meta, evidenc
465
498
  async function finalizeSteeringIfNeeded(config, status, evidence) {
466
499
  if (config.kind !== "plan" && config.kind !== "plan_change") return;
467
500
  if (status.semanticState === "failed" || status.semanticState === "stopped") return;
468
- // A manual completion racing the exit chain must not be resurrected for
501
+ // A manual verdict racing the exit chain must not be resurrected for
469
502
  // approval: the user already closed this row. Same signal as the other
470
- // post-exit guards (completeView writes completed+autoState null to state.json).
471
- if (isManualCompletion(readState(config.root, config.viewId))) return;
503
+ // post-exit guards (mark_completed/mark_holding write the verdict state
504
+ // + autoState null to state.json).
505
+ if (isManualVerdict(readState(config.root, config.viewId))) return;
472
506
  recordPlanReady(config.root, config.viewId, {
473
507
  runId: config.runId,
474
508
  planText: latestEvidenceText(evidence) || status.latestAssistantPreview || status.summary || "Plan ready",
@@ -494,11 +528,11 @@ async function finalizeSteeringIfNeeded(config, status, evidence) {
494
528
  /** @param {import("../src/core/types.mjs").RunConfig} config @param {import("../src/core/types.mjs").RunStatus} status */
495
529
  async function drainQueuedFollowUp(config, status) {
496
530
  if (status.semanticState !== "idle" && status.semanticState !== "completed") return;
497
- // A manual completion racing the exit chain must never be followed up: the
498
- // user just finished this row, so don't launch a new run over it. The
531
+ // A manual verdict racing the exit chain must never be followed up: the
532
+ // user just closed this row, so don't launch a new run over it. The
499
533
  // in-memory status may be stale (fresh-read guards skip classification), so
500
534
  // check the authoritative state.json signal.
501
- if (isManualCompletion(readState(config.root, config.viewId))) return;
535
+ if (isManualVerdict(readState(config.root, config.viewId))) return;
502
536
  if (config.kind === "plan" || config.kind === "plan_change") return;
503
537
  const claimed = claimNextFollowUp(config.root, config.viewId);
504
538
  if (!claimed.ok || !claimed.item) return;
@@ -654,9 +688,9 @@ async function applyHeuristicAutoState(config, status, evidence) {
654
688
  if (!canAutoState(config, status, evidence)) return false;
655
689
  // Cheap pre-check kept as an optimization (avoids a pointless command);
656
690
  // correctness no longer depends on it — the coordinator fences manual
657
- // completions authoritatively (manual_fence).
691
+ // verdicts authoritatively (manual_fence).
658
692
  const latestState = readState(config.root, config.viewId);
659
- if (isManualCompletion(latestState)) return false;
693
+ if (isManualVerdict(latestState)) return false;
660
694
  const latest = latestEvidenceText(evidence) || status.latestAssistantPreview || status.summary || "";
661
695
  const classification = heuristicAutoState(latest, { lastAgentActivityAt: status.lastAgentActivityAt ?? null });
662
696
  return classifyThroughCoordinator(config, status, classification);
@@ -673,11 +707,11 @@ async function maybeModelAutoState(config, status, evidence) {
673
707
  [...config.piArgsPrefix, "--mode", "json", "-p", "--no-session", "--model", model, prompt],
674
708
  15000,
675
709
  );
676
- // The user may have marked the row done manually during the model call. The
677
- // cheap pre-check avoids a pointless command; the coordinator's manual_fence
710
+ // The user may have placed a manual verdict on the row during the model call.
711
+ // The cheap pre-check avoids a pointless command; the coordinator's manual_fence
678
712
  // is the authoritative guard for races after this read.
679
713
  const fresh = readStatus(config.root, config.viewId, config.runId);
680
- if (!fresh || isManualCompletion(fresh)) return false;
714
+ if (!fresh || isManualVerdict(fresh)) return false;
681
715
  Object.assign(status, fresh);
682
716
  const classification = autoStateFromModelOrHeuristic(out, latest, { lastAgentActivityAt: status.lastAgentActivityAt ?? null });
683
717
  return classifyThroughCoordinator(config, status, classification);
@@ -708,10 +742,10 @@ async function maybeModelSummary(config, status) {
708
742
  [...config.piArgsPrefix, "--mode", "json", "-p", "--no-session", "--model", model, prompt],
709
743
  15000,
710
744
  );
711
- // The user may have marked the row done manually during the summary call.
745
+ // The user may have placed a manual verdict on the row during the summary call.
712
746
  // Updating the stale status and letting the caller persist would clobber the
713
- // manual completion, so bail out before touching the in-memory status.
714
- if (isManualCompletion(readState(config.root, config.viewId))) return false;
747
+ // manual verdict, so bail out before touching the in-memory status.
748
+ if (isManualVerdict(readState(config.root, config.viewId))) return false;
715
749
  const text = out.trim().split("\n").slice(-1)[0]?.trim();
716
750
  if (text) {
717
751
  status.summary = text.replace(/^["']|["']$/g, "").slice(0, 80);
@@ -4,7 +4,7 @@
4
4
  * Only reachable via `AGENT_BOARD_COORDINATOR=off` — the documented escape
5
5
  * hatch. The normal path routes `host_run_failed` through the View State
6
6
  * Coordinator (`runner/state-coordinator.mjs`), whose manual_fence / stale_run
7
- * guards own the decision; this direct write has NO manual-completion fence,
7
+ * guards own the decision; this direct write has NO manual-verdict fence,
8
8
  * which is exactly why it must stay unreachable in the default configuration.
9
9
  *
10
10
  * Lives in its own module so `runner/pty-runner.mjs` itself never imports the
@@ -69,7 +69,7 @@ const HEARTBEAT_MS = 1000;
69
69
  * shell adds the wall-clock stamp — into the patch BEFORE the journal
70
70
  * append, so boot replay re-applies the exact same fields deterministically
71
71
  * (there is no clock at replay time either). (Task 1 review F5.) */
72
- const LAST_ACTIVITY_STAMP_KINDS = new Set(["mark_queued", "archive_view", "adopt_session", "reconcile_finalize", "plan_ready", "mark_completed", "host_run_failed"]);
72
+ const LAST_ACTIVITY_STAMP_KINDS = new Set(["mark_queued", "archive_view", "adopt_session", "reconcile_finalize", "plan_ready", "mark_completed", "mark_holding", "clear_holding", "host_run_failed"]);
73
73
  /** Kinds whose status patch may CREATE the run's status file: they carry the
74
74
  * full status content for a run that has no materialized status yet. Every
75
75
  * other kind keeps PR #1's "patch presence ≠ file requirement" semantics
@@ -9,7 +9,7 @@
9
9
  import { spawn } from "node:child_process";
10
10
  import { readJson } from "../src/core/atomic.mjs";
11
11
  import { appendDiagnostic } from "../src/core/diagnostics.mjs";
12
- import { applyAutoStateToStatus, applyAutoStateToViewState, autoStateEnabled, autoStateFromModelOrHeuristic, autoStateModel, buildAutoStatePrompt, heuristicAutoState, isManualCompletion } from "../src/core/auto-state.mjs";
12
+ import { applyAutoStateToStatus, applyAutoStateToViewState, autoStateEnabled, autoStateFromModelOrHeuristic, autoStateModel, buildAutoStatePrompt, heuristicAutoState, isManualVerdict } from "../src/core/auto-state.mjs";
13
13
  import { finalizeEvidence, readEvidence, summarizeEvidence, writeEvidence } from "../src/core/evidence.mjs";
14
14
  import { updateCodeRefsFromEvidence } from "../src/core/code-refs-store.mjs";
15
15
  import { readState, readStatus, readMeta, writeState, writeStatus } from "../src/core/store.mjs";
@@ -31,7 +31,7 @@ async function main() {
31
31
  // Cheap pre-check (optimization only): skip a pointless command when the
32
32
  // manual verdict is already materialized. The coordinator's manual_fence
33
33
  // stays the authoritative guard for races after this read.
34
- if (isManualCompletion(state)) process.exit(0);
34
+ if (isManualVerdict(state)) process.exit(0);
35
35
 
36
36
  const evidence = readEvidence(config.root, config.viewId);
37
37
  const latest = latestEvidenceText(evidence) || state.latestAssistantPreview || state.summary || "";