@north-light/crouter 0.3.282 → 0.3.284

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 (160) hide show
  1. package/dist/api/client.d.ts +22 -4
  2. package/dist/api/client.js +34 -0
  3. package/dist/api/dto/broker-ops.d.ts +11 -0
  4. package/dist/api/dto/broker.d.ts +3 -8
  5. package/dist/api/dto/common.d.ts +1 -1
  6. package/dist/api/dto/messages.d.ts +2 -2
  7. package/dist/api/dto/node-outcomes.d.ts +80 -0
  8. package/dist/api/dto/node-outcomes.js +2 -0
  9. package/dist/api/dto/nodes.d.ts +9 -0
  10. package/dist/api/dto/profiles.d.ts +5 -0
  11. package/dist/api/dto/reports.d.ts +45 -0
  12. package/dist/api/index.d.ts +1 -0
  13. package/dist/api/index.js +1 -0
  14. package/dist/api/routes.d.ts +7 -0
  15. package/dist/api/routes.js +7 -0
  16. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.js +24 -3
  17. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +31 -4
  18. package/dist/clients/attach/overlays/graph.d.ts +5 -0
  19. package/dist/clients/attach/overlays/graph.js +65 -47
  20. package/dist/clients/attach/overlays/pickers.js +8 -9
  21. package/dist/clients/attach/render/card-presentation.js +1 -1
  22. package/dist/clients/attach/viewer.js +1286 -1260
  23. package/dist/commands/api-client.js +22 -23
  24. package/dist/commands/node/create.js +38 -3
  25. package/dist/commands/node/inspect.js +3 -7
  26. package/dist/commands/node/message.js +3 -3
  27. package/dist/commands/node/outcome.d.ts +2 -0
  28. package/dist/commands/node/outcome.js +134 -0
  29. package/dist/commands/node.js +2 -1
  30. package/dist/commands/profile/pause.js +17 -14
  31. package/dist/commands/push.js +102 -1
  32. package/dist/commands/sys/__tests__/setup-core.test.js +4 -1
  33. package/dist/commands/sys/panels/broker-limits-panel.js +7 -1
  34. package/dist/commands/sys/panels/models-panel.js +2 -2
  35. package/dist/commands/sys/settings-shell.d.ts +1 -1
  36. package/dist/commands/sys/settings-shell.js +6 -0
  37. package/dist/commands/sys/settings.js +3 -3
  38. package/dist/core/__tests__/fixtures/fake-engine.d.ts +2 -0
  39. package/dist/core/__tests__/fixtures/fake-engine.js +42 -4
  40. package/dist/core/__tests__/helpers/harness.d.ts +1 -0
  41. package/dist/core/__tests__/helpers/harness.js +9 -0
  42. package/dist/core/__tests__/integration/profile-pause.test.d.ts +1 -0
  43. package/dist/core/__tests__/integration/profile-pause.test.js +115 -0
  44. package/dist/core/__tests__/node-outcome.test.d.ts +1 -0
  45. package/dist/core/__tests__/node-outcome.test.js +184 -0
  46. package/dist/core/__tests__/revive-parked-fresh.test.js +13 -20
  47. package/dist/core/__tests__/seam/broker-provider-retry.test.js +6 -2
  48. package/dist/core/__tests__/seam/dormancy-release.test.js +3 -3
  49. package/dist/core/__tests__/seam/node-outcome-delivery.test.d.ts +1 -0
  50. package/dist/core/__tests__/seam/node-outcome-delivery.test.js +153 -0
  51. package/dist/core/bash-jobs.d.ts +6 -0
  52. package/dist/core/bash-jobs.js +22 -0
  53. package/dist/core/canvas/canvas.d.ts +41 -3
  54. package/dist/core/canvas/canvas.js +153 -39
  55. package/dist/core/canvas/crons.js +2 -1
  56. package/dist/core/canvas/index.d.ts +1 -0
  57. package/dist/core/canvas/index.js +1 -0
  58. package/dist/core/canvas/migrations.js +47 -0
  59. package/dist/core/canvas/node-outcome-deliveries.d.ts +80 -0
  60. package/dist/core/canvas/node-outcome-deliveries.js +163 -0
  61. package/dist/core/canvas/pid.d.ts +7 -0
  62. package/dist/core/canvas/pid.js +17 -0
  63. package/dist/core/canvas/render-source.js +2 -2
  64. package/dist/core/canvas/types.d.ts +42 -1
  65. package/dist/core/canvas/types.js +2 -1
  66. package/dist/core/config.d.ts +4 -1
  67. package/dist/core/config.js +25 -1
  68. package/dist/core/feed/feed.d.ts +8 -0
  69. package/dist/core/feed/feed.js +25 -4
  70. package/dist/core/feed/inbox.d.ts +5 -0
  71. package/dist/core/feed/inbox.js +13 -0
  72. package/dist/core/keybindings/catalog.d.ts +1 -1
  73. package/dist/core/keybindings/catalog.js +3 -0
  74. package/dist/core/profiles/manifest.d.ts +3 -1
  75. package/dist/core/profiles/manifest.js +6 -1
  76. package/dist/core/runtime/broker/daemon-ops.d.ts +3 -1
  77. package/dist/core/runtime/broker/daemon-ops.js +6 -0
  78. package/dist/core/runtime/broker/engine-drive.d.ts +2 -3
  79. package/dist/core/runtime/broker/engine-drive.js +74 -27
  80. package/dist/core/runtime/broker/frame-dispatch.js +1 -0
  81. package/dist/core/runtime/broker/read-ops.js +14 -0
  82. package/dist/core/runtime/broker/rebind.js +0 -7
  83. package/dist/core/runtime/broker-protocol.d.ts +1 -6
  84. package/dist/core/runtime/fleet.d.ts +7 -0
  85. package/dist/core/runtime/headless-pi.d.ts +13 -0
  86. package/dist/core/runtime/headless-pi.js +10 -2
  87. package/dist/core/runtime/lifecycle.d.ts +6 -1
  88. package/dist/core/runtime/lifecycle.js +50 -1
  89. package/dist/core/runtime/nodes.d.ts +5 -1
  90. package/dist/core/runtime/nodes.js +2 -1
  91. package/dist/core/runtime/outcome-document.d.ts +70 -0
  92. package/dist/core/runtime/outcome-document.js +97 -0
  93. package/dist/core/runtime/revive.js +16 -21
  94. package/dist/core/runtime/spawn.d.ts +5 -1
  95. package/dist/core/runtime/spawn.js +18 -7
  96. package/dist/core/runtime/stamp/channel.d.ts +3 -5
  97. package/dist/core/runtime/stamp/channel.js +3 -10
  98. package/dist/core/runtime/stop-guard.js +2 -2
  99. package/dist/core/runtime/structured-output.d.ts +24 -3
  100. package/dist/core/runtime/structured-output.js +42 -3
  101. package/dist/core/runtime/tmux-bindings.js +4 -0
  102. package/dist/core/runtime/warm-pool.d.ts +5 -2
  103. package/dist/core/runtime/warm-pool.js +5 -5
  104. package/dist/core/subscription-state.js +7 -5
  105. package/dist/core/substrate/render.js +10 -10
  106. package/dist/core/user-settings.d.ts +18 -1
  107. package/dist/core/user-settings.js +37 -0
  108. package/dist/daemon/__tests__/node-deadline-lane.test.d.ts +1 -0
  109. package/dist/daemon/__tests__/node-deadline-lane.test.js +76 -0
  110. package/dist/daemon/__tests__/node-outcome-birth-invariants.test.d.ts +1 -0
  111. package/dist/daemon/__tests__/node-outcome-birth-invariants.test.js +122 -0
  112. package/dist/daemon/__tests__/node-outcome-delivery.test.d.ts +1 -0
  113. package/dist/daemon/__tests__/node-outcome-delivery.test.js +86 -0
  114. package/dist/daemon/api/__tests__/broker-settle-park.test.js +86 -18
  115. package/dist/daemon/api/__tests__/profile-launch-gates.test.js +10 -1
  116. package/dist/daemon/api/__tests__/seam/node-outcome-api.test.d.ts +1 -0
  117. package/dist/daemon/api/__tests__/seam/node-outcome-api.test.js +159 -0
  118. package/dist/daemon/api/__tests__/submit-result.test.d.ts +1 -0
  119. package/dist/daemon/api/__tests__/submit-result.test.js +214 -0
  120. package/dist/daemon/api/handlers/broker-ops.js +53 -24
  121. package/dist/daemon/api/handlers/canvas.js +2 -2
  122. package/dist/daemon/api/handlers/messages.js +12 -2
  123. package/dist/daemon/api/handlers/node-outcomes.d.ts +2 -0
  124. package/dist/daemon/api/handlers/node-outcomes.js +136 -0
  125. package/dist/daemon/api/handlers/nodes.js +67 -1
  126. package/dist/daemon/api/handlers/profiles.js +53 -1
  127. package/dist/daemon/api/handlers/reports.js +197 -33
  128. package/dist/daemon/api/map.d.ts +7 -2
  129. package/dist/daemon/api/map.js +78 -4
  130. package/dist/daemon/api/server.js +74 -7
  131. package/dist/daemon/crtrd.js +12 -0
  132. package/dist/daemon/fleet.d.ts +4 -8
  133. package/dist/daemon/fleet.js +48 -9
  134. package/dist/daemon/messaging/node-message.js +2 -1
  135. package/dist/daemon/node-outcome/deliver-outcome.d.ts +14 -0
  136. package/dist/daemon/node-outcome/deliver-outcome.js +155 -0
  137. package/dist/daemon/park-activity.d.ts +9 -0
  138. package/dist/daemon/park-activity.js +30 -0
  139. package/dist/daemon/park-pending.d.ts +10 -5
  140. package/dist/daemon/park-pending.js +18 -8
  141. package/dist/daemon/reconcilers/bash-deadline.js +6 -0
  142. package/dist/daemon/reconcilers/broker-supervision.d.ts +3 -7
  143. package/dist/daemon/reconcilers/broker-supervision.js +29 -33
  144. package/dist/daemon/reconcilers/node-deadline.d.ts +5 -0
  145. package/dist/daemon/reconcilers/node-deadline.js +82 -0
  146. package/dist/daemon/reconcilers/node-lifecycle/freeze-lane.js +9 -1
  147. package/dist/daemon/reconcilers/node-lifecycle/tick.js +12 -3
  148. package/dist/daemon/reconcilers/node-outcome-delivery-lane.d.ts +10 -0
  149. package/dist/daemon/reconcilers/node-outcome-delivery-lane.js +41 -0
  150. package/dist/pi-extensions/__tests__/canvas-structured-output.test.js +66 -66
  151. package/dist/pi-extensions/canvas-structured-output.d.ts +9 -5
  152. package/dist/pi-extensions/canvas-structured-output.js +23 -297
  153. package/dist/shared/env.d.ts +8 -0
  154. package/dist/shared/env.js +14 -0
  155. package/dist/shared/generated-context.d.ts +3 -9
  156. package/dist/shared/generated-context.js +8 -16
  157. package/dist/types.d.ts +12 -0
  158. package/dist/types.js +1 -0
  159. package/package.json +9 -8
  160. package/runtime.lock.json +227 -383
