@north-light/crouter 0.3.235 → 0.3.236

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.
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,58 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { createServer } from 'node:http';
4
+ import { mkdtempSync, rmSync } from 'node:fs';
5
+ import { join } from 'node:path';
6
+ import { parseArgv } from '../../core/command.js';
7
+ import { nodeNew } from '../node/create.js';
8
+ function listen(server, path) {
9
+ return new Promise((resolve, reject) => {
10
+ server.once('error', reject);
11
+ server.listen(path, resolve);
12
+ });
13
+ }
14
+ function close(server) {
15
+ return new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
16
+ }
17
+ test('node new forwards --resident in the create request', async () => {
18
+ const previousHome = process.env['CRTR_HOME'];
19
+ const home = mkdtempSync('/tmp/crtr-node-new-resident-');
20
+ const socketPath = join(home, 'crtrd.sock');
21
+ let requestBody;
22
+ const server = createServer((req, res) => {
23
+ const chunks = [];
24
+ req.on('data', (chunk) => chunks.push(chunk));
25
+ req.on('end', () => {
26
+ requestBody = JSON.parse(Buffer.concat(chunks).toString('utf8'));
27
+ const body = JSON.stringify({
28
+ node_id: 'resident-test',
29
+ name: 'Review worker',
30
+ status: 'active',
31
+ cwd: home,
32
+ });
33
+ res.writeHead(201, { 'content-type': 'application/json', 'content-length': Buffer.byteLength(body) });
34
+ res.end(body);
35
+ });
36
+ });
37
+ process.env['CRTR_HOME'] = home;
38
+ try {
39
+ await listen(server, socketPath);
40
+ const input = await parseArgv(nodeNew.help.params ?? [], [
41
+ 'Review the worker.',
42
+ '--root',
43
+ '--resident',
44
+ '--name',
45
+ 'Review worker',
46
+ ]);
47
+ await nodeNew.run(input);
48
+ assert.equal(requestBody?.['root_lifecycle'], 'resident');
49
+ }
50
+ finally {
51
+ await close(server);
52
+ if (previousHome === undefined)
53
+ delete process.env['CRTR_HOME'];
54
+ else
55
+ process.env['CRTR_HOME'] = previousHome;
56
+ rmSync(home, { recursive: true, force: true });
57
+ }
58
+ });
@@ -81,6 +81,7 @@ function nodeNewParams() {
81
81
  { kind: 'flag', name: 'parent', type: 'string', required: false, constraint: 'Parent node id. Defaults to the calling node.' },
82
82
  { kind: 'flag', name: 'node-id', type: 'string', required: false, constraint: 'Exact id for the new node; lowercase letters, digits, and hyphens only, at most 128 characters. Rejected if already present.' },
83
83
  { kind: 'flag', name: 'root', type: 'bool', required: false, constraint: 'Spawn an independent resident root with no parent or subscription. Mutually exclusive with --worktree.' },
84
+ { kind: 'flag', name: 'resident', type: 'bool', required: false, constraint: 'Birth the node as resident: it parks and stays wakeable without being forced to submit a final. Applies to managed children and roots; roots default to resident.' },
84
85
  { kind: 'flag', name: 'worktree', type: 'string', required: false, constraint: 'Local base branch for a crouter-managed worktree for this child. Mutually exclusive with --root.' },
85
86
  { kind: 'flag', name: 'fork-from', type: 'string', required: false, constraint: 'Fork the new node from an existing node id, absolute session path, or partial pi session uuid.' },
86
87
  {
@@ -179,6 +180,7 @@ async function runNodeCreation(input) {
179
180
  const name = input['name'];
180
181
  const parent = input['parent'];
181
182
  const root = input['root'] === true;
183
+ const resident = input['resident'] === true;
182
184
  const worktreeBaseRaw = input['worktree'];
183
185
  if (worktreeBaseRaw !== undefined && worktreeBaseRaw.trim() === '') {
184
186
  throw new InputError({ error: 'worktree_base_required', message: '--worktree requires a base branch', field: 'worktree', next: 'Pass an existing local branch that landing can fast-forward.' });
@@ -221,6 +223,7 @@ async function runNodeCreation(input) {
221
223
  ...(name !== undefined ? { name } : {}),
222
224
  prompt,
223
225
  ...(root ? { root: true } : { parent: parentId ?? null }),
226
+ ...(resident ? { root_lifecycle: 'resident' } : {}),
224
227
  ...(worktreeBase !== undefined ? { worktree: worktreeBase } : {}),
225
228
  ...(forkFrom !== undefined ? { fork_from: forkFrom } : {}),
226
229
  ...(modelSpec !== undefined ? { model: modelSpec } : {}),
@@ -247,7 +247,7 @@ export const nodePromote = defineLeaf({
247
247
  summary: 'promote yourself to an orchestrator when worthwhile parallel delegation should make coordination your primary job; task duration, sequential phases, or one helper do not qualify. Mode only — lifecycle stays as-is unless you pass --resident',
248
248
  params: [
249
249
  { kind: 'flag', name: 'kind', type: 'string', required: false, constraint: 'Specialize as this kind of orchestrator. The <kinds> list below names every top-level installable kind and when to use each; a registered sub-kind is valid too by exact path but not listed here. For an exact composed persona list, use `crtr sys prompt-review --list --kind <kind>` with the target kind. Defaults to your current kind. Promoting from a generic kind? CHOOSE a concrete one — it sets the orchestrator persona you revive into.' },
250
- { kind: 'flag', name: 'resident', type: 'bool', required: false, constraint: 'ALSO flip lifecycle→resident: make the node interactable — it is never forced to submit a final, stays wakeable while attached or awaiting something live, and completes after the unattended grace otherwise. Omit to stay terminal/orchestrator (delegates + holds a roadmap, but still owes a final up the spine and reaps when done).' },
250
+ { kind: 'flag', name: 'resident', type: 'bool', required: false, constraint: 'ALSO flip lifecycle→resident: make the node interactable — it is never forced to submit a final, and unattended grace parks it after a summary turn instead, leaving it revivable fresh against its goal and roadmap. Omit to stay terminal/orchestrator (delegates + holds a roadmap, but still owes a final up the spine and reaps when done).' },
251
251
  { kind: 'flag', name: 'model', type: 'enum', choices: ['ultra', 'strong'], required: false, constraint: 'Change the model you run on, by capability tier: `ultra` (frontier — reserve for high-taste judgment or enormous work: speccing, planning something large, e2e-testing something hard to test) or `strong` (opus — regular dev work). Omit to keep your current model. An orchestrator steering work is never weaker than opus, so these are the only two choices; the change is durable across future revives.' },
252
252
  { kind: 'flag', name: 'node', type: 'string', required: false, constraint: 'Node to promote. Defaults to the caller (CRTR_NODE_ID).' },
253
253
  ],
@@ -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
+ });
@@ -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);
@@ -22,7 +22,7 @@ import assert from 'node:assert/strict';
22
22
  import { existsSync, readFileSync, writeFileSync } from 'node:fs';
23
23
  import { join } from 'node:path';
24
24
  import { appendInbox, readCursor, readInboxSince } from '../../feed/inbox.js';
25
- import { registerWarmSpare, subscribe, updateNode } from '../../canvas/index.js';
25
+ import { registerWarmSpare, subscribe, subscriptionsOf, updateNode } from '../../canvas/index.js';
26
26
  import { isPidAlive } from '../../canvas/pid.js';
27
27
  import { reviveNode } from '../../runtime/revive.js';
28
28
  import { createHarness } from '../helpers/harness.js';
@@ -349,6 +349,75 @@ test('the unattended park clock cancels an obligation-free terminal node and spa
349
349
  process.env['CRTR_TEST_UNATTENDED_PARK_MS'] = priorParkMs;
350
350
  }
351
351
  });
352
+ test('a resident managed child parks without finalizing while a terminal child is settled by the same grace sweep', { timeout: 40_000 }, async () => {
353
+ const priorParkMs = process.env['CRTR_TEST_UNATTENDED_PARK_MS'];
354
+ const priorGraceMs = process.env['CRTR_TEST_PARK_SUMMARY_GRACE_MS'];
355
+ process.env['CRTR_TEST_UNATTENDED_PARK_MS'] = '1500';
356
+ process.env['CRTR_TEST_PARK_SUMMARY_GRACE_MS'] = '1000';
357
+ try {
358
+ const resident = h.fabricateBrokerNode({
359
+ id: 'resident-managed-child',
360
+ parent: root,
361
+ lifecycle: 'resident',
362
+ status: 'idle',
363
+ intent: 'idle-release',
364
+ pi_session_id: 'resident-managed-session',
365
+ });
366
+ subscribe(root, resident, true);
367
+ // Keep a managed terminal child in the same sweep, but make its parent
368
+ // historical so no live subscriber remains to hold an unfinished report.
369
+ const terminalManager = h.fabricateBrokerNode({ id: 'terminal-manager', status: 'done' });
370
+ const terminal = h.fabricateBrokerNode({
371
+ id: 'terminal-managed-child',
372
+ parent: terminalManager,
373
+ lifecycle: 'terminal',
374
+ status: 'idle',
375
+ intent: 'idle-release',
376
+ pi_session_id: 'terminal-managed-session',
377
+ });
378
+ subscribe(terminalManager, terminal, true);
379
+ const boot = async (id) => {
380
+ updateNode(id, { launch: { extensions: [], tools: [], systemPrompt: '', env: {} } });
381
+ reviveNode(id, { resume: true });
382
+ return (await h.awaitBoot(id)).pid;
383
+ };
384
+ const residentPid = await boot(resident);
385
+ const terminalPid = await boot(terminal);
386
+ const seenAt = 50_000;
387
+ await h.tick(seenAt);
388
+ // Clock expiry arms the resident's pending park and requests its summary
389
+ // turn; the terminal child is cancelled outright by the same pass.
390
+ await h.tick(seenAt + 1500);
391
+ // Summary grace expiry: the degraded enactment parks without a report.
392
+ await h.tick(seenAt + 1500 + 1000);
393
+ await h.waitFor(() => h.node(resident)?.status === 'done' && h.node(resident)?.intent === 'parked' && !isPidAlive(residentPid), {
394
+ timeoutMs: 10_000,
395
+ intervalMs: 20,
396
+ label: 'resident managed child parks and releases its broker',
397
+ });
398
+ await h.waitFor(() => h.node(terminal)?.status === 'canceled' && !isPidAlive(terminalPid), {
399
+ timeoutMs: 10_000,
400
+ intervalMs: 20,
401
+ label: 'terminal managed child settles and releases its broker',
402
+ });
403
+ await h.awaitFleetExit(resident);
404
+ await h.awaitFleetExit(terminal);
405
+ assert.equal(h.node(resident)?.intent, 'parked', 'resident child carries the durable park marker, not a final');
406
+ assert.equal(h.node(resident)?.final_report ?? null, null, 'resident child has no forced final report');
407
+ assert.ok(subscriptionsOf(root).some((edge) => edge.node_id === resident && edge.active), 'resident child keeps its active parent subscription');
408
+ assert.equal(h.node(terminal)?.status, 'canceled', 'terminal child is settled by unattended grace without a final');
409
+ }
410
+ finally {
411
+ if (priorParkMs === undefined)
412
+ delete process.env['CRTR_TEST_UNATTENDED_PARK_MS'];
413
+ else
414
+ process.env['CRTR_TEST_UNATTENDED_PARK_MS'] = priorParkMs;
415
+ if (priorGraceMs === undefined)
416
+ delete process.env['CRTR_TEST_PARK_SUMMARY_GRACE_MS'];
417
+ else
418
+ process.env['CRTR_TEST_PARK_SUMMARY_GRACE_MS'] = priorGraceMs;
419
+ }
420
+ });
352
421
  test('an unattended resident awaiting a live child remains wakeable', { timeout: 30_000 }, async () => {
353
422
  const priorParkMs = process.env['CRTR_TEST_UNATTENDED_PARK_MS'];
354
423
  process.env['CRTR_TEST_UNATTENDED_PARK_MS'] = '1500';
@@ -11,8 +11,8 @@
11
11
  * only `dead` is a fault. */
12
12
  export type NodeStatus = 'active' | 'idle' | 'done' | 'dead' | 'canceled';
13
13
  /** Does stopping finalize the node? terminal = worker (finalizes on push --final);
14
- * resident = interactable while attached or holding a live obligation. An
15
- * unattended obligation-free resident completes after the daemon grace. */
14
+ * resident = an interactable conversation that parks and remains wakeable
15
+ * without being forced to submit a final. */
16
16
  export type Lifecycle = 'terminal' | 'resident';
17
17
  /** base = hands-on worker; orchestrator = delegating manager. Bespoke per kind. */
18
18
  export type Mode = 'base' | 'orchestrator';
@@ -32,7 +32,7 @@ export async function resolveLaunchTarget(req, options = {}) {
32
32
  await assertLaunchKind(kind, cwd, profileId);
33
33
  const mode = (req.mode ?? 'base');
34
34
  const launch = (await buildLaunchSpecAsync(kind, mode, {
35
- lifecycle: req.root === true ? (req.root_lifecycle ?? 'resident') : 'terminal',
35
+ lifecycle: req.root_lifecycle ?? (req.root === true ? 'resident' : 'terminal'),
36
36
  hasManager: req.root !== true,
37
37
  ...(req.model !== undefined ? { model: req.model } : {}),
38
38
  cwd,
@@ -15,19 +15,11 @@ export interface SpawnChildOpts {
15
15
  * subscription back to the spawner, spawned_by=spawner. Resident and brought
16
16
  * forefront by default — see `rootLifecycle` for the unattended variant. */
17
17
  root?: boolean;
18
- /** Only meaningful with `root`. The independent node's lifecycle.
19
- *
20
- * `'resident'` (default) is the ATTENDED root: a standing conversation a
21
- * human picks up, so it owes no final, never reaps on its own, and is
22
- * brought forefront in tmux on spawn.
23
- *
24
- * `'terminal'` is the UNATTENDED root: still parentless (nobody subscribes,
25
- * so nothing receives its push) but it still owes a final, so it reaps when
26
- * its work is done — and it is NEVER brought forefront. This is what
27
- * machinery firing with no human present must birth: the cron executor
28
- * spawns one when its cron's creating node is gone. A resident root there
29
- * would accrete one permanently-live node per fire (the count-based prune
30
- * never deletes live nodes) and steal the human's tmux focus every time. */
18
+ /** Lifecycle for the new node. Roots default to resident and managed
19
+ * children default to terminal. A resident child remains an interactable,
20
+ * wakeable conversation without owing a final; a terminal child remains a
21
+ * bounded worker that must finalize. For roots, terminal keeps the
22
+ * unattended no-viewer behavior. */
31
23
  rootLifecycle?: Lifecycle;
32
24
  /** Direct root creation waits until the broker accepts viewers before it
33
25
  * returns. Cron-launched births use `launch`: the daemon must never wait in
@@ -209,16 +209,10 @@ export async function spawnChildPrepared(opts, beforeBrokerLaunch) {
209
209
  const root = opts.root === true;
210
210
  const spawner = resolveSpawner(opts.parent, ctx.nodeId ?? null, root);
211
211
  const mode = opts.mode ?? 'base';
212
- // Lifecycle keys on ROOT-ness only, independent of mode: an independent root
213
- // (or `--root`) is resident by default (a conversation that persists, woken by
214
- // inbox/human); every spawned child is terminal — it owes a final up the spine
215
- // and reaps when done. A child born as an orchestrator is terminal/orchestrator
216
- // (delegates + holds a roadmap, but still reports up), NOT resident.
217
- //
218
- // `rootLifecycle: 'terminal'` is the one deliberate exception: a parentless
219
- // node that STILL owes a final (see the option's docblock). It is unattended
220
- // by construction, so `attendedRoot` below also suppresses viewer placement.
221
- const lifecycle = root ? (opts.rootLifecycle ?? 'resident') : 'terminal';
212
+ // Roots are resident by default; managed children are terminal workers by
213
+ // default. The one lifecycle option deliberately applies to both shapes so a
214
+ // child can be born as a standing, wakeable conversation too.
215
+ const lifecycle = opts.rootLifecycle ?? (root ? 'resident' : 'terminal');
222
216
  const attendedRoot = root && lifecycle === 'resident';
223
217
  // Born WITHOUT a name — the canvas-goal-capture extension names it async from
224
218
  // its first message (the kickoff task) inside its own pi process, so spawn
@@ -108,9 +108,6 @@ function parseCreateBody(body) {
108
108
  rootLifecycle !== 'terminal') {
109
109
  throw usage("root_lifecycle must be 'resident' or 'terminal'");
110
110
  }
111
- if (rootLifecycle !== undefined && b['root'] !== true) {
112
- throw usage('root_lifecycle is only valid with root: true');
113
- }
114
111
  return b;
115
112
  }
116
113
  /** Build the immediate spawn recipe. */
@@ -14,6 +14,7 @@
14
14
  import { envNodeId, envProfileId, envWatcherDebounceMs, envWatcherTickMs } from '../shared/env.js';
15
15
  import { coalesceBrokerInbox, entryCards, readBrokerCanceledEntryIds, readBrokerCursor, readBrokerInboxSince, reportRefNodeId, } from '../core/runtime/broker/inbox.js';
16
16
  import { advanceBrokerInboxCursor, brokerExtensionState } from '../core/runtime/broker/daemon-ops.js';
17
+ import { captureGoalIfAbsent, generateAndCommitInitialName } from './broker-local.js';
17
18
  import { emitEvent } from '../core/events/emit.js';
18
19
  import { errorClassFromError } from '../core/events/errors.js';
19
20
  import { operationIdContext } from '../core/events/operation-id.js';
@@ -795,6 +796,17 @@ class CanvasInboxWatcher {
795
796
  for (const entry of delivery.entries)
796
797
  requeueEntryIds.add(entry.entry_id);
797
798
  }
799
+ else if (delivery.kind === 'human') {
800
+ // A person's own words, delivered through the durable inbox because the
801
+ // broker was cold when they arrived (crtrd's revive path, not pi's live
802
+ // socket). pi never fires its own `input` event for this path, so
803
+ // canvas-goal-capture's first-message hooks never run — mirror both here
804
+ // so an inbox-delivered first message names the node and survives a
805
+ // fresh revive exactly like a live-socket one. Both helpers are
806
+ // idempotent (no-op once a goal/description already exists).
807
+ captureGoalIfAbsent(nodeId, delivery.text);
808
+ generateAndCommitInitialName(nodeId, delivery.text);
809
+ }
798
810
  }
799
811
  return { inactive: false, requeueEntryIds };
800
812
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@north-light/crouter",
3
- "version": "0.3.235",
3
+ "version": "0.3.236",
4
4
  "description": "crtr — agent runtime with memory, plugins, and marketplaces",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
package/runtime.lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@north-light/crouter",
3
- "version": "0.3.235",
3
+ "version": "0.3.236",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@north-light/crouter",
9
- "version": "0.3.235",
9
+ "version": "0.3.236",
10
10
  "hasInstallScript": true,
11
11
  "license": "MIT",
12
12
  "dependencies": {