@north-light/crouter 0.3.241 → 0.3.242

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 (56) hide show
  1. package/dist/api/dto/broker-ops.d.ts +8 -3
  2. package/dist/api/dto/broker.d.ts +4 -2
  3. package/dist/api/dto/inbox.d.ts +1 -0
  4. package/dist/api/dto/nodes.d.ts +4 -0
  5. package/dist/api/dto/review-comments.d.ts +1 -0
  6. package/dist/builtin-memory/04-orchestration-kernel.md +1 -1
  7. package/dist/clients/attach/session/profile-files.js +4 -1
  8. package/dist/clients/attach/viewer.js +366 -366
  9. package/dist/commands/node/create.js +8 -3
  10. package/dist/commands/sys/daemon.js +1 -1
  11. package/dist/core/__tests__/child-death-wake.test.js +0 -48
  12. package/dist/core/__tests__/daemon-boot.test.js +0 -8
  13. package/dist/core/__tests__/dead-node-policy-table.test.js +1 -2
  14. package/dist/core/__tests__/integration/deferred-no-wake.test.js +14 -2
  15. package/dist/core/__tests__/lifecycle.test.js +5 -5
  16. package/dist/core/__tests__/relaunch-root.test.js +91 -1
  17. package/dist/core/__tests__/revive-capacity.test.js +36 -1
  18. package/dist/core/__tests__/seam/broker-cap-freeze.test.js +73 -0
  19. package/dist/core/__tests__/seam/dormancy-release.test.js +1 -2
  20. package/dist/core/canvas/canvas.d.ts +6 -5
  21. package/dist/core/canvas/canvas.js +8 -7
  22. package/dist/core/canvas/types.d.ts +6 -0
  23. package/dist/core/review/realize.js +1 -1
  24. package/dist/core/runtime/broker/node-named.d.ts +1 -0
  25. package/dist/core/runtime/host.js +114 -103
  26. package/dist/core/runtime/lifecycle.js +3 -2
  27. package/dist/core/runtime/naming.d.ts +26 -8
  28. package/dist/core/runtime/naming.js +75 -43
  29. package/dist/core/runtime/reset.js +12 -5
  30. package/dist/core/runtime/revive-all.d.ts +4 -11
  31. package/dist/core/runtime/revive-all.js +6 -13
  32. package/dist/core/runtime/warm-pool.js +4 -4
  33. package/dist/daemon/api/__tests__/node-create-description.test.js +1 -0
  34. package/dist/daemon/api/handlers/broker-ops.js +14 -7
  35. package/dist/daemon/api/handlers/feedback-comments.js +1 -1
  36. package/dist/daemon/api/map.js +2 -0
  37. package/dist/daemon/crtrd.js +17 -20
  38. package/dist/daemon/messaging/node-message.d.ts +1 -0
  39. package/dist/daemon/messaging/node-message.js +6 -2
  40. package/dist/daemon/reconcilers/live-obligation.d.ts +1 -2
  41. package/dist/daemon/reconcilers/node-lifecycle/freeze-lane.d.ts +0 -5
  42. package/dist/daemon/reconcilers/node-lifecycle/freeze-lane.js +9 -13
  43. package/dist/daemon/reconcilers/node-lifecycle/respawn-policy.js +0 -6
  44. package/dist/daemon/reconcilers/node-lifecycle/terminating.d.ts +0 -2
  45. package/dist/daemon/reconcilers/node-lifecycle/terminating.js +0 -10
  46. package/dist/daemon/reconcilers/node-lifecycle/tick.d.ts +2 -2
  47. package/dist/daemon/reconcilers/node-lifecycle/tick.js +4 -2
  48. package/dist/daemon/reconcilers/storage-maintenance.js +2 -2
  49. package/dist/daemon/review/comment-notify.d.ts +1 -0
  50. package/dist/daemon/review/comment-notify.js +1 -0
  51. package/dist/pi-extensions/broker-local.d.ts +1 -0
  52. package/dist/pi-extensions/broker-local.js +12 -7
  53. package/dist/pi-extensions/canvas-recap.js +1 -0
  54. package/dist/pi-extensions/canvas-stophook.js +2 -0
  55. package/package.json +1 -1
  56. package/runtime.lock.json +2 -2
