@north-light/crouter 0.3.236 → 0.3.237

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (174) hide show
  1. package/dist/api/client.d.ts +2 -1
  2. package/dist/api/client.js +3 -0
  3. package/dist/api/dto/broker-ops.d.ts +1 -1
  4. package/dist/api/dto/common.d.ts +2 -0
  5. package/dist/api/dto/lifecycle.d.ts +9 -2
  6. package/dist/api/dto/messages.d.ts +5 -0
  7. package/dist/api/dto/nodes.d.ts +10 -2
  8. package/dist/api/dto/worktree.d.ts +10 -0
  9. package/dist/api/routes.d.ts +1 -0
  10. package/dist/api/routes.js +1 -0
  11. package/dist/builtin-memory/00-runtime-base/00-authoring.md +1 -1
  12. package/dist/builtin-memory/internal/agent-shaping.md +1 -1
  13. package/dist/builtin-pi-packages/pi-crtr-extensions/README.md +1 -1
  14. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/sysprompt-window.ts +1 -1
  15. package/dist/clients/attach/render/tool-calls.js +18 -3
  16. package/dist/clients/attach/viewer.js +426 -426
  17. package/dist/clients/inbox/review/__tests__/editor-roundtrip.test.js +6 -0
  18. package/dist/clients/inbox/review/roundtrip.js +1 -1
  19. package/dist/commands/memory/lint.js +22 -13
  20. package/dist/commands/memory/shared.js +1 -1
  21. package/dist/commands/node/create.d.ts +1 -1
  22. package/dist/commands/node/create.js +1 -1
  23. package/dist/commands/node/lifecycle.js +1 -1
  24. package/dist/commands/node-worktree.js +39 -8
  25. package/dist/commands/revive.js +11 -2
  26. package/dist/commands/sys/context/admin/actions.d.ts +48 -0
  27. package/dist/commands/sys/context/admin/actions.js +246 -0
  28. package/dist/commands/sys/context/admin/axes-panel.d.ts +21 -0
  29. package/dist/commands/sys/context/admin/axes-panel.js +117 -0
  30. package/dist/commands/sys/context/admin/detail-panel.d.ts +53 -0
  31. package/dist/commands/sys/context/admin/detail-panel.js +178 -0
  32. package/dist/commands/sys/context/admin/docs-panel.d.ts +45 -0
  33. package/dist/commands/sys/context/admin/docs-panel.js +186 -0
  34. package/dist/commands/sys/context/admin/model.d.ts +75 -0
  35. package/dist/commands/sys/context/admin/model.js +209 -0
  36. package/dist/commands/sys/context/admin/shell.d.ts +82 -0
  37. package/dist/commands/sys/context/admin/shell.js +669 -0
  38. package/dist/commands/sys/context/admin.d.ts +1 -0
  39. package/dist/commands/sys/context/admin.js +124 -0
  40. package/dist/commands/sys/context/doc.d.ts +1 -0
  41. package/dist/commands/sys/context/doc.js +196 -0
  42. package/dist/commands/sys/context/prompt-review.d.ts +1 -0
  43. package/dist/commands/sys/{prompt-review.js → context/prompt-review.js} +10 -10
  44. package/dist/commands/sys/context/resolve.d.ts +55 -0
  45. package/dist/commands/sys/context/resolve.js +428 -0
  46. package/dist/commands/sys/context/sysprompt.d.ts +1 -0
  47. package/dist/commands/sys/{sysprompt.js → context/sysprompt.js} +11 -11
  48. package/dist/commands/sys/context.d.ts +2 -0
  49. package/dist/commands/sys/context.js +20 -0
  50. package/dist/commands/sys.js +3 -4
  51. package/dist/core/__tests__/child-death-wake.test.js +38 -22
  52. package/dist/core/__tests__/cron-self-cancel-delivery.test.js +72 -0
  53. package/dist/core/__tests__/daemon-boot.test.js +40 -28
  54. package/dist/core/__tests__/human-deliver.test.js +1 -1
  55. package/dist/core/__tests__/human-node-not-supervised.test.js +10 -15
  56. package/dist/core/__tests__/integration/revive.test.js +12 -12
  57. package/dist/core/__tests__/integration/spawn-root.test.js +6 -0
  58. package/dist/core/__tests__/integration/worktree-reap.test.js +32 -0
  59. package/dist/core/__tests__/lifecycle-terminal-reasons.test.d.ts +1 -0
  60. package/dist/core/__tests__/lifecycle-terminal-reasons.test.js +61 -0
  61. package/dist/core/__tests__/migration.test.js +38 -1
  62. package/dist/core/__tests__/push-final-guard.test.js +1 -1
  63. package/dist/core/__tests__/revive-capacity.test.d.ts +1 -0
  64. package/dist/core/__tests__/revive-capacity.test.js +127 -0
  65. package/dist/core/__tests__/revive-parked-fresh.test.js +7 -7
  66. package/dist/core/__tests__/seam/broker-cap-freeze.test.d.ts +1 -0
  67. package/dist/core/__tests__/seam/broker-cap-freeze.test.js +81 -0
  68. package/dist/core/__tests__/seam/dormancy-release.test.js +7 -7
  69. package/dist/core/__tests__/seam/yield-refresh-transaction.test.js +1 -1
  70. package/dist/core/canvas/browse/render.js +11 -3
  71. package/dist/core/canvas/canvas.d.ts +15 -3
  72. package/dist/core/canvas/canvas.js +52 -16
  73. package/dist/core/canvas/migrations.js +21 -0
  74. package/dist/core/canvas/nav-render.js +3 -1
  75. package/dist/core/canvas/remote-canvas-source.js +2 -0
  76. package/dist/core/canvas/render-source.d.ts +3 -1
  77. package/dist/core/canvas/render-source.js +7 -3
  78. package/dist/core/canvas/status-glyph.d.ts +10 -1
  79. package/dist/core/canvas/status-glyph.js +19 -3
  80. package/dist/core/canvas/types.d.ts +20 -1
  81. package/dist/core/feed/feed.js +1 -1
  82. package/dist/core/human/feedback-companion.js +5 -2
  83. package/dist/core/memory/lint.d.ts +19 -0
  84. package/dist/core/memory/lint.js +97 -0
  85. package/dist/core/memory-resolver.d.ts +4 -0
  86. package/dist/core/memory-resolver.js +7 -7
  87. package/dist/core/preview-registry.js +4 -4
  88. package/dist/core/review/realize.js +4 -2
  89. package/dist/core/runtime/close.js +1 -1
  90. package/dist/core/runtime/fleet.d.ts +29 -1
  91. package/dist/core/runtime/fleet.js +23 -0
  92. package/dist/core/runtime/host.js +32 -11
  93. package/dist/core/runtime/launch-prompt.d.ts +1 -1
  94. package/dist/core/runtime/launch-prompt.js +2 -2
  95. package/dist/core/runtime/lifecycle.d.ts +4 -2
  96. package/dist/core/runtime/lifecycle.js +12 -1
  97. package/dist/core/runtime/memory.js +1 -1
  98. package/dist/core/runtime/reopen.js +10 -1
  99. package/dist/core/runtime/reset.js +1 -1
  100. package/dist/core/runtime/revive-all.d.ts +3 -0
  101. package/dist/core/runtime/revive-all.js +10 -4
  102. package/dist/core/runtime/revive.d.ts +12 -0
  103. package/dist/core/runtime/revive.js +62 -3
  104. package/dist/core/runtime/spawn.d.ts +4 -0
  105. package/dist/core/runtime/spawn.js +36 -7
  106. package/dist/core/substrate/frontmatter-validation.js +1 -1
  107. package/dist/core/substrate/index.d.ts +6 -3
  108. package/dist/core/substrate/index.js +4 -3
  109. package/dist/core/substrate/on-read.d.ts +0 -8
  110. package/dist/core/substrate/on-read.js +45 -124
  111. package/dist/core/substrate/plan.d.ts +94 -0
  112. package/dist/core/substrate/plan.js +266 -0
  113. package/dist/core/substrate/render.d.ts +5 -6
  114. package/dist/core/substrate/render.js +76 -129
  115. package/dist/core/substrate/schema.d.ts +1 -6
  116. package/dist/core/substrate/schema.js +1 -15
  117. package/dist/core/substrate/session-cache.d.ts +2 -10
  118. package/dist/core/substrate/session-cache.js +9 -47
  119. package/dist/core/substrate/subject-fields.d.ts +1 -4
  120. package/dist/core/substrate/subject-fields.js +2 -5
  121. package/dist/core/substrate/surface-match.d.ts +37 -17
  122. package/dist/core/substrate/surface-match.js +44 -53
  123. package/dist/core/tui/page-host.d.ts +6 -0
  124. package/dist/core/tui/page-host.js +12 -3
  125. package/dist/core/worktree.d.ts +8 -0
  126. package/dist/core/worktree.js +65 -0
  127. package/dist/daemon/__tests__/reconciler-signature.test.js +3 -2
  128. package/dist/daemon/__tests__/thaw-order.test.d.ts +1 -0
  129. package/dist/daemon/__tests__/thaw-order.test.js +55 -0
  130. package/dist/daemon/api/__tests__/canvas-snapshot-fields.test.js +6 -0
  131. package/dist/daemon/api/__tests__/node-create-description.test.js +6 -0
  132. package/dist/daemon/api/__tests__/profile-launch-gates.test.js +1 -1
  133. package/dist/daemon/api/bridge.js +5 -1
  134. package/dist/daemon/api/handlers/attach.js +5 -1
  135. package/dist/daemon/api/handlers/bash-jobs.js +1 -1
  136. package/dist/daemon/api/handlers/messages.js +10 -2
  137. package/dist/daemon/api/handlers/nodes.js +8 -1
  138. package/dist/daemon/api/handlers/worktree.js +10 -1
  139. package/dist/daemon/api/map.js +7 -0
  140. package/dist/daemon/companion-retire.js +1 -1
  141. package/dist/daemon/cron/passes.d.ts +0 -5
  142. package/dist/daemon/cron/passes.js +0 -20
  143. package/dist/daemon/cron/sinks.js +1 -1
  144. package/dist/daemon/cron-run.d.ts +5 -6
  145. package/dist/daemon/cron-run.js +7 -16
  146. package/dist/daemon/crtrd.d.ts +0 -10
  147. package/dist/daemon/crtrd.js +23 -61
  148. package/dist/daemon/fleet.d.ts +10 -7
  149. package/dist/daemon/fleet.js +71 -12
  150. package/dist/daemon/messaging/node-message.js +3 -1
  151. package/dist/daemon/profile-delete.js +1 -1
  152. package/dist/daemon/reconcilers/broker-supervision.d.ts +26 -9
  153. package/dist/daemon/reconcilers/broker-supervision.js +39 -52
  154. package/dist/daemon/reconcilers/controller-death.d.ts +7 -0
  155. package/dist/daemon/reconcilers/controller-death.js +26 -0
  156. package/dist/daemon/reconcilers/cron-lane.d.ts +0 -3
  157. package/dist/daemon/reconcilers/cron-lane.js +0 -3
  158. package/dist/daemon/reconcilers/live-obligation.js +3 -1
  159. package/dist/daemon/reconcilers/node-lifecycle/freeze-lane.d.ts +53 -0
  160. package/dist/daemon/reconcilers/node-lifecycle/freeze-lane.js +170 -0
  161. package/dist/daemon/reconcilers/node-lifecycle/respawn-policy.d.ts +5 -0
  162. package/dist/daemon/reconcilers/node-lifecycle/respawn-policy.js +16 -0
  163. package/dist/daemon/reconcilers/node-lifecycle/terminating.d.ts +4 -0
  164. package/dist/daemon/reconcilers/node-lifecycle/terminating.js +13 -0
  165. package/dist/daemon/reconcilers/node-lifecycle/tick.d.ts +26 -0
  166. package/dist/daemon/reconcilers/node-lifecycle/tick.js +140 -0
  167. package/package.json +1 -1
  168. package/runtime.lock.json +2 -2
  169. package/dist/commands/sys/prompt-review.d.ts +0 -1
  170. package/dist/commands/sys/sysprompt.d.ts +0 -1
  171. package/dist/core/__tests__/cron-broker-capacity.test.js +0 -119
  172. package/dist/daemon/reconcilers/dormant-inbox.d.ts +0 -18
  173. package/dist/daemon/reconcilers/dormant-inbox.js +0 -157
  174. /package/dist/core/__tests__/{cron-broker-capacity.test.d.ts → cron-self-cancel-delivery.test.d.ts} +0 -0
