@north-light/crouter 0.3.235 → 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 (181) 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 +14 -5
  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 +399 -399
  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/__tests__/node-new.test.js +58 -0
  20. package/dist/commands/memory/lint.js +22 -13
  21. package/dist/commands/memory/shared.js +1 -1
  22. package/dist/commands/node/create.d.ts +1 -1
  23. package/dist/commands/node/create.js +4 -1
  24. package/dist/commands/node/lifecycle.js +2 -2
  25. package/dist/commands/node-worktree.js +39 -8
  26. package/dist/commands/revive.js +11 -2
  27. package/dist/commands/sys/context/admin/actions.d.ts +48 -0
  28. package/dist/commands/sys/context/admin/actions.js +246 -0
  29. package/dist/commands/sys/context/admin/axes-panel.d.ts +21 -0
  30. package/dist/commands/sys/context/admin/axes-panel.js +117 -0
  31. package/dist/commands/sys/context/admin/detail-panel.d.ts +53 -0
  32. package/dist/commands/sys/context/admin/detail-panel.js +178 -0
  33. package/dist/commands/sys/context/admin/docs-panel.d.ts +45 -0
  34. package/dist/commands/sys/context/admin/docs-panel.js +186 -0
  35. package/dist/commands/sys/context/admin/model.d.ts +75 -0
  36. package/dist/commands/sys/context/admin/model.js +209 -0
  37. package/dist/commands/sys/context/admin/shell.d.ts +82 -0
  38. package/dist/commands/sys/context/admin/shell.js +669 -0
  39. package/dist/commands/sys/context/admin.d.ts +1 -0
  40. package/dist/commands/sys/context/admin.js +124 -0
  41. package/dist/commands/sys/context/doc.d.ts +1 -0
  42. package/dist/commands/sys/context/doc.js +196 -0
  43. package/dist/commands/sys/context/prompt-review.d.ts +1 -0
  44. package/dist/commands/sys/{prompt-review.js → context/prompt-review.js} +10 -10
  45. package/dist/commands/sys/context/resolve.d.ts +55 -0
  46. package/dist/commands/sys/context/resolve.js +428 -0
  47. package/dist/commands/sys/context/sysprompt.d.ts +1 -0
  48. package/dist/commands/sys/{sysprompt.js → context/sysprompt.js} +11 -11
  49. package/dist/commands/sys/context.d.ts +2 -0
  50. package/dist/commands/sys/context.js +20 -0
  51. package/dist/commands/sys.js +3 -4
  52. package/dist/core/__tests__/canvas-inbox-watcher-naming.test.d.ts +1 -0
  53. package/dist/core/__tests__/canvas-inbox-watcher-naming.test.js +258 -0
  54. package/dist/core/__tests__/child-death-wake.test.js +38 -22
  55. package/dist/core/__tests__/cron-self-cancel-delivery.test.d.ts +1 -0
  56. package/dist/core/__tests__/cron-self-cancel-delivery.test.js +72 -0
  57. package/dist/core/__tests__/daemon-boot.test.js +40 -28
  58. package/dist/core/__tests__/helpers/harness.js +1 -0
  59. package/dist/core/__tests__/human-deliver.test.js +1 -1
  60. package/dist/core/__tests__/human-node-not-supervised.test.js +10 -15
  61. package/dist/core/__tests__/integration/revive.test.js +12 -12
  62. package/dist/core/__tests__/integration/spawn-root.test.js +6 -0
  63. package/dist/core/__tests__/integration/worktree-reap.test.js +32 -0
  64. package/dist/core/__tests__/lifecycle-terminal-reasons.test.d.ts +1 -0
  65. package/dist/core/__tests__/lifecycle-terminal-reasons.test.js +61 -0
  66. package/dist/core/__tests__/migration.test.js +38 -1
  67. package/dist/core/__tests__/push-final-guard.test.js +1 -1
  68. package/dist/core/__tests__/revive-capacity.test.d.ts +1 -0
  69. package/dist/core/__tests__/revive-capacity.test.js +127 -0
  70. package/dist/core/__tests__/revive-parked-fresh.test.js +7 -7
  71. package/dist/core/__tests__/seam/broker-cap-freeze.test.d.ts +1 -0
  72. package/dist/core/__tests__/seam/broker-cap-freeze.test.js +81 -0
  73. package/dist/core/__tests__/seam/dormancy-release.test.js +76 -7
  74. package/dist/core/__tests__/seam/yield-refresh-transaction.test.js +1 -1
  75. package/dist/core/canvas/browse/render.js +11 -3
  76. package/dist/core/canvas/canvas.d.ts +15 -3
  77. package/dist/core/canvas/canvas.js +52 -16
  78. package/dist/core/canvas/migrations.js +21 -0
  79. package/dist/core/canvas/nav-render.js +3 -1
  80. package/dist/core/canvas/remote-canvas-source.js +2 -0
  81. package/dist/core/canvas/render-source.d.ts +3 -1
  82. package/dist/core/canvas/render-source.js +7 -3
  83. package/dist/core/canvas/status-glyph.d.ts +10 -1
  84. package/dist/core/canvas/status-glyph.js +19 -3
  85. package/dist/core/canvas/types.d.ts +22 -3
  86. package/dist/core/feed/feed.js +1 -1
  87. package/dist/core/human/feedback-companion.js +5 -2
  88. package/dist/core/memory/lint.d.ts +19 -0
  89. package/dist/core/memory/lint.js +97 -0
  90. package/dist/core/memory-resolver.d.ts +4 -0
  91. package/dist/core/memory-resolver.js +7 -7
  92. package/dist/core/preview-registry.js +4 -4
  93. package/dist/core/review/realize.js +4 -2
  94. package/dist/core/runtime/close.js +1 -1
  95. package/dist/core/runtime/fleet.d.ts +29 -1
  96. package/dist/core/runtime/fleet.js +23 -0
  97. package/dist/core/runtime/host.js +32 -11
  98. package/dist/core/runtime/launch-prompt.d.ts +1 -1
  99. package/dist/core/runtime/launch-prompt.js +2 -2
  100. package/dist/core/runtime/launch-target.js +1 -1
  101. package/dist/core/runtime/lifecycle.d.ts +4 -2
  102. package/dist/core/runtime/lifecycle.js +12 -1
  103. package/dist/core/runtime/memory.js +1 -1
  104. package/dist/core/runtime/reopen.js +10 -1
  105. package/dist/core/runtime/reset.js +1 -1
  106. package/dist/core/runtime/revive-all.d.ts +3 -0
  107. package/dist/core/runtime/revive-all.js +10 -4
  108. package/dist/core/runtime/revive.d.ts +12 -0
  109. package/dist/core/runtime/revive.js +62 -3
  110. package/dist/core/runtime/spawn.d.ts +9 -13
  111. package/dist/core/runtime/spawn.js +40 -17
  112. package/dist/core/substrate/frontmatter-validation.js +1 -1
  113. package/dist/core/substrate/index.d.ts +6 -3
  114. package/dist/core/substrate/index.js +4 -3
  115. package/dist/core/substrate/on-read.d.ts +0 -8
  116. package/dist/core/substrate/on-read.js +45 -124
  117. package/dist/core/substrate/plan.d.ts +94 -0
  118. package/dist/core/substrate/plan.js +266 -0
  119. package/dist/core/substrate/render.d.ts +5 -6
  120. package/dist/core/substrate/render.js +76 -129
  121. package/dist/core/substrate/schema.d.ts +1 -6
  122. package/dist/core/substrate/schema.js +1 -15
  123. package/dist/core/substrate/session-cache.d.ts +2 -10
  124. package/dist/core/substrate/session-cache.js +9 -47
  125. package/dist/core/substrate/subject-fields.d.ts +1 -4
  126. package/dist/core/substrate/subject-fields.js +2 -5
  127. package/dist/core/substrate/surface-match.d.ts +37 -17
  128. package/dist/core/substrate/surface-match.js +44 -53
  129. package/dist/core/tui/page-host.d.ts +6 -0
  130. package/dist/core/tui/page-host.js +12 -3
  131. package/dist/core/worktree.d.ts +8 -0
  132. package/dist/core/worktree.js +65 -0
  133. package/dist/daemon/__tests__/reconciler-signature.test.js +3 -2
  134. package/dist/daemon/__tests__/thaw-order.test.d.ts +1 -0
  135. package/dist/daemon/__tests__/thaw-order.test.js +55 -0
  136. package/dist/daemon/api/__tests__/canvas-snapshot-fields.test.js +6 -0
  137. package/dist/daemon/api/__tests__/node-create-description.test.js +6 -0
  138. package/dist/daemon/api/__tests__/profile-launch-gates.test.js +1 -1
  139. package/dist/daemon/api/bridge.js +5 -1
  140. package/dist/daemon/api/handlers/attach.js +5 -1
  141. package/dist/daemon/api/handlers/bash-jobs.js +1 -1
  142. package/dist/daemon/api/handlers/messages.js +10 -2
  143. package/dist/daemon/api/handlers/nodes.js +8 -4
  144. package/dist/daemon/api/handlers/worktree.js +10 -1
  145. package/dist/daemon/api/map.js +7 -0
  146. package/dist/daemon/companion-retire.js +1 -1
  147. package/dist/daemon/cron/passes.d.ts +0 -5
  148. package/dist/daemon/cron/passes.js +0 -20
  149. package/dist/daemon/cron/sinks.js +1 -1
  150. package/dist/daemon/cron-run.d.ts +5 -6
  151. package/dist/daemon/cron-run.js +7 -16
  152. package/dist/daemon/crtrd.d.ts +0 -10
  153. package/dist/daemon/crtrd.js +23 -61
  154. package/dist/daemon/fleet.d.ts +10 -7
  155. package/dist/daemon/fleet.js +71 -12
  156. package/dist/daemon/messaging/node-message.js +3 -1
  157. package/dist/daemon/profile-delete.js +1 -1
  158. package/dist/daemon/reconcilers/broker-supervision.d.ts +26 -9
  159. package/dist/daemon/reconcilers/broker-supervision.js +39 -52
  160. package/dist/daemon/reconcilers/controller-death.d.ts +7 -0
  161. package/dist/daemon/reconcilers/controller-death.js +26 -0
  162. package/dist/daemon/reconcilers/cron-lane.d.ts +0 -3
  163. package/dist/daemon/reconcilers/cron-lane.js +0 -3
  164. package/dist/daemon/reconcilers/live-obligation.js +3 -1
  165. package/dist/daemon/reconcilers/node-lifecycle/freeze-lane.d.ts +53 -0
  166. package/dist/daemon/reconcilers/node-lifecycle/freeze-lane.js +170 -0
  167. package/dist/daemon/reconcilers/node-lifecycle/respawn-policy.d.ts +5 -0
  168. package/dist/daemon/reconcilers/node-lifecycle/respawn-policy.js +16 -0
  169. package/dist/daemon/reconcilers/node-lifecycle/terminating.d.ts +4 -0
  170. package/dist/daemon/reconcilers/node-lifecycle/terminating.js +13 -0
  171. package/dist/daemon/reconcilers/node-lifecycle/tick.d.ts +26 -0
  172. package/dist/daemon/reconcilers/node-lifecycle/tick.js +140 -0
  173. package/dist/pi-extensions/canvas-inbox-watcher.js +12 -0
  174. package/package.json +1 -1
  175. package/runtime.lock.json +2 -2
  176. package/dist/commands/sys/prompt-review.d.ts +0 -1
  177. package/dist/commands/sys/sysprompt.d.ts +0 -1
  178. package/dist/core/__tests__/cron-broker-capacity.test.js +0 -119
  179. package/dist/daemon/reconcilers/dormant-inbox.d.ts +0 -18
  180. package/dist/daemon/reconcilers/dormant-inbox.js +0 -157
  181. /package/dist/{core/__tests__/cron-broker-capacity.test.d.ts → commands/__tests__/node-new.test.d.ts} +0 -0
