@north-light/crouter 0.3.249 → 0.3.251

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 (175) hide show
  1. package/README.md +1 -1
  2. package/dist/api/__tests__/integration/client.test.js +28 -7
  3. package/dist/api/client.d.ts +1 -1
  4. package/dist/api/client.js +2 -2
  5. package/dist/api/dto/lifecycle.d.ts +3 -0
  6. package/dist/api/dto/messages.d.ts +4 -0
  7. package/dist/api/dto/nodes.d.ts +4 -0
  8. package/dist/api/dto/reports.d.ts +7 -2
  9. package/dist/builtin-memory/00-runtime-base/01-escalation.md +1 -1
  10. package/dist/builtin-memory/05-kinds/review/01-orchestrator.md +3 -0
  11. package/dist/builtin-memory/internal/nodes-and-canvas.md +1 -1
  12. package/dist/builtin-pi-packages/pi-crtr-extensions/README.md +0 -1
  13. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.js +4 -3
  14. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +4 -3
  15. package/dist/clients/attach/session/chrome-refresh.d.ts +3 -0
  16. package/dist/clients/attach/session/chrome-refresh.js +1 -0
  17. package/dist/clients/attach/viewer.js +532 -532
  18. package/dist/clients/inbox/__tests__/integration/stale-row-activation.test.d.ts +1 -0
  19. package/dist/clients/inbox/__tests__/integration/stale-row-activation.test.js +53 -0
  20. package/dist/clients/inbox/controller.js +32 -5
  21. package/dist/clients/inbox/tui/ansi.d.ts +3 -1
  22. package/dist/clients/inbox/tui/ansi.js +14 -10
  23. package/dist/commands/__tests__/api-client-cold-start-diagnostic.test.js +7 -7
  24. package/dist/commands/api-client.d.ts +2 -2
  25. package/dist/commands/api-client.js +5 -5
  26. package/dist/commands/canvas-browse.js +1 -1
  27. package/dist/commands/node/create.js +3 -1
  28. package/dist/commands/node/inspect.js +1 -0
  29. package/dist/commands/node/lifecycle.js +1 -1
  30. package/dist/commands/node/message.js +6 -4
  31. package/dist/commands/node/wait.js +1 -1
  32. package/dist/commands/push.js +36 -37
  33. package/dist/commands/sys/daemon.js +21 -8
  34. package/dist/commands/sys/setup-core.js +2 -2
  35. package/dist/core/__tests__/broker-stream-watchdog-floor.test.d.ts +1 -0
  36. package/dist/core/__tests__/broker-stream-watchdog-floor.test.js +84 -0
  37. package/dist/core/__tests__/broker-turn-admission.test.d.ts +1 -0
  38. package/dist/core/__tests__/broker-turn-admission.test.js +44 -0
  39. package/dist/core/__tests__/canvas-inbox-watcher-hold.test.js +100 -1
  40. package/dist/core/__tests__/child-death-wake.test.js +11 -7
  41. package/dist/core/__tests__/collectible.test.d.ts +1 -0
  42. package/dist/core/__tests__/collectible.test.js +88 -0
  43. package/dist/core/__tests__/context-intro.test.js +4 -41
  44. package/dist/core/__tests__/daemon-boot.test.js +30 -14
  45. package/dist/core/__tests__/daemon-wedge.test.js +25 -19
  46. package/dist/core/__tests__/fixtures/fake-engine.js +20 -2
  47. package/dist/core/__tests__/human-deliver.test.js +5 -5
  48. package/dist/core/__tests__/integration/deferred-no-wake.test.js +65 -4
  49. package/dist/core/__tests__/integration/human-deliver-e2e.test.js +3 -3
  50. package/dist/core/__tests__/integration/revive.test.js +48 -0
  51. package/dist/core/__tests__/integration/spawn-root.test.js +36 -1
  52. package/dist/core/__tests__/integration/worktree-land.test.js +82 -3
  53. package/dist/core/__tests__/integration/worktree-reap.test.js +44 -6
  54. package/dist/core/__tests__/migration.test.js +30 -0
  55. package/dist/core/__tests__/passive-subscription.test.js +39 -1
  56. package/dist/core/__tests__/prune.test.js +12 -3
  57. package/dist/core/__tests__/relaunch-root.test.js +1 -1
  58. package/dist/core/__tests__/respawn-throttle.test.js +16 -146
  59. package/dist/core/__tests__/seam/broker-crash-teardown.test.js +23 -11
  60. package/dist/core/__tests__/seam/broker-provider-retry.test.js +81 -2
  61. package/dist/core/__tests__/seam/dormancy-release.test.js +1 -1
  62. package/dist/core/__tests__/seam/held-deferred-human-prompt.test.d.ts +1 -0
  63. package/dist/core/__tests__/seam/held-deferred-human-prompt.test.js +63 -0
  64. package/dist/core/__tests__/seam/self-close-teardown.test.d.ts +1 -0
  65. package/dist/core/__tests__/seam/self-close-teardown.test.js +49 -0
  66. package/dist/core/__tests__/seam/yield-refresh-transaction.test.js +18 -10
  67. package/dist/core/canvas/canvas.d.ts +9 -15
  68. package/dist/core/canvas/canvas.js +41 -47
  69. package/dist/core/canvas/crons.d.ts +5 -8
  70. package/dist/core/canvas/crons.js +5 -8
  71. package/dist/core/canvas/history.d.ts +5 -0
  72. package/dist/core/canvas/history.js +12 -1
  73. package/dist/core/canvas/migrations.js +58 -1
  74. package/dist/core/canvas/pid.d.ts +1 -1
  75. package/dist/core/canvas/pid.js +5 -2
  76. package/dist/core/canvas/types.d.ts +4 -0
  77. package/dist/core/feed/feed.d.ts +4 -0
  78. package/dist/core/feed/feed.js +13 -8
  79. package/dist/core/feed/inbox.d.ts +2 -1
  80. package/dist/core/feed/inbox.js +22 -3
  81. package/dist/core/git.d.ts +3 -0
  82. package/dist/core/git.js +3 -1
  83. package/dist/core/human/feedback-companion.js +1 -0
  84. package/dist/core/preview-registry.js +0 -1
  85. package/dist/core/review/companion.js +1 -0
  86. package/dist/core/review/realize.js +1 -0
  87. package/dist/core/runtime/broker/auth-reload.d.ts +2 -0
  88. package/dist/core/runtime/broker/auth-reload.js +9 -3
  89. package/dist/core/runtime/broker/engine-drive.d.ts +2 -0
  90. package/dist/core/runtime/broker/engine-drive.js +39 -23
  91. package/dist/core/runtime/broker/event-projection.d.ts +4 -0
  92. package/dist/core/runtime/broker/event-projection.js +43 -23
  93. package/dist/core/runtime/broker/fault-retry.d.ts +9 -0
  94. package/dist/core/runtime/broker/fault-retry.js +94 -8
  95. package/dist/core/runtime/broker/frame-dispatch.d.ts +2 -0
  96. package/dist/core/runtime/broker/frame-dispatch.js +4 -1
  97. package/dist/core/runtime/broker/held-deferred-inbox.d.ts +9 -0
  98. package/dist/core/runtime/broker/held-deferred-inbox.js +8 -0
  99. package/dist/core/runtime/broker/inbox.js +4 -0
  100. package/dist/core/runtime/broker/rebind.js +18 -0
  101. package/dist/core/runtime/broker/turn-admission.d.ts +11 -0
  102. package/dist/core/runtime/broker/turn-admission.js +31 -0
  103. package/dist/core/runtime/broker/turn-ignition.d.ts +2 -0
  104. package/dist/core/runtime/broker/turn-ignition.js +25 -15
  105. package/dist/core/runtime/broker.js +21 -2
  106. package/dist/core/runtime/close.d.ts +11 -9
  107. package/dist/core/runtime/close.js +23 -19
  108. package/dist/core/runtime/fault.d.ts +21 -0
  109. package/dist/core/runtime/fault.js +129 -19
  110. package/dist/core/runtime/fleet.d.ts +0 -6
  111. package/dist/core/runtime/host.d.ts +7 -1
  112. package/dist/core/runtime/host.js +20 -2
  113. package/dist/core/runtime/nodes.d.ts +5 -1
  114. package/dist/core/runtime/nodes.js +24 -1
  115. package/dist/core/runtime/placement.d.ts +6 -6
  116. package/dist/core/runtime/placement.js +15 -8
  117. package/dist/core/runtime/reset.js +2 -2
  118. package/dist/core/runtime/revive.js +40 -4
  119. package/dist/core/runtime/spawn.d.ts +3 -0
  120. package/dist/core/runtime/spawn.js +1 -0
  121. package/dist/core/runtime/warm-pool.d.ts +4 -0
  122. package/dist/core/runtime/warm-pool.js +2 -0
  123. package/dist/core/worktree.d.ts +4 -3
  124. package/dist/core/worktree.js +73 -27
  125. package/dist/daemon/__tests__/integration/api-startup-readiness.test.d.ts +1 -0
  126. package/dist/daemon/__tests__/integration/api-startup-readiness.test.js +81 -0
  127. package/dist/daemon/api/__tests__/reopen-delivery.test.js +38 -0
  128. package/dist/daemon/api/__tests__/seam/api-server.test.js +1 -0
  129. package/dist/daemon/api/__tests__/seam/leaf-api-parity.test.js +13 -2
  130. package/dist/daemon/api/handlers/bash-jobs.js +2 -0
  131. package/dist/daemon/api/handlers/broker-ops.js +23 -2
  132. package/dist/daemon/api/handlers/human.js +1 -1
  133. package/dist/daemon/api/handlers/messages.js +11 -2
  134. package/dist/daemon/api/handlers/nodes.js +13 -1
  135. package/dist/daemon/api/handlers/reports.js +14 -6
  136. package/dist/daemon/api/map.js +1 -0
  137. package/dist/daemon/api/server.d.ts +9 -12
  138. package/dist/daemon/api/server.js +30 -19
  139. package/dist/daemon/companion-retire.js +3 -9
  140. package/dist/daemon/cron/sinks.js +7 -2
  141. package/dist/daemon/crtrd.js +4 -1
  142. package/dist/daemon/fleet.d.ts +2 -16
  143. package/dist/daemon/fleet.js +63 -150
  144. package/dist/daemon/human/finish.js +2 -0
  145. package/dist/daemon/manage.d.ts +11 -0
  146. package/dist/daemon/manage.js +32 -3
  147. package/dist/daemon/messaging/node-message.js +7 -1
  148. package/dist/daemon/reconcilers/bash-deadline.js +2 -0
  149. package/dist/daemon/reconcilers/broker-supervision.d.ts +1 -0
  150. package/dist/daemon/reconcilers/broker-supervision.js +22 -9
  151. package/dist/daemon/reconcilers/live-obligation.d.ts +16 -6
  152. package/dist/daemon/reconcilers/live-obligation.js +18 -6
  153. package/dist/daemon/reconcilers/node-lifecycle/respawn-policy.d.ts +15 -4
  154. package/dist/daemon/reconcilers/node-lifecycle/respawn-policy.js +34 -3
  155. package/dist/daemon/reconcilers/node-lifecycle/terminating.d.ts +4 -1
  156. package/dist/daemon/reconcilers/node-lifecycle/terminating.js +11 -2
  157. package/dist/daemon/reconcilers/node-lifecycle/tick.js +14 -7
  158. package/dist/daemon/reconcilers/storage-maintenance.d.ts +2 -2
  159. package/dist/daemon/reconcilers/storage-maintenance.js +3 -3
  160. package/dist/pi-extensions/__tests__/canvas-stophook-agentend.test.js +4 -4
  161. package/dist/pi-extensions/canvas-context-intro.d.ts +3 -34
  162. package/dist/pi-extensions/canvas-context-intro.js +4 -76
  163. package/dist/pi-extensions/canvas-inbox-watcher.js +105 -4
  164. package/dist/pi-extensions/canvas-review-boundary.d.ts +3 -24
  165. package/dist/pi-extensions/canvas-review-boundary.js +4 -29
  166. package/dist/pi-extensions/canvas-stophook.js +1 -6
  167. package/dist/shared/birth-announcement.d.ts +12 -0
  168. package/dist/shared/birth-announcement.js +25 -0
  169. package/dist/shared/generated-context.d.ts +8 -4
  170. package/dist/shared/generated-context.js +15 -9
  171. package/package.json +4 -4
  172. package/runtime.lock.json +2 -2
  173. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/statusline.ts +0 -254
  174. package/dist/pi-extensions/truncate.d.ts +0 -5
  175. package/dist/pi-extensions/truncate.js +0 -14
