@crouton-kit/crouter 0.3.39 → 0.3.41

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 (188) hide show
  1. package/dist/build-root.js +1 -0
  2. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/__tests__/provider-rotation.test.ts +472 -9
  3. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +284 -76
  4. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/strip-skills-docs.ts +31 -24
  5. package/dist/builtin-pi-packages/pi-crtr-extensions/lib/subscription-state.ts +52 -15
  6. package/dist/clients/attach/attach-cmd.js +706 -733
  7. package/dist/clients/attach/chat-view.js +11 -0
  8. package/dist/clients/web/server.js +5 -28
  9. package/dist/clients/web/web-cmd.js +1 -1
  10. package/dist/commands/__tests__/revive-now-gate.test.js +60 -0
  11. package/dist/commands/canvas-rebuild-index.js +5 -5
  12. package/dist/commands/node-snapshot.js +9 -1
  13. package/dist/commands/node.js +37 -17
  14. package/dist/commands/push.js +8 -0
  15. package/dist/commands/revive.d.ts +10 -0
  16. package/dist/commands/revive.js +28 -10
  17. package/dist/commands/sys/__tests__/setup-core.test.js +19 -0
  18. package/dist/commands/sys/doctor.js +1 -1
  19. package/dist/commands/sys/setup-core.js +3 -2
  20. package/dist/commands/sys/setup.js +1 -1
  21. package/dist/commands/worktree.d.ts +2 -0
  22. package/dist/commands/worktree.js +94 -0
  23. package/dist/core/__tests__/boot.test.js +4 -4
  24. package/dist/core/__tests__/canvas.test.js +19 -7
  25. package/dist/core/__tests__/child-followup.test.js +15 -5
  26. package/dist/core/__tests__/daemon-boot.test.js +6 -1
  27. package/dist/core/__tests__/daemon-wedge.test.js +18 -1
  28. package/dist/core/__tests__/fault-classifier.test.js +30 -0
  29. package/dist/core/__tests__/fixtures/fake-engine.d.ts +13 -0
  30. package/dist/core/__tests__/fixtures/fake-engine.js +20 -0
  31. package/dist/core/__tests__/full/spike-harness.test.js +8 -5
  32. package/dist/core/__tests__/grace-clock.test.js +18 -2
  33. package/dist/core/__tests__/host-teardown-process-group.test.js +465 -0
  34. package/dist/core/__tests__/prune-to-limit.test.js +14 -0
  35. package/dist/core/__tests__/review-model-floor.test.js +32 -0
  36. package/dist/core/__tests__/revive.test.js +18 -4
  37. package/dist/core/__tests__/session-cycles.test.js +77 -0
  38. package/dist/core/__tests__/worktree.test.js +85 -0
  39. package/dist/core/canvas/boot.js +12 -7
  40. package/dist/core/canvas/canvas.d.ts +27 -8
  41. package/dist/core/canvas/canvas.js +54 -26
  42. package/dist/core/canvas/db.js +14 -0
  43. package/dist/core/canvas/history.js +1 -0
  44. package/dist/core/canvas/paths.d.ts +10 -9
  45. package/dist/core/canvas/paths.js +10 -9
  46. package/dist/core/canvas/pid.d.ts +155 -1
  47. package/dist/core/canvas/pid.js +306 -1
  48. package/dist/core/canvas/status-glyph.d.ts +7 -0
  49. package/dist/core/canvas/status-glyph.js +10 -1
  50. package/dist/core/canvas/types.d.ts +34 -0
  51. package/dist/core/fault-classifier.js +5 -1
  52. package/dist/core/runtime/bearings.js +4 -0
  53. package/dist/core/runtime/branded-host.d.ts +7 -0
  54. package/dist/core/runtime/branded-host.js +44 -17
  55. package/dist/core/runtime/broker-sdk.js +28 -68
  56. package/dist/core/runtime/broker.js +35 -4
  57. package/dist/core/runtime/host.d.ts +3 -3
  58. package/dist/core/runtime/host.js +148 -33
  59. package/dist/core/runtime/launch.d.ts +15 -15
  60. package/dist/core/runtime/launch.js +58 -4
  61. package/dist/core/runtime/naming.js +3 -2
  62. package/dist/core/runtime/nodes.d.ts +3 -1
  63. package/dist/core/runtime/nodes.js +1 -0
  64. package/dist/core/runtime/pi-cli.d.ts +2 -0
  65. package/dist/core/runtime/pi-cli.js +51 -0
  66. package/dist/core/runtime/placement.d.ts +1 -1
  67. package/dist/core/runtime/placement.js +5 -2
  68. package/dist/core/runtime/recap.js +2 -1
  69. package/dist/core/runtime/revive.d.ts +21 -15
  70. package/dist/core/runtime/revive.js +87 -52
  71. package/dist/core/runtime/session-cycles.d.ts +30 -0
  72. package/dist/core/runtime/session-cycles.js +77 -0
  73. package/dist/core/runtime/spawn.d.ts +4 -0
  74. package/dist/core/runtime/spawn.js +152 -100
  75. package/dist/core/runtime/tmux.d.ts +7 -10
  76. package/dist/core/runtime/tmux.js +9 -11
  77. package/dist/core/worktree.d.ts +35 -0
  78. package/dist/core/worktree.js +158 -0
  79. package/dist/daemon/crtrd.d.ts +17 -8
  80. package/dist/daemon/crtrd.js +191 -40
  81. package/dist/index.d.ts +3 -0
  82. package/dist/index.js +7 -0
  83. package/dist/pi-extensions/canvas-context-intro.d.ts +1 -0
  84. package/dist/pi-extensions/canvas-context-intro.js +34 -23
  85. package/dist/pi-extensions/canvas-stophook.js +11 -5
  86. package/dist/web-client/assets/{index-CbO8L0mN.js → index-B00YpRQ1.js} +20 -20
  87. package/dist/web-client/assets/index-DrkcvANq.css +2 -0
  88. package/dist/web-client/index.html +2 -2
  89. package/docs/compat/hearth-crtr-v1.md +191 -0
  90. package/docs/public-api.md +75 -0
  91. package/package.json +3 -4
  92. package/dist/core/__tests__/hearth-bootstrap.test.js +0 -136
  93. package/dist/core/hearth/__tests__/model-auth-guest.test.js +0 -151
  94. package/dist/core/hearth/config.d.ts +0 -3
  95. package/dist/core/hearth/config.js +0 -108
  96. package/dist/core/hearth/guest-env.d.ts +0 -9
  97. package/dist/core/hearth/guest-env.js +0 -27
  98. package/dist/core/hearth/index.d.ts +0 -4
  99. package/dist/core/hearth/index.js +0 -4
  100. package/dist/core/hearth/model-auth-guest.d.ts +0 -8
  101. package/dist/core/hearth/model-auth-guest.js +0 -430
  102. package/dist/core/hearth/provider.d.ts +0 -36
  103. package/dist/core/hearth/provider.js +0 -10
  104. package/dist/core/hearth/providers/__tests__/sweep-and-release.test.js +0 -254
  105. package/dist/core/hearth/providers/blaxel-bootstrap.d.ts +0 -12
  106. package/dist/core/hearth/providers/blaxel-bootstrap.js +0 -147
  107. package/dist/core/hearth/providers/blaxel-home.d.ts +0 -54
  108. package/dist/core/hearth/providers/blaxel-home.js +0 -386
  109. package/dist/core/hearth/providers/blaxel.d.ts +0 -36
  110. package/dist/core/hearth/providers/blaxel.js +0 -364
  111. package/dist/core/hearth/providers/types.d.ts +0 -93
  112. package/dist/core/hearth/types.d.ts +0 -155
  113. package/dist/hearth/control-plane/__tests__/error-serialization.test.js +0 -29
  114. package/dist/hearth/control-plane/__tests__/node-message.test.js +0 -60
  115. package/dist/hearth/control-plane/__tests__/oauth-serving-marker.test.d.ts +0 -1
  116. package/dist/hearth/control-plane/__tests__/oauth-serving-marker.test.js +0 -44
  117. package/dist/hearth/control-plane/__tests__/rate-limit-recurrence.test.d.ts +0 -1
  118. package/dist/hearth/control-plane/__tests__/rate-limit-recurrence.test.js +0 -49
  119. package/dist/hearth/control-plane/__tests__/relay-security.test.d.ts +0 -1
  120. package/dist/hearth/control-plane/__tests__/relay-security.test.js +0 -314
  121. package/dist/hearth/control-plane/__tests__/scheduler-scan-loop.test.d.ts +0 -1
  122. package/dist/hearth/control-plane/__tests__/scheduler-scan-loop.test.js +0 -133
  123. package/dist/hearth/control-plane/__tests__/trigger-delivery.test.d.ts +0 -1
  124. package/dist/hearth/control-plane/__tests__/trigger-delivery.test.js +0 -170
  125. package/dist/hearth/control-plane/__tests__/wake-roll.test.d.ts +0 -1
  126. package/dist/hearth/control-plane/__tests__/wake-roll.test.js +0 -230
  127. package/dist/hearth/control-plane/__tests__/webhook-ingress.test.d.ts +0 -1
  128. package/dist/hearth/control-plane/__tests__/webhook-ingress.test.js +0 -167
  129. package/dist/hearth/control-plane/config.d.ts +0 -21
  130. package/dist/hearth/control-plane/config.js +0 -77
  131. package/dist/hearth/control-plane/db.d.ts +0 -30
  132. package/dist/hearth/control-plane/db.js +0 -561
  133. package/dist/hearth/control-plane/hearth-target.d.ts +0 -23
  134. package/dist/hearth/control-plane/hearth-target.js +0 -68
  135. package/dist/hearth/control-plane/ingress/rate-limit.d.ts +0 -24
  136. package/dist/hearth/control-plane/ingress/rate-limit.js +0 -100
  137. package/dist/hearth/control-plane/ingress/route-store.d.ts +0 -31
  138. package/dist/hearth/control-plane/ingress/route-store.js +0 -61
  139. package/dist/hearth/control-plane/ingress/webhook-delivery-store.d.ts +0 -41
  140. package/dist/hearth/control-plane/ingress/webhook-delivery-store.js +0 -69
  141. package/dist/hearth/control-plane/ingress/webhook-route.d.ts +0 -55
  142. package/dist/hearth/control-plane/ingress/webhook-route.js +0 -285
  143. package/dist/hearth/control-plane/main.d.ts +0 -1
  144. package/dist/hearth/control-plane/main.js +0 -88
  145. package/dist/hearth/control-plane/node-message.d.ts +0 -31
  146. package/dist/hearth/control-plane/node-message.js +0 -98
  147. package/dist/hearth/control-plane/register.d.ts +0 -15
  148. package/dist/hearth/control-plane/register.js +0 -34
  149. package/dist/hearth/control-plane/registry.d.ts +0 -22
  150. package/dist/hearth/control-plane/registry.js +0 -168
  151. package/dist/hearth/control-plane/relay.d.ts +0 -44
  152. package/dist/hearth/control-plane/relay.js +0 -711
  153. package/dist/hearth/control-plane/scheduler/fire-store.d.ts +0 -36
  154. package/dist/hearth/control-plane/scheduler/fire-store.js +0 -73
  155. package/dist/hearth/control-plane/scheduler/recurrence.d.ts +0 -7
  156. package/dist/hearth/control-plane/scheduler/recurrence.js +0 -58
  157. package/dist/hearth/control-plane/scheduler/scan-loop.d.ts +0 -38
  158. package/dist/hearth/control-plane/scheduler/scan-loop.js +0 -138
  159. package/dist/hearth/control-plane/scheduler/schedule-store.d.ts +0 -32
  160. package/dist/hearth/control-plane/scheduler/schedule-store.js +0 -66
  161. package/dist/hearth/control-plane/secrets.d.ts +0 -31
  162. package/dist/hearth/control-plane/secrets.js +0 -134
  163. package/dist/hearth/control-plane/server.d.ts +0 -27
  164. package/dist/hearth/control-plane/server.js +0 -482
  165. package/dist/hearth/control-plane/serving.d.ts +0 -15
  166. package/dist/hearth/control-plane/serving.js +0 -106
  167. package/dist/hearth/control-plane/session.d.ts +0 -68
  168. package/dist/hearth/control-plane/session.js +0 -273
  169. package/dist/hearth/control-plane/triggers/acl.d.ts +0 -14
  170. package/dist/hearth/control-plane/triggers/acl.js +0 -52
  171. package/dist/hearth/control-plane/triggers/audit-store.d.ts +0 -38
  172. package/dist/hearth/control-plane/triggers/audit-store.js +0 -79
  173. package/dist/hearth/control-plane/triggers/deliver.d.ts +0 -43
  174. package/dist/hearth/control-plane/triggers/deliver.js +0 -76
  175. package/dist/hearth/control-plane/triggers/envelope.d.ts +0 -29
  176. package/dist/hearth/control-plane/triggers/envelope.js +0 -38
  177. package/dist/hearth/control-plane/types.d.ts +0 -86
  178. package/dist/hearth/control-plane/types.js +0 -1
  179. package/dist/hearth/control-plane/wake.d.ts +0 -86
  180. package/dist/hearth/control-plane/wake.js +0 -550
  181. package/dist/web-client/assets/index-DwO46Cs5.css +0 -2
  182. /package/dist/{core/__tests__/hearth-bootstrap.test.d.ts → commands/__tests__/revive-now-gate.test.d.ts} +0 -0
  183. /package/dist/{core/hearth/__tests__/model-auth-guest.test.d.ts → commands/sys/__tests__/setup-core.test.d.ts} +0 -0
  184. /package/dist/core/{hearth/providers/__tests__/sweep-and-release.test.d.ts → __tests__/fault-classifier.test.d.ts} +0 -0
  185. /package/dist/core/{hearth/providers/types.js → __tests__/host-teardown-process-group.test.d.ts} +0 -0
  186. /package/dist/core/{hearth/types.js → __tests__/review-model-floor.test.d.ts} +0 -0
  187. /package/dist/{hearth/control-plane/__tests__/error-serialization.test.d.ts → core/__tests__/session-cycles.test.d.ts} +0 -0
  188. /package/dist/{hearth/control-plane/__tests__/node-message.test.d.ts → core/__tests__/worktree.test.d.ts} +0 -0
