@north-light/crouter 0.3.311 → 0.3.312

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 (51) hide show
  1. package/dist/clients/attach/viewer.js +394 -394
  2. package/dist/core/__tests__/context-intro.test.js +4 -4
  3. package/dist/core/__tests__/human-deliver.test.js +18 -18
  4. package/dist/core/__tests__/integration/human-deliver-e2e.test.js +5 -5
  5. package/dist/core/__tests__/integration/lifecycle-hooks.test.js +299 -1
  6. package/dist/core/__tests__/integration/spawn-root.test.js +11 -11
  7. package/dist/core/__tests__/on-read-crouter-home-fence.test.js +2 -2
  8. package/dist/core/__tests__/on-read-dedup-resume.test.js +6 -6
  9. package/dist/core/__tests__/on-read-nested-store.test.js +6 -6
  10. package/dist/core/__tests__/profile-project-memory-delivery.test.js +31 -31
  11. package/dist/core/__tests__/spawn-worktree-id-conflict.test.js +3 -3
  12. package/dist/core/__tests__/warm-claim-preference-snapshot.test.js +1 -1
  13. package/dist/core/command-hooks/artifact.d.ts +6 -0
  14. package/dist/core/command-hooks/artifact.js +22 -6
  15. package/dist/core/command-hooks/discovery.d.ts +2 -0
  16. package/dist/core/command-hooks/discovery.js +2 -0
  17. package/dist/core/command-hooks/lifecycle-catalog.d.ts +20 -2
  18. package/dist/core/command-hooks/lifecycle-catalog.js +7 -2
  19. package/dist/core/command-hooks/lifecycle-create.d.ts +4 -0
  20. package/dist/core/command-hooks/lifecycle-create.js +51 -0
  21. package/dist/core/command-hooks/schema.d.ts +2 -2
  22. package/dist/core/command-hooks/schema.js +5 -4
  23. package/dist/core/command-hooks/transport/exec-invoke.js +3 -1
  24. package/dist/core/command-hooks/transport/exec-lifecycle.d.ts +18 -8
  25. package/dist/core/command-hooks/transport/exec-lifecycle.js +89 -30
  26. package/dist/core/errors.d.ts +1 -0
  27. package/dist/core/errors.js +4 -0
  28. package/dist/core/human/feedback-companion.js +1 -1
  29. package/dist/core/review/companion.d.ts +1 -1
  30. package/dist/core/review/companion.js +2 -2
  31. package/dist/core/review/realize.js +6 -6
  32. package/dist/core/runtime/nodes.d.ts +5 -1
  33. package/dist/core/runtime/nodes.js +26 -11
  34. package/dist/core/runtime/recycle.js +16 -12
  35. package/dist/core/runtime/reset.js +4 -1
  36. package/dist/core/runtime/revive.js +13 -12
  37. package/dist/core/runtime/spawn.d.ts +4 -1
  38. package/dist/core/runtime/spawn.js +30 -3
  39. package/dist/core/runtime/warm-pool.d.ts +2 -2
  40. package/dist/core/runtime/warm-pool.js +23 -3
  41. package/dist/daemon/__tests__/node-outcome-birth-invariants.test.js +2 -2
  42. package/dist/daemon/api/__tests__/profile-launch-gates.test.js +2 -2
  43. package/dist/daemon/api/__tests__/seam/profile-delete.test.js +2 -2
  44. package/dist/daemon/api/handlers/human.js +5 -2
  45. package/dist/daemon/api/handlers/nodes.d.ts +1 -0
  46. package/dist/daemon/api/handlers/nodes.js +34 -29
  47. package/dist/hook-authoring.d.ts +28 -2
  48. package/dist/hook-authoring.js +53 -1
  49. package/dist/index.d.ts +1 -1
  50. package/package.json +1 -1
  51. package/runtime.lock.json +5 -5
@@ -48,8 +48,8 @@ after(() => {
48
48
  delete process.env['CRTR_HOME'];
49
49
  delete process.env['CRTR_NODE_ID'];
50
50
  });