@@ -8,22 +8,25 @@
8
8
  // This subtree starts, checks, and stops the daemon process.
9
9
  import { defineLeaf, defineBranch } from '../../core/command.js';
10
10
  import { InputError } from '../../core/io.js';
11
- import { spawnDaemon, stopDaemonProcess, sweepStrayDaemons } from '../../daemon/manage.js';
11
+ import { isDaemonServing, spawnDaemon, stopDaemonProcess, sweepStrayDaemons } from '../../daemon/manage.js';
12
12
  import { isDaemonRunning, readPidfile, isPidAlive } from '../../daemon/pidfile.js';
13
13
  import { cliClient } from '../api-client.js';
14
14
  // daemon start
15
15
  const daemonStart = defineLeaf({
16
16
  name: 'start',
17
17
  description: 'start the daemon in the background',
18
- whenToUse: 'bringing the crtrd supervisor up for the first time in a session so node window-exits get handled (no-op if it is already running)',
18
+ whenToUse: 'bringing the crtrd supervisor up for the first time in a session so node window-exits get handled (no-op only when its Unix API socket already serves)',
19
19
  help: {
20
20
  name: 'sys daemon start',
21
- summary: 'start the crtrd supervisor daemon in the background (no-op if already running)',
21
+ summary: 'start the crtrd supervisor daemon in the background (no-op only if its Unix API socket serves)',
22
22
  params: [],
23
23
  output: [
24
24
  { name: 'started', type: 'boolean', required: true, constraint: 'True when a new daemon process was spawned; false when one was already running.' },
25
25
  { name: 'pid', type: 'number', required: false, constraint: 'PID of the newly spawned daemon.' },
26
26
  { name: 'existing_pid', type: 'number', required: false, constraint: 'PID of the already-running daemon (when started=false).' },
27
+ { name: 'running', type: 'boolean', required: false, constraint: 'True only when a live pidfile owner does not serve its Unix API socket.' },
28
+ { name: 'serving', type: 'boolean', required: false, constraint: 'False only when a live pidfile owner does not answer /healthz.' },
29
+ { name: 'next', type: 'string', required: false, constraint: 'Recovery instruction when running=true and serving=false.' },
27
30
  ],
28
31
  outputKind: 'object',
29
32
  effects: [
@@ -46,18 +49,28 @@ const daemonStatus = defineLeaf({
46
49
  summary: 'check whether the crtrd supervisor daemon is currently running',
47
50
  params: [],
48
51
  output: [
49
- { name: 'running', type: 'boolean', required: true, constraint: 'True when the daemon is alive.' },
52
+ { name: 'running', type: 'boolean', required: true, constraint: 'True when the daemon pidfile owner is alive.' },
53
+ { name: 'serving', type: 'boolean', required: false, constraint: 'True when the live daemon answers /healthz; false when it does not.' },
50
54
  { name: 'pid', type: 'number', required: false, constraint: 'PID of the running daemon.' },
55
+ { name: 'next', type: 'string', required: false, constraint: 'Recovery instruction when running=true and serving=false.' },
51
56
  ],
52
57
  outputKind: 'object',
53
- effects: ['Read-only: reads the pidfile and probes the pid.'],
58
+ effects: ['Read-only: reads the pidfile, probes the pid, and requests /healthz without autostart.'],
54
59
  },
55
60
  run: async (_input) => {
56
61
  const pid = readPidfile();
57
62
  const running = pid !== null && isPidAlive(pid);
58
- return running
59
- ? { running: true, pid }
60
- : { running: false };
63
+ if (!running)
64
+ return { running: false };
65
+ const serving = await isDaemonServing();
66
+ return serving
67
+ ? { running: true, serving: true, pid }
68
+ : {
69
+ running: true,
70
+ serving: false,
71
+ pid,
72
+ next: 'Run `crtr sys daemon stop` to release the non-serving daemon before starting it again.',
73
+ };
61
74
  },
62
75
  });
63
76
  // daemon restart
@@ -178,8 +178,8 @@ const PACKAGE_COPY = {
178
178
  },
179
179
  'crtr-extensions': {
180
180
  name: 'pi-crtr-extensions',
181
- shortDescription: 'crtr companion extensions — command palette, help gating, statusline, subscription/OAuth rotation, nested context.',
182
- longDescription: 'Bundles the extensions built for crtr: a crtr slash-command palette, crtr/crouter help gating, surfacing of Claude Code marketplace plugin commands, nested AGENTS.md/CLAUDE.md loading, a model/subscription/token statusline badge, OAuth login + rotation across multiple Claude subscriptions (provider-rotation), and system-prompt trimming. The OAuth/rotation piece is what lets crtr authenticate your Claude account.',
181
+ shortDescription: 'crtr companion extensions — command palette, help gating, subscription/OAuth rotation, nested context.',
182
+ longDescription: 'Bundles the extensions built for crtr: a crtr slash-command palette, crtr/crouter help gating, surfacing of Claude Code marketplace plugin commands, nested AGENTS.md/CLAUDE.md loading, OAuth login + rotation across multiple Claude subscriptions (provider-rotation), and system-prompt trimming. The OAuth/rotation piece is what lets crtr authenticate your Claude account.',
183
183
  },
184
184
  'oauth-adapter': {
185
185
  name: 'pi-claude-oauth-adapter',
@@ -0,0 +1,84 @@
1
+ import assert from 'node:assert/strict';
2
+ import { test } from 'node:test';
3
+ import { EventProjection } from '../runtime/broker/event-projection.js';
4
+ import { RebindDriver } from '../runtime/broker/rebind.js';
5
+ function generationFor(session) {
6
+ return {
7
+ id: 1,
8
+ session,
9
+ fallback: { blocked: false },
10
+ stagedProviderAgentEnd: null,
11
+ stagedWatchdogAbort: false,
12
+ stagedRefreshAbort: false,
13
+ stagedOverflowFailure: null,
14
+ executingTools: new Set(),
15
+ };
16
+ }
17
+ function projectionFor(session, generation) {
18
+ return new EventProjection({
19
+ registry: { broadcast: () => undefined },
20
+ faultRetry: { onEvent: () => undefined, clearTimer: () => undefined },
21
+ installedGeneration: () => generation,
22
+ currentSession: () => session,
23
+ notifyTurnAccepted: () => undefined,
24
+ emitStartupMilestone: () => undefined,
25
+ formatModelSpec: () => null,
26
+ refreshIntent: async () => false,
27
+ onRefreshIntentError: () => undefined,
28
+ });
29
+ }
30
+ test('agent_start arms the provider watchdog before another engine event arrives', (t) => {
31
+ t.mock.timers.enable({ apis: ['setTimeout'] });
32
+ let aborts = 0;
33
+ const session = { isStreaming: true, abort: async () => { aborts++; } };
34
+ const generation = generationFor(session);
35
+ const projection = projectionFor(session, generation);
36
+ projection.relayEvent({ type: 'agent_start' }, generation);
37
+ t.mock.timers.tick(5 * 60_000);
38
+ assert.equal(aborts, 1, 'the initial streaming event leaves a watchdog floor even if the engine falls silent immediately');
39
+ assert.equal(generation.stagedWatchdogAbort, true, 'the floor retains watchdog provenance for provider-fault recovery');
40
+ });
41
+ test('tool execution replaces the provider watchdog with the long ceiling', (t) => {
42
+ t.mock.timers.enable({ apis: ['setTimeout'] });
43
+ let aborts = 0;
44
+ const session = { isStreaming: true, abort: async () => { aborts++; } };
45
+ const generation = generationFor(session);
46
+ const projection = projectionFor(session, generation);
47
+ projection.relayEvent({ type: 'agent_start' }, generation);
48
+ projection.relayEvent({ type: 'tool_execution_start', toolCallId: 'stuck-tool' }, generation);
49
+ t.mock.timers.tick(5 * 60_000);
50
+ assert.equal(aborts, 0, 'a tool outlives the ordinary five-minute provider watchdog');
51
+ t.mock.timers.tick(15 * 60_000);
52
+ assert.equal(aborts, 1, 'a silent in-process tool cannot clear all watchdog coverage indefinitely');
53
+ });
54
+ test('rebind rearms the watchdog floor for a still-streaming replacement session', async () => {
55
+ const calls = [];
56
+ const projection = {
57
+ reset: () => calls.push('reset'),
58
+ rearmIfStreaming: (generation) => calls.push(`rearm:${generation.id}`),
59
+ };
60
+ const session = {
61
+ isStreaming: true,
62
+ systemPrompt: '',
63
+ bindExtensions: async () => undefined,
64
+ subscribe: () => () => undefined,
65
+ };
66
+ const driver = new RebindDriver({
67
+ nodeId: 'watchdog-floor-rebind',
68
+ cfg: {},
69
+ session,
70
+ services: {},
71
+ runtime: undefined,
72
+ uiContext: () => ({}),
73
+ registry: {},
74
+ toolGroups: { resetForSession: () => undefined },
75
+ faultRetry: () => ({ reset: () => undefined }),
76
+ projection: () => projection,
77
+ setActiveSession: () => undefined,
78
+ disposeAndExit: () => undefined,
79
+ onSessionRebind: () => undefined,
80
+ reWelcomeAll: () => undefined,
81
+ });
82
+ await driver.enqueueRebind(session, {});
83
+ assert.deepEqual(calls, ['reset', 'rearm:1'], 'a rebind that resets projection state immediately restores coverage for its live stream');
84
+ });
@@ -0,0 +1,44 @@
1
+ import assert from 'node:assert/strict';
2
+ import { test } from 'node:test';
3
+ import { createTurnAdmissionGate } from '../runtime/broker/turn-admission.js';
4
+ function deferred() {
5
+ let resolve;
6
+ const promise = new Promise((done) => {
7
+ resolve = done;
8
+ });
9
+ return { promise, resolve };
10
+ }
11
+ test('concurrent starts admit one turn and reject the loser without a spurious settlement', async () => {
12
+ const gate = createTurnAdmissionGate();
13
+ const preflight = deferred();
14
+ const turn = deferred();
15
+ const enteredPreflight = deferred();
16
+ let streaming = false;
17
+ let admittedTurns = 0;
18
+ let settlements = 0;
19
+ const prompt = async (release) => {
20
+ enteredPreflight.resolve();
21
+ await preflight.promise;
22
+ if (streaming) {
23
+ release();
24
+ throw new Error('Agent is already processing');
25
+ }
26
+ streaming = true;
27
+ admittedTurns++;
28
+ release();
29
+ await turn.promise;
30
+ streaming = false;
31
+ settlements++;
32
+ };
33
+ const winner = gate.admit(prompt);
34
+ await enteredPreflight.promise;
35
+ const loser = gate.admit(prompt);
36
+ preflight.resolve();
37
+ await assert.rejects(loser, /Agent is already processing/);
38
+ assert.equal(admittedTurns, 1, 'only the winner entered a turn');
39
+ assert.equal(settlements, 0, 'the rejected call did not settle the live turn');
40
+ assert.equal(streaming, true, 'the winner remains live after the loser rejects');
41
+ turn.resolve();
42
+ await winner;
43
+ assert.equal(settlements, 1, 'the admitted turn alone settled');
44
+ });
@@ -13,6 +13,9 @@ import registerCanvasInboxWatcher from '../../pi-extensions/canvas-inbox-watcher
13
13
  import { appendInbox, readCursor } from '../feed/inbox.js';
14
14
  import { drainBearings } from '../runtime/kickoff.js';
15
15
  import { appendSituationalContext } from '../runtime/situational-context.js';
16
+ import { createEngineDriver } from '../runtime/broker/engine-drive.js';
17
+ import { createTurnAdmissionGate } from '../runtime/broker/turn-admission.js';
18
+ import { heldDeferredInboxPromptJoin } from '../runtime/broker/held-deferred-inbox.js';
16
19
  import { createNode, getNode, recordPid, setIntent } from '../canvas/canvas.js';
17
20
  import { createProfile, pauseProfile, resumeProfile } from '../profiles/manifest.js';
18
21
  import { closeDb } from '../canvas/db.js';
@@ -105,7 +108,11 @@ function ensureWatcherNode() {
105
108
  cwd: '/tmp',
106
109
  kind: 'general',
107
110
  mode: 'base',
108
- lifecycle: 'terminal',
111
+ // Resident: these cases are about the watcher's own cursor/handoff/hold
112
+ // contract at each tier as sent. A terminal node's mail is raised to the
113
+ // steering tier on append (feed/inbox.ts), which would route every case
114
+ // here as a steer.
115
+ lifecycle: 'resident',
109
116
  status: 'active',
110
117
  });
111
118
  recordPid(nodeId, process.pid);
@@ -271,6 +278,98 @@ describe('canvas inbox watcher — hold + idle delivery', () => {
271
278
  assert.equal(pi.injected.length, 1);
272
279
  assert.equal(pi.injected[0].deliverAs, undefined, 'idle → sendUserMessage triggers a turn, no deliverAs');
273
280
  });
281
+ test('the broker prompt boundary prepends held deferred mail before the human body', async () => {
282
+ freshNode('node-deferred-engine-prompt');
283
+ const pi = makeFakePi();
284
+ disposers.push(registerWatcher(pi));
285
+ await wait(TICK_MS + 100);
286
+ const entry = appendInbox('node-deferred-engine-prompt', {
287
+ from: 'child-4',
288
+ tier: 'deferred',
289
+ kind: 'update',
290
+ label: 'held update',
291
+ });
292
+ await wait(SETTLE_MS);
293
+ const order = [];
294
+ const session = {
295
+ isStreaming: false,
296
+ sendCustomMessage: async ({ content }) => { order.push(content); },
297
+ prompt: async (text, options) => {
298
+ order.push(text);
299
+ options.preflightResult?.(true);
300
+ },
301
+ };
302
+ const drive = createEngineDriver({
303
+ registry: { sendFrame: () => undefined },
304
+ replies: {},
305
+ session: () => session,
306
+ dispatches: { track: (promise) => promise },
307
+ memoryRefs: { guidanceFor: () => null },
308
+ authReloadGate: { inFlight: () => undefined },
309
+ rebind: { installedGeneration: () => 0 },
310
+ projection: {},
311
+ toolGroups: {},
312
+ turnAdmission: createTurnAdmissionGate(),
313
+ });
314
+ await drive({}, { type: 'prompt', text: 'where are we?' });
315
+ assert.equal(order.length, 2, 'one held card and the one human prompt share the admitted operation');
316
+ assert.match(order[0], /held update/, 'the held inbox card is first');
317
+ assert.equal(order[1], 'where are we?', 'the human body follows the held card');
318
+ assert.equal(readCursor('node-deferred-engine-prompt'), undefined, 'the joined handoff waits for settlement');
319
+ await pi.fire('agent_start', { type: 'agent_start' }, { isIdle: () => false });
320
+ await settle(pi);
321
+ assert.equal(readCursor('node-deferred-engine-prompt'), entry.entry_id, 'agent_settled commits the broker-joined handoff');
322
+ const staleFollowUpEntry = appendInbox('node-deferred-engine-prompt', {
323
+ from: 'child-4',
324
+ tier: 'deferred',
325
+ kind: 'update',
326
+ label: 'held after a stale client snapshot',
327
+ });
328
+ await wait(SETTLE_MS);
329
+ const beforeFollowUp = order.length;
330
+ await drive({}, { type: 'follow_up', text: 'continue from there' });
331
+ assert.equal(order.length, beforeFollowUp + 2, 'an idle-routed follow_up shares the same prompt boundary');
332
+ assert.match(order.at(-2), /held after a stale client snapshot/, 'the held card still precedes the stale follow_up body');
333
+ assert.equal(order.at(-1), 'continue from there');
334
+ await pi.fire('agent_start', { type: 'agent_start' }, { isIdle: () => false });
335
+ await settle(pi);
336
+ assert.equal(readCursor('node-deferred-engine-prompt'), staleFollowUpEntry.entry_id, 'the m-C prompt settles its joined handoff too');
337
+ });
338
+ test('a held deferred batch is prepended to an admitted human prompt and replays if that prompt reloads before settlement', async () => {
339
+ freshNode('node-deferred-human-prompt');
340
+ const pi = makeFakePi();
341
+ disposers.push(registerWatcher(pi));
342
+ await wait(TICK_MS + 100);
343
+ const entry = appendInbox('node-deferred-human-prompt', {
344
+ from: 'child-4',
345
+ tier: 'deferred',
346
+ kind: 'update',
347
+ label: 'held update',
348
+ });
349
+ await wait(SETTLE_MS);
350
+ assert.equal(pi.injected.length, 0, 'deferred-only mail remains held while no turn starts');
351
+ const firstPromptPrefix = [];
352
+ await heldDeferredInboxPromptJoin()?.({
353
+ sendCustomMessage: async ({ content }) => { firstPromptPrefix.push(content); },
354
+ });
355
+ assert.equal(firstPromptPrefix.length, 1, 'the held card is sent before the human prompt starts');
356
+ assert.match(firstPromptPrefix[0], /held update/);
357
+ assert.equal(readCursor('node-deferred-human-prompt'), undefined, 'the pre-prompt handoff is not committed');
358
+ await shutdown(pi);
359
+ assert.equal(readCursor('node-deferred-human-prompt'), undefined, 'a reload before agent_settled leaves the held entry replayable');
360
+ const replacement = makeFakePi();
361
+ disposers.push(registerWatcher(replacement));
362
+ await wait(SETTLE_MS);
363
+ const replayedPrefix = [];
364
+ await heldDeferredInboxPromptJoin()?.({
365
+ sendCustomMessage: async ({ content }) => { replayedPrefix.push(content); },
366
+ });
367
+ assert.equal(replayedPrefix.length, 1, 'the replacement prompt receives the uncommitted held card again');
368
+ assert.match(replayedPrefix[0], /held update/);
369
+ await replacement.fire('agent_start', { type: 'agent_start' }, { isIdle: () => false });
370
+ await settle(replacement);
371
+ assert.equal(readCursor('node-deferred-human-prompt'), entry.entry_id, 'only agent_settled commits the prompt-joined handoff');
372
+ });
274
373
  // Pause makes the profile inert while its broker is still alive: the entry
275
374
  // must be RETAINED, not consumed. A critical entry is the sharpest case —
276
375
  // its route aborts the live turn, exactly the punch-through pause forbids.
@@ -48,9 +48,8 @@ function deadPid() {
48
48
  const GONE_PANE = '%999999';
49
49
  /** A booted child whose engine pid is DEAD and whose viewer pane is gone — the
50
50
  * shape a crashed/finished child leaves behind. `pi_session_id` set so it is
51
- * "booted"; `intent:null` so a delivered exit would classify as a crash
52
- * (policy row 6, strict resume) — but no exit is delivered here: the tick
53
- * alone must not touch it. */
51
+ * "booted"; `intent:null` selects policy row 6 (strict resume) when the
52
+ * authoritative tick observes the missing engine. */
54
53
  function deadEngineChild(id, over = {}) {
55
54
  createNode(node(id, {
56
55
  pane: GONE_PANE,
@@ -86,7 +85,7 @@ after(() => {
86
85
  delete process.env['CRTR_HOME'];
87
86
  resetFleetForTests();
88
87
  });
89
- test('exit-event authority: a dead-engine booted child is recovered, NOT reaped, and the tick fans NO wake to its dormant parent', async () => {
88
+ test('a dead-engine booted child enters durable backoff before recovery, is NOT reaped, and fans NO wake to its dormant parent', async () => {
90
89
  const originalLaunch = headlessBrokerHost.launch;
91
90
  const launches = [];
92
91
  headlessBrokerHost.launch = (nodeId) => {
@@ -100,9 +99,14 @@ test('exit-event authority: a dead-engine booted child is recovered, NOT reaped,
100
99
  deadEngineChild('CHILD', { cwd: home }); // a cwd that exists — reviveNode's preflight refuses a missing one
101
100
  subscribe('PARENT', 'CHILD', true); // the spawn-time spine edge
102
101
  await superviseTick();
103
- // A stale recorded pid is metadata, not a verdict: the row is relaunched on
104
- // its saved session, never reaped to dead/done/idle.
105
- assert.deepEqual(launches, ['CHILD'], 'the tick recovers the dead engine through the policy table');
102
+ const afterFirstTick = getNode('CHILD');
103
+ assert.deepEqual(launches, [], 'the first tick records the failed launch without relaunching');
104
+ assert.equal(afterFirstTick.respawn_failures, 1, 'the first tick records failure one durably');
105
+ assert.ok(afterFirstTick.respawn_not_before, 'the first tick records durable backoff');
106
+ await superviseTick(Date.parse(afterFirstTick.respawn_not_before));
107
+ // A stale recorded pid is metadata, not a verdict: once backoff matures,
108
+ // the row is relaunched on its saved session, never reaped to dead/done/idle.
109
+ assert.deepEqual(launches, ['CHILD'], 'the matured tick recovers the dead engine through the policy table');
106
110
  assert.equal(getNode('CHILD').status, 'active', 'CHILD is recovered, NOT reaped');
107
111
  assert.equal(readInboxSince('PARENT').length, 0, 'PARENT inbox EMPTY — no daemon liveness wake');
108
112
  }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,88 @@
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, getRow, isCollectible, setFrozen, transitionStatusIntent, } from '../canvas/canvas.js';
7
+ import { closeDb } from '../canvas/db.js';
8
+ let home;
9
+ function node(id, over = {}) {
10
+ return {
11
+ node_id: id,
12
+ name: id,
13
+ created: new Date().toISOString(),
14
+ cwd: '/tmp/work',
15
+ kind: 'general',
16
+ mode: 'base',
17
+ lifecycle: 'terminal',
18
+ status: 'active',
19
+ ...over,
20
+ };
21
+ }
22
+ function terminalRow(id, reason, status = 'done') {
23
+ createNode(node(id));
24
+ transitionStatusIntent(id, { status, terminal_reason: reason });
25
+ return getRow(id);
26
+ }
27
+ before(() => {
28
+ home = mkdtempSync(join(tmpdir(), 'crtr-collectible-'));
29
+ process.env['CRTR_HOME'] = home;
30
+ });
31
+ beforeEach(() => {
32
+ closeDb();
33
+ rmSync(home, { recursive: true, force: true });
34
+ });
35
+ after(() => {
36
+ closeDb();
37
+ rmSync(home, { recursive: true, force: true });
38
+ delete process.env['CRTR_HOME'];
39
+ });
40
+ test('isCollectible applies the terminal reason truth table and absolute retains', () => {
41
+ const terminalCases = [
42
+ ['finalized', 'done', true, true],
43
+ ['finished', 'done', true, true],
44
+ ['parked', 'done', true, true],
45
+ ['closed', 'dead', true, true],
46
+ ['retired', 'canceled', true, true],
47
+ ['crashed', 'dead', false, true],
48
+ ['stranded', 'canceled', false, true],
49
+ [null, 'done', false, true],
50
+ ];
51
+ for (const [reason, status, beforeTtl, pastTtl] of terminalCases) {
52
+ const row = terminalRow(`reason-${reason ?? 'null'}`, reason, status);
53
+ assert.equal(isCollectible(row), beforeTtl, `${reason ?? 'null'} before TTL`);
54
+ assert.equal(isCollectible(row, { pastTtl: true }), pastTtl, `${reason ?? 'null'} past TTL`);
55
+ }
56
+ const frozen = terminalRow('frozen', 'closed');
57
+ setFrozen(frozen.node_id, new Date().toISOString());
58
+ createNode(node('worktree', {
59
+ managed_worktree: {
60
+ state: 'open',
61
+ path: '/tmp/worktree',
62
+ branch: 'crtr/worktree',
63
+ repo_root: '/tmp/repo',
64
+ base_ref: 'main',
65
+ base_sha: 'base',
66
+ created: new Date().toISOString(),
67
+ },
68
+ }));
69
+ transitionStatusIntent('worktree', { status: 'done', terminal_reason: 'closed' });
70
+ const caller = terminalRow('caller', 'closed');
71
+ createNode(node('active'));
72
+ const nonTerminal = getRow('active');
73
+ const absoluteRows = [getRow(frozen.node_id), getRow('worktree'), caller, nonTerminal];
74
+ const previousNodeId = process.env['CRTR_NODE_ID'];
75
+ process.env['CRTR_NODE_ID'] = 'caller';
76
+ try {
77
+ for (const row of absoluteRows) {
78
+ assert.equal(isCollectible(row), false, `${row.node_id} before TTL`);
79
+ assert.equal(isCollectible(row, { pastTtl: true }), false, `${row.node_id} past TTL`);
80
+ }
81
+ }
82
+ finally {
83
+ if (previousNodeId === undefined)
84
+ delete process.env['CRTR_NODE_ID'];
85
+ else
86
+ process.env['CRTR_NODE_ID'] = previousNodeId;
87
+ }
88
+ });
@@ -2,8 +2,8 @@
2
2
  // 1. Situational context rides the bearings as a sibling block, and only
3
3
  // once something has been set.
4
4
  // 2. canvas-context-intro injects the block as its own session message at
5
- // session_start (before the first prompt), idempotent across resumes,
6
- // inert without a node id — and its renderer never overruns the terminal.
5
+ // session_start (before the first prompt), idempotent across resumes, and
6
+ // inert without a node id.
7
7
  //
8
8
  // Run: node --import tsx/esm --test src/core/__tests__/context-intro.test.ts
9
9
  import { test, before, beforeEach, after, afterEach } from 'node:test';
@@ -15,7 +15,7 @@ import { closeDb } from '../canvas/db.js';
15
15
  import { apiSocketPath } from '../canvas/paths.js';
16
16
  import { spawnNode } from '../runtime/nodes.js';
17
17
  import { appendSituationalContext, formatSituationalProse, situationalContextEnvelope, } from '../runtime/situational-context.js';
18
- import registerCanvasContextIntro, { renderContextMessage, CONTEXT_INTRO_CUSTOM_TYPE, } from '../../pi-extensions/canvas-context-intro.js';
18
+ import registerCanvasContextIntro, { CONTEXT_INTRO_CUSTOM_TYPE, } from '../../pi-extensions/canvas-context-intro.js';
19
19
  import { buildContextBearings as buildContextIntro } from '../runtime/bearings.js';
20
20
  import { createApiServer } from '../../daemon/api/server.js';
21
21
  let home;
@@ -61,18 +61,16 @@ function makeFakePi(entries = []) {
61
61
  return {
62
62
  sent: [],
63
63
  entries,
64
- renderers: {},
65
64
  on(e, h) { if (e === 'session_start')
66
65
  this.handler = h; },
67
66
  sendMessage(m) {
68
67
  this.sent.push(m);
69
68
  this.entries.push({ type: 'custom_message', ...m });
70
69
  },
71
- registerMessageRenderer(t, r) { this.renderers[t] = r; },
72
70
  };
73
71
  }
74
72
  function fakeCtx(pi) {
75
- return { sessionManager: { getEntries: () => pi.entries } };
73
+ return { sessionManager: { getBranch: () => pi.entries } };
76
74
  }
77
75
  test('session_start injects the block as the first message of an empty session', async () => {
78
76
  const meta = spawnNode({ kind: 'general', cwd: '/tmp/work', parent: null });
@@ -111,38 +109,3 @@ test('session_start is inert when CRTR_NODE_ID is absent', async () => {
111
109
  await pi.handler({ reason: 'startup' }, fakeCtx(pi));
112
110
  assert.equal(pi.sent.length, 0);
113
111
  });
114
- test('renderer registered for the customType; collapsed hides body, ctrl+o expand reveals it', () => {
115
- const pi = makeFakePi();
116
- registerCanvasContextIntro(pi);
117
- const renderer = pi.renderers[CONTEXT_INTRO_CUSTOM_TYPE];
118
- assert.ok(renderer, 'message renderer registered for crtr-context');
119
- const body = '<crtr-bearings>\nshared document store\n</crtr-bearings>';
120
- const message = { customType: CONTEXT_INTRO_CUSTOM_TYPE, content: body };
121
- const theme = {}; // no fg → plain text, easy to assert
122
- // Collapsed (default): a single stub line, NONE of the body.
123
- const collapsed = renderContextMessage(message, { expanded: false }, theme).render(80);
124
- assert.equal(collapsed.length, 1, 'collapsed is one line');
125
- assert.match(collapsed[0], /ctrl\+o to expand/);
126
- assert.ok(!collapsed.join('\n').includes('shared document store'), 'body hidden when collapsed');
127
- // Expanded (Ctrl+O): label + full body.
128
- const expanded = renderContextMessage(message, { expanded: true }, theme).render(80);
129
- const joined = expanded.join('\n');
130
- assert.ok(joined.includes('shared document store'), 'body shown when expanded');
131
- assert.ok(joined.includes(`[${CONTEXT_INTRO_CUSTOM_TYPE}]`), 'expanded shows the label');
132
- });
133
- test('renderer never emits a line wider than the terminal (truncates the collapsed stub)', () => {
134
- // Regression: a fixed-width stub crashed pi's TUI at narrow widths
135
- // ("Rendered line 1 exceeds terminal width"). Every emitted line — collapsed
136
- // or expanded — must fit within the width handed to render().
137
- const body = Array.from({ length: 6 }, () => 'x'.repeat(200)).join('\n');
138
- const message = { customType: CONTEXT_INTRO_CUSTOM_TYPE, content: body };
139
- const theme = {}; // no fg → plain text, so .length == visible width
140
- for (const w of [1, 2, 5, 10, 20, 42, 80]) {
141
- for (const expanded of [false, true]) {
142
- const lines = renderContextMessage(message, { expanded }, theme).render(w);
143
- for (const line of lines) {
144
- assert.ok([...line].length <= w, `width=${w} expanded=${expanded}: line ${[...line].length} cols exceeds ${w}: ${JSON.stringify(line)}`);
145
- }
146
- }
147
- }
148
- });