@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.
- package/CHANGELOG.md +20 -0
- package/README.md +10 -2
- package/VERIFY.md +2 -1
- package/docs/superpowers/plans/2026-09-29-issue-145-holding-semantic-state.md +1183 -0
- package/docs/superpowers/plans/2026-09-29-issue-145-user-verification.md +66 -0
- package/docs/superpowers/plans/2026-09-30-board-delete-key-x-y-confirm.md +649 -0
- package/docs/superpowers/plans/2026-09-30-issue-153-runner-stop-boot-window.md +618 -0
- package/docs/superpowers/plans/2026-09-30-issue-95-test-discipline.md +555 -0
- package/docs/superpowers/plans/2026-09-30-publish-dispatch-tag-check.md +259 -0
- package/docs/superpowers/specs/2026-09-29-issue-145-holding-semantic-state-design.md +394 -0
- package/docs/superpowers/specs/2026-09-30-board-delete-key-x-y-confirm-design.md +86 -0
- package/docs/superpowers/specs/2026-09-30-issue-153-runner-stop-boot-window-design.md +75 -0
- package/docs/superpowers/specs/2026-09-30-issue-95-test-discipline-design.md +226 -0
- package/docs/superpowers/specs/2026-09-30-publish-dispatch-tag-check-design.md +129 -0
- package/package.json +1 -1
- package/runner/job-runner.mjs +56 -22
- package/runner/pty-runner-legacy.mjs +1 -1
- package/runner/state-coordinator.mjs +1 -1
- package/runner/state-runner.mjs +2 -2
- package/scripts/budget-audit.mjs +135 -0
- package/src/core/auto-state.mjs +12 -10
- package/src/core/coordinator-protocol.mjs +3 -1
- package/src/core/derive.mjs +3 -0
- package/src/core/evidence.mjs +4 -1
- package/src/core/host-probe.mjs +12 -1
- package/src/core/pty-attach-reconnect.mjs +12 -1
- package/src/core/rows.mjs +4 -0
- package/src/core/state-commands.mjs +62 -8
- package/src/core/stop-latch.mjs +41 -0
- package/src/core/store.mjs +8 -1
- package/src/core/terminal-query-sequences.mjs +26 -0
- package/src/core/test-knobs.mjs +28 -0
- package/src/core/types.mjs +5 -2
- package/src/core/warm-host-sweeper.mjs +13 -0
- package/src/runtime/service.mjs +149 -28
- package/src/ui/dashboard.ts +46 -1
- 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
package/runner/job-runner.mjs
CHANGED
|
@@ -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 {
|
|
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
|
|
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
|
|
207
|
+
* fresh manual verdict.
|
|
184
208
|
*/
|
|
185
209
|
const persistUnlessManual = (force = false) => {
|
|
186
210
|
const latestView = readState(root, viewId);
|
|
187
|
-
if (
|
|
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
|
-
*
|
|
208
|
-
*
|
|
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 && !
|
|
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 && !
|
|
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
|
|
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 (
|
|
471
|
-
|
|
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
|
|
498
|
-
// user just
|
|
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 (
|
|
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
|
-
//
|
|
691
|
+
// verdicts authoritatively (manual_fence).
|
|
658
692
|
const latestState = readState(config.root, config.viewId);
|
|
659
|
-
if (
|
|
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
|
|
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 ||
|
|
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
|
|
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
|
|
714
|
-
if (
|
|
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-
|
|
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
|
package/runner/state-runner.mjs
CHANGED
|
@@ -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,
|
|
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 (
|
|
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 || "";
|