@crouton-kit/crouter 0.3.39 → 0.3.41

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 (188) hide show
  1. package/dist/build-root.js +1 -0
  2. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/__tests__/provider-rotation.test.ts +472 -9
  3. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +284 -76
  4. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/strip-skills-docs.ts +31 -24
  5. package/dist/builtin-pi-packages/pi-crtr-extensions/lib/subscription-state.ts +52 -15
  6. package/dist/clients/attach/attach-cmd.js +706 -733
  7. package/dist/clients/attach/chat-view.js +11 -0
  8. package/dist/clients/web/server.js +5 -28
  9. package/dist/clients/web/web-cmd.js +1 -1
  10. package/dist/commands/__tests__/revive-now-gate.test.js +60 -0
  11. package/dist/commands/canvas-rebuild-index.js +5 -5
  12. package/dist/commands/node-snapshot.js +9 -1
  13. package/dist/commands/node.js +37 -17
  14. package/dist/commands/push.js +8 -0
  15. package/dist/commands/revive.d.ts +10 -0
  16. package/dist/commands/revive.js +28 -10
  17. package/dist/commands/sys/__tests__/setup-core.test.js +19 -0
  18. package/dist/commands/sys/doctor.js +1 -1
  19. package/dist/commands/sys/setup-core.js +3 -2
  20. package/dist/commands/sys/setup.js +1 -1
  21. package/dist/commands/worktree.d.ts +2 -0
  22. package/dist/commands/worktree.js +94 -0
  23. package/dist/core/__tests__/boot.test.js +4 -4
  24. package/dist/core/__tests__/canvas.test.js +19 -7
  25. package/dist/core/__tests__/child-followup.test.js +15 -5
  26. package/dist/core/__tests__/daemon-boot.test.js +6 -1
  27. package/dist/core/__tests__/daemon-wedge.test.js +18 -1
  28. package/dist/core/__tests__/fault-classifier.test.js +30 -0
  29. package/dist/core/__tests__/fixtures/fake-engine.d.ts +13 -0
  30. package/dist/core/__tests__/fixtures/fake-engine.js +20 -0
  31. package/dist/core/__tests__/full/spike-harness.test.js +8 -5
  32. package/dist/core/__tests__/grace-clock.test.js +18 -2
  33. package/dist/core/__tests__/host-teardown-process-group.test.js +465 -0
  34. package/dist/core/__tests__/prune-to-limit.test.js +14 -0
  35. package/dist/core/__tests__/review-model-floor.test.js +32 -0
  36. package/dist/core/__tests__/revive.test.js +18 -4
  37. package/dist/core/__tests__/session-cycles.test.js +77 -0
  38. package/dist/core/__tests__/worktree.test.js +85 -0
  39. package/dist/core/canvas/boot.js +12 -7
  40. package/dist/core/canvas/canvas.d.ts +27 -8
  41. package/dist/core/canvas/canvas.js +54 -26
  42. package/dist/core/canvas/db.js +14 -0
  43. package/dist/core/canvas/history.js +1 -0
  44. package/dist/core/canvas/paths.d.ts +10 -9
  45. package/dist/core/canvas/paths.js +10 -9
  46. package/dist/core/canvas/pid.d.ts +155 -1
  47. package/dist/core/canvas/pid.js +306 -1
  48. package/dist/core/canvas/status-glyph.d.ts +7 -0
  49. package/dist/core/canvas/status-glyph.js +10 -1
  50. package/dist/core/canvas/types.d.ts +34 -0
  51. package/dist/core/fault-classifier.js +5 -1
  52. package/dist/core/runtime/bearings.js +4 -0
  53. package/dist/core/runtime/branded-host.d.ts +7 -0
  54. package/dist/core/runtime/branded-host.js +44 -17
  55. package/dist/core/runtime/broker-sdk.js +28 -68
  56. package/dist/core/runtime/broker.js +35 -4
  57. package/dist/core/runtime/host.d.ts +3 -3
  58. package/dist/core/runtime/host.js +148 -33
  59. package/dist/core/runtime/launch.d.ts +15 -15
  60. package/dist/core/runtime/launch.js +58 -4
  61. package/dist/core/runtime/naming.js +3 -2
  62. package/dist/core/runtime/nodes.d.ts +3 -1
  63. package/dist/core/runtime/nodes.js +1 -0
  64. package/dist/core/runtime/pi-cli.d.ts +2 -0
  65. package/dist/core/runtime/pi-cli.js +51 -0
  66. package/dist/core/runtime/placement.d.ts +1 -1
  67. package/dist/core/runtime/placement.js +5 -2
  68. package/dist/core/runtime/recap.js +2 -1
  69. package/dist/core/runtime/revive.d.ts +21 -15
  70. package/dist/core/runtime/revive.js +87 -52
  71. package/dist/core/runtime/session-cycles.d.ts +30 -0
  72. package/dist/core/runtime/session-cycles.js +77 -0
  73. package/dist/core/runtime/spawn.d.ts +4 -0
  74. package/dist/core/runtime/spawn.js +152 -100
  75. package/dist/core/runtime/tmux.d.ts +7 -10
  76. package/dist/core/runtime/tmux.js +9 -11
  77. package/dist/core/worktree.d.ts +35 -0
  78. package/dist/core/worktree.js +158 -0
  79. package/dist/daemon/crtrd.d.ts +17 -8
  80. package/dist/daemon/crtrd.js +191 -40
  81. package/dist/index.d.ts +3 -0
  82. package/dist/index.js +7 -0
  83. package/dist/pi-extensions/canvas-context-intro.d.ts +1 -0
  84. package/dist/pi-extensions/canvas-context-intro.js +34 -23
  85. package/dist/pi-extensions/canvas-stophook.js +11 -5
  86. package/dist/web-client/assets/{index-CbO8L0mN.js → index-B00YpRQ1.js} +20 -20
  87. package/dist/web-client/assets/index-DrkcvANq.css +2 -0
  88. package/dist/web-client/index.html +2 -2
  89. package/docs/compat/hearth-crtr-v1.md +191 -0
  90. package/docs/public-api.md +75 -0
  91. package/package.json +3 -4
  92. package/dist/core/__tests__/hearth-bootstrap.test.js +0 -136
  93. package/dist/core/hearth/__tests__/model-auth-guest.test.js +0 -151
  94. package/dist/core/hearth/config.d.ts +0 -3
  95. package/dist/core/hearth/config.js +0 -108
  96. package/dist/core/hearth/guest-env.d.ts +0 -9
  97. package/dist/core/hearth/guest-env.js +0 -27
  98. package/dist/core/hearth/index.d.ts +0 -4
  99. package/dist/core/hearth/index.js +0 -4
  100. package/dist/core/hearth/model-auth-guest.d.ts +0 -8
  101. package/dist/core/hearth/model-auth-guest.js +0 -430
  102. package/dist/core/hearth/provider.d.ts +0 -36
  103. package/dist/core/hearth/provider.js +0 -10
  104. package/dist/core/hearth/providers/__tests__/sweep-and-release.test.js +0 -254
  105. package/dist/core/hearth/providers/blaxel-bootstrap.d.ts +0 -12
  106. package/dist/core/hearth/providers/blaxel-bootstrap.js +0 -147
  107. package/dist/core/hearth/providers/blaxel-home.d.ts +0 -54
  108. package/dist/core/hearth/providers/blaxel-home.js +0 -386
  109. package/dist/core/hearth/providers/blaxel.d.ts +0 -36
  110. package/dist/core/hearth/providers/blaxel.js +0 -364
  111. package/dist/core/hearth/providers/types.d.ts +0 -93
  112. package/dist/core/hearth/types.d.ts +0 -155
  113. package/dist/hearth/control-plane/__tests__/error-serialization.test.js +0 -29
  114. package/dist/hearth/control-plane/__tests__/node-message.test.js +0 -60
  115. package/dist/hearth/control-plane/__tests__/oauth-serving-marker.test.d.ts +0 -1
  116. package/dist/hearth/control-plane/__tests__/oauth-serving-marker.test.js +0 -44
  117. package/dist/hearth/control-plane/__tests__/rate-limit-recurrence.test.d.ts +0 -1
  118. package/dist/hearth/control-plane/__tests__/rate-limit-recurrence.test.js +0 -49
  119. package/dist/hearth/control-plane/__tests__/relay-security.test.d.ts +0 -1
  120. package/dist/hearth/control-plane/__tests__/relay-security.test.js +0 -314
  121. package/dist/hearth/control-plane/__tests__/scheduler-scan-loop.test.d.ts +0 -1
  122. package/dist/hearth/control-plane/__tests__/scheduler-scan-loop.test.js +0 -133
  123. package/dist/hearth/control-plane/__tests__/trigger-delivery.test.d.ts +0 -1
  124. package/dist/hearth/control-plane/__tests__/trigger-delivery.test.js +0 -170
  125. package/dist/hearth/control-plane/__tests__/wake-roll.test.d.ts +0 -1
  126. package/dist/hearth/control-plane/__tests__/wake-roll.test.js +0 -230
  127. package/dist/hearth/control-plane/__tests__/webhook-ingress.test.d.ts +0 -1
  128. package/dist/hearth/control-plane/__tests__/webhook-ingress.test.js +0 -167
  129. package/dist/hearth/control-plane/config.d.ts +0 -21
  130. package/dist/hearth/control-plane/config.js +0 -77
  131. package/dist/hearth/control-plane/db.d.ts +0 -30
  132. package/dist/hearth/control-plane/db.js +0 -561
  133. package/dist/hearth/control-plane/hearth-target.d.ts +0 -23
  134. package/dist/hearth/control-plane/hearth-target.js +0 -68
  135. package/dist/hearth/control-plane/ingress/rate-limit.d.ts +0 -24
  136. package/dist/hearth/control-plane/ingress/rate-limit.js +0 -100
  137. package/dist/hearth/control-plane/ingress/route-store.d.ts +0 -31
  138. package/dist/hearth/control-plane/ingress/route-store.js +0 -61
  139. package/dist/hearth/control-plane/ingress/webhook-delivery-store.d.ts +0 -41
  140. package/dist/hearth/control-plane/ingress/webhook-delivery-store.js +0 -69
  141. package/dist/hearth/control-plane/ingress/webhook-route.d.ts +0 -55
  142. package/dist/hearth/control-plane/ingress/webhook-route.js +0 -285
  143. package/dist/hearth/control-plane/main.d.ts +0 -1
  144. package/dist/hearth/control-plane/main.js +0 -88
  145. package/dist/hearth/control-plane/node-message.d.ts +0 -31
  146. package/dist/hearth/control-plane/node-message.js +0 -98
  147. package/dist/hearth/control-plane/register.d.ts +0 -15
  148. package/dist/hearth/control-plane/register.js +0 -34
  149. package/dist/hearth/control-plane/registry.d.ts +0 -22
  150. package/dist/hearth/control-plane/registry.js +0 -168
  151. package/dist/hearth/control-plane/relay.d.ts +0 -44
  152. package/dist/hearth/control-plane/relay.js +0 -711
  153. package/dist/hearth/control-plane/scheduler/fire-store.d.ts +0 -36
  154. package/dist/hearth/control-plane/scheduler/fire-store.js +0 -73
  155. package/dist/hearth/control-plane/scheduler/recurrence.d.ts +0 -7
  156. package/dist/hearth/control-plane/scheduler/recurrence.js +0 -58
  157. package/dist/hearth/control-plane/scheduler/scan-loop.d.ts +0 -38
  158. package/dist/hearth/control-plane/scheduler/scan-loop.js +0 -138
  159. package/dist/hearth/control-plane/scheduler/schedule-store.d.ts +0 -32
  160. package/dist/hearth/control-plane/scheduler/schedule-store.js +0 -66
  161. package/dist/hearth/control-plane/secrets.d.ts +0 -31
  162. package/dist/hearth/control-plane/secrets.js +0 -134
  163. package/dist/hearth/control-plane/server.d.ts +0 -27
  164. package/dist/hearth/control-plane/server.js +0 -482
  165. package/dist/hearth/control-plane/serving.d.ts +0 -15
  166. package/dist/hearth/control-plane/serving.js +0 -106
  167. package/dist/hearth/control-plane/session.d.ts +0 -68
  168. package/dist/hearth/control-plane/session.js +0 -273
  169. package/dist/hearth/control-plane/triggers/acl.d.ts +0 -14
  170. package/dist/hearth/control-plane/triggers/acl.js +0 -52
  171. package/dist/hearth/control-plane/triggers/audit-store.d.ts +0 -38
  172. package/dist/hearth/control-plane/triggers/audit-store.js +0 -79
  173. package/dist/hearth/control-plane/triggers/deliver.d.ts +0 -43
  174. package/dist/hearth/control-plane/triggers/deliver.js +0 -76
  175. package/dist/hearth/control-plane/triggers/envelope.d.ts +0 -29
  176. package/dist/hearth/control-plane/triggers/envelope.js +0 -38
  177. package/dist/hearth/control-plane/types.d.ts +0 -86
  178. package/dist/hearth/control-plane/types.js +0 -1
  179. package/dist/hearth/control-plane/wake.d.ts +0 -86
  180. package/dist/hearth/control-plane/wake.js +0 -550
  181. package/dist/web-client/assets/index-DwO46Cs5.css +0 -2
  182. /package/dist/{core/__tests__/hearth-bootstrap.test.d.ts → commands/__tests__/revive-now-gate.test.d.ts} +0 -0
  183. /package/dist/{core/hearth/__tests__/model-auth-guest.test.d.ts → commands/sys/__tests__/setup-core.test.d.ts} +0 -0
  184. /package/dist/core/{hearth/providers/__tests__/sweep-and-release.test.d.ts → __tests__/fault-classifier.test.d.ts} +0 -0
  185. /package/dist/core/{hearth/providers/types.js → __tests__/host-teardown-process-group.test.d.ts} +0 -0
  186. /package/dist/core/{hearth/types.js → __tests__/review-model-floor.test.d.ts} +0 -0
  187. /package/dist/{hearth/control-plane/__tests__/error-serialization.test.d.ts → core/__tests__/session-cycles.test.d.ts} +0 -0
  188. /package/dist/{hearth/control-plane/__tests__/node-message.test.d.ts → core/__tests__/worktree.test.d.ts} +0 -0