@@ -22,7 +22,7 @@ import assert from 'node:assert/strict';
22
22
  import { existsSync, readFileSync, writeFileSync } from 'node:fs';
23
23
  import { join } from 'node:path';
24
24
  import { appendInbox, readCursor, readInboxSince } from '../../feed/inbox.js';
25
- import { registerWarmSpare, subscribe, updateNode } from '../../canvas/index.js';
25
+ import { registerWarmSpare, subscribe, subscriptionsOf, updateNode } from '../../canvas/index.js';
26
26
  import { isPidAlive } from '../../canvas/pid.js';
27
27
  import { reviveNode } from '../../runtime/revive.js';
28
28
  import { createHarness } from '../helpers/harness.js';
@@ -78,7 +78,7 @@ test('idle-release settles its real watcher before exit; daemon revives exactly
78
78
  },
79
79
  },
80
80
  });
81
- reviveNode(manager, { resume: true });
81
+ reviveNode(manager, { resume: true, capacity: 'freeze' });
82
82
  await h.awaitBoot(manager);
83
83
  // A live managed child makes this unfocused terminal manager legitimately
84
84
  // awaiting, so its real stophook takes the idle-release branch.
@@ -171,7 +171,7 @@ test('an unattended resident with no live obligation completes on the daemon clo
171
171
  },
