@north-light/crouter 0.3.236 → 0.3.238

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 (177) hide show
  1. package/dist/api/client.d.ts +2 -1
  2. package/dist/api/client.js +3 -0
  3. package/dist/api/dto/broker-ops.d.ts +1 -1
  4. package/dist/api/dto/common.d.ts +2 -0
  5. package/dist/api/dto/lifecycle.d.ts +9 -2
  6. package/dist/api/dto/messages.d.ts +5 -0
  7. package/dist/api/dto/nodes.d.ts +10 -2
  8. package/dist/api/dto/worktree.d.ts +10 -0
  9. package/dist/api/routes.d.ts +1 -0
  10. package/dist/api/routes.js +1 -0
  11. package/dist/builtin-memory/00-runtime-base/00-authoring.md +1 -1
  12. package/dist/builtin-memory/internal/agent-shaping.md +1 -1
  13. package/dist/builtin-pi-packages/pi-crtr-extensions/README.md +1 -1
  14. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/sysprompt-window.ts +1 -1
  15. package/dist/clients/attach/render/tool-calls.js +18 -3
  16. package/dist/clients/attach/viewer.js +442 -442
  17. package/dist/clients/inbox/review/__tests__/editor-roundtrip.test.js +6 -0
  18. package/dist/clients/inbox/review/roundtrip.js +1 -1
  19. package/dist/commands/memory/lint.js +22 -13
  20. package/dist/commands/memory/shared.js +1 -1
  21. package/dist/commands/node/create.d.ts +1 -1
  22. package/dist/commands/node/create.js +1 -1
  23. package/dist/commands/node/lifecycle.js +1 -1
  24. package/dist/commands/node-worktree.js +39 -8
  25. package/dist/commands/revive.js +11 -2
  26. package/dist/commands/sys/context/admin/actions.d.ts +48 -0
  27. package/dist/commands/sys/context/admin/actions.js +246 -0
  28. package/dist/commands/sys/context/admin/axes-panel.d.ts +21 -0
  29. package/dist/commands/sys/context/admin/axes-panel.js +117 -0
  30. package/dist/commands/sys/context/admin/detail-panel.d.ts +53 -0
  31. package/dist/commands/sys/context/admin/detail-panel.js +178 -0
  32. package/dist/commands/sys/context/admin/docs-panel.d.ts +45 -0
  33. package/dist/commands/sys/context/admin/docs-panel.js +186 -0
  34. package/dist/commands/sys/context/admin/model.d.ts +75 -0
  35. package/dist/commands/sys/context/admin/model.js +209 -0
  36. package/dist/commands/sys/context/admin/shell.d.ts +82 -0
  37. package/dist/commands/sys/context/admin/shell.js +669 -0
  38. package/dist/commands/sys/context/admin.d.ts +1 -0
  39. package/dist/commands/sys/context/admin.js +124 -0
  40. package/dist/commands/sys/context/doc.d.ts +1 -0
  41. package/dist/commands/sys/context/doc.js +196 -0
  42. package/dist/commands/sys/context/prompt-review.d.ts +1 -0
  43. package/dist/commands/sys/{prompt-review.js → context/prompt-review.js} +10 -10
  44. package/dist/commands/sys/context/resolve.d.ts +55 -0
  45. package/dist/commands/sys/context/resolve.js +428 -0
  46. package/dist/commands/sys/context/sysprompt.d.ts +1 -0
  47. package/dist/commands/sys/{sysprompt.js → context/sysprompt.js} +11 -11
  48. package/dist/commands/sys/context.d.ts +2 -0
  49. package/dist/commands/sys/context.js +20 -0
  50. package/dist/commands/sys.js +3 -4
  51. package/dist/core/__tests__/child-death-wake.test.js +38 -22
  52. package/dist/core/__tests__/cron-self-cancel-delivery.test.js +72 -0
  53. package/dist/core/__tests__/daemon-boot.test.js +40 -28
  54. package/dist/core/__tests__/human-deliver.test.js +1 -1
  55. package/dist/core/__tests__/human-node-not-supervised.test.js +10 -15
  56. package/dist/core/__tests__/integration/revive.test.js +12 -12
  57. package/dist/core/__tests__/integration/spawn-root.test.js +6 -0
  58. package/dist/core/__tests__/integration/worktree-reap.test.js +32 -0
  59. package/dist/core/__tests__/lifecycle-terminal-reasons.test.d.ts +1 -0
  60. package/dist/core/__tests__/lifecycle-terminal-reasons.test.js +61 -0
  61. package/dist/core/__tests__/migration.test.js +38 -1
  62. package/dist/core/__tests__/push-final-guard.test.js +1 -1
  63. package/dist/core/__tests__/revive-capacity.test.d.ts +1 -0
  64. package/dist/core/__tests__/revive-capacity.test.js +127 -0
  65. package/dist/core/__tests__/revive-parked-fresh.test.js +7 -7
  66. package/dist/core/__tests__/seam/broker-cap-freeze.test.d.ts +1 -0
  67. package/dist/core/__tests__/seam/broker-cap-freeze.test.js +81 -0
  68. package/dist/core/__tests__/seam/dormancy-release.test.js +7 -7
  69. package/dist/core/__tests__/seam/yield-refresh-transaction.test.js +1 -1
  70. package/dist/core/canvas/browse/render.js +11 -3
  71. package/dist/core/canvas/canvas.d.ts +15 -3
  72. package/dist/core/canvas/canvas.js +52 -16
  73. package/dist/core/canvas/migrations.js +21 -0
  74. package/dist/core/canvas/nav-render.js +3 -1
  75. package/dist/core/canvas/remote-canvas-source.js +2 -0
  76. package/dist/core/canvas/render-source.d.ts +3 -1
  77. package/dist/core/canvas/render-source.js +7 -3
  78. package/dist/core/canvas/status-glyph.d.ts +10 -1
  79. package/dist/core/canvas/status-glyph.js +19 -3
  80. package/dist/core/canvas/types.d.ts +20 -1
  81. package/dist/core/feed/feed.js +1 -1
  82. package/dist/core/human/feedback-companion.js +5 -2
  83. package/dist/core/keybindings/catalog.d.ts +1 -1
  84. package/dist/core/keybindings/catalog.js +6 -2
  85. package/dist/core/memory/lint.d.ts +19 -0
  86. package/dist/core/memory/lint.js +97 -0
  87. package/dist/core/memory-resolver.d.ts +4 -0
  88. package/dist/core/memory-resolver.js +7 -7
  89. package/dist/core/preview-registry.js +4 -4
  90. package/dist/core/review/realize.js +4 -2
  91. package/dist/core/runtime/close.js +1 -1
  92. package/dist/core/runtime/fleet.d.ts +29 -1
  93. package/dist/core/runtime/fleet.js +23 -0
  94. package/dist/core/runtime/host.js +32 -11
  95. package/dist/core/runtime/launch-prompt.d.ts +1 -1
  96. package/dist/core/runtime/launch-prompt.js +2 -2
  97. package/dist/core/runtime/lifecycle.d.ts +4 -2
  98. package/dist/core/runtime/lifecycle.js +12 -1
  99. package/dist/core/runtime/memory.js +1 -1
  100. package/dist/core/runtime/reopen.js +10 -1
  101. package/dist/core/runtime/reset.js +1 -1
  102. package/dist/core/runtime/revive-all.d.ts +3 -0
  103. package/dist/core/runtime/revive-all.js +10 -4
  104. package/dist/core/runtime/revive.d.ts +12 -0
  105. package/dist/core/runtime/revive.js +62 -3
  106. package/dist/core/runtime/spawn.d.ts +4 -0
  107. package/dist/core/runtime/spawn.js +36 -7
  108. package/dist/core/runtime/tmux-bindings.js +4 -0
  109. package/dist/core/substrate/frontmatter-validation.js +1 -1
  110. package/dist/core/substrate/index.d.ts +6 -3
  111. package/dist/core/substrate/index.js +4 -3
  112. package/dist/core/substrate/on-read.d.ts +0 -8
  113. package/dist/core/substrate/on-read.js +45 -124
  114. package/dist/core/substrate/plan.d.ts +94 -0
  115. package/dist/core/substrate/plan.js +266 -0
  116. package/dist/core/substrate/render.d.ts +5 -6
  117. package/dist/core/substrate/render.js +76 -129
  118. package/dist/core/substrate/schema.d.ts +1 -6
  119. package/dist/core/substrate/schema.js +1 -15
  120. package/dist/core/substrate/session-cache.d.ts +2 -10
  121. package/dist/core/substrate/session-cache.js +9 -47
  122. package/dist/core/substrate/subject-fields.d.ts +1 -4
  123. package/dist/core/substrate/subject-fields.js +2 -5
  124. package/dist/core/substrate/surface-match.d.ts +37 -17
  125. package/dist/core/substrate/surface-match.js +44 -53
  126. package/dist/core/tui/page-host.d.ts +6 -0
  127. package/dist/core/tui/page-host.js +12 -3
  128. package/dist/core/worktree.d.ts +8 -0
  129. package/dist/core/worktree.js +65 -0
  130. package/dist/daemon/__tests__/reconciler-signature.test.js +3 -2
  131. package/dist/daemon/__tests__/thaw-order.test.d.ts +1 -0
  132. package/dist/daemon/__tests__/thaw-order.test.js +55 -0
  133. package/dist/daemon/api/__tests__/canvas-snapshot-fields.test.js +6 -0
  134. package/dist/daemon/api/__tests__/node-create-description.test.js +6 -0
  135. package/dist/daemon/api/__tests__/profile-launch-gates.test.js +1 -1
  136. package/dist/daemon/api/bridge.js +5 -1
  137. package/dist/daemon/api/handlers/attach.js +5 -1
  138. package/dist/daemon/api/handlers/bash-jobs.js +1 -1
  139. package/dist/daemon/api/handlers/messages.js +10 -2
  140. package/dist/daemon/api/handlers/nodes.js +8 -1
  141. package/dist/daemon/api/handlers/worktree.js +10 -1
  142. package/dist/daemon/api/map.js +7 -0
  143. package/dist/daemon/companion-retire.js +1 -1
  144. package/dist/daemon/cron/passes.d.ts +0 -5
  145. package/dist/daemon/cron/passes.js +0 -20
  146. package/dist/daemon/cron/sinks.js +1 -1
  147. package/dist/daemon/cron-run.d.ts +5 -6
  148. package/dist/daemon/cron-run.js +7 -16
  149. package/dist/daemon/crtrd.d.ts +0 -10
  150. package/dist/daemon/crtrd.js +23 -61
  151. package/dist/daemon/fleet.d.ts +10 -7
  152. package/dist/daemon/fleet.js +71 -12
  153. package/dist/daemon/messaging/node-message.js +3 -1
  154. package/dist/daemon/profile-delete.js +1 -1
  155. package/dist/daemon/reconcilers/broker-supervision.d.ts +26 -9
  156. package/dist/daemon/reconcilers/broker-supervision.js +39 -52
  157. package/dist/daemon/reconcilers/controller-death.d.ts +7 -0
  158. package/dist/daemon/reconcilers/controller-death.js +26 -0
  159. package/dist/daemon/reconcilers/cron-lane.d.ts +0 -3
  160. package/dist/daemon/reconcilers/cron-lane.js +0 -3
  161. package/dist/daemon/reconcilers/live-obligation.js +3 -1
  162. package/dist/daemon/reconcilers/node-lifecycle/freeze-lane.d.ts +53 -0
  163. package/dist/daemon/reconcilers/node-lifecycle/freeze-lane.js +170 -0
  164. package/dist/daemon/reconcilers/node-lifecycle/respawn-policy.d.ts +5 -0
  165. package/dist/daemon/reconcilers/node-lifecycle/respawn-policy.js +16 -0
  166. package/dist/daemon/reconcilers/node-lifecycle/terminating.d.ts +4 -0
  167. package/dist/daemon/reconcilers/node-lifecycle/terminating.js +13 -0
  168. package/dist/daemon/reconcilers/node-lifecycle/tick.d.ts +26 -0
  169. package/dist/daemon/reconcilers/node-lifecycle/tick.js +140 -0
  170. package/package.json +1 -1
  171. package/runtime.lock.json +2 -2
  172. package/dist/commands/sys/prompt-review.d.ts +0 -1
  173. package/dist/commands/sys/sysprompt.d.ts +0 -1
  174. package/dist/core/__tests__/cron-broker-capacity.test.js +0 -119
  175. package/dist/daemon/reconcilers/dormant-inbox.d.ts +0 -18
  176. package/dist/daemon/reconcilers/dormant-inbox.js +0 -157
  177. /package/dist/core/__tests__/{cron-broker-capacity.test.d.ts → cron-self-cancel-delivery.test.d.ts} +0 -0
