@north-light/crouter 0.3.197 → 0.3.198

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 (144) hide show
  1. package/dist/api/client.d.ts +2 -2
  2. package/dist/api/dto/inbox.d.ts +46 -0
  3. package/dist/builtin-memory/00-runtime-base.md +3 -3
  4. package/dist/builtin-memory/internal/nodes-and-canvas.md +1 -1
  5. package/dist/builtin-memory/internal/storage-tiers.md +1 -1
  6. package/dist/builtin-memory/wedged-child-on-runaway-bash.md +1 -1
  7. package/dist/clients/attach/render/chat-view.d.ts +3 -8
  8. package/dist/clients/attach/render/chat-view.js +3 -24
  9. package/dist/clients/attach/render/markdown-source.js +0 -6
  10. package/dist/clients/attach/render/tool-calls.js +16 -2
  11. package/dist/clients/attach/viewer.js +613 -602
  12. package/dist/clients/inbox/__tests__/serial/inbox-controller.test.js +2 -2
  13. package/dist/clients/inbox/__tests__/serial/mount-panel.test.js +11 -4
  14. package/dist/clients/inbox/controller.js +4 -4
  15. package/dist/clients/inbox/page-adapter.d.ts +2 -1
  16. package/dist/clients/inbox/tui/render.js +46 -25
  17. package/dist/clients/inbox/tui/slots.d.ts +1 -3
  18. package/dist/clients/inbox/tui/slots.js +8 -37
  19. package/dist/clients/inbox/tui/types.d.ts +4 -3
  20. package/dist/commands/__tests__/human.test.js +160 -23
  21. package/dist/commands/canvas-browse.js +1 -1
  22. package/dist/commands/human/doc.js +21 -74
  23. package/dist/commands/human/html.d.ts +2 -0
  24. package/dist/commands/human/html.js +81 -0
  25. package/dist/commands/human/prompts.d.ts +5 -4
  26. package/dist/commands/human/prompts.js +81 -153
  27. package/dist/commands/human/shared.d.ts +5 -6
  28. package/dist/commands/human/shared.js +13 -61
  29. package/dist/commands/human.js +7 -2
  30. package/dist/core/__tests__/daemon-boot.test.js +7 -4
  31. package/dist/core/__tests__/dead-node-policy-table.test.js +20 -36
  32. package/dist/core/__tests__/human-deliver.test.js +28 -37
  33. package/dist/core/__tests__/seam/broker-crash-teardown.test.js +13 -11
  34. package/dist/core/__tests__/seam/dormancy-release.test.js +63 -4
  35. package/dist/core/__tests__/serial/human-deliver-e2e.test.js +3 -13
  36. package/dist/core/canvas/__tests__/attention.test.js +2 -2
  37. package/dist/core/canvas/__tests__/render-remote.test.js +2 -2
  38. package/dist/core/canvas/browse/app.d.ts +1 -1
  39. package/dist/core/canvas/browse/app.js +21 -3
  40. package/dist/core/canvas/browse/render.d.ts +4 -0
  41. package/dist/core/canvas/browse/render.js +5 -1
  42. package/dist/core/canvas/canvas.d.ts +26 -0
  43. package/dist/core/canvas/canvas.js +55 -0
  44. package/dist/core/command.js +6 -11
  45. package/dist/core/config.js +3 -2
  46. package/dist/core/help.d.ts +3 -0
  47. package/dist/core/human/__tests__/html-markdown.test.d.ts +1 -0
  48. package/dist/core/human/__tests__/html-markdown.test.js +52 -0
  49. package/dist/core/human/__tests__/page-catalog.test.d.ts +1 -0
  50. package/dist/core/human/__tests__/page-catalog.test.js +16 -0
  51. package/dist/core/human/__tests__/page-render.test.js +40 -23
  52. package/dist/core/human/__tests__/page-tickets.test.js +48 -27
  53. package/dist/core/human/__tests__/page.test.js +48 -65
  54. package/dist/core/human/__tests__/serial/inbox-core.test.js +5 -15
  55. package/dist/core/human/answer-text.d.ts +3 -0
  56. package/dist/core/human/answer-text.js +98 -0
  57. package/dist/core/human/answer.d.ts +51 -0
  58. package/dist/core/human/answer.js +125 -0
  59. package/dist/core/human/component-docs.d.ts +14 -0
  60. package/dist/core/human/component-docs.js +161 -0
  61. package/dist/core/human/html-markdown.d.ts +3 -0
  62. package/dist/core/human/html-markdown.js +288 -0
  63. package/dist/core/human/page-catalog.d.ts +19 -3
  64. package/dist/core/human/page-catalog.js +73 -16
  65. package/dist/core/human/page-render.d.ts +3 -2
  66. package/dist/core/human/page-render.js +19 -45
  67. package/dist/core/human/page-schema.d.ts +108 -8
  68. package/dist/core/human/page-schema.js +88 -31
  69. package/dist/core/human/page-synth.d.ts +2 -4
  70. package/dist/core/human/page-synth.js +23 -7
  71. package/dist/core/human/page.d.ts +14 -7
  72. package/dist/core/human/page.js +178 -90
  73. package/dist/core/human/review-schema.d.ts +1 -0
  74. package/dist/core/human/review-schema.js +1 -1
  75. package/dist/core/human/scan.d.ts +3 -2
  76. package/dist/core/human/scan.js +7 -8
  77. package/dist/core/human/summary.d.ts +4 -7
  78. package/dist/core/human/summary.js +4 -60
  79. package/dist/core/human/tickets.d.ts +10 -9
  80. package/dist/core/human/tickets.js +5 -3
  81. package/dist/core/human/types.d.ts +4 -0
  82. package/dist/core/keybindings/catalog.d.ts +2 -2
  83. package/dist/core/keybindings/catalog.js +1 -0
  84. package/dist/core/review/realize.js +9 -1
  85. package/dist/core/runtime/boot-root.js +6 -1
  86. package/dist/core/runtime/broker.js +8 -6
  87. package/dist/core/runtime/busy.d.ts +4 -3
  88. package/dist/core/runtime/busy.js +4 -3
  89. package/dist/core/runtime/spawn.js +4 -4
  90. package/dist/core/user-settings.d.ts +17 -2
  91. package/dist/core/user-settings.js +32 -11
  92. package/dist/daemon/api/handlers/human.js +1 -1
  93. package/dist/daemon/api/handlers/inbox.js +22 -7
  94. package/dist/daemon/cron-run.js +16 -2
  95. package/dist/daemon/crtrd.js +1 -1
  96. package/dist/daemon/fleet.d.ts +22 -10
  97. package/dist/daemon/fleet.js +43 -24
  98. package/dist/daemon/human/finish.d.ts +2 -1
  99. package/dist/daemon/human/finish.js +45 -28
  100. package/dist/daemon/human/sweep.js +4 -2
  101. package/dist/daemon/reconcilers/broker-supervision.js +11 -8
  102. package/dist/daemon/reconcilers/dormant-inbox.js +11 -6
  103. package/dist/daemon/reconcilers/live-obligation.d.ts +21 -1
  104. package/dist/daemon/reconcilers/live-obligation.js +30 -2
  105. package/dist/daemon/reconcilers/storage-maintenance.d.ts +5 -0
  106. package/dist/daemon/reconcilers/storage-maintenance.js +28 -1
  107. package/dist/daemon/review/sweep.js +12 -1
  108. package/dist/pages/bundle.css +1 -1
  109. package/dist/pages/bundle.js +1008 -577
  110. package/dist/pages/comments.d.ts +29 -0
  111. package/dist/pages/comments.js +51 -0
  112. package/dist/pages/controls.d.ts +98 -0
  113. package/dist/pages/controls.js +261 -0
  114. package/dist/pages/elements/cards.d.ts +15 -4
  115. package/dist/pages/elements/cards.js +299 -186
  116. package/dist/pages/elements/chart.d.ts +22 -0
  117. package/dist/pages/elements/chart.js +107 -91
  118. package/dist/pages/elements/options.d.ts +9 -0
  119. package/dist/pages/elements/options.js +268 -155
  120. package/dist/pages/elements/pages.d.ts +6 -0
  121. package/dist/pages/elements/pages.js +60 -38
  122. package/dist/pages/elements/slot.d.ts +5 -0
  123. package/dist/pages/elements/slot.js +17 -10
  124. package/dist/pages/elements/table.d.ts +17 -0
  125. package/dist/pages/elements/table.js +336 -228
  126. package/dist/pages/elements/text.d.ts +9 -3
  127. package/dist/pages/elements/text.js +350 -143
  128. package/dist/pages/host.d.ts +0 -24
  129. package/dist/pages/host.js +0 -17
  130. package/dist/pages/readonly.d.ts +24 -0
  131. package/dist/pages/readonly.js +31 -0
  132. package/dist/pages/responses.d.ts +37 -0
  133. package/dist/pages/responses.js +56 -0
  134. package/dist/pi-extensions/canvas-bash-valve.d.ts +9 -1
  135. package/dist/pi-extensions/canvas-bash-valve.js +12 -8
  136. package/dist/types.d.ts +19 -5
  137. package/dist/types.js +2 -1
  138. package/package.json +1 -1
  139. package/runtime.lock.json +2 -2
  140. package/scripts/install-runtime.mjs +19 -24
  141. package/dist/clients/attach/render/html-markdown.d.ts +0 -13
  142. package/dist/clients/attach/render/html-markdown.js +0 -206
  143. package/dist/core/human/markdown-html.d.ts +0 -8
  144. package/dist/core/human/markdown-html.js +0 -581