@@ -0,0 +1,94 @@
1
+ import { defineBranch, defineLeaf } from '../core/command.js';
2
+ import { InputError } from '../core/io.js';
3
+ import { closeManagedWorktree, WorktreeError } from '../core/worktree.js';
4
+ function requireCallerNode() {
5
+ const id = process.env['CRTR_NODE_ID'];
6
+ if (id === undefined || id.trim() === '') {
7
+ throw new InputError({
8
+ error: 'no_node_context',
9
+ message: 'CRTR_NODE_ID is not set — worktree commands run inside a canvas node.',
10
+ next: 'Run this command from a node process spawned by the runtime.',
11
+ });
12
+ }
13
+ return id.trim();
14
+ }
15
+ function mapWorktreeError(err) {
16
+ if (!(err instanceof WorktreeError))
17
+ return null;
18
+ return new InputError({
19
+ error: err.code,
20
+ message: err.message,
21
+ ...(err.detail !== undefined ? { received: err.detail } : {}),
22
+ next: err.next,
23
+ });
24
+ }
25
+ const worktreeClose = defineLeaf({
26
+ name: 'close',
27
+ description: 'land and close the current node-managed worktree',
28
+ whenToUse: 'you are inside the node that owns an open managed worktree and are ready to rebase it onto origin/main, push it to main, and (best-effort) remove the worktree and delete the branch before finishing',
29
+ help: {
30
+ name: 'worktree close',
31
+ summary: 'land and close the current node-managed worktree',
32
+ params: [],
33
+ output: [
34
+ { name: 'node_id', type: 'string', required: true, constraint: 'The caller node id whose managed worktree was closed.' },
35
+ { name: 'branch', type: 'string', required: true, constraint: 'The worktree branch that was fast-forwarded into main.' },
36
+ { name: 'pushed_sha', type: 'string', required: true, constraint: 'The landed commit SHA that was pushed to origin/main.' },
37
+ { name: 'worktree_path', type: 'string', required: true, constraint: 'The managed worktree path that was closed (best-effort removed; see worktree_removed).' },
38
+ { name: 'worktree_removed', type: 'boolean', required: true, constraint: 'True when the worktree directory was removed; false means the push still landed (the close is done) but automatic removal failed and needs a manual follow-up.' },
39
+ { name: 'worktree_remove_error', type: 'string', required: false, constraint: 'Present only when worktree_removed is false: the manual cleanup command to run.' },
40
+ { name: 'branch_deleted', type: 'boolean', required: true, constraint: 'True when the local branch was deleted; false means the push still landed (the close is done) but the local branch cleanup failed and needs a manual follow-up.' },
41
+ { name: 'branch_delete_error', type: 'string', required: false, constraint: 'Present only when branch_deleted is false: the manual cleanup command to run.' },
42
+ ],
43
+ outputKind: 'object',
44
+ effects: [
45
+ 'Fetches origin/main, rebases the worktree onto it, fast-forwards origin/main, marks the node-managed worktree closed, then attempts (best-effort) to remove the worktree and delete the local branch.',
46
+ ],
47
+ },
48
+ run: async () => {
49
+ const nodeId = requireCallerNode();
50
+ try {
51
+ return { ...closeManagedWorktree(nodeId) };
52
+ }
53
+ catch (err) {
54
+ const mapped = mapWorktreeError(err);
55
+ if (mapped !== null)
56
+ throw mapped;
57
+ throw err;
58
+ }
59
+ },
60
+ render: (r) => {
61
+ const branch = String(r['branch'] ?? '');
62
+ const sha = String(r['pushed_sha'] ?? '');
63
+ const path = String(r['worktree_path'] ?? '');
64
+ let base = `Managed worktree landed — branch ${branch} pushed as ${sha}.`;
65
+ const notes = [];
66
+ if (r['worktree_removed'] === false) {
67
+ notes.push(String(r['worktree_remove_error'] ?? `worktree checkout at ${path} could not be removed automatically.`));
68
+ }
69
+ else {
70
+ base += ` Worktree removed at ${path}.`;
71
+ }
72
+ if (r['branch_deleted'] === false) {
73
+ notes.push(String(r['branch_delete_error'] ?? `local branch ${branch} could not be deleted automatically.`));
74
+ }
75
+ if (notes.length > 0)
76
+ return `${base} Note: ${notes.join(' ')}`;
77
+ return base;
78
+ },
79
+ });
80
+ export function registerWorktree() {
81
+ return defineBranch({
82
+ name: 'worktree',
83
+ rootEntry: {
84
+ concept: 'crouter-managed git worktrees tied to node lifecycle',
85
+ desc: 'close a node-owned managed worktree',
86
+ useWhen: 'landing completed work from inside the node that owns the worktree',
87
+ },
88
+ help: {
89
+ name: 'worktree',
90
+ summary: 'manage a node-owned git worktree',
91
+ },
92
+ children: [worktreeClose],
93
+ });
94
+ }
@@ -1,6 +1,6 @@
1
1
  // Run with: node --import tsx/esm --test src/core/__tests__/boot.test.ts
