@north-light/crouter 0.3.235 → 0.3.237

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 (181) hide show
  1. package/dist/api/client.d.ts +2 -1
  2. package/dist/api/client.js +3 -0
  3. package/dist/api/dto/broker-ops.d.ts +1 -1
  4. package/dist/api/dto/common.d.ts +2 -0
  5. package/dist/api/dto/lifecycle.d.ts +9 -2
  6. package/dist/api/dto/messages.d.ts +5 -0
  7. package/dist/api/dto/nodes.d.ts +14 -5
  8. package/dist/api/dto/worktree.d.ts +10 -0
  9. package/dist/api/routes.d.ts +1 -0
  10. package/dist/api/routes.js +1 -0
  11. package/dist/builtin-memory/00-runtime-base/00-authoring.md +1 -1
  12. package/dist/builtin-memory/internal/agent-shaping.md +1 -1
  13. package/dist/builtin-pi-packages/pi-crtr-extensions/README.md +1 -1
  14. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/sysprompt-window.ts +1 -1
  15. package/dist/clients/attach/render/tool-calls.js +18 -3
  16. package/dist/clients/attach/viewer.js +399 -399
  17. package/dist/clients/inbox/review/__tests__/editor-roundtrip.test.js +6 -0
  18. package/dist/clients/inbox/review/roundtrip.js +1 -1
  19. package/dist/commands/__tests__/node-new.test.js +58 -0
  20. package/dist/commands/memory/lint.js +22 -13
  21. package/dist/commands/memory/shared.js +1 -1
  22. package/dist/commands/node/create.d.ts +1 -1
  23. package/dist/commands/node/create.js +4 -1
  24. package/dist/commands/node/lifecycle.js +2 -2
  25. package/dist/commands/node-worktree.js +39 -8
  26. package/dist/commands/revive.js +11 -2
  27. package/dist/commands/sys/context/admin/actions.d.ts +48 -0
  28. package/dist/commands/sys/context/admin/actions.js +246 -0
  29. package/dist/commands/sys/context/admin/axes-panel.d.ts +21 -0
  30. package/dist/commands/sys/context/admin/axes-panel.js +117 -0
  31. package/dist/commands/sys/context/admin/detail-panel.d.ts +53 -0
  32. package/dist/commands/sys/context/admin/detail-panel.js +178 -0
  33. package/dist/commands/sys/context/admin/docs-panel.d.ts +45 -0
  34. package/dist/commands/sys/context/admin/docs-panel.js +186 -0
  35. package/dist/commands/sys/context/admin/model.d.ts +75 -0
  36. package/dist/commands/sys/context/admin/model.js +209 -0
  37. package/dist/commands/sys/context/admin/shell.d.ts +82 -0
  38. package/dist/commands/sys/context/admin/shell.js +669 -0
  39. package/dist/commands/sys/context/admin.d.ts +1 -0
  40. package/dist/commands/sys/context/admin.js +124 -0
  41. package/dist/commands/sys/context/doc.d.ts +1 -0
  42. package/dist/commands/sys/context/doc.js +196 -0
  43. package/dist/commands/sys/context/prompt-review.d.ts +1 -0
  44. package/dist/commands/sys/{prompt-review.js → context/prompt-review.js} +10 -10
  45. package/dist/commands/sys/context/resolve.d.ts +55 -0
  46. package/dist/commands/sys/context/resolve.js +428 -0
  47. package/dist/commands/sys/context/sysprompt.d.ts +1 -0
  48. package/dist/commands/sys/{sysprompt.js → context/sysprompt.js} +11 -11
  49. package/dist/commands/sys/context.d.ts +2 -0
  50. package/dist/commands/sys/context.js +20 -0
  51. package/dist/commands/sys.js +3 -4
  52. package/dist/core/__tests__/canvas-inbox-watcher-naming.test.d.ts +1 -0
  53. package/dist/core/__tests__/canvas-inbox-watcher-naming.test.js +258 -0
  54. package/dist/core/__tests__/child-death-wake.test.js +38 -22
  55. package/dist/core/__tests__/cron-self-cancel-delivery.test.d.ts +1 -0
  56. package/dist/core/__tests__/cron-self-cancel-delivery.test.js +72 -0
  57. package/dist/core/__tests__/daemon-boot.test.js +40 -28
  58. package/dist/core/__tests__/helpers/harness.js +1 -0
  59. package/dist/core/__tests__/human-deliver.test.js +1 -1
  60. package/dist/core/__tests__/human-node-not-supervised.test.js +10 -15
  61. package/dist/core/__tests__/integration/revive.test.js +12 -12
  62. package/dist/core/__tests__/integration/spawn-root.test.js +6 -0
  63. package/dist/core/__tests__/integration/worktree-reap.test.js +32 -0
  64. package/dist/core/__tests__/lifecycle-terminal-reasons.test.d.ts +1 -0
  65. package/dist/core/__tests__/lifecycle-terminal-reasons.test.js +61 -0
  66. package/dist/core/__tests__/migration.test.js +38 -1
  67. package/dist/core/__tests__/push-final-guard.test.js +1 -1
  68. package/dist/core/__tests__/revive-capacity.test.d.ts +1 -0
  69. package/dist/core/__tests__/revive-capacity.test.js +127 -0
  70. package/dist/core/__tests__/revive-parked-fresh.test.js +7 -7
  71. package/dist/core/__tests__/seam/broker-cap-freeze.test.d.ts +1 -0
  72. package/dist/core/__tests__/seam/broker-cap-freeze.test.js +81 -0
  73. package/dist/core/__tests__/seam/dormancy-release.test.js +76 -7
  74. package/dist/core/__tests__/seam/yield-refresh-transaction.test.js +1 -1
  75. package/dist/core/canvas/browse/render.js +11 -3
  76. package/dist/core/canvas/canvas.d.ts +15 -3
  77. package/dist/core/canvas/canvas.js +52 -16
  78. package/dist/core/canvas/migrations.js +21 -0
  79. package/dist/core/canvas/nav-render.js +3 -1
  80. package/dist/core/canvas/remote-canvas-source.js +2 -0
  81. package/dist/core/canvas/render-source.d.ts +3 -1
  82. package/dist/core/canvas/render-source.js +7 -3
  83. package/dist/core/canvas/status-glyph.d.ts +10 -1
  84. package/dist/core/canvas/status-glyph.js +19 -3
  85. package/dist/core/canvas/types.d.ts +22 -3
  86. package/dist/core/feed/feed.js +1 -1
  87. package/dist/core/human/feedback-companion.js +5 -2
  88. package/dist/core/memory/lint.d.ts +19 -0
  89. package/dist/core/memory/lint.js +97 -0
  90. package/dist/core/memory-resolver.d.ts +4 -0
  91. package/dist/core/memory-resolver.js +7 -7
  92. package/dist/core/preview-registry.js +4 -4
  93. package/dist/core/review/realize.js +4 -2
  94. package/dist/core/runtime/close.js +1 -1
  95. package/dist/core/runtime/fleet.d.ts +29 -1
  96. package/dist/core/runtime/fleet.js +23 -0
  97. package/dist/core/runtime/host.js +32 -11
  98. package/dist/core/runtime/launch-prompt.d.ts +1 -1
  99. package/dist/core/runtime/launch-prompt.js +2 -2
  100. package/dist/core/runtime/launch-target.js +1 -1
  101. package/dist/core/runtime/lifecycle.d.ts +4 -2
  102. package/dist/core/runtime/lifecycle.js +12 -1
  103. package/dist/core/runtime/memory.js +1 -1
  104. package/dist/core/runtime/reopen.js +10 -1
  105. package/dist/core/runtime/reset.js +1 -1
  106. package/dist/core/runtime/revive-all.d.ts +3 -0
  107. package/dist/core/runtime/revive-all.js +10 -4
  108. package/dist/core/runtime/revive.d.ts +12 -0
  109. package/dist/core/runtime/revive.js +62 -3
  110. package/dist/core/runtime/spawn.d.ts +9 -13
  111. package/dist/core/runtime/spawn.js +40 -17
  112. package/dist/core/substrate/frontmatter-validation.js +1 -1
  113. package/dist/core/substrate/index.d.ts +6 -3
  114. package/dist/core/substrate/index.js +4 -3
  115. package/dist/core/substrate/on-read.d.ts +0 -8
  116. package/dist/core/substrate/on-read.js +45 -124
  117. package/dist/core/substrate/plan.d.ts +94 -0
  118. package/dist/core/substrate/plan.js +266 -0
  119. package/dist/core/substrate/render.d.ts +5 -6
  120. package/dist/core/substrate/render.js +76 -129
  121. package/dist/core/substrate/schema.d.ts +1 -6
  122. package/dist/core/substrate/schema.js +1 -15
  123. package/dist/core/substrate/session-cache.d.ts +2 -10
  124. package/dist/core/substrate/session-cache.js +9 -47
  125. package/dist/core/substrate/subject-fields.d.ts +1 -4
  126. package/dist/core/substrate/subject-fields.js +2 -5
  127. package/dist/core/substrate/surface-match.d.ts +37 -17
  128. package/dist/core/substrate/surface-match.js +44 -53
  129. package/dist/core/tui/page-host.d.ts +6 -0
  130. package/dist/core/tui/page-host.js +12 -3
  131. package/dist/core/worktree.d.ts +8 -0
  132. package/dist/core/worktree.js +65 -0
  133. package/dist/daemon/__tests__/reconciler-signature.test.js +3 -2
  134. package/dist/daemon/__tests__/thaw-order.test.d.ts +1 -0
  135. package/dist/daemon/__tests__/thaw-order.test.js +55 -0
  136. package/dist/daemon/api/__tests__/canvas-snapshot-fields.test.js +6 -0
  137. package/dist/daemon/api/__tests__/node-create-description.test.js +6 -0
  138. package/dist/daemon/api/__tests__/profile-launch-gates.test.js +1 -1
  139. package/dist/daemon/api/bridge.js +5 -1
  140. package/dist/daemon/api/handlers/attach.js +5 -1
  141. package/dist/daemon/api/handlers/bash-jobs.js +1 -1
  142. package/dist/daemon/api/handlers/messages.js +10 -2
  143. package/dist/daemon/api/handlers/nodes.js +8 -4
  144. package/dist/daemon/api/handlers/worktree.js +10 -1
  145. package/dist/daemon/api/map.js +7 -0
  146. package/dist/daemon/companion-retire.js +1 -1
  147. package/dist/daemon/cron/passes.d.ts +0 -5
  148. package/dist/daemon/cron/passes.js +0 -20
  149. package/dist/daemon/cron/sinks.js +1 -1
  150. package/dist/daemon/cron-run.d.ts +5 -6
  151. package/dist/daemon/cron-run.js +7 -16
  152. package/dist/daemon/crtrd.d.ts +0 -10
  153. package/dist/daemon/crtrd.js +23 -61
  154. package/dist/daemon/fleet.d.ts +10 -7
  155. package/dist/daemon/fleet.js +71 -12
  156. package/dist/daemon/messaging/node-message.js +3 -1
  157. package/dist/daemon/profile-delete.js +1 -1
  158. package/dist/daemon/reconcilers/broker-supervision.d.ts +26 -9
  159. package/dist/daemon/reconcilers/broker-supervision.js +39 -52
  160. package/dist/daemon/reconcilers/controller-death.d.ts +7 -0
  161. package/dist/daemon/reconcilers/controller-death.js +26 -0
  162. package/dist/daemon/reconcilers/cron-lane.d.ts +0 -3
  163. package/dist/daemon/reconcilers/cron-lane.js +0 -3
  164. package/dist/daemon/reconcilers/live-obligation.js +3 -1
  165. package/dist/daemon/reconcilers/node-lifecycle/freeze-lane.d.ts +53 -0
  166. package/dist/daemon/reconcilers/node-lifecycle/freeze-lane.js +170 -0
  167. package/dist/daemon/reconcilers/node-lifecycle/respawn-policy.d.ts +5 -0
  168. package/dist/daemon/reconcilers/node-lifecycle/respawn-policy.js +16 -0
  169. package/dist/daemon/reconcilers/node-lifecycle/terminating.d.ts +4 -0
  170. package/dist/daemon/reconcilers/node-lifecycle/terminating.js +13 -0
  171. package/dist/daemon/reconcilers/node-lifecycle/tick.d.ts +26 -0
  172. package/dist/daemon/reconcilers/node-lifecycle/tick.js +140 -0
  173. package/dist/pi-extensions/canvas-inbox-watcher.js +12 -0
  174. package/package.json +1 -1
  175. package/runtime.lock.json +2 -2
  176. package/dist/commands/sys/prompt-review.d.ts +0 -1
  177. package/dist/commands/sys/sysprompt.d.ts +0 -1
  178. package/dist/core/__tests__/cron-broker-capacity.test.js +0 -119
  179. package/dist/daemon/reconcilers/dormant-inbox.d.ts +0 -18
  180. package/dist/daemon/reconcilers/dormant-inbox.js +0 -157
  181. /package/dist/{core/__tests__/cron-broker-capacity.test.d.ts → commands/__tests__/node-new.test.d.ts} +0 -0
