@zhuxixi/pi-agent-board 0.9.0 → 0.10.1
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 +28 -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 +49 -3
|
@@ -0,0 +1,618 @@
|
|
|
1
|
+
# Issue #153 Runner Boot-Window Stop Latch — Implementation Plan
|
|
2
|
+
|
|
3
|
+
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
4
|
+
|
|
5
|
+
**Goal:** Make a stop signal delivered during job-runner's boot window (between the observable `run_started` and the SIGTERM handler wiring) still finalize the run, via a module-scope stop latch.
|
|
6
|
+
|
|
7
|
+
**Architecture:** A pure state machine (`createStopLatch`) is installed as SIGTERM/SIGINT handlers at job-runner's module scope — before anything is observable. The real `stop()` replays a latched signal once it exists. A test-only knob (`AGENT_BOARD_TEST_BOOT_WINDOW_MS`) widens the window deterministically so the race is testable without timing luck; `service.reconcile()` convergence of an unfixable hard-kill is pinned as defense in depth.
|
|
8
|
+
|
|
9
|
+
**Tech Stack:** Node.js 20+ ESM (.mjs) with JSDoc types, `node:test` + `assert/strict`, tabs.
|
|
10
|
+
|
|
11
|
+
**Spec:** `docs/superpowers/specs/2026-09-30-issue-153-runner-stop-boot-window-design.md` — the plan argues from the spec; executors read both.
|
|
12
|
+
|
|
13
|
+
## Global Constraints
|
|
14
|
+
|
|
15
|
+
- Indentation: **tabs**. Comments and commit messages: **English**.
|
|
16
|
+
- Commit subjects: conventional (`feat`/`test`/`docs`/`refactor`), ending with `(issue #153)`.
|
|
17
|
+
- `npm run verify` must exit 0 at branch head before the branch is called done.
|
|
18
|
+
- Tests are self-contained: every fixture lives under a `mkdtempSync` root, removed in `finally`; never mutate `~/.pi/agent/agent-board`; never touch the main checkout.
|
|
19
|
+
- The test knob is `AGENT_BOARD_TEST_*`-scoped and must be inert when unset.
|
|
20
|
+
- Windows: signals cannot be caught (Node hard-kills); this fix is Unix-only by design — do not add platform branches.
|
|
21
|
+
- All work happens in this worktree (`$WT`); never edit files outside it.
|
|
22
|
+
|
|
23
|
+
## Review Focus
|
|
24
|
+
|
|
25
|
+
Five failure modes the spec implies but happy paths don't exercise. Each is pinned to the task that owns the code:
|
|
26
|
+
|
|
27
|
+
1. **A signal during module import (before the latch handlers exist) still exits by default** — nothing is published yet, so nothing observable is lost. Pinned: Task 3's A3 assertion (`stop_latch_armed` is the run's first diagnostic) proves nothing is published before the latch.
|
|
28
|
+
2. **Both SIGINT and SIGTERM noted before wiring** — must collapse into one stop, not two. Pinned: Task 1's first-wins and take-once unit tests.
|
|
29
|
+
3. **A normal stop after wiring must behave exactly as before** — pinned: Task 2's step runs the whole existing `runner.integration` suite; the existing `stopping the runner finalizes the run as stopped` test is the regression guard.
|
|
30
|
+
4. **The boot-window knob leaking between tests or into production** — pinned: Task 3's leg 2 runs without the knob and must pass identically (inert-when-unset), and every leg deletes the env var in `finally`.
|
|
31
|
+
5. **Replay double-firing with a later real signal** — `take()` clears once and `stop()` is `worker.killed`-guarded. Pinned: Task 1's take-once unit test plus the existing stop test staying green.
|
|
32
|
+
|
|
33
|
+
Known-untested residual: the latch replay finalizing through the `coordinator_disabled` legacy branch is not directly pinned; the existing legacy-path suites cover that branch indirectly. Recorded, not expanded here.
|
|
34
|
+
|
|
35
|
+
---
|
|
36
|
+
|
|
37
|
+
### Task 1: Stop-latch pure state machine
|
|
38
|
+
|
|
39
|
+
**Files:**
|
|
40
|
+
- Create: `src/core/stop-latch.mjs`
|
|
41
|
+
- Test: `test/stop-latch.test.mjs`
|
|
42
|
+
|
|
43
|
+
**Interfaces:**
|
|
44
|
+
- Produces: `createStopLatch()` → `{ note(signal: string): void, pending(): string | null, take(): string | null }` — `note` idempotent (first signal wins), `take` reads-and-clears once.
|
|
45
|
+
|
|
46
|
+
- [ ] **Step 1: Write the failing tests**
|
|
47
|
+
|
|
48
|
+
```js
|
|
49
|
+
// test/stop-latch.test.mjs
|
|
50
|
+
import assert from "node:assert/strict";
|
|
51
|
+
import { test } from "node:test";
|
|
52
|
+
import { createStopLatch } from "../src/core/stop-latch.mjs";
|
|
53
|
+
|
|
54
|
+
test("A2: latch records the first signal and collapses later ones", () => {
|
|
55
|
+
const latch = createStopLatch();
|
|
56
|
+
assert.equal(latch.pending(), null, "nothing noted initially");
|
|
57
|
+
latch.note("SIGTERM");
|
|
58
|
+
latch.note("SIGINT");
|
|
59
|
+
assert.equal(latch.pending(), "SIGTERM", "first signal wins; SIGINT/SIGTERM collapse into one stop intent");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("A2: take returns the noted signal exactly once", () => {
|
|
63
|
+
const latch = createStopLatch();
|
|
64
|
+
latch.note("SIGINT");
|
|
65
|
+
assert.equal(latch.take(), "SIGINT", "take returns the noted signal");
|
|
66
|
+
assert.equal(latch.take(), null, "take clears — a replayed stop cannot double-fire");
|
|
67
|
+
assert.equal(latch.pending(), null);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("A2: take on an empty latch is null", () => {
|
|
71
|
+
const latch = createStopLatch();
|
|
72
|
+
assert.equal(latch.take(), null);
|
|
73
|
+
assert.equal(latch.pending(), null);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("A2: latches are independent instances", () => {
|
|
77
|
+
const a = createStopLatch();
|
|
78
|
+
const b = createStopLatch();
|
|
79
|
+
a.note("SIGTERM");
|
|
80
|
+
assert.equal(b.pending(), null, "no shared state across instances");
|
|
81
|
+
});
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
- [ ] **Step 2: Run tests to verify they fail**
|
|
85
|
+
|
|
86
|
+
Run: `node --test test/stop-latch.test.mjs`
|
|
87
|
+
Expected: FAIL — `Cannot find module '.../src/core/stop-latch.mjs'`
|
|
88
|
+
|
|
89
|
+
- [ ] **Step 3: Write the minimal implementation**
|
|
90
|
+
|
|
91
|
+
```js
|
|
92
|
+
// src/core/stop-latch.mjs
|
|
93
|
+
/**
|
|
94
|
+
* Minimal stop-intent latch for detached runners (issue #153).
|
|
95
|
+
*
|
|
96
|
+
* A runner installs signal handlers as early as module scope — before its
|
|
97
|
+
* config is even read — but the real stop path (kill worker, finalize the
|
|
98
|
+
* run) only exists once the worker is spawned and the close handlers are
|
|
99
|
+
* wired. A stop signal that lands in between must not die with Node's
|
|
100
|
+
* default action (that would leave the run without a terminal state); the
|
|
101
|
+
* latch records it and the runner replays it once the real stop path exists.
|
|
102
|
+
*
|
|
103
|
+
* Pure state: no signal wiring, no timers, no I/O.
|
|
104
|
+
*/
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* @returns {{
|
|
108
|
+
* note(signal: string): void,
|
|
109
|
+
* pending(): string | null,
|
|
110
|
+
* take(): string | null,
|
|
111
|
+
* }} A latch object. `note` is idempotent (the first signal wins, later ones
|
|
112
|
+
* collapse); `take` returns the recorded signal once, then null.
|
|
113
|
+
*/
|
|
114
|
+
export function createStopLatch() {
|
|
115
|
+
/** @type {string | null} */
|
|
116
|
+
let noted = null;
|
|
117
|
+
return {
|
|
118
|
+
/** Record a stop signal. @param {string} signal */
|
|
119
|
+
note(signal) {
|
|
120
|
+
if (noted == null) noted = signal;
|
|
121
|
+
},
|
|
122
|
+
/** The first recorded signal, or null. */
|
|
123
|
+
pending() {
|
|
124
|
+
return noted;
|
|
125
|
+
},
|
|
126
|
+
/** Read-and-clear: returns the recorded signal once, then null. */
|
|
127
|
+
take() {
|
|
128
|
+
const signal = noted;
|
|
129
|
+
noted = null;
|
|
130
|
+
return signal;
|
|
131
|
+
},
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
- [ ] **Step 4: Run tests to verify they pass**
|
|
137
|
+
|
|
138
|
+
Run: `node --test test/stop-latch.test.mjs`
|
|
139
|
+
Expected: PASS (4 tests, 0 fail)
|
|
140
|
+
|
|
141
|
+
- [ ] **Step 5: Commit**
|
|
142
|
+
|
|
143
|
+
```bash
|
|
144
|
+
git add src/core/stop-latch.mjs test/stop-latch.test.mjs
|
|
145
|
+
git commit -m "feat: add stop-latch state machine (issue #153)"
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
---
|
|
149
|
+
|
|
150
|
+
### Task 2: Wire the latch into job-runner
|
|
151
|
+
|
|
152
|
+
**Files:**
|
|
153
|
+
- Modify: `runner/job-runner.mjs` (four insertions — anchors quoted below)
|
|
154
|
+
|
|
155
|
+
**Interfaces:**
|
|
156
|
+
- Consumes: `createStopLatch()` from Task 1.
|
|
157
|
+
- Produces: env knob `AGENT_BOARD_TEST_BOOT_WINDOW_MS` (test-only delay between `run_started` and the spawn) and diagnostic code `stop_latch_armed` — both consumed by Task 3.
|
|
158
|
+
|
|
159
|
+
- [ ] **Step 1: Add the import**
|
|
160
|
+
|
|
161
|
+
In `runner/job-runner.mjs`, beside the other `../src/core/...` imports (after the `prompt-transport` import line):
|
|
162
|
+
|
|
163
|
+
```js
|
|
164
|
+
import { createStopLatch } from "../src/core/stop-latch.mjs";
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
- [ ] **Step 2: Install the latch at module scope**
|
|
168
|
+
|
|
169
|
+
Immediately after `const WRITE_THROTTLE_MS = 250;` (line ~36):
|
|
170
|
+
|
|
171
|
+
```js
|
|
172
|
+
// Stop-intent latch (issue #153): a stop signal that arrives before the real
|
|
173
|
+
// stop path exists (config read → run_started → worker spawn → handler
|
|
174
|
+
// wiring) must not kill this process via Node's default action — the run
|
|
175
|
+
// would never reach a terminal state. The latch records it; bootstrapRun
|
|
176
|
+
// replays it into stop() once the handlers are wired. A signal during module
|
|
177
|
+
// import (before these handlers exist) still exits by default, but nothing
|
|
178
|
+
// has been published at that point, so no observable state is lost.
|
|
179
|
+
const stopLatch = createStopLatch();
|
|
180
|
+
process.on("SIGTERM", () => stopLatch.note("SIGTERM"));
|
|
181
|
+
process.on("SIGINT", () => stopLatch.note("SIGINT"));
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
- [ ] **Step 3: Emit `stop_latch_armed` as the run's FIRST diagnostic**
|
|
185
|
+
|
|
186
|
+
In `main()`, immediately BEFORE the existing `appendDiagnostic(... code: "runner_start" ...)` line:
|
|
187
|
+
|
|
188
|
+
```js
|
|
189
|
+
// First observable write of the run (issue #153, A3): the stop latch was
|
|
190
|
+
// armed at module scope — before this diagnostic, nothing about the run is
|
|
191
|
+
// observable. Pinned by the stop-window integration test asserting this is
|
|
192
|
+
// diagnostics.jsonl's first entry.
|
|
193
|
+
appendDiagnostic(root, viewId, { source: "runner", runId, code: "stop_latch_armed", message: "Stop latch armed before any observable state", details: { signals: ["SIGTERM", "SIGINT"] } });
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
- [ ] **Step 4: Add the test-only boot-window knob**
|
|
197
|
+
|
|
198
|
+
In `bootstrapRun`, immediately AFTER the closing brace of the `if (!coordinatorDisabled()) { ... }` run_started block and BEFORE the `/** One transient progress beat ... */` comment:
|
|
199
|
+
|
|
200
|
+
```js
|
|
201
|
+
// Test-only boot-window knob (issue #153): deterministically widen the gap
|
|
202
|
+
// between the observable run_started and the handler wiring so a stop can
|
|
203
|
+
// be delivered inside the window without timing luck. Inert when unset.
|
|
204
|
+
const bootWindowMs = Number(process.env.AGENT_BOARD_TEST_BOOT_WINDOW_MS || 0);
|
|
205
|
+
if (bootWindowMs > 0) await new Promise((resolveDelay) => setTimeout(resolveDelay, bootWindowMs));
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
- [ ] **Step 5: Replay a latched stop once the real handlers exist**
|
|
209
|
+
|
|
210
|
+
Immediately after the two lines `process.on("SIGTERM", stop);` / `process.on("SIGINT", stop);` (line ~371):
|
|
211
|
+
|
|
212
|
+
```js
|
|
213
|
+
// Replay a stop observed before the real handlers existed (issue #153).
|
|
214
|
+
// take() clears, so this fires at most once and cannot double-fire with a
|
|
215
|
+
// later real signal; stop() itself is worker.killed-guarded.
|
|
216
|
+
if (stopLatch.take() != null) stop();
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
- [ ] **Step 6: Run the existing runner suites — no regression**
|
|
220
|
+
|
|
221
|
+
Run: `node --test test/runner.integration.test.mjs test/stop-latch.test.mjs`
|
|
222
|
+
Expected: PASS (the existing `stopping the runner finalizes the run as stopped` must stay green — Review Focus #3). If anything else in the file is red, stop and investigate before committing.
|
|
223
|
+
|
|
224
|
+
- [ ] **Step 7: Commit**
|
|
225
|
+
|
|
226
|
+
```bash
|
|
227
|
+
git add runner/job-runner.mjs
|
|
228
|
+
git commit -m "feat: module-scope stop latch replays early stops in job-runner (issue #153)"
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
---
|
|
232
|
+
|
|
233
|
+
### Task 3: Boot-window integration tests (A1 + A3 + A10)
|
|
234
|
+
|
|
235
|
+
> Addendum: A10 originally lived in this task; Task 3's review found it red against unfixed reconcile (blocking finding) and it moved to Task 4 with the loadRow seam — see the Task 4 addendum below.
|
|
236
|
+
|
|
237
|
+
**Files:**
|
|
238
|
+
- Create: `test/runner-stop-window.integration.test.mjs`
|
|
239
|
+
|
|
240
|
+
**Interfaces:**
|
|
241
|
+
- Consumes: `createStopLatch` semantics (Task 1), the knob `AGENT_BOARD_TEST_BOOT_WINDOW_MS` and diagnostic code `stop_latch_armed` (Task 2), `launchRun` / `startCoordinator` / `createView` / `readStatus` / `readState` / `readPid` / `readDiagnostics` helpers, `createService` for A10.
|
|
242
|
+
|
|
243
|
+
- [ ] **Step 1: Write the test file**
|
|
244
|
+
|
|
245
|
+
```js
|
|
246
|
+
/**
|
|
247
|
+
* Boot-window stop-safety integration tests (issue #153).
|
|
248
|
+
*
|
|
249
|
+
* A1: a stop delivered between the observable run_started and the handler
|
|
250
|
+
* wiring must still finalize the run (module-scope stop latch). Leg 1
|
|
251
|
+
* injects the window deterministically via AGENT_BOARD_TEST_BOOT_WINDOW_MS;
|
|
252
|
+
* leg 2 repeats the flow with the knob unset (inert-when-unset proof).
|
|
253
|
+
* A3: the run's first diagnostics.jsonl entry is stop_latch_armed — nothing
|
|
254
|
+
* is published before the process can handle a stop.
|
|
255
|
+
* A10: a runner hard-killed before finalizing (the pre-#153 failure shape)
|
|
256
|
+
* is converged by service.reconcile() — defense in depth behind the latch.
|
|
257
|
+
*/
|
|
258
|
+
import assert from "node:assert/strict";
|
|
259
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
260
|
+
import { tmpdir } from "node:os";
|
|
261
|
+
import { fileURLToPath } from "node:url";
|
|
262
|
+
import { join } from "node:path";
|
|
263
|
+
import { test } from "node:test";
|
|
264
|
+
import { readDiagnostics } from "../src/core/diagnostics.mjs";
|
|
265
|
+
import { launchRun } from "../src/core/launch.mjs";
|
|
266
|
+
import { isAlive } from "../src/core/pid.mjs";
|
|
267
|
+
import { createService } from "../src/runtime/service.mjs";
|
|
268
|
+
import { createView, readPid, readState, readStatus } from "../src/core/store.mjs";
|
|
269
|
+
import { startCoordinator } from "../test-support/ensure-coordinator-helper.mjs";
|
|
270
|
+
|
|
271
|
+
const ROOT_DIR = fileURLToPath(new URL("../", import.meta.url));
|
|
272
|
+
const RUNNER = join(ROOT_DIR, "runner", "job-runner.mjs");
|
|
273
|
+
const FAKE_PI = join(ROOT_DIR, "test-support", "fake-pi.mjs");
|
|
274
|
+
|
|
275
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
276
|
+
|
|
277
|
+
/** Kill a detached process before deleting its root so it can never orphan (issue #33). */
|
|
278
|
+
async function killDetached(pid) {
|
|
279
|
+
if (!pid || pid <= 0) return;
|
|
280
|
+
try {
|
|
281
|
+
process.kill(pid, "SIGTERM");
|
|
282
|
+
} catch {
|
|
283
|
+
return; // already exited
|
|
284
|
+
}
|
|
285
|
+
const deadline = Date.now() + 1000;
|
|
286
|
+
while (Date.now() < deadline) {
|
|
287
|
+
await sleep(50);
|
|
288
|
+
try {
|
|
289
|
+
process.kill(pid, 0);
|
|
290
|
+
} catch {
|
|
291
|
+
return; // exited
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
try {
|
|
295
|
+
process.kill(pid, "SIGKILL");
|
|
296
|
+
} catch {
|
|
297
|
+
/* already gone */
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** Poll `fn()` until it returns truthy or timeout. */
|
|
302
|
+
async function waitFor(fn, timeoutMs = 15000, intervalMs = 50) {
|
|
303
|
+
const start = Date.now();
|
|
304
|
+
for (;;) {
|
|
305
|
+
const v = await fn();
|
|
306
|
+
if (v) return v;
|
|
307
|
+
if (Date.now() - start > timeoutMs) return null;
|
|
308
|
+
await sleep(intervalMs);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function makeConfig(root, viewId, runId, sessionFile, cwd, prompt) {
|
|
313
|
+
return {
|
|
314
|
+
root,
|
|
315
|
+
viewId,
|
|
316
|
+
runId,
|
|
317
|
+
kind: "dispatch",
|
|
318
|
+
sessionFile,
|
|
319
|
+
cwd,
|
|
320
|
+
prompt,
|
|
321
|
+
piCommand: process.execPath,
|
|
322
|
+
piArgsPrefix: [FAKE_PI],
|
|
323
|
+
model: null,
|
|
324
|
+
tools: null,
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function testService(root) {
|
|
329
|
+
return createService({
|
|
330
|
+
root,
|
|
331
|
+
runnerScript: RUNNER,
|
|
332
|
+
ptyRunnerScript: join(ROOT_DIR, "runner", "pty-runner.mjs"),
|
|
333
|
+
piCommand: process.execPath,
|
|
334
|
+
piArgsPrefix: [FAKE_PI],
|
|
335
|
+
defaultCwd: process.cwd(),
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/** Shared fixture: launch a hanging run and wait until working is observable. */
|
|
340
|
+
async function startWorkingRun(root) {
|
|
341
|
+
const meta = createView(root, { id: "view_1", name: "stopwin", cwd: root });
|
|
342
|
+
const config = makeConfig(root, "view_1", "run_1", meta.sessionFile, root, "do it");
|
|
343
|
+
const runnerPid = launchRun(root, config, { runnerScript: RUNNER }).pid;
|
|
344
|
+
assert.ok(runnerPid && runnerPid > 0, "runner spawned");
|
|
345
|
+
const working = await waitFor(() => {
|
|
346
|
+
const s = readStatus(root, "view_1", "run_1");
|
|
347
|
+
return s && s.semanticState === "working" ? s : null;
|
|
348
|
+
});
|
|
349
|
+
assert.ok(working, "run reached working (run_started is observable)");
|
|
350
|
+
return { runnerPid };
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/** A3 must hold on every leg: stop_latch_armed is the run's first diagnostic. */
|
|
354
|
+
function assertFirstDiagnosticIsLatchArmed(root) {
|
|
355
|
+
const diags = readDiagnostics(root, "view_1");
|
|
356
|
+
assert.ok(diags.length > 0, "diagnostics exist");
|
|
357
|
+
assert.equal(diags[0].code, "stop_latch_armed", "stop latch armed before anything was published");
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
async function stopWindowLeg(t, { injected }) {
|
|
361
|
+
const root = mkdtempSync(join(tmpdir(), "agentview-stop-window-"));
|
|
362
|
+
process.env.FAKE_PI_MODE = "hang";
|
|
363
|
+
process.env.AGENT_BOARD_SUMMARY_MODEL = "off";
|
|
364
|
+
if (injected) process.env.AGENT_BOARD_TEST_BOOT_WINDOW_MS = "800";
|
|
365
|
+
let runnerPid = null;
|
|
366
|
+
// Tracked coordinator: the run's terminal state routes through it; without
|
|
367
|
+
// this fixture the client's ensure path spawns an untracked twin.
|
|
368
|
+
const coord = await startCoordinator(root);
|
|
369
|
+
try {
|
|
370
|
+
const started = await startWorkingRun(root);
|
|
371
|
+
runnerPid = started.runnerPid;
|
|
372
|
+
|
|
373
|
+
const pid = readPid(root, "view_1", "run_1");
|
|
374
|
+
assert.ok(pid, "have runner pid");
|
|
375
|
+
// Injected leg: the knob guarantees the SIGTERM lands between run_started
|
|
376
|
+
// and the handler wiring (the poll resolves ~25ms into the 800ms window).
|
|
377
|
+
process.kill(pid, "SIGTERM");
|
|
378
|
+
|
|
379
|
+
const status = await waitFor(() => {
|
|
380
|
+
const s = readStatus(root, "view_1", "run_1");
|
|
381
|
+
return s && s.endedAt != null ? s : null;
|
|
382
|
+
});
|
|
383
|
+
assert.ok(status, "run finalized after the early stop");
|
|
384
|
+
assert.equal(status.semanticState, "stopped", "early stop still produces the stopped verdict");
|
|
385
|
+
assertFirstDiagnosticIsLatchArmed(root);
|
|
386
|
+
} finally {
|
|
387
|
+
delete process.env.AGENT_BOARD_TEST_BOOT_WINDOW_MS;
|
|
388
|
+
await killDetached(runnerPid);
|
|
389
|
+
await coord.kill();
|
|
390
|
+
delete process.env.FAKE_PI_MODE;
|
|
391
|
+
delete process.env.AGENT_BOARD_SUMMARY_MODEL;
|
|
392
|
+
rmSync(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
test("A1 (injected window): SIGTERM inside the boot window still finalizes as stopped", { timeout: 20000 }, async () => {
|
|
397
|
+
await stopWindowLeg(t, { injected: true });
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
test("A1 (knob unset): the same flow passes with the knob inert", { timeout: 20000 }, async () => {
|
|
401
|
+
await stopWindowLeg(t, { injected: false });
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
test("A10: reconcile converges a runner hard-killed before finalizing", { timeout: 20000 }, async () => {
|
|
405
|
+
const root = mkdtempSync(join(tmpdir(), "agentview-stop-window-"));
|
|
406
|
+
process.env.FAKE_PI_MODE = "hang";
|
|
407
|
+
process.env.AGENT_BOARD_SUMMARY_MODEL = "off";
|
|
408
|
+
let runnerPid = null;
|
|
409
|
+
const coord = await startCoordinator(root);
|
|
410
|
+
try {
|
|
411
|
+
const started = await startWorkingRun(root);
|
|
412
|
+
runnerPid = started.runnerPid;
|
|
413
|
+
|
|
414
|
+
// Simulate the pre-#153 failure shape: a hard kill nothing can intercept,
|
|
415
|
+
// leaving working/alive with no endedAt.
|
|
416
|
+
process.kill(runnerPid, "SIGKILL");
|
|
417
|
+
await waitFor(() => (isAlive(runnerPid) ? null : true), 10000);
|
|
418
|
+
const frozen = readStatus(root, "view_1", "run_1");
|
|
419
|
+
assert.ok(frozen, "status exists");
|
|
420
|
+
assert.equal(frozen.endedAt, null, "no terminal state after the hard kill");
|
|
421
|
+
assertFirstDiagnosticIsLatchArmed(root);
|
|
422
|
+
|
|
423
|
+
await testService(root).reconcile();
|
|
424
|
+
|
|
425
|
+
const state = readState(root, "view_1");
|
|
426
|
+
assert.equal(state.semanticState, "failed", "reconcile converged the dead run");
|
|
427
|
+
assert.equal(state.processState, "exited");
|
|
428
|
+
assert.equal(state.summary, "Failed (runner exited)");
|
|
429
|
+
} finally {
|
|
430
|
+
await killDetached(runnerPid);
|
|
431
|
+
const orphanWorker = (() => {
|
|
432
|
+
try {
|
|
433
|
+
return readStatus(root, "view_1", "run_1")?.pid ?? null;
|
|
434
|
+
} catch {
|
|
435
|
+
return null;
|
|
436
|
+
}
|
|
437
|
+
})();
|
|
438
|
+
await killDetached(orphanWorker);
|
|
439
|
+
await coord.kill();
|
|
440
|
+
delete process.env.FAKE_PI_MODE;
|
|
441
|
+
delete process.env.AGENT_BOARD_SUMMARY_MODEL;
|
|
442
|
+
rmSync(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
|
|
443
|
+
}
|
|
444
|
+
});
|
|
445
|
+
```
|
|
446
|
+
|
|
447
|
+
- [ ] **Step 2: Run the new tests — expect green**
|
|
448
|
+
|
|
449
|
+
Run: `node --test test/runner-stop-window.integration.test.mjs`
|
|
450
|
+
Expected: PASS (3 tests). If A1-injected fails with `run finalized after stop`, the latch wiring from Task 2 is not effective — investigate before proceeding.
|
|
451
|
+
|
|
452
|
+
- [ ] **Step 3: Red-world check — prove the tests bite**
|
|
453
|
+
|
|
454
|
+
Temporarily comment out the replay line from Task 2 (`if (stopLatch.take() != null) stop();`), then:
|
|
455
|
+
|
|
456
|
+
Run: `node --test test/runner-stop-window.integration.test.mjs`
|
|
457
|
+
Expected: the injected-window leg FAILS (`run finalized after stop`); leg 2 and A10 still pass.
|
|
458
|
+
Restore the line, re-run: PASS. (This is the same red-world discipline used by #145's A18.)
|
|
459
|
+
|
|
460
|
+
- [ ] **Step 4: Run the whole runner-related suite**
|
|
461
|
+
|
|
462
|
+
Run: `node --test test/runner.integration.test.mjs test/runner-stop-window.integration.test.mjs test/stop-latch.test.mjs`
|
|
463
|
+
Expected: PASS.
|
|
464
|
+
|
|
465
|
+
- [ ] **Step 5: Commit**
|
|
466
|
+
|
|
467
|
+
```bash
|
|
468
|
+
git add test/runner-stop-window.integration.test.mjs
|
|
469
|
+
git commit -m "test: boot-window stop latch coverage and dead-run convergence (issue #153)"
|
|
470
|
+
```
|
|
471
|
+
|
|
472
|
+
---
|
|
473
|
+
|
|
474
|
+
### Task 4 (addendum 2026-09-30, controller-ruled after Task 3's A10 blocking finding): Dead-runner row convergence
|
|
475
|
+
|
|
476
|
+
**Files:**
|
|
477
|
+
- Modify: `src/core/store.mjs:393-404` (loadRow alive computation)
|
|
478
|
+
- Test: `test/store.test.mjs` (append unit tests) and the A10 integration test below
|
|
479
|
+
|
|
480
|
+
**Interfaces:**
|
|
481
|
+
- Consumes: `readPid(root, viewId, runId)` → `number|null` (null = no record or pid:null record; `src/core/store.mjs:337-339`).
|
|
482
|
+
- Produces: `loadRow().alive` semantics — "a recorded dead runner pid wins over the `processState: "alive"` mirror; the mirror applies only when no pid record exists". Downstream consumers (`isAgentBusy`, `archiveView`, `service.reconcile`) change behavior through `row.alive`.
|
|
483
|
+
|
|
484
|
+
- [ ] **Step 1: Write the failing unit tests** — append to `test/store.test.mjs` (reuse its existing fixture helpers for meta/state): `deadPid()` must `await` the child's `exit` event before use so it is genuinely reaped:
|
|
485
|
+
|
|
486
|
+
```js
|
|
487
|
+
/** A pid that is definitely dead (spawned, exited, reaped). */
|
|
488
|
+
async function deadPid() {
|
|
489
|
+
const child = spawn(process.execPath, ["-e", "process.exit(0)"], { stdio: "ignore" });
|
|
490
|
+
await new Promise((resolveExit) => child.on("exit", resolveExit));
|
|
491
|
+
return child.pid;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
test("loadRow: a recorded dead runner pid wins over the alive mirror (issue #153)", async () => {
|
|
495
|
+
const root = mkdtempSync(join(tmpdir(), "agentview-store-alive-"));
|
|
496
|
+
try {
|
|
497
|
+
createView(root, { id: "v", name: "t", cwd: root });
|
|
498
|
+
const st = readState(root, "v");
|
|
499
|
+
st.currentRunId = "run_1";
|
|
500
|
+
st.processState = "alive";
|
|
501
|
+
writeState(root, st);
|
|
502
|
+
const pid = await deadPid();
|
|
503
|
+
writeFileSync(P.pidPath(root, "v", "run_1"), JSON.stringify({ pid, at: Date.now() }));
|
|
504
|
+
const row = loadRow(root, "v");
|
|
505
|
+
assert.equal(row.alive, false, "recorded dead pid ⇒ not alive, mirror must not resurrect it");
|
|
506
|
+
} finally {
|
|
507
|
+
rmSync(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
|
|
508
|
+
}
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
test("loadRow: the alive mirror still applies when no pid record exists (foreground path)", async () => {
|
|
512
|
+
const root = mkdtempSync(join(tmpdir(), "agentview-store-alive-"));
|
|
513
|
+
try {
|
|
514
|
+
createView(root, { id: "v", name: "t", cwd: root });
|
|
515
|
+
const st = readState(root, "v");
|
|
516
|
+
st.currentRunId = "run_fg";
|
|
517
|
+
st.processState = "alive";
|
|
518
|
+
writeState(root, st);
|
|
519
|
+
// no pid.json for run_fg — the foreground follow-up shape
|
|
520
|
+
const row = loadRow(root, "v");
|
|
521
|
+
assert.equal(row.alive, true, "no pid record ⇒ mirror decides (foreground preserved)");
|
|
522
|
+
} finally {
|
|
523
|
+
rmSync(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
|
|
524
|
+
}
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
test("loadRow: a recorded live pid keeps the row alive regardless of the mirror", async () => {
|
|
528
|
+
const root = mkdtempSync(join(tmpdir(), "agentview-store-alive-"));
|
|
529
|
+
try {
|
|
530
|
+
createView(root, { id: "v", name: "t", cwd: root });
|
|
531
|
+
const st = readState(root, "v");
|
|
532
|
+
st.currentRunId = "run_1";
|
|
533
|
+
st.processState = "alive";
|
|
534
|
+
writeState(root, st);
|
|
535
|
+
writeFileSync(P.pidPath(root, "v", "run_1"), JSON.stringify({ pid: process.pid, at: Date.now() }));
|
|
536
|
+
const row = loadRow(root, "v");
|
|
537
|
+
assert.equal(row.alive, true);
|
|
538
|
+
} finally {
|
|
539
|
+
rmSync(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
|
|
540
|
+
}
|
|
541
|
+
});
|
|
542
|
+
```
|
|
543
|
+
|
|
544
|
+
(Imports the file already needs: `spawn` from `node:child_process`, `writeFileSync` from `node:fs` — add if missing.)
|
|
545
|
+
|
|
546
|
+
- [ ] **Step 2: Re-add the A10 integration test as this task's RED** — create `test/runner-stop-window.integration.test.mjs` is Task 3's file; instead append to it a new `test(...)` block using the SAME helpers already defined there (copy the A10 block from this plan's Task 3 brief verbatim — fixture, SIGKILL, `testService(root).reconcile()`, assertions `failed` / `exited` / `Failed (runner exited)`, orphan-worker cleanup in finally).
|
|
547
|
+
|
|
548
|
+
- [ ] **Step 3: Run to verify RED** — `node --test test/store.test.mjs test/runner-stop-window.integration.test.mjs`: expect the new unit test "recorded dead pid wins" FAIL (`alive === true`), the two mirror/live unit tests PASS, and the A10 integration test FAIL (`actual 'working' expected 'failed'`). This red is Task 3's documented blocking finding — same fingerprint.
|
|
549
|
+
|
|
550
|
+
- [ ] **Step 4: Implement the loadRow seam** — in `src/core/store.mjs`, replace the alive computation:
|
|
551
|
+
|
|
552
|
+
```js
|
|
553
|
+
let alive = false;
|
|
554
|
+
// True when a detached runner pid record exists for the current run. The
|
|
555
|
+
// processState mirror below exists for the foreground path, which has no
|
|
556
|
+
// runner pid to poll — it must never override a RECORDED pid: an
|
|
557
|
+
// un-finalized dead runner is the row's truth, and trusting the mirror
|
|
558
|
+
// here makes reconcile() skip the row forever (issue #153, A10).
|
|
559
|
+
let runnerPidRecorded = false;
|
|
560
|
+
if (enrichedState?.currentRunId) {
|
|
561
|
+
const pid = readPid(root, viewId, enrichedState.currentRunId);
|
|
562
|
+
runnerPidRecorded = pid != null;
|
|
563
|
+
alive = isAlive(pid);
|
|
564
|
+
}
|
|
565
|
+
// A managed session can also be active in the foreground after the user attaches
|
|
566
|
+
// and types a follow-up. In that path there is no detached runner pid for us to
|
|
567
|
+
// poll, but foreground extension events mirror processState into state.json.
|
|
568
|
+
if (!alive && !runnerPidRecorded && enrichedState?.processState === "alive") alive = true;
|
|
569
|
+
```
|
|
570
|
+
|
|
571
|
+
- [ ] **Step 5: Run to verify GREEN** — same command as Step 3: all pass, plus `node --test test/runner.integration.test.mjs test/service.test.mjs test/host-concurrency.integration.test.mjs` (row.alive consumers) and `npm test` once.
|
|
572
|
+
|
|
573
|
+
- [ ] **Step 6: Commit**
|
|
574
|
+
|
|
575
|
+
```bash
|
|
576
|
+
git add src/core/store.mjs test/store.test.mjs test/runner-stop-window.integration.test.mjs
|
|
577
|
+
git commit -m "fix: loadRow trusts a recorded dead runner pid over the alive mirror (issue #153)"
|
|
578
|
+
```
|
|
579
|
+
|
|
580
|
+
**Review focus for this task:** foreground case preserved (unit test 2); live pid unaffected (unit test 3); consumers of `row.alive` (`isAgentBusy`, `archiveView`, `canAutoDrain`, warm-host sweeper) reviewed against the new semantics — the reviewer runs `rg -n "row.alive|\\.alive\\b" src/` as a named-risk check; a `pid.json` holding `pid:null` counts as no record (mirror applies) — documented semantics, `readPid` returns null.
|
|
581
|
+
|
|
582
|
+
---
|
|
583
|
+
|
|
584
|
+
### Task 5: Full gate + load-repeat acceptance (A4)
|
|
585
|
+
|
|
586
|
+
**Files:**
|
|
587
|
+
- No new files. (A4 is a one-time acceptance procedure, not a recurring CI test.)
|
|
588
|
+
|
|
589
|
+
**Interfaces:**
|
|
590
|
+
- Consumes: everything above.
|
|
591
|
+
|
|
592
|
+
- [ ] **Step 1: Full verify**
|
|
593
|
+
|
|
594
|
+
Run: `npm run verify`
|
|
595
|
+
Expected: exit 0 (typecheck, perf gate, tests, coverage thresholds, pack:dry).
|
|
596
|
+
|
|
597
|
+
- [ ] **Step 2: A4 load-repeat procedure**
|
|
598
|
+
|
|
599
|
+
Start the full suite in the background (`npm test &`), then run 20 consecutive iterations:
|
|
600
|
+
|
|
601
|
+
```bash
|
|
602
|
+
for i in $(seq 1 20); do node --test test/runner.integration.test.mjs >/tmp/a4-$i.tap 2>&1 || echo "FAIL run $i"; done
|
|
603
|
+
```
|
|
604
|
+
|
|
605
|
+
Expected: 20/20 green. Record the result summary (one line) for the PR body. Pre-fix baseline: failures within the first 2–3 runs under load.
|
|
606
|
+
|
|
607
|
+
- [ ] **Step 3: Record acceptance + wrap up**
|
|
608
|
+
|
|
609
|
+
Record A1/A2/A3/A10 (commands + outcomes) and A4 (run count) in the PR body draft; U1/U2 stay with #95. Note the residual Windows/import-window caveats from the spec. No CHANGELOG edit (machine-generated at release).
|
|
610
|
+
|
|
611
|
+
---
|
|
612
|
+
|
|
613
|
+
## Self-Review (executed while writing)
|
|
614
|
+
|
|
615
|
+
- Spec coverage: invariant → Tasks 1+2; A1/A3 → Task 3 leg 1 + `assertFirstDiagnosticIsLatchArmed`; A2 → Task 1; A10 → Task 3; A4 → Task 4; knob guards → Task 2 Step 4 + Task 3 leg 2. Platform scope/risks recorded in the spec, not tasks (no code). No orphan acceptance IDs.
|
|
616
|
+
- Placeholder scan: none — every step carries actual code or an exact command with expected output.
|
|
617
|
+
- Type consistency: `createStopLatch()` API identical across Tasks 1–2; knob name and diagnostic code identical across Tasks 2–3.
|
|
618
|
+
- Review Focus: all five lines pinned (see inline).
|