2
2
  //
3
- // BUG LOCKED (Hearth P6 Bug B — a sandbox/VM destroy+recreate, or a host
3
+ // BUG LOCKED (a sandbox/VM destroy+recreate, or a host
4
4
  // reboot, leaves stale `pi_pid` values in the durable canvas.db; a pid-reuse
5
5
  // collision on the fresh boot can fool `isPidAlive` into reading a dead
6
6
  // broker as alive, wedging the node forever). reconcileBootLiveness is the
@@ -61,11 +61,11 @@ test('bootChanged: no persisted history is never a change (never clears a genuin
61
61
  test('bootChanged: same kernel boot_id is the SAME boot (daemon restart, no reboot)', () => {
62
62
  assert.equal(bootChanged(ident('boot-A', 1_000_000 + 5_000), ident('boot-A', 1_000_000)), false, 'identical boot_id — same boot regardless of epoch');
63
63
  });
64
- test('bootChanged: a DIFFERENT boot_id under 60s wall-delta is still detected (Major-2 regression)', () => {
64
+ test('bootChanged: a DIFFERENT boot_id under 60s wall-delta is still detected', () => {
65
65
  // A genuinely fast reboot/recreate: boot B starts only 42s after boot A
66
- // (Hearth\'s measured recreate window), well inside the 60s wall tolerance.
66
+ // (a measured real-world recreate window), well inside the 60s wall tolerance.
67
67
  // The exact kernel boot_id catches it with NO reliance on the wall-clock
68
- // window — the case the old epoch-only compare false-negatived.
68
+ // window — boot_id is the primary signal, not wall time.
69
69
  assert.equal(bootChanged(ident('boot-B', 1_000_000 + 42_000), ident('boot-A', 1_000_000)), true, 'distinct boot_id 42s apart — a real quick recreate, detected');
70
70
  });
71
71
  test('bootChanged: same boot_id with a huge epoch jump is NOT a boot change (NTP step within a boot)', () => {
@@ -60,6 +60,20 @@ test('updateNode merges meta and re-indexes the row', () => {
60
60
  // unspecified fields preserved
61
61
  assert.equal(getNode('a')?.kind, 'general');
62
62
  });
63
+ // Regression: cycle_pending is an IDENTITY field (meta.json-only, no db
64
+ // column — like pi_session_file) written by reviveNode/canvas-stophook and
65
+ // read back by the daemon's retry sites. writeMeta only persists keys listed
66
+ // in IDENTITY_KEYS; if cycle_pending is missing from that whitelist, updateNode
67
+ // silently drops it and getNode reads back undefined, so retryResumeMode
68
+ // always falls back to strict resume (the exact bug this guards against).
69
+ test('cycle_pending survives the updateNode/getNode meta.json round-trip', () => {
70
+ createNode(node('a'));
71
+ assert.equal(getNode('a')?.cycle_pending, undefined); // never set
72
+ updateNode('a', { cycle_pending: true });
73
+ assert.equal(getNode('a')?.cycle_pending, true);
74
+ updateNode('a', { cycle_pending: false });
75
+ assert.equal(getNode('a')?.cycle_pending, false);
76
+ });
63
77
  test('setStatus updates both meta and row', () => {
64
78
  createNode(node('a'));
65
79
  setStatus('a', 'done');
@@ -139,13 +153,11 @@ test('recordSpawn writes the audit-only spawned_by edge', () => {
139
153
  assert.equal(subscriptionsOf('child').length, 0);
140
154
  assert.equal(subscribersOf('parent').length, 0);
141
155
  });
142
- // --- Regression: Hearth M0 cold-boot 0-byte meta.json -----------------------
143
- // A bare write-temp-then-rename WITHOUT fsync left meta.json 0-byte on the guest's
144
- // durable NFS home after a sandbox recreate (the open inode is silly-renamed and the
145
- // new content never flushed). Cold-boot's `canvas rebuild-index` + `canvas revive`
146
- // then JSON.parse('') "Unexpected end of JSON input" → the chain dies → guest
147
- // daemon-only, no web on 7878. Fix: writeMeta fsyncs temp+parent dir (durable), and
148
- // readMeta/rebuildIndex tolerate an empty/corrupt meta as MISSING instead of throwing.
156
+ // --- Durability contract: meta.json is durable and never truncated -------
157
+ // writeMeta uses atomic rename with fsync on both the temp file and parent
158
+ // directory to ensure atomicity and durability. readMeta/rebuildIndex tolerate
159
+ // an empty/corrupt meta.json as MISSING (returning null) rather than throwing,
160
+ // gracefully handling rare disk/NFS anomalies without breaking recovery.
149
161
  test('writeMeta produces a durable, non-empty, atomically-named meta.json', () => {
150
162
  createNode(node('a'));
151
163
  const p = nodeMetaPath('a');
@@ -42,6 +42,7 @@ after(() => {
42
42
  delete process.env['CRTR_HOME'];
43
43
  });
44
44
  const STD = 'Two moves only';
45
+ const DEDUPE = 'duplicate';
45
46
  const YIELD = 'crtr node yield';
46
47
  test('readContextTokens: prefers context_tokens, falls back to tokens_in, else null', () => {
47
48
  createNode(node('n1'));
@@ -59,25 +60,34 @@ test('childFollowUp: orchestrator past 100k → yield nudge with rounded k', ()
59
60
  const msg = childFollowUp('orch');
60
61
  assert.match(msg, /crtr node yield/);
61
62
  assert.match(msg, /~135k/); // rounded
63
+ assert.match(msg, new RegExp(DEDUPE));
62
64
  assert.doesNotMatch(msg, /Two moves only/);
63
65
  });
64
66
  test('childFollowUp: orchestrator below 100k → standard road sign', () => {
65
67
  createNode(node('orch', { mode: 'orchestrator' }));
66
68
  writeTelemetry('orch', { context_tokens: 80_000 });
67
- assert.match(childFollowUp('orch'), new RegExp(STD));
68
- assert.doesNotMatch(childFollowUp('orch'), new RegExp(YIELD));
69
+ const msg = childFollowUp('orch');
70
+ assert.match(msg, new RegExp(STD));
71
+ assert.match(msg, new RegExp(DEDUPE));
72
+ assert.doesNotMatch(msg, new RegExp(YIELD));
69
73
  });
70
74
  test('childFollowUp: base spawner past 100k → standard (only orchestrators yield)', () => {
71
75
  createNode(node('base', { mode: 'base' }));
72
76
  writeTelemetry('base', { context_tokens: 150_000 });
73
- assert.match(childFollowUp('base'), new RegExp(STD));
77
+ const msg = childFollowUp('base');
78
+ assert.match(msg, new RegExp(STD));
79
+ assert.match(msg, new RegExp(DEDUPE));
74
80
  });
75
81
  test('childFollowUp: orchestrator with no telemetry → standard (unknown size)', () => {
76
82
  createNode(node('orch', { mode: 'orchestrator' }));
77
- assert.match(childFollowUp('orch'), new RegExp(STD));
83
+ const msg = childFollowUp('orch');
84
+ assert.match(msg, new RegExp(STD));
85
+ assert.match(msg, new RegExp(DEDUPE));
78
86
  });
79
87
  test('childFollowUp: missing / undefined spawner → standard', () => {
80
88
  assert.match(childFollowUp(undefined), new RegExp(STD));
81
89
  assert.match(childFollowUp(''), new RegExp(STD));
82
- assert.match(childFollowUp('ghost'), new RegExp(STD)); // no such node
90
+ const msg = childFollowUp('ghost');
91
+ assert.match(msg, new RegExp(STD)); // no such node
92
+ assert.match(msg, new RegExp(DEDUPE));
83
93
  });
@@ -12,7 +12,7 @@ import { superviseTick, readPidfile } from '../../daemon/crtrd.js';
12
12
  import { verifyDaemonStartup } from '../../daemon/manage.js';
13
13
  import { waitForBrokerViewSocket } from '../runtime/placement.js';
14
14
  import { viewSocketPath } from '../canvas/paths.js';
15
- import { signBrandedHostArtifacts, brandedHostWorks } from '../runtime/branded-host.js';
15
+ import { signBrandedHostArtifacts, brandedHostWorks, codesignIdentitiesForBrandedHost } from '../runtime/branded-host.js';
16
16
  import { markBusy, clearBusy } from '../runtime/busy.js';
17
17
  let home;
18
18
  function node(id, over = {}) {
@@ -301,6 +301,11 @@ test('signBrandedHostArtifacts signs the copied libnode before the branded binar
301
301
  signBrandedHostArtifacts('/tmp/crouter', '/tmp/libnode.dylib', (path) => signed.push(path));
302
302
  assert.deepEqual(signed, ['/tmp/libnode.dylib', '/tmp/crouter']);
303
303
  });
304
+ test('codesignIdentitiesForBrandedHost prefers configured or discovered identities and otherwise ad-hoc signs', () => {
305
+ assert.deepEqual(codesignIdentitiesForBrandedHost({ configured: 'Developer ID Application: Example', discovered: 'Apple Development: Local' }), ['Developer ID Application: Example']);
306
+ assert.deepEqual(codesignIdentitiesForBrandedHost({ discovered: 'Apple Development: Local' }), ['Apple Development: Local']);
307
+ assert.deepEqual(codesignIdentitiesForBrandedHost(), ['-']);
308
+ });
304
309
  test('brandedHostWorks health-checks if a binary is executable', () => {
305
310
  // Issue #102: the branded host binary can be corrupted or killed by macOS
306
311
  // before exec, causing spawn() to fail silently. The health-check detects this.
@@ -12,7 +12,7 @@
12
12
  // fires on its own (fails toward 'pending', not 'wedged').
13
13
  import { test } from 'node:test';
14
14
  import assert from 'node:assert/strict';
15
- import { wedgeVerdict, WEDGE_QUIET_MS, WEDGE_CPU_MAX_PERCENT, sumTreeCpu } from '../../daemon/crtrd.js';
15
+ import { wedgeVerdict, WEDGE_QUIET_MS, WEDGE_CPU_MAX_PERCENT, sumTreeCpu, treeHasDescendants } from '../../daemon/crtrd.js';
16
16
  test('wedgeVerdict: a node whose turn already ended is never wedged, regardless of the other signals', () => {
17
17
  assert.equal(wedgeVerdict({ busy: false, quietForMs: null, cpuPercent: null }), 'leave');
18
18
  assert.equal(wedgeVerdict({ busy: false, quietForMs: 10 * 60 * 60_000, cpuPercent: 0 }), 'leave');
@@ -85,3 +85,20 @@ test('sumTreeCpu: malformed/blank lines are skipped; empty output returns null',
85
85
  assert.equal(sumTreeCpu('', 100), null);
86
86
  assert.equal(sumTreeCpu(' \n \n', 100), null);
87
87
  });
88
+ // ---------------------------------------------------------------------------
89
+ // treeHasDescendants (pure) — issue #119: the broker-alone case has no
90
+ // subprocess to kill, so remediation must branch on this instead of assuming
91
+ // a runaway child always exists.
92
+ // ---------------------------------------------------------------------------
93
+ test('treeHasDescendants: broker alone in its own tree — nothing to kill', () => {
94
+ const ps = [' 100 1 0.0'].join('\n');
95
+ assert.equal(treeHasDescendants(ps, 100), false);
96
+ });
97
+ test('treeHasDescendants: a child present — a subprocess to kill', () => {
98
+ const ps = [' 100 1 0.0', ' 200 100 0.0'].join('\n');
99
+ assert.equal(treeHasDescendants(ps, 100), true);
100
+ });
101
+ test('treeHasDescendants: root pid absent from the ps output returns null (can\'t tell)', () => {
102
+ const ps = [' 200 1 0.0'].join('\n');
103
+ assert.equal(treeHasDescendants(ps, 100), null);
104
+ });
@@ -0,0 +1,30 @@
1
+ // Run with: node --import tsx/esm --test src/core/__tests__/fault-classifier.test.ts
2
+ //
3
+ // Issue #94 — a fatal provider AUTH fault (a dead OAuth grant) must classify as
4
+ // kind 'auth' + disposition 'fatal', NOT 'other'. The stophook hands `classify`
5
+ // a raw provider error STRING (not an HTTP object), so the text branch alone
6
+ // has to recognise auth — otherwise `invalid_grant` mislabelled as 'other' and
7
+ // the canvas said "other · needs you" instead of "auth · needs you". Both are
8
+ // fatal, but the taxonomy must name the cause so the notice can say re-login.
9
+ import { test } from 'node:test';
10
+ import assert from 'node:assert/strict';
11
+ import { classify } from '../fault-classifier.js';
12
+ test('invalid_grant text classifies as fatal auth (issue #94)', () => {
13
+ const c = classify('pi→provider', 'Anthropic token refresh failed: invalid_grant');
14
+ assert.equal(c.kind, 'auth');
15
+ assert.equal(c.disposition, 'fatal');
16
+ });
17
+ test('"refresh token not found or invalid" is auth, not a transient connection blip', () => {
18
+ const c = classify('pi→provider', 'Refresh token not found or invalid');
19
+ assert.equal(c.kind, 'auth', 'must not fall into the not-found/connection branch');
20
+ assert.equal(c.disposition, 'fatal');
21
+ });
22
+ test('the auth branch does not steal transient rate-limit / overloaded errors', () => {
23
+ assert.equal(classify('pi→provider', 'rate limit exceeded (429)').kind, 'rate-limit');
24
+ assert.equal(classify('pi→provider', 'Overloaded: server busy (529)').kind, 'overloaded');
25
+ assert.equal(classify('pi→provider', 'ECONNRESET while streaming').kind, 'connection');
26
+ });
27
+ test('HTTP 401/403 objects still classify as auth (unchanged path)', () => {
28
+ assert.equal(classify('pi→provider', { status: 401, statusText: 'Unauthorized' }).kind, 'auth');
29
+ assert.equal(classify('pi→provider', { status: 403 }).kind, 'auth');
30
+ });
@@ -17,6 +17,19 @@ export declare class SessionManager {
17
17
  static open(path: string): SessionManager;
18
18
  getSessionId(): string;
19
19
  getSessionFile(): string;
20
+ private leafId;
21
+ /** Cycling support (crtr-cycle marker) — mirrors the real SDK's getLeafId/
22
+ * resetLeaf/appendCustomEntry just enough that `buildBrokerSession`'s cycling
23
+ * branch (broker.ts) doesn't throw. The fake has no real cross-boot tree at
24
+ * this point (FakeSession builds a fresh SessionTree every boot regardless),
25
+ * so these are recording stubs, not a faithful branch model —
26
+ * cycleAwareMessages already degrades to single-cycle passthrough for a
27
+ * session manager without getEntries/getBranch (never implemented on the
28
+ * fake), so multi-cycle REPLAY is out of scope for the fixture; only "does
29
+ * not crash" is in scope. */
30
+ getLeafId(): string | null;
31
+ resetLeaf(): void;
32
+ appendCustomEntry(_customType: string, _data?: unknown): string;
20
33
  }
21
34
  type Handler = (ev: unknown, ctx: unknown) => void | Promise<void>;
22
35
  interface BindExtensionsOpts {
@@ -100,6 +100,26 @@ export class SessionManager {
100
100
  getSessionFile() {
101
101
  return this.sessionFile;
102
102
  }
103
+ leafId = null;
104
+ /** Cycling support (crtr-cycle marker) — mirrors the real SDK's getLeafId/
105
+ * resetLeaf/appendCustomEntry just enough that `buildBrokerSession`'s cycling
106
+ * branch (broker.ts) doesn't throw. The fake has no real cross-boot tree at
107
+ * this point (FakeSession builds a fresh SessionTree every boot regardless),
108
+ * so these are recording stubs, not a faithful branch model —
109
+ * cycleAwareMessages already degrades to single-cycle passthrough for a
110
+ * session manager without getEntries/getBranch (never implemented on the
111
+ * fake), so multi-cycle REPLAY is out of scope for the fixture; only "does
112
+ * not crash" is in scope. */
113
+ getLeafId() {
114
+ return this.leafId;
115
+ }
116
+ resetLeaf() {
117
+ this.leafId = null;
118
+ }
119
+ appendCustomEntry(_customType, _data) {
120
+ this.leafId = `fake-cycle-${randomUUID().slice(0, 8)}`;
121
+ return this.leafId;
122
+ }
103
123
  }
104
124
  // ---------------------------------------------------------------------------
105
125
  // AgentSessionEvent stream synthesis (T8 / G1, G3, G4, G8). The fake emits a
@@ -11,7 +11,8 @@
11
11
  // canvas transitions.
12
12
  //
13
13
  // Milestones (de-risk order):
14
- // 1. SEAM — piCommand substitutes CRTR_PI_BINARY only when set (pure unit).
14
+ // 1. SEAM — piCommand execs the bundled pi CLI by default, substitutes
15
+ // CRTR_PI_BINARY only when set (pure unit).
15
16
  // 2. ROUND-TRIP— real `node new --headless` → real broker boots with CRTR_NODE_ID
16
17
  // + the -e canvas extensions intact (GO/NO-GO).
17
18
  // 3. REAL HOOKS— the broker loads the real stophook: session_start captures
@@ -41,7 +42,8 @@
41
42
  import { test, before, after } from 'node:test';
42
43
  import assert from 'node:assert/strict';
43
44
  import { createHarness } from '../helpers/harness.js';
44
- import { piCommand } from '../../runtime/placement.js';
45
+ import { piCommand, shellQuote } from '../../runtime/placement.js';
46
+ import { bundledPiCommandForShell } from '../../runtime/pi-cli.js';
45
47
  import { CANVAS_EXTENSIONS } from '../../runtime/launch.js';
46
48
  let h;
47
49
  let root;
@@ -56,13 +58,14 @@ after(async () => {
56
58
  // ===========================================================================
57
59
  // MILESTONE 1 — the CRTR_PI_BINARY seam (pure unit; no boot, no tmux).
58
60
  // ===========================================================================
59
- test('M1 seam: piCommand exec\'s `pi` when CRTR_PI_BINARY is unset, substitutes when set', () => {
61
+ test('M1 seam: piCommand execs the bundled pi CLI when CRTR_PI_BINARY is unset, substitutes when set', () => {
60
62
  const saved = process.env['CRTR_PI_BINARY'];
61
63
  try {
62
64
  delete process.env['CRTR_PI_BINARY'];
65
+ const bundled = bundledPiCommandForShell(shellQuote);
63
66
  const unset = piCommand(['-e', '/abs/ext.ts', '-n', 'label']);
64
- assert.equal(unset, "pi '-e' '/abs/ext.ts' '-n' 'label'", 'unset → identical to exec pi');
65
- assert.ok(unset.startsWith('pi '), 'unset → leads with the literal pi binary');
67
+ assert.equal(unset, `${bundled} '-e' '/abs/ext.ts' '-n' 'label'`, 'unset → identical to exec\'ing the bundled pi CLI');
68
+ assert.ok(unset.startsWith(bundled), 'unset → leads with the resolved bundled-pi command');
66
69
  process.env['CRTR_PI_BINARY'] = '/tmp/fake-pi';
67
70
  const set = piCommand(['-e', '/abs/ext.ts']);
68
71
  assert.ok(set.startsWith('/tmp/fake-pi '), 'set → leads with the substituted binary');
@@ -34,7 +34,22 @@ import { test } from 'node:test';
34
34
  import assert from 'node:assert/strict';
35
35
  import { spawnSync } from 'node:child_process';
36
36
  import { createHarness } from './helpers/harness.js';
37
- import { isPidAlive } from '../../daemon/crtrd.js';
37
+ import { isPidAlive, retryResumeMode } from '../../daemon/crtrd.js';
38
+ // ===========================================================================
39
+ // retryResumeMode (pure) — a crash-retry of a dead/stranded engine retries AS
40
+ // A CYCLE (resume:false) when the dead attempt's own launch was itself a
41
+ // cycling revive that never reached session_start (cycle_pending), and
42
+ // resumes STRICTLY (resume:true) otherwise. See canvas.test.ts's
43
+ // updateNode/getNode round-trip for the companion guarantee that cycle_pending
44
+ // actually survives the meta.json persistence the daemon reads it back from.
45
+ // ===========================================================================
46
+ test('retryResumeMode: cycle_pending=true retries as a cycle, everything else resumes strictly', () => {
47
+ assert.equal(retryResumeMode({ cycle_pending: true }), false, 'a pending cycling attempt retries AS a cycle');
48
+ assert.equal(retryResumeMode({ cycle_pending: false }), true, 'cycle_pending=false resumes strictly');
49
+ assert.equal(retryResumeMode({ cycle_pending: null }), true, 'cycle_pending=null resumes strictly');
50
+ assert.equal(retryResumeMode({}), true, 'cycle_pending undefined resumes strictly');
51
+ assert.equal(retryResumeMode(null), true, 'no meta at all resumes strictly');
52
+ });
38
53
  // A pid that is reaped (dead) by the time spawnSync returns — the "dead but
39
54
  // supervised" pid the grace path judges.
40
55
  function deadPid() {
@@ -103,7 +118,8 @@ test('grace clock: a dead-pi broker does NOT revive within REVIVE_GRACE_MS, but
103
118
  assert.equal(b.cycles ?? 0, 1, 'past REVIVE_GRACE_MS: the dead pi is revived → cycles bumped to 1');
104
119
  assert.equal(b.status, 'active', 'B active after the grace-window revive (transition(revive))');
105
120
  assert.equal(b.intent ?? null, null, 'intent cleared by the revive');
106
- assert.equal(b.pi_pid ?? null, null, 'pi_pid cleared by the window-backed revive (clearPid)');
121
+ assert.notEqual(b.pi_pid, dead, 'pi_pid no longer the stale dead pid — the revive records the fresh broker pid immediately');
122
+ assert.equal(b.pi_pid == null ? false : b.pi_pid > 0, true, 'revive leaves no pid=null boot window for the daemon to false-crash (cc2c754)');
107
123
  }
108
124
  }
109
125
  finally {