@north-light/crouter 0.3.248 → 0.3.250

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 (172) 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 +5 -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 +11 -0
  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/self-close-teardown.test.d.ts +1 -0
  63. package/dist/core/__tests__/seam/self-close-teardown.test.js +49 -0
  64. package/dist/core/__tests__/seam/yield-refresh-transaction.test.js +18 -10
  65. package/dist/core/canvas/canvas.d.ts +9 -15
  66. package/dist/core/canvas/canvas.js +41 -47
  67. package/dist/core/canvas/crons.d.ts +5 -8
  68. package/dist/core/canvas/crons.js +5 -8
  69. package/dist/core/canvas/history.d.ts +5 -0
  70. package/dist/core/canvas/history.js +12 -1
  71. package/dist/core/canvas/migrations.js +58 -1
  72. package/dist/core/canvas/pid.d.ts +1 -1
  73. package/dist/core/canvas/pid.js +5 -2
  74. package/dist/core/canvas/types.d.ts +4 -0
  75. package/dist/core/feed/feed.d.ts +4 -0
  76. package/dist/core/feed/feed.js +13 -8
  77. package/dist/core/feed/inbox.d.ts +2 -1
  78. package/dist/core/feed/inbox.js +22 -3
  79. package/dist/core/git.d.ts +3 -0
  80. package/dist/core/git.js +3 -1
  81. package/dist/core/human/feedback-companion.js +1 -0
  82. package/dist/core/preview-registry.js +0 -1
  83. package/dist/core/review/companion.js +1 -0
  84. package/dist/core/review/realize.js +1 -0
  85. package/dist/core/runtime/broker/auth-reload.d.ts +2 -0
  86. package/dist/core/runtime/broker/auth-reload.js +9 -3
  87. package/dist/core/runtime/broker/engine-drive.d.ts +2 -0
  88. package/dist/core/runtime/broker/engine-drive.js +28 -23
  89. package/dist/core/runtime/broker/event-projection.d.ts +4 -0
  90. package/dist/core/runtime/broker/event-projection.js +43 -23
  91. package/dist/core/runtime/broker/fault-retry.d.ts +9 -0
  92. package/dist/core/runtime/broker/fault-retry.js +94 -8
  93. package/dist/core/runtime/broker/frame-dispatch.d.ts +2 -0
  94. package/dist/core/runtime/broker/frame-dispatch.js +4 -1
  95. package/dist/core/runtime/broker/inbox.js +4 -0
  96. package/dist/core/runtime/broker/rebind.js +18 -0
  97. package/dist/core/runtime/broker/turn-admission.d.ts +11 -0
  98. package/dist/core/runtime/broker/turn-admission.js +31 -0
  99. package/dist/core/runtime/broker/turn-ignition.d.ts +2 -0
  100. package/dist/core/runtime/broker/turn-ignition.js +25 -15
  101. package/dist/core/runtime/broker.js +21 -2
  102. package/dist/core/runtime/close.d.ts +11 -9
  103. package/dist/core/runtime/close.js +23 -19
  104. package/dist/core/runtime/fault.d.ts +21 -0
  105. package/dist/core/runtime/fault.js +129 -19
  106. package/dist/core/runtime/fleet.d.ts +0 -6
  107. package/dist/core/runtime/host.d.ts +7 -1
  108. package/dist/core/runtime/host.js +20 -2
  109. package/dist/core/runtime/nodes.d.ts +5 -1
  110. package/dist/core/runtime/nodes.js +24 -1
  111. package/dist/core/runtime/placement.d.ts +6 -6
  112. package/dist/core/runtime/placement.js +15 -8
  113. package/dist/core/runtime/reset.js +2 -2
  114. package/dist/core/runtime/revive.js +40 -4
  115. package/dist/core/runtime/spawn.d.ts +3 -0
  116. package/dist/core/runtime/spawn.js +1 -0
  117. package/dist/core/runtime/warm-pool.d.ts +4 -0
  118. package/dist/core/runtime/warm-pool.js +2 -0
  119. package/dist/core/worktree.d.ts +4 -3
  120. package/dist/core/worktree.js +73 -27
  121. package/dist/daemon/__tests__/integration/api-startup-readiness.test.d.ts +1 -0
  122. package/dist/daemon/__tests__/integration/api-startup-readiness.test.js +81 -0
  123. package/dist/daemon/api/__tests__/reopen-delivery.test.js +38 -0
  124. package/dist/daemon/api/__tests__/seam/api-server.test.js +1 -0
  125. package/dist/daemon/api/__tests__/seam/leaf-api-parity.test.js +13 -2
  126. package/dist/daemon/api/handlers/bash-jobs.js +2 -0
  127. package/dist/daemon/api/handlers/broker-ops.js +23 -2
  128. package/dist/daemon/api/handlers/human.js +1 -1
  129. package/dist/daemon/api/handlers/messages.js +11 -2
  130. package/dist/daemon/api/handlers/nodes.js +13 -1
  131. package/dist/daemon/api/handlers/reports.js +14 -6
  132. package/dist/daemon/api/map.js +1 -0
  133. package/dist/daemon/api/server.d.ts +9 -12
  134. package/dist/daemon/api/server.js +30 -19
  135. package/dist/daemon/companion-retire.js +3 -9
  136. package/dist/daemon/cron/sinks.js +7 -2
  137. package/dist/daemon/crtrd.js +4 -1
  138. package/dist/daemon/fleet.d.ts +2 -16
  139. package/dist/daemon/fleet.js +63 -150
  140. package/dist/daemon/human/finish.js +2 -0
  141. package/dist/daemon/manage.d.ts +11 -0
  142. package/dist/daemon/manage.js +32 -3
  143. package/dist/daemon/messaging/node-message.js +7 -1
  144. package/dist/daemon/reconcilers/bash-deadline.js +2 -0
  145. package/dist/daemon/reconcilers/broker-supervision.d.ts +1 -0
  146. package/dist/daemon/reconcilers/broker-supervision.js +22 -9
  147. package/dist/daemon/reconcilers/live-obligation.d.ts +16 -6
  148. package/dist/daemon/reconcilers/live-obligation.js +18 -6
  149. package/dist/daemon/reconcilers/node-lifecycle/respawn-policy.d.ts +15 -4
  150. package/dist/daemon/reconcilers/node-lifecycle/respawn-policy.js +34 -3
  151. package/dist/daemon/reconcilers/node-lifecycle/terminating.d.ts +4 -1
  152. package/dist/daemon/reconcilers/node-lifecycle/terminating.js +11 -2
  153. package/dist/daemon/reconcilers/node-lifecycle/tick.js +14 -7
  154. package/dist/daemon/reconcilers/storage-maintenance.d.ts +2 -2
  155. package/dist/daemon/reconcilers/storage-maintenance.js +3 -3
  156. package/dist/pi-extensions/__tests__/canvas-stophook-agentend.test.js +4 -4
  157. package/dist/pi-extensions/canvas-context-intro.d.ts +3 -34
  158. package/dist/pi-extensions/canvas-context-intro.js +4 -76
  159. package/dist/pi-extensions/canvas-review-boundary.d.ts +3 -24
  160. package/dist/pi-extensions/canvas-review-boundary.js +4 -29
  161. package/dist/pi-extensions/canvas-stophook.js +1 -6
  162. package/dist/shared/birth-announcement.d.ts +12 -0
  163. package/dist/shared/birth-announcement.js +25 -0
  164. package/dist/shared/generated-context.d.ts +8 -4
  165. package/dist/shared/generated-context.js +15 -9
  166. package/package.json +4 -4
  167. package/runtime.lock.json +2 -2
  168. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/statusline.ts +0 -254
  169. package/dist/clients/inbox/tui/clipboard.d.ts +0 -2
  170. package/dist/clients/inbox/tui/clipboard.js +0 -26
  171. package/dist/pi-extensions/truncate.d.ts +0 -5
  172. package/dist/pi-extensions/truncate.js +0 -14