@@ -0,0 +1,258 @@
1
+ // Tests for the naming/goal-capture gap on the durable-inbox delivery path.
2
+ //
3
+ // Run with: node --conditions=crtr-src --import tsx/esm --test src/core/__tests__/canvas-inbox-watcher-naming.test.ts
4
+ //
5
+ // A cold-booting child's first message falls to the durable inbox and pi's
6
+ // `input` event never fires for it, so canvas-goal-capture's first-message
7
+ // hooks (naming, goal capture) never run for it. The inbox watcher now mirrors
8
+ // both on its own `kind: 'human'` visible delivery. This pins that mirror:
9
+ // a human delivery reaches the naming pipeline and captures the goal, a
10
+ // card/envelope delivery touches neither, and an already-described node's
11
+ // naming call still fetches state (the daemon owns the compare-and-set) but
12
+ // never issues a commit.
13
+ //
14
+ // The naming attempt is observed at the daemon socket exactly as in
15
+ // canvas-goal-capture-envelope.test.ts: generateAndCommitInitialName starts by
16
+ // asking crtrd for the node's projection (GET .../broker/extension-state), so
17
+ // a request for a node id is proof the pipeline ran for it. An unnamed node's
18
+ // GET is answered with an error — same as that file — so the test never
19
+ // reaches the real headless model call; a "described" node's GET succeeds with
20
+ // a non-empty description, which is enough to prove the guard short-circuits
21
+ // before any commit (POST .../broker/generated-name) is sent.
22
+ import { test, before, after, afterEach } from 'node:test';
23
+ import assert from 'node:assert/strict';
24
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
25
+ import { createServer } from 'node:net';
26
+ import { tmpdir } from 'node:os';
27
+ import { join } from 'node:path';
28
+ import registerCanvasInboxWatcher from '../../pi-extensions/canvas-inbox-watcher.js';
29
+ import { createNode } from '../canvas/canvas.js';
30
+ import { closeDb } from '../canvas/db.js';
31
+ import { apiSocketPath } from '../canvas/paths.js';
32
+ import { appendInbox } from '../feed/inbox.js';
33
+ const TICK_MS = 20;
34
+ const DEBOUNCE_MS = 25;
35
+ let home;
36
+ let socket;
37
+ const getRequests = [];
38
+ const postRequests = [];
39
+ /** node_id -> description the stub answers `extension-state` with. Absent =
40
+ * the GET errors (mirrors canvas-goal-capture-envelope.test.ts: proof of
41
+ * attempt without a real model call). Present (even '') = the GET succeeds. */
42
+ const descriptions = new Map();
43
+ function node(id) {
44
+ return {
45
+ node_id: id,
46
+ name: id,
47
+ created: new Date().toISOString(),
48
+ cwd: '/tmp/work',
49
+ kind: 'general',
50
+ mode: 'base',
51
+ lifecycle: 'terminal',
52
+ status: 'active',
53
+ };
54
+ }
55
+ function initialPromptPath(id) {
56
+ return join(home, 'nodes', id, 'context', 'initial-prompt.md');
57
+ }
58
+ function respond(connection, status, body) {
59
+ const json = JSON.stringify(body);
60
+ connection.end(`HTTP/1.1 ${status}\r\n`
61
+ + 'Content-Type: application/json\r\n'
62
+ + `Content-Length: ${Buffer.byteLength(json)}\r\n`
63
+ + 'Connection: close\r\n\r\n'
64
+ + json);
65
+ }
66
+ function nodeIdFromPath(path) {
67
+ return path.split('/nodes/')[1]?.split('/')[0] ?? '';
68
+ }
69
+ /** Every node's watcher fetches `extension-state` ONCE at session_start (to
70
+ * seed report-node names for card rendering) regardless of what it later
71
+ * delivers, so a bare presence check can't tell a startup fetch from a
72
+ * naming-triggered one. Naming issues a SECOND, independent fetch for the
73
+ * same node id — count occurrences instead of checking presence. */
74
+ function getRequestCount(nodeId) {
75
+ return getRequests.filter((line) => line.includes(`/nodes/${nodeId}/`)).length;
76
+ }
77
+ function makeFakePi() {
78
+ const handlers = {};
79
+ return {
80
+ injected: [],
81
+ sentMessages: [],
82
+ on(e, h) { handlers[e] = h; },
83
+ sendUserMessage(content, options) { this.injected.push({ content, deliverAs: options?.deliverAs }); },
84
+ sendMessage(message) { this.sentMessages.push(message); },
85
+ fire(e, ev, ctx) { handlers[e]?.(ev, ctx); },
86
+ };
87
+ }
88
+ const wait = (ms) => new Promise((r) => setTimeout(r, ms));
89
+ async function waitFor(predicate, timeoutMs = 3000, stepMs = 10) {
90
+ const deadline = Date.now() + timeoutMs;
91
+ while (Date.now() < deadline) {
92
+ if (predicate())
93
+ return;
94
+ await wait(stepMs);
95
+ }
96
+ }
97
+ const disposers = [];
98
+ function registerWatcher(pi) {
99
+ const dispose = registerCanvasInboxWatcher(pi);
100
+ pi.fire('session_start', { type: 'session_start' }, undefined);
101
+ return dispose;
102
+ }
103
+ /** Point CRTR_HOME/CRTR_NODE_ID at this node and make sure it exists in canvas.db. */
104
+ function freshNode(id) {
105
+ process.env['CRTR_NODE_ID'] = id;
106
+ createNode(node(id));
107
+ }
108
+ let origHome;
109
+ let origNode;
110
+ let origTick;
111
+ let origDebounce;
112
+ before(async () => {
113
+ origHome = process.env['CRTR_HOME'];
114
+ origNode = process.env['CRTR_NODE_ID'];
115
+ origTick = process.env['CRTR_WATCHER_TICK_MS'];
116
+ origDebounce = process.env['CRTR_WATCHER_DEBOUNCE_MS'];
117
+ home = mkdtempSync(join(tmpdir(), 'crtr-inbox-naming-'));
118
+ mkdirSync(join(home, 'nodes'), { recursive: true });
119
+ process.env['CRTR_HOME'] = home;
120
+ process.env['CRTR_WATCHER_TICK_MS'] = String(TICK_MS);
121
+ process.env['CRTR_WATCHER_DEBOUNCE_MS'] = String(DEBOUNCE_MS);
122
+ socket = createServer((connection) => {
123
+ connection.on('data', (chunk) => {
124
+ const raw = chunk.toString('utf8');
125
+ const requestLine = raw.split('\r\n')[0] ?? '';
126
+ const [method, path] = requestLine.split(' ');
127
+ if (method === 'GET' && path?.includes('/broker/extension-state')) {
128
+ getRequests.push(raw);
129
+ const nodeId = nodeIdFromPath(path);
130
+ if (!descriptions.has(nodeId)) {
131
+ respond(connection, '500 Internal Server Error', { error: { code: 'internal', message: 'stub daemon' } });
132
+ return;
133
+ }
134
+ respond(connection, '200 OK', {
135
+ node: {
136
+ node_id: nodeId, name: nodeId, description: descriptions.get(nodeId), kind: 'general',
137
+ mode: 'base', lifecycle: 'terminal', status: 'active', cwd: '/tmp/work',
138
+ parent: null, fork_from: null, profile_id: null, intent: null, created: new Date().toISOString(),
139
+ },
140
+ warm_spare: false, ancestors: [], children: [], fork_source: null,
141
+ subject: { kind: 'general', mode: 'base', lifecycle: 'terminal', hasManager: false, cwd: '/tmp/work', scope: 'project', orchestration: { depth: 0 }, profile: null },
142
+ report_nodes: [],
143
+ });
144
+ return;
145
+ }
146
+ if (method === 'POST' && path?.includes('/broker/generated-name')) {
147
+ postRequests.push(raw);
148
+ respond(connection, '200 OK', { applied: false });
149
+ return;
150
+ }
151
+ // Everything else (e.g. advanceBrokerInboxCursor) — a harmless error is
152
+ // fine: the watcher retries cursor commits later and never blocks
153
+ // visible delivery on them.
154
+ respond(connection, '500 Internal Server Error', { error: { code: 'internal', message: 'stub daemon' } });
155
+ });
156
+ });
157
+ await new Promise((resolve) => socket.listen(apiSocketPath(), resolve));
158
+ });
159
+ afterEach(() => {
160
+ while (disposers.length > 0)
161
+ disposers.pop()();
162
+ });
163
+ after(async () => {
164
+ closeDb();
165
+ await new Promise((resolve) => socket.close(() => resolve()));
166
+ rmSync(home, { recursive: true, force: true });
167
+ if (origHome === undefined)
168
+ delete process.env['CRTR_HOME'];
169
+ else
170
+ process.env['CRTR_HOME'] = origHome;
171
+ if (origNode === undefined)
172
+ delete process.env['CRTR_NODE_ID'];
173
+ else
174
+ process.env['CRTR_NODE_ID'] = origNode;
175
+ if (origTick === undefined)
176
+ delete process.env['CRTR_WATCHER_TICK_MS'];
177
+ else
178
+ process.env['CRTR_WATCHER_TICK_MS'] = origTick;
179
+ if (origDebounce === undefined)
180
+ delete process.env['CRTR_WATCHER_DEBOUNCE_MS'];
181
+ else
182
+ process.env['CRTR_WATCHER_DEBOUNCE_MS'] = origDebounce;
183
+ });
184
+ test('an inbox-delivered human first message reaches the naming pipeline and captures the goal', async () => {
185
+ freshNode('inbox-human-unnamed');
186
+ const pi = makeFakePi();
187
+ disposers.push(registerWatcher(pi));
188
+ await wait(TICK_MS + 100);
189
+ appendInbox('inbox-human-unnamed', {
190
+ from: 'human',
191
+ tier: 'normal',
192
+ kind: 'message',
193
+ label: 'build me a receipt tracker',
194
+ data: { body: 'build me a receipt tracker' },
195
+ });
196
+ await waitFor(() => pi.injected.length >= 1);
197
+ assert.equal(pi.injected[0].content, 'build me a receipt tracker', 'the human body still rides the visible digest verbatim');
198
+ // One GET at session_start (report-node seed) plus one from the naming
199
+ // pipeline (generateAndCommitInitialName's own state fetch) = 2.
200
+ await waitFor(() => getRequestCount('inbox-human-unnamed') >= 2);
201
+ assert.equal(getRequestCount('inbox-human-unnamed'), 2, 'an inbox-delivered human message reaches the same naming pipeline a live message does');
202
+ assert.equal(readFileSync(initialPromptPath('inbox-human-unnamed'), 'utf8').trim(), 'build me a receipt tracker', 'an inbox-delivered first message is also captured as the goal, for fresh-revive recovery');
203
+ });
204
+ test('a card/envelope delivery never reaches naming or goal capture', async () => {
205
+ freshNode('inbox-card-only');
206
+ const pi = makeFakePi();
207
+ const dispose = registerWatcher(pi);
208
+ await wait(TICK_MS + 100);
209
+ appendInbox('inbox-card-only', { from: 'child-9', tier: 'normal', kind: 'update', label: 'phase one done' });
210
+ await waitFor(() => pi.injected.length >= 1, 3000);
211
+ assert.equal(pi.injected.length, 1, 'the card still reaches the digest as a visible send');
212
+ dispose(); // release ownership of this node id before a second watcher binds a new one
213
+ // Clock: a human message to a DIFFERENT node whose naming pipeline we know
214
+ // fires (proven above). Once its GET lands, the card node's window to fire
215
+ // its own GET has already passed — the watcher fires naming synchronously
216
+ // off the same delivery cycle, not on a later timer.
217
+ freshNode('inbox-card-clock');
218
+ const clockPi = makeFakePi();
219
+ disposers.push(registerWatcher(clockPi));
220
+ await wait(TICK_MS + 100);
221
+ appendInbox('inbox-card-clock', { from: 'human', tier: 'normal', kind: 'message', label: 'clock', data: { body: 'clock' } });
222
+ await waitFor(() => getRequests.some((line) => line.includes('inbox-card-clock')));
223
+ // Exactly the one startup GET (report-node seed) — no second, naming-triggered fetch.
224
+ assert.equal(getRequestCount('inbox-card-only'), 1, 'a card/envelope delivery never asks the naming pipeline to name the node');
225
+ assert.equal(existsSync(initialPromptPath('inbox-card-only')), false, 'a card/envelope delivery never seeds a goal file either');
226
+ });
227
+ test('an already-described node fetches state but never commits a new name', async () => {
228
+ descriptions.set('inbox-already-named', 'existing-description');
229
+ freshNode('inbox-already-named');
230
+ const pi = makeFakePi();
231
+ const dispose = registerWatcher(pi);
232
+ await wait(TICK_MS + 100);
233
+ appendInbox('inbox-already-named', {
234
+ from: 'human',
235
+ tier: 'normal',
236
+ kind: 'message',
237
+ label: 'second thing to do',
238
+ data: { body: 'second thing to do' },
239
+ });
240
+ await waitFor(() => pi.injected.length >= 1);
241
+ // One GET at session_start plus one from the naming pipeline's own state
242
+ // fetch: the guard is checked AFTER this fetch resolves, not before it.
243
+ await waitFor(() => getRequestCount('inbox-already-named') >= 2);
244
+ assert.equal(getRequestCount('inbox-already-named'), 2, 'the naming pipeline still asks the daemon for the current projection');
245
+ dispose(); // release ownership of this node id before a second watcher binds a new one
246
+ // Clock: a second node's naming pipeline fires synchronously off the same
247
+ // delivery cycle (proven in the first test above). Once its GET has landed,
248
+ // this node's guard — checked in the very same .then() before any commit
249
+ // call — has already resolved one way or the other.
250
+ freshNode('inbox-commit-clock');
251
+ const clockPi = makeFakePi();
252
+ disposers.push(registerWatcher(clockPi));
253
+ await wait(TICK_MS + 100);
254
+ appendInbox('inbox-commit-clock', { from: 'human', tier: 'normal', kind: 'message', label: 'clock', data: { body: 'clock' } });
255
+ await waitFor(() => getRequests.some((line) => line.includes('inbox-commit-clock')));
256
+ await wait(DEBOUNCE_MS + 100);
257
+ assert.equal(postRequests.some((line) => line.includes('inbox-already-named')), false, 'an already-described node is untouched: the guard short-circuits before any generated-name commit');
258
+ });
@@ -38,7 +38,9 @@ import { closeNode } from '../runtime/close.js';
38
38
  import { superviseTick } from '../../daemon/crtrd.js';
