@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
@@ -7,12 +7,12 @@
7
7
  // its helpers in isolation.
8
8
  import { test, before, after, afterEach } from 'node:test';
9
9
  import assert from 'node:assert/strict';
10
- import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
10
+ import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
11
11
  import { join } from 'node:path';
12
12
  import { createHeadlessHarness } from '../helpers/harness.js';
13
13
  import { createAttachKit } from '../helpers/broker-clients.js';
14
14
  import { defaultModelLaddersConfig } from '../../../types.js';
15
- import { readFault } from '../../runtime/fault.js';
15
+ import { admitProviderRetryEpisode, readFault, readProviderRetryEpisode } from '../../runtime/fault.js';
16
16
  import { buildPiArgv, normalizeModel } from '../../runtime/launch.js';
17
17
  import { encodeFrame } from '../../runtime/broker-protocol.js';
18
18
  const MODEL_LADDERS = defaultModelLaddersConfig();
@@ -79,6 +79,18 @@ function agentStartCount(id) {
79
79
  return 0;
80
80
  }
81
81
  }
82
+ async function crashAndRespawn(id, boots) {
83
+ const pid = h.node(id)?.pi_pid;
84
+ assert.ok(pid != null, 'the broker has a live pid before its physical death');
85
+ process.kill(pid, 'SIGKILL');
86
+ await h.awaitFleetExit(id);
87
+ await h.tick();
88
+ const deferred = h.node(id);
89
+ assert.ok(deferred.respawn_not_before != null, 'the daemon records respawn backoff after the broker exit');
90
+ await h.tick(Date.parse(deferred.respawn_not_before) + 1);
91
+ await h.awaitBoot(id, { minCount: boots + 1 });
92
+ await h.waitFor(() => existsSync(h.brokerSock(id)), { label: 'replacement broker listener bound' });
93
+ }
82
94
  before(async () => {
83
95
  h = await createHeadlessHarness({ sessionPrefix: 'crtr-provider-retry' });
84
96
  });
@@ -130,6 +142,73 @@ test('a managed-cooling terminal authors a rate-limit auto fault whose deadline
130
142
  await new Promise((resolve) => setTimeout(resolve, 2500));
131
143
  assert.equal(agentStartCount(child), baselineStarts + 1, 'the fault-retry timer re-drives the turn exactly once');
132
144
  });
