@north-light/crouter 0.3.236 → 0.3.237

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (174) hide show
  1. package/dist/api/client.d.ts +2 -1
  2. package/dist/api/client.js +3 -0
  3. package/dist/api/dto/broker-ops.d.ts +1 -1
  4. package/dist/api/dto/common.d.ts +2 -0
  5. package/dist/api/dto/lifecycle.d.ts +9 -2
  6. package/dist/api/dto/messages.d.ts +5 -0
  7. package/dist/api/dto/nodes.d.ts +10 -2
  8. package/dist/api/dto/worktree.d.ts +10 -0
  9. package/dist/api/routes.d.ts +1 -0
  10. package/dist/api/routes.js +1 -0
  11. package/dist/builtin-memory/00-runtime-base/00-authoring.md +1 -1
  12. package/dist/builtin-memory/internal/agent-shaping.md +1 -1
  13. package/dist/builtin-pi-packages/pi-crtr-extensions/README.md +1 -1
  14. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/sysprompt-window.ts +1 -1
  15. package/dist/clients/attach/render/tool-calls.js +18 -3
  16. package/dist/clients/attach/viewer.js +426 -426
  17. package/dist/clients/inbox/review/__tests__/editor-roundtrip.test.js +6 -0
  18. package/dist/clients/inbox/review/roundtrip.js +1 -1
  19. package/dist/commands/memory/lint.js +22 -13
  20. package/dist/commands/memory/shared.js +1 -1
  21. package/dist/commands/node/create.d.ts +1 -1
  22. package/dist/commands/node/create.js +1 -1
  23. package/dist/commands/node/lifecycle.js +1 -1
  24. package/dist/commands/node-worktree.js +39 -8
  25. package/dist/commands/revive.js +11 -2
  26. package/dist/commands/sys/context/admin/actions.d.ts +48 -0
  27. package/dist/commands/sys/context/admin/actions.js +246 -0
  28. package/dist/commands/sys/context/admin/axes-panel.d.ts +21 -0
  29. package/dist/commands/sys/context/admin/axes-panel.js +117 -0
  30. package/dist/commands/sys/context/admin/detail-panel.d.ts +53 -0
  31. package/dist/commands/sys/context/admin/detail-panel.js +178 -0
  32. package/dist/commands/sys/context/admin/docs-panel.d.ts +45 -0
  33. package/dist/commands/sys/context/admin/docs-panel.js +186 -0
  34. package/dist/commands/sys/context/admin/model.d.ts +75 -0
  35. package/dist/commands/sys/context/admin/model.js +209 -0
  36. package/dist/commands/sys/context/admin/shell.d.ts +82 -0
  37. package/dist/commands/sys/context/admin/shell.js +669 -0
  38. package/dist/commands/sys/context/admin.d.ts +1 -0
  39. package/dist/commands/sys/context/admin.js +124 -0
  40. package/dist/commands/sys/context/doc.d.ts +1 -0
  41. package/dist/commands/sys/context/doc.js +196 -0
  42. package/dist/commands/sys/context/prompt-review.d.ts +1 -0
  43. package/dist/commands/sys/{prompt-review.js → context/prompt-review.js} +10 -10
  44. package/dist/commands/sys/context/resolve.d.ts +55 -0
  45. package/dist/commands/sys/context/resolve.js +428 -0
  46. package/dist/commands/sys/context/sysprompt.d.ts +1 -0
  47. package/dist/commands/sys/{sysprompt.js → context/sysprompt.js} +11 -11
  48. package/dist/commands/sys/context.d.ts +2 -0
  49. package/dist/commands/sys/context.js +20 -0
  50. package/dist/commands/sys.js +3 -4
  51. package/dist/core/__tests__/child-death-wake.test.js +38 -22
  52. package/dist/core/__tests__/cron-self-cancel-delivery.test.js +72 -0
  53. package/dist/core/__tests__/daemon-boot.test.js +40 -28
  54. package/dist/core/__tests__/human-deliver.test.js +1 -1
  55. package/dist/core/__tests__/human-node-not-supervised.test.js +10 -15
  56. package/dist/core/__tests__/integration/revive.test.js +12 -12
  57. package/dist/core/__tests__/integration/spawn-root.test.js +6 -0
  58. package/dist/core/__tests__/integration/worktree-reap.test.js +32 -0
  59. package/dist/core/__tests__/lifecycle-terminal-reasons.test.d.ts +1 -0
  60. package/dist/core/__tests__/lifecycle-terminal-reasons.test.js +61 -0
  61. package/dist/core/__tests__/migration.test.js +38 -1
  62. package/dist/core/__tests__/push-final-guard.test.js +1 -1
  63. package/dist/core/__tests__/revive-capacity.test.d.ts +1 -0
  64. package/dist/core/__tests__/revive-capacity.test.js +127 -0
  65. package/dist/core/__tests__/revive-parked-fresh.test.js +7 -7
  66. package/dist/core/__tests__/seam/broker-cap-freeze.test.d.ts +1 -0
  67. package/dist/core/__tests__/seam/broker-cap-freeze.test.js +81 -0
  68. package/dist/core/__tests__/seam/dormancy-release.test.js +7 -7
  69. package/dist/core/__tests__/seam/yield-refresh-transaction.test.js +1 -1
  70. package/dist/core/canvas/browse/render.js +11 -3
  71. package/dist/core/canvas/canvas.d.ts +15 -3
  72. package/dist/core/canvas/canvas.js +52 -16
  73. package/dist/core/canvas/migrations.js +21 -0
  74. package/dist/core/canvas/nav-render.js +3 -1
  75. package/dist/core/canvas/remote-canvas-source.js +2 -0
  76. package/dist/core/canvas/render-source.d.ts +3 -1
  77. package/dist/core/canvas/render-source.js +7 -3
  78. package/dist/core/canvas/status-glyph.d.ts +10 -1
  79. package/dist/core/canvas/status-glyph.js +19 -3
  80. package/dist/core/canvas/types.d.ts +20 -1
  81. package/dist/core/feed/feed.js +1 -1
  82. package/dist/core/human/feedback-companion.js +5 -2
  83. package/dist/core/memory/lint.d.ts +19 -0
  84. package/dist/core/memory/lint.js +97 -0
  85. package/dist/core/memory-resolver.d.ts +4 -0
  86. package/dist/core/memory-resolver.js +7 -7
  87. package/dist/core/preview-registry.js +4 -4
  88. package/dist/core/review/realize.js +4 -2
  89. package/dist/core/runtime/close.js +1 -1
  90. package/dist/core/runtime/fleet.d.ts +29 -1
  91. package/dist/core/runtime/fleet.js +23 -0
  92. package/dist/core/runtime/host.js +32 -11
  93. package/dist/core/runtime/launch-prompt.d.ts +1 -1
  94. package/dist/core/runtime/launch-prompt.js +2 -2
  95. package/dist/core/runtime/lifecycle.d.ts +4 -2
  96. package/dist/core/runtime/lifecycle.js +12 -1
  97. package/dist/core/runtime/memory.js +1 -1
  98. package/dist/core/runtime/reopen.js +10 -1
  99. package/dist/core/runtime/reset.js +1 -1
  100. package/dist/core/runtime/revive-all.d.ts +3 -0
  101. package/dist/core/runtime/revive-all.js +10 -4
  102. package/dist/core/runtime/revive.d.ts +12 -0
  103. package/dist/core/runtime/revive.js +62 -3
  104. package/dist/core/runtime/spawn.d.ts +4 -0
  105. package/dist/core/runtime/spawn.js +36 -7
  106. package/dist/core/substrate/frontmatter-validation.js +1 -1
  107. package/dist/core/substrate/index.d.ts +6 -3
  108. package/dist/core/substrate/index.js +4 -3
  109. package/dist/core/substrate/on-read.d.ts +0 -8
  110. package/dist/core/substrate/on-read.js +45 -124
  111. package/dist/core/substrate/plan.d.ts +94 -0
  112. package/dist/core/substrate/plan.js +266 -0
  113. package/dist/core/substrate/render.d.ts +5 -6
  114. package/dist/core/substrate/render.js +76 -129
  115. package/dist/core/substrate/schema.d.ts +1 -6
  116. package/dist/core/substrate/schema.js +1 -15
  117. package/dist/core/substrate/session-cache.d.ts +2 -10
  118. package/dist/core/substrate/session-cache.js +9 -47
  119. package/dist/core/substrate/subject-fields.d.ts +1 -4
  120. package/dist/core/substrate/subject-fields.js +2 -5
  121. package/dist/core/substrate/surface-match.d.ts +37 -17
  122. package/dist/core/substrate/surface-match.js +44 -53
  123. package/dist/core/tui/page-host.d.ts +6 -0
  124. package/dist/core/tui/page-host.js +12 -3
  125. package/dist/core/worktree.d.ts +8 -0
  126. package/dist/core/worktree.js +65 -0
  127. package/dist/daemon/__tests__/reconciler-signature.test.js +3 -2
  128. package/dist/daemon/__tests__/thaw-order.test.d.ts +1 -0
  129. package/dist/daemon/__tests__/thaw-order.test.js +55 -0
  130. package/dist/daemon/api/__tests__/canvas-snapshot-fields.test.js +6 -0
  131. package/dist/daemon/api/__tests__/node-create-description.test.js +6 -0
  132. package/dist/daemon/api/__tests__/profile-launch-gates.test.js +1 -1
  133. package/dist/daemon/api/bridge.js +5 -1
  134. package/dist/daemon/api/handlers/attach.js +5 -1
  135. package/dist/daemon/api/handlers/bash-jobs.js +1 -1
  136. package/dist/daemon/api/handlers/messages.js +10 -2
  137. package/dist/daemon/api/handlers/nodes.js +8 -1
  138. package/dist/daemon/api/handlers/worktree.js +10 -1
  139. package/dist/daemon/api/map.js +7 -0
  140. package/dist/daemon/companion-retire.js +1 -1
  141. package/dist/daemon/cron/passes.d.ts +0 -5
  142. package/dist/daemon/cron/passes.js +0 -20
  143. package/dist/daemon/cron/sinks.js +1 -1
  144. package/dist/daemon/cron-run.d.ts +5 -6
  145. package/dist/daemon/cron-run.js +7 -16
  146. package/dist/daemon/crtrd.d.ts +0 -10
  147. package/dist/daemon/crtrd.js +23 -61
  148. package/dist/daemon/fleet.d.ts +10 -7
  149. package/dist/daemon/fleet.js +71 -12
  150. package/dist/daemon/messaging/node-message.js +3 -1
  151. package/dist/daemon/profile-delete.js +1 -1
  152. package/dist/daemon/reconcilers/broker-supervision.d.ts +26 -9
  153. package/dist/daemon/reconcilers/broker-supervision.js +39 -52
  154. package/dist/daemon/reconcilers/controller-death.d.ts +7 -0
  155. package/dist/daemon/reconcilers/controller-death.js +26 -0
  156. package/dist/daemon/reconcilers/cron-lane.d.ts +0 -3
  157. package/dist/daemon/reconcilers/cron-lane.js +0 -3
  158. package/dist/daemon/reconcilers/live-obligation.js +3 -1
  159. package/dist/daemon/reconcilers/node-lifecycle/freeze-lane.d.ts +53 -0
  160. package/dist/daemon/reconcilers/node-lifecycle/freeze-lane.js +170 -0
  161. package/dist/daemon/reconcilers/node-lifecycle/respawn-policy.d.ts +5 -0
  162. package/dist/daemon/reconcilers/node-lifecycle/respawn-policy.js +16 -0
  163. package/dist/daemon/reconcilers/node-lifecycle/terminating.d.ts +4 -0
  164. package/dist/daemon/reconcilers/node-lifecycle/terminating.js +13 -0
  165. package/dist/daemon/reconcilers/node-lifecycle/tick.d.ts +26 -0
  166. package/dist/daemon/reconcilers/node-lifecycle/tick.js +140 -0
  167. package/package.json +1 -1
  168. package/runtime.lock.json +2 -2
  169. package/dist/commands/sys/prompt-review.d.ts +0 -1
  170. package/dist/commands/sys/sysprompt.d.ts +0 -1
  171. package/dist/core/__tests__/cron-broker-capacity.test.js +0 -119
  172. package/dist/daemon/reconcilers/dormant-inbox.d.ts +0 -18
  173. package/dist/daemon/reconcilers/dormant-inbox.js +0 -157
  174. /package/dist/core/__tests__/{cron-broker-capacity.test.d.ts → cron-self-cancel-delivery.test.d.ts} +0 -0