39
39
  import { bindTestFleet } from './helpers/fleet.js';
40
40
  import { resetFleetForTests } from '../runtime/fleet.js';
41
+ import { headlessBrokerHost } from '../runtime/host.js';
41
42
  let home;
43
+ let tf;
42
44
  function node(id, over = {}) {
43
45
  return {
44
46
  node_id: id,
@@ -96,9 +98,9 @@ const wakeFromChild = (parent, child) => readInboxSince(parent).find((e) => e.fr
96
98
  before(() => {
97
99
  home = mkdtempSync(join(tmpdir(), 'crtr-child-death-wake-'));
98
100
  process.env['CRTR_HOME'] = home;
99
- // superviseTick requires a bound fleet (D-2). Empty is the point: none of
100
- // these fabricated rows has a live broker, so the tick must leave them alone.
101
- bindTestFleet();
101
+ // superviseTick requires a bound fleet (D-2). It starts empty: none of these
102
+ // fabricated rows has a live broker.
103
+ tf = bindTestFleet();
102
104
  });
103
105
  beforeEach(() => {
104
106
  closeDb();
@@ -111,27 +113,41 @@ after(() => {
111
113
  resetFleetForTests();
112
114
  });
113
115
  // BROKER CUT — exit-event authority REPLACES daemon pane-gone reaping. A booted
114
- // child whose engine pid is dead (and whose viewer pane is gone) is REVIVABLE,
116
+ // child whose engine pid is dead (and whose viewer pane is gone) is RECOVERED,
115
117
  // not reaped, and the daemon's tick fans NO wake to the dormant parent. This
116
118
  // single case subsumes the four deleted scenarios (crash / quiet-finalize /
117
- // awaiting a live grandchild / pending self-wake): with pane state ignored,
118
- // surfaceChildDeath gone, and the tick running no liveness pass at all (dead-
119
- // node recovery rides real exit events), the daemon treats them all identically
120
- // — no reap, no liveness wake — so the old CRUX (no spurious wake on healthy
121
- // dormancy) holds by construction.
122
- test('exit-event authority: a dead-engine booted child is revivable, NOT reaped, and the tick fans NO wake to its dormant parent', async () => {
123
- inboxWaitingParent('PARENT');
124
- deadEngineChild('CHILD');
125
- subscribe('PARENT', 'CHILD', true); // the spawn-time spine edge
126
- await superviseTick();
127
- // The tick has no liveness pass: a stale recorded pid is metadata, not a
128
- // death signal — the row stays revivable (recovery would ride a real exit
129
- // event), never reaped to dead/done/idle.
130
- assert.equal(getNode('CHILD').status, 'active', 'CHILD stays revivable, NOT reaped');
131
- // surfaceChildDeath is deleted: the daemon raises no liveness wake. The
132
- // doctrine wake now rides the child's own `push final` (or close.ts), so a
133
- // purely-inbox-waiting parent is never spuriously woken on dormancy/crash.
134
- assert.equal(readInboxSince('PARENT').length, 0, 'PARENT inbox EMPTY — no daemon liveness wake');
119
+ // awaiting a live grandchild / pending self-wake): with pane state ignored and
120
+ // surfaceChildDeath gone, the daemon treats them all identically — no reap, no
121
+ // liveness wake — so the old CRUX (no spurious wake on healthy dormancy) holds
122
+ // by construction. The node-lifecycle tick relaunches the row through the same
123
+ // policy table its exit event would have used (D-12); that recovery is silent.
124
+ test('exit-event authority: a dead-engine booted child is recovered, NOT reaped, and the tick fans NO wake to its dormant parent', async () => {
125
+ const originalLaunch = headlessBrokerHost.launch;
126
+ const launches = [];
127
+ headlessBrokerHost.launch = (nodeId) => {
128
+ launches.push(nodeId);
129
+ const handle = { pid: 424_242, exited: new Promise(() => { }) };
130
+ tf.fleet.register(nodeId, handle); // the real host contract: registration at the launch seam
131
+ return handle;
132
+ };
133
+ try {
134
+ inboxWaitingParent('PARENT');
135
+ deadEngineChild('CHILD', { cwd: home }); // a cwd that exists — reviveNode's preflight refuses a missing one
136
+ subscribe('PARENT', 'CHILD', true); // the spawn-time spine edge
137
+ await superviseTick();
138
+ // A stale recorded pid is metadata, not a verdict: the row is relaunched on
139
+ // its saved session, never reaped to dead/done/idle.
140
+ assert.deepEqual(launches, ['CHILD'], 'the tick recovers the dead engine through the policy table');
141
+ assert.equal(getNode('CHILD').status, 'active', 'CHILD is recovered, NOT reaped');
142
+ // surfaceChildDeath is deleted: the daemon raises no liveness wake. The
143
+ // doctrine wake now rides the child's own `push final` (or close.ts), so a
144
+ // purely-inbox-waiting parent is never spuriously woken on dormancy/crash.
145
+ assert.equal(readInboxSince('PARENT').length, 0, 'PARENT inbox EMPTY — no daemon liveness wake');
146
+ }
147
+ finally {
148
+ headlessBrokerHost.launch = originalLaunch;
149
+ tf.fleet.forget('CHILD');
150
+ }
135
151
  });
136
152
  test('node close of a child wakes its SURVIVING manager (the parent outside the closing set)', async () => {
137
153
  inboxWaitingParent('PARENT');
@@ -0,0 +1,72 @@
1
+ // Run with: node --import tsx/esm --test src/core/__tests__/cron-self-cancel-delivery.test.ts
2
+ //
3
+ // A cron whose own command cancels its row mid-run must still deliver the
4
+ // output that run produced: cancellation retires the schedule, not the run in
5
+ // flight.
6
+ import { after, afterEach, before, test } from 'node:test';
7
+ import assert from 'node:assert/strict';
8
+ import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
9
+ import { tmpdir } from 'node:os';
10
+ import { join } from 'node:path';
11
+ import { setTimeout as delay } from 'node:timers/promises';
12
+ import { closeDb } from '../canvas/db.js';
13
+ import { armCron, cancelCron, getCron } from '../canvas/crons.js';
14
+ import { executeCron, markSelfCanceledCronRun } from '../../daemon/cron-run.js';
15
+ let home;
16
+ let realHome;
17
+ function arm(overrides) {
18
+ armCron({
19
+ name: overrides.cron_id,
20
+ created_by: null,
21
+ fire_at: new Date(Date.now() - 1_000).toISOString(),
22
+ recur: null,
23
+ tz: null,
24
+ expires_at: null,
25
+ anchor_node: null,
26
+ cancel_on_wake: false,
27
+ cwd: home,
28
+ env_json: null,
29
+ profile: null,
30
+ scope: 'profile',
31
+ run_timeout_s: 30,
32
+ overlap: 'skip',
33
+ on_output: 'silent',
34
+ sink: '{}',
35
+ tier: 'normal',
36
+ ...overrides,
37
+ });
38
+ }
39
+ before(() => {
40
+ home = mkdtempSync(join(tmpdir(), 'crtr-cron-broker-capacity-'));
41
+ realHome = process.env['CRTR_HOME'];
42
+ process.env['CRTR_HOME'] = home;
43
+ });
44
+ afterEach(() => {
45
+ closeDb();
46
+ });
47
+ after(() => {
48
+ closeDb();
49
+ rmSync(home, { recursive: true, force: true });
50
+ if (realHome === undefined)
51
+ delete process.env['CRTR_HOME'];
52
+ else
53
+ process.env['CRTR_HOME'] = realHome;
54
+ });
55
+ test('an on-change cron that cancels itself still delivers its final output', async () => {
56
+ const runIdPath = join(home, 'self-cancel-run-id');
57
+ arm({
58
+ cron_id: 'self-cancel-delivery',
59
+ command: `printf '%s' "$CRTR_RUN_ID" > '${runIdPath}'; echo settled; sleep 0.1`,
60
+ recur: JSON.stringify({ every: '1h' }),
61
+ on_output: 'on-change',
62
+ sink: JSON.stringify({ human: true }),
63
+ });
64
+ const run = executeCron(getCron('self-cancel-delivery'), { outOfBand: false });
65
+ for (let i = 0; i < 50 && !existsSync(runIdPath); i++)
66
+ await delay(10);
67
+ assert.ok(existsSync(runIdPath), 'the live run exposed its id to its self-cancel command');
68
+ assert.equal(markSelfCanceledCronRun('self-cancel-delivery', readFileSync(runIdPath, 'utf8')), true);
69
+ assert.equal(cancelCron('self-cancel-delivery', { preserveRunningProcess: true }), true);
70
+ const record = await run;
71
+ assert.equal(record?.delivered, 'delivered: human send --inbox');
72
+ });
@@ -15,6 +15,7 @@ import { verifyDaemonStartup, waitForDaemonExit } from '../../daemon/manage.js';
15
15
  import { waitForBrokerViewSocket } from '../runtime/placement-tmux.js';
16
16
  import { viewSocketPath } from '../canvas/paths.js';
17
17
  import { markBusy, clearBusy } from '../runtime/busy.js';
18
+ import { headlessBrokerHost } from '../runtime/host.js';
18
19
  import { bindTestFleet } from './helpers/fleet.js';
19
20
  import { resetFleetForTests } from '../runtime/fleet.js';
20
21
  let home;
@@ -92,34 +93,45 @@ test('never-booted broker exit is marked dead and surfaced to the parent as urge
92
93
  assert.ok(wake, 'the doctrine wake rides beside the urgent push');
93
94
  assert.match(wake.label, /crashed/i);
94
95
  });
95
- // BROKER CUT: surfaceChildDeath was DELETED and the tick runs no liveness pass.
96
- // A crashed booted child (engine pid DEAD, pi_session_id set) is never marked
97
- // `dead` by a tick and never fans a "child died" notice — recovery rides the
98
- // broker's real exit event (policy rows 5/6 revive it on its saved session), so
99
- // a fabricated row with no fleet entry is simply left revivable and the doctrine wake
100
- // relocated (the revived child pushes when it truly finishes; a deliberate close
101
- // is woken by close.ts). This test preserves the discriminating intent in the
102
- // new model: a crash is never reaped to `dead` and never raises a false
103
- // boot-failure alarm.
104
- test('a crashed booted child is revivable, not reaped, and raises no false boot-failure alarm', async () => {
105
- createNode(node('P2', { kind: 'developer', lifecycle: 'resident' }));
106
- createNode(node('C2', {
107
- parent: 'P2',
108
- kind: 'explore',
109
- pi_pid: deadPid(), // engine crashed — its recorded pid is dead
110
- pi_session_id: '019e8f00-booted-once', // it booted before dying
111
- intent: null,
112
- }));
113
- spawnEdge('P2', 'C2');
114
- markBusy('C2'); // died mid-generation
115
- await superviseTick();
116
- // The tick has no liveness pass: a stale recorded pid is metadata, not a
117
- // death signal — the row stays active/revivable, never reaped to `dead`.
118
- assert.equal(getNode('C2').status, 'active', 'crashed booted child stays revivable, NOT marked dead');
119
- // surfaceChildDeath is gone and this is not a never-booted node, so the daemon
120
- // fans NO push here — no false boot-failure / child-death alarm on a crash.
121
- assert.equal(readInboxSince('P2').length, 0, 'no false boot-failure / child-death push on a crash (wake relocated to push/close)');
122
- clearBusy('C2');
96
+ // surfaceChildDeath was DELETED, and the node-lifecycle tick recovers a crashed
97
+ // booted child through the SAME policy table its exit event would have used
98
+ // (design D-12), so tick recovery and exit recovery can never diverge. A row
99
+ // whose recorded pid is provably dead and whose session was recorded is a
100
+ // resumable crash: it is relaunched on that saved session, never reaped to
101
+ // `dead` and never announced as a boot failure or a child death.
102
+ test('a crashed booted child is relaunched on its saved session, not reaped, and raises no false boot-failure alarm', async () => {
103
+ const originalLaunch = headlessBrokerHost.launch;
104
+ const launches = [];
105
+ headlessBrokerHost.launch = (nodeId) => {
106
+ launches.push(nodeId);
107
+ const handle = { pid: 424_242, exited: new Promise(() => { }) };
108
+ tf.fleet.register(nodeId, handle); // the real host contract: registration at the launch seam
109
+ return handle;
110
+ };
111
+ try {
112
+ createNode(node('P2', { kind: 'developer', lifecycle: 'resident' }));
113
+ createNode(node('C2', {
114
+ cwd: home, // a cwd that exists — reviveNode's preflight refuses a missing one
115
+ parent: 'P2',
116
+ kind: 'explore',
117
+ pi_pid: deadPid(), // engine crashed — its recorded pid is dead
118
+ pi_session_id: '019e8f00-booted-once', // it booted before dying
119
+ intent: null,
120
+ }));
121
+ spawnEdge('P2', 'C2');
122
+ markBusy('C2'); // died mid-generation
123
+ await superviseTick();
124
+ assert.deepEqual(launches, ['C2'], 'the tick recovers the crash through the policy table, exactly as the exit event would');
125
+ assert.equal(getNode('C2').status, 'active', 'crashed booted child is relaunched, NOT marked dead');
126
+ // surfaceChildDeath is gone and this is not a never-booted node, so the daemon
127
+ // fans NO push here — no false boot-failure / child-death alarm on a crash.
128
+ assert.equal(readInboxSince('P2').length, 0, 'no false boot-failure / child-death push on a crash (wake relocated to push/close)');
129
+ clearBusy('C2');
130
+ }
131
+ finally {
132
+ headlessBrokerHost.launch = originalLaunch;
133
+ tf.fleet.forget('C2');
134
+ }
123
135
  });
124
136
  // An unplaced active node is not daemon-managed; the daemon leaves it untouched
125
137
  // rather than treating the missing broker/session fields as a boot failure.
@@ -505,6 +505,7 @@ export async function createHarness(opts = {}) {
505
505
  prompt: task,
506
506
  parent: parentId,
507
507
  ...(o.mode !== undefined ? { mode: o.mode } : {}),
508
+ ...(o.lifecycle !== undefined ? { rootLifecycle: o.lifecycle } : {}),
508
509
  ...(o.model !== undefined ? { model: o.model } : {}),
509
510
  });
510
511
  await harness.awaitBoot(result.node.node_id);
@@ -192,7 +192,7 @@ describe('terminal ticket delivery', () => {
192
192
  await deliverTerminalResult(bridge);
193
193
  assert.equal(getNode(companion)?.final_report, finalReport, 'terminal replay preserves the first canonical final');
194
194
  assert.equal(getNode(companion)?.finalized_at, finalizedAt, 'terminal replay preserves the first finalization time');
195
- assert.throws(() => reviveNode(companion, { resume: true }), /finalization latch/);
195
+ assert.throws(() => reviveNode(companion, { resume: true, capacity: 'freeze' }), /finalization latch/);
196
196
  const { bridge: dismissedBridge } = bridgeUnder();
197
197
  const dismissedDir = submitBridgePage(dismissedBridge);
198
198
  const dismissedCompanion = spawnNode({ kind: 'general', parent: dismissedBridge, cwd: home, name: 'dismissed feedback companion', lifecycle: 'resident' }).node_id;
@@ -14,12 +14,11 @@
14
14
  // (pid==null, session==null) as a never-booted broker and boot-failed it —
15
15
  // killing the human prompt before the person could ever answer.
16
16
  //
17
- // THE FIX under exit-event authority lives in TWO filters, both driven here:
18
- // superviseTick drops kind:'human' rows from every row pass, and the startup
19
- // recovery sweep skips them before classification — the sweep treats every
20
- // swept row as known-dead, so an unfiltered human row (session null, no fork
21
- // claim) would land in policy row 8 (boot failure), the modern re-incarnation
22
- // of the exact bug.
17
+ // THE FIX under exit-event authority is superviseTick's one filter, driven
18
+ // here: it drops kind:'human' rows before every row pass, including the
19
+ // node-lifecycle tick that classifies a row with no live broker as known-dead.
20
+ // An unfiltered human row (session null, no fork claim) would land in policy
21
+ // row 8 (boot failure), the modern re-incarnation of the exact bug.
23
22
  import { test, before, after, beforeEach } from 'node:test';
24
23
  import assert from 'node:assert/strict';
25
24
  import { mkdtempSync, rmSync } from 'node:fs';
@@ -28,7 +27,7 @@ import { join } from 'node:path';
28
27
  import { createNode, getNode, subscribe } from '../canvas/canvas.js';
29
28
  import { closeDb } from '../canvas/db.js';
30
29
  import { readInboxSince } from '../feed/inbox.js';
31
- import { superviseTick, runStartupRecoverySweep } from '../../daemon/crtrd.js';
30
+ import { superviseTick } from '../../daemon/crtrd.js';
32
31
  import { bindTestFleet } from './helpers/fleet.js';
33
32
  import { resetFleetForTests } from '../runtime/fleet.js';
34
33
  let home;
@@ -49,7 +48,7 @@ function node(id, over = {}) {
49
48
  before(() => {
50
49
  home = mkdtempSync(join(tmpdir(), 'crtr-human-not-supervised-'));
51
50
  process.env['CRTR_HOME'] = home;
52
- tf = bindTestFleet(); // superviseTick + the sweep require a bound fleet (D-2)
51
+ tf = bindTestFleet(); // superviseTick requires a bound fleet (D-2)
53
52
  });
54
53
  beforeEach(() => {
55
54
  closeDb();
@@ -79,16 +78,12 @@ test('a kind:"human" bridge row is NEVER boot-failed by the daemon (it has no br
79
78
  // the tick leave IT alone — it is only here to catch a push.
80
79
  createNode(node('ASKER', { status: 'idle', intent: 'idle-release', pi_session_id: 'booted' }));
81
80
  subscribe('ASKER', 'HUMAN', true);
82
- // Two well-spaced ticks: under exit-event authority the tick runs no liveness
83
- // pass at all, and its row passes (controller-wait settle, pass-2 inbox wake)
84
- // must drop the human row before reading it.
81
+ // Two well-spaced ticks: the row passes (controller-wait settle, inbox wake,
82
+ // and the node-lifecycle classification of a row with no live broker) must
83
+ // all drop the human row before reading it.
85
84
  const t0 = Date.now();
86
85
  await superviseTick(t0);
87
86
  await superviseTick(t0 + 10_000_000);
88
- // The startup recovery sweep is the sharper threat: it classifies every swept
89
- // active|idle row as known-dead, so only its kind:'human' filter keeps the
90
- // bridge row out of policy row 8 (boot failure).
91
- await runStartupRecoverySweep(tf.fleet);
92
87
  assert.equal(getNode('HUMAN').status, 'active', 'human bridge row stays alive — never crash-reaped as a dead broker');
93
88
  assert.equal(readInboxSince('ASKER').length, 0, 'no surfaceBootFailure urgent push — the asker is never told its human prompt "never started"');
94
89
  });