@@ -0,0 +1,465 @@
1
+ // Run with: node --import tsx/esm --test src/core/__tests__/host-teardown-process-group.test.ts
2
+ //
3
+ // Regression cover for crouton-labs/crouter#98: a canceled/wedged child node
4
+ // ORPHANS its subprocess tree (bash → test runner → spawned app) because
5
+ // teardown sent a single SIGTERM to the broker pid ONLY — no group signal, no
6
+ // SIGKILL escalation, and (the deeper gap) no awareness that the pi SDK's
7
+ // bash tool spawns its shell child `detached: true`, which calls `setsid()`
8
+ // and lands that child in a brand-new process GROUP of its own, distinct from
9
+ // the broker's. A plain `kill(brokerPid, sig)` — and even a GROUP kill of
10
+ // `-brokerPid` — never reaches a descendant shaped that way; only walking the
11
+ // process TREE by ppid (which `setsid()` never changes) and signaling each
12
+ // discovered descendant's OWN group actually reaps it.
13
+ //
14
+ // These tests drive `headlessBrokerHost.teardown()` directly (no real pi
15
+ // engine, no tmux) against REAL fabricated process trees standing in for
16
+ // "broker (session leader) → descendant", asserting against REAL pids read
17
+ // back with `isPidAlive` — never a mock.
18
+ //
19
+ // (1) BUG LOCKED — four locks:
20
+ // • "dead broker orphans a same-group descendant" — the fake broker is
21
+ // SIGKILLed out from under us BEFORE teardown runs (so `getNode().pi_pid`
22
+ // is a pid that's already gone), leaving its non-detached child alive in
23
+ // the SAME process group. A plain `kill(deadPid, 'SIGTERM')` throws
24
+ // ESRCH and touches nothing else; the broker's own group is what must
25
+ // be reaped (its pgid is untouched by the kernel reparenting the child's
26
+ // ppid to init, which happens immediately on the broker's exit).
27
+ // • "wedged broker orphans a DETACHED descendant" (the exact GH repro
28
+ // shape — the current test's core addition): the broker is alive but
29
+ // unreachable (nothing listening on view.sock), and its descendant
30
+ // detached into its OWN process group exactly like the pi bash tool
31
+ // does. We assert structurally that the descendant's pgid differs from
32
+ // the broker's pid — proof a bare `kill(-brokerPid)` cannot reach it —
33
+ // then prove the real `teardown()` (ppid-tree-walk) reaps BOTH the
34
+ // broker and the detached descendant.
35
+ // • "detached descendant ignores SIGTERM" (a stubborn/wedged leaf): must
36
+ // survive the initial tree-wide SIGTERM and only die once the SIGKILL
37
+ // escalation fires after the grace window — proving the ladder actually
38
+ // reaches SIGKILL against a descendant's OWN group, not just the
39
+ // broker's.
40
+ // • "CONNECTED broker exits cleanly after the shutdown frame but leaves a
41
+ // DETACHED descendant alive" (review finding 1, the CONNECTED/shutdown
42
+ // path — the fake broker here is a real unix-socket server on the
43
+ // node's view.sock that ACCEPTS the connection + shutdown frame, then
44
+ // exits WITHOUT reaping its own detached descendant): proves teardown's
45
+ // post-shutdown-frame liveness/escalation check reuses the ONE tree
46
+ // snapshot taken before the broker could exit, rather than re-walking
47
+ // `ps` by ppid after the broker is gone — a re-walk finds nothing the
48
+ // instant the kernel reparents the surviving descendant away from the
49
+ // now-dead broker's pid, silently skipping escalation forever.
50
+ //
51
+ // (2) HOW THIS FAILS IF THE BUG REGRESSES — reverting to a single
52
+ // `process.kill(pid, 'SIGTERM')` (or even `killProcessGroup(pid, sig)`
53
+ // alone, with no ppid-tree walk) of the broker pid leaves the detached-
54
+ // descendant assertions in this file red: those pids stay alive
55
+ // indefinitely (the `waitFor` polls time out), and the SIGTERM-ignoring
56
+ // detached leaf never dies at all. Reverting ONLY the connected-path fix
57
+ // (having `teardown()` re-derive the tree via a fresh `isProcessTreeAlive`/
58
+ // `descendantPids` walk after the shutdown frame, instead of reusing the
59
+ // pre-signal snapshot) leaves just the fourth test above red: the CONNECTED
60
+ // broker's detached descendant survives forever because the re-walk loses
61
+ // it the instant the broker exits.
62
+ import { test, before, after } from 'node:test';
63
+ import assert from 'node:assert/strict';
64
+ import { spawn } from 'node:child_process';
65
+ import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
66
+ import { tmpdir } from 'node:os';
67
+ import { join } from 'node:path';
68
+ import { createHarness } from './helpers/harness.js';
69
+ import { isPidAlive, capturePidIdentities, killProcessTreePids, captureTeardownSnapshot } from '../canvas/pid.js';
70
+ import { recordPid } from '../canvas/canvas.js';
71
+ import { openDb } from '../canvas/db.js';
72
+ import { headlessBrokerHost } from '../runtime/host.js';
73
+ let h;
74
+ let root;
75
+ let scratch;
76
+ // Every pid this suite spawns as a fixture (leader OR descendant), tracked the
77
+ // INSTANT it is known — review finding 2: an assertion or setup failure
78
+ // partway through a test must not leak a `sleep 300` (or a leader waiting on
79
+ // one) past this file's own `after()`, which otherwise only disposes the
80
+ // harness/scratch dir and never touches these fabricated OS processes. Swept
81
+ // best-effort in `after()` below, in ADDITION to (never instead of) each
82
+ // test's real assertion that `headlessBrokerHost.teardown()` itself reaped them.
83
+ const spawnedPids = new Set();
84
+ function trackPid(pid) {
85
+ if (pid != null)
86
+ spawnedPids.add(pid);
87
+ }
88
+ before(async () => {
89
+ h = await createHarness({ headless: true, sessionPrefix: 'crtr-hostpg' });
90
+ root = h.spawnRoot('host-teardown-process-group suite root');
91
+ scratch = mkdtempSync(join(tmpdir(), 'crtr-hostpg-'));
92
+ });
93
+ after(async () => {
94
+ if (h !== undefined)
95
+ await h.dispose();
96
+ rmSync(scratch, { recursive: true, force: true });
97
+ // Best-effort final sweep (review finding 2): a test that asserted before
98
+ // teardown finished reaping, or threw mid-fixture-setup, can leave a tracked
99
+ // pid (or its whole process GROUP, for the detached-leader fixtures) alive.
100
+ // Every signal here is best-effort — ESRCH (already gone) is swallowed — so
101
+ // this is a harmless no-op against a suite that reaped everything cleanly.
102
+ for (const pid of spawnedPids) {
103
+ try {
104
+ process.kill(pid, 'SIGKILL');
105
+ }
106
+ catch { /* already gone */ }
107
+ try {
108
+ process.kill(-pid, 'SIGKILL');
109
+ }
110
+ catch { /* already gone, or never its own group leader */ }
111
+ }
112
+ });
113
+ /** Wait for `leader` to write a pid (as a newline-terminated decimal number) to
114
+ * its stdout — with an actual FAILURE path (review finding 2): a bare
115
+ * `stdout.on('data', ...)` promise as used here previously had no `exit`/
116
+ * `close`/timeout rejection, so a leader that crashed or exited before ever
117
+ * writing its line left the `await` hanging FOREVER (no output, no timeout,
118
+ * the whole suite process wedged). This rejects on the leader exiting first,
119
+ * a stream error, OR a `timeoutMs` ceiling — whichever comes first — and
120
+ * always tears down its own listeners so it settles exactly once. */
121
+ async function readPidFromStdout(leader, label, timeoutMs = 5_000) {
122
+ return new Promise((resolvePid, reject) => {
123
+ let buf = '';
124
+ let settled = false;
125
+ const cleanup = () => {
126
+ clearTimeout(timer);
127
+ leader.stdout?.off('data', onData);
128
+ leader.off('error', onError);
129
+ leader.off('exit', onExit);
130
+ };
131
+ const onData = (chunk) => {
132
+ if (settled)
133
+ return;
134
+ buf += chunk.toString('utf8');
135
+ const nl = buf.indexOf('\n');
136
+ if (nl === -1)
137
+ return;
138
+ settled = true;
139
+ cleanup();
140
+ resolvePid(Number.parseInt(buf.slice(0, nl), 10));
141
+ };
142
+ const onError = (err) => {
143
+ if (settled)
144
+ return;
145
+ settled = true;
146
+ cleanup();
147
+ reject(err);
148
+ };
149
+ const onExit = (code, signal) => {
150
+ if (settled)
151
+ return;
152
+ settled = true;
153
+ cleanup();
154
+ reject(new Error(`${label}: leader exited (code=${String(code)}, signal=${String(signal)}) before writing its pid`));
155
+ };
156
+ const timer = setTimeout(() => {
157
+ if (settled)
158
+ return;
159
+ settled = true;
160
+ cleanup();
161
+ reject(new Error(`${label}: timed out after ${timeoutMs}ms waiting for the leader to write its pid`));
162
+ }, timeoutMs);
163
+ leader.stdout?.on('data', onData);
164
+ leader.once('error', onError);
165
+ leader.once('exit', onExit);
166
+ });
167
+ }
168
+ /** Spawn a fake "broker" (its own session/process group, like the real
169
+ * `launch()`) that in turn backgrounds a plain (non-detached, SAME-group)
170
+ * descendant — used only for the "dead broker, shared-group descendant"
171
+ * case, where the broker's own pgid (untouched by kernel reparenting) is
172
+ * what teardown must still reach. Hands back both pids once the descendant
173
+ * has actually started. */
174
+ async function spawnBrokerWithSharedGroupDescendant(tag, descendantScript = 'sleep 300') {
175
+ const pidFile = join(scratch, `${tag}.pid`);
176
+ const leader = spawn('bash', ['-c', `(${descendantScript}) & echo $! > ${pidFile}; wait`], {
177
+ detached: true,
178
+ stdio: 'ignore',
179
+ });
180
+ if (leader.pid == null)
181
+ throw new Error('failed to spawn fake broker leader');
182
+ trackPid(leader.pid);
183
+ await h.waitFor(() => existsSync(pidFile), { label: `${tag}: descendant pid file written` });
184
+ const descendantPid = Number.parseInt(readFileSync(pidFile, 'utf8').trim(), 10);
185
+ assert.ok(Number.isInteger(descendantPid) && descendantPid > 0, `${tag}: parsed a real descendant pid`);
186
+ trackPid(descendantPid);
187
+ await h.waitFor(() => isPidAlive(descendantPid), { label: `${tag}: descendant alive before teardown` });
188
+ return { leaderPid: leader.pid, descendantPid };
189
+ }
190
+ /** Spawn a fake "broker" that is ITSELF `detached: true` (own session/process
191
+ * group, exactly like the real `launch()`), which in turn spawns `descendantCmd`
192
+ * with `detached: true` TOO — mirroring the real pi bash tool's shell child
193
+ * (verified in `@earendil-works/pi-agent-core`'s `harness/env/nodejs.js`),
194
+ * which lands in a brand-new process group distinct from the broker's. The
195
+ * leader is a tiny inline Node script (not bash — macOS ships no `setsid`
196
+ * binary to force a real detach from a shell) that writes the descendant's
197
+ * pid to stdout once spawned, then idles. Hands back both pids once the
198
+ * descendant has actually started AND is confirmed to be in its OWN group. */
199
+ async function spawnBrokerWithDetachedDescendant(descendantCmd) {
200
+ const leaderScript = [
201
+ "const { spawn } = require('node:child_process');",
202
+ 'const [cmd, ...args] = process.argv.slice(1);',
203
+ "const child = spawn(cmd, args, { detached: true, stdio: 'ignore' });",
204
+ 'child.unref();',
205
+ "process.stdout.write(String(child.pid) + '\\n');",
206
+ 'setInterval(() => {}, 1000);',
207
+ ].join('\n');
208
+ const leader = spawn(process.execPath, ['-e', leaderScript, ...descendantCmd], {
209
+ detached: true,
210
+ stdio: ['ignore', 'pipe', 'ignore'],
211
+ });
212
+ if (leader.pid == null)
213
+ throw new Error('failed to spawn fake detached broker leader');
214
+ leader.unref();
215
+ const leaderPid = leader.pid;
216
+ trackPid(leaderPid);
217
+ const descendantPid = await readPidFromStdout(leader, 'spawnBrokerWithDetachedDescendant');
218
+ trackPid(descendantPid);
219
+ assert.ok(Number.isInteger(descendantPid) && descendantPid > 0, 'parsed a real detached descendant pid');
220
+ await h.waitFor(() => isPidAlive(descendantPid), { label: 'detached descendant alive before teardown' });
221
+ return { leaderPid, descendantPid };
222
+ }
223
+ /** `ps -o pgid= -p <pid>` as a number, or `null` if the pid has no row (already
224
+ * gone). Used only to prove structurally that a detached descendant's pgid
225
+ * differs from its broker's pid — never used to signal anything. */
226
+ async function readPgid(pid) {
227
+ const { execFileSync } = await import('node:child_process');
228
+ try {
229
+ const out = execFileSync('ps', ['-o', 'pgid=', '-p', String(pid)], { encoding: 'utf8' });
230
+ const parsed = Number.parseInt(out.trim(), 10);
231
+ return Number.isInteger(parsed) ? parsed : null;
232
+ }
233
+ catch {
234
+ return null;
235
+ }
236
+ }
237
+ /** Spawn a fake "broker" that is ITSELF `detached: true` (own session/process
238
+ * group, mirroring `launch()`) AND listens as a REAL unix-socket server on
239
+ * `sockPath` (the exact path `headlessBrokerHost.teardown()` connects to). It
240
+ * also spawns `descendantCmd` `detached: true` (own group, mirroring the pi
241
+ * bash tool's shell child) before it starts listening, so the descendant is
242
+ * discoverable via ppid at the moment teardown snapshots the tree. On the
243
+ * FIRST byte received over an accepted connection (i.e. the real `shutdown`
244
+ * frame `teardown()` writes) it calls `process.exit(0)` IMMEDIATELY —
245
+ * modeling a `dispose()` that exits without ever reaping its own detached
246
+ * descendant. Used only by the "review finding 1" CONNECTED-path regression
247
+ * test below. Hands back both pids once the descendant is confirmed alive. */
248
+ async function spawnBrokerWithDetachedDescendantAndSocket(sockPath, descendantCmd) {
249
+ const leaderScript = [
250
+ "const net = require('node:net');",
251
+ "const { spawn } = require('node:child_process');",
252
+ 'const [sockPath, cmd, ...args] = process.argv.slice(1);',
253
+ "const child = spawn(cmd, args, { detached: true, stdio: 'ignore' });",
254
+ 'child.unref();',
255
+ 'const server = net.createServer((conn) => {',
256
+ " conn.once('data', () => { process.exit(0); });",
257
+ " conn.once('end', () => { process.exit(0); });",
258
+ '});',
259
+ 'server.listen(sockPath, () => {',
260
+ " process.stdout.write(String(child.pid) + '\\n');",
261
+ '});',
262
+ ].join('\n');
263
+ const leader = spawn(process.execPath, ['-e', leaderScript, sockPath, ...descendantCmd], {
264
+ detached: true,
265
+ stdio: ['ignore', 'pipe', 'ignore'],
266
+ });
267
+ if (leader.pid == null)
268
+ throw new Error('failed to spawn fake connected-broker leader');
269
+ leader.unref();
270
+ const leaderPid = leader.pid;
271
+ trackPid(leaderPid);
272
+ const descendantPid = await readPidFromStdout(leader, 'spawnBrokerWithDetachedDescendantAndSocket');
273
+ trackPid(descendantPid);
274
+ assert.ok(Number.isInteger(descendantPid) && descendantPid > 0, 'parsed a real detached descendant pid (connected-broker case)');
275
+ await h.waitFor(() => isPidAlive(descendantPid), {
276
+ label: 'detached descendant alive before teardown (connected-broker case)',
277
+ });
278
+ return { leaderPid, descendantPid };
279
+ }
280
+ test('dead broker orphans a SAME-group descendant — teardown reaps it via the broker\'s own group signal', async () => {
281
+ const { leaderPid, descendantPid } = await spawnBrokerWithSharedGroupDescendant('dead-broker');
282
+ // Simulate the exact GH repro: the broker itself is already gone (crashed /
283
+ // force-killed / wedged-then-reaped) BEFORE teardown ever runs — a plain
284
+ // `kill(leaderPid, 'SIGTERM')` would ESRCH here and touch nothing else.
285
+ process.kill(leaderPid, 'SIGKILL');
286
+ await h.waitFor(() => !isPidAlive(leaderPid), { label: 'fake broker leader is dead' });
287
+ assert.equal(isPidAlive(descendantPid), true, 'descendant survives its leader (orphaned, same pgid)');
288
+ const id = h.fabricateBrokerNode({ parent: root, pi_pid: leaderPid, status: 'active' });
289
+ headlessBrokerHost.teardown(id);
290
+ await h.waitFor(() => !isPidAlive(descendantPid), {
291
+ timeoutMs: 6_000,
292
+ label: 'orphaned same-group descendant reaped by the broker group kill',
293
+ });
294
+ });
295
+ test('wedged (alive, unreachable) broker with a DETACHED descendant — process-group kill alone cannot reach it; ' +
296
+ 'the ppid-tree-walk teardown does', async () => {
297
+ const { leaderPid, descendantPid } = await spawnBrokerWithDetachedDescendant(['sleep', '300']);
298
+ assert.equal(isPidAlive(leaderPid), true, 'fake broker leader alive before teardown');
299
+ assert.equal(isPidAlive(descendantPid), true, 'detached descendant alive before teardown');
300
+ // Structural proof that a bare process-GROUP kill of the broker can never
301
+ // reach this descendant: `process.kill(-pid, sig)` only reaches processes
302
+ // whose pgid === pid. The detached descendant called its own setsid(), so
303
+ // its pgid is its OWN pid, not the broker's — confirmed here without
304
+ // signaling anything.
305
+ const leaderPgid = await readPgid(leaderPid);
306
+ const descendantPgid = await readPgid(descendantPid);
307
+ assert.equal(leaderPgid, leaderPid, 'broker leader is its own group leader (detached launch)');
308
+ assert.equal(descendantPgid, descendantPid, 'descendant is ALSO its own group leader (detached spawn)');
309
+ assert.notEqual(descendantPgid, leaderPid, 'descendant pgid differs from the broker pid — a group kill of ' +
310
+ '-brokerPid structurally cannot reach it');
311
+ // No real broker is listening on this node's view.sock, so teardown's
312
+ // connect() always fails here — exactly the "wedged, unreachable" path.
313
+ // The broker is still alive at the OS level (not yet exited), so the
314
+ // ppid link from broker → descendant is intact for the tree walk.
315
+ const id = h.fabricateBrokerNode({ parent: root, pi_pid: leaderPid, status: 'active' });
316
+ headlessBrokerHost.teardown(id);
317
+ await h.waitFor(() => !isPidAlive(leaderPid), { timeoutMs: 6_000, label: 'fake broker leader reaped' });
318
+ await h.waitFor(() => !isPidAlive(descendantPid), {
319
+ timeoutMs: 6_000,
320
+ label: 'DETACHED descendant reaped by the ppid-tree-walk kill',
321
+ });
322
+ });
323
+ test('a detached descendant that ignores SIGTERM only dies once the SIGKILL escalation fires', async () => {
324
+ // The leaf traps SIGTERM (ignores it) but cannot trap SIGKILL — proves the
325
+ // ladder genuinely reaches the final SIGKILL rung against a descendant's
326
+ // OWN group, not just the broker's.
327
+ const { leaderPid, descendantPid } = await spawnBrokerWithDetachedDescendant([
328
+ 'bash',
329
+ '-c',
330
+ 'trap "" TERM; sleep 300',
331
+ ]);
332
+ const id = h.fabricateBrokerNode({ parent: root, pi_pid: leaderPid, status: 'active' });
333
+ const start = Date.now();
334
+ headlessBrokerHost.teardown(id);
335
+ // Give the initial tree-wide SIGTERM a moment to land — the stubborn leaf
336
+ // must still be alive (it ignores TERM), proving SIGTERM alone doesn't reap it.
337
+ await new Promise((r) => setTimeout(r, 300));
338
+ assert.equal(isPidAlive(descendantPid), true, 'SIGTERM-ignoring descendant survives the first rung');
339
+ await h.waitFor(() => !isPidAlive(descendantPid), {
340
+ timeoutMs: 8_000,
341
+ label: 'stubborn detached descendant reaped once the SIGKILL escalation fires',
342
+ });
343
+ assert.ok(Date.now() - start >= 1_500, 'the kill only lands after the SIGKILL grace window, not instantly');
344
+ });
345
+ test('CONNECTED broker accepts the shutdown frame and exits cleanly, but leaves a DETACHED descendant alive — ' +
346
+ 'teardown must still reap it (review finding 1: post-shutdown liveness/escalation must reuse the ONE fixed ' +
347
+ "tree snapshot taken before the broker could exit, never re-walk `ps` by ppid afterward, since the exiting " +
348
+ "broker severs the descendant's ppid link the SAME tick)", async () => {
349
+ // Fabricate the node FIRST (placeholder pid) so we know its view.sock path
350
+ // before spawning the fake broker that must listen on that exact path.
351
+ const id = h.fabricateBrokerNode({ parent: root, pi_pid: null, status: 'active' });
352
+ const sockPath = h.brokerSock(id);
353
+ const { leaderPid, descendantPid } = await spawnBrokerWithDetachedDescendantAndSocket(sockPath, ['sleep', '300']);
354
+ recordPid(id, leaderPid);
355
+ assert.equal(isPidAlive(leaderPid), true, 'fake connected broker leader alive before teardown');
356
+ assert.equal(isPidAlive(descendantPid), true, 'detached descendant alive before teardown');
357
+ // teardown() connects, writes the shutdown frame, and the fake broker
358
+ // exits the INSTANT it receives that first byte — WITHOUT reaping its own
359
+ // detached descendant (modeling a dispose() that never reaches the pi bash
360
+ // tool's shell child). This is the exact connected/shutdown path finding 1
361
+ // covers: a re-walk-by-ppid AFTER that exit would find the descendant
362
+ // reparented away from `leaderPid` and conclude the tree is dead, silently
363
+ // skipping escalation — this descendant would then survive FOREVER.
364
+ headlessBrokerHost.teardown(id);
365
+ await h.waitFor(() => !isPidAlive(leaderPid), {
366
+ timeoutMs: 6_000,
367
+ label: 'fake connected broker leader exited (accepted the shutdown frame)',
368
+ });
369
+ await h.waitFor(() => !isPidAlive(descendantPid), {
370
+ timeoutMs: 8_000,
371
+ label: 'descendant of a CLEANLY-EXITED connected broker reaped by the fixed-snapshot escalation',
372
+ });
373
+ });
374
+ test('a FAILED initial identity probe must not be mistaken for "pid already gone" — killProcessTreePids still ' +
375
+ 'signals every real live pid in the snapshot (final review, crouter#98 signal-safety finding: ' +
376
+ '`capturePidIdentities` collapsed "ps succeeded, no row" and "ps probe itself failed" into the same empty ' +
377
+ 'map, so a transient failure of the INITIAL capture made every real live pid look "reused" against a ' +
378
+ 'later-successful current probe, and `killProcessTreePids` skipped signaling all of them — silently ' +
379
+ "disabling the entire #98 reap. This must fail against that collapse-bug behavior and pass once a failed " +
380
+ 'probe is reported distinctly (`null`) instead of an empty map.)', async () => {
381
+ const { leaderPid, descendantPid } = await spawnBrokerWithSharedGroupDescendant('probe-failure');
382
+ const tree = [leaderPid, descendantPid];
383
+ // Force `capturePidIdentities`'s `ps` spawn to fail deterministically —
384
+ // no binary named `ps` is resolvable on this PATH — standing in for a
385
+ // transient probe failure (timeout, spawn error) at the exact moment
386
+ // `teardown()` takes its INITIAL identity snapshot. Mutating
387
+ // `process.env.PATH` is safe here only because `capturePidIdentities` is
388
+ // fully synchronous (`spawnSync`) and this file's tests run sequentially,
389
+ // so the broken PATH is never visible to any other in-flight test.
390
+ const origPath = process.env.PATH;
391
+ let identities;
392
+ try {
393
+ process.env.PATH = '/nonexistent-crtr-test-path';
394
+ identities = capturePidIdentities(tree);
395
+ }
396
+ finally {
397
+ process.env.PATH = origPath;
398
+ }
399
+ assert.equal(identities, null, 'a failed ps probe reports null, never an empty-but-valid map');
400
+ assert.equal(isPidAlive(leaderPid), true, 'fake broker leader alive before the guarded signal');
401
+ assert.equal(isPidAlive(descendantPid), true, 'shared-group descendant alive before the guarded signal');
402
+ // The current probe inside killProcessTreePids now runs with PATH already
403
+ // restored, so it succeeds — this is the exact shape of the bug: a failed
404
+ // INITIAL capture followed by a successful current one. A pre-fix
405
+ // `identities` here would be an empty (not null) map, which the collapse
406
+ // bug reads as "both pids were already dead at snapshot time," and then
407
+ // reads the current probe's real (alive) identities as proof of reuse —
408
+ // skipping every signal below and leaving both pids alive forever.
409
+ killProcessTreePids(tree, 'SIGTERM', identities);
410
+ await h.waitFor(() => !isPidAlive(leaderPid), {
411
+ timeoutMs: 6_000,
412
+ label: 'broker leader signaled despite the failed initial identity probe',
413
+ });
414
+ await h.waitFor(() => !isPidAlive(descendantPid), {
415
+ timeoutMs: 6_000,
416
+ label: 'shared-group descendant signaled despite the failed initial identity probe',
417
+ });
418
+ });
419
+ test('captureTeardownSnapshot refuses a pid whose CURRENT identity differs from its recorded LAUNCH-time baseline ' +
420
+ '(final review, Fix 1: the guard must catch a pid ALREADY reused before teardown ever starts, not just reuse ' +
421
+ 'across the escalation window) — tree comes back EMPTY, so nothing downstream ever signals the stranger', async () => {
422
+ const { leaderPid, descendantPid } = await spawnBrokerWithSharedGroupDescendant('snapshot-mismatch');
423
+ // A deliberately WRONG "launch-time" identity — no real `ps lstart`/
424
+ // `/proc` starttime pair for a genuinely alive process can ever equal
425
+ // this string, so this stands in for "the OS recycled this pid for an
426
+ // unrelated process since launch" without needing to actually wait out a
427
+ // real pid-recycle.
428
+ const snapshot = captureTeardownSnapshot(leaderPid, 'deliberately-mismatched-launch-identity');
429
+ assert.equal(snapshot.reused, true, 'a real, alive, differently-identified pid reports as reused');
430
+ assert.deepEqual(snapshot.tree, [], 'a reused root yields an EMPTY tree — no descendant walk from a stranger');
431
+ assert.equal(snapshot.identities, null, 'no identity map for a poisoned/reused snapshot');
432
+ // Sanity: the SAME pid with its OWN real current identity as the baseline
433
+ // is never flagged reused, and its descendant tree resolves normally.
434
+ const realIdentity = capturePidIdentities([leaderPid])?.get(leaderPid) ?? null;
435
+ assert.notEqual(realIdentity, null, 'the real launch identity capture succeeds for a genuinely alive pid');
436
+ const matched = captureTeardownSnapshot(leaderPid, realIdentity);
437
+ assert.equal(matched.reused, false, 'a matching baseline is never flagged reused');
438
+ assert.ok(matched.tree.includes(leaderPid) && matched.tree.includes(descendantPid), 'the real tree is walked normally when the baseline matches');
439
+ // Never signaled by this test — the `after()` sweep is the safety net.
440
+ assert.equal(isPidAlive(leaderPid), true, 'unsignaled leader is still alive');
441
+ assert.equal(isPidAlive(descendantPid), true, 'unsignaled descendant is still alive');
442
+ });
443
+ test('end-to-end: headlessBrokerHost.teardown() honors a launch-time identity mismatch and never signals the ' +
444
+ 'stand-in "reused pid" tree at all, even after both the SIGTERM and SIGKILL escalation grace windows elapse', async () => {
445
+ const { leaderPid, descendantPid } = await spawnBrokerWithSharedGroupDescendant('teardown-mismatch');
446
+ const id = h.fabricateBrokerNode({ parent: root, pi_pid: leaderPid, status: 'active' });
447
+ // recordPid captures the REAL current identity for leaderPid (genuinely
448
+ // alive right now, so this succeeds) — then this corrupts JUST the stored
449
+ // identity column to simulate "the recorded launch-time baseline no
450
+ // longer matches this pid's CURRENT owner", standing in for an actual OS
451
+ // pid-recycle between this node's launch and this teardown() call.
452
+ recordPid(id, leaderPid);
453
+ openDb()
454
+ .prepare('UPDATE nodes SET pi_pid_identity = ? WHERE node_id = ?')
455
+ .run('deliberately-mismatched-launch-identity', id);
456
+ headlessBrokerHost.teardown(id);
457
+ // No real broker listens on this fabricated node's view.sock, so
458
+ // teardown's connect-error escalation path is the one in play. Wait past
459
+ // BOTH the SIGTERM and SIGKILL grace windows (2s + 2s, host.ts) it would
460
+ // use if it were (wrongly) signaling this tree — if the guard failed, this
461
+ // fixture would be dead well before this wait elapses.
462
+ await new Promise((r) => setTimeout(r, 5_000));
463
+ assert.equal(isPidAlive(leaderPid), true, 'mismatched-identity leader was NEVER signaled — still alive');
464
+ assert.equal(isPidAlive(descendantPid), true, 'its descendant was NEVER signaled either — still alive');
465
+ });
@@ -76,6 +76,20 @@ test('deletes the oldest terminal nodes beyond max(cap, liveCount)', () => {
76
76
  assert.ok(!existsSync(nodeDir(id)), `${id} dir should be removed`);
77
77
  }
78
78
  });