@@ -0,0 +1,72 @@
1
+ // Run with: node --import tsx/esm --test src/core/__tests__/cron-self-cancel-delivery.test.ts
2
+ //
3
+ // A cron whose own command cancels its row mid-run must still deliver the
4
+ // output that run produced: cancellation retires the schedule, not the run in
5
+ // flight.
6
+ import { after, afterEach, before, test } from 'node:test';
7
+ import assert from 'node:assert/strict';
8
+ import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
9
+ import { tmpdir } from 'node:os';
10
+ import { join } from 'node:path';
11
+ import { setTimeout as delay } from 'node:timers/promises';
12
+ import { closeDb } from '../canvas/db.js';
13
+ import { armCron, cancelCron, getCron } from '../canvas/crons.js';
14
+ import { executeCron, markSelfCanceledCronRun } from '../../daemon/cron-run.js';
15
+ let home;
16
+ let realHome;
17
+ function arm(overrides) {
18
+ armCron({
19
+ name: overrides.cron_id,
20
+ created_by: null,
21
+ fire_at: new Date(Date.now() - 1_000).toISOString(),
22
+ recur: null,
23
+ tz: null,
24
+ expires_at: null,
25
+ anchor_node: null,
26
+ cancel_on_wake: false,
27
+ cwd: home,
28
+ env_json: null,
29
+ profile: null,
30
+ scope: 'profile',
31
+ run_timeout_s: 30,
32
+ overlap: 'skip',
33
+ on_output: 'silent',
34
+ sink: '{}',
35
+ tier: 'normal',
36
+ ...overrides,
37
+ });
38
+ }
39
+ before(() => {
40
+ home = mkdtempSync(join(tmpdir(), 'crtr-cron-broker-capacity-'));
41
+ realHome = process.env['CRTR_HOME'];
42
+ process.env['CRTR_HOME'] = home;
43
+ });
44
+ afterEach(() => {
45
+ closeDb();
46
+ });
47
+ after(() => {
48
+ closeDb();
49
+ rmSync(home, { recursive: true, force: true });
50
+ if (realHome === undefined)
51
+ delete process.env['CRTR_HOME'];
52
+ else
53
+ process.env['CRTR_HOME'] = realHome;
54
+ });
55
+ test('an on-change cron that cancels itself still delivers its final output', async () => {
56
+ const runIdPath = join(home, 'self-cancel-run-id');
57
+ arm({
58
+ cron_id: 'self-cancel-delivery',
59
+ command: `printf '%s' "$CRTR_RUN_ID" > '${runIdPath}'; echo settled; sleep 0.1`,
60
+ recur: JSON.stringify({ every: '1h' }),
61
+ on_output: 'on-change',
62
+ sink: JSON.stringify({ human: true }),
63
+ });
64
+ const run = executeCron(getCron('self-cancel-delivery'), { outOfBand: false });
65
+ for (let i = 0; i < 50 && !existsSync(runIdPath); i++)
66
+ await delay(10);
67
+ assert.ok(existsSync(runIdPath), 'the live run exposed its id to its self-cancel command');
68
+ assert.equal(markSelfCanceledCronRun('self-cancel-delivery', readFileSync(runIdPath, 'utf8')), true);
69
+ assert.equal(cancelCron('self-cancel-delivery', { preserveRunningProcess: true }), true);
70
+ const record = await run;
71
+ assert.equal(record?.delivered, 'delivered: human send --inbox');
72
+ });
@@ -15,6 +15,7 @@ import { verifyDaemonStartup, waitForDaemonExit } from '../../daemon/manage.js';
15
15
  import { waitForBrokerViewSocket } from '../runtime/placement-tmux.js';
