@north-light/crouter 0.3.249 → 0.3.251

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 (175) hide show
  1. package/README.md +1 -1
  2. package/dist/api/__tests__/integration/client.test.js +28 -7
  3. package/dist/api/client.d.ts +1 -1
  4. package/dist/api/client.js +2 -2
  5. package/dist/api/dto/lifecycle.d.ts +3 -0
  6. package/dist/api/dto/messages.d.ts +4 -0
  7. package/dist/api/dto/nodes.d.ts +4 -0
  8. package/dist/api/dto/reports.d.ts +7 -2
  9. package/dist/builtin-memory/00-runtime-base/01-escalation.md +1 -1
  10. package/dist/builtin-memory/05-kinds/review/01-orchestrator.md +3 -0
  11. package/dist/builtin-memory/internal/nodes-and-canvas.md +1 -1
  12. package/dist/builtin-pi-packages/pi-crtr-extensions/README.md +0 -1
  13. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.js +4 -3
  14. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +4 -3
  15. package/dist/clients/attach/session/chrome-refresh.d.ts +3 -0
  16. package/dist/clients/attach/session/chrome-refresh.js +1 -0
  17. package/dist/clients/attach/viewer.js +532 -532
  18. package/dist/clients/inbox/__tests__/integration/stale-row-activation.test.d.ts +1 -0
  19. package/dist/clients/inbox/__tests__/integration/stale-row-activation.test.js +53 -0
  20. package/dist/clients/inbox/controller.js +32 -5
  21. package/dist/clients/inbox/tui/ansi.d.ts +3 -1
  22. package/dist/clients/inbox/tui/ansi.js +14 -10
  23. package/dist/commands/__tests__/api-client-cold-start-diagnostic.test.js +7 -7
  24. package/dist/commands/api-client.d.ts +2 -2
  25. package/dist/commands/api-client.js +5 -5
  26. package/dist/commands/canvas-browse.js +1 -1
  27. package/dist/commands/node/create.js +3 -1
  28. package/dist/commands/node/inspect.js +1 -0
  29. package/dist/commands/node/lifecycle.js +1 -1
  30. package/dist/commands/node/message.js +6 -4
  31. package/dist/commands/node/wait.js +1 -1
  32. package/dist/commands/push.js +36 -37
  33. package/dist/commands/sys/daemon.js +21 -8
  34. package/dist/commands/sys/setup-core.js +2 -2
  35. package/dist/core/__tests__/broker-stream-watchdog-floor.test.d.ts +1 -0
  36. package/dist/core/__tests__/broker-stream-watchdog-floor.test.js +84 -0
  37. package/dist/core/__tests__/broker-turn-admission.test.d.ts +1 -0
  38. package/dist/core/__tests__/broker-turn-admission.test.js +44 -0
  39. package/dist/core/__tests__/canvas-inbox-watcher-hold.test.js +100 -1
  40. package/dist/core/__tests__/child-death-wake.test.js +11 -7
  41. package/dist/core/__tests__/collectible.test.d.ts +1 -0
  42. package/dist/core/__tests__/collectible.test.js +88 -0
  43. package/dist/core/__tests__/context-intro.test.js +4 -41
  44. package/dist/core/__tests__/daemon-boot.test.js +30 -14
  45. package/dist/core/__tests__/daemon-wedge.test.js +25 -19
  46. package/dist/core/__tests__/fixtures/fake-engine.js +20 -2
  47. package/dist/core/__tests__/human-deliver.test.js +5 -5
  48. package/dist/core/__tests__/integration/deferred-no-wake.test.js +65 -4
  49. package/dist/core/__tests__/integration/human-deliver-e2e.test.js +3 -3
  50. package/dist/core/__tests__/integration/revive.test.js +48 -0
  51. package/dist/core/__tests__/integration/spawn-root.test.js +36 -1
  52. package/dist/core/__tests__/integration/worktree-land.test.js +82 -3
  53. package/dist/core/__tests__/integration/worktree-reap.test.js +44 -6
  54. package/dist/core/__tests__/migration.test.js +30 -0
  55. package/dist/core/__tests__/passive-subscription.test.js +39 -1
  56. package/dist/core/__tests__/prune.test.js +12 -3
  57. package/dist/core/__tests__/relaunch-root.test.js +1 -1
  58. package/dist/core/__tests__/respawn-throttle.test.js +16 -146
  59. package/dist/core/__tests__/seam/broker-crash-teardown.test.js +23 -11
  60. package/dist/core/__tests__/seam/broker-provider-retry.test.js +81 -2
  61. package/dist/core/__tests__/seam/dormancy-release.test.js +1 -1
  62. package/dist/core/__tests__/seam/held-deferred-human-prompt.test.d.ts +1 -0
  63. package/dist/core/__tests__/seam/held-deferred-human-prompt.test.js +63 -0
  64. package/dist/core/__tests__/seam/self-close-teardown.test.d.ts +1 -0
  65. package/dist/core/__tests__/seam/self-close-teardown.test.js +49 -0
  66. package/dist/core/__tests__/seam/yield-refresh-transaction.test.js +18 -10
  67. package/dist/core/canvas/canvas.d.ts +9 -15
  68. package/dist/core/canvas/canvas.js +41 -47
  69. package/dist/core/canvas/crons.d.ts +5 -8
  70. package/dist/core/canvas/crons.js +5 -8
  71. package/dist/core/canvas/history.d.ts +5 -0
  72. package/dist/core/canvas/history.js +12 -1
  73. package/dist/core/canvas/migrations.js +58 -1
  74. package/dist/core/canvas/pid.d.ts +1 -1
  75. package/dist/core/canvas/pid.js +5 -2
  76. package/dist/core/canvas/types.d.ts +4 -0
  77. package/dist/core/feed/feed.d.ts +4 -0
  78. package/dist/core/feed/feed.js +13 -8
  79. package/dist/core/feed/inbox.d.ts +2 -1
  80. package/dist/core/feed/inbox.js +22 -3
  81. package/dist/core/git.d.ts +3 -0
  82. package/dist/core/git.js +3 -1
  83. package/dist/core/human/feedback-companion.js +1 -0
  84. package/dist/core/preview-registry.js +0 -1
  85. package/dist/core/review/companion.js +1 -0
  86. package/dist/core/review/realize.js +1 -0
  87. package/dist/core/runtime/broker/auth-reload.d.ts +2 -0
  88. package/dist/core/runtime/broker/auth-reload.js +9 -3
  89. package/dist/core/runtime/broker/engine-drive.d.ts +2 -0
  90. package/dist/core/runtime/broker/engine-drive.js +39 -23
  91. package/dist/core/runtime/broker/event-projection.d.ts +4 -0
  92. package/dist/core/runtime/broker/event-projection.js +43 -23
  93. package/dist/core/runtime/broker/fault-retry.d.ts +9 -0
  94. package/dist/core/runtime/broker/fault-retry.js +94 -8
  95. package/dist/core/runtime/broker/frame-dispatch.d.ts +2 -0
  96. package/dist/core/runtime/broker/frame-dispatch.js +4 -1
  97. package/dist/core/runtime/broker/held-deferred-inbox.d.ts +9 -0
  98. package/dist/core/runtime/broker/held-deferred-inbox.js +8 -0
  99. package/dist/core/runtime/broker/inbox.js +4 -0
  100. package/dist/core/runtime/broker/rebind.js +18 -0
  101. package/dist/core/runtime/broker/turn-admission.d.ts +11 -0
  102. package/dist/core/runtime/broker/turn-admission.js +31 -0
  103. package/dist/core/runtime/broker/turn-ignition.d.ts +2 -0
  104. package/dist/core/runtime/broker/turn-ignition.js +25 -15
  105. package/dist/core/runtime/broker.js +21 -2
  106. package/dist/core/runtime/close.d.ts +11 -9
  107. package/dist/core/runtime/close.js +23 -19
  108. package/dist/core/runtime/fault.d.ts +21 -0
  109. package/dist/core/runtime/fault.js +129 -19
  110. package/dist/core/runtime/fleet.d.ts +0 -6
  111. package/dist/core/runtime/host.d.ts +7 -1
  112. package/dist/core/runtime/host.js +20 -2
  113. package/dist/core/runtime/nodes.d.ts +5 -1
  114. package/dist/core/runtime/nodes.js +24 -1
  115. package/dist/core/runtime/placement.d.ts +6 -6
  116. package/dist/core/runtime/placement.js +15 -8
  117. package/dist/core/runtime/reset.js +2 -2
  118. package/dist/core/runtime/revive.js +40 -4
  119. package/dist/core/runtime/spawn.d.ts +3 -0
  120. package/dist/core/runtime/spawn.js +1 -0
  121. package/dist/core/runtime/warm-pool.d.ts +4 -0
  122. package/dist/core/runtime/warm-pool.js +2 -0
  123. package/dist/core/worktree.d.ts +4 -3
  124. package/dist/core/worktree.js +73 -27
  125. package/dist/daemon/__tests__/integration/api-startup-readiness.test.d.ts +1 -0
  126. package/dist/daemon/__tests__/integration/api-startup-readiness.test.js +81 -0
  127. package/dist/daemon/api/__tests__/reopen-delivery.test.js +38 -0
  128. package/dist/daemon/api/__tests__/seam/api-server.test.js +1 -0
  129. package/dist/daemon/api/__tests__/seam/leaf-api-parity.test.js +13 -2
  130. package/dist/daemon/api/handlers/bash-jobs.js +2 -0
  131. package/dist/daemon/api/handlers/broker-ops.js +23 -2
  132. package/dist/daemon/api/handlers/human.js +1 -1
  133. package/dist/daemon/api/handlers/messages.js +11 -2
  134. package/dist/daemon/api/handlers/nodes.js +13 -1
  135. package/dist/daemon/api/handlers/reports.js +14 -6
  136. package/dist/daemon/api/map.js +1 -0
  137. package/dist/daemon/api/server.d.ts +9 -12
  138. package/dist/daemon/api/server.js +30 -19
  139. package/dist/daemon/companion-retire.js +3 -9
  140. package/dist/daemon/cron/sinks.js +7 -2
  141. package/dist/daemon/crtrd.js +4 -1
  142. package/dist/daemon/fleet.d.ts +2 -16
  143. package/dist/daemon/fleet.js +63 -150
  144. package/dist/daemon/human/finish.js +2 -0
  145. package/dist/daemon/manage.d.ts +11 -0
  146. package/dist/daemon/manage.js +32 -3
  147. package/dist/daemon/messaging/node-message.js +7 -1
  148. package/dist/daemon/reconcilers/bash-deadline.js +2 -0
  149. package/dist/daemon/reconcilers/broker-supervision.d.ts +1 -0
  150. package/dist/daemon/reconcilers/broker-supervision.js +22 -9
  151. package/dist/daemon/reconcilers/live-obligation.d.ts +16 -6
  152. package/dist/daemon/reconcilers/live-obligation.js +18 -6
  153. package/dist/daemon/reconcilers/node-lifecycle/respawn-policy.d.ts +15 -4
  154. package/dist/daemon/reconcilers/node-lifecycle/respawn-policy.js +34 -3
  155. package/dist/daemon/reconcilers/node-lifecycle/terminating.d.ts +4 -1
  156. package/dist/daemon/reconcilers/node-lifecycle/terminating.js +11 -2
  157. package/dist/daemon/reconcilers/node-lifecycle/tick.js +14 -7
  158. package/dist/daemon/reconcilers/storage-maintenance.d.ts +2 -2
  159. package/dist/daemon/reconcilers/storage-maintenance.js +3 -3
  160. package/dist/pi-extensions/__tests__/canvas-stophook-agentend.test.js +4 -4
  161. package/dist/pi-extensions/canvas-context-intro.d.ts +3 -34
  162. package/dist/pi-extensions/canvas-context-intro.js +4 -76
  163. package/dist/pi-extensions/canvas-inbox-watcher.js +105 -4
  164. package/dist/pi-extensions/canvas-review-boundary.d.ts +3 -24
  165. package/dist/pi-extensions/canvas-review-boundary.js +4 -29
  166. package/dist/pi-extensions/canvas-stophook.js +1 -6
  167. package/dist/shared/birth-announcement.d.ts +12 -0
  168. package/dist/shared/birth-announcement.js +25 -0
  169. package/dist/shared/generated-context.d.ts +8 -4
  170. package/dist/shared/generated-context.js +15 -9
  171. package/package.json +4 -4
  172. package/runtime.lock.json +2 -2
  173. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/statusline.ts +0 -254
  174. package/dist/pi-extensions/truncate.d.ts +0 -5
  175. package/dist/pi-extensions/truncate.js +0 -14