79
+ test('a non-terminal node with a dead pid is never a prune candidate (issue #114)', () => {
80
+ // An active row whose broker crashed: status stays 'active' but pi_pid is
81
+ // NULL (never recorded), so isPidAlive would be false. It must still be
82
+ // protected — crash-recovery, not the count-based pruner, owns this row —
83
+ // even though it's the oldest node and cap is exceeded by newer terminal rows.
84
+ createNode(node('crashed-active', { status: 'active', created: at(100) }));
85
+ for (let i = 0; i < 5; i++) {
86
+ createNode(node(`term-${i}`, { status: 'dead', created: at(i) }));
87
+ }
88
+ const result = pruneToLimit({ maxNodes: 3 });
89
+ assert.ok(!result.pruned.some((p) => p.node_id === 'crashed-active'), 'a crashed-but-active node must not be selected for pruning');
90
+ assert.ok(getRow('crashed-active') !== null, 'crashed-active row must survive');
91
+ assert.ok(existsSync(nodeDir('crashed-active')), 'crashed-active dir must survive');
92
+ });
79
93
  test('--dry-run reports candidates but deletes nothing', () => {
80
94
  for (let i = 0; i < 5; i++) {
81
95
  createNode(node(`term-${i}`, { status: 'dead', created: at(i) }));
@@ -0,0 +1,32 @@
1
+ // Run with: node --import tsx/esm --test src/core/__tests__/review-model-floor.test.ts
2
+ //
3
+ // Issue #111 — review work must not be forced down to haiku/light by an
4
+ // explicit `--model light`. Review personas already default to strong; this
5
+ // locks the launch-spec choke point so review-labeled work keeps that floor
6
+ // even when a spawning agent chooses a cheaper override.
7
+ import { test } from 'node:test';
8
+ import assert from 'node:assert/strict';
9
+ import { buildLaunchSpec, normalizeModel } from '../runtime/launch.js';
10
+ function spec(kind, model) {
11
+ return buildLaunchSpec(kind, 'base', {
12
+ lifecycle: 'terminal',
13
+ hasManager: true,
14
+ ...(model !== undefined ? { model } : {}),
15
+ }).launch.model;
16
+ }
17
+ test('review ignores a light override and keeps its strong persona floor', () => {
18
+ assert.equal(spec('review', 'light'), normalizeModel('openai/strong'));
19
+ assert.equal(spec('review', 'haiku'), normalizeModel('openai/strong'));
20
+ });
21
+ test('plan reviewer sub-kinds also keep their strong persona floor', () => {
22
+ assert.equal(spec('plan/reviewers/security', 'medium'), normalizeModel('openai/strong'));
23
+ assert.equal(spec('plan/reviewers/requirements-coverage', 'light'), normalizeModel('openai/strong'));
24
+ });
25
+ test('review still permits equal-or-stronger explicit models', () => {
26
+ assert.equal(spec('review', 'strong'), normalizeModel('strong'));
27
+ assert.equal(spec('review', 'ultra'), normalizeModel('ultra'));
28
+ });
29
+ test('explore remains cheap by default and may explicitly run light', () => {
30
+ assert.equal(spec('explore'), normalizeModel('anthropic/light'));
31
+ assert.equal(spec('explore', 'light'), normalizeModel('light'));
32
+ });
@@ -117,11 +117,18 @@ test('resumeArgs selects nothing (falls through to fresh) when the recorded file
117
117
  });
118
118
  assert.deepEqual(resumeArgs(m, true), {});
119
119
  });