@@ -51,21 +51,33 @@ async function surfaceRecoveryFailure(meta, error) {
51
51
  await pushUrgent(meta.node_id, body, { from: meta.node_id });
52
52
  }
53
53
  /** Whether a dead relaunch should resume the saved session strictly, or retry
54
- * as a fresh cycle instead. A cycling attempt (cycle_pending) that dies
55
- * before session_start must retry AS a cycle — a strict reopen only restores
56
- * the transcript at an idle prompt, it cannot restart the dead engine turn.
57
- * A dirty interrupted BUSY turn also requests a cycle; a broker-certified
58
- * clean abort is handled explicitly by classifyDeadNode before this fallback. */
59
- export function retryResumeMode(meta, interruptedTurn = false) {
60
- return meta?.cycle_pending !== true && !interruptedTurn;
54
+ * as a fresh cycle instead. A cycling attempt (cycle_pending) that dies before
55
+ * session_start must retry AS a cycle: the fresh branch it was opening is not
56
+ * confirmed, so resuming would silently continue the old leaf the refresh was
57
+ * meant to leave behind.
58
+ *
59
+ * An interrupted turn is NOT a reason to cycle. A strict reopen does restore
60
+ * the transcript at an idle prompt without restarting the dead turn — but the
61
+ * fix for that is the continuation prompt recovery delivers (see `#respawn`),
62
+ * not throwing the conversation away. */
63
+ export function retryResumeMode(meta) {
64
+ return meta?.cycle_pending !== true;
61
65
  }
62
66
  /** One pure function producing the action for a node known-dead. Used at
63
67
  * exactly two call sites — an exit-policy job and the startup recovery sweep
64
68
  * (design D-12) — so exit recovery and startup recovery can never diverge.
65
- * Evaluated in table order; `busy` is the on-disk busy marker. A clean-abort
66
- * certificate is written by the broker only after Pi persisted its aborted
67
- * entry; without it, a set busy marker means a dirty mid-turn crash. */
68
- export function classifyDeadNode(row, busy, cleanAbort = false) {
69
+ * Evaluated in table order; `busy` is the on-disk busy marker.
70
+ *
71
+ * `busy` no longer selects the ACTION. Whether the engine died mid-turn or
72
+ * parked at an idle prompt, the saved transcript is equally resumable — pi
73
+ * persists whole entries (`appendFileSync` per line) so a killed generation
74
+ * simply leaves the file ending at its last complete entry, and the loader
75
+ * skips a malformed line rather than rejecting the session. A mid-turn death
76
+ * needs the turn re-driven, which is a continuation prompt on top of the
77
+ * resume (see `#respawn`), not a fresh branch. `busy` therefore survives here
78
+ * only to route a node that died before session_start ever recorded a session
79
+ * to a fresh relaunch instead of terminalizing it as a boot failure. */
80
+ export function classifyDeadNode(row, busy) {
69
81
  if (row === null)
70
82
  return 'forget'; // row 1: row deleted
71
83
  if (row.status === 'done' || row.status === 'canceled' || row.status === 'dead')
@@ -74,15 +86,13 @@ export function classifyDeadNode(row, busy, cleanAbort = false) {
74
86
  return 'respawn-fresh'; // row 3
75
87
  if (row.intent === 'idle-release')
76
88
  return 'dormant'; // row 4
77
- if (cleanAbort && row.pi_session_id != null)
78
- return 'respawn-resume'; // row 5: broker-certified interrupted turn
79
- if (busy)
80
- return 'respawn-cycle'; // row 6: dirty interrupted turn
81
89
  if (row.pi_session_id != null)
82
- return retryResumeMode(row, false) ? 'respawn-resume' : 'respawn-cycle'; // row 7
83
- if (row.fork_from != null && row.pi_session_id == null)
84
- return 'respawn-fresh'; // row 8: bounded ordinary fork-birth retry
85
- return 'boot-failure'; // row 9
90
+ return retryResumeMode(row) ? 'respawn-resume' : 'respawn-cycle'; // row 5: any saved session
91
+ if (busy)
92
+ return 'respawn-fresh'; // row 6: died mid-turn before a session was ever recorded
93
+ if (row.fork_from != null)
94
+ return 'respawn-fresh'; // row 7: bounded ordinary fork-birth retry
95
+ return 'boot-failure'; // row 8
86
96
  }
87
97
  // ---------------------------------------------------------------------------
88
98
  // Respawn throttle (replaces the stranded-relaunch counters, event-driven)
@@ -248,8 +258,12 @@ export class DaemonFleet {
248
258
  }
249
259
  async #applyPolicy(nodeId, origin) {
250
260
  const meta = getNode(nodeId);
251
- const cleanAbort = hasCleanAbort(nodeId);
252
- const action = classifyDeadNode(meta, isBusy(nodeId), cleanAbort);
261
+ // Two durable witnesses mean the dead broker had a turn in flight. A dirty
262
+ // death leaves the busy marker behind; a bounded graceful abort clears busy
263
+ // as the run settles, then writes the clean-abort certificate. Both need the
264
+ // same recovery: resume the saved transcript and re-drive the stopped turn.
265
+ const interruptedTurn = isBusy(nodeId) || hasCleanAbort(nodeId);
266
+ const action = classifyDeadNode(meta, interruptedTurn);
253
267
  switch (action) {
254
268
  case 'forget':
255
269
  // Fanouts for terminal exits already happened at the transition site.
@@ -271,12 +285,12 @@ export class DaemonFleet {
271
285
  case 'respawn-fresh':
272
286
  case 'respawn-cycle':
273
287
  case 'respawn-resume':
274
- await this.#respawn(nodeId, meta, action, origin, cleanAbort);
288
+ await this.#respawn(nodeId, meta, action, origin, interruptedTurn);
275
289
  break;
276
290
  }
277
291
  return action;
278
292
  }
279
- async #respawn(nodeId, meta, action, origin, cleanAbort) {
293
+ async #respawn(nodeId, meta, action, origin, interruptedTurn) {
280
294
  const state = this.#throttle.get(nodeId) ?? { failures: 0 };
281
295
  if (state.failures >= MAX_RESPAWN_ATTEMPTS) {
282
296
  await this.#terminalize(nodeId, meta, {
@@ -327,7 +341,12 @@ export class DaemonFleet {
327
341
  try {
328
342
  reviveNode(nodeId, {
329
343
  resume: action === 'respawn-resume',
330
- wakeReason: cleanAbort ? 'runtime-restart-abort' : undefined,
344
+ // A resumed session whose turn was interrupted comes back parked at an
345
+ // idle prompt with the dead turn unfinished. The continuation re-drives
346
+ // it in place, so the conversation continues instead of being rebuilt
347
+ // from disk on a fresh branch. Only a true resume can carry it; a cycle
348
+ // or fresh relaunch gets its own kickoff.
349
+ wakeReason: action === 'respawn-resume' && interruptedTurn ? 'runtime-restart-abort' : undefined,
331
350
  // Replacing a dead broker instance is recovery, not a wake: nothing
332
351
  // external arrived for this node, so an armed deadline survives the
333
352
  // relaunch instead of being consumed by it.
@@ -1,3 +1,4 @@
1
+ import type { ProductPageComponents } from '../../types.js';
1
2
  import type { TicketResult } from '../../core/human/types.js';
2
3
  /**
3
4
  * The one completion path for a page ticket: claim takeover, exclusive result
@@ -5,7 +6,7 @@ import type { TicketResult } from '../../core/human/types.js';
5
6
  * node-addressed resolve route and the ticket-id-addressed inbox respond route —
6
7
  * finishes a page here, so an answer always reaches the node that asked.
7
8
  */
8
- export declare function resolvePageTicket(nodeId: string, responses: unknown, productKinds?: string[]): Promise<TicketResult>;
9
+ export declare function resolvePageTicket(nodeId: string, responses: unknown, productKinds?: ProductPageComponents): Promise<TicketResult>;
9
10
  /** Cancel a ticket with the store's first-writer-wins publication. */
10
11
  export declare function cancelHumanTicket(nodeId: string, opts: {
11
12
  reason?: string;
@@ -1,36 +1,45 @@
1
1
  import { join } from 'node:path';
2
2
  import { ApiError } from '../../api/index.js';
3
- import { getNode, subscribersOf } from '../../core/canvas/canvas.js';
3
+ import { getRow, subscribersOf } from '../../core/canvas/canvas.js';
4
+ import { readConfig } from '../../core/config.js';
4
5
  import { ticketDir } from '../../core/human/root.js';
6
+ import { parsePage } from '../../core/human/page.js';
5
7
  import { cancelTicket, projectReviewOutput, readTicketResult, takeoverAndCompletePage, } from '../../core/human/tickets.js';
8
+ import { describePageAnswer } from '../../core/human/answer.js';
9
+ import { renderAnswerText } from '../../core/human/answer-text.js';
6
10
  import { responsePath } from '../../core/human/convention.js';
7
11
  import { readJsonOrNull } from '../../core/fs-utils.js';
8
12
  import { appendInbox } from '../../core/feed/inbox.js';
9
13
  import { FinalDeliveryError, pushFinal } from '../../core/feed/feed.js';
10
14
  import { transition } from '../../core/runtime/lifecycle.js';
11
- /**
12
- * The answer a page's asking node wakes with. Slot responses are small and
13
- * typed, so they are inlined as compact JSON rather than left behind the path:
14
- * the node reads its answers out of its own report. An acknowledgement carries
15
- * no responses at all and reads as one line saying the human saw the page.
16
- * Interim rendering — the designed answer view is phase 5.
17
- */
18
- function renderPageAnswer(result, resultPath) {
19
- const entries = Object.entries(result.responses);
20
- const lines = [];
21
- if (entries.length === 0) {
22
- lines.push('The human read the page and dismissed it. No slot responses came back, so the acknowledgement is the whole answer.');
15
+ /** A corrupt manifest must not strand the asking node: the raw response remains its escape hatch. */
16
+ function renderCorruptPageAnswer(result, resultPath) {
17
+ return [
18
+ 'The page manifest could not be read, so the raw response is provided.',
19
+ '',
20
+ JSON.stringify(result.responses),
21
+ '',
22
+ `Response record: ${resultPath}`,
23
+ ].join('\n');
24
+ }
25
+ function pageTitle(dir) {
26
+ try {
27
+ return parsePage(dir, readConfig('user').page_components).title;
28
+ }
29
+ catch {
30
+ return undefined;
31
+ }
32
+ }
33
+ function renderPageAnswer(dir, result, resultPath) {
34
+ const productKinds = readConfig('user').page_components;
35
+ let manifest;
36
+ try {
37
+ manifest = parsePage(dir, productKinds);
23
38
  }
24
- else {
25
- lines.push(`The human answered ${entries.length} slot${entries.length === 1 ? '' : 's'}:`, '');
26
- for (const [slotId, response] of entries)
27
- lines.push(`${slotId}: ${JSON.stringify(response)}`);
28
- const summary = result.summary.trim();
29
- if (summary !== '')
30
- lines.push('', summary);
39
+ catch {
40
+ return renderCorruptPageAnswer(result, resultPath);
31
41
  }
32
- lines.push('', `Response metadata (slot ids, typed responses, timestamp): ${resultPath}`);
33
- return lines.join('\n');
42
+ return renderAnswerText(describePageAnswer(manifest, result.responses, { completedAt: result.completedAt, productKinds }), resultPath);
34
43
  }
35
44
  function deliveryFailed(error) {
36
45
  // A canonical final is committed inside its own write boundary and can never
@@ -96,12 +105,20 @@ export async function deliverTerminalResult(nodeId) {
96
105
  if (result === null)
97
106
  return false;
98
107
  projectReviewOutput(dir);
99
- const node = getNode(nodeId);
108
+ // The ROW, not `getNode`: a ticket directory IS the bridge's node directory,
109
+ // so a bridge whose row has been pruned still answers `getNode` from its
110
+ // surviving meta.json — with no status at all, which passed every guard below
111
+ // and then threw out of `transition` on every sweep, re-fanning the
112
+ // cancellation notice each time. No row means nothing left to deliver to.
113
+ const node = getRow(nodeId);
100
114
  if (node === null || node.status === 'dead' || node.status === 'canceled' || (node.status === 'done' && node.final_report !== null)) {
101
115
  return true;
102
116
  }
103
117
  if (result.kind === 'canceled') {
104
- const note = result.reason !== undefined && result.reason !== '' ? ` — ${result.reason}` : '';
118
+ const title = pageTitle(dir);
119
+ const reason = result.reason !== undefined && result.reason !== '' ? result.reason : 'no reason was given';
120
+ const subject = title === undefined ? `human interaction ${nodeId} canceled — no answer is coming` : `human interaction ${nodeId} — “${title}” canceled, no answer is coming`;
121
+ const body = title === undefined ? `The human interaction ${nodeId} was canceled — ${reason}. No response will arrive, and any partial answers the human had entered were discarded unsent.` : `The page “${title}” was canceled — ${reason}. No response will arrive, and any partial answers the human had entered were discarded unsent.`;
105
122
  // One failing subscriber must not silence the rest, and the bridge stays
106
123
  // live until every notice lands: a partial fanout leaves the ticket for the
107
124
  // next resolve/cancel attempt or the daemon-start sweep to replay. A
@@ -114,10 +131,10 @@ export async function deliverTerminalResult(nodeId) {
114
131
  try {
115
132
  appendInbox(subscriber.node_id, {
116
133
  from: nodeId,
117
- tier: 'deferred',
134
+ tier: 'normal',
118
135
  kind: 'message',
119
- label: `human interaction ${nodeId} canceled — no answer is coming${note}`,
120
- data: { body: `The human interaction ${nodeId} was canceled${note}. No response will arrive.` },
136
+ label: subject,
137
+ data: { body },
121
138
  });
122
139
  }
123
140
  catch {
@@ -130,7 +147,7 @@ export async function deliverTerminalResult(nodeId) {
130
147
  return true;
131
148
  }
132
149
  if (result.kind === 'page') {
133
- await pushFinal(nodeId, renderPageAnswer(result, responsePath(dir)));
150
+ await pushFinal(nodeId, renderPageAnswer(dir, result, responsePath(dir)));
134
151
  }
135
152
  return true;
136
153
  }
@@ -1,6 +1,6 @@
1
1
  import { readdirSync, statSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
- import { getNode } from '../../core/canvas/canvas.js';
3
+ import { getRow } from '../../core/canvas/canvas.js';
4
4
  import { getReviewByBridge } from '../../core/review/store.js';
5
5
  import { emitEvent } from '../../core/events/emit.js';
6
6
  import { ticketsRoot } from '../../core/human/root.js';
@@ -27,7 +27,9 @@ export async function reconcileUndeliveredTickets() {
27
27
  // finisher retries only when its winning operation is explicitly repeated.
28
28
  if (getReviewByBridge(entry) !== null)
29
29
  continue;
30
- const bridge = getNode(entry);
30
+ // The ROW is what carries status; `getNode` would answer from a pruned
31
+ // bridge's surviving meta.json with no status at all.
32
+ const bridge = getRow(entry);
31
33
  if (bridge === null || isTerminalBridge(bridge.status))
32
34
  continue;
33
35
  if (await deliverTerminalResult(entry))
@@ -3,7 +3,7 @@ import { join } from 'node:path';
3
3
  import { homedir } from 'node:os';
4
4
  import { execFile } from 'node:child_process';
5
5
  import { promisify } from 'node:util';
6
- import { getRow, getNode, subscribersOf } from '../../core/canvas/index.js';
6
+ import { getRow, getNode, isWarmSpare, subscribersOf } from '../../core/canvas/index.js';
7
7
  import { contextDir, isSafeNodeId } from '../../core/canvas/paths.js';
8
8
  import { fullName } from '../../core/canvas/labels.js';
9
9
  import { recordedPidLiveness } from '../../core/canvas/pid.js';
@@ -20,7 +20,7 @@ import { operationIdContext } from '../../core/events/operation-id.js';
20
20
  import { readCursor, readInboxSince } from '../../core/feed/inbox.js';
21
21
  import { activeBackgroundBashJobs } from '../../core/bash-jobs.js';
22
22
  import { focusOf } from '../../core/runtime/placement.js';
23
- import { hasLiveObligation } from './live-obligation.js';
23
+ import { parkEvent } from './live-obligation.js';
24
24
  const execFileAsync = promisify(execFile);
25
25
  function configuredPositiveInteger(name, fallback) {
26
26
  const raw = process.env[name];
@@ -174,6 +174,14 @@ export class BrokerSupervisionReconciler {
174
174
  return thresholds;
175
175
  }
176
176
  handleUnattendedParking(id, row, livePid, now) {
177
+ // An unclaimed warm-pool spare is not a conversation: the booted broker it
178
+ // holds IS its whole value, and nobody is meant to be attending it. Its
179
+ // lifetime belongs to `reapStaleSpares` (warm-pool.ts) and its own TTL, so
180
+ // parking one here would empty the pool every 15 minutes.
181
+ if (isWarmSpare(id)) {
182
+ this.unattendedSince.delete(id);
183
+ return;
184
+ }
177
185
  if (row === null
178
186
  || row.pi_session_id == null
179
187
  || row.pi_pid !== livePid
@@ -206,12 +214,7 @@ export class BrokerSupervisionReconciler {
206
214
  if (now - since < unattendedParkMsForDaemon())
207
215
  return;
208
216
  this.unattendedSince.delete(id);
209
- const liveObligation = hasLiveObligation(id);
210
- // A resident with nothing left to wake it has completed its conversation:
211
- // terminalize it so history retention can eventually prune it. A resident
212
- // still awaiting a child/controller/human/deadline/output stays wakeable, as does
213
- // every terminal node (whose own stop guard owns semantic completion).
214
- transition(id, row.lifecycle === 'resident' && !liveObligation ? 'finish' : 'release');
217
+ transition(id, parkEvent(id, row.lifecycle));
215
218
  headlessBrokerHost.teardown(id);
216
219
  }
217
220
  async warnOverCapInTmux(count, warning, max) {
@@ -7,7 +7,7 @@ import { appendControllerDeathNotice, readCursor, readInboxSince, } from '../../
7
7
  import { fanDoctrineWake } from '../../core/runtime/close.js';
8
8
  import { transition } from '../../core/runtime/lifecycle.js';
9
9
  import { reviveNode } from '../../core/runtime/revive.js';
10
- import { hasLiveObligation } from './live-obligation.js';
10
+ import { parkEvent } from './live-obligation.js';
11
11
  // Idle-release/inbox retry cap (2026-07-10 diagnosis, round 2): the SAME
12
12
  // livelock shape as the stranded-relaunch case, but on the separate pass that
13
13
  // resumes a dormant idle-released node when its inbox gains an unseen entry.
@@ -80,11 +80,16 @@ export class DormantInboxReconciler {
80
80
  if (!entries.some((entry) => entry.tier !== 'deferred')) {
81
81
  this.relaunchState.delete(id);
82
82
  const meta = getNode(id);
83
- // An idle resident with no wake-worthy inbox entry still needs
84
- // lifecycle settlement. Once nothing live remains, this is completed
85
- // history rather than a permanently protected idle row.
86
- if (meta?.lifecycle === 'resident' && !hasLiveObligation(id)) {
87
- transition(id, 'finish');
83
+ // An idle row with no wake-worthy inbox entry still needs the same
84
+ // lifecycle settlement the live park clock applies: once nothing
85
+ // live remains, this is history rather than a permanently protected
86
+ // idle row. The row is already parked, so only a terminalizing
87
+ // verdict is worth writing. Unclaimed warm-pool spares never reach
88
+ // here — `listNodes` hides them from `ctx.rows`.
89
+ if (meta !== null) {
90
+ const event = parkEvent(id, meta.lifecycle);
91
+ if (event !== 'release')
92
+ transition(id, event);
88
93
  }
89
94
  return;
90
95
  }
@@ -1,2 +1,22 @@
1
- /** Durable work that requires a resident to remain automatically wakeable. */
1
+ import { type Lifecycle } from '../../core/canvas/index.js';
2
+ /** Durable work that requires a node to remain automatically wakeable. */
2
3
  export declare function hasLiveObligation(nodeId: string): boolean;
4
+ /** What parking an unattended node actually amounts to — the one rule both the
5
+ * live-broker park clock (broker-supervision) and the already-parked
6
+ * reconciliation (dormant-inbox) apply.
7
+ *
8
+ * `release` — something can still wake it, so parking is a pause.
9
+ * `finish` — a resident with nothing left to wake it has completed its
10
+ * conversation, and the row becomes prunable history.
11
+ * `cancel` — a terminal node that never pushed a final was torn down before
12
+ * finishing, which is what `canceled` records; it stays revivable
13
+ * and becomes prunable. A live subscriber still awaiting its
14
+ * report is an outstanding deliverable, so such a node keeps its
15
+ * wakeable idle row instead of being written off.
16
+ *
17
+ * Undelivered mail of ANY tier also holds the row. A deferred-only unseen set
18
+ * never wakes a node on its own — it rides the next natural cycle — but a
19
+ * terminalized row no longer takes that cycle, so writing one off would drop
20
+ * content the sender already handed over.
21
+ */
22
+ export declare function parkEvent(nodeId: string, lifecycle: Lifecycle): 'release' | 'finish' | 'cancel';
@@ -1,7 +1,8 @@
1
- import { hasActiveLiveSubscription, hasLiveMessageWait, hasPendingCancelOnWakeCron, } from '../../core/canvas/index.js';
1
+ import { hasActiveLiveSubscription, hasLiveMessageWait, hasLiveSubscriber, hasPendingCancelOnWakeCron, } from '../../core/canvas/index.js';
2
2
  import { countTickets } from '../../core/canvas/attention.js';
3
+ import { readCursor, readInboxSince } from '../../core/feed/inbox.js';
3
4
  import { readOutputRequest } from '../../core/runtime/structured-output.js';
4
- /** Durable work that requires a resident to remain automatically wakeable. */
5
+ /** Durable work that requires a node to remain automatically wakeable. */
5
6
  export function hasLiveObligation(nodeId) {
6
7
  return hasActiveLiveSubscription(nodeId)
7
8
  || hasLiveMessageWait(nodeId)
@@ -9,3 +10,30 @@ export function hasLiveObligation(nodeId) {
9
10
  || countTickets(nodeId) > 0
10
11
  || readOutputRequest(nodeId).state !== 'absent';
11
12
  }
13
+ /** What parking an unattended node actually amounts to — the one rule both the
14
+ * live-broker park clock (broker-supervision) and the already-parked
15
+ * reconciliation (dormant-inbox) apply.
16
+ *
17
+ * `release` — something can still wake it, so parking is a pause.
18
+ * `finish` — a resident with nothing left to wake it has completed its
19
+ * conversation, and the row becomes prunable history.
20
+ * `cancel` — a terminal node that never pushed a final was torn down before
21
+ * finishing, which is what `canceled` records; it stays revivable
22
+ * and becomes prunable. A live subscriber still awaiting its
23
+ * report is an outstanding deliverable, so such a node keeps its
24
+ * wakeable idle row instead of being written off.
25
+ *
26
+ * Undelivered mail of ANY tier also holds the row. A deferred-only unseen set
27
+ * never wakes a node on its own — it rides the next natural cycle — but a
28
+ * terminalized row no longer takes that cycle, so writing one off would drop
29
+ * content the sender already handed over.
30
+ */
31
+ export function parkEvent(nodeId, lifecycle) {
32
+ if (hasLiveObligation(nodeId))
33
+ return 'release';
34
+ if (readInboxSince(nodeId, readCursor(nodeId)).length > 0)
35
+ return 'release';
36
+ if (lifecycle === 'resident')
37
+ return 'finish';
38
+ return hasLiveSubscriber(nodeId) ? 'release' : 'cancel';
39
+ }
@@ -4,7 +4,12 @@ export declare const DEAD_REAP_GRACE_MS: number;
4
4
  export declare class StorageMaintenanceReconciler {
5
5
  private lastPruneAt;
6
6
  private lastSpareSweepAt;
7
+ private lastGhostSweepAt;
7
8
  run(now: number): void;
9
+ /** Drop rows whose on-disk node dir is gone. Such a row can never be revived,
10
+ * focused, or closed — every one of those resolves the node through meta.json —
11
+ * so it sits in the roster jamming the surfaces that try. One readdir per sweep. */
12
+ private reapGhostRows;
8
13
  /** Milliseconds since meta/telemetry was last written. Inbox activity is
9
14
  * deliberately excluded: children can keep writing a dead manager's inbox. */
10
15
  private msSinceDiskActivity;
@@ -1,6 +1,6 @@
1
1
  import { statSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
- import { closeFocusRow, listFocuses, listNodes, pruneToLimit, } from '../../core/canvas/index.js';
3
+ import { closeFocusRow, listFocuses, listNodes, pruneToLimit, reapGhostRows, } from '../../core/canvas/index.js';
4
4
  import { isSafeNodeId, jobDir, nodeDir } from '../../core/canvas/paths.js';
5
5
  import { emitEvent } from '../../core/events/emit.js';
6
6
  import { operationIdContext } from '../../core/events/operation-id.js';
@@ -12,20 +12,47 @@ export const DEAD_REAP_GRACE_MS = 10 * 60_000;
12
12
  const AUTO_PRUNE_MAX = 2000;
13
13
  const PRUNE_INTERVAL_MS = 60 * 60 * 1000;
14
14
  const SPARE_SWEEP_INTERVAL_MS = 60 * 1000;
15
+ const GHOST_SWEEP_INTERVAL_MS = 60 * 1000;
15
16
  /** Recurring storage cleanup. The in-memory throttle clocks intentionally reset
16
17
  * on daemon boot, so the first tick runs both full sweeps. */
17
18
  export class StorageMaintenanceReconciler {
18
19
  lastPruneAt = Number.NEGATIVE_INFINITY;
19
20
  lastSpareSweepAt = Number.NEGATIVE_INFINITY;
21
+ lastGhostSweepAt = Number.NEGATIVE_INFINITY;
20
22
  run(now) {
21
23
  // Placement reap MUST precede focus GC, so GC catches a focus row the reap
22
24
  // strands. Both run after dormant-inbox reconciliation, so a same-tick
23
25
  // revive has already had the chance to re-anchor its focus.
24
26
  this.reapDeadResidue(now);
25
27
  this.gcStaleFocuses();
28
+ this.reapGhostRows(now);
26
29
  this.autoPrune(now);
27
30
  this.gcWarmPool(now);
28
31
  }
32
+ /** Drop rows whose on-disk node dir is gone. Such a row can never be revived,
33
+ * focused, or closed — every one of those resolves the node through meta.json —
34
+ * so it sits in the roster jamming the surfaces that try. One readdir per sweep. */
35
+ reapGhostRows(now) {
36
+ if (now - this.lastGhostSweepAt < GHOST_SWEEP_INTERVAL_MS)
37
+ return;
38
+ this.lastGhostSweepAt = now;
39
+ operationIdContext.fresh(() => {
40
+ try {
41
+ const reaped = reapGhostRows();
42
+ if (reaped.length > 0) {
43
+ emitEvent({
44
+ level: 'info',
45
+ event: 'canvas.ghost_rows.reaped',
46
+ outcome: 'succeeded',
47
+ fields: { reaped_count: reaped.length, reaped_ids: reaped },
48
+ });
49
+ }
50
+ }
51
+ catch (err) {
52
+ emitEvent({ level: 'error', event: 'canvas.ghost_rows.reap_failed', error: err });
53
+ }
54
+ });
55
+ }
29
56
  /** Milliseconds since meta/telemetry was last written. Inbox activity is
30
57
  * deliberately excluded: children can keep writing a dead manager's inbox. */
31
58
  msSinceDiskActivity(nodeId, now) {
@@ -4,6 +4,8 @@ import { reviewPath } from '../../core/human/convention.js';
4
4
  import { ticketDir } from '../../core/human/root.js';
5
5
  import { submitReview } from '../../core/human/tickets.js';
6
6
  import { reviewsRoot } from '../../core/canvas/paths.js';
7
+ import { getNode } from '../../core/canvas/index.js';
8
+ import { loadProfileManifest } from '../../core/profiles/manifest.js';
7
9
  import { projectReviewResult } from '../../core/review/project.js';
8
10
  import { realizeReview } from '../../core/review/realize.js';
9
11
  import { listBindingReviews, listReviews, markProjected, markProjectionError, } from '../../core/review/store.js';
@@ -23,6 +25,12 @@ function republishTicketReview(review) {
23
25
  const dir = ticketDir(review.bridge_node_id);
24
26
  if (existsSync(reviewPath(dir)))
25
27
  return;
28
+ const origin = getNode(review.origin_node_id);
29
+ if (origin === null)
30
+ throw new Error(`ticket review origin is missing: ${review.origin_node_id}`);
31
+ const profileName = origin.profile_id == null
32
+ ? undefined
33
+ : loadProfileManifest(origin.profile_id).manifest.name;
26
34
  submitReview({
27
35
  dir,
28
36
  review: {
@@ -30,7 +38,10 @@ function republishTicketReview(review) {
30
38
  ...(review.output_path === null ? {} : { output: review.output_path }),
31
39
  title: basename(review.file),
32
40
  subtitle: review.subtitle,
33
- source: { nodeId: review.origin_node_id },
41
+ source: {
42
+ nodeId: review.origin_node_id,
43
+ ...(profileName === undefined ? {} : { profileName }),
44
+ },
34
45
  blockedSince: review.created,
35
46
  },
36
47
  });
@@ -1 +1 @@
1
- :where(:root,:host){color-scheme:light dark;--crtr-color-background: #ffffff;--crtr-color-surface: #f7f7f8;--crtr-color-surface-hover: #eeeeef;--crtr-color-text: #1c1c1f;--crtr-color-text-muted: #68686f;--crtr-color-border: #d9d9de;--crtr-color-accent: #5753c9;--crtr-color-accent-contrast: #ffffff;--crtr-color-danger: #c73535;--crtr-color-focus: #706bea;--crtr-chart-1: #5753c9;--crtr-chart-2: #2c8c75;--crtr-chart-3: #cf7c2a;--crtr-chart-4: #b84c84;--crtr-chart-5: #477eb8;--crtr-font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;--crtr-font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;--crtr-font-size: .875rem;--crtr-line-height: 1.5;--crtr-radius-small: .375rem;--crtr-radius-medium: .625rem;--crtr-space-1: .25rem;--crtr-space-2: .5rem;--crtr-space-3: .75rem;--crtr-space-4: 1rem;--crtr-shadow: 0 1px 2px rgb(0 0 0 / .08)}:where(crtr-options,crtr-text,crtr-table,crtr-cards,crtr-chart,crtr-pages,crtr-page,crtr-slot){color:var(--crtr-color-text);font-family:var(--crtr-font-family);font-size:var(--crtr-font-size);line-height:var(--crtr-line-height)}@media(prefers-color-scheme:dark){:where(:root,:host){--crtr-color-background: #161618;--crtr-color-surface: #222225;--crtr-color-surface-hover: #2d2d31;--crtr-color-text: #f2f2f3;--crtr-color-text-muted: #aaaab1;--crtr-color-border: #414147;--crtr-color-accent: #9692ff;--crtr-color-accent-contrast: #18181b;--crtr-color-danger: #f06a6a;--crtr-color-focus: #aaa7ff}}
1
+ :where(:root,:host){color-scheme:var(--crtr-color-scheme, light dark);--crtr-color-background: #ffffff;--crtr-color-surface: #f7f7f8;--crtr-color-surface-hover: #eeeeef;--crtr-color-text: #1c1c1f;--crtr-color-text-muted: #68686f;--crtr-color-border: #d9d9de;--crtr-color-accent: #5753c9;--crtr-color-accent-contrast: #ffffff;--crtr-color-danger: #c73535;--crtr-color-focus: #706bea;--crtr-chart-1: #5753c9;--crtr-chart-2: #2c8c75;--crtr-chart-3: #cf7c2a;--crtr-chart-4: #b84c84;--crtr-chart-5: #477eb8;--crtr-font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;--crtr-font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;--crtr-font-size: .875rem;--crtr-line-height: 1.5;--crtr-radius-small: .375rem;--crtr-radius-medium: .625rem;--crtr-space-1: .25rem;--crtr-space-2: .5rem;--crtr-space-3: .75rem;--crtr-space-4: 1rem;--crtr-shadow: 0 1px 2px rgb(0 0 0 / .08)}:where(crtr-options){color:var(--crtr-options-color, var(--crtr-color-text));font-family:var(--crtr-options-font-family, var(--crtr-font-family));font-size:var(--crtr-options-font-size, var(--crtr-font-size));line-height:var(--crtr-options-line-height, var(--crtr-line-height))}:where(crtr-text){color:var(--crtr-text-color, var(--crtr-color-text));font-family:var(--crtr-text-font-family, var(--crtr-font-family));font-size:var(--crtr-text-font-size, var(--crtr-font-size));line-height:var(--crtr-text-line-height, var(--crtr-line-height))}:where(crtr-table){color:var(--crtr-table-color, var(--crtr-color-text));font-family:var(--crtr-table-font-family, var(--crtr-font-family));font-size:var(--crtr-table-font-size, var(--crtr-font-size));line-height:var(--crtr-table-line-height, var(--crtr-line-height))}:where(crtr-cards){color:var(--crtr-cards-color, var(--crtr-color-text));font-family:var(--crtr-cards-font-family, var(--crtr-font-family));font-size:var(--crtr-cards-font-size, var(--crtr-font-size));line-height:var(--crtr-cards-line-height, var(--crtr-line-height))}:where(crtr-chart){color:var(--crtr-chart-color, var(--crtr-color-text));font-family:var(--crtr-chart-font-family, var(--crtr-font-family));font-size:var(--crtr-chart-font-size, var(--crtr-font-size));line-height:var(--crtr-chart-line-height, var(--crtr-line-height))}:where(crtr-pages,crtr-page){color:var(--crtr-page-color, var(--crtr-color-text));font-family:var(--crtr-page-font-family, var(--crtr-font-family));font-size:var(--crtr-page-font-size, var(--crtr-font-size));line-height:var(--crtr-page-line-height, var(--crtr-line-height))}:where(crtr-slot){color:var(--crtr-slot-color, var(--crtr-color-text));font-family:var(--crtr-slot-font-family, var(--crtr-font-family));font-size:var(--crtr-slot-font-size, var(--crtr-font-size));line-height:var(--crtr-slot-line-height, var(--crtr-line-height))}@media(prefers-color-scheme:dark){:where(:root,:host){--crtr-color-background: #161618;--crtr-color-surface: #222225;--crtr-color-surface-hover: #2d2d31;--crtr-color-text: #f2f2f3;--crtr-color-text-muted: #aaaab1;--crtr-color-border: #414147;--crtr-color-accent: #9692ff;--crtr-color-accent-contrast: #18181b;--crtr-color-danger: #f06a6a;--crtr-color-focus: #aaa7ff;--crtr-shadow: 0 1px 2px rgb(0 0 0 / .45);--crtr-chart-1: #9692ff;--crtr-chart-2: #4fc3a4;--crtr-chart-3: #eba05a;--crtr-chart-4: #e281b4;--crtr-chart-5: #71b0e6}}