172
172
  });
173
173
  subscribe(root, nodeId, true);
174
- reviveNode(nodeId, { resume: true });
174
+ reviveNode(nodeId, { resume: true, capacity: 'freeze' });
175
175
  const boot = await h.awaitBoot(nodeId);
176
176
  assert.equal(h.fleet.has(nodeId), true, 'the revived broker is fleet-owned');
177
177
  const seenAt = 10_000;
@@ -223,7 +223,7 @@ test('a non-attending observer does not prevent unattended resident parking', {
223
223
  intent: 'idle-release',
224
224
  });
225
225
  updateNode(nodeId, { launch: { extensions: [], tools: [], systemPrompt: '', env: {} } });
226
- reviveNode(nodeId, { resume: true });
226
+ reviveNode(nodeId, { resume: true, capacity: 'freeze' });
227
227
  const boot = await h.awaitBoot(nodeId);
228
228
  await attach.attach(nodeId, 'observer', 'system-observer-test', { attends: false });
229
229
  assert.equal(JSON.parse(readFileSync(join(h.home, 'nodes', nodeId, 'job', 'attach.json'), 'utf8')).viewers, 0, 'a non-attending hello is not persisted as human attendance');
@@ -268,7 +268,7 @@ test('a connected broker viewer keeps an obligation-free resident live', { timeo
268
268
  env: {},
269
269
  },
270
270
  });
271
- reviveNode(nodeId, { resume: true });
271
+ reviveNode(nodeId, { resume: true, capacity: 'freeze' });
272
272
  const boot = await h.awaitBoot(nodeId);
273
273
  // awaitBoot returns on the boot proof, which is written BEFORE the broker's
274
274
  // own `persistAttachState()` lays down the truthful zero state. Writing our