@@ -0,0 +1,127 @@
1
+ // Run with: node --conditions=crtr-src --import tsx/esm --test src/core/__tests__/revive-capacity.test.ts
2
+ //
3
+ // The broker cap is a real admission gate, and `reviveNode` is where every
4
+ // launcher meets it. A caller declares what it wants when the ledger is full —
5
+ // `freeze` (its reason to run survives waiting) or `refuse` (it needs an engine
6
+ // now) — and this file pins the whole disposition matrix: the refusal names the
7
+ // cap and the live count instead of failing silently, the freeze marks the row
8
+ // once per episode without touching its session identity, an already-live node
9
+ // is admitted at the cap because it consumes no new slot, and the launch that
10
+ // finally happens retires the mark.
11
+ import { test, before, beforeEach, after } from 'node:test';
12
+ import assert from 'node:assert/strict';
13
+ import { mkdtempSync, rmSync } from 'node:fs';
14
+ import { tmpdir } from 'node:os';
15
+ import { join } from 'node:path';
16
+ import { createNode, getNode } from '../canvas/canvas.js';
17
+ import { closeDb } from '../canvas/db.js';
18
+ import { bindDaemonEventSource, resetEventSourceForTests } from '../events/source.js';
19
+ import { resetFleetForTests } from '../runtime/fleet.js';
20
+ import { headlessBrokerHost } from '../runtime/host.js';
21
+ import { reviveNode } from '../runtime/revive.js';
22
+ import { bindTestFleet } from './helpers/fleet.js';
23
+ let home;
24
+ let tf;
25
+ let seq = 0;
26
+ const originalLaunch = headlessBrokerHost.launch;
27
+ /** The real host contract, minus the process: register at the launch seam, so
28
+ * the ledger consumes the reservation exactly as production does. */
29
+ function stubLaunch() {
30
+ headlessBrokerHost.launch = (nodeId) => {
31
+ const handle = { pid: 500_000 + seq++, exited: new Promise(() => { }) };
32
+ tf.fleet.register(nodeId, handle);
33
+ return handle;
34
+ };
35
+ }
36
+ function node(id, over = {}) {
37
+ createNode({
38
+ node_id: id,
39
+ name: id,
40
+ created: new Date().toISOString(),
41
+ cwd: home,
42
+ kind: 'developer',
43
+ mode: 'base',
44
+ lifecycle: 'terminal',
45
+ status: 'active',
46
+ host_kind: 'broker',
47
+ ...over,
48
+ });
49
+ return id;
50
+ }
51
+ /** Fill the ledger to the cap with live brokers. */
52
+ function saturate() {
53
+ reviveNode(node(`filler-a-${seq}`), { resume: false, capacity: 'refuse' });
54
+ reviveNode(node(`filler-b-${seq}`), { resume: false, capacity: 'refuse' });
55
+ assert.equal(tf.fleet.size(), 2, 'precondition: the ledger is at its cap of 2');
56
+ }
57
+ before(() => {
58
+ home = mkdtempSync(join(tmpdir(), 'crtr-revive-capacity-'));
59
+ process.env['CRTR_HOME'] = home;
60
+ process.env['CRTR_MAX_LIVE_BROKERS'] = '2';
61
+ resetEventSourceForTests();
62
+ bindDaemonEventSource();
63
+ });
64
+ beforeEach(() => {
65
+ tf = bindTestFleet();
66
+ stubLaunch();
67
+ });
68
+ after(() => {
69
+ headlessBrokerHost.launch = originalLaunch;
70
+ tf.fleet.dispose();
71
+ resetFleetForTests();
72
+ resetEventSourceForTests();
73
+ closeDb();
74
+ delete process.env['CRTR_MAX_LIVE_BROKERS'];
75
+ delete process.env['CRTR_HOME'];
76
+ rmSync(home, { recursive: true, force: true });
77
+ });
78
+ test('a refusing caller gets an error naming the cap and the live count, never a silent no-op', () => {
79
+ saturate();
80
+ const turned = node('refused', { pi_session_id: 'sess-refused' });
81
+ assert.throws(() => reviveNode(turned, { resume: true, capacity: 'refuse' }), (err) => {
82
+ const details = err.details ?? {};
83
+ assert.equal(details['error'], 'broker_cap_reached', 'the refusal is a typed InputError, so it survives the /v1 error body');
84
+ assert.match(String(details['message']), /2 live brokers at the cap of 2/, 'the message names both the live count and the cap');
85
+ return true;
86
+ });
87
+ const meta = getNode(turned);
88
+ assert.equal(meta.frozen_at ?? null, null, 'a refusal is not a freeze — nothing waits for this row');
89
+ assert.equal(meta.pi_session_id, 'sess-refused', 'the refusal happens before any mutation of the session identity');
90
+ assert.equal(meta.cycles ?? 0, 0, 'a node turned away never spent a cycle');
91
+ });
92
+ test('a freezing caller gets a frozen row, marked once and left otherwise untouched', () => {
93
+ saturate();
94
+ const waiting = node('frozen', { pi_session_id: 'sess-frozen' });
95
+ const first = reviveNode(waiting, { resume: true, capacity: 'freeze' });
96
+ assert.equal(first.outcome, 'frozen', 'the caller learns it got no engine');
97
+ const marked = getNode(waiting);
98
+ assert.ok(marked.frozen_at != null, 'the row carries the durable wait mark the thaw order reads');
99
+ assert.equal(marked.pi_pid ?? null, null, 'a frozen row holds no pid — every status surface reads it as frozen, not stale-active');
100
+ assert.equal(marked.cycles ?? 0, 0, 'a frozen node never spent a cycle');
101
+ const second = reviveNode(waiting, { resume: true, capacity: 'freeze' });
102
+ assert.equal(second.outcome, 'frozen');
103
+ assert.equal(getNode(waiting).frozen_at, marked.frozen_at, 'freezing is once-per-episode: a second saturated tick keeps the original wait timestamp');
104
+ });
105
+ test('an already-live node is admitted at the cap — it consumes no new slot', () => {
106
+ const live = node('already-live');
107
+ reviveNode(live, { resume: false, capacity: 'refuse' });
108
+ reviveNode(node('already-live-filler'), { resume: false, capacity: 'refuse' });
109
+ assert.equal(tf.fleet.size(), 2, 'precondition: this node holds one of the two slots, and the ledger is full');
110
+ // Both dispositions agree: liveness is answered before capacity ever is.
111
+ assert.equal(reviveNode(live, { resume: true, capacity: 'refuse' }).outcome, 'already-live');
112
+ assert.equal(reviveNode(live, { resume: true, capacity: 'freeze' }).outcome, 'already-live');
113
+ assert.equal(getNode(live).frozen_at ?? null, null, 'a live node is never marked as waiting for a slot');
114
+ });
115
+ test('a freed slot lets the frozen row launch, and the launch retires the mark', async () => {
116
+ const filler = node('filler-exits');
117
+ reviveNode(filler, { resume: false, capacity: 'refuse' });
118
+ reviveNode(node('filler-stays'), { resume: false, capacity: 'refuse' });
119
+ const waiting = node('thaws', { pi_session_id: 'sess-thaws' });
120
+ assert.equal(reviveNode(waiting, { resume: true, capacity: 'freeze' }).outcome, 'frozen');
121
+ // A real exit frees the slot; its policy job runs on the lane like production.
122
+ tf.fleet.deliverExit(filler, { code: 0, signal: null });
123
+ await tf.drain();
124
+ assert.equal(reviveNode(waiting, { resume: true, capacity: 'freeze' }).outcome, 'launched');
125
+ assert.equal(getNode(waiting).frozen_at ?? null, null, 'a launch is the definition of not-frozen');
126
+ assert.equal(tf.fleet.has(waiting), true, 'the thawed node owns the freed slot');
127
+ });
@@ -106,7 +106,7 @@ after(() => {
106
106
  test('a parked node with mail waiting re-enters fresh even when the caller asked to resume, and the marker clears', () => {
107
107
  parkResident('parked', { roadmap: '# Roadmap\n\n- one open item\n' });
108
108
  deliverMail('parked');
109
- const result = reviveNode('parked', { resume: true });
109
+ const result = reviveNode('parked', { resume: true, capacity: 'freeze' });
110
110
  assert.equal(result.resumed, false, 'the concluded transcript is not replayed');
111
111
  assert.deepEqual(placements(), [{ nodeId: 'parked', resuming: false }]);
112
112
  assert.equal(kickedOff(launches[0]), true, 'the fresh window is handed its bearings');
@@ -118,12 +118,12 @@ test('a parked node with mail waiting re-enters fresh even when the caller asked
118
118
  test('a parked node grounded only by a goal still re-enters fresh when mail rides the revive', () => {
119
119
  parkResident('goal-only', { goal: 'Watch the deploy lane.' });
120
120
  deliverMail('goal-only');
121
- assert.equal(reviveNode('goal-only', { resume: true }).resumed, false);
121
+ assert.equal(reviveNode('goal-only', { resume: true, capacity: 'freeze' }).resumed, false);
122
122
  assert.equal(kickedOff(launches[0]), true);
123
123
  });
124
124
  test('an input-less revival of a parked node quietly resumes and injects no kickoff', () => {
125
125
  parkResident('quiet', { roadmap: '# Roadmap\n\n- one open item\n' });
126
- const result = reviveNode('quiet', { resume: true });
126
+ const result = reviveNode('quiet', { resume: true, capacity: 'freeze' });
127
127
  assert.equal(result.resumed, true, 'nothing arrived, so there is nothing a fresh window would answer');
128
128
  assert.deepEqual(placements(), [{ nodeId: 'quiet', resuming: true }]);
129
129
  assert.equal(kickedOff(launches[0]), false, 'a quiet resume generates nothing');
@@ -135,20 +135,20 @@ test('an input-less revival of a parked node quietly resumes and injects no kick
135
135
  test('deferred mail alone is not input, so the revival still resumes quietly', () => {
136
136
  parkResident('deferred', { roadmap: '# Roadmap\n\n- one open item\n' });
137
137
  deliverMail('deferred', 'deferred');
138
- assert.equal(reviveNode('deferred', { resume: true }).resumed, true);
138
+ assert.equal(reviveNode('deferred', { resume: true, capacity: 'freeze' }).resumed, true);
139
139
  assert.equal(kickedOff(launches[0]), false);
140
140
  assert.equal(readCursor('deferred'), undefined, 'the deferred entry rides the next natural cycle, unconsumed');
141
141
  });
142
142
  test('mail already covered by the cursor is not input, so the revival still resumes quietly', () => {
143
143
  parkResident('stale', { roadmap: '# Roadmap\n\n- one open item\n' });
144
144
  writeCursor('stale', deliverMail('stale'));
145
- assert.equal(reviveNode('stale', { resume: true }).resumed, true, 'only the UNSEEN suffix counts as input');
145
+ assert.equal(reviveNode('stale', { resume: true, capacity: 'freeze' }).resumed, true, 'only the UNSEEN suffix counts as input');
146
146
  assert.equal(kickedOff(launches[0]), false);
147
147
  });
148
148
  test('a parked node with no goal and no roadmap resumes its transcript rather than waking amnesiac', () => {
149
149
  parkResident('bare');
150
150
  deliverMail('bare');
151
- const result = reviveNode('bare', { resume: true });
151
+ const result = reviveNode('bare', { resume: true, capacity: 'freeze' });
152
152
  assert.equal(result.resumed, true, 'the degraded park has nothing on disk to ground a fresh window');
153
153
  assert.deepEqual(placements(), [{ nodeId: 'bare', resuming: true }]);
154
154
  });
@@ -156,5 +156,5 @@ test('an ordinary done node with a roadmap still resumes — only the park marke
156
156
  parkResident('finished', { roadmap: '# Roadmap\n\n- one open item\n' });
157
157
  transition('finished', 'revive');
158
158
  transition('finished', 'finish');
159
- assert.equal(reviveNode('finished', { resume: true }).resumed, true);
159
+ assert.equal(reviveNode('finished', { resume: true, capacity: 'freeze' }).resumed, true);
160
160
  });
@@ -0,0 +1,81 @@
1
+ // Run with: npm run build && node --test dist/core/__tests__/seam/broker-cap-freeze.test.js
2
+ //
3
+ // The cap as the host actually meets it: real broker processes, the real
4
+ // ledger, and the real supervision tick. With CRTR_MAX_LIVE_BROKERS=2 a third
5
+ // birth gets no engine at all — the row exists, carries the durable wait mark,
6
+ // and holds no pid — and it survives repeated saturated ticks rather than being
7
+ // written off as a boot failure. When a live node finishes and gives its slot
8
+ // back, the next tick spends that slot on the waiting row and retires the mark.
9
+ //
10
+ // This is the whole point of P3, and none of it is observable without real
11
+ // processes: the freeze is decided at the launch seam and the thaw is decided
12
+ // by the daemon pass, so it belongs in the compiled seam tier.
13
+ import { after, before, test } from 'node:test';
14
+ import assert from 'node:assert/strict';
15
+ import { closeDb } from '../../canvas/db.js';
16
+ import { isPidAlive } from '../../canvas/pid.js';
17
+ import { closeNode } from '../../runtime/close.js';
18
+ import { spawnChild } from '../../runtime/spawn.js';
19
+ import { createHarness } from '../helpers/harness.js';
20
+ let h;
21
+ let root;
22
+ before(async () => {
23
+ // Read per launch by brokerThresholdsForDaemon(), so setting it before the
24
+ // harness boots covers every launch this file drives.
25
+ process.env['CRTR_MAX_LIVE_BROKERS'] = '2';
26
+ h = await createHarness({ headless: true, sessionPrefix: 'crtr-broker-cap' });
27
+ root = h.spawnRoot('broker cap suite root');
28
+ });
29
+ after(async () => {
30
+ if (h !== undefined)
31
+ await h.dispose();
32
+ delete process.env['CRTR_MAX_LIVE_BROKERS'];
33
+ });
34
+ test('a birth at the cap freezes instead of booting, survives saturated ticks, and thaws when a slot frees', { timeout: 120_000 }, async () => {
35
+ const first = await h.spawnHeadlessChild(root, 'first live child');
36
+ const second = await h.spawnHeadlessChild(root, 'second live child');
37
+ assert.equal(h.fleet.size(), 2, 'precondition: both slots are held by real brokers');
38
+ // The third birth meets a full ledger. spawnChild returns rather than
39
+ // crashing the row: a node that only has to wait is not a failed launch.
40
+ const born = await spawnChild({
41
+ kind: 'general',
42
+ cwd: h.node(first).cwd,
43
+ prompt: 'third child, born at the cap',
44
+ parent: root,
45
+ });
46
+ closeDb();
47
+ const third = born.node.node_id;
48
+ assert.equal(born.frozen, true, 'the birth reports that it got no engine');
49
+ const frozenRow = h.node(third);
50
+ assert.ok(frozenRow.frozen_at != null, 'the row carries the durable wait mark');
51
+ assert.equal(frozenRow.pi_pid ?? null, null, 'no pid was ever recorded — status surfaces read frozen, not stale-active');
52
+ assert.equal(h.fleet.has(third), false, 'no broker process exists for the frozen row');
53
+ assert.equal(h.bootCount(third), 0, 'nothing booted');
54
+ // Six saturated ticks: the cap is not a failure, so nothing is charged
55
+ // against the row and nothing terminalizes it.
56
+ for (let i = 0; i < 6; i++)
57
+ await h.tick();
58
+ closeDb();
59
+ const survived = h.node(third);
60
+ assert.equal(survived.status, 'active', 'six saturated ticks leave the row alive and still owed an engine');
61
+ assert.equal(survived.respawn_failures ?? 0, 0, 'waiting for a slot is not a respawn failure');
62
+ assert.equal(survived.frozen_at, frozenRow.frozen_at, 'the original wait timestamp survives — the thaw order reads it');
63
+ assert.equal(h.fleet.size(), 2, 'the two live brokers were never disturbed');
64
+ // Finish one node: its broker exits and gives the slot back.
65
+ const firstPid = h.node(first).pi_pid;
66
+ closeNode(first, { rootEvent: 'finish' });
67
+ closeDb();
68
+ await h.awaitFleetExit(first);
69
+ assert.equal(h.fleet.size(), 1, 'the finished node released its slot');
70
+ // The next tick spends the freed slot on the waiting row.
71
+ await h.tick();
72
+ closeDb();
73
+ await h.awaitBoot(third);
74
+ closeDb();
75
+ const thawed = h.node(third);
76
+ assert.equal(thawed.frozen_at ?? null, null, 'the launch retired the wait mark');
77
+ assert.ok(thawed.pi_pid != null && isPidAlive(thawed.pi_pid), 'the thawed node holds a live broker');
78
+ assert.equal(h.fleet.has(third), true, 'the freed slot is now the thawed node\u2019s');
79
+ assert.equal(h.fleet.has(second), true, 'the untouched live node kept its slot throughout');
80
+ assert.notEqual(thawed.pi_pid, firstPid, 'the thaw is a real new broker, not the finished node\u2019s pid lingering');
81
+ });
@@ -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' });
@@ -378,7 +378,7 @@ test('a resident managed child parks without finalizing while a terminal child i
378
378
  subscribe(terminalManager, terminal, true);
379
379
  const boot = async (id) => {
380
380
  updateNode(id, { launch: { extensions: [], tools: [], systemPrompt: '', env: {} } });
381
- reviveNode(id, { resume: true });
381
+ reviveNode(id, { resume: true, capacity: 'freeze' });
382
382
  return (await h.awaitBoot(id)).pid;
383
383
  };
384
384
  const residentPid = await boot(resident);
@@ -439,7 +439,7 @@ test('an unattended resident awaiting a live child remains wakeable', { timeout:
439
439
  env: {},
440
440
  },
441
441
  });
442
- reviveNode(nodeId, { resume: true });
442
+ reviveNode(nodeId, { resume: true, capacity: 'freeze' });
443
443
  const boot = await h.awaitBoot(nodeId);
444
444
  const seenAt = 20_000;
445
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;