@north-light/crouter 0.3.196 → 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 (148) 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 +90 -168
  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/termrender/display.d.ts +7 -3
  91. package/dist/core/termrender/display.js +45 -9
  92. package/dist/core/termrender/termrender.d.ts +3 -12
  93. package/dist/core/termrender/termrender.js +6 -37
  94. package/dist/core/user-settings.d.ts +17 -2
  95. package/dist/core/user-settings.js +32 -11
  96. package/dist/daemon/api/handlers/human.js +1 -1
  97. package/dist/daemon/api/handlers/inbox.js +22 -7
  98. package/dist/daemon/cron-run.js +16 -2
  99. package/dist/daemon/crtrd.js +1 -1
  100. package/dist/daemon/fleet.d.ts +22 -10
  101. package/dist/daemon/fleet.js +43 -24
  102. package/dist/daemon/human/finish.d.ts +2 -1
  103. package/dist/daemon/human/finish.js +45 -28
  104. package/dist/daemon/human/sweep.js +4 -2
  105. package/dist/daemon/reconcilers/broker-supervision.js +11 -8
  106. package/dist/daemon/reconcilers/dormant-inbox.js +11 -6
  107. package/dist/daemon/reconcilers/live-obligation.d.ts +21 -1
  108. package/dist/daemon/reconcilers/live-obligation.js +30 -2
  109. package/dist/daemon/reconcilers/storage-maintenance.d.ts +5 -0
  110. package/dist/daemon/reconcilers/storage-maintenance.js +28 -1
  111. package/dist/daemon/review/sweep.js +12 -1
  112. package/dist/pages/bundle.css +1 -1
  113. package/dist/pages/bundle.js +1008 -577
  114. package/dist/pages/comments.d.ts +29 -0
  115. package/dist/pages/comments.js +51 -0
  116. package/dist/pages/controls.d.ts +98 -0
  117. package/dist/pages/controls.js +261 -0
  118. package/dist/pages/elements/cards.d.ts +15 -4
  119. package/dist/pages/elements/cards.js +299 -186
  120. package/dist/pages/elements/chart.d.ts +22 -0
  121. package/dist/pages/elements/chart.js +107 -91
  122. package/dist/pages/elements/options.d.ts +9 -0
  123. package/dist/pages/elements/options.js +268 -155
  124. package/dist/pages/elements/pages.d.ts +6 -0
  125. package/dist/pages/elements/pages.js +60 -38
  126. package/dist/pages/elements/slot.d.ts +5 -0
  127. package/dist/pages/elements/slot.js +17 -10
  128. package/dist/pages/elements/table.d.ts +17 -0
  129. package/dist/pages/elements/table.js +336 -228
  130. package/dist/pages/elements/text.d.ts +9 -3
  131. package/dist/pages/elements/text.js +350 -143
  132. package/dist/pages/host.d.ts +0 -24
  133. package/dist/pages/host.js +0 -17
  134. package/dist/pages/readonly.d.ts +24 -0
  135. package/dist/pages/readonly.js +31 -0
  136. package/dist/pages/responses.d.ts +37 -0
  137. package/dist/pages/responses.js +56 -0
  138. package/dist/pi-extensions/canvas-bash-valve.d.ts +9 -1
  139. package/dist/pi-extensions/canvas-bash-valve.js +12 -8
  140. package/dist/types.d.ts +19 -5
  141. package/dist/types.js +2 -1
  142. package/package.json +1 -1
  143. package/runtime.lock.json +2 -2
  144. package/scripts/install-runtime.mjs +19 -24
  145. package/dist/clients/attach/render/html-markdown.d.ts +0 -13
  146. package/dist/clients/attach/render/html-markdown.js +0 -206
  147. package/dist/core/human/markdown-html.d.ts +0 -8
  148. package/dist/core/human/markdown-html.js +0 -581
@@ -7,10 +7,12 @@ import { basename, resolve, join } from 'node:path';
7
7
  import { ApiError } from '../../../api/index.js';