@@ -311,7 +311,7 @@ test('the unattended park clock cancels an obligation-free terminal node and spa
311
311
  try {
312
312
  const boot = async (id) => {
313
313
  updateNode(id, { launch: { extensions: [], tools: [], systemPrompt: '', env: {} } });
314
- reviveNode(id, { resume: true });
314
+ reviveNode(id, { resume: true, capacity: 'freeze' });
315
315
  return (await h.awaitBoot(id)).pid;
316
316
  };
317
317
  const settled = h.fabricateBrokerNode({ id: 'terminal-no-work', parent: root, status: 'idle', intent: 'idle-release' });
@@ -349,6 +349,75 @@ test('the unattended park clock cancels an obligation-free terminal node and spa
349
349
  process.env['CRTR_TEST_UNATTENDED_PARK_MS'] = priorParkMs;
350
350
  }
351
351
  });
352
+ test('a resident managed child parks without finalizing while a terminal child is settled by the same grace sweep', { timeout: 40_000 }, async () => {
353
+ const priorParkMs = process.env['CRTR_TEST_UNATTENDED_PARK_MS'];
354
+ const priorGraceMs = process.env['CRTR_TEST_PARK_SUMMARY_GRACE_MS'];
355
+ process.env['CRTR_TEST_UNATTENDED_PARK_MS'] = '1500';
356
+ process.env['CRTR_TEST_PARK_SUMMARY_GRACE_MS'] = '1000';
357
+ try {
358
+ const resident = h.fabricateBrokerNode({
359
+ id: 'resident-managed-child',
360
+ parent: root,
361
+ lifecycle: 'resident',
362
+ status: 'idle',
363
+ intent: 'idle-release',
364
+ pi_session_id: 'resident-managed-session',
365
+ });
366
+ subscribe(root, resident, true);
367
+ // Keep a managed terminal child in the same sweep, but make its parent
368
+ // historical so no live subscriber remains to hold an unfinished report.
369
+ const terminalManager = h.fabricateBrokerNode({ id: 'terminal-manager', status: 'done' });
370
+ const terminal = h.fabricateBrokerNode({
371
+ id: 'terminal-managed-child',
372
+ parent: terminalManager,
373
+ lifecycle: 'terminal',
374
+ status: 'idle',
375
+ intent: 'idle-release',
376
+ pi_session_id: 'terminal-managed-session',
377
+ });
378
+ subscribe(terminalManager, terminal, true);
379
+ const boot = async (id) => {
380
+ updateNode(id, { launch: { extensions: [], tools: [], systemPrompt: '', env: {} } });
381
+ reviveNode(id, { resume: true, capacity: 'freeze' });
382
+ return (await h.awaitBoot(id)).pid;
383
+ };
384
+ const residentPid = await boot(resident);
385
+ const terminalPid = await boot(terminal);
386
+ const seenAt = 50_000;
387
+ await h.tick(seenAt);
388
+ // Clock expiry arms the resident's pending park and requests its summary
389
+ // turn; the terminal child is cancelled outright by the same pass.
390
+ await h.tick(seenAt + 1500);
391
+ // Summary grace expiry: the degraded enactment parks without a report.
392
+ await h.tick(seenAt + 1500 + 1000);
393
+ await h.waitFor(() => h.node(resident)?.status === 'done' && h.node(resident)?.intent === 'parked' && !isPidAlive(residentPid), {
394
+ timeoutMs: 10_000,
395
+ intervalMs: 20,
396
+ label: 'resident managed child parks and releases its broker',
397
+ });
398
+ await h.waitFor(() => h.node(terminal)?.status === 'canceled' && !isPidAlive(terminalPid), {
399
+ timeoutMs: 10_000,
400
+ intervalMs: 20,
401
+ label: 'terminal managed child settles and releases its broker',
402
+ });
403
+ await h.awaitFleetExit(resident);
404
+ await h.awaitFleetExit(terminal);
405
+ assert.equal(h.node(resident)?.intent, 'parked', 'resident child carries the durable park marker, not a final');
406
+ assert.equal(h.node(resident)?.final_report ?? null, null, 'resident child has no forced final report');
407
+ assert.ok(subscriptionsOf(root).some((edge) => edge.node_id === resident && edge.active), 'resident child keeps its active parent subscription');
408
+ assert.equal(h.node(terminal)?.status, 'canceled', 'terminal child is settled by unattended grace without a final');
409
+ }
410
+ finally {
411
+ if (priorParkMs === undefined)
412
+ delete process.env['CRTR_TEST_UNATTENDED_PARK_MS'];
413
+ else
414
+ process.env['CRTR_TEST_UNATTENDED_PARK_MS'] = priorParkMs;
415
+ if (priorGraceMs === undefined)
416
+ delete process.env['CRTR_TEST_PARK_SUMMARY_GRACE_MS'];
417
+ else
418
+ process.env['CRTR_TEST_PARK_SUMMARY_GRACE_MS'] = priorGraceMs;
419
+ }
420
+ });
352
421
  test('an unattended resident awaiting a live child remains wakeable', { timeout: 30_000 }, async () => {
353
422
  const priorParkMs = process.env['CRTR_TEST_UNATTENDED_PARK_MS'];
354
423
  process.env['CRTR_TEST_UNATTENDED_PARK_MS'] = '1500';
@@ -370,7 +439,7 @@ test('an unattended resident awaiting a live child remains wakeable', { timeout:
370
439
  env: {},
371
440
  },
372
441
  });
373
- reviveNode(nodeId, { resume: true });
442
+ reviveNode(nodeId, { resume: true, capacity: 'freeze' });
374
443
  const boot = await h.awaitBoot(nodeId);
375
444
  const seenAt = 20_000;
376
445
  await h.tick(seenAt);
@@ -65,7 +65,7 @@ test('yield-message survives one pre-session_start retry and clears on session_s
65
65
  // the retry still roots a fresh cycle carrying the note, never reopening the
66
66
  // old session. Driving reviveNode directly is exactly what focus's revive path
67
67
  // calls, minus a tmux pane this contract does not involve.
68
- const focusRevive = reviveNode(id, { resume: true });
68
+ const focusRevive = reviveNode(id, { resume: true, capacity: 'freeze' });
69
69
  const focusPid = focusRevive.launch?.pid;
70
70
  assert.ok(focusPid != null, 'the competing focus invocation launches the replacement broker');
71
71
  assert.equal(focusRevive.resumed, false, 'refresh intent overrides this invocation\'s strict-resume request');
@@ -11,7 +11,7 @@
11
11
  // Every hue (fg / bg color) is gated on `caps.color`; structural SGR (bold/dim/
12
12
  // reverse) is allowed always. See detectColorCaps() for the gate, and the
13
13
  // canvas-browse color spec for the rationale + palette.
14
- import { STATUS_GLYPH, STATUS_COLOR, resolveNodeVisual, faultSummary } from '../status-glyph.js';
14
+ import { STATUS_GLYPH, STATUS_COLOR, isFrozenRow, resolveNodeVisual, faultSummary } from '../status-glyph.js';
15
15
  import { TABS, matchIndices, promptText, previewSnippet } from './model.js';
16
16
  // Span/color primitives live in core/tui/draw.ts (one copy, shared with the
17
17
  // `crtr view` host). Re-export the color caps so browse's importers + tests keep
@@ -185,7 +185,11 @@ function spansWidth(spans) {
185
185
  * node is genuinely mid-turn; otherwise the lifecycle word. The word is what makes
186
186
  * state legible at a glance and survives NO_COLOR (the glyph is the second cue). */
187
187
  function statusRail(r) {
188
- const v = resolveNodeVisual(r.status, { streaming: r.streaming === true, hanging: r.hanging ?? null });
188
+ const v = resolveNodeVisual(r.status, {
189
+ streaming: r.streaming === true,
190
+ frozen: isFrozenRow(r),
191
+ hanging: r.hanging ?? null,
192
+ });
189
193
  // Plain status keeps browse's own status WORD; overlays (hanging/⟳)
190
194
  // carry their own word from resolveNodeVisual. A hanging kind label can exceed
191
195
  // STATUS_W — acceptable: the rare, attention-demanding row gets a slightly
@@ -366,7 +370,11 @@ function previewPanel(r, width, caps, now, query) {
366
370
  out.push('');
367
371
  return out;
368
372
  }
369
- const v = resolveNodeVisual(r.status, { streaming: r.streaming === true, hanging: r.hanging ?? null });
373
+ const v = resolveNodeVisual(r.status, {
374
+ streaming: r.streaming === true,
375
+ frozen: isFrozenRow(r),
376
+ hanging: r.hanging ?? null,
377
+ });
370
378
  const glyph = caps.color
371
379
  ? `${ESC}${v.color}m${v.glyph}${RESET}`
372
380
  : v.glyph;
@@ -1,4 +1,4 @@
1
- import type { NodeMeta, NodeIdentity, NodeRow, NodeStatus, ExitIntent, SubscriptionRef, Mode, Lifecycle } from './types.js';
1
+ import type { NodeMeta, NodeIdentity, NodeRow, NodeStatus, ExitIntent, SubscriptionRef, Mode, Lifecycle, TerminalReason } from './types.js';
2
2
  /** Create a node: scaffold its dirs, persist identity to meta.json, and seed the
3
3
  * row (identity + runtime from the incoming meta). Returns the hydrated view. */
4
4
  export declare function createNode(meta: NodeMeta): NodeMeta;
@@ -35,6 +35,7 @@ export declare function setIntent(nodeId: string, intent: ExitIntent): void;
35
35
  export declare function transitionStatusIntent(nodeId: string, fields: {
36
36
  status?: NodeStatus;
37
37
  intent?: ExitIntent;
38
+ terminal_reason?: TerminalReason | null;
38
39
  }, opts?: {
39
40
  fromStatuses?: readonly NodeStatus[];
40
41
  requireNoFinalReport?: boolean;
@@ -74,7 +75,16 @@ export declare function recordPid(nodeId: string, pid: number): void;
74
75
  * `recordPid` and must be cleared together, or a subsequent relaunch that
75
76
  * reuses the pid namespace could compare its own fresh pid against a STALE
76
77
  * identity left over from the pid this row held before. */
77
- export declare function clearPid(nodeId: string): void;
78
+ export declare function clearRecordedPid(nodeId: string): void;
79
+ export declare const clearPid: typeof clearRecordedPid;
80
+ export declare function recordLaunch(nodeId: string, at: string): void;
81
+ export declare function recordSettledTurn(nodeId: string, at: string): void;
82
+ export declare function setFrozen(nodeId: string, at: string): void;
83
+ export declare function clearFrozen(nodeId: string): void;
84
+ export declare function recordRespawnAttempt(nodeId: string, attempt: {
85
+ failures: number;
86
+ notBefore: string | null;
87
+ }): void;
78
88
  /** ONE-TIME completion of the boot-scoped-identity format migration, run once
79
89
  * at daemon startup (the natural boot-reconciliation chokepoint, right after
80
90
  * `reconcileBootLiveness`). It closes the gap the `identitiesMatch` legacy
@@ -119,9 +129,11 @@ export declare function migrateLegacyPidIdentities(captureIdentity?: (pid: numbe
119
129
  * Unlike `listNodes`, this includes unclaimed warm-pool spares: profile
120
130
  * deletion owns every matching row, whether or not it is user-visible. */
121
131
  export declare function listNodesByProfile(profileId: string): NodeMeta[];
122
- /** All rows, optionally filtered by status. Unclaimed warm spares are excluded. */
132
+ /** All rows, optionally filtered by status and by carrying a recorded pid (the
133
+ * supervision tick's terminal-row query). Unclaimed warm spares are excluded. */
123
134
  export declare function listNodes(filter?: {
124
135
  status?: NodeStatus | NodeStatus[];
136
+ withRecordedPid?: boolean;
125
137
  }): NodeRow[];
126
138
  /** Mark an already-spawned node as an unclaimed spare for `recipeKey`. Hides it
127
139
  * from every listing until it is claimed. */
@@ -13,10 +13,9 @@ import { isPidAlive, recordedPidLiveness, capturePidIdentities, identitiesMatch,
13
13
  import { ensureHome, ensureNodeDirs, nodeMetaPath, nodeDir, nodesRoot, jobDir, } from './paths.js';
14
14
  // meta.json (durable identity — the source of truth for what PERSISTS)
15
15
  //
16
- // One authoritative store per fact: meta.json holds NodeIdentity only; the six
17
- // runtime fields (status, intent, pi_pid, window, tmux_session, pane) are
18
- // authoritative in the WAL'd `nodes` row, each mutated by one atomic setter
19
- // below. getNode() hydrates the two back into the historical NodeMeta view.
16
+ // One authoritative store per fact: meta.json holds NodeIdentity only; live
17
+ // runtime fields are authoritative in the WAL'd `nodes` row. getNode() hydrates
18
+ // the two back into the historical NodeMeta view.
20
19
  /** The identity keys meta.json persists. Listed explicitly so no runtime field
21
20
  * can ever leak onto disk even when a fully-hydrated NodeMeta is handed in. */
22
21
  const IDENTITY_KEYS = [
@@ -129,6 +128,12 @@ function rowFrom(r) {
129
128
  created: r['created'],
130
129
  intent: r['intent'] ?? null,
131
130
  waiting_for: r['waiting_for'] ?? null,
131
+ frozen_at: r['frozen_at'] ?? null,
132
+ respawn_failures: r['respawn_failures'] ?? 0,
133
+ respawn_not_before: r['respawn_not_before'] ?? null,
134
+ launched_at: r['launched_at'] ?? null,
135
+ last_settled_at: r['last_settled_at'] ?? null,
136
+ terminal_reason: r['terminal_reason'] ?? null,
132
137
  final_report: r['final_report'] ?? null,
133
138
  finalized_at: r['finalized_at'] ?? null,
134
139
  pi_pid: r['pi_pid'] ?? null,
@@ -142,7 +147,7 @@ function rowFrom(r) {
142
147
  * no row exists yet (the hydration then falls back to whatever the meta held). */
143
148
  function runtimeFromRow(nodeId) {
144
149
  const r = openDb()
145
- .prepare('SELECT status, intent, waiting_for, final_report, finalized_at, pi_pid, pi_pid_identity, "window", tmux_session, pane FROM nodes WHERE node_id = ?')
150
+ .prepare('SELECT status, intent, waiting_for, frozen_at, respawn_failures, respawn_not_before, launched_at, last_settled_at, terminal_reason, final_report, finalized_at, pi_pid, pi_pid_identity, "window", tmux_session, pane FROM nodes WHERE node_id = ?')
146
151
  .get(nodeId);
147
152
  if (r === undefined)
148
153
  return null;
@@ -150,6 +155,12 @@ function runtimeFromRow(nodeId) {
150
155
  status: r['status'] ?? 'active',
151
156
  intent: r['intent'] ?? null,
152
157
  waiting_for: r['waiting_for'] ?? null,
158
+ frozen_at: r['frozen_at'] ?? null,
159
+ respawn_failures: r['respawn_failures'] ?? 0,
160
+ respawn_not_before: r['respawn_not_before'] ?? null,
161
+ launched_at: r['launched_at'] ?? null,
162
+ last_settled_at: r['last_settled_at'] ?? null,
163
+ terminal_reason: r['terminal_reason'] ?? null,
153
164
  final_report: r['final_report'] ?? null,
154
165
  finalized_at: r['finalized_at'] ?? null,
155
166
  pi_pid: r['pi_pid'] ?? null,
@@ -273,6 +284,10 @@ export function transitionStatusIntent(nodeId, fields, opts = {}) {
273
284
  assignments.push('intent = ?');
274
285
  values.push(fields.intent ?? null);
275
286
  }
287
+ if (Object.prototype.hasOwnProperty.call(fields, 'terminal_reason')) {
288
+ assignments.push('terminal_reason = ?');
289
+ values.push(fields.terminal_reason ?? null);
290
+ }
276
291
  const predicates = ['node_id = ?'];
277
292
  values.push(nodeId);
278
293
  if (opts.fromStatuses !== undefined) {
@@ -334,9 +349,29 @@ export function recordPid(nodeId, pid) {
334
349
  * `recordPid` and must be cleared together, or a subsequent relaunch that
335
350
  * reuses the pid namespace could compare its own fresh pid against a STALE
336
351
  * identity left over from the pid this row held before. */
337
- export function clearPid(nodeId) {
352
+ export function clearRecordedPid(nodeId) {
338
353
  openDb().prepare('UPDATE nodes SET pi_pid = NULL, pi_pid_identity = NULL WHERE node_id = ?').run(nodeId);
339
354
  }
355
+ export const clearPid = clearRecordedPid;
356
+ export function recordLaunch(nodeId, at) {
357
+ openDb()
358
+ .prepare('UPDATE nodes SET launched_at = ?, frozen_at = NULL, respawn_not_before = NULL WHERE node_id = ?')
359
+ .run(at, nodeId);
360
+ }
361
+ export function recordSettledTurn(nodeId, at) {
362
+ openDb().prepare('UPDATE nodes SET last_settled_at = ? WHERE node_id = ?').run(at, nodeId);
363
+ }
364
+ export function setFrozen(nodeId, at) {
365
+ openDb().prepare('UPDATE nodes SET frozen_at = ? WHERE node_id = ?').run(at, nodeId);
366
+ }
367
+ export function clearFrozen(nodeId) {
368
+ openDb().prepare('UPDATE nodes SET frozen_at = NULL WHERE node_id = ?').run(nodeId);
369
+ }
370
+ export function recordRespawnAttempt(nodeId, attempt) {
371
+ openDb()
372
+ .prepare('UPDATE nodes SET respawn_failures = ?, respawn_not_before = ? WHERE node_id = ?')
373
+ .run(attempt.failures, attempt.notBefore, nodeId);
374
+ }
340
375
  /** ONE-TIME completion of the boot-scoped-identity format migration, run once
341
376
  * at daemon startup (the natural boot-reconciliation chokepoint, right after
342
377
  * `reconcileBootLiveness`). It closes the gap the `identitiesMatch` legacy
@@ -421,20 +456,21 @@ export function listNodesByProfile(profileId) {
421
456
  return meta;
422
457
  });
423
458
  }
424
- /** All rows, optionally filtered by status. Unclaimed warm spares are excluded. */
459
+ /** All rows, optionally filtered by status and by carrying a recorded pid (the
460
+ * supervision tick's terminal-row query). Unclaimed warm spares are excluded. */
425
461
  export function listNodes(filter) {
426
- const db = openDb();
427
- let rows;
462
+ const clauses = [NOT_A_SPARE];
463
+ const params = [];
428
464
  if (filter?.status !== undefined) {
429
465
  const statuses = Array.isArray(filter.status) ? filter.status : [filter.status];
430
- const placeholders = statuses.map(() => '?').join(',');
431
- rows = db
432
- .prepare(`SELECT * FROM nodes WHERE status IN (${placeholders}) AND ${NOT_A_SPARE} ORDER BY created`)
433
- .all(...statuses);
434
- }
435
- else {
436
- rows = db.prepare(`SELECT * FROM nodes WHERE ${NOT_A_SPARE} ORDER BY created`).all();
466
+ clauses.push(`status IN (${statuses.map(() => '?').join(',')})`);
467
+ params.push(...statuses);
437
468
  }
469
+ if (filter?.withRecordedPid === true)
470
+ clauses.push('pi_pid IS NOT NULL');
471
+ const rows = openDb()
472
+ .prepare(`SELECT * FROM nodes WHERE ${clauses.join(' AND ')} ORDER BY created`)
473
+ .all(...params);
438
474
  return rows.map(rowFrom);
439
475
  }
440
476
  // Warm pool — spare booted roots awaiting a `prefer_warm` claim (see
@@ -1215,6 +1215,26 @@ function addNodeSessionFileIndex(db) {
1215
1215
  }
1216
1216
  db.exec('CREATE INDEX IF NOT EXISTS idx_nodes_pi_session_file ON nodes(pi_session_file);');
1217
1217
  }
1218
+ /** v35 — durable node lifecycle execution state and terminal cause. */
1219
+ function addNodeLifecycleColumns(db) {
1220
+ const columns = nodeColumns(db);
1221
+ addNodeColumnIfMissing(db, columns, 'frozen_at', 'ALTER TABLE nodes ADD COLUMN frozen_at TEXT;');
1222
+ addNodeColumnIfMissing(db, columns, 'respawn_failures', 'ALTER TABLE nodes ADD COLUMN respawn_failures INTEGER NOT NULL DEFAULT 0;');
1223
+ addNodeColumnIfMissing(db, columns, 'respawn_not_before', 'ALTER TABLE nodes ADD COLUMN respawn_not_before TEXT;');
1224
+ addNodeColumnIfMissing(db, columns, 'launched_at', 'ALTER TABLE nodes ADD COLUMN launched_at TEXT;');
1225
+ addNodeColumnIfMissing(db, columns, 'last_settled_at', 'ALTER TABLE nodes ADD COLUMN last_settled_at TEXT;');
1226
+ addNodeColumnIfMissing(db, columns, 'terminal_reason', 'ALTER TABLE nodes ADD COLUMN terminal_reason TEXT;');
1227
+ db.exec(`
1228
+ UPDATE nodes SET terminal_reason = CASE
1229
+ WHEN status = 'done' AND final_report IS NOT NULL THEN 'finalized'
1230
+ WHEN status = 'done' AND intent = 'parked' THEN 'parked'
1231
+ WHEN status = 'done' THEN 'finished'
1232
+ WHEN status = 'canceled' THEN 'stranded'
1233
+ WHEN status = 'dead' THEN 'crashed'
1234
+ END
1235
+ WHERE status IN ('done','canceled','dead') AND terminal_reason IS NULL;
1236
+ `);
1237
+ }
1218
1238
  /** The journaled lane of the state-migration registry, adapted to this chain's
1219
1239
  * step shape. Registry order is preserved, so these versions stay stable as
1220
1240
  * long as the registry is append-only (same contract as the local list). */
@@ -1262,6 +1282,7 @@ export const MIGRATIONS = [
1262
1282
  /* v32 */ addCronHeldColumns,
1263
1283
  /* v33 */ addHumanActionDeliveries,
1264
1284
  /* v34 */ addNodeSessionFileIndex,
1285
+ /* v35 */ addNodeLifecycleColumns,
1265
1286
  ...JOURNALED_STATE_MIGRATIONS,
1266
1287
  ];
1267
1288
  /** Migration indexes that manage their OWN transaction and therefore must not
@@ -14,7 +14,7 @@ import { isPidAlive } from './pid.js';
14
14
  import { isBusy } from '../runtime/busy.js';
15
15
  import { readFault } from '../runtime/fault.js';
16
16
  import { activeFaultForDisplay } from './render-source.js';
17
- import { resolveNodeVisual } from './status-glyph.js';
17
+ import { isFrozenRow, resolveNodeVisual } from './status-glyph.js';
18
18
  // Tuning constants
19
19
  /** pi's InteractiveMode.MAX_WIDGET_LINES — the hard cap on lines in a string
20
20
  * array widget; anything beyond it pi truncates with its own "... (widget
@@ -62,6 +62,8 @@ export function nodeGlyph(node, remote = false) {
62
62
  return '?';
63
63
  const v = resolveNodeVisual(node.status, {
64
64
  streaming: remote ? false : streamingFor(node),
65
+ // Durable row state, so it holds for a remote node too.
66
+ frozen: isFrozenRow(node),
65
67
  hanging: remote ? null : hangingFor(node),
66
68
  });
67
69
  return `${ESC}${v.color}m${v.glyph}${RESET}`;
@@ -161,6 +161,8 @@ export class RemoteCanvasSource {
161
161
  created: n.created,
162
162
  intent: n.intent ?? null,
163
163
  waiting_for: n.waiting_for ?? null,
164
+ frozen_at: n.frozen_at ?? null,
165
+ terminal_reason: n.terminal_reason ?? null,
164
166
  pi_pid: n.pi_pid ?? null,
165
167
  pi_pid_identity: n.pi_pid_identity ?? null,
166
168
  window: n.window ?? null,
@@ -1,6 +1,6 @@
1
1
  import type { CanvasSource } from './source.js';
2
2
  import { type Fault } from '../runtime/fault.js';
3
- import type { NodeStatus, Lifecycle, NodeMeta, NodeRow } from './types.js';
3
+ import type { NodeStatus, Lifecycle, NodeMeta, NodeRow, TerminalReason } from './types.js';
4
4
  export declare function activeFaultForDisplay<T extends {
5
5
  status: NodeStatus;
6
6
  pi_pid?: number | null;
@@ -24,6 +24,8 @@ export interface DashboardRow {
24
24
  /** Generated first-task description. Searchable below user-authored prompts. */
25
25
  description?: string;
26
26
  status: NodeStatus;
27
+ frozen_at?: string | null;
28
+ terminal_reason?: TerminalReason | null;
27
29
  kind: string;
28
30
  mode: string;
29
31
  ctx_tokens: number;
@@ -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
  }
@@ -11,13 +11,14 @@
11
11
  * only `dead` is a fault. */
12
12
  export type NodeStatus = 'active' | 'idle' | 'done' | 'dead' | 'canceled';
13
13
  /** Does stopping finalize the node? terminal = worker (finalizes on push --final);
14
- * resident = interactable while attached or holding a live obligation. An
15
- * unattended obligation-free resident completes after the daemon grace. */
14
+ * resident = an interactable conversation that parks and remains wakeable
15
+ * without being forced to submit a final. */
16
16
  export type Lifecycle = 'terminal' | 'resident';
17
17
  /** base = hands-on worker; orchestrator = delegating manager. Bespoke per kind. */
18
18
  export type Mode = 'base' | 'orchestrator';
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
  }