@@ -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';
@@ -940,6 +941,16 @@ class FakeSession {
940
941
  async dispatch(cmd) {
941
942
  const ctx = this.buildCtx();
942
943
  switch (cmd.cmd) {
944
+ case 'self_close': {
945
+ const cli = join(dirname(new URL(import.meta.url).pathname), '../../../cli.js');
946
+ const resultPath = join(this.dir, 'self-close-result');
947
+ const pidPath = join(this.dir, 'self-close-shell.pid');
948
+ 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 });
949
+ if (shell.pid !== undefined)
950
+ writeFileSync(pidPath, `${shell.pid}\n`);
951
+ shell.unref();
952
+ break;
953
+ }
943
954
  case 'stream':
944
955
  // Drive a subscribe-channel streaming turn WITHOUT a controller (G3:
945
956
  // 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({
@@ -71,7 +71,11 @@ test('closeManagedWorktree leaves the active caller checkout intact with named d
71
71
  assert.equal(wt.base_ref, 'main', 'default base must remain backward-compatible with persisted main worktrees');
72
72
  assert.equal(wt.path, managedWorktreePath(nodeId));
73
73
  assert.ok(existsSync(wt.path), 'worktree checkout should exist after create');
74
- createNode(node(nodeId, { cwd: wt.path, managed_worktree: wt }));
74
+ const sessionDir = join(home, 'sessions');
75
+ mkdirSync(sessionDir, { recursive: true });
76
+ const sessionFile = join(sessionDir, 'session.jsonl');
77
+ writeFileSync(sessionFile, `${JSON.stringify({ type: 'session', version: 3, id: 'wt-session', cwd: wt.path })}\n`);
78
+ createNode(node(nodeId, { cwd: wt.path, managed_worktree: wt, pi_session_id: 'wt-session', pi_session_file: sessionFile }));
75
79
  const result = closeManagedWorktree(nodeId);
76
80
  assert.equal(result.worktree_removed, false);
77
81
  assert.match(result.worktree_remove_error ?? '', /cleanup pending/);
@@ -83,6 +87,8 @@ test('closeManagedWorktree leaves the active caller checkout intact with named d
83
87
  assert.equal(meta?.managed_worktree?.state, 'closed');
84
88
  assert.equal(meta?.managed_worktree?.cleanup, 'pending');
85
89
  assert.equal(meta?.cwd, wt.repo_root, 'a later revive uses the durable base checkout');
90
+ assert.equal(meta?.pi_session_id, null, 'the live transcript remains sole-writer-owned, so close invalidates its stale resume identity');
91
+ assert.equal(meta?.pi_session_file, null, 'a later revive launches fresh rather than loading the header that still names this checkout');
86
92
  });
87
93
  test('two concurrent creates from linked worktrees serialize on their shared repository', async () => {
88
94
  const worktreeModule = new URL('../../worktree.ts', import.meta.url).href;
@@ -334,7 +340,7 @@ test('rejects remote and nonexistent worktree bases before creating any resource
334
340
  assert.equal(gitSync(['show-ref', '--verify', '--quiet', `refs/heads/crtr/${nodeId}`], sourceDir).status, 1, `${base} must not leave a dedicated branch`);
335
341
  }
336
342
  });
337
- test('refuses to close when the recorded base was deleted and leaves the worktree open', () => {
343
+ test('refuses to close a committed worktree when the recorded base was deleted', () => {
338
344
  const nodeId = 'wt-deleted-base';
339
345
  run(['branch', 'feature/deleted-base'], sourceDir);
340
346
  const mainBefore = gitSync(['rev-parse', 'main'], sourceDir).stdout.trim();
@@ -345,10 +351,83 @@ test('refuses to close when the recorded base was deleted and leaves the worktre
345
351
  run(['-c', 'user.email=test@example.com', '-c', 'user.name=Test', 'commit', '-m', 'deleted base work'], wt.path);
346
352
  run(['branch', '-D', 'feature/deleted-base'], sourceDir);
347
353
  assert.throws(() => closeManagedWorktree(nodeId), (error) => error instanceof Error && 'code' in error && error.code === 'missing_base_branch');
348
- assert.equal(getNode(nodeId)?.managed_worktree?.state, 'open', 'a missing base must not silently close or retarget the worktree');
354
+ assert.equal(getNode(nodeId)?.managed_worktree?.state, 'open', 'a missing base with committed work must remain open for a human decision');
349
355
  assert.ok(existsSync(wt.path), 'the worktree must remain available for a human decision');
350
356
  assert.equal(gitSync(['rev-parse', 'main'], sourceDir).stdout.trim(), mainBefore, 'missing base must never auto-land onto main');
351
357
  });
358
+ // NL-153/NL-154: the parent can land and finish before its clean child. Its
359
+ // checkout and branch then disappear, leaving `repo_root` as an empty path;
360
+ // the child itself still resolves the shared repository and is exactly at its
361
+ // recorded creation SHA, so close is a no-op with only deferred cleanup left.
362
+ test('close concludes a clean child after parent-first closure removes its checkout and base branch', () => {
363
+ const repo = mkdtempSync(join(tmpdir(), 'crtr-worktree-parent-first-'));
364
+ try {
365
+ run(['init', '-b', 'main'], repo);
366
+ run(['config', 'user.email', 'test@example.com'], repo);
367
+ run(['config', 'user.name', 'Test'], repo);
368
+ writeFileSync(join(repo, 'README.md'), 'hello\n');
369
+ run(['add', '.'], repo);
370
+ run(['commit', '-m', 'init'], repo);
371
+ const parent = createManagedWorktree(repo, 'wt-parent-first-parent');
372
+ createNode(node('wt-parent-first-parent', { cwd: parent.path, managed_worktree: parent }));
373
+ const child = createManagedWorktree(parent.path, 'wt-parent-first-child', parent.branch);
374
+ createNode(node('wt-parent-first-child', { cwd: child.path, managed_worktree: child }));
375
+ assert.equal(gitSync(['rev-parse', 'HEAD'], child.path).stdout.trim(), child.base_sha, 'the child must be a clean zero-commit checkout at its recorded base');
376
+ closeManagedWorktree('wt-parent-first-parent');
377
+ run(['worktree', 'remove', parent.path], repo);
378
+ run(['branch', '-D', parent.branch], repo);
379
+ mkdirSync(parent.path, { recursive: true });
380
+ assert.notEqual(gitSync(['rev-parse', '--git-dir'], parent.path).status, 0, 'the recorded parent path must be an empty non-Git directory');
381
+ assert.equal(gitSync(['show-ref', '--verify', '--quiet', `refs/heads/${parent.branch}`], child.path).status, 1, 'the parent base branch must be gone');
382
+ const result = closeManagedWorktree('wt-parent-first-child');
383
+ assert.equal(result.landed_sha, child.base_sha, 'a no-op close reports the recorded creation SHA');
384
+ assert.equal(result.worktree_removed, false, 'the live caller checkout remains for deferred cleanup');
385
+ assert.equal(getNode('wt-parent-first-child')?.managed_worktree?.state, 'closed');
386
+ run(['worktree', 'remove', child.path], repo);
387
+ run(['branch', '-D', child.branch], repo);
388
+ }
389
+ finally {
390
+ rmSync(repo, { recursive: true, force: true });
391
+ }
392
+ });
393
+ // A failed ref inspection is not proof that a clean child has nothing to
394
+ // land: only show-ref's completed absent-ref result may take that no-op path.
395
+ test('close surfaces a failed base ref inspection instead of closing a clean worktree', () => {
396
+ const nodeId = 'wt-base-ref-inspection-failure';
397
+ const wt = createManagedWorktree(sourceDir, nodeId);
398
+ createNode(node(nodeId, { cwd: wt.path, managed_worktree: wt }));
399
+ const wrapperDir = mkdtempSync(join(tmpdir(), 'crtr-git-wrapper-'));
400
+ const wrapperPath = join(wrapperDir, 'git');
401
+ const originalPath = process.env['PATH'] ?? '';
402
+ writeFileSync(wrapperPath, `#!/bin/sh
403
+ if [ "$1" = "show-ref" ] && [ "$2" = "--verify" ] && [ "$3" = "--quiet" ] && [ "$4" = "refs/heads/main" ]; then
404
+ echo "simulated unreadable ref store" >&2
405
+ exit 128
406
+ fi
407
+ PATH="$CRTR_TEST_REAL_PATH" exec git "$@"
408
+ `);
409
+ chmodSync(wrapperPath, 0o755);
410
+ process.env['CRTR_TEST_REAL_PATH'] = originalPath;
411
+ process.env['PATH'] = `${wrapperDir}:${originalPath}`;
412
+ try {
413
+ assert.throws(() => closeManagedWorktree(nodeId), (error) => error instanceof Error
414
+ && 'code' in error
415
+ && error.code === 'base_branch_inspection_failed'
416
+ && 'detail' in error
417
+ && typeof error.detail === 'string'
418
+ && error.detail.includes('status: 128')
419
+ && error.detail.includes('stderr: simulated unreadable ref store'));
420
+ }
421
+ finally {
422
+ process.env['PATH'] = originalPath;
423
+ delete process.env['CRTR_TEST_REAL_PATH'];
424
+ rmSync(wrapperDir, { recursive: true, force: true });
425
+ run(['worktree', 'remove', wt.path], sourceDir);
426
+ run(['branch', '-D', wt.branch], sourceDir);
427
+ }
428
+ assert.equal(getNode(nodeId)?.managed_worktree?.state, 'open', 'the failed inspection must not mark a clean worktree closed');
429
+ assert.ok(!existsSync(wt.path), 'the test must clean up its temporary checkout');
430
+ });
352
431
  // Regression for #330/#331: crtr's own untracked `.crouter/config.json` /
353
432
  // `state.json` bookkeeping (written by ordinary project-scope init) must
354
433
  // never block `close`'s dirty check — Fix A seeds `.git/info/exclude` for
@@ -261,10 +261,9 @@ test('autoDropCleanManagedWorktree drops a branch with real commits already cont
261
261
  assert.equal(gitSync(['merge-base', '--is-ancestor', branchSha, 'main'], repo).status, 0, 'the landed commit must remain reachable from main after the drop');
262
262
  rmSync(repo, { recursive: true, force: true });
263
263
  });
264
- // A base that no longer resolves (renamed/deleted local branch) must refuse
265
- // auto-drop rather than guess — the SAME guard `resolveLandableBase` already
266
- // gives the explicit close path.
267
- test('autoDropCleanManagedWorktree refuses when the recorded base branch no longer resolves', async () => {
264
+ // A clean child whose recorded base branch vanished has nothing to land only
265
+ // when its live HEAD exactly matches the persisted creation SHA.
266
+ test('autoDropCleanManagedWorktree drops a clean worktree at base_sha when its recorded base branch no longer resolves', async () => {
268
267
  const { autoDropCleanManagedWorktree } = await import('../../worktree.js');
269
268
  const repo = mkdtempSync(join(tmpdir(), 'crtr-worktree-missingbase-'));
270
269
  run(['init', '-b', 'main'], repo);
@@ -279,11 +278,50 @@ test('autoDropCleanManagedWorktree refuses when the recorded base branch no long
279
278
  // The recorded base branch ('main') has been renamed away since creation.
280
279
  run(['branch', '-m', 'main', 'renamed-main'], repo);
281
280
  const result = autoDropCleanManagedWorktree(nodeId);
282
- assert.equal(result, null, 'an unresolvable base must never be treated as safe to drop');
283
- assert.equal(getNode(nodeId)?.managed_worktree?.state, 'open');
281
+ assert.ok(result !== null, 'a clean worktree exactly at its recorded base SHA has nothing to land');
282
+ assert.equal(getNode(nodeId)?.managed_worktree?.state, 'closed');
284
283
  assert.ok(existsSync(wt.path));
285
284
  rmSync(repo, { recursive: true, force: true });
286
285
  });
286
+ // Reap may take the same missing-base no-op only after show-ref completed with
287
+ // its documented absent-ref status; inspection failures must remain visible.
288
+ test('autoDropCleanManagedWorktree surfaces a failed base ref inspection', async () => {
289
+ const { autoDropCleanManagedWorktree } = await import('../../worktree.js');
290
+ const nodeId = 'wt-reap-base-ref-inspection-failure';
291
+ const wt = createManagedWorktree(sourceDir, nodeId);
292
+ createNode(node(nodeId, { cwd: wt.path, managed_worktree: wt }));
293
+ const wrapperDir = mkdtempSync(join(tmpdir(), 'crtr-git-wrapper-'));
294
+ const wrapperPath = join(wrapperDir, 'git');
295
+ const originalPath = process.env['PATH'] ?? '';
296
+ writeFileSync(wrapperPath, `#!/bin/sh
297
+ if [ "$1" = "show-ref" ] && [ "$2" = "--verify" ] && [ "$3" = "--quiet" ] && [ "$4" = "refs/heads/main" ]; then
298
+ echo "simulated unreadable ref store" >&2
299
+ exit 128
300
+ fi
301
+ PATH="$CRTR_TEST_REAL_PATH" exec git "$@"
302
+ `);
303
+ chmodSync(wrapperPath, 0o755);
304
+ process.env['CRTR_TEST_REAL_PATH'] = originalPath;
305
+ process.env['PATH'] = `${wrapperDir}:${originalPath}`;
306
+ try {
307
+ assert.throws(() => autoDropCleanManagedWorktree(nodeId), (error) => error instanceof Error
308
+ && 'code' in error
309
+ && error.code === 'base_branch_inspection_failed'
310
+ && 'detail' in error
311
+ && typeof error.detail === 'string'
312
+ && error.detail.includes('status: 128')
313
+ && error.detail.includes('stderr: simulated unreadable ref store'));
314
+ }
315
+ finally {
316
+ process.env['PATH'] = originalPath;
317
+ delete process.env['CRTR_TEST_REAL_PATH'];
318
+ rmSync(wrapperDir, { recursive: true, force: true });
319
+ run(['worktree', 'remove', wt.path], sourceDir);
320
+ run(['branch', '-D', wt.branch], sourceDir);
321
+ }
322
+ assert.equal(getNode(nodeId)?.managed_worktree?.state, 'open', 'the failed inspection must not mark a clean worktree closed');
323
+ assert.ok(!existsSync(wt.path), 'the test must clean up its temporary checkout');
324
+ });
287
325
  // The Critical fix: the TOCTOU race between proving clean-and-contained and
288
326
  // the destructive branch delete. `proveCleanEmptyManagedWorktree` and
289
327
  // `autoDropCleanManagedWorktreeLocked` are exported specifically so these
@@ -215,6 +215,36 @@ test('a fresh db has the four additive runtime columns', () => {
215
215
  assert.equal(rt['window'], null);
216
216
  assert.equal(rt['tmux_session'], null);
217
217
  });
218
+ test('session-file ownership migration deterministically clears duplicate claimants before creating its UNIQUE index', () => {
219
+ ensureHome();
220
+ const raw = new DatabaseSync(canvasDbPath());
221
+ migrateToVersion(raw, 35);
222
+ const sessionFile = '/tmp/preexisting-duplicate.jsonl';
223
+ const aliasedSessionFile = '/tmp/./preexisting-duplicate.jsonl';
224
+ for (const id of ['first-owner', 'later-claimant']) {
225
+ const nodeDir = join(home, 'nodes', id);
226
+ mkdirSync(nodeDir, { recursive: true });
227
+ writeFileSync(join(nodeDir, 'meta.json'), JSON.stringify({
228
+ node_id: id, name: id, created: id === 'first-owner' ? '2026-01-01T00:00:00.000Z' : '2026-01-02T00:00:00.000Z',
229
+ cwd: '/tmp', kind: 'developer', mode: 'base', lifecycle: 'terminal', pi_session_id: `${id}-session`, pi_session_file: id === 'first-owner' ? sessionFile : aliasedSessionFile,
230
+ }));
231
+ }
232
+ raw.prepare('INSERT INTO nodes (node_id, name, kind, mode, lifecycle, status, cwd, created, pi_session_file) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)')
233
+ .run('first-owner', 'first-owner', 'developer', 'base', 'terminal', 'active', '/tmp', '2026-01-01T00:00:00.000Z', sessionFile);
234
+ raw.prepare('INSERT INTO nodes (node_id, name, kind, mode, lifecycle, status, cwd, created, pi_session_file) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)')
235
+ .run('later-claimant', 'later-claimant', 'developer', 'base', 'terminal', 'active', '/tmp', '2026-01-02T00:00:00.000Z', aliasedSessionFile);
236
+ assert.doesNotThrow(() => migrate(raw), 'a real canvas with pre-existing duplicates must migrate');
237
+ const rows = raw.prepare('SELECT node_id, pi_session_file FROM nodes ORDER BY created, node_id').all();
238
+ assert.deepEqual(rows.map((row) => [row.node_id, row.pi_session_file]), [
239
+ ['first-owner', sessionFile],
240
+ ['later-claimant', null],
241
+ ], 'the oldest created row wins after aliases are canonicalized; later claimants are cleared');
242
+ const loser = JSON.parse(readFileSync(join(home, 'nodes', 'later-claimant', 'meta.json'), 'utf8'));
243
+ assert.equal(loser.pi_session_id, null, 'the loser meta cannot reindex its stale session id');
244
+ assert.equal(loser.pi_session_file, null, 'the loser meta cannot restore the duplicate projection');
245
+ assert.throws(() => raw.prepare('UPDATE nodes SET pi_session_file = ? WHERE node_id = ?').run(sessionFile, 'later-claimant'), /UNIQUE/, 'the schema enforces the repaired invariant');
246
+ raw.close();
247
+ });
218
248
  test('node lifecycle substrate migration adds six columns and backfills terminal reasons', () => {
219
249
  ensureHome();
220
250
  const raw = new DatabaseSync(canvasDbPath());
@@ -13,6 +13,7 @@ import { mkdtempSync, mkdirSync, rmSync, existsSync, readFileSync, appendFileSyn
13
13
  import { tmpdir } from 'node:os';
14
14
  import { basename, join } from 'node:path';
15
15
  import { createNode, subscribe } from '../canvas/canvas.js';
16
+ import { armCron, getCron } from '../canvas/crons.js';
16
17
  import { closeDb } from '../canvas/db.js';
17
18
  import { inboxPath, passivePath, apiSocketPath } from '../canvas/paths.js';
18
19
  import { createApiServer } from '../../daemon/api/server.js';
@@ -99,16 +100,53 @@ test('passive push accumulates in passive.jsonl, not inbox.jsonl', async () => {
99
100
  assert.equal(acc[0].label, 'first observation');
100
101
  assert.match(acc[0].ref ?? '', /^pub:reports\/\d{8}T\d{9}-[0-9a-f]{8}-update\.md$/, 'report refs use a compact random suffix');
101
102
  });
102
- test('active push still lands in inbox.jsonl (wakes)', async () => {
103
+ test('active push still lands in inbox.jsonl (wakes) and clears the manager deadline', async () => {
103
104
  createNode(node('pub'));
104
105
  createNode(node('worker-mgr'));
105
106
  subscribe('worker-mgr', 'pub', true); // ACTIVE
107
+ armCron({
108
+ cron_id: 'manager-deadline',
109
+ name: 'manager deadline',
110
+ command: 'echo stale deadline',
111
+ fire_at: new Date(Date.now() + 60_000).toISOString(),
112
+ recur: null,
113
+ tz: null,
114
+ expires_at: null,
115
+ anchor_node: 'worker-mgr',
116
+ cancel_on_wake: true,
117
+ created_by: null,
118
+ cwd: home,
119
+ env_json: null,
120
+ profile: null,
121
+ scope: 'profile',
122
+ run_timeout_s: 30,
123
+ overlap: 'skip',
124
+ on_output: 'silent',
125
+ sink: '{}',
126
+ tier: 'normal',
127
+ });
106
128
  await push('pub', { kind: 'update', body: 'active report' });
107
129
  assert.equal(existsSync(passivePath('worker-mgr')), false);
108
130
  const inbox = readInboxSince('worker-mgr');
109
131
  assert.equal(inbox.length, 1);
110
132
  assert.equal(inbox[0].from, 'pub');
111
133
  assert.match(inbox[0].ref ?? '', /^pub:reports\/.+-update\.md$/);
134
+ assert.equal(getCron('manager-deadline'), null, 'the child report won the deadline race');
135
+ });
136
+ test('a deferred update still lands in an active subscriber inbox at deferred tier', async () => {
137
+ createNode(node('pub'));
138
+ // Resident: deferred mail to a TERMINAL target is bumped to urgent by the
139
+ // shipped tier bump, so only a resident subscriber shows the requested tier.
140
+ createNode(node('worker-mgr', { lifecycle: 'resident' }));
141
+ subscribe('worker-mgr', 'pub', true); // ACTIVE
142
+ await push('pub', { kind: 'update', deliveryTier: 'deferred', body: 'active report' });
143
+ assert.equal(existsSync(passivePath('worker-mgr')), false);
144
+ const inbox = readInboxSince('worker-mgr');
145
+ assert.equal(inbox.length, 1);
146
+ assert.equal(inbox[0].from, 'pub');
147
+ assert.equal(inbox[0].tier, 'deferred', 'requested delivery urgency reaches the subscriber unchanged');
148
+ assert.equal(inbox[0].kind, 'update', 'the stored report kind is untouched by delivery urgency');
149
+ assert.match(inbox[0].ref ?? '', /^pub:reports\/.+-update\.md$/);
112
150
  });
113
151
  test('mixed active + passive subscribers route to their own stores', async () => {
114
152
  createNode(node('pub'));