@@ -75,7 +75,7 @@ function nodeNewParams() {
75
75
  return [
76
76
  { kind: 'stdin', name: 'prompt', required: false, constraint: 'First user message.' },
77
77
  { kind: 'flag', name: 'kind', type: 'string', required: false, constraint: "Persona kind. Defaults to the profile's default kind; `general` when the profile has none. The <kinds> list below names every top-level installable kind." },
78
- { kind: 'flag', name: 'mode', type: 'enum', choices: ['base', 'orchestrator'], required: false, default: 'base', constraint: 'Persona mode. base for hands-on work; orchestrator when the unit itself needs decomposition across children.' },
78
+ { kind: 'flag', name: 'mode', type: 'enum', choices: ['base', 'orchestrator'], required: false, default: 'base', constraint: 'Persona mode. base for one hands-on unit; orchestrator when the unit itself needs decomposition across children. When you hold several parallel units of one kind, spawn ONE orchestrator child of that kind to own the fan-out rather than spawning the units yourself — unless you are already an orchestrator of that kind, whose job is exactly that fan-out.' },
79
79
  { kind: 'flag', name: 'cwd', type: 'path', required: false, constraint: 'Pin the node to this directory. Defaults to the spawner\u2019s directory for a managed child, and to the directory this command runs in for a root.' },
80
80
  { kind: 'flag', name: 'name', type: 'string', required: false, constraint: 'Display name.' },
81
81
  { kind: 'flag', name: 'parent', type: 'string', required: false, constraint: 'Parent node id. Defaults to the calling node.' },
@@ -147,6 +147,7 @@ function nodeNewOutput() {
147
147
  return [
148
148
  { name: 'node_id', type: 'string', required: true, constraint: 'New node id.' },
149
149
  { name: 'name', type: 'string', required: true, constraint: 'Display name.' },
150
+ { name: 'frozen_at', type: 'string', required: false, constraint: 'Present when a managed child exists but capacity deferred its broker start.' },
150
151
  ...(inTmux()
151
152
  ? [
152
153
  { name: 'window', type: 'string', required: false, constraint: 'tmux window id when a viewer opens.' },
@@ -242,23 +243,27 @@ async function runNodeCreation(input) {
242
243
  placement = openSpawnViewer(detail.node_id, detail.cwd, detail.name);
243
244
  if (root)
244
245
  mark('node_new.viewer_window_opened');
246
+ const frozen = !root && detail.frozen_at !== null;
245
247
  const result = {
246
248
  node_id: detail.node_id,
247
249
  name: detail.name,
250
+ ...(frozen ? { frozen_at: detail.frozen_at } : {}),
248
251
  window: placement.window,
249
252
  session: placement.session,
250
253
  follow_up: root
251
254
  ? (noKickoff
252
255
  ? 'Independent root spawned idle. No kickoff turn ran; hand it off or attach and prompt it. No report wakes you.'
253
256
  : 'Independent root spawned. You are not subscribed, so its finish does not wake you; hand it off and move on.')
254
- : await childFollowUp(parentId),
257
+ : (frozen
258
+ ? 'Child row exists, but no broker started because capacity is full. It is frozen and will launch when a broker slot frees; its final report wakes you then.'
259
+ : await childFollowUp(parentId)),
255
260
  };
256
261
  return result;
257
262
  }
258
263
  export const nodeNew = defineLeaf({
259
264
  name: 'new',
260
265
  description: 'create and start a node immediately',
261
- whenToUse: 'a unit of work is genuinely separable from what you are doing: a bounded subtask you would otherwise grind out inline, several independent units worth running in parallel, a scouting pass over unfamiliar code, a change that wants its own git worktree, or an answer you need in a fixed shape rather than as prose',
266
+ whenToUse: 'a unit of work is genuinely separable from what you are doing: a bounded subtask you would otherwise grind out inline, several independent units worth running in parallel (usually via one orchestrator child of the matching kind rather than a flat fan-out from you), a scouting pass over unfamiliar code, a change that wants its own git worktree, or an answer you need in a fixed shape rather than as prose',
262
267
  tier: 'important',
263
268
  help: {
264
269
  name: 'node new',
@@ -80,7 +80,7 @@ const daemonRestart = defineLeaf({
80
80
  effects: [
81
81
  'The daemon acknowledges immediately, then after a short grace tears its whole broker fleet down, releases its claim, spawns a successor on the runtime generation currently selected, and exits.',
82
82
  'Each torn-down broker hands its foreground bash commands to the file-backed background job system; they keep running and report completion by urgent inbox message.',
83
- 'Every node it tore down — including the caller — is resumed by the successor through the ordinary startup recovery sweep.',
83
+ 'Every node it tore down — including the caller — is resolved by the successor through the ordinary row-driven supervision tick.',
84
84
  'The calling process is killed as part of that teardown, AFTER this result is returned.',
85
85
  ],
86
86
  },
@@ -1,30 +1,4 @@
1
1
  // Run with: node --import tsx/esm --test src/core/__tests__/child-death-wake.test.ts
2
- //
3
- // DOCTRINE WAKE — "a parent that delegated and went dormant is still woken when
4
- // its child reaches a genuine terminal outcome, and is NEVER spuriously woken on
5
- // healthy dormancy." Drives the REAL daemon decision pass (superviseTick) + the
6
- // REAL closeNode against canvas rows fabricated DIRECTLY in an isolated home — NO
7
- // real tmux session, NO broker boot.
8
- //
9
- // BROKER CUT (this worktree): the daemon's OLD parent-wake mechanism is GONE.
10
- // U7 deleted surfaceChildDeath and the entire pane-gone reaping block (the
11
- // crash/finalize/release routing that used to mark a pane-gone child dead/done/
12
- // idle and fan a system inbox entry to its subscribers). Under exit-event
13
- // authority a viewer pane/window closing is NOT a node death, and the tick runs
14
- // no liveness pass at all — a booted child's death is observed only through its
15
- // broker's real exit event (fleet exit-policy job), which recovery-revives it
16
- // on its saved session rather than surfacing it as dead. The doctrine wake
17
- // therefore RELOCATED off the daemon's tick:
18
- // • child finishes → `crtr push final` wakes subscribers via the PUSH itself;
19
- // • child crashes → its exit event drives a recovery revive (it comes back
20
- // and pushes later);
21
- // • child never boots → the exit event terminalizes it and surfaceBootFailure
22
- // pushes urgent (daemon-boot.test);
23
- // • child `node close`d → close.ts fans the child-closed wake (tested below).
24
- // So the daemon now fans NO liveness wake at all, which makes the old CRUX
25
- // (healthy dormancy must not wake the parent) hold by construction. This file
26
- // locks in (a) the new pid-only non-reaping + no-daemon-wake contract, and (b)
27
- // the one genuine-terminal wake that still lives in core: close.ts.
28
2
  import { test, before, after, beforeEach } from 'node:test';
29
3
  import assert from 'node:assert/strict';
30
4
  import { mkdtempSync, rmSync, mkdirSync, writeFileSync } from 'node:fs';
@@ -112,15 +86,6 @@ after(() => {
112
86
  delete process.env['CRTR_HOME'];
113
87
  resetFleetForTests();
114
88
  });
115
- // BROKER CUT — exit-event authority REPLACES daemon pane-gone reaping. A booted
116
- // child whose engine pid is dead (and whose viewer pane is gone) is RECOVERED,
117
- // not reaped, and the daemon's tick fans NO wake to the dormant parent. This
118
- // single case subsumes the four deleted scenarios (crash / quiet-finalize /
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
89
  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
90
  const originalLaunch = headlessBrokerHost.launch;
126
91
  const launches = [];
@@ -139,9 +104,6 @@ test('exit-event authority: a dead-engine booted child is recovered, NOT reaped,
139
104
  // its saved session, never reaped to dead/done/idle.
140
105
  assert.deepEqual(launches, ['CHILD'], 'the tick recovers the dead engine through the policy table');
141
106
  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
107
  assert.equal(readInboxSince('PARENT').length, 0, 'PARENT inbox EMPTY — no daemon liveness wake');
146
108
  }
147
109
  finally {
@@ -163,13 +125,3 @@ test('node close of a child wakes its SURVIVING manager (the parent outside the
163
125
  assert.ok(wake, 'node close fanned a child-closed entry to the surviving manager (D-1: previously none)');
164
126
  assert.match(wake.label, /closed/i, 'the entry names the closed child');
165
127
  });
166
- // NOTE (broker cut): the two NEGATIVE "CRUX" tests that used to live here — a
167
- // child dormant while awaiting a live grandchild, and a child dormant on a
168
- // pending self-wake — asserted the daemon's pane-gone routing chose idle-release
169
- // (status 'idle', intent 'idle-release') over finalize, so it would not spuriously
170
- // wake the parent. U7 DELETED that routing entirely: pane state is ignored, the
171
- // daemon never finalizes/releases on liveness, and surfaceChildDeath is gone, so
172
- // the daemon raises no liveness wake at all. The no-spurious-wake invariant they
173
- // protected now holds by construction and is covered by the pid-only test above
174
- // (PARENT inbox stays empty). Removed rather than adapted because the
175
- // idle-release-vs-finalize distinction they asserted no longer exists.
@@ -93,12 +93,6 @@ test('never-booted broker exit is marked dead and surfaced to the parent as urge
93
93
  assert.ok(wake, 'the doctrine wake rides beside the urgent push');
94
94
  assert.match(wake.label, /crashed/i);
95
95
  });
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
96
  test('a crashed booted child is relaunched on its saved session, not reaped, and raises no false boot-failure alarm', async () => {
103
97
  const originalLaunch = headlessBrokerHost.launch;
104
98
  const launches = [];
@@ -123,8 +117,6 @@ test('a crashed booted child is relaunched on its saved session, not reaped, and
123
117
  await superviseTick();
124
118
  assert.deepEqual(launches, ['C2'], 'the tick recovers the crash through the policy table, exactly as the exit event would');
125
119
  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
120
  assert.equal(readInboxSince('P2').length, 0, 'no false boot-failure / child-death push on a crash (wake relocated to push/close)');
129
121
  clearBusy('C2');
130
122
  }
@@ -2,8 +2,7 @@
2
2
  //
3
3
  // The invariant-5 policy table (design flow B): `classifyDeadNode` is the ONE
4
4
  // pure function mapping a known-dead node's durable row state to its recovery
5
- // action, used at exactly two call sites — the exit-policy job and the startup
6
- // recovery sweep — so exit recovery and startup recovery can never diverge.
5
+ // action, used by the exit-policy job and the row-driven supervision tick.
7
6
  // This file pins every table row directly.
8
7
  import { test } from 'node:test';
9
8
  import assert from 'node:assert/strict';
@@ -44,7 +44,7 @@ import { tmpdir } from 'node:os';
44
44
  import { join } from 'node:path';
45
45
  import { spawnSync } from 'node:child_process';
46
46
  import { DatabaseSync } from 'node:sqlite';
47
- import { createNode, deleteNode, getNode, setStatus, withFreshTerminalGuard } from '../../canvas/canvas.js';
47
+ import { createNode, deleteNode, getNode, setFrozen, setStatus, withFreshTerminalGuard } from '../../canvas/canvas.js';
48
48
  import { hasNoNaturalCycle } from '../../runtime/revive-all.js';
49
49
  import { canvasDbPath, nodeDir, apiSocketPath } from '../../canvas/paths.js';
50
50
  import { closeDb } from '../../canvas/db.js';
@@ -170,7 +170,7 @@ test('node message send --tier deferred to an ordinary dormant (idle-released) t
170
170
  assert.equal(after.status, 'idle', 'target left exactly where it was');
171
171
  assert.equal(after.intent, 'idle-release', 'target left exactly where it was');
172
172
  });
173
- test('node message send --tier deferred to a terminal target (done/canceled — no natural next cycle) is rejected before any side effect', { timeout: 30_000 }, async () => {
173
+ test('node message send --tier deferred rejects ordinary terminal targets but accepts a frozen terminal target with a thaw cycle owed', { timeout: 30_000 }, async () => {
174
174
  for (const status of ['done', 'canceled']) {
175
175
  const id = `terminal-${status}`;
176
176
  createNode(node(id, { status, intent: null }));
@@ -190,6 +190,18 @@ test('node message send --tier deferred to a terminal target (done/canceled —
190
190
  assert.equal(launchCalls, 0, `${status}: a rejected deferred must never attempt a revive`);
191
191
  assert.equal(readInboxSince(id, undefined).length, inboxBefore, `${status}: a rejected deferred must not append an inbox entry`);
192
192
  }
193
+ const frozenId = 'terminal-frozen';
194
+ createNode(node(frozenId, {
195
+ lifecycle: 'resident',
196
+ status: 'done',
197
+ intent: 'parked',
198
+ }));
199
+ setFrozen(frozenId, new Date().toISOString());
200
+ const inboxBefore = readInboxSince(frozenId, undefined).length;
201
+ const result = await messageSendLeaf().run({ to: frozenId, tier: 'deferred', body: 'held for thaw' });
202
+ assert.equal(result['guidance'], "Delivered — held for the target's next natural cycle; not revived.", 'a frozen terminal row still has a thaw cycle');
203
+ assert.equal(result['revived'], false, 'deferred mail waits for the thaw without launching');
204
+ assert.equal(readInboxSince(frozenId, undefined).length, inboxBefore + 1, 'the deferred message is durably appended');
193
205
  });
194
206
  // #343 third-fix-cycle review: the IMMEDIATE `--tier deferred` path had its
195
207
  // own, separate TOCTOU — a stale-snapshot check (`rejectDeferredWithoutNaturalCycle`)
@@ -133,17 +133,17 @@ test('yield: illegal from done|dead|canceled → throws', () => {
133
133
  assert.throws(() => transition(`n_${from}`, 'yield'), /illegal lifecycle transition/);
134
134
  }
135
135
  });
136
- // release → idle + intent='idle-release', legal only from active|idle
137
- test('release: active|idle → idle + intent=idle-release', () => {
138
- for (const from of LIVE) {
136
+ // release → idle + intent='idle-release', legal from active|idle|done
137
+ test('release: active|idle|done → idle + intent=idle-release', () => {
138
+ for (const from of [...LIVE, 'done']) {
139
139
  mk(`n_${from}`, from);
140
140
  const m = transition(`n_${from}`, 'release');
141
141
  assert.equal(m.status, 'idle', `release from ${from}`);
142
142
  assert.equal(m.intent, 'idle-release', `release from ${from} sets intent=idle-release`);
143
143
  }
144
144
  });
145
- test('release: illegal from done|dead|canceled → throws', () => {
146
- for (const from of TERMINAL) {
145
+ test('release: illegal from dead|canceled → throws', () => {
146
+ for (const from of ['dead', 'canceled']) {
147
147
  mk(`n_${from}`, from);
148
148
  assert.throws(() => transition(`n_${from}`, 'release'), /illegal lifecycle transition/);
149
149
  }
@@ -8,12 +8,18 @@
8
8
  // behavior via the injectable deps seam, so they run tmux-free and broker-free.
9
9
  import { test, before, after, beforeEach } from 'node:test';
10
10
  import assert from 'node:assert/strict';
11
- import { mkdtempSync, rmSync } from 'node:fs';
11
+ import { existsSync, mkdtempSync, rmSync } from 'node:fs';
12
12
  import { tmpdir } from 'node:os';
13
13
  import { join } from 'node:path';
14
+ import { CrtrClient } from '../../api/client.js';
15
+ import { createApiServer } from '../../daemon/api/server.js';
16
+ import registerCanvasStophook from '../../pi-extensions/canvas-stophook.js';
14
17
  import { createNode, getNode, subscribe } from '../canvas/canvas.js';
15
18
  import { closeDb } from '../canvas/db.js';
19
+ import { apiSocketPath } from '../canvas/paths.js';
20
+ import { brokerCapReached, resetFleetForTests } from '../runtime/fleet.js';
16
21
  import { relaunchRoot } from '../runtime/reset.js';
22
+ import { bindTestFleet } from './helpers/fleet.js';
17
23
  let home;
18
24
  function node(id, over = {}) {
19
25
  return {
@@ -31,6 +37,20 @@ function node(id, over = {}) {
31
37
  /** A tmux-free / broker-free deps seam. `pid` controls the new broker launch:
32
38
  * a number = success, null = a boot failure. Records the node ids launchBroker
33
39
  * was called with, so the boot-failure test can find the half-born new node. */
40
+ async function waitForApi() {
41
+ const client = CrtrClient.forLocalSocket({ autostart: false });
42
+ for (let i = 0; i < 200; i++) {
43
+ if (existsSync(apiSocketPath())) {
44
+ try {
45
+ await client.healthz();
46
+ return;
47
+ }
48
+ catch { /* the socket can appear before it accepts */ }
49
+ }
50
+ await new Promise((resolve) => setTimeout(resolve, 5));
51
+ }
52
+ throw new Error('API socket did not become ready');
53
+ }
34
54
  function makeDeps(pid) {
35
55
  const launched = [];
36
56
  const deps = {
@@ -102,6 +122,76 @@ test('relaunchRoot on a boot failure leaves the old root + descendants fully int
102
122
  assert.equal(launched.length, 1, 'one broker launch was attempted');
103
123
  assert.equal(getNode(launched[0])?.status, 'dead', 'the half-born new node is crashed (dead)');
104
124
  });
125
+ test('relaunchRoot at capacity refuses visibly and settles the half-born row before preserving the old root', async () => {
126
+ createNode(node('root', { parent: null, lifecycle: 'resident' }));
127
+ createNode(node('child', { parent: 'root' }));
128
+ subscribe('root', 'child', true);
129
+ const launched = [];
130
+ const deps = {
131
+ launchBroker: (nodeId) => {
132
+ launched.push(nodeId);
133
+ throw brokerCapReached(2, 2);
134
+ },
135
+ waitForViewSocket: () => true,
136
+ respawnViewer: () => true,
137
+ teardownBroker: () => { },
138
+ };
139
+ await assert.rejects(() => relaunchRoot('root', deps), (error) => {
140
+ const details = error.details ?? {};
141
+ assert.equal(details['error'], 'broker_cap_reached');
142
+ assert.match(String(details['message']), /2 live brokers at the cap of 2/);
143
+ return true;
144
+ });
145
+ assert.equal(getNode('root')?.status, 'active', 'the old root remains live');
146
+ assert.equal(getNode('child')?.status, 'active', 'its descendants remain live');
147
+ assert.equal(launched.length, 1);
148
+ assert.equal(getNode(launched[0])?.status, 'dead', 'the half-born replacement is terminal, not an active pid-less strand');
149
+ });
150
+ test('root /new capacity refusal reaches the attached human through broker UI notify', async () => {
151
+ process.env['CRTR_MAX_LIVE_BROKERS'] = '2';
152
+ process.env['CRTR_NODE_ID'] = 'root';
153
+ createNode(node('root', { parent: null, lifecycle: 'resident' }));
154
+ const tf = bindTestFleet();
155
+ const never = new Promise(() => { });
156
+ tf.fleet.register('filler-a', { pid: 700_001, exited: never });
157
+ tf.fleet.register('filler-b', { pid: 700_002, exited: never });
158
+ const server = createApiServer();
159
+ try {
160
+ await waitForApi();
161
+ let sessionStart;
162
+ registerCanvasStophook({
163
+ on(event, handler) {
164
+ if (event === 'session_start')
165
+ sessionStart = async (ev, ctx) => { await handler(ev, ctx); };
166
+ },
167
+ sendUserMessage() { },
168
+ sendMessage() { },
169
+ });
170
+ let resolveNotice;
171
+ const notice = new Promise((resolve) => { resolveNotice = resolve; });
172
+ await sessionStart?.({ reason: 'new' }, {
173
+ sessionManager: {
174
+ getSessionId: () => 'root-new-session',
175
+ getSessionFile: () => null,
176
+ getEntries: () => [],
177
+ },
178
+ ui: { notify: (message) => resolveNotice(message) },
179
+ });
180
+ const message = await Promise.race([
181
+ notice,
182
+ new Promise((_, reject) => setTimeout(() => reject(new Error('capacity notice was not shown')), 1_000)),
183
+ ]);
184
+ assert.match(message, /^\/new refused: no broker slot is free/);
185
+ assert.match(message, /2 live brokers at the cap of 2/);
186
+ }
187
+ finally {
188
+ await server.close();
189
+ tf.fleet.dispose();
190
+ resetFleetForTests();
191
+ delete process.env['CRTR_MAX_LIVE_BROKERS'];
192
+ delete process.env['CRTR_NODE_ID'];
193
+ }
194
+ });
105
195
  test('relaunchRoot is a no-op for a non-root child and an already-parked root', async () => {
106
196
  createNode(node('root', { parent: null, lifecycle: 'resident' }));
107
197
  createNode(node('child', { parent: 'root' }));
@@ -10,15 +10,18 @@
10
10
  // finally happens retires the mark.
11
11
  import { test, before, beforeEach, after } from 'node:test';
12
12
  import assert from 'node:assert/strict';
13
- import { mkdtempSync, rmSync } from 'node:fs';
13
+ import { mkdirSync, mkdtempSync, rmSync } from 'node:fs';
14
14
  import { tmpdir } from 'node:os';
15
15
  import { join } from 'node:path';
16
16
  import { createNode, getNode } from '../canvas/canvas.js';
17
+ import { jobDir } from '../canvas/paths.js';
17
18
  import { closeDb } from '../canvas/db.js';
18
19
  import { bindDaemonEventSource, resetEventSourceForTests } from '../events/source.js';
19
20
  import { resetFleetForTests } from '../runtime/fleet.js';
21
+ import { CANVAS_EXTENSIONS } from '../runtime/canvas-extensions.js';
20
22
  import { headlessBrokerHost } from '../runtime/host.js';
21
23
  import { reviveNode } from '../runtime/revive.js';
24
+ import { deliverNodeMessage } from '../../daemon/messaging/node-message.js';
22
25
  import { bindTestFleet } from './helpers/fleet.js';
23
26
  let home;
24
27
  let tf;
@@ -89,6 +92,24 @@ test('a refusing caller gets an error naming the cap and the live count, never a
89
92
  assert.equal(meta.pi_session_id, 'sess-refused', 'the refusal happens before any mutation of the session identity');
90
93
  assert.equal(meta.cycles ?? 0, 0, 'a node turned away never spent a cycle');
91
94
  });
95
+ test('a pre-spawn launch failure releases the reservation', () => {
96
+ const id = node('pre-spawn-failure');
97
+ mkdirSync(join(jobDir(id), 'broker.log'));
98
+ headlessBrokerHost.launch = originalLaunch;
99
+ try {
100
+ assert.throws(() => headlessBrokerHost.launch(id, {
101
+ argv: CANVAS_EXTENSIONS.flatMap((path) => ['-e', path]),
102
+ env: {},
103
+ }, { cwd: home, name: id, resuming: false }), /EISDIR/);
104
+ assert.equal(tf.fleet.occupancy(), 0, 'the failed launch leaves no slot reserved');
105
+ assert.equal(tf.fleet.reserveSlot(id), true, 'the same node can claim the released slot immediately');
106
+ tf.fleet.releaseReservation(id);
107
+ assert.equal(tf.fleet.occupancy(), 0);
108
+ }
109
+ finally {
110
+ stubLaunch();
111
+ }
112
+ });
92
113
  test('a freezing caller gets a frozen row, marked once and left otherwise untouched', () => {
93
114
  saturate();
94
115
  const waiting = node('frozen', { pi_session_id: 'sess-frozen' });
@@ -102,6 +123,20 @@ test('a freezing caller gets a frozen row, marked once and left otherwise untouc
102
123
  assert.equal(second.outcome, 'frozen');
103
124
  assert.equal(getNode(waiting).frozen_at, marked.frozen_at, 'freezing is once-per-episode: a second saturated tick keeps the original wait timestamp');
104
125
  });
126
+ test('a durable daemon message reports capacity_frozen when its wake cannot launch', async () => {
127
+ saturate();
128
+ const waiting = node('message-frozen');
129
+ const delivery = await deliverNodeMessage({
130
+ node_id: waiting,
131
+ body: 'capacity-delayed notice',
132
+ label: 'notice',
133
+ mode: 'wake',
134
+ });
135
+ assert.equal(delivery.via, 'inbox');
136
+ assert.ok(delivery.entry_id !== null, 'the message stays durable while the broker waits');
137
+ assert.equal(delivery.revived, false);
138
+ assert.equal(delivery.reason, 'capacity_frozen');
139
+ });
105
140
  test('an already-live node is admitted at the cap — it consumes no new slot', () => {
106
141
  const live = node('already-live');
107
142
  reviveNode(live, { resume: false, capacity: 'refuse' });
@@ -12,9 +12,14 @@
12
12
  // by the daemon pass, so it belongs in the compiled seam tier.
13
13
  import { after, before, test } from 'node:test';
14
14
  import assert from 'node:assert/strict';
15
+ import { createNode } from '../../canvas/canvas.js';
15
16
  import { closeDb } from '../../canvas/db.js';
16
17
  import { isPidAlive } from '../../canvas/pid.js';
18
+ import { appendInbox } from '../../feed/inbox.js';
17
19
  import { closeNode } from '../../runtime/close.js';
20
+ import { headlessBrokerHost } from '../../runtime/host.js';
21
+ import { transition } from '../../runtime/lifecycle.js';
22
+ import { reviveNode } from '../../runtime/revive.js';
18
23
  import { spawnChild } from '../../runtime/spawn.js';
19
24
  import { createHarness } from '../helpers/harness.js';
20
25
  let h;
@@ -31,6 +36,34 @@ after(async () => {
31
36
  await h.dispose();
32
37
  delete process.env['CRTR_MAX_LIVE_BROKERS'];
33
38
  });
39
+ test('node new reports a frozen delegated birth without claiming its broker started', { timeout: 120_000 }, async () => {
40
+ const created = [];
41
+ try {
42
+ for (const prompt of ['first API child', 'second API child']) {
43
+ const result = h.cli(root, ['--json', 'node', 'new', '--kind', 'general', prompt]);
44
+ assert.equal(result.code, 0, `node new should succeed\n${result.stderr}`);
45
+ const nodeId = result.json.node_id;
46
+ created.push(nodeId);
47
+ await h.awaitBoot(nodeId);
48
+ }
49
+ const frozen = h.cli(root, ['--json', 'node', 'new', '--kind', 'general', 'frozen API child']);
50
+ assert.equal(frozen.code, 0, `node new should record the frozen birth\n${frozen.stderr}`);
51
+ const output = frozen.json;
52
+ created.push(output.node_id);
53
+ assert.ok(output.frozen_at !== undefined, 'JSON reports the durable frozen birth detail');
54
+ assert.match(output.follow_up, /Child row exists, but no broker started because capacity is full/);
55
+ const prose = h.cli(root, ['node', 'new', '--kind', 'general', 'another frozen API child']);
56
+ assert.equal(prose.code, 0, `node new should report capacity in prose\n${prose.stderr}`);
57
+ assert.match(prose.stdout, /Child row exists, but no broker started because capacity is full/);
58
+ const proseNode = prose.stdout.match(/\(([^)]+)\)/)?.[1];
59
+ assert.ok(proseNode !== undefined, 'prose names the created frozen row');
60
+ created.push(proseNode);
61
+ }
62
+ finally {
63
+ for (const nodeId of created)
64
+ h.cli(root, ['node', 'lifecycle', 'close', '--node', nodeId]);
65
+ }
66
+ });
34
67
  test('a birth at the cap freezes instead of booting, survives saturated ticks, and thaws when a slot frees', { timeout: 120_000 }, async () => {
35
68
  const first = await h.spawnHeadlessChild(root, 'first live child');
36
69
  const second = await h.spawnHeadlessChild(root, 'second live child');
@@ -78,4 +111,44 @@ test('a birth at the cap freezes instead of booting, survives saturated ticks, a
78
111
  assert.equal(h.fleet.has(third), true, 'the freed slot is now the thawed node\u2019s');
79
112
  assert.equal(h.fleet.has(second), true, 'the untouched live node kept its slot throughout');
80
113
  assert.notEqual(thawed.pi_pid, firstPid, 'the thaw is a real new broker, not the finished node\u2019s pid lingering');
114
+ transition(second, 'park');
115
+ headlessBrokerHost.teardown(second);
116
+ await h.awaitFleetExit(second);
117
+ const filler = await h.spawnHeadlessChild(root, 'terminal thaw filler');
118
+ assert.equal(h.fleet.size(), 2, 'the parked target is non-live while the cap remains full');
119
+ appendInbox(second, { from: root, tier: 'normal', kind: 'message', label: 'durable wake' });
120
+ assert.equal(reviveNode(second, { resume: true, capacity: 'freeze' }).outcome, 'frozen');
121
+ closeDb();
122
+ const frozenTerminal = h.node(second);
123
+ assert.equal(frozenTerminal.status, 'done', 'freezing preserves the terminal status');
124
+ assert.equal(frozenTerminal.intent, 'parked', 'freezing preserves the terminal intent');
125
+ assert.ok(frozenTerminal.frozen_at != null, 'the durable wake remains in the bounded terminal tick population');
126
+ headlessBrokerHost.teardown(filler);
127
+ await h.awaitFleetExit(filler);
128
+ await h.tick();
129
+ await h.awaitBoot(second, { minCount: 2 });
130
+ closeDb();
131
+ assert.equal(h.node(second).frozen_at ?? null, null, 'a freed slot thaws the frozen terminal wake');
132
+ assert.equal(h.fleet.has(second), true, 'the terminal wake receives the freed slot');
133
+ createNode({
134
+ node_id: 'resident-drain',
135
+ name: 'resident drain',
136
+ kind: 'general',
137
+ mode: 'base',
138
+ lifecycle: 'resident',
139
+ status: 'done',
140
+ intent: 'parked',
141
+ cwd: h.node(third).cwd,
142
+ host_kind: 'broker',
143
+ parent: root,
144
+ created: new Date().toISOString(),
145
+ pi_session_id: 'resident-drain-session',
146
+ });
147
+ assert.equal(reviveNode('resident-drain', { resume: true, capacity: 'freeze' }).outcome, 'frozen');
148
+ await h.tick();
149
+ closeDb();
150
+ const drained = h.node('resident-drain');
151
+ assert.equal(drained.status, 'idle', 'an eligible resident drains to Dormant');
152
+ assert.equal(drained.frozen_at ?? null, null, 'draining retires the frozen mark');
153
+ assert.equal(h.fleet.size(), 2, 'the drain does not spend a live-broker slot');
81
154
  });
@@ -181,8 +181,7 @@ test('an unattended resident with no live obligation completes on the daemon clo
181
181
  // Clock expiry: arms the pending park and requests the summary turn.
182
182
  await h.tick(seenAt + 1500);
183
183
  // Summary grace expiry: the degraded enactment parks without a report.
184
- // (If the summary turn's settlement already released the broker in the
185
- // api-server process, the dormant-inbox reconciler applies the same park.)
184
+ // The lifecycle tick applies the same park after a settled release.
186
185
  await h.tick(seenAt + 1500 + 1000);
187
186
  await h.waitFor(() => {
188
187
  const node = h.node(nodeId);
@@ -129,11 +129,11 @@ export declare function migrateLegacyPidIdentities(captureIdentity?: (pid: numbe
129
129
  * Unlike `listNodes`, this includes unclaimed warm-pool spares: profile
130
130
  * deletion owns every matching row, whether or not it is user-visible. */
131
131
  export declare function listNodesByProfile(profileId: string): NodeMeta[];
132
- /** All rows, optionally filtered by status and by carrying a recorded pid (the
133
- * supervision tick's terminal-row query). Unclaimed warm spares are excluded. */
132
+ /** All rows, optionally filtered by status and terminal execution traces. Unclaimed warm spares are excluded. */
134
133
  export declare function listNodes(filter?: {
135
134
  status?: NodeStatus | NodeStatus[];
136
135
  withRecordedPid?: boolean;
136
+ withRecordedPidOrFrozen?: boolean;
137
137
  }): NodeRow[];
138
138
  /** Mark an already-spawned node as an unclaimed spare for `recipeKey`. Hides it
139
139
  * from every listing until it is claimed. */
@@ -170,8 +170,8 @@ export declare function isWarmSpare(nodeId: string): boolean;
170
170
  * pool has none. The select+delete run inside ONE canvas write-lock
171
171
  * transaction, so two concurrent creates can never be handed the same spare.
172
172
  * A spare whose broker is gone is skipped here and reaped by
173
- * `reapStaleSpares` — nothing ever resumes a spare (the recovery sweep reads
174
- * `listNodes`, which hides them), so a dead spare is garbage, not a parked one.
173
+ * `reapStaleSpares` — nothing ever resumes a spare (the daemon's row query
174
+ * hides them), so a dead spare is garbage, not a parked one.
175
175
  *
176
176
  * The claim also restamps the row's `created` to `created` — birth time is a
177
177
  * spare's MINT time, and consumers sort conversations on it, so an hour-old
@@ -291,7 +291,7 @@ export type TerminalGuardResult<T> = {
291
291
  /** Close the TOCTOU between checking a target has a natural cycle ahead
292
292
  * of it and the write that depends on that (for example, a deferred inbox
293
293
  * append). Fresh-reads `nodeId`'s
294
- * (status, final_report) and either runs `body` (a write) or short-circuits
294
+ * (status, final_report, frozen_at) and either runs `body` (a write) or short-circuits
295
295
  * with a `'terminal'`/`'missing'` outcome — the read and `body`'s write share
296
296
  * ONE canvas write-lock boundary (`withCanvasWrite`'s `BEGIN IMMEDIATE`), so
297
297
  * a concurrent finish/cancel/finalization/deletion either commits entirely
@@ -311,6 +311,7 @@ export type TerminalGuardResult<T> = {
311
311
  export declare function withFreshTerminalGuard<T>(nodeId: string, isTerminal: (row: {
312
312
  status: NodeStatus;
313
313
  final_report: string | null;
314
+ frozen_at: string | null;
314
315
  }) => boolean, body: () => T): TerminalGuardResult<T>;
315
316
  /** Rebuild node rows from on-disk metas (the db node table is a derived index).
316
317
  * Only the IDENTITY columns are rebuilt — they are a projection of meta. The
@@ -19,7 +19,7 @@ import { ensureHome, ensureNodeDirs, nodeMetaPath, nodeDir, nodesRoot, jobDir, }
19
19
  /** The identity keys meta.json persists. Listed explicitly so no runtime field
20
20
  * can ever leak onto disk even when a fully-hydrated NodeMeta is handed in. */
21
21
  const IDENTITY_KEYS = [
22
- 'node_id', 'name', 'description', 'icon', 'cycles', 'created', 'cwd', 'host_kind', 'profile_id', 'kind', 'mode',
22
+ 'node_id', 'name', 'description', 'title', 'icon', 'cycles', 'created', 'cwd', 'host_kind', 'profile_id', 'kind', 'mode',
23
23
  'lifecycle', 'persona_ack', 'parent', 'spawned_by', 'fork_from', 'fork_source_file', 'review_binding', 'passive_default', 'pi_session_id',
24
24
  'pi_session_file', 'cycle_pending', 'model_override',
25
25
  'launch', 'managed_worktree',
@@ -456,8 +456,7 @@ export function listNodesByProfile(profileId) {
456
456
  return meta;
457
457
  });
458
458
  }
459
- /** All rows, optionally filtered by status and by carrying a recorded pid (the
460
- * supervision tick's terminal-row query). Unclaimed warm spares are excluded. */
459
+ /** All rows, optionally filtered by status and terminal execution traces. Unclaimed warm spares are excluded. */
461
460
  export function listNodes(filter) {
462
461
  const clauses = [NOT_A_SPARE];
463
462
  const params = [];
@@ -468,6 +467,8 @@ export function listNodes(filter) {
468
467
  }
469
468
  if (filter?.withRecordedPid === true)
470
469
  clauses.push('pi_pid IS NOT NULL');
470
+ if (filter?.withRecordedPidOrFrozen === true)
471
+ clauses.push('(pi_pid IS NOT NULL OR frozen_at IS NOT NULL)');
471
472
  const rows = openDb()
472
473
  .prepare(`SELECT * FROM nodes WHERE ${clauses.join(' AND ')} ORDER BY created`)
473
474
  .all(...params);
@@ -539,8 +540,8 @@ function spareIsClaimable(row) {
539
540
  * pool has none. The select+delete run inside ONE canvas write-lock
540
541
  * transaction, so two concurrent creates can never be handed the same spare.
541
542
  * A spare whose broker is gone is skipped here and reaped by
542
- * `reapStaleSpares` — nothing ever resumes a spare (the recovery sweep reads
543
- * `listNodes`, which hides them), so a dead spare is garbage, not a parked one.
543
+ * `reapStaleSpares` — nothing ever resumes a spare (the daemon's row query
544
+ * hides them), so a dead spare is garbage, not a parked one.
544
545
  *
545
546
  * The claim also restamps the row's `created` to `created` — birth time is a
546
547
  * spare's MINT time, and consumers sort conversations on it, so an hour-old
@@ -763,7 +764,7 @@ export function settleDeadMessageWait(nodeId, controller, deliver) {
763
764
  /** Close the TOCTOU between checking a target has a natural cycle ahead
764
765
  * of it and the write that depends on that (for example, a deferred inbox
765
766
  * append). Fresh-reads `nodeId`'s
766
- * (status, final_report) and either runs `body` (a write) or short-circuits
767
+ * (status, final_report, frozen_at) and either runs `body` (a write) or short-circuits
767
768
  * with a `'terminal'`/`'missing'` outcome — the read and `body`'s write share
768
769
  * ONE canvas write-lock boundary (`withCanvasWrite`'s `BEGIN IMMEDIATE`), so
769
770
  * a concurrent finish/cancel/finalization/deletion either commits entirely
@@ -783,7 +784,7 @@ export function settleDeadMessageWait(nodeId, controller, deliver) {
783
784
  export function withFreshTerminalGuard(nodeId, isTerminal, body) {
784
785
  return withCanvasWrite((db) => {
785
786
  const row = db
786
- .prepare('SELECT status, final_report FROM nodes WHERE node_id = ?')
787
+ .prepare('SELECT status, final_report, frozen_at FROM nodes WHERE node_id = ?')
787
788
  .get(nodeId);
788
789
  if (row === undefined) {
789
790
  return { kind: 'missing' };
@@ -90,6 +90,12 @@ export interface NodeIdentity {
90
90
  /** A 2-4 word kebab-case handle derived from the node's first prompt (named
91
91
  * headlessly by pi; see runtime/naming.ts). Shown in the editor label. */
92
92
  description?: string;
93
+ /** The same first prompt named as PROSE — a sentence-case phrase with its
94
+ * punctuation intact, produced by the same headless namer in the same call as
95
+ * `description`. Kebab case is right for a tmux window and an editor label
96
+ * and wrong for a conversation list, so a surface reading to a person takes
97
+ * this. Absent on a node named before titles existed. */
98
+ title?: string;
93
99
  /** A single Nerd Font glyph depicting the node's work, chosen by the same
94
100
  * headless namer that produced `description` (see runtime/naming.ts). Kept as
95
101
  * its own field rather than baked into the description so each surface decides