51
- test('the stored situational card rides the bearings body; absent when unset', () => {
52
- const meta = spawnNode({ kind: 'general', cwd: '/tmp/work', parent: null });
51
+ test('the stored situational card rides the bearings body; absent when unset', async () => {
52
+ const meta = await spawnNode({ kind: 'general', cwd: '/tmp/work', parent: null });
53
53
  assert.equal(situationalContextEnvelope(meta.node_id), null, 'no card until something is set');
54
54
  assert.doesNotMatch(buildContextIntro(meta.node_id), /kind="situational"/, 'bearings carry no card when unset');
55
55
  appendSituationalContext(meta.node_id, formatSituationalProse('user is looking at the billing applet'));
@@ -73,7 +73,7 @@ function fakeCtx(pi) {
73
73
  return { sessionManager: { getBranch: () => pi.entries } };
74
74
  }
75
75
  test('session_start injects the block as the first message of an empty session', async () => {
76
- const meta = spawnNode({ kind: 'general', cwd: '/tmp/work', parent: null });
76
+ const meta = await spawnNode({ kind: 'general', cwd: '/tmp/work', parent: null });
77
77
  process.env['CRTR_NODE_ID'] = meta.node_id;
78
78
  const pi = makeFakePi();
79
79
  registerCanvasContextIntro(pi);
@@ -87,7 +87,7 @@ test('session_start injects the block as the first message of an empty session',
87
87
  assert.match(pi.sent[0].content, /<crtr-bearings>/);
88
88
  });
89
89
  test('session_start is idempotent across resume (skips if already in history)', async () => {
90
- const meta = spawnNode({ kind: 'general', cwd: '/tmp/work', parent: null });
90
+ const meta = await spawnNode({ kind: 'general', cwd: '/tmp/work', parent: null });
91
91
  process.env['CRTR_NODE_ID'] = meta.node_id;
92
92
  // Restored session already carries OUR block (which names this node id) → no
93
93
  // duplicate injection. The content matters: the guard is fork-aware and only
@@ -45,9 +45,9 @@ async function withMutedStdout(fn) {
45
45
  process.stdout.write = originalWrite;
46
46
  }
47
47
  }
48
- function bridgeUnder() {
49
- const parent = spawnNode({ kind: 'general', parent: null, cwd: home, name: 'asker' }).node_id;
50
- const bridge = spawnNode({ kind: 'human', parent, creator: parent, cwd: home, name: 'human-page', lifecycle: 'terminal' }).node_id;
48
+ async function bridgeUnder() {
49
+ const parent = (await spawnNode({ kind: 'general', parent: null, cwd: home, name: 'asker' })).node_id;
50
+ const bridge = (await 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) {
@@ -97,7 +97,7 @@ after(() => {
97
97
  });
98
98
  describe('enqueue is producer-only', () => {
99
99
  test('send preserves title and subtitle from the authored page', async () => {
100
- const parent = spawnNode({ kind: 'general', parent: null, cwd: home, name: 'asker' }).node_id;
100
+ const parent = (await spawnNode({ kind: 'general', parent: null, cwd: home, name: 'asker' })).node_id;
101
101
  process.env['CRTR_NODE_ID'] = parent;
102
102
  const page = join(home, 'release-decision.tsx');
103
103
  writeFileSync(page, 'export default () => (\n <Page title="Release decision" subtitle="Decide whether to release now so the launch can proceed.">\n <p>This first line stays page content.</p>\n <UserText id="question" initialText="" />\n </Page>\n);\n');
@@ -117,7 +117,7 @@ describe('enqueue is producer-only', () => {
117
117
  assert.equal(manifest.slots.filter((slot) => slot.kind === 'text').length, 1);
118
118
  });
119
119
  test('send materializes a piped page before validation and publication', async () => {
120
- const parent = spawnNode({ kind: 'general', parent: null, cwd: home, name: 'asker' }).node_id;
120
+ const parent = (await spawnNode({ kind: 'general', parent: null, cwd: home, name: 'asker' })).node_id;
121
121
  process.env['CRTR_NODE_ID'] = parent;
122
122
  const document = 'export default () => (\n <Page title="Piped question" subtitle="Answer the question to unblock this task.">\n <UserText id="question" initialText="" />\n </Page>\n);\n';
123
123
  const result = (await humanSend.run({ document }));
@@ -128,7 +128,7 @@ describe('enqueue is producer-only', () => {
128
128
  });
129
129
  describe('terminal ticket delivery', () => {
130
130
  test("resolvePageTicket pushFinals the person's words once and wakes the subscribed parent", async () => {
131
- const { parent, bridge } = bridgeUnder();
131
+ const { parent, bridge } = await bridgeUnder();
132
132
  const dir = submitBridgePage(bridge);
133
133
  const result = await resolvePageTicket(bridge, { question: { text: 'yes, ship', edited: true } });
134
134
  assert.equal(result.kind, 'page');
@@ -142,7 +142,7 @@ describe('terminal ticket delivery', () => {
142
142
  assert.ok(!body.includes(join(dir, 'response.json')));
143
143
  });
144
144
  test('settlement delivers feedback outside the human-answer entry', async () => {
145
- const { parent, bridge } = bridgeUnder();
145
+ const { parent, bridge } = await bridgeUnder();
146
146
  const dir = submitBridgePage(bridge);
147
147
  writeFileSync(pageFeedbackPath(dir), JSON.stringify({
148
148
  schema: 'crtr.page-feedback/v1',
@@ -167,7 +167,7 @@ describe('terminal ticket delivery', () => {
167
167
  assert.deepEqual(answer, { kind: 'final', disposition: 'human-answer', body: `yes, ship\n\nRead this report with \`crtr canvas history read ${final.ref}\`.`, ref: final.ref });
168
168
  });
169
169
  test('a replay against a terminal bridge is a no-op', async () => {
170
- const { parent, bridge } = bridgeUnder();
170
+ const { parent, bridge } = await bridgeUnder();
171
171
  submitBridgePage(bridge);
172
172
  await resolvePageTicket(bridge, { question: { text: 'yes', edited: true } });
173
173
  assert.equal(await deliverTerminalResult(bridge), true);
@@ -176,9 +176,9 @@ describe('terminal ticket delivery', () => {
176
176
  assert.equal(finals.length, 1, 'replay after a terminal bridge pushes nothing further');
177
177
  });
178
178
  test('approved companions receive one canonical final while dismissed companions remain canceled', async () => {
179
- const { bridge } = bridgeUnder();
179
+ const { bridge } = await bridgeUnder();
180
180
  const dir = submitBridgePage(bridge);
181
- const companion = spawnNode({ kind: 'general', parent: bridge, creator: bridge, cwd: home, name: 'feedback companion', lifecycle: 'resident' }).node_id;
181
+ const companion = (await 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 } });
@@ -193,9 +193,9 @@ describe('terminal ticket delivery', () => {
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
195
  await assert.rejects(reviveNode(companion, { resume: true, capacity: 'freeze' }), /finalization latch/);
196
- const { bridge: dismissedBridge } = bridgeUnder();
196
+ const { bridge: dismissedBridge } = await bridgeUnder();
197
197
  const dismissedDir = submitBridgePage(dismissedBridge);
198
- const dismissedCompanion = spawnNode({ kind: 'general', parent: dismissedBridge, creator: dismissedBridge, cwd: home, name: 'dismissed feedback companion', lifecycle: 'resident' }).node_id;
198
+ const dismissedCompanion = (await 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');
@@ -206,9 +206,9 @@ describe('terminal ticket delivery', () => {
206
206
  assert.equal(getNode(dismissedCompanion)?.final_report, null);
207
207
  });
208
208
  test('cancelHumanTicket finishes the bridge and notifies non-actor subscribers', async () => {
209
- const actor = spawnNode({ kind: 'general', parent: null, cwd: home, name: 'actor' }).node_id;
210
- const bridge = spawnNode({ kind: 'human', parent: actor, creator: actor, cwd: home, name: 'human-page', lifecycle: 'terminal' }).node_id;
211
- const other = spawnNode({ kind: 'general', parent: null, cwd: home, name: 'other' }).node_id;
209
+ const actor = (await spawnNode({ kind: 'general', parent: null, cwd: home, name: 'actor' })).node_id;
210
+ const bridge = (await spawnNode({ kind: 'human', parent: actor, creator: actor, cwd: home, name: 'human-page', lifecycle: 'terminal' })).node_id;
211
+ const other = (await 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);
214
214
  submitBridgePage(bridge);
@@ -222,9 +222,9 @@ describe('terminal ticket delivery', () => {
222
222
  assert.match(notes[0].label, /canceled/);
223
223
  });
224
224
  test('cancellation reaches subscribers when the bridge is already done, and retries conflict rather than fail', async () => {
225
- const actor = spawnNode({ kind: 'general', parent: null, cwd: home, name: 'actor' }).node_id;
226
- const bridge = spawnNode({ kind: 'human', parent: actor, creator: actor, cwd: home, name: 'human-page', lifecycle: 'terminal' }).node_id;
227
- const other = spawnNode({ kind: 'general', parent: null, cwd: home, name: 'other' }).node_id;
225
+ const actor = (await spawnNode({ kind: 'general', parent: null, cwd: home, name: 'actor' })).node_id;
226
+ const bridge = (await spawnNode({ kind: 'human', parent: actor, creator: actor, cwd: home, name: 'human-page', lifecycle: 'terminal' })).node_id;
227
+ const other = (await 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);
230
230
  submitBridgePage(bridge);
@@ -88,8 +88,8 @@ function reportBody(ref) {
88
88
  return resolved.body;
89
89
  }
90
90
  test('malformed resolves leave the mounted ticket claim in place', async () => {
91
- const parent = spawnNode({ kind: 'general', parent: null, cwd: home, name: 'asker' }).node_id;
92
- const pageBridge = spawnNode({ kind: 'human', parent, creator: parent, cwd: home, name: 'human-page', lifecycle: 'terminal' }).node_id;
91
+ const parent = (await spawnNode({ kind: 'general', parent: null, cwd: home, name: 'asker' })).node_id;
92
+ const pageBridge = (await 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, creator: parent, cwd: home, name: 'human-review', lifecycle: 'terminal' }).node_id;
104
+ const reviewBridge = (await 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');
@@ -113,8 +113,8 @@ test('malformed resolves leave the mounted ticket claim in place', async () => {
113
113
  assert.equal(readTicketClaim(reviewDir)?.token, reviewClaim.token, 'review ticket resolve must not take over the live claim');
114
114
  });
115
115
  test('the ticket resolve route pushFinals the readable answer to the parent', async () => {
116
- const parent = spawnNode({ kind: 'general', parent: null, cwd: home, name: 'asker' }).node_id;
117
- const bridge = spawnNode({ kind: 'human', parent, creator: parent, cwd: home, name: 'human-page', lifecycle: 'terminal' }).node_id;
116
+ const parent = (await spawnNode({ kind: 'general', parent: null, cwd: home, name: 'asker' })).node_id;
117
+ const bridge = (await 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' });
@@ -1,11 +1,17 @@
1
1
  import assert from 'node:assert/strict';
2
- import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
3
3
  import { join } from 'node:path';
4
4
  import test from 'node:test';
5
5
  import { contextDir, jobDir, nodeDir, reportsDir } from '../../canvas/paths.js';
6
+ import { listNodes, listWarmSpares } from '../../canvas/canvas.js';
6
7
  import { validateHookManifest } from '../../command-hooks/schema.js';
7
8
  import { headlessBrokerHost } from '../../runtime/host.js';
8
9
  import { reviveNode } from '../../runtime/revive.js';
10
+ import { handleCreate, handleFork } from '../../../daemon/api/handlers/nodes.js';
11
+ import { armCron, getCron } from '../../canvas/crons.js';
12
+ import { settleRun } from '../../../daemon/cron/settle.js';
13
+ import { relaunchRoot } from '../../runtime/reset.js';
14
+ import { recycleNode } from '../../runtime/recycle.js';
9
15
  import { createHarness } from '../helpers/harness.js';
10
16
  function installLifecyclePlugin(harness, name, op) {
11
17
  const ops = typeof op === 'string' ? [op] : op;
@@ -53,6 +59,64 @@ process.stdin.on('end', () => {
53
59
  `);
54
60
  chmodSync(executable, 0o755);
55
61
  }
62
+ function installCreateLifecyclePlugin(harness, name, declarations, behavior) {
63
+ const root = join(harness.agentHome, '.crouter', 'plugins', name);
64
+ const records = join(root, 'records.jsonl');
65
+ mkdirSync(join(root, '.crouter-plugin'), { recursive: true });
66
+ writeFileSync(join(root, '.crouter-plugin', 'plugin.json'), JSON.stringify({
67
+ name,
68
+ version: '0.1.0',
69
+ description: 'node create lifecycle fixture',
70
+ hooks: 'hooks.json',
71
+ hookExecutable: 'bin/hook.js',
72
+ }));
73
+ writeFileSync(join(root, 'hooks.json'), JSON.stringify({
74
+ schemaVersion: 2,
75
+ lifecycle: declarations.map((declaration) => ({ ...declaration, description: `${declaration.op} fixture`, effects: ['writes fixture state'] })),
76
+ }));
77
+ const executable = join(root, 'bin', 'hook.js');
78
+ mkdirSync(join(root, 'bin'), { recursive: true });
79
+ writeFileSync(executable, `#!/usr/bin/env node
80
+ const { appendFileSync, existsSync, readdirSync } = require('node:fs');
81
+ const { join } = require('node:path');
82
+ const behavior = ${JSON.stringify(behavior)};
83
+ let input = '';
84
+ process.stdin.on('data', (chunk) => { input += chunk; });
85
+ process.stdin.on('end', () => {
86
+ const request = JSON.parse(input);
87
+ appendFileSync(${JSON.stringify(records)}, JSON.stringify(request) + '\\n');
88
+ if (request.event === 'node:create' && behavior === 'refuse') {
89
+ process.stdout.write(JSON.stringify({ protocolVersion: 1, ok: false, error: { code: 'refused', message: 'fixture refusal' } }));
90
+ return;
91
+ }
92
+ if (request.event === 'node:create' && behavior === 'crash') process.exit(1);
93
+ if (request.event === 'node:create' && behavior === 'hang') return setInterval(() => {}, 1000);
94
+ if (request.event === 'node:create' && behavior === 'bad-output') return process.stdout.write('not an envelope');
95
+ if (request.event === 'node:create' && behavior === 'second-refuses') {
96
+ const nodes = join(request.runtime.canvasHome, 'nodes');
97
+ if (existsSync(nodes) && readdirSync(nodes).some((entry) => entry !== 'index.json')) {
98
+ process.stdout.write(JSON.stringify({ protocolVersion: 1, ok: false, error: { code: 'occupied', message: 'second create refused' } }));
99
+ return;
100
+ }
101
+ }
102
+ if (request.event === 'node:start' && behavior === 'start-refuses') {
103
+ process.stdout.write(JSON.stringify({ protocolVersion: 1, ok: false, error: { code: 'ignored', message: 'start refusal' } }));
104
+ return;
105
+ }
106
+ process.stdout.write(JSON.stringify({ protocolVersion: 1, ok: true }));
107
+ });
108
+ `);
109
+ chmodSync(executable, 0o755);
110
+ return { root, records };
111
+ }
112
+ function createContext(body, params = {}) {
113
+ return { method: 'POST', path: '/v1/nodes', params, query: new URLSearchParams(), body };
114
+ }
115
+ function lifecycleRecords(path) {
116
+ return existsSync(path)
117
+ ? readFileSync(path, 'utf8').trim().split('\n').filter(Boolean).map((line) => JSON.parse(line))
118
+ : [];
119
+ }
56
120
  function installV1CommandHook(harness) {
57
121
  const root = join(harness.agentHome, '.crouter', 'plugins', 'z-v1-command');
58
122
  mkdirSync(join(root, '.crouter-plugin'), { recursive: true });
@@ -149,6 +213,233 @@ test('node:start hooks receive resolved paths before broker launch; invalid and
149
213
  await harness.dispose();
150
214
  }
151
215
  });
216
+ test('node:create fails closed before a row exists and node:start fires on cold birth', async () => {
217
+ const harness = await createHarness({ headless: true, sessionPrefix: 'crtr-node-create-hooks' });
218
+ try {
219
+ const birth = installCreateLifecyclePlugin(harness, 'birth-fixture', [
220
+ { event: 'node:create', phase: 'before', op: 'record-create' },
221
+ { event: 'node:start', phase: 'on', op: 'record-start' },
222
+ ], 'start-refuses');
223
+ const created = await handleCreate(createContext({ kind: 'developer', root: true, profile: null, cwd: harness.agentHome, prompt: '' }));
224
+ const nodeId = created.body.node_id;
225
+ await harness.awaitBoot(nodeId);
226
+ const records = lifecycleRecords(birth.records);
227
+ const create = records.find((record) => record.event === 'node:create');
228
+ assert.deepEqual(create.create, {
229
+ kind: 'developer', mode: 'base', cwd: harness.agentHome, profile: null, root: true, parentId: null, requestingNodeId: null, lifecycle: 'resident', replaces: null,
230
+ });
231
+ assert.deepEqual(create.runtime, { canvasHome: harness.home, profile: null });
232
+ assert.deepEqual(create.context, { cwd: harness.agentHome });
233
+ assert.equal(Object.hasOwn(create, 'node'), false);
234
+ const start = records.find((record) => record.event === 'node:start');
235
+ assert.equal(start.runtime.isBirth, true);
236
+ assert.ok(harness.node(nodeId), 'a refusing node:start hook leaves the cold birth launched');
237
+ rmSync(birth.root, { recursive: true, force: true });
238
+ for (const behavior of ['refuse', 'crash', 'bad-output', 'hang']) {
239
+ const plugin = installCreateLifecyclePlugin(harness, `create-${behavior}`, [{ event: 'node:create', phase: 'before', op: behavior }], behavior);
240
+ const count = listNodes().length;
241
+ await assert.rejects(() => handleCreate(createContext({ kind: 'developer', root: true, profile: null, cwd: harness.agentHome, prompt: '' })), (error) => {
242
+ assert.equal(error.code, 'node_create_refused');
243
+ assert.match(error.message, new RegExp(`${behavior === 'refuse' ? 'fixture refusal' : `plugin "create-${behavior}" lifecycle hook "${behavior}"`}`));
244
+ return true;
245
+ });
246
+ assert.equal(listNodes().length, count, `${behavior} leaves no node row`);
247
+ rmSync(plugin.root, { recursive: true, force: true });
248
+ }
249
+ const missing = installCreateLifecyclePlugin(harness, 'create-missing', [{ event: 'node:create', phase: 'before', op: 'missing-op' }], 'allow');
250
+ rmSync(join(missing.root, 'bin', 'hook.js'));
251
+ await assert.rejects(() => handleCreate(createContext({ kind: 'developer', root: true, profile: null, cwd: harness.agentHome, prompt: '' })), (error) => {
252
+ assert.equal(error.code, 'node_create_refused');
253
+ assert.match(error.message, /plugin "create-missing" lifecycle hook "missing-op"/);
254
+ return true;
255
+ });
256
+ rmSync(missing.root, { recursive: true, force: true });
257
+ const malformed = installCreateLifecyclePlugin(harness, 'create-malformed', [
258
+ { event: 'node:create', phase: 'before', op: 'valid-create' },
259
+ { event: 'node:create', phase: 'on', op: 'invalid-create' },
260
+ ], 'allow');
261
+ const count = listNodes().length;
262
+ await assert.rejects(() => handleCreate(createContext({ kind: 'developer', root: true, profile: null, cwd: harness.agentHome, prompt: '' })), (error) => {
263
+ assert.equal(error.code, 'node_create_refused');
264
+ assert.match(error.message, /plugin "create-malformed" lifecycle hook "valid-create"/);
265
+ return true;
266
+ });
267
+ assert.equal(listNodes().length, count, 'a later malformed node:create declaration leaves no node row');
268
+ rmSync(malformed.root, { recursive: true, force: true });
269
+ const malformedOtherLifecycle = installCreateLifecyclePlugin(harness, 'create-malformed-other-lifecycle', [
270
+ { event: 'node:start', phase: 'before', op: 'invalid-start' },
271
+ { event: 'node:create', phase: 'before', op: 'valid-create' },
272
+ ], 'allow');
273
+ await assert.rejects(() => handleCreate(createContext({ kind: 'developer', root: true, profile: null, cwd: harness.agentHome, prompt: '' })), (error) => {
274
+ assert.equal(error.code, 'node_create_refused');
275
+ assert.match(error.message, /plugin "create-malformed-other-lifecycle" lifecycle hook "valid-create"/);
276
+ return true;
277
+ });
278
+ rmSync(malformedOtherLifecycle.root, { recursive: true, force: true });
279
+ }
280
+ finally {
281
+ await harness.dispose();
282
+ }
283
+ });
284
+ test('node:fork runs the node:create gate for its new resident root', async () => {
285
+ const harness = await createHarness({ headless: true, sessionPrefix: 'crtr-node-fork-create' });
286
+ try {
287
+ const plugin = installCreateLifecyclePlugin(harness, 'fork-create', [{ event: 'node:create', phase: 'before', op: 'record-fork' }], 'allow');
288
+ const source = await handleCreate(createContext({ kind: 'developer', root: true, profile: null, cwd: harness.agentHome, prompt: '' }));
289
+ const sourceId = source.body.node_id;
290
+ await harness.awaitBoot(sourceId);
291
+ const fork = await handleFork(createContext({}, { id: sourceId }));
292
+ const forkId = fork.body.node_id;
293
+ await harness.awaitBoot(forkId);
294
+ const creates = lifecycleRecords(plugin.records).filter((record) => record.event === 'node:create');
295
+ assert.equal(creates.length, 2);
296
+ assert.deepEqual(creates[1].create, {
297
+ kind: 'developer', mode: 'base', cwd: harness.agentHome, profile: null, root: true, parentId: null, requestingNodeId: null, lifecycle: 'resident', replaces: null,
298
+ });
299
+ }
300
+ finally {
301
+ await harness.dispose();
302
+ }
303
+ });
304
+ test('node:create gates warm mint and warm claim with the birth payload', async () => {
305
+ const harness = await createHarness({ headless: true, sessionPrefix: 'crtr-node-create-warm' });
306
+ const priorWarmPool = process.env['CRTR_WARM_POOL'];
307
+ process.env['CRTR_WARM_POOL'] = '1';
308
+ try {
309
+ const plugin = installCreateLifecyclePlugin(harness, 'warm-create', [{ event: 'node:create', phase: 'before', op: 'record-warm' }], 'allow');
310
+ const first = await handleCreate(createContext({ kind: 'developer', root: true, profile: null, cwd: harness.agentHome, prompt: '', prefer_warm: true }));
311
+ await harness.awaitBoot(first.body.node_id);
312
+ let spareId;
313
+ for (let i = 0; i < 100; i++) {
314
+ spareId = listWarmSpares()[0]?.node_id;
315
+ if (spareId !== undefined)
316
+ break;
317
+ await new Promise((resolve) => setTimeout(resolve, 25));
318
+ }
319
+ assert.ok(spareId, 'the cold create refills one warm spare');
320
+ await harness.awaitBoot(spareId);
321
+ const createsBeforeClaim = lifecycleRecords(plugin.records).filter((record) => record.event === 'node:create').length;
322
+ const second = await handleCreate(createContext({ kind: 'developer', root: true, profile: null, cwd: harness.agentHome, prompt: '', prefer_warm: true }));
323
+ assert.equal(second.body.node_id, spareId, 'the second request claims the pre-born spare');
324
+ const creates = lifecycleRecords(plugin.records).filter((record) => record.event === 'node:create');
325
+ assert.equal(creates.length, createsBeforeClaim + 1, 'claiming the spare runs one additional create gate');
326
+ for (const record of creates) {
327
+ assert.deepEqual(record.create, {
328
+ kind: 'developer', mode: 'base', cwd: harness.agentHome, profile: null, root: true, parentId: null, requestingNodeId: null, lifecycle: 'resident', replaces: null,
329
+ });
330
+ }
331
+ let replacementSpareId;
332
+ for (let i = 0; i < 100; i++) {
333
+ replacementSpareId = listWarmSpares()[0]?.node_id;
334
+ if (replacementSpareId !== undefined)
335
+ break;
336
+ await new Promise((resolve) => setTimeout(resolve, 25));
337
+ }
338
+ assert.ok(replacementSpareId, 'a claimed spare is refilled before the test tears down its harness');
339
+ await harness.awaitBoot(replacementSpareId);
340
+ }
341
+ finally {
342
+ if (priorWarmPool === undefined)
343
+ delete process.env['CRTR_WARM_POOL'];
344
+ else
345
+ process.env['CRTR_WARM_POOL'] = priorWarmPool;
346
+ await harness.dispose();
347
+ }
348
+ });
349
+ test('a refused recycle leaves the old root active', async () => {
350
+ const harness = await createHarness({ headless: true, sessionPrefix: 'crtr-node-create-recycle' });
351
+ const priorWarmPool = process.env['CRTR_WARM_POOL'];
352
+ process.env['CRTR_WARM_POOL'] = '0';
353
+ try {
354
+ const source = await handleCreate(createContext({ kind: 'developer', root: true, profile: null, cwd: harness.agentHome, prompt: '' }));
355
+ const sourceId = source.body.node_id;
356
+ await harness.awaitBoot(sourceId);
357
+ installCreateLifecyclePlugin(harness, 'recycle-refusal', [{ event: 'node:create', phase: 'before', op: 'refuse-recycle' }], 'refuse');
358
+ await assert.rejects(() => recycleNode(sourceId, '%recycle-refusal'), /fixture refusal/);
359
+ assert.equal(harness.node(sourceId)?.status, 'active', 'the old root is unchanged when the replacement hook refuses');
360
+ }
361
+ finally {
362
+ if (priorWarmPool === undefined)
363
+ delete process.env['CRTR_WARM_POOL'];
364
+ else
365
+ process.env['CRTR_WARM_POOL'] = priorWarmPool;
366
+ await harness.dispose();
367
+ }
368
+ });
369
+ test('node:create reports the root replaced by /new', async () => {
370
+ const harness = await createHarness({ headless: true, sessionPrefix: 'crtr-node-create-relaunch' });
371
+ const priorWarmPool = process.env['CRTR_WARM_POOL'];
372
+ process.env['CRTR_WARM_POOL'] = '0';
373
+ try {
374
+ const plugin = installCreateLifecyclePlugin(harness, 'relaunch-create', [{ event: 'node:create', phase: 'before', op: 'record-relaunch' }], 'allow');
375
+ const source = await handleCreate(createContext({ kind: 'developer', root: true, profile: null, cwd: harness.agentHome, prompt: '' }));
376
+ const sourceId = source.body.node_id;
377
+ await harness.awaitBoot(sourceId);
378
+ const result = await relaunchRoot(sourceId, {
379
+ launchBroker: () => ({ pid: 999_999, exited: new Promise(() => { }) }),
380
+ waitForViewSocket: () => true,
381
+ respawnViewer: () => true,
382
+ teardownBroker: async () => ({ confirmed: true }),
383
+ });
384
+ assert.ok(result);
385
+ const create = lifecycleRecords(plugin.records).filter((record) => record.event === 'node:create').at(-1);
386
+ assert.deepEqual(create.create, {
387
+ kind: 'developer', mode: 'base', cwd: harness.agentHome, profile: null, root: true, parentId: null, requestingNodeId: null, lifecycle: 'resident', replaces: sourceId,
388
+ });
389
+ }
390
+ finally {
391
+ if (priorWarmPool === undefined)
392
+ delete process.env['CRTR_WARM_POOL'];
393
+ else
394
+ process.env['CRTR_WARM_POOL'] = priorWarmPool;
395
+ await harness.dispose();
396
+ }
397
+ });
398
+ test('a refused cron spawn records its failure and does not throw', async () => {
399
+ const harness = await createHarness({ headless: true, sessionPrefix: 'crtr-node-create-cron' });
400
+ try {
401
+ installCreateLifecyclePlugin(harness, 'cron-create', [{ event: 'node:create', phase: 'before', op: 'refuse-cron' }], 'refuse');
402
+ armCron({
403
+ cron_id: 'refused-cron-spawn', name: 'refused cron spawn', created_by: null, command: 'echo fixture', fire_at: new Date().toISOString(), recur: null, tz: null, expires_at: null, anchor_node: null, cancel_on_wake: false,
404
+ cwd: harness.agentHome, env_json: null, profile: null, scope: 'global', run_timeout_s: 30, overlap: 'skip', on_output: 'always', sink: JSON.stringify({ spawn: 'developer' }), tier: 'normal',
405
+ });
406
+ const cron = getCron('refused-cron-spawn');
407
+ const record = await settleRun(cron, {
408
+ outOfBand: false,
409
+ outcome: {
410
+ kind: 'disposed',
411
+ ctx: {
412
+ runId: 'refused-cron-run', startedAtIso: new Date().toISOString(), exitCode: 0, timedOut: false, runTimeoutS: 30, processErrorMessage: null,
413
+ stdoutHead: 'fixture output', stdoutSha256: 'fixture-output-hash', stderrHead: '',
414
+ },
415
+ },
416
+ record: { runId: 'refused-cron-run', startedAtIso: new Date().toISOString(), finishedAtMs: Date.now(), durationMs: 1, exitCode: 0, stdoutHead: 'fixture output', stderrHead: null },
417
+ lease: 'none',
418
+ });
419
+ assert.match(record.delivered ?? '', /fixture refusal/, 'the cron run records the refused birth');
420
+ assert.equal(getCron(cron.cron_id)?.state, 'paused', 'the refused delivery pauses the cron instead of throwing or retrying');
421
+ assert.equal(listNodes().length, 0, 'neither the sink nor escalation writes a node row');
422
+ }
423
+ finally {
424
+ await harness.dispose();
425
+ }
426
+ });
427
+ test('node:create serializes concurrent creates through the persisted row', async () => {
428
+ const harness = await createHarness({ headless: true, sessionPrefix: 'crtr-node-create-lock' });
429
+ try {
430
+ installCreateLifecyclePlugin(harness, 'create-lock', [{ event: 'node:create', phase: 'before', op: 'only-one' }], 'second-refuses');
431
+ const request = () => handleCreate(createContext({ kind: 'developer', root: true, profile: null, cwd: harness.agentHome, prompt: '' }));
432
+ const results = await Promise.allSettled([request(), request()]);
433
+ assert.equal(results.filter((result) => result.status === 'fulfilled').length, 1);
434
+ const refusal = results.find((result) => result.status === 'rejected');
435
+ assert.equal(refusal.reason.code, 'node_create_refused');
436
+ assert.match(refusal.reason.message, /second create refused/);
437
+ assert.equal(listNodes().length, 1);
438
+ }
439
+ finally {
440
+ await harness.dispose();
441
+ }
442
+ });
152
443
  test('hooks.json v1 remains command-only while v2 accepts a lifecycle-only declaration', () => {
153
444
  const v1 = validateHookManifest({
154
445
  schemaVersion: 1,
@@ -176,10 +467,17 @@ test('hooks.json v1 remains command-only while v2 accepts a lifecycle-only decla
176
467
  lifecycle: [{ event: 'node:close', phase: 'on', op: 'cleanup', description: 'cleanup', effects: ['removes a directory'] }],
177
468
  });
178
469
  assert.deepEqual(close.issues, []);
470
+ const create = validateHookManifest({
471
+ schemaVersion: 2,
472
+ lifecycle: [{ event: 'node:create', phase: 'before', op: 'guard', description: 'guard', effects: ['refuses a create'] }],
473
+ });
474
+ assert.deepEqual(create.issues, []);
179
475
  for (const manifest of [
180
476
  { schemaVersion: 2, hooks: [], lifecycle: [] },
181
477
  { schemaVersion: 2, lifecycle: [{ event: 'node:stop', phase: 'on', op: 'prepare', description: 'prepare', effects: ['creates a directory'] }] },
182
478
  { schemaVersion: 2, lifecycle: [{ event: 'node:start', phase: 'before', op: 'prepare', description: 'prepare', effects: ['creates a directory'] }] },
479
+ { schemaVersion: 2, lifecycle: [{ event: 'node:create', phase: 'on', op: 'prepare', description: 'prepare', effects: ['creates a directory'] }] },
480
+ { schemaVersion: 2, lifecycle: [{ event: 'node:close', phase: 'before', op: 'prepare', description: 'prepare', effects: ['creates a directory'] }] },
183
481
  { schemaVersion: 2, lifecycle: [{ event: 'node:start', phase: 'on', op: 'prepare', description: 'prepare', effects: ['creates a directory'], unexpected: true }] },
184
482
  ]) {
185
483
  assert.equal(validateHookManifest(manifest).manifest, undefined, 'invalid v2 lifecycle declarations are rejected');
@@ -66,9 +66,9 @@ after(() => {
66
66
  else
67
67
  process.env['CRTR_PIDFILE'] = previousPidfile;
68
68
  });
69
- test('managed child: parent on the spine + active subscription + provenance', () => {
69
+ test('managed child: parent on the spine + active subscription + provenance', async () => {
70
70
  createNode(spawner('A'));
71
- const child = spawnNode({ kind: 'developer', cwd: '/tmp/work', parent: 'A', creator: 'A' });
71
+ const child = await spawnNode({ kind: 'developer', cwd: '/tmp/work', parent: 'A', creator: 'A' });
72
72
  const meta = getNode(child.node_id);
73
73
  assert.equal(meta.parent, 'A', 'child keeps its spine parent');
74
74
  assert.equal(meta.spawned_by, 'A', 'child provenance defaults to its parent');
@@ -78,10 +78,10 @@ test('managed child: parent on the spine + active subscription + provenance', ()
78
78
  assert.equal(subs[0].node_id, 'A');
79
79
  assert.equal(subs[0].active, true, 'parent subscription is active');
80
80
  });
81
- test('external child birth is deferred, persists its creator, and renders its age at delivery in both inbox mirrors', () => {
81
+ test('external child birth is deferred, persists its creator, and renders its age at delivery in both inbox mirrors', async () => {
82
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' });
83
+ const creator = await spawnNode({ kind: 'general', cwd: '/tmp/work', parent: null, name: 'Core' });
84
+ const child = await spawnNode({ kind: 'developer', cwd: '/tmp/work', parent: 'A', creator: creator.node_id, name: 'worker' });
85
85
  const saved = getNode(child.node_id);
86
86
  assert.equal(saved.creator, creator.node_id, 'creator persists through meta.json and getNode');
87
87
  const [entry] = readInboxSince('A');
@@ -106,14 +106,14 @@ test('external child birth is deferred, persists its creator, and renders its ag
106
106
  assert.doesNotMatch(card, /\[\[CRTR_BIRTH_ANNOUNCEMENT_AGE\]\]/, 'the marker is not visible to the recipient');
107
107
  }
108
108
  }
109
- spawnNode({ kind: 'developer', cwd: '/tmp/work', parent: 'A', creator: 'A' });
109
+ await spawnNode({ kind: 'developer', cwd: '/tmp/work', parent: 'A', creator: 'A' });
110
110
  assert.equal(readInboxSince('A').length, 1, 'a parent-created child stays silent');
111
- spawnNode({ kind: 'developer', cwd: '/tmp/work', parent: null });
111
+ await spawnNode({ kind: 'developer', cwd: '/tmp/work', parent: null });
112
112
  assert.equal(readInboxSince('A').length, 1, 'a root never announces');
113
113
  });
114
- test('independent root: provenance only, no parent, no subscription', () => {
114
+ test('independent root: provenance only, no parent, no subscription', async () => {
115
115
  createNode(spawner('A'));
116
- const root = spawnNode({
116
+ const root = await spawnNode({
117
117
  kind: 'developer',
118
118
  cwd: '/tmp/work',
119
119
  parent: null,
@@ -135,10 +135,10 @@ test('resolveSpawner: a --root spawn needs no caller; a managed child does (regr
135
135
  assert.equal(resolveSpawner('A', null, false), 'A', 'explicit parent satisfies a child spawn');
136
136
  assert.throws(() => resolveSpawner(undefined, null, false), /requires a calling node/, 'a managed child with no caller still throws');
137
137
  });
138
- test('unknown parent: spawnNode throws and mints NO node dir / row (validate before create)', () => {
138
+ test('unknown parent: spawnNode throws and mints NO node dir / row (validate before create)', async () => {
139
139
  // Pre-allocate the would-be id so we can prove nothing was scaffolded for it.
140
140
  const orphanId = 'orphan-under-ghost';
141
- assert.throws(() => spawnNode({ kind: 'developer', cwd: '/tmp/work', parent: 'ghost', nodeId: orphanId }), /cannot spawn under unknown parent node: ghost/);
141
+ await assert.rejects(() => spawnNode({ kind: 'developer', cwd: '/tmp/work', parent: 'ghost', nodeId: orphanId }), /cannot spawn under unknown parent node: ghost/);
142
142
  // No half-born orphan: no meta (getNode null), no db row, no node dir on disk.
143
143
  assert.equal(getNode(orphanId), null, 'no meta.json written for the orphan');
144
144
  assert.equal(getRow(orphanId), null, 'no index row written for the orphan');
@@ -44,8 +44,8 @@ after(() => {
44
44
  else
45
45
  process.env['HOME'] = prevHomeEnv;
46
46
  });
47
- test('a store nested in the crouter home does not leak into a canvas-artifact read', () => {
48
- const node = spawnNode({ kind: 'general', cwd: fakeHome, parent: null }).node_id;
47
+ test('a store nested in the crouter home does not leak into a canvas-artifact read', async () => {
48
+ const node = (await spawnNode({ kind: 'general', cwd: fakeHome, parent: null })).node_id;
49
49
  // A private store misresolved into the crouter home: `~/.crouter/.crouter/memory/`.
50
50
  const leakedStore = join(fakeHome, '.crouter', '.crouter', 'memory');
51
51
  mkdirSync(leakedStore, { recursive: true });
@@ -56,11 +56,11 @@ after(() => {
56
56
  else
57
57
  process.env['HOME'] = prevHomeEnv;
58
58
  });
59
- test('project and user memory docs without read surfaces do not fire on file reads', () => {
59
+ test('project and user memory docs without read surfaces do not fire on file reads', async () => {
60
60
  const fakeHome = mkdtempSync(join(tmpdir(), 'crtr-onread-user-home-'));
61
61
  process.env['HOME'] = fakeHome;
62
62
  resetScopeCache();
63
- const node = spawnNode({ kind: 'general', cwd: join(fakeHome, 'work'), parent: null }).node_id;
63
+ const node = (await spawnNode({ kind: 'general', cwd: join(fakeHome, 'work'), parent: null })).node_id;
64
64
  const userMemDir = join(fakeHome, '.crouter', 'memory');
65
65
  mkdirSync(userMemDir, { recursive: true });
66
66
  writeFileSync(join(userMemDir, 'noisy-global.md'), '---\nkind: preference\n' +
@@ -79,8 +79,8 @@ test('project and user memory docs without read surfaces do not fire on file rea
79
79
  assert.equal(rendered, '', 'no file context renders without an explicit trigger');
80
80
  rmSync(fakeHome, { recursive: true, force: true });
81
81
  });
82
- test('on-read exposure survives a strict resume and resets for a fresh context', () => {
83
- const node = spawnNode({ kind: 'general', cwd: work, parent: null }).node_id;
82
+ test('on-read exposure survives a strict resume and resets for a fresh context', async () => {
83
+ const node = (await spawnNode({ kind: 'general', cwd: work, parent: null })).node_id;
84
84
  // A file-routed substrate doc in a `.crouter/memory/` ancestor. The
85
85
  // `./`-anchored glob pins the repo-relative anchor: `src/file.ts` matches
86
86
  // only relative to the doc's owning repo dir, never absolutely.
@@ -111,8 +111,8 @@ test('on-read exposure survives a strict resume and resets for a fresh context',
111
111
  const stateFresh = loadContextExposureState(node);
112
112
  assert.ok(renderOnReadDocs(node, readFile, exposureTarget(stateFresh, 'transcript')).includes(FIXTURE_BODY), 'a fresh transcript surfaces the doc again');
113
113
  });
114
- test('an explicitly routed doc injects on-read under its frontmatter name, not the path-derived one', () => {
115
- const node = spawnNode({ kind: 'general', cwd: work, parent: null }).node_id;
114
+ test('an explicitly routed doc injects on-read under its frontmatter name, not the path-derived one', async () => {
115
+ const node = (await spawnNode({ kind: 'general', cwd: work, parent: null })).node_id;
116
116
  const memDir = join(work, '.crouter', 'memory');
117
117
  declareRepositoryRoot(memDir);
118
118
  writeFileSync(join(memDir, 'physical-guide.md'), '---\nkind: knowledge\n' +