@@ -18,7 +18,7 @@ import { RemoteCanvasSource } from './remote-canvas-source.js';
18
18
  import { fullName } from './labels.js';
19
19
  import { jobDir, contextDir } from './paths.js';
20
20
  import { isPidAlive } from './pid.js';
21
- import { resolveNodeVisual, faultSummary } from './status-glyph.js';
21
+ import { isFrozenRow, resolveNodeVisual, faultSummary } from './status-glyph.js';
22
22
  import { readFault } from '../runtime/fault.js';
23
23
  import { parseCard } from '../../shared/generated-context.js';
24
24
  // Active-fault overlay: stale daemon-owned provider faults are hidden once the
@@ -63,7 +63,7 @@ async function nodeLineFromSource(source, nodeId, indent, connector, remote, tic
63
63
  if (node === null)
64
64
  return `${indent}${connector}? <missing meta: ${nodeId}>`;
65
65
  const hanging = remote ? null : hangingFor(node);
66
- const glyph = resolveNodeVisual(node.status, { hanging }).glyph;
66
+ const glyph = resolveNodeVisual(node.status, { hanging, frozen: isFrozenRow(node) }).glyph;
67
67
  const ctx = remote ? '0k' : fmtCtx(readNodeTelemetry(nodeId).tokens_in);
68
68
  const asks = tickets[nodeId] ?? 0;
69
69
  const askSuffix = asks > 0 ? ` ⚑${asks}` : '';
@@ -114,7 +114,7 @@ export async function renderTreeFromSource(source, rootId) {
114
114
  const asks = tickets[rootId] ?? 0;
115
115
  const askSuffix = asks > 0 ? ` ⚑${asks}` : '';
116
116
  const hanging = remote ? null : hangingFor(node);
117
- const glyph = resolveNodeVisual(node.status, { hanging }).glyph;
117
+ const glyph = resolveNodeVisual(node.status, { hanging, frozen: isFrozenRow(node) }).glyph;
118
118
  const faultSuffix = hanging !== null ? ` · ${faultSummary(hanging)}` : '';
119
119
  const out = [];
120
120
  out.push(`${glyph} ${fullName(node)} [${node.kind}/${node.mode}] ctx ${ctx}${askSuffix}${faultSuffix}`);
@@ -388,6 +388,8 @@ export function dashboardRowFromNodeRow(row, focusedNodeIds, remote) {
388
388
  name: row.name, // handle only; enrichRow upgrades to fullName (meta.description)
389
389
  handle: row.name,
390
390
  status: row.status,
391
+ frozen_at: row.frozen_at ?? null,
392
+ terminal_reason: row.terminal_reason ?? null,
391
393
  kind: row.kind,
392
394
  mode: row.mode,
393
395
  ctx_tokens: 0, // lazy: enrichRow
@@ -423,6 +425,8 @@ export async function dashboardRowsFromSource(source, rootId) {
423
425
  handle: node.name,
424
426
  description: node.description,
425
427
  status: node.status,
428
+ frozen_at: node.frozen_at ?? null,
429
+ terminal_reason: node.terminal_reason ?? null,
426
430
  kind: node.kind,
427
431
  mode: node.mode,
428
432
  ctx_tokens: tel.tokens_in ?? 0,
@@ -15,10 +15,19 @@ export interface NodeVisual {
15
15
  word?: string;
16
16
  bold?: boolean;
17
17
  }
18
+ /** Whether a row is frozen: the broker ledger marked it waiting for a slot and
19
+ * no process holds it. Both halves matter — a launch clears `frozen_at`, so a
20
+ * mark sitting beside a live pid is stale rather than frozen. A projection
21
+ * that carries no pid at all (the dashboard row) reads on the mark alone. */
22
+ export declare function isFrozenRow(row: {
23
+ frozen_at?: string | null;
24
+ pi_pid?: number | null;
25
+ }): boolean;
18
26
  /** Resolve a node's glyph + hue + overlay word, applying the precedence
19
- * hanging > streaming > status. */
27
+ * hanging > frozen > streaming > status. */
20
28
  export declare function resolveNodeVisual(status: NodeStatus, opts: {
21
29
  streaming?: boolean;
30
+ frozen?: boolean;
22
31
  hanging?: Fault | {
23
32
  kind: FaultKind;
24
33
  } | null;
@@ -1,8 +1,11 @@
1
1
  // status-glyph.ts — the canonical status glyph and color map used by the canvas
2
2
  // ASCII forest and interactive browser. The overlay precedence is
3
- // hanging > streaming > status. `hanging` and `streaming` are
3
+ // hanging > frozen > streaming > status. `hanging` and `streaming` are
4
4
  // mutually exclusive in practice (hanging means the turn ended, so `busy` is
5
- // gone) but the precedence is defined regardless.
5
+ // gone) but the precedence is defined regardless. `frozen` outranks `streaming`
6
+ // because a frozen row has no process at all: any streaming read against it is
7
+ // a stale marker, and showing it as active is the lie the freeze state exists
8
+ // to stop telling.
6
9
  import { FAULT_QUIET_MS } from '../runtime/fault.js';
7
10
  // ── Status glyphs (the mono, color-free primary encoding) ─────────────────────
8
11
  export const STATUS_GLYPH = {
@@ -24,6 +27,9 @@ export const STATUS_COLOR = {
24
27
  // ── Streaming overlay (genuinely mid-turn) — browse's existing statusRail visual.
25
28
  const STREAMING_GLYPH = '⟳';
26
29
  const STREAMING_COLOR = 92; // bright green (brighter than active-status green)
30
+ // ── Frozen overlay (waiting on a broker slot: marked, but no process). ───────
31
+ const FROZEN_GLYPH = '◌';
32
+ const FROZEN_COLOR = 94; // bright blue
27
33
  // ── Active-fault overlay (one glyph, kind label carries the detail). ─────────
28
34
  export const HANGING_GLYPH = '⚠';
29
35
  export const HANGING_COLOR = 33; // yellow
@@ -42,12 +48,22 @@ export function hangingLabel(kind) {
42
48
  default: return 'other';
43
49
  }
44
50
  }
51
+ /** Whether a row is frozen: the broker ledger marked it waiting for a slot and
52
+ * no process holds it. Both halves matter — a launch clears `frozen_at`, so a
53
+ * mark sitting beside a live pid is stale rather than frozen. A projection
54
+ * that carries no pid at all (the dashboard row) reads on the mark alone. */
55
+ export function isFrozenRow(row) {
56
+ return row.frozen_at != null && row.pi_pid == null;
57
+ }
45
58
  /** Resolve a node's glyph + hue + overlay word, applying the precedence
46
- * hanging > streaming > status. */
59
+ * hanging > frozen > streaming > status. */
47
60
  export function resolveNodeVisual(status, opts) {
48
61
  if (opts.hanging != null) {
49
62
  return { glyph: HANGING_GLYPH, color: HANGING_COLOR, word: hangingLabel(opts.hanging.kind), bold: true };
50
63
  }
64
+ if (opts.frozen === true) {
65
+ return { glyph: FROZEN_GLYPH, color: FROZEN_COLOR, word: 'frozen' };
66
+ }
51
67
  if (opts.streaming === true) {
52
68
  return { glyph: STREAMING_GLYPH, color: STREAMING_COLOR, word: 'live', bold: true };
53
69
  }
@@ -18,6 +18,7 @@ export type Lifecycle = 'terminal' | 'resident';
18
18
  export type Mode = 'base' | 'orchestrator';
19
19
  /** Why a node last stopped — drives the daemon's reap-vs-revive decision. */
20
20
  export type ExitIntent = 'done' | 'refresh' | 'idle-release' | 'parked' | null;
21
+ export type TerminalReason = 'finalized' | 'finished' | 'parked' | 'closed' | 'retired' | 'crashed' | 'stranded';
21
22
  /** The two structural edges. `subscribes_to` is the load-bearing spine (flow,
22
23
  * org chart, views, completion routing). `spawned_by` is audit only. */
23
24
  export type EdgeType = 'subscribes_to' | 'spawned_by';
@@ -48,7 +49,7 @@ export interface LaunchSpec {
48
49
  env: Record<string, string>;
49
50
  }
50
51
  export interface ManagedWorktree {
51
- state: 'open' | 'closed';
52
+ state: 'open' | 'closed' | 'abandoned';
52
53
  /** A closed worktree remains registered until its owner has exited, so Git
53
54
  * never deletes the caller's live checkout beneath it. */
54
55
  cleanup?: 'pending' | 'complete';
@@ -59,6 +60,12 @@ export interface ManagedWorktree {
59
60
  base_sha: string;
60
61
  created: string;
61
62
  closed?: string;
63
+ abandoned?: {
64
+ branch: string;
65
+ tip_sha: string;
66
+ at: string;
67
+ by: string;
68
+ };
62
69
  }
63
70
  /** Immutable companion provenance. The origin session/leaf/model coordinates
64
71
  * remain canonical in the review row rather than being duplicated into node
@@ -181,6 +188,12 @@ export interface NodeRuntime {
181
188
  /** Controller whose next inbox delivery permits this terminal node to sleep.
182
189
  * Mutable durable runtime state: ancestry never implies a wait. */
183
190
  waiting_for?: string | null;
191
+ frozen_at?: string | null;
192
+ respawn_failures?: number;
193
+ respawn_not_before?: string | null;
194
+ launched_at?: string | null;
195
+ last_settled_at?: string | null;
196
+ terminal_reason?: TerminalReason | null;
184
197
  /** Immutable basename of the committed canonical final report, or null. */
185
198
  final_report?: string | null;
186
199
  /** ISO timestamp carried by the committed canonical final report, or null. */
@@ -244,6 +257,12 @@ export interface NodeRow {
244
257
  /** Authoritative runtime columns (see NodeRuntime). */
245
258
  intent: ExitIntent;
246
259
  waiting_for: string | null;
260
+ frozen_at?: string | null;
261
+ respawn_failures?: number;
262
+ respawn_not_before?: string | null;
263
+ launched_at?: string | null;
264
+ last_settled_at?: string | null;
265
+ terminal_reason?: TerminalReason | null;
247
266
  final_report?: string | null;
248
267
  finalized_at?: string | null;
249
268
  pi_pid: number | null;
@@ -91,7 +91,7 @@ function commitCanonicalFinal(nodeId, staged) {
91
91
  const subscribers = db.prepare(`SELECT from_id AS node_id, active, created FROM edges WHERE type = 'subscribes_to' AND to_id = ? ORDER BY created`).all(nodeId).map((row) => ({ node_id: row['node_id'], active: row['active'] === 1, created: row['created'] }));
92
92
  install(staged);
93
93
  installed = true;
94
- db.prepare("UPDATE nodes SET status = 'done', intent = 'done', final_report = ?, finalized_at = ? WHERE node_id = ? AND final_report IS NULL").run(staged.basename, staged.ts, nodeId);
94
+ db.prepare("UPDATE nodes SET status = 'done', intent = 'done', terminal_reason = 'finalized', final_report = ?, finalized_at = ? WHERE node_id = ? AND final_report IS NULL").run(staged.basename, staged.ts, nodeId);
95
95
  db.exec('COMMIT');
96
96
  return { subscribers };
97
97
  }
@@ -40,7 +40,7 @@ function materializeFeedbackBranch(dir, capture) {
40
40
  * the first-comment race to a concurrently recorded companion. */
41
41
  export function discardFeedbackCompanion(nodeId) {
42
42
  try {
43
- transition(nodeId, 'cancel');
43
+ transition(nodeId, 'cancel', { reason: 'retired' });
44
44
  headlessBrokerHost.teardown(nodeId);
45
45
  }
46
46
  catch { /* the refusal is authoritative; a straggler node is inert */ }
@@ -88,7 +88,10 @@ export async function realizeFeedbackCompanion(args) {
88
88
  });
89
89
  try {
90
90
  initializeForkContextExposure(node.node_id, args.senderNodeId);
91
- reviveNode(node.node_id, { resume: false });
91
+ // The bind deadline below only a live broker can meet, so a capacity
92
+ // refusal must surface here and discard the companion — freezing would
93
+ // burn the whole deadline waiting for a launch that is not coming.
94
+ reviveNode(node.node_id, { resume: false, capacity: 'refuse' });
92
95
  if (!await awaitCompanionBind(node.node_id, COMPANION_BIND_DEADLINE_MS)) {
93
96
  throw new Error(`feedback companion did not bind within ${COMPANION_BIND_DEADLINE_MS}ms`);
94
97
  }
@@ -33,6 +33,9 @@ export type MemoryLintRule =
33
33
  | 'dangling-link'
34
34
  /** A `memory-read` route that matches no canonical name. */
35
35
  | 'memory-read-route'
36
+ /** A document no reachability path routes an agent to — no surface delivers
37
+ * it, no reached document links it, and no reached directory lists it. */
38
+ | 'orphan'
36
39
  /** A project store's workspace front door is missing or not its root doc. */
37
40
  | 'front-door'
38
41
  /** Boot entries in a nested store, which never rides the boot catalog. */
@@ -91,6 +94,22 @@ export declare function lintShortPreviewBody(fm: Record<string, unknown>, body:
91
94
  * answers with its own document and its listing. Derived from the canonical
92
95
  * tree, never from string prefixes of a name. */
93
96
  export declare function resolvableCanonicalNames(docs: readonly MemoryDoc[]): Set<string>;
97
+ /** The orphan rule. A document is reachable when an agent is routed to it
98
+ * without knowing its name: a surface delivers it (a `memory-read` surface is
99
+ * deliberate companion wiring and counts), it is invocable as a slash command,
100
+ * a reached document's real `[[link]]` names it, or a reached CURATED INDEX
101
+ * document's listing includes it. The listing edge routes only through a
102
+ * directory that carries its own non-root index document — a bare directory
103
+ * has no curator, and a store's root front door is delivered with the whole
104
+ * boot tree, so treating either listing as a route would reach every doc and
105
+ * defeat the rule (the same reason the corpus root is never seeded). Curated
106
+ * BODY LINKS on those docs still route. Reachability is computed over the
107
+ * WHOLE target view — a doc linked only from another store is still reachable.
108
+ * `flagStoreRoot` restricts which store's unreachable docs are flagged while
109
+ * every store still participates in the graph as a potential linker. */
110
+ export declare function lintOrphans(viewDocs: readonly MemoryDoc[], options?: {
111
+ flagStoreRoot?: string;
112
+ }): MemoryLintFinding[];
94
113
  export interface MemoryLintStoreOptions {
95
114
  /** Canonical names a `[[link]]` or route may target — the target view the
96
115
  * store's documents are read in. Omit to skip reference checks entirely
@@ -133,6 +133,98 @@ export function resolvableCanonicalNames(docs) {
133
133
  walk(buildMemoryTree(docs).root);
134
134
  return names;
135
135
  }
136
+ /** The orphan rule. A document is reachable when an agent is routed to it
137
+ * without knowing its name: a surface delivers it (a `memory-read` surface is
138
+ * deliberate companion wiring and counts), it is invocable as a slash command,
139
+ * a reached document's real `[[link]]` names it, or a reached CURATED INDEX
140
+ * document's listing includes it. The listing edge routes only through a
141
+ * directory that carries its own non-root index document — a bare directory
142
+ * has no curator, and a store's root front door is delivered with the whole
143
+ * boot tree, so treating either listing as a route would reach every doc and
144
+ * defeat the rule (the same reason the corpus root is never seeded). Curated
145
+ * BODY LINKS on those docs still route. Reachability is computed over the
146
+ * WHOLE target view — a doc linked only from another store is still reachable.
147
+ * `flagStoreRoot` restricts which store's unreachable docs are flagged while
148
+ * every store still participates in the graph as a potential linker. */
149
+ export function lintOrphans(viewDocs, options = {}) {
150
+ const tree = buildMemoryTree(viewDocs);
151
+ const index = new Map();
152
+ const indexWalk = (node) => {
153
+ if (node.name !== '')
154
+ index.set(node.name, node);
155
+ for (const child of node.childDirectories)
156
+ indexWalk(child);
157
+ for (const child of node.childDocuments)
158
+ index.set(child.name, child);
159
+ };
160
+ indexWalk(tree.root);
161
+ const reachable = new Set();
162
+ const queue = [];
163
+ const enqueue = (name) => {
164
+ if (name === '' || reachable.has(name) || !index.has(name))
165
+ return;
166
+ reachable.add(name);
167
+ queue.push(name);
168
+ };
169
+ // Roots: a doc an agent reaches without a name — any surface entry, or a
170
+ // slash command. A `memory-read` surface counts (deliberate companion wiring).
171
+ for (const doc of viewDocs) {
172
+ const fm = doc.frontmatter ?? {};
173
+ if (parsedSubstrateSurfaces(fm).length > 0 || fm['slash'] === true)
174
+ enqueue(doc.name);
175
+ }
176
+ // Fixpoint: reading a reachable node routes to its real links, and — when the
177
+ // node is a curated non-root index — to the members its listing renders.
178
+ while (queue.length > 0) {
179
+ const node = index.get(queue.shift());
180
+ const doc = node.document;
181
+ if (doc !== null) {
182
+ for (const target of docLinkNames(doc.body))
183
+ if (index.has(target))
184
+ enqueue(target);
185
+ }
186
+ if (doc === null || doc.localName === '')
187
+ continue;
188
+ for (const child of node.childDirectories) {
189
+ if (child.document?.frontmatter?.['unlisted'] === true)
190
+ continue;
191
+ enqueue(child.name);
192
+ }
193
+ for (const child of node.childDocuments) {
194
+ if (child.document.frontmatter?.['unlisted'] === true)
195
+ continue;
196
+ enqueue(child.name);
197
+ }
198
+ }
199
+ const findings = [];
200
+ for (const doc of viewDocs) {
201
+ if (options.flagStoreRoot !== undefined && doc.store.storeRoot !== options.flagStoreRoot)
202
+ continue;
203
+ if (reachable.has(doc.name))
204
+ continue;
205
+ // Not the running user's to fix, but still a linker in the graph above.
206
+ if (doc.scope === 'builtin' || doc.plugin !== undefined)
207
+ continue;
208
+ // A store's root document is its entry point by definition.
209
+ if (doc.localName === '')
210
+ continue;
211
+ // Persona layers are read by construction, same as the length rule.
212
+ if (doc.name === 'kinds' || doc.name.startsWith('kinds/'))
213
+ continue;
214
+ if (ignoresRule(doc.frontmatter ?? {}, 'orphan'))
215
+ continue;
216
+ findings.push({
217
+ severity: 'warning',
218
+ rule: 'orphan',
219
+ path: doc.path,
220
+ name: doc.name,
221
+ scope: doc.scope,
222
+ ...(doc.store.storeRoot === '' ? {} : { storeRoot: doc.store.storeRoot }),
223
+ message: `\`${doc.name}\` is unreachable: no surface delivers it, no reached document links it, and no reached directory lists it — it is findable only by deliberate search. Give it a surface, link it from a reached doc (a cluster index, or its directory's own ${INDEX_SEGMENT}.md), or accept it with \`lint-ignore: orphan\`.`,
224
+ });
225
+ }
226
+ return findings;
227
+ }
136
228
  const FRONT_DOOR_REMEDY = 'the store\'s root INDEX.md is the workspace front door: give it `surfaces: [{on: workspace-open, at: content}, {on: read, match: "./**", at: content}]` (run `crtr memory write -h` first)';
137
229
  const GLOB_META_RE = /[*?[\]{}]/;
138
230
  function safeGlob(target, glob) {
@@ -566,6 +658,11 @@ export function lintMemoryTarget(options = {}) {
566
658
  walk(child);
567
659
  };
568
660
  walk(tree.root);
661
+ // Reachability is a whole-view property, so it runs once over the target view
662
+ // and only in the unfiltered view — a `--scope` slice hides the surfaced or
663
+ // linking docs that make a doc reachable and would report false orphans.
664
+ if (options.scope === undefined)
665
+ findings.push(...lintOrphans(view.docs));
569
666
  return {
570
667
  ...makeResult(findings, checked, documents, storesLinted),
571
668
  stores: view.stores,
@@ -184,6 +184,10 @@ export interface MemoryTarget {
184
184
  * or null outside a node. */
185
185
  nodeId: string | null;
186
186
  }
187
+ /** The target the ambient process describes: cwd + the two env seams every
188
+ * profile/node-aware memory path reads through (`CRTR_PROFILE_ID`,
189
+ * `CRTR_NODE_ID`). */
190
+ export declare function ambientMemoryTarget(): MemoryTarget;
187
191
  /** The exact project store owned by `ownerDir` — the `--dir` selector's store,
188
192
  * loaded whatever its mount status so lint and migration can inspect a store
189
193
  * the runtime refuses to mount. */
@@ -18,7 +18,7 @@ import { associateRepository } from './memory/repository-association.js';
18
18
  /** The target the ambient process describes: cwd + the two env seams every
19
19
  * profile/node-aware memory path reads through (`CRTR_PROFILE_ID`,
20
20
  * `CRTR_NODE_ID`). */
21
- function ambientTarget() {
21
+ export function ambientMemoryTarget() {
22
22
  return {
23
23
  cwd: process.cwd(),
24
24
  profileId: envProfileId() || null,
@@ -27,7 +27,7 @@ function ambientTarget() {
27
27
  }
28
28
  /** The selected profile id from the process env, or '' when none. */
29
29
  function selectedProfileId() {
30
- return ambientTarget().profileId ?? '';
30
+ return ambientMemoryTarget().profileId ?? '';
31
31
  }
32
32
  // Store discovery: which physical stores a target sees, in what order, and
33
33
  // whether each one's namespace lets it mount.
@@ -287,7 +287,7 @@ export function memoryStoresInPrecedence(target, scope, includeDescendants = fal
287
287
  * how a DELETED doc's revision log is still found: history outlives the doc,
288
288
  * so its lookup cannot go through document resolution. */
289
289
  export function nativeMemoryStoresInPrecedence(scope, includeDescendants = false) {
290
- return memoryStoresInPrecedence(ambientTarget(), scope, includeDescendants)
290
+ return memoryStoresInPrecedence(ambientMemoryTarget(), scope, includeDescendants)
291
291
  .filter((store) => store.mountStatus === 'ready' && store.scope !== 'builtin' && store.plugin === undefined)
292
292
  .map((store) => ({ scope: store.scope, memoryDir: store.storeRoot, store }));
293
293
  }
@@ -475,9 +475,9 @@ export function listProjectMemoryDocs(startDir = process.cwd(), profileId = sele
475
475
  * candidates stay in source order, so a caller's first-wins dedup yields the
476
476
  * address winners. */
477
477
  export function listAllMemoryDocs(scope, quiet = false, includeDescendants = false) {
478
- return [...loadMemoryTargetView(ambientTarget(), { ...(scope ? { scope } : {}), quiet, includeDescendants }).docs];
478
+ return [...loadMemoryTargetView(ambientMemoryTarget(), { ...(scope ? { scope } : {}), quiet, includeDescendants }).docs];
479
479
  }
480
- export function createMemoryDocSnapshot(target = ambientTarget()) {
480
+ export function createMemoryDocSnapshot(target = ambientMemoryTarget()) {
481
481
  // Quiet: a snapshot feeds command registration and migration planning, where
482
482
  // another doc's frontmatter health is noise — corpus health is `lint`'s job.
483
483
  const view = loadMemoryTargetView(target, { quiet: true });
@@ -513,7 +513,7 @@ export function readMemoryDocContent(path, includeFrontmatter = false) {
513
513
  }
514
514
  /** The address winner for an exact canonical name in the ambient target. */
515
515
  export function resolveMemoryDoc(rawName, opts = {}) {
516
- return resolveMemoryDocForTarget(rawName, ambientTarget(), opts);
516
+ return resolveMemoryDocForTarget(rawName, ambientMemoryTarget(), opts);
517
517
  }
518
518
  /** Resolve a memory document as ANOTHER node would see it — the same precedence
519
519
  * chain (node-local > project stack > profile > user > builtin), read from the
@@ -527,7 +527,7 @@ export function resolveMemoryDocForTarget(rawName, target, opts = {}) {
527
527
  * in source order — what path-aware inventory, diagnostics, and automatic
528
528
  * event eligibility consume instead of the winner alone. */
529
529
  export function resolveMemoryCandidates(rawName, opts = {}) {
530
- return resolveMemoryCandidatesForTarget(rawName, ambientTarget(), opts);
530
+ return resolveMemoryCandidatesForTarget(rawName, ambientMemoryTarget(), opts);
531
531
  }
532
532
  export function resolveMemoryCandidatesForTarget(rawName, target, opts = {}) {
533
533
  const view = loadMemoryTargetView(target, { ...opts, quiet: true });
@@ -11,7 +11,7 @@
11
11
  // Source of truth: this Set must mirror every leaf that declares
12
12
  // `help.preview.suppressOutput: true` in its own command file (currently
13
13
  // canvas roster, canvas snapshot, node inspect snapshot, node inspect show,
14
- // and sys sysprompt). There is no automated sync — when you add or remove a leaf's
14
+ // and sys context sysprompt). There is no automated sync — when you add or remove a leaf's
15
15
  // `preview.suppressOutput`, update this Set in the same commit.
16
16
  // `preview-registry-sync.test.ts` (which imports the real command tree — safe
17
17
  // in a test, never in the attach bundle) asserts the two stay identical.
@@ -22,7 +22,7 @@ export const SUPPRESS_OUTPUT_LEAVES = new Set([
22
22
  'canvas snapshot',
23
23
  'node inspect snapshot',
24
24
  'node inspect show',
25
- 'sys sysprompt',
25
+ 'sys context sysprompt',
26
26
  ]);
27
27
  /** True when the leaf at this full help-name path (e.g. "canvas snapshot")
28
28
  * declares `preview.suppressOutput: true`. */
@@ -162,8 +162,8 @@ export const LEAF_ICON_CP = {
162
162
  'sys settings': 0xf013,
163
163
  'sys setup': 0xf0ad,
164
164
  'sys doctor': 0xf0f1,
165
- 'sys sysprompt': 0xf15c,
166
- 'sys prompt-review': 0xf002,
165
+ 'sys context sysprompt': 0xf15c,
166
+ 'sys context prompt-review': 0xf002,
167
167
  'sys feedback': 0xf075,
168
168
  'sys update': 0xf021,
169
169
  'sys version': 0xf02b,
@@ -83,7 +83,7 @@ function cancelInvisibleBridge(nodeId) {
83
83
  // cannot produce a final report or notify the origin.
84
84
  const node = getNode(nodeId);
85
85
  if (node !== null && node.status !== 'done' && node.status !== 'canceled')
86
- transition(nodeId, 'cancel');
86
+ transition(nodeId, 'cancel', { reason: 'retired' });
87
87
  }
88
88
  /** Assert the only route a bridge has back to its origin before ticket exposure. */
89
89
  function ensureBridgeSubscription(review, bridgeNodeId) {
@@ -168,8 +168,10 @@ export async function realizeReview(reviewId) {
168
168
  const companion = getNode(review.companion_node_id);
169
169
  if (companion === null)
170
170
  throw companionUnbound(review.review_id, 0);
171
+ // Realization is bounded by the bind deadline below, which only a live broker
172
+ // can meet: refuse at the cap rather than freeze into a certain timeout.
171
173
  if (companion.pi_session_id === null)
172
- reviveNode(companion.node_id, { resume: false });
174
+ reviveNode(companion.node_id, { resume: false, capacity: 'refuse' });
173
175
  // R4: session_start is the durable bind boundary.
174
176
  if (!await awaitCompanionBind(review.companion_node_id, COMPANION_BIND_DEADLINE_MS)) {
175
177
  throw companionUnbound(review.review_id, COMPANION_BIND_DEADLINE_MS);
@@ -188,7 +188,7 @@ export function closeNode(rootId, opts = {}) {
188
188
  transition(id, 'finish');
189
189
  }
190
190
  else {
191
- transition(id, 'cancel');
191
+ transition(id, 'cancel', { reason: 'closed' });
192
192
  }
193
193
  // 2) Tear the node's ENGINE down: send the `shutdown` frame so the broker
194
194
  // PROCESS exits and releases the sole .jsonl writer. Then proactively
@@ -1,4 +1,17 @@
1
+ import { InputError } from '../io.js';
1
2
  import type { HostHandle } from './host.js';
3
+ /** The daemon's live-broker thresholds. The hard cap is the ledger's admission
4
+ * bound; `warning` is the saturation notice supervision fires below it. Lives
5
+ * here — beside the ledger that enforces it — so the ledger, a refusal
6
+ * message, and supervision's warning all read ONE definition. */
7
+ export declare function brokerThresholdsForDaemon(): {
8
+ warning: number;
9
+ automaticReviveCap: number;
10
+ };
11
+ /** The typed refusal a caller that cannot wait gets when the ledger is full.
12
+ * An InputError so its details round-trip through crtrd's error body and reach
13
+ * a CLI caller as the real cap text instead of an opaque 5xx. */
14
+ export declare function brokerCapReached(cap: number, live: number): InputError;
2
15
  /** Real child-exit status, exactly as Node reports it. Codes/signals are
3
16
  * diagnostics only — never policy inputs (design D-4). */
4
17
  export interface FleetExitStatus {
@@ -32,8 +45,19 @@ export interface FleetRegistry {
32
45
  has(nodeId: string): boolean;
33
46
  /** Live-fleet checks pass (yield-stall, wedge, fatal-fault). */
34
47
  get(nodeId: string): FleetEntry | undefined;
35
- /** The capacity count (design D-7: capacity = fleet.size(), exact). */
48
+ /** The capacity count: registered brokers only, excluding reservations. */
36
49
  size(): number;
50
+ /** Every slot the cap accounts for — registered brokers plus in-flight
51
+ * reservations (a launch that has claimed a slot but not yet registered, and
52
+ * an exited broker whose exit-policy job has not yet run). */
53
+ occupancy(): number;
54
+ /** Claim a slot for `nodeId`. True means the caller may launch; false means
55
+ * the cap is reached and the caller must freeze or refuse. Idempotent — a
56
+ * node that already holds a slot (reserved or registered) gets true without
57
+ * consuming a second one. */
58
+ reserveSlot(nodeId: string): boolean;
59
+ /** Give back a slot claimed by `reserveSlot` and not consumed by a launch. */
60
+ releaseReservation(nodeId: string): void;
37
61
  entries(): Iterable<[string, FleetEntry]>;
38
62
  /** Drop an entry without policy (terminal exits + close paths). */
39
63
  forget(nodeId: string): void;
@@ -45,6 +69,10 @@ export interface FleetRegistry {
45
69
  /** Test seam — delivers a synthetic exit through the SAME path as
46
70
  * onChildExit, so fabricated-row tests drive policy without a process. */
47
71
  deliverExit(nodeId: string, status: FleetExitStatus): void;
72
+ /** The node-lifecycle tick's enactment entry: classify a row known to have no
73
+ * live engine and enact through the SAME policy table exit events use, so
74
+ * tick recovery and exit recovery can never diverge. */
75
+ applyDeadRowPolicy(nodeId: string): Promise<void>;
48
76
  }
49
77
  /** Inject the daemon's fleet registry. Idempotent for the same instance;
50
78
  * throws on a conflicting rebind (one registry per process, ever). */
@@ -11,6 +11,29 @@
11
11
  // the structural enforcement that broker launches happen only inside crtrd
12
12
  // (design D-2): a CLI or library process has no fleet and cannot launch an
13
13
  // unowned broker.
14
+ import { readConfig } from '../config.js';
15
+ import { InputError } from '../io.js';
16
+ import { envMaxLiveBrokers, envWarnLiveBrokers } from '../../shared/env.js';
17
+ /** The daemon's live-broker thresholds. The hard cap is the ledger's admission
18
+ * bound; `warning` is the saturation notice supervision fires below it. Lives
19
+ * here — beside the ledger that enforces it — so the ledger, a refusal
20
+ * message, and supervision's warning all read ONE definition. */
21
+ export function brokerThresholdsForDaemon() {
22
+ const configured = readConfig('user').brokerThresholds;
23
+ const automaticReviveCap = envMaxLiveBrokers() ?? configured.automaticReviveCap;
24
+ const warning = envWarnLiveBrokers() ?? configured.warning;
25
+ return { warning: Math.min(warning, automaticReviveCap), automaticReviveCap };
26
+ }
27
+ /** The typed refusal a caller that cannot wait gets when the ledger is full.
28
+ * An InputError so its details round-trip through crtrd's error body and reach
29
+ * a CLI caller as the real cap text instead of an opaque 5xx. */
30
+ export function brokerCapReached(cap, live) {
31
+ return new InputError({
32
+ error: 'broker_cap_reached',
33
+ message: `no broker slot is free — ${live} live brokers at the cap of ${cap}.`,
34
+ next: `Finish or close a live node (\`crtr canvas render\` shows them), or raise the cap with CRTR_MAX_LIVE_BROKERS, then retry.`,
35
+ });
36
+ }
14
37
  let bound;
15
38
  /** Inject the daemon's fleet registry. Idempotent for the same instance;
16
39
  * throws on a conflicting rebind (one registry per process, ever). */
@@ -22,7 +22,7 @@ import { buildBrokerEnv } from './spawn-env.js';
22
22
  import { hostExecPath } from './branded-host.js';
23
23
  import { isPidAlive, killProcessTreePids, isAnyPidAlive, captureTeardownSnapshot } from '../canvas/pid.js';
24
24
  import { assertMandatoryCanvasExtensions } from './canvas-extensions.js';
25
- import { boundFleet } from './fleet.js';
25
+ import { boundFleet, brokerCapReached, brokerThresholdsForDaemon } from './fleet.js';
26
26
  import { emitEvent } from '../events/emit.js';
27
27
  import { bindBrokerEventSource, eventSource } from '../events/source.js';
28
28
  import { envBrokerEngine } from '../../shared/env.js';
@@ -193,6 +193,19 @@ export const headlessBrokerHost = {
193
193
  launch(nodeId, inv, opts) {
194
194
  // Fast-fail known launch errors before we detach a broker process.
195
195
  preflightBrokerLaunch(nodeId, inv, opts.cwd);
196
+ // Resolve the fleet BEFORE opening any handle: an unbound-fleet launch must
197
+ // fail fast with nothing to clean up (design D-2 — broker launches happen
198
+ // only inside crtrd, or a test harness that explicitly bound a fleet).
199
+ const fleet = boundFleet();
200
+ // The ledger is the ONE admission gate for a broker process: every launch
201
+ // path (revive, spawn, recycle, reset) funnels through here, so claiming
202
+ // the slot before anything is opened — and giving it back on any path that
203
+ // does not end in a registered handle — is what makes the cap real rather
204
+ // than advisory. A caller that cannot proceed without a slot sees this
205
+ // typed refusal; a caller that can wait catches it and freezes instead.
206
+ if (!fleet.reserveSlot(nodeId)) {
207
+ throw brokerCapReached(brokerThresholdsForDaemon().automaticReviveCap, fleet.size());
208
+ }
196
209
  // Redirect the detached broker's stdout+stderr to a per-node log under the
197
210
  // node's existing job/ dir. It retains arbitrary engine/process residue and
198
211
  // failed-canonical fatal fallback output. Append-mode preserves crash-revive history.
@@ -233,10 +246,6 @@ export const headlessBrokerHost = {
233
246
  }
234
247
  if (eventSource() === undefined)
235
248
  bindBrokerEventSource(nodeId);
236
- // Resolve the fleet BEFORE spawning: an unbound-fleet launch must fail
237
- // fast with nothing to clean up (design D-2 — broker launches happen only
238
- // inside crtrd, or a test harness that explicitly bound a fleet).
239
- const fleet = boundFleet();
240
249
  // `--canvas-home` declares broker ownership in argv so the daemon's
241
250
  // startup epoch census can match its own canvas' brokers exactly (crtrd's
242
251
  // brokerCommandDeclaresCanvasHome) instead of inferring from install
@@ -244,12 +253,20 @@ export const headlessBrokerHost = {
244
253
  // daemon's census reaps every prior-epoch broker without adoption (D-8).
245
254
  // Both precede the node id: the node id is by contract the FINAL argv
246
255
  // token (both broker-cli and the ps census parse it that way).
247
- const child = spawn(hostExecPath(), [resolveBrokerEntry(), '--canvas-home', crtrHome(), '--epoch', fleet.epoch(), nodeId], {
248
- cwd: opts.cwd,
249
- detached: true,
250
- stdio: ['ignore', logFd, logFd],
251
- env: childEnv,
252
- });
256
+ let child;
257
+ try {
258
+ child = spawn(hostExecPath(), [resolveBrokerEntry(), '--canvas-home', crtrHome(), '--epoch', fleet.epoch(), nodeId], {
259
+ cwd: opts.cwd,
260
+ detached: true,
261
+ stdio: ['ignore', logFd, logFd],
262
+ env: childEnv,
263
+ });
264
+ }
265
+ catch (error) {
266
+ fleet.releaseReservation(nodeId);
267
+ closeSync(logFd);
268
+ throw error;
269
+ }
253
270
  // The child holds its own dup of the fd; release the parent's copy so the
254
271
  // launching process (CLI or daemon) never leaks it.
255
272
  closeSync(logFd);
@@ -299,6 +316,10 @@ export const headlessBrokerHost = {
299
316
  fleet.register(nodeId, handle);
300
317
  void exited.then((status) => fleet.onChildExit(nodeId, status));
301
318
  }
319
+ else {
320
+ // Nothing was registered, so nothing will ever free this slot at exit.
321
+ fleet.releaseReservation(nodeId);
322
+ }
302
323
  return handle;
303
324
  },
304
325
  isAlive(node) {
@@ -4,5 +4,5 @@
4
4
  * before. ALL runtime/spine/lifecycle/kind prompting now lives as gated builtin
5
5
  * memory docs (src/builtin-memory/), spliced in fresh every turn — this scaffold
6
6
  * must never duplicate that prose. Imported by launch.ts (its own systemPrompt)
7
- * and `sys prompt-review` so the two can never drift. */
7
+ * and `sys context prompt-review` so the two can never drift. */
8
8
  export declare const LAUNCH_SYSTEM_PROMPT: string;