@@ -66,6 +66,12 @@ export class SettingsShell {
66
66
  label: "Models",
67
67
  panel: this.models,
68
68
  },
69
+ {
70
+ id: "provider-options",
71
+ group: "Intelligence",
72
+ label: "Provider options",
73
+ panel: prefs("providers", "Provider options", "How a provider serves your turns, once it is authenticated."),
74
+ },
69
75
  {
70
76
  id: "keybindings",
71
77
  group: "Interface",
@@ -9,11 +9,11 @@ import { BROKER_LIMIT_OVERRIDE_PRECEDENCE } from './setup.js';
9
9
  /** Rail destinations, in rail order. Kept here rather than imported from the
10
10
  * shell so registering the `sys` subtree still costs no pi module graph. */
11
11
  const PAGES = [
12
- 'providers', 'models', 'keybindings', 'display', 'profiles', 'panes', 'broker-limits', 'updates',
12
+ 'providers', 'models', 'provider-options', 'keybindings', 'display', 'profiles', 'panes', 'broker-limits', 'updates',
13
13
  ];
14
14
  export const sysSettingsLeaf = defineLeaf({
15
15
  name: 'settings',
16
- description: 'open the settings page — providers, models, keybindings, display, profiles, panes, broker limits, updates',
16
+ description: 'open the settings page — providers, models, provider options, keybindings, display, profiles, panes, broker limits, updates',
17
17
  whenToUse: 'the user wants to change crouter configuration interactively. Agents should use sys config get/set, which is scriptable; sys setup installs a fresh machine.',
18
18
  help: {
19
19
  name: 'sys settings',
@@ -31,7 +31,7 @@ export const sysSettingsLeaf = defineLeaf({
31
31
  output: [],
32
32
  outputKind: 'object',
33
33
  effects: [
34
- 'Opens a pi-tui page whose left rail groups ongoing configuration: Intelligence (providers, models), Interface (keybindings, display), and Workspace (profiles, panes, broker limits, updates).',
34
+ 'Opens a pi-tui page whose left rail groups ongoing configuration: Intelligence (providers, models, provider options), Interface (keybindings, display), and Workspace (profiles, panes, broker limits, updates).',
35
35
  'Profiles edits are immediate: adding or removing a project directory, cycling its memory rung, setting a home, renaming, pausing and pinning a directory default all write the same profile manifests and pin file as the `crtr profile` leaves; deleting a profile runs the same cascade as `crtr profile delete --force`.',
36
36
  'Preference, model-ladder and keybinding edits save to the user config as you make them; saved keybinding changes also refresh crouter-owned tmux bindings without overwriting occupied user keys.',
37
37
  `Broker warning and automatic-revive hard-cap values save under user config brokerThresholds; crtrd reads them on its next supervision tick. ${BROKER_LIMIT_OVERRIDE_PRECEDENCE}`,
@@ -171,6 +171,8 @@ declare class FakeSession {
171
171
  get model(): FakeModel | undefined;
172
172
  private currentModel;
173
173
  private currentThinkingLevel;
174
+ private bashAbort;
175
+ private bashRun;
174
176
  get thinkingLevel(): ThinkingLevel | undefined;
175
177
  setThinkingLevel(level: ThinkingLevel): void;
176
178
  cycleThinkingLevel(): ThinkingLevel;
@@ -35,6 +35,7 @@ import { join, dirname } from 'node:path';
35
35
  import { randomUUID } from 'node:crypto';
36
36
  import { DefaultResourceLoader, getAgentDir } from '@earendil-works/pi-coding-agent';
37
37
  import { MANAGED_PROVIDER_COOLING_DIAGNOSTIC } from '../../runtime/managed-provider-cooling.js';
38
+ import registerCanvasBashValve, { createValveOperations } from '../../../pi-extensions/canvas-bash-valve.js';
38
39
  /** Test seams for prompt-scoped failures before session_start. The one-shot
39
40
  * variant records its first failure per node so the next cycling retry can
40
41
  * reach boot confirmation. */
@@ -369,6 +370,8 @@ class FakeSession {
369
370
  // this getter — store whatever it hands us.
370
371
  currentModel;
371
372
  currentThinkingLevel;
373
+ bashAbort;
374
+ bashRun;
372
375
  get thinkingLevel() {
373
376
  return this.currentThinkingLevel;
374
377
  }
@@ -517,10 +520,12 @@ class FakeSession {
517
520
  }
518
521
  abort() {
519
522
  const held = this.heldTurn;
520
- if (held === undefined)
521
- return Promise.resolve();
522
- held.release();
523
- return held.settled;
523
+ if (held !== undefined) {
524
+ held.release();
525
+ return held.settled;
526
+ }
527
+ this.bashAbort?.abort();
528
+ return this.bashRun ?? Promise.resolve();
524
529
  }
525
530
  /** In-place tree rewind (broker `navigate_tree`). Mirrors the real SDK's
526
531
  * contract: the session file is unchanged, the live history is truncated,
@@ -975,6 +980,39 @@ class FakeSession {
975
980
  tool: cmd.tool,
976
981
  });
977
982
  break;
983
+ case 'bash': {
984
+ const nodeId = process.env['CRTR_NODE_ID'];
985
+ const contextDir = process.env['CRTR_CONTEXT_DIR'];
986
+ if (nodeId === undefined || contextDir === undefined)
987
+ throw new Error('bash control requires canvas node paths');
988
+ this.streaming = true;
989
+ await this.fire('agent_start', {}, ctx);
990
+ this.emit({ type: 'agent_start' });
991
+ // The resource loader imports extensions through jiti while this fixture
992
+ // imports its valve operations through Node. Register the same module
993
+ // instance here so its SIGTERM listener owns this real test job too.
994
+ registerCanvasBashValve({ on: () => { } });
995
+ const controller = new AbortController();
996
+ this.bashAbort = controller;
997
+ const run = createValveOperations(nodeId, contextDir).exec(cmd.command ?? 'true', process.cwd(), {
998
+ onData: () => { },
999
+ signal: controller.signal,
1000
+ }).then(() => { });
1001
+ this.bashRun = run;
1002
+ try {
1003
+ await run;
1004
+ }
1005
+ finally {
1006
+ this.bashAbort = undefined;
1007
+ this.bashRun = undefined;
1008
+ this.streaming = false;
1009
+ }
1010
+ await this.fire('agent_end', { messages: [] }, ctx);
1011
+ this.emit({ type: 'agent_end', messages: [], willRetry: false });
1012
+ await this.fire('agent_settled', { type: 'agent_settled' }, ctx);
1013
+ this.emit({ type: 'agent_settled' });
1014
+ break;
1015
+ }
978
1016
  case 'turn':
979
1017
  this.streaming = true;
980
1018
  await this.fire('agent_start', {}, ctx);
@@ -49,6 +49,7 @@ export interface SpawnOpts {
49
49
  mode?: Mode;
50
50
  lifecycle?: Lifecycle;
51
51
  model?: string;
52
+ profile?: string | null;
52
53
  id?: string;
53
54
  }
54
55
  /** Fabricate a broker-hosted node row directly — tmux-free, boot-free, NO
@@ -514,6 +514,7 @@ export async function createHarness(opts = {}) {
514
514
  ...(o.mode !== undefined ? { mode: o.mode } : {}),
515
515
  ...(o.lifecycle !== undefined ? { rootLifecycle: o.lifecycle } : {}),
516
516
  ...(o.model !== undefined ? { model: o.model } : {}),
517
+ ...(o.profile !== undefined ? { profile: o.profile } : {}),
517
518
  });
518
519
  await harness.awaitBoot(result.node.node_id);
519
520
  return result.node.node_id;
@@ -572,6 +573,14 @@ export async function createHarness(opts = {}) {
572
573
  `pi_pid_identity=${n?.pi_pid_identity ?? 'null'} status=${n?.status ?? 'null'} ` +
573
574
  `intent=${n?.intent ?? 'null'} pi_session_id=${n?.pi_session_id ?? 'null'} ` +
574
575
  `bootCount=${bootCount(nodeId)} minCount=${minCount}\n`);
576
+ // Diagnostic (2026-09-03 CI investigation): confirm or rule out the
577
+ // backoff-gated respawn path (fleet.ts #respawn's `assess` branch)
578
+ // stranding this row behind a `respawn_not_before` window that never
579
+ // gets a further h.tick() to clear it, since awaitBoot itself never
580
+ // ticks. Remove once the real CI failure is diagnosed.
581
+ process.stderr.write(`[awaitBoot] respawn state: respawn_failures=${n?.respawn_failures ?? 'null'} ` +
582
+ `respawn_not_before=${n?.respawn_not_before ?? 'null'} launched_at=${n?.launched_at ?? 'null'} ` +
583
+ `last_settled_at=${n?.last_settled_at ?? 'null'} now=${new Date().toISOString()}\n`);
575
584
  // Identity-aware liveness: what does the current process state say about recorded identity?
576
585
  if (n?.pi_pid !== null && n?.pi_pid !== undefined) {
577
586
  const liveness = recordedPidLiveness(n.pi_pid, n.pi_pid_identity);
@@ -0,0 +1,115 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { existsSync, readFileSync, readdirSync } from 'node:fs';
4
+ import { contextDir } from '../../canvas/paths.js';
5
+ import { getRow } from '../../canvas/canvas.js';
6
+ import { bashJobPaths, bashJobsDir } from '../../bash-jobs.js';
7
+ import { readCursor } from '../../feed/inbox.js';
8
+ import { transition } from '../../runtime/lifecycle.js';
9
+ import { ensureRootProfile, pauseProfile } from '../../profiles/manifest.js';
10
+ import { createHeadlessHarness } from '../helpers/harness.js';
11
+ function runningJob(nodeId) {
12
+ const dir = bashJobsDir(contextDir(nodeId));
13
+ try {
14
+ for (const jobId of readdirSync(dir)) {
15
+ const paths = bashJobPaths(contextDir(nodeId), jobId);
16
+ if (existsSync(paths.jobRun))
17
+ return paths;
18
+ }
19
+ }
20
+ catch {
21
+ // The valve has not created its job directory yet.
22
+ }
23
+ return undefined;
24
+ }
25
+ test('profile pause backgrounds real bash, retains its inbox cursor, and recovers after a quick resume without failure', { timeout: 120_000 }, async () => {
26
+ const h = await createHeadlessHarness({ sessionPrefix: 'crtr-profile-pause' });
27
+ try {
28
+ const profile = ensureRootProfile();
29
+ const parent = h.fabricateBrokerNode({ lifecycle: 'resident' });
30
+ const child = await h.spawnHeadlessChild(parent, 'stay available while the profile pauses', { profile: profile.profileId });
31
+ h.fakeCmd(child, { cmd: 'bash', command: "sh -c 'sleep 2; printf preserved'" });
32
+ const job = await h.waitFor(() => runningJob(child), { label: 'valve job.run' });
33
+ assert.ok(existsSync(job.jobRun), 'the bash valve owns the foreground command before pause');
34
+ const paused = h.cli(null, ['profile', 'pause', profile.profileId]);
35
+ assert.equal(paused.code, 0, paused.stderr);
36
+ await h.tick();
37
+ assert.equal(h.bootCount(child), 1, 'the lifecycle pass while paused launches no replacement');
38
+ const retained = h.cli(null, ['node', 'message', 'send', '--to', child, 'mail retained while profile is paused']);
39
+ assert.equal(retained.code, 0, retained.stderr);
40
+ const retainedEntry = h.inbox(child).find((entry) => entry.data?.['body'] === 'mail retained while profile is paused');
41
+ assert.ok(retainedEntry, 'the message was durably appended');
42
+ assert.equal(readCursor(child), undefined, 'pause leaves the durable inbox cursor unadvanced');
43
+ const resumed = h.cli(null, ['profile', 'resume', profile.profileId]);
44
+ assert.equal(resumed.code, 0, resumed.stderr);
45
+ await h.awaitFleetExit(child);
46
+ assert.ok(existsSync(job.jobBg), 'SIGTERM atomically handed job.run to job.bg');
47
+ assert.equal(existsSync(job.jobRun), false, 'the foreground valve ownership is gone');
48
+ await h.waitFor(() => existsSync(job.jobExit), { label: 'background bash completion' });
49
+ assert.equal(readFileSync(job.jobExit, 'utf8').trim(), '0', 'the handoff command completed after its broker exited');
50
+ assert.equal(readCursor(child), undefined, 'neither retained message nor background completion advanced the stopped broker cursor');
51
+ await h.tick();
52
+ assert.equal(h.bootCount(child), 1, 'the first resumed policy pass only consumes the pause interruption');
53
+ assert.equal(getRow(child)?.respawn_failures, 0, 'the intentional SIGTERM never counts as a crash');
54
+ await h.tick();
55
+ // `#respawn`'s backoff-assessment defers this exact relaunch on its first
56
+ // assessment: this node's original spawn (`spawnChild`'s direct
57
+ // `headlessBrokerHost.launch`) never records `launched_at`, so
58
+ // `decideRespawnAttempt` can never see `last_settled_at > launched_at` on
59
+ // this first-ever `#respawn` call for the row and always takes the
60
+ // `respawn_not_before` backoff branch (fleet.ts's RESPAWN_BACKOFF_BASE_MS,
61
+ // currently 5s) instead of launching. A THIRD tick, issued once real time
62
+ // has passed that window, finds `respawn_not_before` already set (so
63
+ // `#respawn` skips reassessment this time) and actually calls `reviveNode`.
64
+ // Real-time sleep, not a fake clock: `now()` here is the daemon's real
65
+ // `Date.now`, so only the wall clock — not another tick — advances it.
66
+ await new Promise((r) => setTimeout(r, 5_500));
67
+ await h.tick();
68
+ await h.awaitBoot(child, { minCount: 2, timeoutMs: 30_000 });
69
+ const deliveries = await h.awaitWake(child, { match: /mail retained while profile is paused/ });
70
+ assert.ok(deliveries.some((content) => /mail retained while profile is paused/.test(content)), 'the resumed watcher delivers the retained backlog');
71
+ }
72
+ finally {
73
+ await h.dispose();
74
+ }
75
+ });
76
+ test('profile pause preserves an idle-release row so mail queued during pause wakes it', { timeout: 120_000 }, async () => {
77
+ const h = await createHeadlessHarness({ sessionPrefix: 'crtr-profile-pause-idle' });
78
+ try {
79
+ const profile = ensureRootProfile();
80
+ const parent = h.fabricateBrokerNode({ lifecycle: 'resident' });
81
+ const child = await h.spawnHeadlessChild(parent, 'await ordinary mail', { profile: profile.profileId, lifecycle: 'resident' });
82
+ transition(child, 'release');
83
+ const paused = h.cli(null, ['profile', 'pause', profile.profileId]);
84
+ assert.equal(paused.code, 0, paused.stderr);
85
+ const queued = h.cli(null, ['node', 'message', 'send', '--to', child, 'ordinary mail queued during pause']);
86
+ assert.equal(queued.code, 0, queued.stderr);
87
+ await h.awaitFleetExit(child);
88
+ const resumed = h.cli(null, ['profile', 'resume', profile.profileId]);
89
+ assert.equal(resumed.code, 0, resumed.stderr);
90
+ await h.tick();
91
+ await h.tick();
92
+ await h.awaitBoot(child, { minCount: 2, timeoutMs: 60_000 });
93
+ const deliveries = await h.awaitWake(child, { match: /ordinary mail queued during pause/ });
94
+ assert.ok(deliveries.some((content) => /ordinary mail queued during pause/.test(content)), 'the idle-release wake is not stranded behind the pause marker');
95
+ }
96
+ finally {
97
+ await h.dispose();
98
+ }
99
+ });
100
+ test('lifecycle catches a broker that became live after the pause command fast scan', { timeout: 60_000 }, async () => {
101
+ const h = await createHeadlessHarness({ sessionPrefix: 'crtr-profile-pause-race' });
102
+ try {
103
+ const profile = ensureRootProfile();
104
+ const parent = h.fabricateBrokerNode({ lifecycle: 'resident' });
105
+ const child = await h.spawnHeadlessChild(parent, 'broker launched after pause scan', { profile: profile.profileId });
106
+ pauseProfile(profile.profileId);
107
+ await h.tick();
108
+ await h.awaitFleetExit(child);
109
+ assert.equal(h.bootCount(child), 1, 'the paused lifecycle tick terminates the late live broker instead of leaving it running');
110
+ assert.equal(getRow(child)?.respawn_failures, 0, 'the late broker interruption has no failure cost');
111
+ }
112
+ finally {
113
+ await h.dispose();
114
+ }
115
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,184 @@
1
+ import { after, afterEach, beforeEach, test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { DatabaseSync } from 'node:sqlite';
4
+ import { mkdirSync, mkdtempSync, rmSync } from 'node:fs';
5
+ import { tmpdir } from 'node:os';
6
+ import { join } from 'node:path';
7
+ import { closeDb, MIGRATIONS } from '../canvas/db.js';
8
+ import { createNode, getRow } from '../canvas/canvas.js';
9
+ import { armOutcomeDelivery, claimOutcomeDelivery, outcomeDeliveryBackoffMs, readOutcomeDelivery, recoverRunningOutcomeDeliveries, settleOutcomeDelivery, } from '../canvas/node-outcome-deliveries.js';
10
+ import { transition } from '../runtime/lifecycle.js';
11
+ import { composeNodeOutcomeDocument } from '../runtime/outcome-document.js';
12
+ import { pushFinal } from '../feed/feed.js';
13
+ import { OUTCOME_PAYLOAD_MAX_BYTES } from '../canvas/types.js';
14
+ let home;
15
+ const priorHome = process.env['CRTR_HOME'];
16
+ function node(nodeId) {
17
+ return {
18
+ node_id: nodeId,
19
+ name: nodeId,
20
+ created: new Date().toISOString(),
21
+ cwd: home,
22
+ kind: 'general',
23
+ mode: 'base',
24
+ lifecycle: 'terminal',
25
+ status: 'active',
26
+ };
27
+ }
28
+ beforeEach(() => {
29
+ closeDb();
30
+ home = mkdtempSync(join(tmpdir(), 'crtr-node-outcome-'));
31
+ process.env['CRTR_HOME'] = home;
32
+ });
33
+ afterEach(() => {
34
+ closeDb();
35
+ rmSync(home, { recursive: true, force: true });
36
+ });
37
+ after(() => {
38
+ closeDb();
39
+ if (priorHome === undefined)
40
+ delete process.env['CRTR_HOME'];
41
+ else
42
+ process.env['CRTR_HOME'] = priorHome;
43
+ });
44
+ test('the terminal outcome latch survives competing terminal transitions and revive', () => {
45
+ createNode(node('latched'));
46
+ transition('latched', 'finish');
47
+ transition('latched', 'cancel');
48
+ let row = getRow('latched');
49
+ assert.deepEqual({ kind: row.outcome_kind, reason: row.outcome_reason, revision: row.outcome_revision }, {
50
+ kind: 'failure', reason: 'finished', revision: 1,
51
+ });
52
+ transition('latched', 'revive');
53
+ row = getRow('latched');
54
+ assert.equal(row.outcome_at === null, false, 'revive must not clear a latched outcome');
55
+ assert.equal(row.outcome_revision, 1);
56
+ });
57
+ test('outcome detail keeps only a 500-byte first line', () => {
58
+ createNode(node('detail'));
59
+ transition('detail', 'crash', {
60
+ outcome: { detail: { schema: 'crtr.node-outcome-detail/v1', message: `${'x'.repeat(1_000)}\nignored` } },
61
+ });
62
+ const detail = JSON.parse(getRow('detail').outcome_detail_json);
63
+ assert.equal(Buffer.byteLength(detail.message, 'utf8'), 500);
64
+ assert.equal(detail.message.includes('\n'), false);
65
+ });
66
+ test('the outcome composer is total and omits an oversized structured result', () => {
67
+ const meta = node('document');
68
+ meta.description = 'x'.repeat(2 * 1024);
69
+ createNode(meta);
70
+ const document = composeNodeOutcomeDocument({
71
+ node: meta,
72
+ outcome: { kind: 'result', reason: 'finalized', at: new Date().toISOString(), revision: 1, detailJson: null },
73
+ action: { name: 'test-action', payload: { callback: 'one' } },
74
+ finalReport: 'final.md',
75
+ structuredResult: { value: 'y'.repeat(300 * 1024) },
76
+ });
77
+ assert.ok(Buffer.byteLength(JSON.stringify(document), 'utf8') <= 256 * 1024);
78
+ assert.equal(Buffer.byteLength(document.node.description, 'utf8') <= 1024, true);
79
+ assert.equal(document.result?.structuredResult, undefined);
80
+ assert.equal(document.result?.structuredResultOmitted, true);
81
+ });
82
+ test('the outcome composer caps a failure document with an oversized node name', () => {
83
+ const meta = node('failure-document');
84
+ meta.name = 'x'.repeat(300 * 1024);
85
+ const document = composeNodeOutcomeDocument({
86
+ node: meta,
87
+ outcome: { kind: 'failure', reason: 'crashed', at: new Date().toISOString(), revision: 1, detailJson: null },
88
+ action: { name: 'test-action', payload: { callback: 'one' } },
89
+ });
90
+ assert.ok(Buffer.byteLength(JSON.stringify(document), 'utf8') <= 256 * 1024);
91
+ assert.ok(Buffer.byteLength(document.node.name, 'utf8') <= 1024);
92
+ assert.equal(document.result, undefined);
93
+ assert.ok(document.failure);
94
+ });
95
+ test('a canonical final supersedes a prior failure outcome and increments revision', async () => {
96
+ createNode(node('supersede'));
97
+ transition('supersede', 'crash');
98
+ transition('supersede', 'revive');
99
+ await pushFinal('supersede', 'recovered and finished');
100
+ const row = getRow('supersede');
101
+ assert.deepEqual({ kind: row.outcome_kind, reason: row.outcome_reason, revision: row.outcome_revision }, {
102
+ kind: 'result', reason: 'finalized', revision: 2,
103
+ });
104
+ assert.equal(row.outcome_detail_json, null);
105
+ });
106
+ test('outcome delivery rejects an oversized payload before a node is armed', () => {
107
+ createNode(node('oversized-payload'));
108
+ assert.throws(() => armOutcomeDelivery({
109
+ nodeId: 'oversized-payload', actionName: 'test-action', argv: ['/bin/true'], cwd: home, configOrigin: 'test', payload: { value: 'x'.repeat(OUTCOME_PAYLOAD_MAX_BYTES) },
110
+ }), /exceeds/);
111
+ assert.equal(readOutcomeDelivery('oversized-payload'), null);
112
+ });
113
+ test('outcome delivery armed after settlement is born pending with its document', () => {
114
+ createNode(node('settled-delivery'));
115
+ transition('settled-delivery', 'crash');
116
+ const delivery = armOutcomeDelivery({
117
+ nodeId: 'settled-delivery', actionName: 'test-action', argv: ['/bin/true'], cwd: home, configOrigin: 'test', payload: { callback: 'one' }, now: 123,
118
+ });
119
+ assert.equal(delivery?.state, 'pending');
120
+ assert.equal(delivery?.nextAttemptAt, 123);
121
+ assert.deepEqual(delivery?.document && {
122
+ schema: delivery.document.schema,
123
+ outcomeId: delivery.document.outcomeId,
124
+ action: delivery.document.action,
125
+ }, {
126
+ schema: 'crtr.node-outcome/v1',
127
+ outcomeId: 'settled-delivery:1',
128
+ action: { name: 'test-action', payload: { callback: 'one' } },
129
+ });
130
+ });
131
+ test('outcome delivery state is fenced, recoverable, and follows the durable backoff', () => {
132
+ createNode(node('delivery'));
133
+ assert.ok(armOutcomeDelivery({
134
+ nodeId: 'delivery', actionName: 'test-action', argv: ['/bin/true'], cwd: home, configOrigin: 'test', payload: { callback: 'one' }, now: 100,
135
+ }));
136
+ transition('delivery', 'crash');
137
+ assert.equal(readOutcomeDelivery('delivery')?.state, 'pending');
138
+ assert.ok(readOutcomeDelivery('delivery')?.document, 'the armed row is stamped with its outcome document');
139
+ const first = claimOutcomeDelivery('delivery', Date.now(), 'daemon-a');
140
+ assert.equal(first.state, 'running');
141
+ assert.equal(settleOutcomeDelivery('delivery', first.attempt, 'daemon-b', { state: 'accepted' }, Date.now()), false, 'a stale claimant cannot settle');
142
+ assert.equal(settleOutcomeDelivery('delivery', first.attempt, 'daemon-a', { state: 'accepted' }, Date.now()), true);
143
+ assert.equal(readOutcomeDelivery('delivery')?.state, 'accepted');
144
+ createNode(node('recover'));
145
+ armOutcomeDelivery({ nodeId: 'recover', actionName: 'test-action', argv: ['/bin/true'], cwd: home, configOrigin: 'test', now: 100 });
146
+ transition('recover', 'crash');
147
+ const running = claimOutcomeDelivery('recover', Date.now(), 'daemon-a');
148
+ assert.equal(running.state, 'running');
149
+ const recoveredAt = Date.now();
150
+ assert.equal(recoverRunningOutcomeDeliveries(recoveredAt), 1);
151
+ assert.deepEqual(readOutcomeDelivery('recover') && {
152
+ state: readOutcomeDelivery('recover').state,
153
+ nextAttemptAt: readOutcomeDelivery('recover').nextAttemptAt,
154
+ claimOwner: readOutcomeDelivery('recover').claimOwner,
155
+ }, { state: 'pending', nextAttemptAt: recoveredAt, claimOwner: null });
156
+ assert.equal(outcomeDeliveryBackoffMs(1), 5_000);
157
+ assert.equal(outcomeDeliveryBackoffMs(2), 10_000);
158
+ assert.equal(outcomeDeliveryBackoffMs(3), 20_000);
159
+ assert.equal(outcomeDeliveryBackoffMs(11), 3_600_000);
160
+ assert.equal(outcomeDeliveryBackoffMs(100), 3_600_000);
161
+ });
162
+ test('v39 migrates a v38 database and backfills finalized and terminal rows', () => {
163
+ const dbPath = join(home, 'v38.db');
164
+ mkdirSync(join(home, 'nodes'), { recursive: true });
165
+ const db = new DatabaseSync(dbPath);
166
+ db.exec('PRAGMA foreign_keys = ON;');
167
+ for (const migration of MIGRATIONS.slice(0, 38))
168
+ migration(db);
169
+ db.prepare(`INSERT INTO nodes (node_id, name, kind, mode, lifecycle, status, cwd, created, final_report, finalized_at, terminal_reason)
170
+ VALUES (?, ?, 'general', 'base', 'terminal', ?, ?, ?, ?, ?, ?)`)
171
+ .run('finalized', 'finalized', 'done', home, '2026-01-01T00:00:00.000Z', 'report.md', '2026-01-01T01:00:00.000Z', 'finalized');
172
+ db.prepare(`INSERT INTO nodes (node_id, name, kind, mode, lifecycle, status, cwd, created, terminal_reason)
173
+ VALUES (?, ?, 'general', 'base', 'terminal', ?, ?, ?, ?)`)
174
+ .run('dead', 'dead', 'dead', home, '2026-01-02T00:00:00.000Z', 'crashed');
175
+ MIGRATIONS[38](db);
176
+ const rows = db.prepare('SELECT node_id, outcome_kind, outcome_reason, outcome_at, outcome_revision FROM nodes ORDER BY node_id').all().map((row) => ({ ...row }));
177
+ assert.deepEqual(rows, [
178
+ { node_id: 'dead', outcome_kind: 'failure', outcome_reason: 'crashed', outcome_at: '2026-01-02T00:00:00.000Z', outcome_revision: 1 },
179
+ { node_id: 'finalized', outcome_kind: 'result', outcome_reason: 'finalized', outcome_at: '2026-01-01T01:00:00.000Z', outcome_revision: 1 },
180
+ ]);
181
+ assert.ok(db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'node_outcome_deliveries'").get());
182
+ assert.ok(db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'index' AND name = 'idx_nodes_deadline'").get());
183
+ db.close();
184
+ });
@@ -1,17 +1,10 @@
1
1
  // Run with: node --conditions=crtr-src --import tsx/esm --test src/core/__tests__/revive-parked-fresh.test.ts
2
2
  //
3
- // Re-entering a parked node starts a FRESH cycle grounded in goal + roadmap
4
- // rather than replaying the transcript its parking turn already concluded —
5
- // but only when the revival CARRIES NEW INPUT. Wake-worthy mail that the node
6
- // has not seen is that input: every doorway that wakes a parked node because
7
- // something arrived appends its durable inbox entry before calling in. An
8
- // input-less revival (a bare lifecycle revive, a viewer attach, a focus) has
9
- // nothing to answer, so it quietly resumes the old transcript and injects no
10
- // kickoff. The rule is a property of the node, evaluated once inside
11
- // reviveNode, so every doorway gets it from one strict-resume veto. Its other
12
- // exception is a park with nothing on disk to stand on (reachable only on the
13
- // degraded path, which writes no summary): resuming an old transcript beats
14
- // waking amnesiac.
3
+ // Re-entering a parked node resumes its main transcript: the isolated parking
4
+ // turn never wrote to it. Wake-worthy mail remains durable for the inbox watcher
5
+ // to deliver after boot; it does not create a fresh-cycle kickoff. An input-less
6
+ // revival likewise resumes quietly. The rule is a property of the node,
7
+ // evaluated once inside reviveNode, so every doorway gets it uniformly.
15
8
  import { after, afterEach, before, beforeEach, test } from 'node:test';
16
9
  import assert from 'node:assert/strict';
17
10
  import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
@@ -103,23 +96,23 @@ after(() => {
103
96
  else
104
97
  process.env['CRTR_HOME'] = priorHome;
105
98
  });
106
- test('a parked node with mail waiting re-enters fresh even when the caller asked to resume, and the marker clears', async () => {
99
+ test('a parked node with mail waiting resumes its main transcript and clears the marker', async () => {
107
100
  parkResident('parked', { roadmap: '# Roadmap\n\n- one open item\n' });
108
101
  deliverMail('parked');
109
102
  const result = await reviveNode('parked', { resume: true, capacity: 'freeze' });
110
- assert.equal(result.resumed, false, 'the concluded transcript is not replayed');
111
- assert.deepEqual(placements(), [{ nodeId: 'parked', resuming: false }]);
112
- assert.equal(kickedOff(launches[0]), true, 'the fresh window is handed its bearings');
103
+ assert.equal(result.resumed, true, 'the isolated parking turn never concluded the main transcript');
104
+ assert.deepEqual(placements(), [{ nodeId: 'parked', resuming: true }]);
105
+ assert.equal(kickedOff(launches[0]), false, 'the inbox watcher delivers the mail after the resumed boot');
113
106
  const reopened = getNode('parked');
114
107
  assert.equal(reopened.status, 'active');
115
108
  assert.equal(reopened.intent ?? null, null, 'the park marker clears on revival');
116
- assert.equal(reopened.cycle_pending, true, 'the fresh cycle is provisional until session_start confirms it');
109
+ assert.equal(reopened.cycle_pending, false, 'a strict resume does not open a new cycle');
117
110
  });
118
- test('a parked node grounded only by a goal still re-enters fresh when mail rides the revive', async () => {
111
+ test('a parked node grounded only by a goal still resumes when mail rides the revive', async () => {
119
112
  parkResident('goal-only', { goal: 'Watch the deploy lane.' });
120
113
  deliverMail('goal-only');
121
- assert.equal((await reviveNode('goal-only', { resume: true, capacity: 'freeze' })).resumed, false);
122
- assert.equal(kickedOff(launches[0]), true);
114
+ assert.equal((await reviveNode('goal-only', { resume: true, capacity: 'freeze' })).resumed, true);
115
+ assert.equal(kickedOff(launches[0]), false);
123
116
  });
124
117
  test('an input-less revival of a parked node quietly resumes and injects no kickoff', async () => {
125
118
  parkResident('quiet', { roadmap: '# Roadmap\n\n- one open item\n' });
@@ -394,7 +394,11 @@ test('reload_auth restores a newly authenticated personal route before the next
394
394
  const unauthenticatedProvider = join(h.home, 'fake-pi.unauthenticated-provider');
395
395
  mkdirSync(userConfigDir, { recursive: true });
396
396
  writeFileSync(userConfigPath, JSON.stringify({
397
- kinds: { general: { whenToUse: 'General work.', model: 'anthropic/medium' } },
397
+ // A fixture-private kind, never `general`: the child launches with the
398
+ // package root as cwd, so an ambient project-scope config above the repo
399
+ // (`~/.crouter/config.json`) overrides this user-scope `general` and the
400
+ // child boots on that machine's default strength instead of medium.
401
+ kinds: { 'test/logical-anthropic': { whenToUse: 'Logical Anthropic routing test.', model: 'anthropic/medium' } },
398
402
  modelRoutes: {
399
403
  personal: { family: 'anthropic', credentialSource: 'user', providerId: personal.provider, models: { ultra: personalSpec, strong: personalSpec, medium: personalSpec, light: personalSpec } },
400
404
  funded: { family: 'anthropic', credentialSource: 'northlight', providerId: funded.provider, models: { ultra: `${funded.provider}/${funded.id}:medium`, strong: `${funded.provider}/${funded.id}:medium`, medium: `${funded.provider}/${funded.id}:medium`, light: `${funded.provider}/${funded.id}:medium` } },
@@ -404,7 +408,7 @@ test('reload_auth restores a newly authenticated personal route before the next
404
408
  writeFileSync(unauthenticatedProvider, personal.provider);
405
409
  try {
406
410
  const parent = h.spawnRoot('auth route reconciliation parent');
407
- const child = await h.spawnHeadlessChild(parent, 'auth route reconciliation child');
411
+ const child = await h.spawnHeadlessChild(parent, 'auth route reconciliation child', { kind: 'test/logical-anthropic' });
408
412
  assert.deepEqual(model(child), funded, 'the logical request starts on the funded route while personal auth is absent');
409
413
  const controller = await kit.connectRaw(child, { read: true });
410
414
  controller.send({ type: 'hello', role: 'controller', client_id: 'auth-route-reconciliation' });
@@ -175,7 +175,7 @@ test('an unattended resident with no live obligation completes on the daemon clo
175
175
  await reviveNode(nodeId, { resume: true, capacity: 'freeze' });
176
176
  const boot = await h.awaitBoot(nodeId);
177
177
  assert.equal(h.fleet.has(nodeId), true, 'the revived broker is fleet-owned');
178
- const seenAt = 10_000;
178
+ const seenAt = Date.now();
179
179
  await h.tick(seenAt);
180
180
  await h.tick(seenAt + 1499);
181
181
  assert.equal(h.fleet.has(nodeId), true, 'the daemon keeps the broker through the full grace');
@@ -228,7 +228,7 @@ test('a non-attending observer does not prevent unattended resident parking', {
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');
230
230
  try {
231
- const seenAt = 12_000;
231
+ const seenAt = Date.now();
232
232
  await h.tick(seenAt);
233
233
  await h.tick(seenAt + 1500);
234
234
  await h.tick(seenAt + 1500 + 1000);
@@ -383,7 +383,7 @@ test('a resident managed child parks without finalizing while a terminal child i
383
383
  };
384
384
  const residentPid = await boot(resident);
385
385
  const terminalPid = await boot(terminal);
386
- const seenAt = 50_000;
386
+ const seenAt = Date.now();
387
387
  await h.tick(seenAt);
388
388
  // Clock expiry arms the resident's pending park and requests its summary
389
389
  // turn; the terminal child is cancelled outright by the same pass.