16
16
  import { viewSocketPath } from '../canvas/paths.js';
17
17
  import { markBusy, clearBusy } from '../runtime/busy.js';
18
+ import { headlessBrokerHost } from '../runtime/host.js';
18
19
  import { bindTestFleet } from './helpers/fleet.js';
19
20
  import { resetFleetForTests } from '../runtime/fleet.js';
20
21
  let home;
@@ -92,34 +93,45 @@ test('never-booted broker exit is marked dead and surfaced to the parent as urge
92
93
  assert.ok(wake, 'the doctrine wake rides beside the urgent push');
93
94
  assert.match(wake.label, /crashed/i);
94
95
  });
95
- // BROKER CUT: surfaceChildDeath was DELETED and the tick runs no liveness pass.
96
- // A crashed booted child (engine pid DEAD, pi_session_id set) is never marked
97
- // `dead` by a tick and never fans a "child died" notice — recovery rides the
98
- // broker's real exit event (policy rows 5/6 revive it on its saved session), so
99
- // a fabricated row with no fleet entry is simply left revivable and the doctrine wake
100
- // relocated (the revived child pushes when it truly finishes; a deliberate close
101
- // is woken by close.ts). This test preserves the discriminating intent in the
102
- // new model: a crash is never reaped to `dead` and never raises a false
103
- // boot-failure alarm.
104
- test('a crashed booted child is revivable, not reaped, and raises no false boot-failure alarm', async () => {
105
- createNode(node('P2', { kind: 'developer', lifecycle: 'resident' }));
106
- createNode(node('C2', {
107
- parent: 'P2',
108
- kind: 'explore',
109
- pi_pid: deadPid(), // engine crashed — its recorded pid is dead
110
- pi_session_id: '019e8f00-booted-once', // it booted before dying
111
- intent: null,
112
- }));
113
- spawnEdge('P2', 'C2');
114
- markBusy('C2'); // died mid-generation
115
- await superviseTick();
116
- // The tick has no liveness pass: a stale recorded pid is metadata, not a
117
- // death signal — the row stays active/revivable, never reaped to `dead`.
118
- assert.equal(getNode('C2').status, 'active', 'crashed booted child stays revivable, NOT marked dead');
119
- // surfaceChildDeath is gone and this is not a never-booted node, so the daemon
120
- // fans NO push here — no false boot-failure / child-death alarm on a crash.
121
- assert.equal(readInboxSince('P2').length, 0, 'no false boot-failure / child-death push on a crash (wake relocated to push/close)');
122
- clearBusy('C2');
96
+ // surfaceChildDeath was DELETED, and the node-lifecycle tick recovers a crashed
97
+ // booted child through the SAME policy table its exit event would have used
98
+ // (design D-12), so tick recovery and exit recovery can never diverge. A row
99
+ // whose recorded pid is provably dead and whose session was recorded is a
100
+ // resumable crash: it is relaunched on that saved session, never reaped to
101
+ // `dead` and never announced as a boot failure or a child death.
102
+ test('a crashed booted child is relaunched on its saved session, not reaped, and raises no false boot-failure alarm', async () => {
103
+ const originalLaunch = headlessBrokerHost.launch;
104
+ const launches = [];
105
+ headlessBrokerHost.launch = (nodeId) => {
106
+ launches.push(nodeId);
107
+ const handle = { pid: 424_242, exited: new Promise(() => { }) };
108
+ tf.fleet.register(nodeId, handle); // the real host contract: registration at the launch seam
109
+ return handle;
110
+ };
111
+ try {
112
+ createNode(node('P2', { kind: 'developer', lifecycle: 'resident' }));
113
+ createNode(node('C2', {
114
+ cwd: home, // a cwd that exists — reviveNode's preflight refuses a missing one
115
+ parent: 'P2',
116
+ kind: 'explore',
117
+ pi_pid: deadPid(), // engine crashed — its recorded pid is dead
118
+ pi_session_id: '019e8f00-booted-once', // it booted before dying
119
+ intent: null,
120
+ }));
121
+ spawnEdge('P2', 'C2');
122
+ markBusy('C2'); // died mid-generation
123
+ await superviseTick();
124
+ assert.deepEqual(launches, ['C2'], 'the tick recovers the crash through the policy table, exactly as the exit event would');
125
+ assert.equal(getNode('C2').status, 'active', 'crashed booted child is relaunched, NOT marked dead');
126
+ // surfaceChildDeath is gone and this is not a never-booted node, so the daemon
127
+ // fans NO push here — no false boot-failure / child-death alarm on a crash.
128
+ assert.equal(readInboxSince('P2').length, 0, 'no false boot-failure / child-death push on a crash (wake relocated to push/close)');
129
+ clearBusy('C2');
130
+ }
131
+ finally {
132
+ headlessBrokerHost.launch = originalLaunch;
133
+ tf.fleet.forget('C2');
134
+ }
123
135
  });
124
136
  // An unplaced active node is not daemon-managed; the daemon leaves it untouched
125
137
  // rather than treating the missing broker/session fields as a boot failure.
@@ -192,7 +192,7 @@ describe('terminal ticket delivery', () => {
192
192
  await deliverTerminalResult(bridge);
193
193
  assert.equal(getNode(companion)?.final_report, finalReport, 'terminal replay preserves the first canonical final');
194
194
  assert.equal(getNode(companion)?.finalized_at, finalizedAt, 'terminal replay preserves the first finalization time');
195
- assert.throws(() => reviveNode(companion, { resume: true }), /finalization latch/);
195
+ assert.throws(() => reviveNode(companion, { resume: true, capacity: 'freeze' }), /finalization latch/);
196
196
  const { bridge: dismissedBridge } = bridgeUnder();
197
197
  const dismissedDir = submitBridgePage(dismissedBridge);
198
198
  const dismissedCompanion = spawnNode({ kind: 'general', parent: dismissedBridge, cwd: home, name: 'dismissed feedback companion', lifecycle: 'resident' }).node_id;
@@ -14,12 +14,11 @@
14
14
  // (pid==null, session==null) as a never-booted broker and boot-failed it —
15
15
  // killing the human prompt before the person could ever answer.
16
16
  //
17
- // THE FIX under exit-event authority lives in TWO filters, both driven here:
18
- // superviseTick drops kind:'human' rows from every row pass, and the startup
19
- // recovery sweep skips them before classification — the sweep treats every
20
- // swept row as known-dead, so an unfiltered human row (session null, no fork
21
- // claim) would land in policy row 8 (boot failure), the modern re-incarnation
22
- // of the exact bug.
17
+ // THE FIX under exit-event authority is superviseTick's one filter, driven
18
+ // here: it drops kind:'human' rows before every row pass, including the
19
+ // node-lifecycle tick that classifies a row with no live broker as known-dead.
20
+ // An unfiltered human row (session null, no fork claim) would land in policy
21
+ // row 8 (boot failure), the modern re-incarnation of the exact bug.
23
22
  import { test, before, after, beforeEach } from 'node:test';
24
23
  import assert from 'node:assert/strict';
25
24
  import { mkdtempSync, rmSync } from 'node:fs';
@@ -28,7 +27,7 @@ import { join } from 'node:path';
28
27
  import { createNode, getNode, subscribe } from '../canvas/canvas.js';
29
28
  import { closeDb } from '../canvas/db.js';
30
29
  import { readInboxSince } from '../feed/inbox.js';
31
- import { superviseTick, runStartupRecoverySweep } from '../../daemon/crtrd.js';
30
+ import { superviseTick } from '../../daemon/crtrd.js';
32
31
  import { bindTestFleet } from './helpers/fleet.js';
33
32
  import { resetFleetForTests } from '../runtime/fleet.js';
34
33
  let home;
@@ -49,7 +48,7 @@ function node(id, over = {}) {
49
48
  before(() => {
50
49
  home = mkdtempSync(join(tmpdir(), 'crtr-human-not-supervised-'));
51
50
  process.env['CRTR_HOME'] = home;
52
- tf = bindTestFleet(); // superviseTick + the sweep require a bound fleet (D-2)
51
+ tf = bindTestFleet(); // superviseTick requires a bound fleet (D-2)
53
52
  });
54
53
  beforeEach(() => {
55
54
  closeDb();
@@ -79,16 +78,12 @@ test('a kind:"human" bridge row is NEVER boot-failed by the daemon (it has no br
79
78
  // the tick leave IT alone — it is only here to catch a push.
80
79
  createNode(node('ASKER', { status: 'idle', intent: 'idle-release', pi_session_id: 'booted' }));
81
80
  subscribe('ASKER', 'HUMAN', true);
82
- // Two well-spaced ticks: under exit-event authority the tick runs no liveness
83
- // pass at all, and its row passes (controller-wait settle, pass-2 inbox wake)
84
- // must drop the human row before reading it.
81
+ // Two well-spaced ticks: the row passes (controller-wait settle, inbox wake,
82
+ // and the node-lifecycle classification of a row with no live broker) must
83
+ // all drop the human row before reading it.
85
84
  const t0 = Date.now();
86
85
  await superviseTick(t0);
87
86
  await superviseTick(t0 + 10_000_000);
88
- // The startup recovery sweep is the sharper threat: it classifies every swept
89
- // active|idle row as known-dead, so only its kind:'human' filter keeps the
90
- // bridge row out of policy row 8 (boot failure).
91
- await runStartupRecoverySweep(tf.fleet);
92
87
  assert.equal(getNode('HUMAN').status, 'active', 'human bridge row stays alive — never crash-reaped as a dead broker');
93
88
  assert.equal(readInboxSince('ASKER').length, 0, 'no surfaceBootFailure urgent push — the asker is never told its human prompt "never started"');
94
89
  });
@@ -226,7 +226,7 @@ test('reviveNode no-ops when the node has a live fleet entry — double-revive g
226
226
  });
227
227
  h.fleet.register(M, { pid: 424242, exited: new Promise(() => { }) });
228
228
  const before = h.node(M).cycles ?? 0;
229
- const res = reviveNode(M, { resume: true });
229
+ const res = reviveNode(M, { resume: true, capacity: 'freeze' });
230
230
  assert.equal(res.resumed, false, 'guard path does not re-resume');
231
231
  assert.equal(h.node(M).cycles ?? 0, before, 'cycle counter NOT bumped (guard returned early)');
232
232
  }
@@ -254,7 +254,7 @@ test('reviveNode PROCEEDS when the node has NO fleet entry — cycle counter bum
254
254
  pi_session_file: sessionFile,
255
255
  });
256
256
  assert.equal(h.node(M).cycles ?? 0, 0, 'fabricated at cycle 0 (no revive yet)');
257
- reviveNode(M, { resume: true });
257
+ reviveNode(M, { resume: true, capacity: 'freeze' });
258
258
  const after = h.node(M);
259
259
  assert.equal(after.cycles, 1, 'a real (non-guard) revive bumped the cycle counter to 1');
260
260
  assert.notEqual(after.pi_pid, stalePid, 'revive records the fresh broker pid immediately, not the stale dead pid');
@@ -298,11 +298,11 @@ test('a recovery relaunch keeps the armed deadline; a wake revive consumes it',
298
298
  try {
299
299
  const recovered = h.fabricateBrokerNode({ status: 'active', intent: null, pi_pid: deadPid(), pi_session_id: 'uuid-1' });
300
300
  armDeadline(recovered, 'cron-recovery');
301
- reviveNode(recovered, { resume: true, recovery: true });
301
+ reviveNode(recovered, { resume: true, recovery: true, capacity: 'freeze' });
302
302
  assert.equal(hasPendingCancelOnWakeCron(recovered), true, 'the daemon replacing a dead broker is not a wake — the deadline survives so the wait can still settle');
303
303
  const woken = h.fabricateBrokerNode({ status: 'active', intent: null, pi_pid: deadPid(), pi_session_id: 'uuid-2' });
304
304
  armDeadline(woken, 'cron-wake');
305
- reviveNode(woken, { resume: true });
305
+ reviveNode(woken, { resume: true, capacity: 'freeze' });
306
306
  assert.equal(hasPendingCancelOnWakeCron(woken), false, 'a wake revive won the race and cancels the deadline');
307
307
  }
308
308
  finally {
@@ -327,7 +327,7 @@ test('reviveNode fresh-fallback clears the stale session identity (Major-3: no p
327
327
  pi_session_id: 'uuid-stale',
328
328
  pi_session_file: missingFile,
329
329
  });
330
- const res = reviveNode(M, { resume: true });
330
+ const res = reviveNode(M, { resume: true, capacity: 'freeze' });
331
331
  assert.equal(res.resumed, false, 'a missing .jsonl forces a fresh (non-resuming) launch');
332
332
  const after = h.node(M);
333
333
  assert.equal(after.pi_session_id, null, 'stale pi_session_id cleared before the fresh launch');
@@ -373,7 +373,7 @@ test('unbound forks replay one durable source file for node-id, path, and UUID o
373
373
  deleteNode('ordinary-fork-source');
374
374
  for (let index = 0; index < operands.length; index++) {
375
375
  const destination = `ordinary-fork-${index}`;
376
- reviveNode(destination, { resume: index !== 1 });
376
+ reviveNode(destination, { resume: index !== 1, capacity: 'freeze' });
377
377
  const argv = launches[index];
378
378
  const fork = argv.indexOf('--fork');
379
379
  assert.equal(argv[fork + 1], forkFile, `${operands[index]} retries from the durable resolved file`);
@@ -406,7 +406,7 @@ test('reviveNode throws a typed cwd_missing error and does NOT crash the node wh
406
406
  pi_pid: deadPid(),
407
407
  });
408
408
  updateNode(M, { cwd: missingCwd });
409
- assert.throws(() => reviveNode(M, { resume: true }), (e) => e instanceof InputError && e.payload.error === 'cwd_missing' && e.message.includes(missingCwd), 'reviveNode must throw a typed InputError naming the missing cwd path');
409
+ assert.throws(() => reviveNode(M, { resume: true, capacity: 'freeze' }), (e) => e instanceof InputError && e.payload.error === 'cwd_missing' && e.message.includes(missingCwd), 'reviveNode must throw a typed InputError naming the missing cwd path');
410
410
  const after = h.node(M);
411
411
  assert.equal(after.status, 'active', 'a cwd-missing preflight refusal must NOT crash the node (fixable in place, not a dead engine)');
412
412
  }
@@ -429,7 +429,7 @@ test('reviveNode crashes and clears pid when launch/preflight throws', async ()
429
429
  pi_session_file: sessionFile,
430
430
  });
431
431
  headlessBrokerHost.launch = (_nodeId, _inv, _opts) => { throw new Error('boom'); };
432
- assert.throws(() => reviveNode(M, { resume: true }), /refusing to revive/i);
432
+ assert.throws(() => reviveNode(M, { resume: true, capacity: 'freeze' }), /refusing to revive/i);
433
433
  const after = h.node(M);
434
434
  assert.equal(after.status, 'dead', 'failed revive marks the node dead');
435
435
  assert.equal(after.pi_pid, null, 'failed revive clears the stale pid');
@@ -462,7 +462,7 @@ test('reviveNode fans a doctrine wake to subscribers when launch/preflight throw
462
462
  // was fabricated directly, not spawned through the CLI.
463
463
  subscribe(root, M, true);
464
464
  headlessBrokerHost.launch = (_nodeId, _inv, _opts) => { throw new Error('boom'); };
465
- assert.throws(() => reviveNode(M, { resume: true }), /refusing to revive/i);
465
+ assert.throws(() => reviveNode(M, { resume: true, capacity: 'freeze' }), /refusing to revive/i);
466
466
  assert.equal(h.node(M).status, 'dead', 'failed revive marks the node dead');
467
467
  const entries = readInboxSince(root, undefined);
468
468
  const notice = entries.find((e) => e.data?.['reason'] === 'child-crashed' && e.data?.['child'] === M);
@@ -497,19 +497,19 @@ test('an unstarted node relaunches as a birth, not a "revived fresh" kickoff', (
497
497
  const missing = join(home, 'sessions', 'never-written.jsonl');
498
498
  // (1) Bare interactive node — nothing on disk anywhere: launch promptless.
499
499
  createNode(node('bare-unstarted', { cwd: home, pi_session_id: 'uuid-a', pi_session_file: missing }));
500
- reviveNode('bare-unstarted', { resume: true });
500
+ reviveNode('bare-unstarted', { resume: true, capacity: 'freeze' });
501
501
  const bare = launches[0];
502
502
  assert.equal(bare.some((a) => isReviveKickoff(a)), false, 'an unstarted node is never told it lost context it never had');
503
503
  assert.deepEqual(bare, buildPiArgv(getNode('bare-unstarted'), {}).argv, 'a promptless boot — the argv carries no trailing prompt positional at all');
504
504
  // (2) Spawned with a mandate whose first turn never persisted: replay it.
505
505
  createNode(node('mandate-unstarted', { cwd: home, pi_session_id: 'uuid-b', pi_session_file: missing }));
506
506
  writeGoal('mandate-unstarted', 'do the thing');
507
- reviveNode('mandate-unstarted', { resume: true });
507
+ reviveNode('mandate-unstarted', { resume: true, capacity: 'freeze' });
508
508
  assert.equal(launches[1].at(-1), 'do the thing', 'a birth replays the spawn mandate');
509
509
  // (3) Real durable progress on disk → still a genuine fresh revive.
510
510
  createNode(node('progressed', { cwd: home, pi_session_id: 'uuid-c', pi_session_file: missing }));
511
511
  seedRoadmap('progressed', { goal: 'ship it' });
512
- reviveNode('progressed', { resume: true });
512
+ reviveNode('progressed', { resume: true, capacity: 'freeze' });
513
513
  assert.equal(isReviveKickoff(launches[2].at(-1)), true, 'a node with a roadmap still rebuilds from the kickoff');
514
514
  }
515
515
  finally {
@@ -15,6 +15,8 @@ import { spawnNode } from '../../runtime/nodes.js';
15
15
  import { readGoal } from '../../runtime/kickoff.js';
16
16
  import { spawnChild, resolveSpawner } from '../../runtime/spawn.js';
17
17
  import { headlessBrokerHost } from '../../runtime/host.js';
18
+ import { resetFleetForTests } from '../../runtime/fleet.js';
19
+ import { bindTestFleet } from '../helpers/fleet.js';
18
20
  import { recordFault } from '../../runtime/fault.js';
19
21
  import { piSessionsRoot } from '../../runtime/pi-vendored.js';
20
22
  import { emptyContextExposureState, exposureTarget, loadContextExposureState, registerExposure, saveContextExposureState, } from '../../substrate/injected-store.js';
@@ -40,6 +42,9 @@ before(() => {
40
42
  process.env['CRTR_HOME'] = home;
41
43
  previousPidfile = process.env['CRTR_PIDFILE'];
42
44
  process.env['CRTR_PIDFILE'] = join(home, 'crtrd.pid');
45
+ // Every launcher claims a slot from the ledger before it reaches the host, so
46
+ // a spawn in-process needs a bound fleet (D-2).
47
+ bindTestFleet();
43
48
  });
44
49
  beforeEach(() => {
45
50
  closeDb();
@@ -52,6 +57,7 @@ beforeEach(() => {
52
57
  });
53
58
  after(() => {
54
59
  closeDb();
60
+ resetFleetForTests();
55
61
  rmSync(home, { recursive: true, force: true });
56
62
  if (previousPidfile === undefined)
57
63
  delete process.env['CRTR_PIDFILE'];
@@ -9,6 +9,9 @@ import { tmpdir } from 'node:os';
9
9
  import { join } from 'node:path';
10
10
  import { gitSync } from '../../git.js';
11
11
  import { createManagedWorktree, closeManagedWorktree } from '../../worktree.js';
12
+ import { createApiServer } from '../../../daemon/api/server.js';
13
+ import { apiSocketPath } from '../../canvas/paths.js';
14
+ import { CrtrClient } from '../../../api/index.js';
12
15
  import { createNode, getNode } from '../../canvas/canvas.js';
13
16
  import { closeDb } from '../../canvas/db.js';
14
17
  let home;
@@ -193,6 +196,35 @@ test('autoDropCleanManagedWorktree refuses a worktree with real uncommitted chan
193
196
  assert.equal(result, null, 'real uncommitted changes must never be silently dropped');
194
197
  assert.equal(getNode(nodeId)?.managed_worktree?.state, 'open');
195
198
  });
199
+ test('abandon preserves committed and dirty work, records its retained tip, and releases finalization', async () => {
200
+ const nodeId = 'wt-abandon';
201
+ const wt = createManagedWorktree(sourceDir, nodeId);
202
+ writeFileSync(join(wt.path, 'committed.txt'), 'committed work\n');
203
+ run(['add', '.'], wt.path);
204
+ run(['-c', 'user.email=test@example.com', '-c', 'user.name=Test', 'commit', '-m', 'real work'], wt.path);
205
+ writeFileSync(join(wt.path, 'dirty.txt'), 'dirty work\n');
206
+ createNode(node(nodeId, { cwd: wt.path, status: 'done', managed_worktree: wt }));
207
+ const server = createApiServer();
208
+ try {
209
+ await new Promise((resolve) => setTimeout(resolve, 25));
210
+ const client = new CrtrClient({ socketPath: apiSocketPath(), autostart: false });
211
+ const result = await client.abandonWorktree(nodeId, { by: 'test-invoker' });
212
+ const meta = getNode(nodeId);
213
+ assert.equal(gitSync(['rev-parse', wt.branch], sourceDir).stdout.trim(), result.tip_sha, 'the retained branch names the recorded tip');
214
+ assert.equal(gitSync(['show', `${result.tip_sha}:committed.txt`], sourceDir).stdout, 'committed work\n', 'the pre-existing commit remains reachable');
215
+ assert.equal(gitSync(['show', `${result.tip_sha}:dirty.txt`], sourceDir).stdout, 'dirty work\n', 'the WIP commit preserves dirty work');
216
+ assert.equal(gitSync(['show', '-s', '--format=%an <%ae>%n%s%n%(trailers:only)', result.tip_sha], sourceDir).stdout, `crtr-abandon <crtr-abandon@localhost>\ncrtr-abandon\nCrtr-Abandon: ${nodeId} ${meta?.managed_worktree?.abandoned?.at}\n\n`, 'the WIP commit has the reserved author, subject sentinel, and durable trailer');
217
+ assert.equal(existsSync(wt.path), false, 'the checkout is removed');
218
+ assert.equal(meta?.managed_worktree?.state, 'abandoned', 'the node records the abandoned disposition');
219
+ assert.deepEqual(meta?.managed_worktree?.abandoned, { branch: wt.branch, tip_sha: result.tip_sha, at: meta?.managed_worktree?.abandoned?.at, by: 'test-invoker' }, 'the metadata names the retained tip and invoker');
220
+ assert.equal(meta?.cwd, wt.repo_root, 'the node cwd repoints to the repository root');
221
+ await client.pushReport(nodeId, { tier: 'final', body: 'abandoned work preserved' });
222
+ assert.ok(getNode(nodeId)?.final_report, 'push final proceeds after abandon releases the worktree gate');
223
+ }
224
+ finally {
225
+ await server.close();
226
+ }
227
+ });
196
228
  // The actual #327 positive case: a branch with REAL commits whose tip is
197
229
  // already contained in a base that moved forward past this worktree's
198
230
  // original base_sha (a manager landed the same change another way, without
@@ -0,0 +1,61 @@
1
+ import { after, before, beforeEach, test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { mkdtempSync, rmSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import { createNode, getNode } from '../canvas/canvas.js';
7
+ import { closeDb } from '../canvas/db.js';
8
+ import { transition } from '../runtime/lifecycle.js';
9
+ let home;
10
+ function seed(nodeId) {
11
+ const node = {
12
+ node_id: nodeId,
13
+ name: nodeId,
14
+ created: new Date().toISOString(),
15
+ cwd: '/tmp/work',
16
+ kind: 'general',
17
+ mode: 'base',
18
+ lifecycle: 'terminal',
19
+ status: 'active',
20
+ };
21
+ createNode(node);
22
+ }
23
+ function reason(nodeId) {
24
+ return getNode(nodeId)?.terminal_reason;
25
+ }
26
+ before(() => {
27
+ home = mkdtempSync(join(tmpdir(), 'crtr-lifecycle-reasons-'));
28
+ process.env['CRTR_HOME'] = home;
29
+ });
30
+ beforeEach(() => {
31
+ closeDb();
32
+ rmSync(home, { recursive: true, force: true });
33
+ });
34
+ after(() => {
35
+ closeDb();
36
+ rmSync(home, { recursive: true, force: true });
37
+ delete process.env['CRTR_HOME'];
38
+ });
39
+ test('transition records terminal reasons and clears them on terminal-to-live transitions', () => {
40
+ for (const [event, expected] of [
41
+ ['finish', 'finished'],
42
+ ['park', 'parked'],
43
+ ['cancel', 'stranded'],
44
+ ['crash', 'crashed'],
45
+ ['yield', null],
46
+ ['release', null],
47
+ ]) {
48
+ const nodeId = `event-${event}`;
49
+ seed(nodeId);
50
+ transition(nodeId, event);
51
+ assert.equal(reason(nodeId), expected, `${event} records its terminal reason`);
52
+ }
53
+ seed('refresh-launch');
54
+ transition('refresh-launch', 'cancel');
55
+ transition('refresh-launch', 'refresh-launch');
56
+ assert.equal(reason('refresh-launch'), null, 'refresh-launch clears a terminal reason');
57
+ seed('revive');
58
+ transition('revive', 'finish');
59
+ transition('revive', 'revive');
60
+ assert.equal(reason('revive'), null, 'revive clears a terminal reason');
61
+ });
@@ -215,6 +215,43 @@ test('a fresh db has the four additive runtime columns', () => {
215
215
  assert.equal(rt['window'], null);
216
216
  assert.equal(rt['tmux_session'], null);
217
217
  });
218
+ test('node lifecycle substrate migration adds six columns and backfills terminal reasons', () => {
219
+ ensureHome();
220
+ const raw = new DatabaseSync(canvasDbPath());
221
+ migrateToVersion(raw, 34);
222
+ const lifecycleColumns = [
223
+ 'frozen_at',
224
+ 'respawn_failures',
225
+ 'respawn_not_before',
226
+ 'launched_at',
227
+ 'last_settled_at',
228
+ 'terminal_reason',
229
+ ];
230
+ for (const column of lifecycleColumns)
231
+ assert.ok(!nodeColumns(raw).includes(column));
232
+ const insert = raw.prepare('INSERT INTO nodes (node_id, name, kind, status, intent, final_report, cwd, created) VALUES (?, ?, ?, ?, ?, ?, ?, ?)');
233
+ const created = '2020-01-01T00:00:00Z';
234
+ insert.run('finalized', 'finalized', 'general', 'done', 'done', 'final.md', '/tmp', created);
235
+ insert.run('parked', 'parked', 'general', 'done', 'parked', null, '/tmp', created);
236
+ insert.run('finished', 'finished', 'general', 'done', 'done', null, '/tmp', created);
237
+ insert.run('stranded', 'stranded', 'general', 'canceled', null, null, '/tmp', created);
238
+ insert.run('crashed', 'crashed', 'general', 'dead', null, null, '/tmp', created);
239
+ insert.run('active', 'active', 'general', 'active', null, null, '/tmp', created);
240
+ raw.close();
241
+ const db = openDb();
242
+ assert.equal(userVersion(db), MIGRATIONS.length);
243
+ for (const column of lifecycleColumns)
244
+ assert.ok(nodeColumns(db).includes(column));
245
+ const reasons = Object.fromEntries(db.prepare('SELECT node_id, terminal_reason FROM nodes ORDER BY node_id').all().map((row) => [row.node_id, row.terminal_reason]));
246
+ assert.deepEqual(reasons, {
247
+ active: null,
248
+ crashed: 'crashed',
249
+ finalized: 'finalized',
250
+ finished: 'finished',
251
+ parked: 'parked',
252
+ stranded: 'stranded',
253
+ });
254
+ });
218
255
  // v4: edges FK ON DELETE CASCADE (the one irreversible rebuild)
219
256
  /** Build a hand-rolled db at exactly v3 (baseline tables + the four runtime
220
257
  * columns, edges with NO foreign key, `user_version = 3`) — the shape the live
@@ -337,7 +374,7 @@ test('v16 repairs a v15 database missing canonical finalization columns', () =>
337
374
  ensureHome();
338
375
  const db = new DatabaseSync(join(home, 'finalization.db'));
339
376
  try {
340
- MIGRATIONS[0](db);
377
+ migrateToVersion(db, 13);
341
378
  db.exec('PRAGMA user_version = 15');
342
379
  migrate(db);
343
380
  const columns = nodeColumns(db);
@@ -179,6 +179,6 @@ test('reviveNode refuses to revive a node that became finalized after the caller
179
179
  process.env['CRTR_NODE_ID'] = id;
180
180
  await pushFinal(id, 'concurrent final');
181
181
  assert.equal(getNode(id)?.status, 'done');
182
- assert.throws(() => reviveNode(id, { resume: true }), /finaliz/i, 'reviveNode\'s own belt-and-suspenders check refuses a now-latched target');
182
+ assert.throws(() => reviveNode(id, { resume: true, capacity: 'freeze' }), /finaliz/i, 'reviveNode\'s own belt-and-suspenders check refuses a now-latched target');
183
183
  assert.equal(getNode(id)?.status, 'done', 'the node stays done, never silently revived into an unsatisfiable mandate');
184
184
  });
@@ -0,0 +1 @@
1
+ export {};