@@ -62,23 +62,24 @@ after(() => {
62
62
  });
63
63
  // A broker that exits while pi_session_id was NEVER set: the vehicle died
64
64
  // before pi ever booted. The spawner already returned status="active", so the
65
- // parent must be told — the exit-policy job (table row 8) marks it dead AND
66
- // pushes urgent up. The old null-pid boot-grace clock is gone: the exit event
67
- // names the exact death, so there is nothing to bridge.
68
- test('never-booted broker exit is marked dead and surfaced to the parent as urgent', async () => {
65
+ // authoritative tick applies table row 8 and tells the parent. The delivered
66
+ // exit is observation-only.
67
+ test('never-booted broker is marked dead by the tick and surfaced to the parent as urgent', async () => {
69
68
  createNode(node('P', { kind: 'developer', lifecycle: 'resident' }));
70
69
  createNode(node('C', {
71
70
  parent: 'P',
72
71
  kind: 'explore',
72
+ pi_pid: deadPid(), // launch recorded its process before it died
73
73
  pi_session_id: null, // never booted
74
74
  intent: null,
75
75
  }));
76
76
  spawnEdge('P', 'C');
77
- // Exit-event authority: the launch's real child exit (delivered synthetically
78
- // here — same path) classifies as table row 8: pi_session_id null, no fork
79
- // claim → boot failure. No grace, no retry.
77
+ // The launch's real child exit is delivered synthetically here, then the
78
+ // authoritative tick classifies the dead recorded pid as table row 8:
79
+ // pi_session_id null, no fork claim → boot failure. No grace, no retry.
80
80
  tf.fleet.deliverExit('C', { code: 1, signal: null });
81
81
  await tf.drain();
82
+ await superviseTick();
82
83
  assert.equal(getNode('C').status, 'dead', 'never-booted child is dead');
83
84
  // Row 8 fans TWO entries: the urgent surfaceBootFailure push and the normal
84
85
  // doctrine wake (design flow B row 8 — crash + surfaceBootFailure + doctrine
@@ -115,7 +116,12 @@ test('a crashed booted child is relaunched on its saved session, not reaped, and
115
116
  spawnEdge('P2', 'C2');
116
117
  markBusy('C2'); // died mid-generation
117
118
  await superviseTick();
118
- assert.deepEqual(launches, ['C2'], 'the tick recovers the crash through the policy table, exactly as the exit event would');
119
+ const afterFirstTick = getNode('C2');
120
+ assert.deepEqual(launches, [], 'the first tick records the failed launch without relaunching');
121
+ assert.equal(afterFirstTick.respawn_failures, 1, 'the first tick records failure one durably');
122
+ assert.ok(afterFirstTick.respawn_not_before, 'the first tick records durable backoff');
123
+ await superviseTick(Date.parse(afterFirstTick.respawn_not_before));
124
+ assert.deepEqual(launches, ['C2'], 'the matured tick recovers the crash through the policy table');
119
125
  assert.equal(getNode('C2').status, 'active', 'crashed booted child is relaunched, NOT marked dead');
120
126
  assert.equal(readInboxSince('P2').length, 0, 'no false boot-failure / child-death push on a crash (wake relocated to push/close)');
121
127
  clearBusy('C2');
@@ -155,19 +161,21 @@ test('idle-release node with a boot-cleared (null) pid and no unseen inbox is se
155
161
  assert.equal(after.intent, null, 'no revive fired — the row is settled, not relaunched');
156
162
  assert.equal(after.pi_pid, null, 'no fresh pid — reviveNode was never invoked');
157
163
  });
158
- // Daemon start guard: spawnDaemon must not report success before the daemon is
159
- // plausibly alive and has written its pidfile. This is a pure bounded poll over
160
- // injected clocks/probes, so we can lock it down deterministically without a
161
- // real daemon process.
162
- test('verifyDaemonStartup waits for pidfile + live pid, then reports success for the spawned pid', async () => {
164
+ // Daemon start guard: spawnDaemon must not report success before the daemon's
165
+ // pidfile owner is live AND its Unix API socket serves. This is a pure bounded
166
+ // poll over injected clocks/probes, so we can lock it down deterministically
167
+ // without a real daemon process.
168
+ test('verifyDaemonStartup waits for pidfile, live pid, and serving socket before reporting success', async () => {
163
169
  let now = 0;
164
170
  let pidfileReady = false;
165
171
  let pidAliveReady = false;
172
+ let socketServing = false;
166
173
  let slept = 0;
167
174
  const existingPid = await verifyDaemonStartup(42, 50, {
168
175
  now: () => now,
169
176
  readPidfile: () => (pidfileReady ? 42 : null),
170
177
  isPidAlive: () => pidAliveReady,
178
+ isDaemonServing: async () => socketServing,
171
179
  sleepMs: (ms) => {
172
180
  slept += ms;
173
181
  now += ms;
@@ -175,12 +183,15 @@ test('verifyDaemonStartup waits for pidfile + live pid, then reports success for
175
183
  pidfileReady = true;
176
184
  if (now >= 30)
177
185
  pidAliveReady = true;
186
+ if (now >= 40)
187
+ socketServing = true;
178
188
  },
179
189
  });
180
190
  assert.equal(existingPid, null, 'the spawned daemon owns the pidfile when startup succeeds');
181
191
  assert.equal(pidfileReady, true, 'pidfile must have appeared before success');
182
192
  assert.equal(pidAliveReady, true, 'pid must have been alive before success');
183
- assert.equal(slept > 0, true, 'the guard must actually poll');
193
+ assert.equal(socketServing, true, 'the Unix API socket must serve before success');
194
+ assert.equal(slept >= 40, true, 'the guard must actually poll through socket readiness');
184
195
  });
185
196
  test('runDaemon awaits activation before ownership and readiness', () => {
186
197
  const source = readFileSync(fileURLToPath(new URL('../../daemon/crtrd.ts', import.meta.url)), 'utf8');
@@ -204,6 +215,7 @@ test('verifyDaemonStartup default window tolerates a slower cold start', async (
204
215
  now: () => now,
205
216
  readPidfile: () => (pidfileReady ? 42 : null),
206
217
  isPidAlive: () => pidAliveReady,
218
+ isDaemonServing: async () => pidAliveReady,
207
219
  sleepMs: (ms) => {
208
220
  now += ms;
209
221
  if (now >= 600)
@@ -221,6 +233,7 @@ test('startup verification waits for delayed activation inside the shared window
221
233
  now: () => now,
222
234
  readPidfile: () => (activationComplete ? 42 : null),
223
235
  isPidAlive: () => activationComplete,
236
+ isDaemonServing: async () => activationComplete,
224
237
  sleepMs: (ms) => {
225
238
  now += ms;
226
239
  if (now >= 25_000)
@@ -239,6 +252,7 @@ test('verifyDaemonStartup returns the existing live daemon when another pid won
239
252
  now: () => now,
240
253
  readPidfile: () => 7,
241
254
  isPidAlive: (pid) => pid === 7,
255
+ isDaemonServing: async () => true,
242
256
  sleepMs: (ms) => {
243
257
  slept += ms;
244
258
  now += ms;
@@ -276,6 +290,7 @@ test('verifyDaemonStartup throws after its tiny startup window expires', async (
276
290
  now: () => now,
277
291
  readPidfile: () => null,
278
292
  isPidAlive: () => false,
293
+ isDaemonServing: async () => false,
279
294
  sleepMs: (ms) => {
280
295
  now += ms;
281
296
  },
@@ -299,6 +314,7 @@ test('verifyDaemonStartup throws when the spawned daemon exits before it can bec
299
314
  await assert.rejects(async () => verifyDaemonStartup(77, 2_000, {
300
315
  readPidfile: () => null,
301
316
  isPidAlive: () => false,
317
+ isDaemonServing: async () => false,
302
318
  childExited: () => exited,
303
319
  }), /exited before becoming ready \(with exit code 1\)/);
304
320
  assert.ok(Date.now() - started < 1_500, 'must fail fast on the real child exit, not run out the window');
@@ -1,41 +1,47 @@
1
1
  // Run with: node --import tsx/esm --test src/core/__tests__/daemon-wedge.test.ts
2
2
  //
3
3
  // Issue #110 — pid-alive WEDGED node detection. The pure decision core:
4
- // wedgeVerdict(busy, quietForMs, cpuPercent). No wall-clock sleeps, no ps
4
+ // wedgeVerdict(busy, quietForMs, cpuPercent, hasDescendants). No wall-clock sleeps, no ps
5
5
  // shell-outs, no fake pids — every input is injected directly, mirroring the
6
6
  // yieldStallVerdict truth-table suite in refresh-stall-recycle.test.ts.
7
7
  //
8
- // LOCKS: (1) a routine long-running BUT ACTIVE turn (heartbeat recent, or CPU
9
- // non-trivial) must never verdict 'wedged' — false positives are the failure
10
- // mode this feature exists to avoid. (2) only busy+quiet+near-zero-CPU, all at
11
- // once, past the generous grace, fires. (3) a null/failed CPU sample never
8
+ // LOCKS: (1) a routine long-running turn with a CPU-active subprocess must
9
+ // never verdict 'wedged' — false positives are the failure mode this feature
10
+ // exists to avoid. (2) a childless busy broker that is quiet past grace is
11
+ // wedged even when its own CPU is high. (3) a null/failed CPU sample never
12
12
  // fires on its own (fails toward 'pending', not 'wedged').
13
13
  import { test } from 'node:test';
14
14
  import assert from 'node:assert/strict';
15
15
  import { wedgeVerdict, WEDGE_QUIET_MS, WEDGE_CPU_MAX_PERCENT, sumTreeCpu, treeHasDescendants, } from '../../daemon/reconcilers/broker-supervision.js';
16
16
  test('wedgeVerdict: a node whose turn already ended is never wedged, regardless of the other signals', () => {
17
- assert.equal(wedgeVerdict({ busy: false, quietForMs: null, cpuPercent: null }), 'leave');
18
- assert.equal(wedgeVerdict({ busy: false, quietForMs: 10 * 60 * 60_000, cpuPercent: 0 }), 'leave');
17
+ assert.equal(wedgeVerdict({ busy: false, quietForMs: null, cpuPercent: null, hasDescendants: null }), 'leave');
18
+ assert.equal(wedgeVerdict({ busy: false, quietForMs: 10 * 60 * 60_000, cpuPercent: 0, hasDescendants: null }), 'leave');
19
19
  });
20
20
  test('wedgeVerdict: a busy node with an unreadable heartbeat is left alone (not enough evidence)', () => {
21
- assert.equal(wedgeVerdict({ busy: true, quietForMs: null, cpuPercent: null }), 'leave');
21
+ assert.equal(wedgeVerdict({ busy: true, quietForMs: null, cpuPercent: null, hasDescendants: null }), 'leave');
22
22
  });
23
23
  test('wedgeVerdict: routine turns — even long ones — never trip before the grace elapses', () => {
24
- assert.equal(wedgeVerdict({ busy: true, quietForMs: 0, cpuPercent: 0 }), 'leave', 'just started');
25
- assert.equal(wedgeVerdict({ busy: true, quietForMs: 5 * 60_000, cpuPercent: 0 }), 'leave', 'a routine multi-minute tool call');
26
- assert.equal(wedgeVerdict({ busy: true, quietForMs: WEDGE_QUIET_MS - 1, cpuPercent: 0 }), 'leave', 'just under the grace, even with 0% cpu');
24
+ assert.equal(wedgeVerdict({ busy: true, quietForMs: 0, cpuPercent: 0, hasDescendants: null }), 'leave', 'just started');
25
+ assert.equal(wedgeVerdict({ busy: true, quietForMs: 5 * 60_000, cpuPercent: 0, hasDescendants: null }), 'leave', 'a routine multi-minute tool call');
26
+ assert.equal(wedgeVerdict({ busy: true, quietForMs: WEDGE_QUIET_MS - 1, cpuPercent: 0, hasDescendants: null }), 'leave', 'just under the grace, even with 0% cpu');
27
27
  });
28
28
  test('wedgeVerdict: past the grace with NO cpu corroboration only pends — never fires on time alone', () => {
29
- assert.equal(wedgeVerdict({ busy: true, quietForMs: WEDGE_QUIET_MS, cpuPercent: null }), 'pending', 'cpu sample failed/unavailable — never treated as confirmation');
30
- assert.equal(wedgeVerdict({ busy: true, quietForMs: WEDGE_QUIET_MS * 10, cpuPercent: null }), 'pending', 'no amount of extra quiet time substitutes for the corroborating signal');
29
+ assert.equal(wedgeVerdict({ busy: true, quietForMs: WEDGE_QUIET_MS, cpuPercent: null, hasDescendants: null }), 'pending', 'cpu sample failed/unavailable — never treated as confirmation');
30
+ assert.equal(wedgeVerdict({ busy: true, quietForMs: WEDGE_QUIET_MS * 10, cpuPercent: null, hasDescendants: null }), 'pending', 'no amount of extra quiet time substitutes for the corroborating signal');
31
31
  });
32
- test('wedgeVerdict: a long-quiet turn that is STILL VISIBLY WORKING (real cpu) is exonerated, not wedged', () => {
33
- assert.equal(wedgeVerdict({ busy: true, quietForMs: WEDGE_QUIET_MS * 3, cpuPercent: WEDGE_CPU_MAX_PERCENT + 0.1 }), 'pending', 'cpu just above the ceiling — a genuinely churning long build — must not fire');
34
- assert.equal(wedgeVerdict({ busy: true, quietForMs: WEDGE_QUIET_MS * 3, cpuPercent: 40 }), 'pending', 'clearly-active cpu never fires regardless of how quiet the heartbeat looks');
32
+ test('wedgeVerdict: high CPU exonerates a broker with descendants but not a childless stalled engine', () => {
33
+ assert.equal(wedgeVerdict({
34
+ busy: true,
35
+ quietForMs: WEDGE_QUIET_MS * 3,
36
+ cpuPercent: WEDGE_CPU_MAX_PERCENT + 0.1,
37
+ hasDescendants: true,
38
+ }), 'pending', 'cpu just above the ceiling — a genuinely churning long build — must not fire');
39
+ assert.equal(wedgeVerdict({ busy: true, quietForMs: WEDGE_QUIET_MS * 3, cpuPercent: 40, hasDescendants: true }), 'pending', 'clearly-active child cpu never fires regardless of how quiet the heartbeat looks');
40
+ assert.equal(wedgeVerdict({ busy: true, quietForMs: WEDGE_QUIET_MS * 3, cpuPercent: 40, hasDescendants: false }), 'wedged', 'a childless broker spinning in-process is itself stalled');
35
41
  });
36
- test('wedgeVerdict: fires ONLY when busy + quiet-past-grace + near-zero cpu all corroborate', () => {
37
- assert.equal(wedgeVerdict({ busy: true, quietForMs: WEDGE_QUIET_MS, cpuPercent: 0 }), 'wedged');
38
- assert.equal(wedgeVerdict({ busy: true, quietForMs: WEDGE_QUIET_MS + 60_000, cpuPercent: WEDGE_CPU_MAX_PERCENT }), 'wedged', 'at the cpu ceiling (inclusive)');
42
+ test('wedgeVerdict: fires when busy + quiet-past-grace + near-zero cpu all corroborate', () => {
43
+ assert.equal(wedgeVerdict({ busy: true, quietForMs: WEDGE_QUIET_MS, cpuPercent: 0, hasDescendants: true }), 'wedged');
44
+ assert.equal(wedgeVerdict({ busy: true, quietForMs: WEDGE_QUIET_MS + 60_000, cpuPercent: WEDGE_CPU_MAX_PERCENT, hasDescendants: true }), 'wedged', 'at the cpu ceiling (inclusive)');
39
45
  });
40
46
  // sumTreeCpu (pure) — process-tree CPU sampling. Review fix for issue #110:
41
47
  // sampling only the broker pid false-fired on a broker awaiting a busy CHILD
@@ -29,6 +29,7 @@
29
29
  // <nodeDir>/fake-pi.error — any boot/import failure
30
30
  // <nodeDir>/fake-pi.cmd — harness writes ONE JSON command (turn|stop|auto_retry|dialog)
31
31
  // <nodeDir>/fake-pi.dialog.jsonl — one line per resolved unattended dialog (§5.4)
32
+ import { spawn } from 'node:child_process';
32
33
  import { writeFileSync, readFileSync, existsSync, mkdirSync, appendFileSync, unlinkSync, } from 'node:fs';
33
34
  import { join, dirname } from 'node:path';
34
35
  import { randomUUID } from 'node:crypto';
@@ -493,12 +494,19 @@ class FakeSession {
493
494
  * a resolvable `/name` token must not crash on a missing method. */
494
495
  customMessages = [];
495
496
  sendCustomMessage(message, options) {
496
- this.customMessages.push({
497
+ const customMessage = {
497
498
  customType: message.customType,
498
499
  content: message.content,
499
500
  display: message.display,
500
501
  deliverAs: options?.deliverAs,
501
- });
502
+ };
503
+ this.customMessages.push(customMessage);
504
+ try {
505
+ appendFileSync(join(this.dir, 'fake-pi.custom-messages.jsonl'), JSON.stringify(customMessage) + '\n');
506
+ }
507
+ catch {
508
+ /* best effort test proof */
509
+ }
502
510
  return Promise.resolve();
503
511
  }
504
512
  abort() {
@@ -940,6 +948,16 @@ class FakeSession {
940
948
  async dispatch(cmd) {
941
949
  const ctx = this.buildCtx();
942
950
  switch (cmd.cmd) {
951
+ case 'self_close': {
952
+ const cli = join(dirname(new URL(import.meta.url).pathname), '../../../cli.js');
953
+ const resultPath = join(this.dir, 'self-close-result');
954
+ const pidPath = join(this.dir, 'self-close-shell.pid');
955
+ const shell = spawn('sh', ['-c', `"${process.execPath}" "${cli}" node lifecycle close --node "$CRTR_NODE_ID"; code=$?; printf '%s\\n' "$code" > "${resultPath}"; while :; do sleep 1; done`], { detached: true, stdio: 'ignore', env: process.env });
956
+ if (shell.pid !== undefined)
957
+ writeFileSync(pidPath, `${shell.pid}\n`);
958
+ shell.unref();
959
+ break;
960
+ }
943
961
  case 'stream':
944
962
  // Drive a subscribe-channel streaming turn WITHOUT a controller (G3:
945
963
  // produce-while-detached) or with sized frames (G8: a fast event stream
@@ -47,7 +47,7 @@ async function withMutedStdout(fn) {
47
47
  }
48
48
  function bridgeUnder() {
49
49
  const parent = spawnNode({ kind: 'general', parent: null, cwd: home, name: 'asker' }).node_id;
50
- const bridge = spawnNode({ kind: 'human', parent, cwd: home, name: 'human-page', lifecycle: 'terminal' }).node_id;
50
+ const bridge = spawnNode({ kind: 'human', parent, creator: parent, cwd: home, name: 'human-page', lifecycle: 'terminal' }).node_id;
51
51
  return { parent, bridge };
52
52
  }
53
53
  function submitBridgePage(bridge) {
@@ -178,7 +178,7 @@ describe('terminal ticket delivery', () => {
178
178
  test('approved companions receive one canonical final while dismissed companions remain canceled', async () => {
179
179
  const { bridge } = bridgeUnder();
180
180
  const dir = submitBridgePage(bridge);
181
- const companion = spawnNode({ kind: 'general', parent: bridge, cwd: home, name: 'feedback companion', lifecycle: 'resident' }).node_id;
181
+ const companion = spawnNode({ kind: 'general', parent: bridge, creator: bridge, cwd: home, name: 'feedback companion', lifecycle: 'resident' }).node_id;
182
182
  createPageFeedbackComment(dir, { quote: 'Ship it?', note: 'Confirm the release.', companionNodeId: companion });
183
183
  transition(companion, 'finish');
184
184
  await resolvePageTicket(bridge, { question: { text: 'yes', edited: true } });
@@ -195,7 +195,7 @@ describe('terminal ticket delivery', () => {
195
195
  assert.throws(() => reviveNode(companion, { resume: true, capacity: 'freeze' }), /finalization latch/);
196
196
  const { bridge: dismissedBridge } = bridgeUnder();
197
197
  const dismissedDir = submitBridgePage(dismissedBridge);
198
- const dismissedCompanion = spawnNode({ kind: 'general', parent: dismissedBridge, cwd: home, name: 'dismissed feedback companion', lifecycle: 'resident' }).node_id;
198
+ const dismissedCompanion = spawnNode({ kind: 'general', parent: dismissedBridge, creator: dismissedBridge, cwd: home, name: 'dismissed feedback companion', lifecycle: 'resident' }).node_id;
199
199
  createPageFeedbackComment(dismissedDir, { quote: 'Ship it?', note: 'Do not ship.', companionNodeId: dismissedCompanion });
200
200
  await cancelHumanTicket(dismissedBridge, { reason: 'dismissed', actor: 'human' });
201
201
  assert.equal(getNode(dismissedCompanion)?.status, 'canceled');
@@ -207,7 +207,7 @@ describe('terminal ticket delivery', () => {
207
207
  });
208
208
  test('cancelHumanTicket finishes the bridge and notifies non-actor subscribers', async () => {
209
209
  const actor = spawnNode({ kind: 'general', parent: null, cwd: home, name: 'actor' }).node_id;
210
- const bridge = spawnNode({ kind: 'human', parent: actor, cwd: home, name: 'human-page', lifecycle: 'terminal' }).node_id;
210
+ const bridge = spawnNode({ kind: 'human', parent: actor, creator: actor, cwd: home, name: 'human-page', lifecycle: 'terminal' }).node_id;
211
211
  const other = spawnNode({ kind: 'general', parent: null, cwd: home, name: 'other' }).node_id;
212
212
  const { subscribe } = await import('../canvas/canvas.js');
213
213
  subscribe(other, bridge, true);
@@ -223,7 +223,7 @@ describe('terminal ticket delivery', () => {
223
223
  });
224
224
  test('cancellation reaches subscribers when the bridge is already done, and retries conflict rather than fail', async () => {
225
225
  const actor = spawnNode({ kind: 'general', parent: null, cwd: home, name: 'actor' }).node_id;
226
- const bridge = spawnNode({ kind: 'human', parent: actor, cwd: home, name: 'human-page', lifecycle: 'terminal' }).node_id;
226
+ const bridge = spawnNode({ kind: 'human', parent: actor, creator: actor, cwd: home, name: 'human-page', lifecycle: 'terminal' }).node_id;
227
227
  const other = spawnNode({ kind: 'general', parent: null, cwd: home, name: 'other' }).node_id;
228
228
  const { subscribe } = await import('../canvas/canvas.js');
229
229
  subscribe(other, bridge, true);
@@ -44,7 +44,7 @@ import { tmpdir } from 'node:os';
44
44
  import { join } from 'node:path';
45
45
  import { spawnSync } from 'node:child_process';
46
46
  import { DatabaseSync } from 'node:sqlite';
47
- import { createNode, deleteNode, getNode, setFrozen, setStatus, withFreshTerminalGuard } from '../../canvas/canvas.js';
47
+ import { createNode, deleteNode, getNode, setFrozen, setStatus, subscribe, withFreshTerminalGuard } from '../../canvas/canvas.js';
48
48
  import { hasNoNaturalCycle } from '../../runtime/revive-all.js';
49
49
  import { canvasDbPath, nodeDir, apiSocketPath } from '../../canvas/paths.js';
50
50
  import { closeDb } from '../../canvas/db.js';
@@ -148,7 +148,10 @@ after(() => {
148
148
  // Command layer — the immediate-delivery wake block in node.ts
149
149
  test('node message send --tier deferred to an ordinary dormant (idle-released) target does not revive it — appended and held for the next natural cycle', { timeout: 30_000 }, async () => {
150
150
  const id = 'idleReleaseTarget';
151
- createNode(node(id, { status: 'idle', intent: 'idle-release' }));
151
+ // Resident: the hold is what a target with a real later cycle gets. A
152
+ // terminal target takes no such cycle, so its mail is raised to the steering
153
+ // tier instead (locked by the next test).
154
+ createNode(node(id, { lifecycle: 'resident', status: 'idle', intent: 'idle-release' }));
152
155
  const inboxBefore = readInboxSince(id, undefined).length;
153
156
  const originalLaunch = headlessBrokerHost.launch;
154
157
  let launchCalls = 0;
@@ -170,6 +173,30 @@ test('node message send --tier deferred to an ordinary dormant (idle-released) t
170
173
  assert.equal(after.status, 'idle', 'target left exactly where it was');
171
174
  assert.equal(after.intent, 'idle-release', 'target left exactly where it was');
172
175
  });
176
+ test('node message send --tier deferred to a TERMINAL-lifecycle target is raised to the steering tier and wakes it', { timeout: 30_000 }, async () => {
177
+ const id = 'terminalLifecycleTarget';
178
+ createNode(node(id, { status: 'idle', intent: 'idle-release', pi_pid: deadPid(), pi_session_id: null, cwd: home }));
179
+ // Fully stubbed (never delegates): the point is that the raised entry DOES
180
+ // attempt the wake, so a real detached broker must not be spawned to prove it.
181
+ const originalLaunch = headlessBrokerHost.launch;
182
+ let launchCalls = 0;
183
+ headlessBrokerHost.launch = (() => {
184
+ launchCalls++;
185
+ return { pid: 0x7ffffffe, exited: new Promise(() => { }) };
186
+ });
187
+ let res;
188
+ try {
189
+ res = (await messageSendLeaf().run({ to: id, tier: 'deferred', body: 'not held' }));
190
+ }
191
+ finally {
192
+ headlessBrokerHost.launch = originalLaunch;
193
+ }
194
+ const entries = readInboxSince(id, undefined);
195
+ assert.equal(entries.length, 1, 'the message is durably appended');
196
+ assert.equal(entries[0].tier, 'urgent', 'a terminal target takes no later cycle, so the entry is raised to the steering tier');
197
+ assert.equal(launchCalls, 1, 'the raised entry wakes the dormant terminal target instead of waiting for a cycle it never takes');
198
+ assert.equal(res['revived'], true, 'the send reports the wake it actually performed');
199
+ });
173
200
  test('node message send --tier deferred rejects ordinary terminal targets but accepts a frozen terminal target with a thaw cycle owed', { timeout: 30_000 }, async () => {
174
201
  for (const status of ['done', 'canceled']) {
175
202
  const id = `terminal-${status}`;
@@ -385,7 +412,13 @@ test("withFreshTerminalGuard reports 'missing' — never runs the guarded body
385
412
  // proven separately below through the real inbox watcher.
386
413
  test('daemon second pass: a deferred-only unseen set never wakes an idle-released node; a later non-deferred entry wakes it (decision only — real delivery is proven separately below)', { timeout: 30_000 }, async () => {
387
414
  const id = 'idleReleaseDeferredDaemon';
388
- createNode(node(id, { status: 'idle', intent: 'idle-release', pi_pid: deadPid(), pi_session_id: null, cwd: home }));
415
+ createNode(node(id, { lifecycle: 'resident', status: 'idle', intent: 'idle-release', pi_pid: deadPid(), pi_session_id: null, cwd: home }));
416
+ // A live child is a standing obligation, which is what keeps this row
417
+ // idle-released across the tick. Without one it would settle into its park
418
+ // instead (the resident carve-out, locked in the next test) and the wake half
419
+ // below would be measuring a parked row.
420
+ createNode(node(`${id}Child`, { status: 'active' }));
421
+ subscribe(id, `${id}Child`);
389
422
  const baseline = readCursor(id); // undefined — the real watcher never ran here
390
423
  const originalLaunch = headlessBrokerHost.launch;
391
424
  let launchCalls = 0;
@@ -437,6 +470,34 @@ test('daemon second pass: a deferred-only unseen set never wakes an idle-release
437
470
  const unseen = readInboxSince(id, baseline).map((e) => e.entry_id);
438
471
  assert.deepEqual(unseen, [deferredEntry.entry_id, normalEntry.entry_id], 'neither entry was dropped or skipped by the daemon');
439
472
  });
473
+ test('the lifecycle tick parks a resident holding deferred-only mail — mail that never wakes it must not hold it idle forever either', { timeout: 30_000 }, async () => {
474
+ const id = 'residentDeferredOnly';
475
+ createNode(node(id, { lifecycle: 'resident', status: 'idle', intent: 'idle-release', pi_pid: deadPid(), pi_session_id: null, cwd: home }));
476
+ const originalLaunch = headlessBrokerHost.launch;
477
+ let launchCalls = 0;
478
+ headlessBrokerHost.launch = (() => {
479
+ launchCalls++;
480
+ return { pid: 0x7ffffffe, exited: new Promise(() => { }) };
481
+ });
482
+ try {
483
+ const held = appendInbox(id, {
484
+ from: null,
485
+ tier: 'deferred',
486
+ kind: 'message',
487
+ label: 'Child done — auto-done notice',
488
+ data: { reason: 'child-auto-done' },
489
+ });
490
+ await superviseTick();
491
+ const after = getNode(id);
492
+ assert.equal(after.status, 'done', 'the resident settles into its park instead of sitting idle behind mail that cannot wake it');
493
+ assert.equal(after.intent, 'parked', 'parked, not released');
494
+ assert.equal(launchCalls, 0, 'settling quietly never launches an engine');
495
+ assert.deepEqual(readInboxSince(id, undefined).map((e) => e.entry_id), [held.entry_id], 'the held entry survives the park, ready for the cycle that reopens the conversation');
496
+ }
497
+ finally {
498
+ headlessBrokerHost.launch = originalLaunch;
499
+ }
500
+ });
440
501
  function makeFakePi() {
441
502
  const handlers = {};
442
503
  return {
@@ -453,7 +514,7 @@ test('the real inbox watcher holds a deferred entry alone, then delivers it TOGE
453
514
  // The watcher may advance its cursor only for the current broker generation.
454
515
  // This fake Pi runs in this test process, so its fabricated node must carry
455
516
  // that process as the active broker just like a real launched node does.
456
- createNode(node(id, { status: 'idle', intent: null, pi_pid: process.pid }));
517
+ createNode(node(id, { lifecycle: 'resident', status: 'idle', intent: null, pi_pid: process.pid }));
457
518
  const TICK_MS = 20;
458
519
  const DEBOUNCE_MS = 25;
459
520
  const SETTLE_MS = TICK_MS * 2 + DEBOUNCE_MS + 30;
@@ -89,7 +89,7 @@ function reportBody(ref) {
89
89
  }
90
90
  test('malformed resolves leave the mounted ticket claim in place', async () => {
91
91
  const parent = spawnNode({ kind: 'general', parent: null, cwd: home, name: 'asker' }).node_id;
92
- const pageBridge = spawnNode({ kind: 'human', parent, cwd: home, name: 'human-page', lifecycle: 'terminal' }).node_id;
92
+ const pageBridge = spawnNode({ kind: 'human', parent, creator: parent, cwd: home, name: 'human-page', lifecycle: 'terminal' }).node_id;
93
93
  const pageDir = submitBridgePage(pageBridge);
94
94
  const pageClaim = claimTicket(pageDir);
95
95
  assert.ok(pageClaim !== null);
@@ -101,7 +101,7 @@ test('malformed resolves leave the mounted ticket claim in place', async () => {
101
101
  await expectReviewSubmitGuidance(pageBridge, body);
102
102
  assert.equal(readTicketClaim(pageDir)?.token, pageClaim.token, 'feedback-shaped input must not take over the live claim');
103
103
  }
104
- const reviewBridge = spawnNode({ kind: 'human', parent, cwd: home, name: 'human-review', lifecycle: 'terminal' }).node_id;
104
+ const reviewBridge = spawnNode({ kind: 'human', parent, creator: parent, cwd: home, name: 'human-review', lifecycle: 'terminal' }).node_id;
105
105
  const reviewDir = ticketDir(reviewBridge);
106
106
  const file = join(home, 'review.md');
107
107
  writeFileSync(file, '# Review\n');
@@ -114,7 +114,7 @@ test('malformed resolves leave the mounted ticket claim in place', async () => {
114
114
  });
115
115
  test('the ticket resolve route pushFinals the readable answer to the parent', async () => {
116
116
  const parent = spawnNode({ kind: 'general', parent: null, cwd: home, name: 'asker' }).node_id;
117
- const bridge = spawnNode({ kind: 'human', parent, cwd: home, name: 'human-page', lifecycle: 'terminal' }).node_id;
117
+ const bridge = spawnNode({ kind: 'human', parent, creator: parent, cwd: home, name: 'human-page', lifecycle: 'terminal' }).node_id;
118
118
  const dir = submitBridgePage(bridge);
119
119
  const result = await client.resolveHumanTicket(bridge, { responses: { question: { text: 'yes, ship', edited: true } } });
120
120
  assert.deepEqual(result, { completed: true, reply_delivered: true, kind: 'page' });
@@ -36,6 +36,7 @@ import { createNode, deleteNode, getNode, subscribe, updateNode } from '../../ca
36
36
  import { armCron, hasPendingCancelOnWakeCron } from '../../canvas/crons.js';
37
37
  import { readInboxSince } from '../../feed/inbox.js';
38
38
  import { closeDb } from '../../canvas/db.js';
39
+ import { apiSocketPath } from '../../canvas/paths.js';
39
40
  import { buildPiArgv } from '../../runtime/launch.js';
40
41
  import { headlessBrokerHost } from '../../runtime/host.js';
41
42
  import { resumeArgs, reviveNode } from '../../runtime/revive.js';
@@ -43,6 +44,8 @@ import { readGoal, writeGoal } from '../../runtime/kickoff.js';
43
44
  import { parseCard } from '../../../shared/generated-context.js';
44
45
  import { seedRoadmap } from '../../runtime/roadmap.js';
45
46
  import { InputError } from '../../io.js';
47
+ import { CrtrClient } from '../../../api/client.js';
48
+ import { readFault } from '../../runtime/fault.js';
46
49
  import { createHarness } from '../helpers/harness.js';
47
50
  import { bindTestFleet } from '../helpers/fleet.js';
48
51
  import { resetFleetForTests } from '../../runtime/fleet.js';
@@ -309,6 +312,32 @@ test('a recovery relaunch keeps the armed deadline; a wake revive consumes it',
309
312
  await h.dispose();
310
313
  }
311
314
  });
315
+ test('a second broker session-bound request is shut down instead of adopting a live node transcript (NL-162)', async () => {
316
+ const h = await createHarness({ headless: true, sessionPrefix: 'crtr-revive-session-owner' });
317
+ try {
318
+ const sessionFile = join(home, 'sessions', 'owned.jsonl');
319
+ mkdirSync(join(home, 'sessions'), { recursive: true });
320
+ writeFileSync(sessionFile, '{}\n', 'utf8');
321
+ const owner = h.fabricateBrokerNode({
322
+ id: 'live-session-owner',
323
+ pi_pid: process.pid,
324
+ pi_session_id: 'owner-session',
325
+ pi_session_file: sessionFile,
326
+ });
327
+ const claimant = h.fabricateBrokerNode({ id: 'session-claimant', pi_pid: deadPid() });
328
+ const client = new CrtrClient({ socketPath: apiSocketPath(), autostart: false });
329
+ const result = await client.bindBrokerSession(claimant, {
330
+ piSessionId: 'claimant-session', sessionFile, pid: process.pid, reason: 'startup', reviewBoundaryIds: [],
331
+ });
332
+ assert.deepEqual(result, { action: 'shutdown' }, 'the second broker is refused before it can bind the live transcript');
333
+ assert.equal(h.node(owner)?.pi_session_file, sessionFile, 'the live owner retains its transcript coordinate');
334
+ assert.equal(h.node(claimant)?.pi_session_file, null, 'the claimant never adopts the owner transcript');
335
+ assert.equal(readFault(claimant)?.op, 'session-file-ownership', 'the refusal is durable and observable');
336
+ }
337
+ finally {
338
+ await h.dispose();
339
+ }
340
+ });
312
341
  test('reviveNode fresh-fallback clears the stale session identity (Major-3: no phantom stranded-relaunch)', async () => {
313
342
  const h = await createHarness({ headless: true, sessionPrefix: 'crtr-revive-freshfb' });
314
343
  try {
@@ -414,6 +443,25 @@ test('reviveNode throws a typed cwd_missing error and does NOT crash the node wh
414
443
  await h.dispose();
415
444
  }
416
445
  });
446
+ test('reviveNode relaunches fresh when the session header cwd was removed (NL-143)', async () => {
447
+ const h = await createHarness({ headless: true, sessionPrefix: 'crtr-revive-stale-session-cwd' });
448
+ try {
449
+ const removedCwd = join(home, 'removed-worktree');
450
+ const sessionFile = join(home, 'sessions', 'stale-cwd.jsonl');
451
+ mkdirSync(join(home, 'sessions'), { recursive: true });
452
+ writeFileSync(sessionFile, `${JSON.stringify({ type: 'session', version: 3, id: 'stale-session', cwd: removedCwd })}\n`, 'utf8');
453
+ const M = h.fabricateBrokerNode({
454
+ status: 'active', intent: null, pi_pid: deadPid(), pi_session_id: 'stale-session', pi_session_file: sessionFile,
455
+ });
456
+ const result = reviveNode(M, { resume: true, capacity: 'freeze', recovery: true });
457
+ assert.equal(result.resumed, false, 'the missing session cwd vetoes --session and launches fresh');
458
+ assert.equal(h.node(M)?.pi_session_id, null, 'the stale session id is cleared before the fresh launch');
459
+ assert.equal(h.node(M)?.pi_session_file, null, 'the stale session file is cleared before the fresh launch');
460
+ }
461
+ finally {
462
+ await h.dispose();
463
+ }
464
+ });
417
465
  test('reviveNode crashes and clears pid when launch/preflight throws', async () => {
418
466
  const h = await createHarness({ headless: true, sessionPrefix: 'crtr-revive-fail' });
419
467
  const originalLaunch = headlessBrokerHost.launch;
@@ -9,6 +9,8 @@ import { join } from 'node:path';
9
9
  import { createServer } from 'node:net';
10
10
  import { spawn as spawnProcess } from 'node:child_process';
11
11
  import { createNode, getNode, getRow, subscribersOf } from '../../canvas/canvas.js';
12
+ import { coalesce, readInboxSince } from '../../feed/inbox.js';
13
+ import { coalesceBrokerInbox } from '../../runtime/broker/inbox.js';
12
14
  import { closeDb } from '../../canvas/db.js';
13
15
  import { nodeDir } from '../../canvas/paths.js';
14
16
  import { spawnNode } from '../../runtime/nodes.js';
@@ -66,7 +68,7 @@ after(() => {
66
68
  });
67
69
  test('managed child: parent on the spine + active subscription + provenance', () => {
68
70
  createNode(spawner('A'));
69
- const child = spawnNode({ kind: 'developer', cwd: '/tmp/work', parent: 'A' });
71
+ const child = spawnNode({ kind: 'developer', cwd: '/tmp/work', parent: 'A', creator: 'A' });
70
72
  const meta = getNode(child.node_id);
71
73
  assert.equal(meta.parent, 'A', 'child keeps its spine parent');
72
74
  assert.equal(meta.spawned_by, 'A', 'child provenance defaults to its parent');
@@ -76,6 +78,39 @@ test('managed child: parent on the spine + active subscription + provenance', ()
76
78
  assert.equal(subs[0].node_id, 'A');
77
79
  assert.equal(subs[0].active, true, 'parent subscription is active');
78
80
  });
81
+ test('external child birth is deferred, persists its creator, and renders its age at delivery in both inbox mirrors', () => {
82
+ createNode(spawner('A'));
83
+ const creator = spawnNode({ kind: 'general', cwd: '/tmp/work', parent: null, name: 'Core' });
84
+ const child = spawnNode({ kind: 'developer', cwd: '/tmp/work', parent: 'A', creator: creator.node_id, name: 'worker' });
85
+ const saved = getNode(child.node_id);
86
+ assert.equal(saved.creator, creator.node_id, 'creator persists through meta.json and getNode');
87
+ const [entry] = readInboxSince('A');
88
+ assert.ok(entry, 'the parent receives exactly one birth entry');
89
+ assert.equal(entry.from, child.node_id, 'birth groups with the child’s future reports');
90
+ assert.equal(entry.tier, 'deferred', 'birth never starts a parent cycle');
91
+ assert.equal(entry.data?.['body'], `worker (${child.node_id}, developer) was created under you by Core [[CRTR_BIRTH_ANNOUNCEMENT_AGE]] ago. For your awareness only — no response or action is needed, and no need to mention it unless asked. Treat its future reports as background you already know about; investigate only if something genuinely needs you.`);
92
+ for (const [elapsed, age] of [
93
+ [0, 'moments'],
94
+ [60_000, '1 minute'],
95
+ [59 * 60_000, '59 minutes'],
96
+ [60 * 60_000, '1 hour'],
97
+ [47 * 60 * 60_000, '47 hours'],
98
+ [48 * 60 * 60_000, '2 days'],
99
+ ]) {
100
+ const aged = { ...entry, ts: new Date(Date.now() - elapsed).toISOString() };
101
+ const daemonCard = coalesce([aged])[0].text;
102
+ // The two inbox readers deserialize the same JSON shape under distinct entry-id brands.
103
+ const brokerCard = coalesceBrokerInbox([aged], new Map())[0].text;
104
+ for (const card of [daemonCard, brokerCard]) {
105
+ assert.ok(card.includes(`created under you by Core ${age} ago`), 'the renderer, not the stored body, computes age at delivery');
106
+ assert.doesNotMatch(card, /\[\[CRTR_BIRTH_ANNOUNCEMENT_AGE\]\]/, 'the marker is not visible to the recipient');
107
+ }
108
+ }
109
+ spawnNode({ kind: 'developer', cwd: '/tmp/work', parent: 'A', creator: 'A' });
110
+ assert.equal(readInboxSince('A').length, 1, 'a parent-created child stays silent');
111
+ spawnNode({ kind: 'developer', cwd: '/tmp/work', parent: null });
112
+ assert.equal(readInboxSince('A').length, 1, 'a root never announces');
113
+ });
79
114
  test('independent root: provenance only, no parent, no subscription', () => {
80
115
  createNode(spawner('A'));
81
116
  const root = spawnNode({