120
- test('resumeArgs selects neither source on a no-resume (refresh) revive', () => {
120
+ test('resumeArgs CYCLES the existing file (same path + newCycle) on a no-resume (refresh) revive', () => {
121
121
  mkdirSync(home, { recursive: true });
122
122
  const sessionFile = join(home, 'resume-args-refresh.jsonl');
123
123
  writeFileSync(sessionFile, '{}\n', 'utf8');
124
124
  const m = node('n', { pi_session_id: 'uuid-123', pi_session_file: sessionFile });
125
+ assert.deepEqual(resumeArgs(m, false), { resumeSessionPath: sessionFile, newCycle: true });
126
+ });
127
+ test('resumeArgs selects neither source on a refresh revive with no `.jsonl` on disk yet', () => {
128
+ const m = node('n', {
129
+ pi_session_id: 'uuid-123',
130
+ pi_session_file: join(home, 'refresh-never-written.jsonl'),
131
+ });
125
132
  assert.deepEqual(resumeArgs(m, false), {});
126
133
  });
127
134
  // ---------------------------------------------------------------------------
@@ -230,18 +237,22 @@ test('reviveNode PROCEEDS when the broker engine is DOWN (dead pid) — cycle co
230
237
  // engine) launch, so the bump is observable instantly; the throwaway broker is
231
238
  // killed by dispose().
232
239
  const sessionFile = join(home, 'sessions', 'crtr-revive-proceed.jsonl');
240
+ const stalePid = deadPid();
233
241
  mkdirSync(join(home, 'sessions'), { recursive: true });
234
242
  writeFileSync(sessionFile, '{}\n', 'utf8');
235
243
  const M = h.fabricateBrokerNode({
236
244
  status: 'active',
237
245
  intent: null,
238
- pi_pid: deadPid(),
246
+ pi_pid: stalePid,
239
247
  pi_session_id: 'uuid-1',
240
248
  pi_session_file: sessionFile,
241
249
  });
242
250
  assert.equal(h.node(M).cycles ?? 0, 0, 'fabricated at cycle 0 (no revive yet)');
243
251
  reviveNode(M, { resume: true });
244
- assert.equal(h.node(M).cycles, 1, 'a real (non-guard) revive bumped the cycle counter to 1');
252
+ const after = h.node(M);
253
+ assert.equal(after.cycles, 1, 'a real (non-guard) revive bumped the cycle counter to 1');
254
+ assert.notEqual(after.pi_pid, stalePid, 'revive records the fresh broker pid immediately, not the stale dead pid');
255
+ assert.equal(after.pi_pid == null ? false : after.pi_pid > 0, true, 'revive leaves no pid=null boot window for the daemon to false-crash');
245
256
  }
246
257
  finally {
247
258
  await h.dispose();
@@ -257,10 +268,11 @@ test('reviveNode fresh-fallback clears the stale session identity (Major-3: no p
257
268
  // pi_session_id — a leftover id would be misread as a stranded relaunch and
258
269
  // retried forever instead of surfaced as a boot failure.
259
270
  const missingFile = join(home, 'sessions', 'never-written.jsonl');
271
+ const stalePid = deadPid();
260
272
  const M = h.fabricateBrokerNode({
261
273
  status: 'active',
262
274
  intent: null,
263
- pi_pid: deadPid(),
275
+ pi_pid: stalePid,
264
276
  pi_session_id: 'uuid-stale',
265
277
  pi_session_file: missingFile,
266
278
  });
@@ -269,6 +281,8 @@ test('reviveNode fresh-fallback clears the stale session identity (Major-3: no p
269
281
  const after = h.node(M);
270
282
  assert.equal(after.pi_session_id, null, 'stale pi_session_id cleared before the fresh launch');
271
283
  assert.equal(after.pi_session_file, null, 'stale pi_session_file cleared before the fresh launch');
284
+ assert.notEqual(after.pi_pid, stalePid, 'fresh fallback records the new broker pid immediately');
285
+ assert.equal(after.pi_pid == null ? false : after.pi_pid > 0, true, 'fresh fallback leaves no pid=null boot window for the daemon to false-crash');
272
286
  }
273
287
  finally {
274
288
  await h.dispose();