8
8
  import { assetRoot } from '../../../core/asset-root.js';
9
9
  import { pageManifestPath, reviewPath } from '../../../core/human/convention.js';
10
+ import { parsePage } from '../../../core/human/page.js';
10
11
  import { renderPageHtml } from '../../../core/human/page-render.js';
11
12
  import { ticketsRoot } from '../../../core/human/root.js';
12
13
  import { scanInbox, scanPageHistory } from '../../../core/human/scan.js';
13
14
  import { readPageTicket, readTicketResult, validatePageTicketResponses, writePageProgress, } from '../../../core/human/tickets.js';
15
+ import { describePageAnswer } from '../../../core/human/answer.js';
14
16
  import { readConfig } from '../../../core/config.js';
15
17
  import { cancelHumanTicket, resolvePageTicket } from '../../human/finish.js';
16
18
  import { filterTerminalReviewTickets } from '../../../core/review/ticket-filter.js';
@@ -80,6 +82,8 @@ function pageSlotToDTO(slot) {
80
82
  dto.id = slot.id;
81
83
  if (slot.unvalidated === true)
82
84
  dto.unvalidated = true;
85
+ if (slot.display === true)
86
+ dto.display = true;
83
87
  return dto;
84
88
  }
85
89
  function pageManifestToDTO(manifest) {
@@ -102,6 +106,8 @@ function pageManifestToDTO(manifest) {
102
106
  source.askedBy = manifest.source.askedBy;
103
107
  if (manifest.source.blockedSince !== undefined)
104
108
  source.blockedSince = manifest.source.blockedSince;
109
+ if (manifest.source.profileName !== undefined)
110
+ source.profileName = manifest.source.profileName;
105
111
  if (manifest.source.nodeId !== undefined)
106
112
  source.nodeId = manifest.source.nodeId;
107
113
  if (Object.keys(source).length > 0)
@@ -163,7 +169,7 @@ function isSlotValidationError(err) {
163
169
  // GET /v1/human/inbox — enumerate
164
170
  // ===========================================================================
165
171
  function handleList() {
166
- const productKinds = readConfig('user').page_components ?? [];
172
+ const productKinds = readConfig('user').page_components;
167
173
  const allItems = scanInbox(productKinds);
168
174
  const filtered = filterTerminalReviewTickets(allItems);
169
175
  const tickets = filtered.map((item) => {
@@ -210,7 +216,7 @@ function handleHistory(ctx) {
210
216
  if (!nodeId || typeof nodeId !== 'string') {
211
217
  throw new ApiError(400, 'invalid_request', 'history requires a node_id query param');
212
218
  }
213
- const productKinds = readConfig('user').page_components ?? [];
219
+ const productKinds = readConfig('user').page_components;
214
220
  const history = scanPageHistory(nodeId, productKinds);
215
221
  const canonicalRoot = realpathSync(ticketsRoot());
216
222
  const tickets = history.map((item) => {
@@ -230,6 +236,8 @@ function handleHistory(ctx) {
230
236
  };
231
237
  if (item.subtitle !== '')
232
238
  dto.subtitle = item.subtitle;
239
+ if (item.answerDigest !== undefined)
240
+ dto.answer_digest = item.answerDigest;
233
241
  return dto;
234
242
  });
235
243
  return { status: 200, body: { tickets } };
@@ -245,7 +253,7 @@ function handleGetPage(ctx) {
245
253
  if (resolved.kind === 'review') {
246
254
  throw new ApiError(409, 'ticket_kind_unsupported', 'review tickets have no page read operation');
247
255
  }
248
- const productKinds = readConfig('user').page_components ?? [];
256
+ const productKinds = readConfig('user').page_components;
249
257
  let ticket;
250
258
  try {
251
259
  ticket = readPageTicket(resolved.dir, productKinds);
@@ -287,7 +295,7 @@ async function handleRespond(ctx) {
287
295
  if (result?.kind === 'canceled') {
288
296
  throw new ApiError(409, 'ticket_canceled', 'canceled tickets cannot be answered');
289
297
  }
290
- const productKinds = readConfig('user').page_components ?? [];
298
+ const productKinds = readConfig('user').page_components;
291
299
  try {
292
300
  validatePageTicketResponses(resolved.dir, request.responses, productKinds);
293
301
  }
@@ -330,7 +338,7 @@ function handleProgress(ctx) {
330
338
  if (result !== null && result.kind !== 'canceled') {
331
339
  throw new ApiError(409, 'ticket_already_resolved', 'ticket was already resolved');
332
340
  }
333
- const productKinds = readConfig('user').page_components ?? [];
341
+ const productKinds = readConfig('user').page_components;
334
342
  try {
335
343
  writePageProgress(resolved.dir, request.responses, productKinds);
336
344
  return { status: 200, body: {} };
@@ -390,7 +398,14 @@ function handleGetResponse(ctx) {
390
398
  if (result.kind !== 'page') {
391
399
  throw new ApiError(409, 'ticket_kind_mismatch', 'unexpected result kind');
392
400
  }
393
- return { status: 200, body: result };
401
+ const productKinds = readConfig('user').page_components;
402
+ try {
403
+ const manifest = parsePage(resolved.dir, productKinds);
404
+ return { status: 200, body: { ...result, answer: describePageAnswer(manifest, result.responses, { completedAt: result.completedAt, productKinds }) } };
405
+ }
406
+ catch {
407
+ throw new ApiError(500, 'internal', 'the ticket answer could not be read');
408
+ }
394
409
  }
395
410
  // ===========================================================================
396
411
  // GET /v1/human/inbox/:ticket_id/render — the page's HTML payload
@@ -403,7 +418,7 @@ function handleRenderPage(ctx) {
403
418
  if (resolved.kind === 'review') {
404
419
  throw new ApiError(409, 'ticket_kind_unsupported', 'review tickets have no page render operation');
405
420
  }
406
- const productKinds = readConfig('user').page_components ?? [];
421
+ const productKinds = readConfig('user').page_components;
407
422
  // Addressable for pending, resolved, and canceled tickets alike — the same
408
423
  // read rule as the detail route: a page stays readable after it resolves.
409
424
  let ticket;
@@ -163,6 +163,16 @@ function resolveLiveProfile(c) {
163
163
  return null;
164
164
  }
165
165
  }
166
+ function resolveLiveProfileName(c) {
167
+ if (c.profile == null)
168
+ return undefined;
169
+ try {
170
+ return loadProfileManifest(c.profile).manifest.name;
171
+ }
172
+ catch {
173
+ return undefined;
174
+ }
175
+ }
166
176
  // ---------------------------------------------------------------------------
167
177
  // Delivery sinks
168
178
  // ---------------------------------------------------------------------------
@@ -223,10 +233,14 @@ function deliverHumanSink(c, stdout) {
223
233
  // half-built `nfy-*` directory behind.
224
234
  let prepared;
225
235
  try {
236
+ const profileName = resolveLiveProfileName(c);
226
237
  prepared = preparePage({
227
238
  sourceFile: synthesized.path,
228
- dialect: 'md',
229
- source: { sessionName: `cron ${c.name} (${c.cron_id})` },
239
+ dialect: 'html',
240
+ source: {
241
+ sessionName: `cron ${c.name} (${c.cron_id})`,
242
+ ...(profileName === undefined ? {} : { profileName }),
243
+ },
230
244
  productKinds: readConfig('user').page_components,
231
245
  });
232
246
  }
@@ -8,7 +8,7 @@
8
8
  // The fleet registry's entry IS the node's liveness — no pid probe, no grace
9
9
  // clock. A broker's real `exit` event enqueues an exit-policy job on the
10
10
  // daemon's serial work lane (never inline on the Node event callback), where
11
- // classifyDeadNode — one pure 9-row policy table over durable row state —
11
+ // classifyDeadNode — one pure 8-row policy table over durable row state —
12
12
  // decides forget / dormant / respawn / boot-failure. The startup recovery
13
13
  // sweep classifies boot-time dead rows through the SAME table, so exit
14
14
  // recovery and startup recovery can never diverge. Every broker's argv is
@@ -10,12 +10,16 @@ import type { HostHandle } from '../core/runtime/host.js';
10
10
  * seeing a silent `dead`. */
11
11
  export declare function surfaceBootFailure(meta: NodeMeta): Promise<void>;
12
12
  /** Whether a dead relaunch should resume the saved session strictly, or retry
13
- * as a fresh cycle instead. A cycling attempt (cycle_pending) that dies
14
- * before session_start must retry AS a cycle — a strict reopen only restores
15
- * the transcript at an idle prompt, it cannot restart the dead engine turn.
16
- * A dirty interrupted BUSY turn also requests a cycle; a broker-certified
17
- * clean abort is handled explicitly by classifyDeadNode before this fallback. */
18
- export declare function retryResumeMode(meta: Pick<NodeMeta, 'cycle_pending'> | null, interruptedTurn?: boolean): boolean;
13
+ * as a fresh cycle instead. A cycling attempt (cycle_pending) that dies before
14
+ * session_start must retry AS a cycle: the fresh branch it was opening is not
15
+ * confirmed, so resuming would silently continue the old leaf the refresh was
16
+ * meant to leave behind.
17
+ *
18
+ * An interrupted turn is NOT a reason to cycle. A strict reopen does restore
19
+ * the transcript at an idle prompt without restarting the dead turn — but the
20
+ * fix for that is the continuation prompt recovery delivers (see `#respawn`),
21
+ * not throwing the conversation away. */
22
+ export declare function retryResumeMode(meta: Pick<NodeMeta, 'cycle_pending'> | null): boolean;
19
23
  export type DeadNodeAction = 'forget' | 'respawn-fresh' | 'dormant' | 'respawn-cycle' | 'respawn-resume' | 'boot-failure';
20
24
  /** The narrow durable state the policy table reads. Structural (a Pick, not
21
25
  * NodeMeta itself) so tests fabricate table rows directly. */
@@ -23,10 +27,18 @@ export type DeadNodeRow = Pick<NodeMeta, 'status' | 'intent' | 'pi_session_id' |
23
27
  /** One pure function producing the action for a node known-dead. Used at
24
28
  * exactly two call sites — an exit-policy job and the startup recovery sweep
25
29
  * (design D-12) — so exit recovery and startup recovery can never diverge.
26
- * Evaluated in table order; `busy` is the on-disk busy marker. A clean-abort
27
- * certificate is written by the broker only after Pi persisted its aborted
28
- * entry; without it, a set busy marker means a dirty mid-turn crash. */
29
- export declare function classifyDeadNode(row: DeadNodeRow | null, busy: boolean, cleanAbort?: boolean): DeadNodeAction;
30
+ * Evaluated in table order; `busy` is the on-disk busy marker.
31
+ *
32
+ * `busy` no longer selects the ACTION. Whether the engine died mid-turn or
33
+ * parked at an idle prompt, the saved transcript is equally resumable — pi
34
+ * persists whole entries (`appendFileSync` per line) so a killed generation
35
+ * simply leaves the file ending at its last complete entry, and the loader
36
+ * skips a malformed line rather than rejecting the session. A mid-turn death
37
+ * needs the turn re-driven, which is a continuation prompt on top of the
38
+ * resume (see `#respawn`), not a fresh branch. `busy` therefore survives here
39
+ * only to route a node that died before session_start ever recorded a session
40
+ * to a fresh relaunch instead of terminalizing it as a boot failure. */
41
+ export declare function classifyDeadNode(row: DeadNodeRow | null, busy: boolean): DeadNodeAction;
30
42
  /** An exit with uptime at or above this resets the consecutive-failure
31
43
  * counter before classification; a shorter-lived exit increments it. */
32
44
  export declare const HEALTHY_UPTIME_MS = 60000;
@@ -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;