145
+ test('a crashed broker re-drives only its pending provider episode, never admitted or invalidated episodes', { timeout: 90_000 }, async () => {
146
+ const parent = h.spawnRoot('durable provider episode parent');
147
+ seedPool('anthropic', false);
148
+ seedPool('openai-codex', false);
149
+ const pendingNode = await h.spawnHeadlessChild(parent, 'pending provider episode', { model: OPENAI_STRONG.spec });
150
+ await h.waitFor(() => agentStartCount(pendingNode) >= 1, { label: 'pending episode fixture started' });
151
+ const pendingStarts = agentStartCount(pendingNode);
152
+ h.fakeCmd(pendingNode, {
153
+ cmd: 'stop', reason: 'error', errorMessage: 'Managed provider pools are temporarily unavailable.', deadline: Date.now() + 2_000,
154
+ });
155
+ const pending = await h.waitFor(() => readProviderRetryEpisode(pendingNode)?.state === 'pending' ? readProviderRetryEpisode(pendingNode) : null, { label: 'pending provider episode persisted before crash' });
156
+ assert.equal(pending.sessionFile, realpathSync(h.node(pendingNode).pi_session_file), 'the pending episode is tied to its transcript path');
157
+ await crashAndRespawn(pendingNode, h.bootCount(pendingNode));
158
+ await h.waitFor(() => agentStartCount(pendingNode) >= pendingStarts + 1, { label: 'replacement re-drove the pending episode' });
159
+ await new Promise((resolve) => setTimeout(resolve, 300));
160
+ assert.equal(agentStartCount(pendingNode), pendingStarts + 1, 'a pending episode re-drives exactly once after broker death');
161
+ const admittedNode = await h.spawnHeadlessChild(parent, 'admitted provider episode', { model: OPENAI_STRONG.spec });
162
+ await h.waitFor(() => agentStartCount(admittedNode) >= 1, { label: 'admitted episode fixture started' });
163
+ const admittedStarts = agentStartCount(admittedNode);
164
+ h.fakeCmd(admittedNode, {
165
+ cmd: 'stop', reason: 'error', errorMessage: 'Managed provider pools are temporarily unavailable.', deadline: Date.now() + 60_000,
166
+ });
167
+ const admittedPending = await h.waitFor(() => readProviderRetryEpisode(admittedNode)?.state === 'pending' ? readProviderRetryEpisode(admittedNode) : null, { label: 'admitted episode pending record persisted' });
168
+ const admittedFault = readFault(admittedNode);
169
+ assert.ok(admittedFault !== null, 'the pending episode retains its retry fault before admission');
170
+ assert.equal(admitProviderRetryEpisode(admittedNode, admittedPending.sessionFile, admittedFault), true, 'the dispatch boundary durably admits the matching episode');
171
+ assert.equal(readProviderRetryEpisode(admittedNode)?.state, 'admitted');
172
+ await crashAndRespawn(admittedNode, h.bootCount(admittedNode));
173
+ await new Promise((resolve) => setTimeout(resolve, 300));
174
+ assert.equal(agentStartCount(admittedNode), admittedStarts, 'an admitted episode is never replayed by replacement startup');
175
+ const invalidatedNode = await h.spawnHeadlessChild(parent, 'invalidated provider episode', { model: OPENAI_STRONG.spec });
176
+ await h.waitFor(() => agentStartCount(invalidatedNode) >= 1, { label: 'invalidated episode fixture started' });
177
+ const invalidatedStarts = agentStartCount(invalidatedNode);
178
+ h.fakeCmd(invalidatedNode, {
179
+ cmd: 'stop', reason: 'error', errorMessage: 'Managed provider pools are temporarily unavailable.', deadline: Date.now() + 60_000,
180
+ });
181
+ await h.waitFor(() => readProviderRetryEpisode(invalidatedNode)?.state === 'pending' ? true : null, { label: 'invalidated episode pending record persisted' });
182
+ const controller = await kit.attach(invalidatedNode, 'controller', 'invalidated-provider-episode');
183
+ controller.send({ type: 'new_session' });
184
+ await controller.waitFrame((frame) => frame.type === 'ack' && frame.for === 'new_session', 'replacement session acknowledged');
185
+ assert.equal(readFault(invalidatedNode), null, 'replacement discards the old daemon-owned auto fault instead of inheriting its anchor');
186
+ await h.waitFor(() => readProviderRetryEpisode(invalidatedNode)?.state === 'invalidated' ? true : null, { label: 'rebind durably invalidated the old episode' });
187
+ controller.close();
188
+ await crashAndRespawn(invalidatedNode, h.bootCount(invalidatedNode));
189
+ await new Promise((resolve) => setTimeout(resolve, 300));
190
+ assert.equal(agentStartCount(invalidatedNode), invalidatedStarts, 'an invalidated rebound-session episode is never replayed by replacement startup');
191
+ const cyclingNode = await h.spawnHeadlessChild(parent, 'fresh-cycle provider episode', { model: OPENAI_STRONG.spec });
192
+ await h.waitFor(() => agentStartCount(cyclingNode) >= 1, { label: 'fresh-cycle episode fixture started' });
193
+ h.fakeCmd(cyclingNode, {
194
+ cmd: 'stop', reason: 'error', errorMessage: 'Managed provider pools are temporarily unavailable.', deadline: Date.now() + 60_000,
195
+ });
196
+ await h.waitFor(() => readProviderRetryEpisode(cyclingNode)?.state === 'pending' ? true : null, { label: 'fresh-cycle episode pending record persisted' });
197
+ const cyclingBoots = h.bootCount(cyclingNode);
198
+ const yieldResult = h.cli(cyclingNode, ['node', 'yield', 'begin a fresh cycle']);
199
+ assert.equal(yieldResult.code, 0, `node yield should succeed\n--stderr--\n${yieldResult.stderr}`);
200
+ const cyclingPid = h.node(cyclingNode)?.pi_pid;
201
+ assert.ok(cyclingPid != null, 'the refresh-pending broker remains live until its physical death');
202
+ process.kill(cyclingPid, 'SIGKILL');
203
+ await h.awaitFleetExit(cyclingNode);
204
+ await h.tick();
205
+ const cyclingBackoff = h.node(cyclingNode);
206
+ assert.ok(cyclingBackoff.respawn_not_before != null, 'the fresh-cycle relaunch receives daemon backoff');
207
+ await h.tick(Date.parse(cyclingBackoff.respawn_not_before) + 1);
208
+ await h.awaitBoot(cyclingNode, { minCount: cyclingBoots + 1 });
209
+ await h.waitFor(() => existsSync(h.brokerSock(cyclingNode)), { label: 'fresh-cycle replacement broker listener bound' });
210
+ assert.equal(readProviderRetryEpisode(cyclingNode)?.state, 'invalidated', 'new-cycle startup invalidates the old transcript episode');
211
+ });
133
212
  test('reload_auth re-drives a parked fatal provider-auth turn exactly once', { timeout: 30_000 }, async () => {
134
213
  const parent = h.spawnRoot('auth reload parent');
135
214
  seedPool('anthropic', false);
@@ -194,7 +194,7 @@ test('an unattended resident with no live obligation completes on the daemon clo
194
194
  assert.equal(h.node(nodeId)?.status, 'done', 'the row is terminal and eligible for history pruning');
195
195
  const wake = readInboxSince(root).find((entry) => entry.from === nodeId && entry.data?.['reason'] === 'child-auto-done');
196
196
  assert.ok(wake, 'the subscribing root is woken when the unattended clock completes the resident');
197
- assert.match(wake.label, /sent no final report/, 'the wake distinguishes automatic completion from a final report');
197
+ assert.match(wake.label, /submitted no final report/, 'the wake distinguishes automatic completion from a final report');
198
198
  await h.tick();
199
199
  assert.equal(h.fleet.has(nodeId), false, 'a supervision tick does not revive completed history');
200
200
  }
@@ -0,0 +1,63 @@
1
+ // Run with: npm run build && node --test --test-timeout=180000 dist/core/__tests__/seam/held-deferred-human-prompt.test.js
2
+ //
3
+ // Regression: Pi loads canvas extensions through Jiti with moduleCache:false,
4
+ // while broker frame dispatch is native ESM. Deferred mail held by the Jiti
5
+ // watcher must still join the next human prompt driven by native broker code.
6
+ import { after, before, test } from 'node:test';
7
+ import assert from 'node:assert/strict';
8
+ import { existsSync, readFileSync } from 'node:fs';
9
+ import { join } from 'node:path';
10
+ import { appendInbox, readCursor } from '../../feed/inbox.js';
11
+ import { updateNode } from '../../canvas/index.js';
12
+ import { reviveNode } from '../../runtime/revive.js';
13
+ import { createHeadlessHarness } from '../helpers/harness.js';
14
+ import { createAttachKit, delay, frameHas, tok } from '../helpers/broker-clients.js';
15
+ let h;
16
+ let root;
17
+ const kit = createAttachKit(() => h);
18
+ before(async () => {
19
+ h = await createHeadlessHarness({ sessionPrefix: 'crtr-held-deferred-prompt' });
20
+ root = h.spawnRoot('held deferred prompt seam root');
21
+ });
22
+ after(async () => {
23
+ kit.closeAll();
24
+ if (h !== undefined)
25
+ await h.dispose();
26
+ });
27
+ test('a Jiti-loaded watcher prepends its held deferred batch to a native broker human prompt', { timeout: 30_000 }, async () => {
28
+ const nodeId = h.fabricateBrokerNode({
29
+ id: 'held-deferred-human-prompt',
30
+ parent: root,
31
+ lifecycle: 'resident',
32
+ status: 'idle',
33
+ });
34
+ updateNode(nodeId, {
35
+ launch: {
36
+ extensions: [],
37
+ tools: [],
38
+ systemPrompt: '',
39
+ env: {
40
+ CRTR_WATCHER_TICK_MS: '20',
41
+ CRTR_WATCHER_DEBOUNCE_MS: '25',
42
+ },
43
+ },
44
+ });
45
+ reviveNode(nodeId, { resume: true, capacity: 'freeze' });
46
+ await h.awaitBoot(nodeId);
47
+ const entry = appendInbox(nodeId, {
48
+ from: 'child',
49
+ tier: 'deferred',
50
+ kind: 'update',
51
+ label: 'held Jiti deferred update',
52
+ });
53
+ await delay(150);
54
+ const customPath = join(h.home, 'nodes', nodeId, 'fake-pi.custom-messages.jsonl');
55
+ assert.equal(existsSync(customPath), false, 'deferred-only mail stays held and does not wake the broker');
56
+ const controller = await kit.attach(nodeId, 'controller', 'held-deferred-prompt-controller');
57
+ const prompt = tok('HELD-DEFERRED-HUMAN-PROMPT');
58
+ controller.send({ type: 'prompt', text: prompt });
59
+ await controller.waitFrame((frame) => frame.type === 'agent_end' && frameHas(frame, prompt), 'the human prompt turn');
60
+ const customMessages = readFileSync(customPath, 'utf8');
61
+ assert.match(customMessages, /held Jiti deferred update/, 'the Jiti extension instance joined its held batch before the native broker prompt');
62
+ assert.equal(readCursor(nodeId), entry.entry_id, 'agent_settled commits the joined handoff');
63
+ });
@@ -0,0 +1,49 @@
1
+ // Run with: npm run build && node --test dist/core/__tests__/seam/self-close-teardown.test.js
2
+ import { after, before, test } from 'node:test';
3
+ import assert from 'node:assert/strict';
4
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
5
+ import { join } from 'node:path';
6
+ import { isPidAlive } from '../../canvas/pid.js';
7
+ import { nodeDir } from '../../canvas/paths.js';
8
+ import { createHarness } from '../helpers/harness.js';
9
+ let h;
10
+ let root;
11
+ before(async () => {
12
+ h = await createHarness({ headless: true, sessionPrefix: 'crtr-self-close' });
13
+ root = h.spawnRoot('self-close teardown root');
14
+ });
15
+ after(async () => {
16
+ if (h !== undefined)
17
+ await h.dispose();
18
+ });
19
+ test('a node closes itself without killing its long-lived calling shell', { timeout: 60_000 }, async () => {
20
+ const id = await h.spawnHeadlessChild(root, 'self-close worker');
21
+ const brokerPid = h.node(id)?.pi_pid;
22
+ assert.ok(brokerPid != null && isPidAlive(brokerPid), 'precondition: worker broker is alive');
23
+ const session = h.node(id)?.pi_session_file;
24
+ assert.ok(session != null, 'worker has a session file');
25
+ writeFileSync(session, JSON.stringify({ type: 'message', message: { role: 'assistant', content: [{ type: 'text', text: 'work' }] } }) + '\n');
26
+ const dir = nodeDir(id);
27
+ const resultPath = join(dir, 'self-close-result');
28
+ const shellPidPath = join(dir, 'self-close-shell.pid');
29
+ let shellPid;
30
+ try {
31
+ h.fakeCmd(id, { cmd: 'self_close' });
32
+ await h.waitFor(() => existsSync(resultPath), { label: 'self-close CLI result' });
33
+ assert.equal(readFileSync(resultPath, 'utf8').trim(), '0', 'the shell received a successful close result');
34
+ shellPid = Number(readFileSync(shellPidPath, 'utf8').trim());
35
+ assert.ok(Number.isInteger(shellPid) && shellPid > 0, 'fixture recorded the long-lived calling shell pid');
36
+ assert.equal(isPidAlive(shellPid), true, 'the calling shell survives the daemon teardown');
37
+ await h.waitFor(() => !isPidAlive(brokerPid), { label: 'self-closing broker exits' });
38
+ assert.equal(isPidAlive(shellPid), true, 'the calling shell remains alive after the broker is gone');
39
+ assert.equal(h.status(id), 'canceled', 'close writes the terminal transition before teardown');
40
+ }
41
+ finally {
42
+ if (shellPid !== undefined) {
43
+ try {
44
+ process.kill(shellPid, 'SIGKILL');
45
+ }
46
+ catch { /* shell already exited */ }
47
+ }
48
+ }
49
+ });
@@ -24,8 +24,8 @@ after(async () => {
24
24
  if (h !== undefined)
25
25
  await h.dispose();
26
26
  });
27
- // 30s (approved compiled-seam cap): three real broker boots plus the exit-policy
28
- // respawn throttle's first ~5s backoff before the successful retry.
27
+ // 30s (approved compiled-seam cap): three real broker boots plus the durable
28
+ // respawn backoff's first ~5s delay before the successful retry.
29
29
  test('yield-message survives one pre-session_start retry and clears on session_start', { timeout: 30_000 }, async () => {
30
30
  const note = `Issue #146 pre-session_start replay ${FAIL_BEFORE_SESSION_START_ONCE} keeps the wake note`;
31
31
  const id = await h.spawnHeadlessChild(root, 'headless worker — yield message transactional retry');
@@ -35,29 +35,37 @@ test('yield-message survives one pre-session_start retry and clears on session_s
35
35
  const rc = h.cli(id, ['node', 'yield', note]);
36
36
  assert.equal(rc.code, 0, `node yield should succeed\n--stderr--\n${rc.stderr}`);
37
37
  // `node yield` arms intent=refresh; the current turn ending is what lets the
38
- // stophook release the old broker. Its real ChildProcess exit enqueues policy
39
- // row 3 and launches the fresh cycle (after any short-lived-exit backoff), so
40
- // there is no liveness tick or grace clock.
38
+ // stophook release the old broker. The supervision tick classifies that real
39
+ // ChildProcess exit, persists its backoff, and launches the fresh cycle once
40
+ // the backoff matures.
41
41
  await h.stop(id);
42
42
  await h.waitFor(() => !isPidAlive(oldPid), { label: 'yielded broker exited before session_start' });
43
+ await h.awaitFleetExit(id);
44
+ await h.tick();
45
+ const initialRefreshBackoff = h.node(id);
46
+ assert.equal(initialRefreshBackoff.respawn_failures, 1, 'the old broker death is durably counted');
47
+ assert.ok(initialRefreshBackoff.respawn_not_before != null, 'the initial refresh retry is durably deferred');
48
+ assert.equal(initialRefreshBackoff.pi_pid, null, 'classification clears the old broker pid');
49
+ await h.tick(Date.parse(initialRefreshBackoff.respawn_not_before) + 1);
43
50
  // The initial refresh attempt launches and fails before session_start,
44
51
  // leaving the note still present in node context.
45
52
  const failedCyclePid = await h.waitFor(() => {
46
53
  const pid = h.node(id)?.pi_pid;
47
54
  return pid != null && pid !== oldPid ? pid : null;
48
- }, { label: 'exit-event refresh attempt recorded its broker pid', timeoutMs: 30_000 });
55
+ }, { label: 'tick-driven refresh attempt recorded its broker pid', timeoutMs: 30_000 });
49
56
  await h.waitFor(() => !isPidAlive(failedCyclePid), { label: 'one-shot cycling broker died before session_start' });
50
57
  // PID death precedes Node's ChildProcess `exit` delivery. Wait through the
51
- // authoritative fleet barrier and its queued exit-policy job so the contender
52
- // below reaches refresh arbitration instead of returning on stale liveness.
53
- // The policy has only armed its backoff timer at this point; no recovery broker
54
- // owns the slot yet.
58
+ // authoritative fleet barrier, then classify the failure once so the contender
59
+ // below reaches refresh arbitration while durable backoff is pending.
55
60
  await h.awaitFleetExit(id);
61
+ await h.tick();
56
62
  const afterFirstExit = h.node(id);
57
63
  assert.equal(h.fleet.has(id), false, 'the failed broker has left the authoritative fleet before focus competes');
58
64
  assert.equal(afterFirstExit?.cycles, baseCycles + 1, 'the first refresh attempt advances cycles');
59
65
  assert.equal(afterFirstExit?.intent, 'refresh', 'the durable refresh intent still confronts the strict-resume contender');
60
66
  assert.equal(afterFirstExit?.cycle_pending, true, 'cycle_pending remains true until session_start confirms');
67
+ assert.equal(afterFirstExit?.respawn_failures, 2, 'the failed refresh attempt is durably counted');
68
+ assert.ok(afterFirstExit?.respawn_not_before != null, 'focus competes while the durable refresh backoff is pending');
61
69
  assert.equal(readYieldMessage(id), note, 'yield note stays present after pre-session_start failure');
62
70
  assert.equal(existsSync(yieldMessagePath(id)), true, 'yield file survives the failed cycle');
63
71
  // A real `surface node focus` on the node right now asks the revive arbiter for
@@ -334,6 +334,10 @@ export interface PruneResult {
334
334
  pruned: PrunedNode[];
335
335
  dryRun: boolean;
336
336
  }
337
+ /** Whether a row may be removed by an automatic collection sweep. */
338
+ export declare function isCollectible(row: NodeRow, opts?: {
339
+ pastTtl?: boolean;
340
+ }): boolean;
337
341
  /** {@link deleteNode}'s result: whether it actually deleted the node. */
338
342
  export interface DeleteNodeResult {
339
343
  deleted: boolean;
@@ -396,24 +400,14 @@ export declare function pruneNodes(opts: {
396
400
  * store rather than per-node loss, and reaps nothing. Returns the reaped ids. */
397
401
  export declare function reapGhostRows(): string[];
398
402
  /** Count-based retention sweep: retain the `maxNodes` MOST-RECENTLY-ACTIVE
399
- * nodes plus every protected node, so the real retained floor is
400
- * `max(maxNodes, protectedCount)`. Bounds the otherwise-unbounded growth the
401
- * TTL sweep can't (a canvas that never crosses the TTL still grows without
403
+ * nodes plus every row the collection authority retains, so the real retained
404
+ * floor is `max(maxNodes, retainedCount)`. Bounds the otherwise-unbounded growth
405
+ * the TTL sweep can't (a canvas that never crosses the TTL still grows without
402
406
  * limit).
403
407
  *
404
- * Protected (never a prune candidate, regardless of recency rank): any
405
- * non-terminal node — status active|idle — REGARDLESS of `pi_pid` liveness,
406
- * or a node with an open managed worktree. A crashed-but-non-terminal row
407
- * (dead pid, status still active/idle) must survive for daemon crash-recovery
408
- * to resume, and an open worktree must retain its node ownership until
409
- * `node worktree close`; neither is eligible for count-based pruning. The caller
410
- * ($CRTR_NODE_ID) is also always protected.
411
- *
412
408
  * Algorithm: order ALL nodes by recency DESC (telemetry.json mtime, falling back
413
- * to `created`); `target = max(maxNodes, protectedCount)`; delete every node
414
- * ranked beyond `target` (0-indexed rank >= target) that is NOT protected and
415
- * NOT the caller. Retained = all protected + the newest terminal nodes up to
416
- * `target`.
409
+ * to `created`); `target = max(maxNodes, retainedCount)`; delete every node
410
+ * ranked beyond `target` (0-indexed rank >= target) that is collectible.
417
411
  *
418
412
  * Mirrors {@link pruneNodes} exactly for the delete (shared `deletePrunedNodes`:
419
413
  * transactioned row sweep + FK edge cascade + best-effort dir removal). `dryRun`
@@ -20,7 +20,7 @@ import { ensureHome, ensureNodeDirs, nodeMetaPath, nodeDir, nodesRoot, jobDir, }
20
20
  * can ever leak onto disk even when a fully-hydrated NodeMeta is handed in. */
21
21
  const IDENTITY_KEYS = [
22
22
  'node_id', 'name', 'description', 'title', 'icon', 'cycles', 'created', 'cwd', 'host_kind', 'profile_id', 'kind', 'mode',
23
- 'lifecycle', 'persona_ack', 'parent', 'spawned_by', 'fork_from', 'fork_source_file', 'review_binding', 'passive_default', 'pi_session_id',
23
+ 'lifecycle', 'persona_ack', 'parent', 'spawned_by', 'creator', 'fork_from', 'fork_source_file', 'review_binding', 'passive_default', 'pi_session_id',
24
24
  'pi_session_file', 'cycle_pending', 'model_override',
25
25
  'launch', 'managed_worktree',
26
26
  ];
@@ -208,12 +208,13 @@ export function canonicalSessionFile(sessionFile) {
208
208
  /** Resolve the crouter node that owns one exact Pi session file. */
209
209
  export function findNodeBySessionFile(sessionFile) {
210
210
  const canonical = canonicalSessionFile(sessionFile);
211
- const matches = openDb()
212
- .prepare('SELECT node_id FROM nodes WHERE pi_session_file = ? ORDER BY created')
213
- .all(canonical);
214
- if (matches.length > 1)
215
- throw new Error(`multiple nodes own pi session file: ${canonical}`);
216
- return matches[0] === undefined ? null : getNode(matches[0].node_id);
211
+ // v36 makes this unique. LIMIT 1 also keeps the lookup total for a canvas
212
+ // opened during repair of an older duplicate projection: the deterministic
213
+ // oldest row remains the owner instead of turning a lookup into a crash.
214
+ const match = openDb()
215
+ .prepare('SELECT node_id FROM nodes WHERE pi_session_file = ? ORDER BY created, node_id LIMIT 1')
216
+ .get(canonical);
217
+ return match === undefined ? null : getNode(match.node_id);
217
218
  }
218
219
  /** The indexed row (from the db) — cheap for queries that don't need full meta. */
219
220
  export function getRow(nodeId) {
@@ -839,6 +840,16 @@ export function rebuildIndex() {
839
840
  function hasOpenManagedWorktree(nodeId) {
840
841
  return getNode(nodeId)?.managed_worktree?.state === 'open';
841
842
  }
843
+ /** Whether a row may be removed by an automatic collection sweep. */
844
+ export function isCollectible(row, opts = {}) {
845
+ if (!['done', 'dead', 'canceled'].includes(row.status)
846
+ || row.pi_pid !== null
847
+ || row.frozen_at !== null
848
+ || hasOpenManagedWorktree(row.node_id)
849
+ || row.node_id === envNodeId())
850
+ return false;
851
+ return opts.pastTtl === true || !['crashed', 'stranded', null].includes(row.terminal_reason ?? null);
852
+ }
842
853
  /** Hard-delete ONE node: drop its row (the edges→nodes FK, ON DELETE CASCADE,
843
854
  * GCs its edges) and remove its on-disk `nodes/<id>/` dir. The single-node
844
855
  * analogue of {@link pruneNodes}, for IMMEDIATE reaping — e.g. an empty node the
@@ -905,14 +916,13 @@ export function pruneNodes(opts) {
905
916
  const selfId = envNodeId() ?? '';
906
917
  const db = openDb();
907
918
  const terminal = db
908
- .prepare(`SELECT node_id, status, created FROM nodes
909
- WHERE status IN ('dead', 'done', 'canceled') AND created < ?
910
- ORDER BY created`)
911
- .all(cutoff).map((r) => ({
912
- node_id: r['node_id'],
913
- status: r['status'],
914
- created: r['created'],
915
- }));
919
+ .prepare(`SELECT * FROM nodes
920
+ WHERE status IN ('dead', 'done', 'canceled') AND created < ?
921
+ ORDER BY created`)
922
+ .all(cutoff)
923
+ .map(rowFrom)
924
+ .filter((row) => isCollectible(row, { pastTtl: true }))
925
+ .map((row) => ({ node_id: row.node_id, status: row.status, created: row.created }));
916
926
  // Stale non-terminal sweep (opt-in): active | idle past the TTL whose process
917
927
  // is provably gone (pi_pid NULL or not alive). Never the caller itself.
918
928
  const stale = !includeStale ? [] : db
@@ -930,8 +940,9 @@ export function pruneNodes(opts) {
930
940
  node_id: r['node_id'],
931
941
  status: r['status'],
932
942
  created: r['created'],
933
- }));
934
- const candidates = [...terminal, ...stale].filter((node) => !hasOpenManagedWorktree(node.node_id));
943
+ }))
944
+ .filter((node) => !hasOpenManagedWorktree(node.node_id));
945
+ const candidates = [...terminal, ...stale];
935
946
  if (dryRun || candidates.length === 0)
936
947
  return { pruned: candidates, dryRun };
937
948
  deletePrunedNodes(candidates);
@@ -1015,50 +1026,33 @@ function recencyMs(nodeId, created) {
1015
1026
  return Number.isNaN(t) ? 0 : t;
1016
1027
  }
1017
1028
  /** Count-based retention sweep: retain the `maxNodes` MOST-RECENTLY-ACTIVE
1018
- * nodes plus every protected node, so the real retained floor is
1019
- * `max(maxNodes, protectedCount)`. Bounds the otherwise-unbounded growth the
1020
- * TTL sweep can't (a canvas that never crosses the TTL still grows without
1029
+ * nodes plus every row the collection authority retains, so the real retained
1030
+ * floor is `max(maxNodes, retainedCount)`. Bounds the otherwise-unbounded growth
1031
+ * the TTL sweep can't (a canvas that never crosses the TTL still grows without
1021
1032
  * limit).
1022
1033
  *
1023
- * Protected (never a prune candidate, regardless of recency rank): any
1024
- * non-terminal node — status active|idle — REGARDLESS of `pi_pid` liveness,
1025
- * or a node with an open managed worktree. A crashed-but-non-terminal row
1026
- * (dead pid, status still active/idle) must survive for daemon crash-recovery
1027
- * to resume, and an open worktree must retain its node ownership until
1028
- * `node worktree close`; neither is eligible for count-based pruning. The caller
1029
- * ($CRTR_NODE_ID) is also always protected.
1030
- *
1031
1034
  * Algorithm: order ALL nodes by recency DESC (telemetry.json mtime, falling back
1032
- * to `created`); `target = max(maxNodes, protectedCount)`; delete every node
1033
- * ranked beyond `target` (0-indexed rank >= target) that is NOT protected and
1034
- * NOT the caller. Retained = all protected + the newest terminal nodes up to
1035
- * `target`.
1035
+ * to `created`); `target = max(maxNodes, retainedCount)`; delete every node
1036
+ * ranked beyond `target` (0-indexed rank >= target) that is collectible.
1036
1037
  *
1037
1038
  * Mirrors {@link pruneNodes} exactly for the delete (shared `deletePrunedNodes`:
1038
1039
  * transactioned row sweep + FK edge cascade + best-effort dir removal). `dryRun`
1039
1040
  * reports the candidate set and deletes NOTHING. */
1040
1041
  export function pruneToLimit(opts) {
1041
1042
  const dryRun = opts.dryRun ?? false;
1042
- const selfId = envNodeId() ?? '';
1043
1043
  const rows = listNodes();
1044
- // Rank ALL nodes by recency DESC, tagging protection as we go. Protection is
1045
- // status-only (active|idle) — NOT gated on pi_pid liveness — so a node whose
1046
- // broker crashed but whose row is still non-terminal stays protected for
1047
- // crash-recovery instead of being hard-deleted here.
1048
1044
  const ranked = rows
1049
- .map((r) => ({
1050
- node_id: r.node_id,
1051
- status: r.status,
1052
- created: r.created,
1053
- recency: recencyMs(r.node_id, r.created),
1054
- protected: r.status === 'active' || r.status === 'idle' || hasOpenManagedWorktree(r.node_id),
1045
+ .map((row) => ({
1046
+ row,
1047
+ recency: recencyMs(row.node_id, row.created),
1048
+ retained: !isCollectible(row),
1055
1049
  }))
1056
1050
  .sort((a, b) => b.recency - a.recency);
1057
- const protectedCount = ranked.filter((r) => r.protected).length;
1058
- const target = Math.max(opts.maxNodes, protectedCount);
1051
+ const retainedCount = ranked.filter((r) => r.retained).length;
1052
+ const target = Math.max(opts.maxNodes, retainedCount);
1059
1053
  const candidates = ranked
1060
- .filter((r, rank) => rank >= target && !r.protected && r.node_id !== selfId)
1061
- .map((r) => ({ node_id: r.node_id, status: r.status, created: r.created }));
1054
+ .filter((r, rank) => rank >= target && !r.retained)
1055
+ .map(({ row }) => ({ node_id: row.node_id, status: row.status, created: row.created }));
1062
1056
  if (dryRun || candidates.length === 0)
1063
1057
  return { pruned: candidates, dryRun };
1064
1058
  deletePrunedNodes(candidates);
@@ -185,14 +185,11 @@ export declare function setCronLastOutputHash(cron_id: string, hash: string): vo
185
185
  * terminal node that stopped without finishing. */
186
186
  export declare function hasPendingCancelOnWakeCron(anchor_node: string): boolean;
187
187
  /** DELETE every cancel-on-wake cron anchored to this node — the deadline rule.
188
- * Two seams call it: reviveNode on a WAKE — every revive except a recovery
189
- * relaunch, which only replaces a dead broker instance and leaves the wait
190
- * open (the wake won the race), and the `finish`/`cancel` lifecycle transitions (the wait ended
191
- * with the node, so its deadline must not fire against a finalized or closed
192
- * row). Standing declarative crons are untouched — only cancel-on-wake rows.
193
- * Kills any in-flight
194
- * run process group first (the cancelCron discipline), under one canvas write
195
- * boundary. Returns the removed cron ids. */
188
+ * A real wake consumes the wait at its delivery boundary or, as a dormant
189
+ * fallback, in reviveNode; recovery replacement is not a wake. Terminal
190
+ * lifecycle transitions also clear the wait. Standing declarative crons are
191
+ * untouched. Kills an in-flight run process group first (the cancelCron
192
+ * discipline), under one canvas write boundary. Returns removed cron ids. */
196
193
  export declare function cancelCronsOnWake(anchor_node: string): string[];
197
194
  /** UPDATE the next fire time — recurrence advancement. The daemon settles a
198
195
  * recurring row to its next slot BEFORE enacting the run (crash-safe). */
@@ -269,14 +269,11 @@ export function hasPendingCancelOnWakeCron(anchor_node) {
269
269
  .get(anchor_node) !== undefined);
270
270
  }
271
271
  /** DELETE every cancel-on-wake cron anchored to this node — the deadline rule.
272
- * Two seams call it: reviveNode on a WAKE — every revive except a recovery
273
- * relaunch, which only replaces a dead broker instance and leaves the wait
274
- * open (the wake won the race), and the `finish`/`cancel` lifecycle transitions (the wait ended
275
- * with the node, so its deadline must not fire against a finalized or closed
276
- * row). Standing declarative crons are untouched — only cancel-on-wake rows.
277
- * Kills any in-flight
278
- * run process group first (the cancelCron discipline), under one canvas write
279
- * boundary. Returns the removed cron ids. */
272
+ * A real wake consumes the wait at its delivery boundary or, as a dormant
273
+ * fallback, in reviveNode; recovery replacement is not a wake. Terminal
274
+ * lifecycle transitions also clear the wait. Standing declarative crons are
275
+ * untouched. Kills an in-flight run process group first (the cancelCron
276
+ * discipline), under one canvas write boundary. Returns removed cron ids. */
280
277
  export function cancelCronsOnWake(anchor_node) {
281
278
  return withCanvasWrite((db) => {
282
279
  const rows = db
@@ -53,6 +53,11 @@ export interface CorpusFilter {
53
53
  * CRTR_NODE_ID via the canvas data layer), falling back to the process cwd
54
54
  * when not run as a node. */
55
55
  export declare function callerCwd(): string;
56
+ /** The history ref of a node's newest report, or null when it left none.
57
+ * Report filenames lead with a compact timestamp, so the lexicographic maximum
58
+ * is the latest one. Callers use this to hand a reader something they can
59
+ * actually open (`crtr canvas history read <ref>`) instead of a bare node id. */
60
+ export declare function newestReportRef(nodeId: string): string | null;
56
61
  /** Build the searchable artifact set for a scope + corpus filter. Node facets
57
62
  * are applied against the db rows first (cheap); only surviving nodes' files
58
63
  * are read. */
@@ -220,7 +220,7 @@ function metaArtifact(row, desc) {
220
220
  loadBody: () => body,
221
221
  };
222
222
  }
223
- /** List the markdown reports a node left, newest filename first. */
223
+ /** List the markdown reports a node left, in directory order. */
224
224
  function listReportFiles(nodeId) {
225
225
  const dir = reportsDir(nodeId);
226
226
  if (!pathExists(dir))
@@ -232,6 +232,17 @@ function listReportFiles(nodeId) {
232
232
  return [];
233
233
  }
234
234
  }
235
+ /** The history ref of a node's newest report, or null when it left none.
236
+ * Report filenames lead with a compact timestamp, so the lexicographic maximum
237
+ * is the latest one. Callers use this to hand a reader something they can
238
+ * actually open (`crtr canvas history read <ref>`) instead of a bare node id. */
239
+ export function newestReportRef(nodeId) {
240
+ let newest = null;
241
+ for (const f of listReportFiles(nodeId))
242
+ if (newest === null || f > newest)
243
+ newest = f;
244
+ return newest === null ? null : `${nodeId}:reports/${newest}`;
245
+ }
235
246
  /** Enumerate every context markdown doc a node left (recursive). */
236
247
  function listDocFiles(nodeId) {
237
248
  const dir = contextDir(nodeId);