@crouton-kit/crouter 0.3.40 → 0.3.42

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 (195) 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 +720 -747
  7. package/dist/clients/attach/chat-view.js +11 -0
  8. package/dist/clients/attach/view-socket.d.ts +8 -1
  9. package/dist/clients/attach/view-socket.js +15 -1
  10. package/dist/clients/web/server.js +5 -28
  11. package/dist/clients/web/web-cmd.js +1 -1
  12. package/dist/commands/__tests__/revive-now-gate.test.js +60 -0
  13. package/dist/commands/canvas-rebuild-index.js +5 -5
  14. package/dist/commands/memory/shared.d.ts +7 -3
  15. package/dist/commands/memory/shared.js +35 -5
  16. package/dist/commands/memory/write.js +5 -3
  17. package/dist/commands/node-snapshot.js +9 -1
  18. package/dist/commands/node.js +37 -17
  19. package/dist/commands/push.js +8 -0
  20. package/dist/commands/revive.d.ts +10 -0
  21. package/dist/commands/revive.js +28 -10
  22. package/dist/commands/sys/__tests__/setup-core.test.js +19 -0
  23. package/dist/commands/sys/doctor.js +1 -1
  24. package/dist/commands/sys/setup-core.js +3 -2
  25. package/dist/commands/sys/setup.js +1 -1
  26. package/dist/commands/worktree.d.ts +2 -0
  27. package/dist/commands/worktree.js +94 -0
  28. package/dist/core/__tests__/boot.test.js +4 -4
  29. package/dist/core/__tests__/canvas.test.js +19 -7
  30. package/dist/core/__tests__/child-followup.test.js +15 -5
  31. package/dist/core/__tests__/daemon-boot.test.js +6 -1
  32. package/dist/core/__tests__/daemon-wedge.test.js +18 -1
  33. package/dist/core/__tests__/fault-classifier.test.js +30 -0
  34. package/dist/core/__tests__/fixtures/fake-engine.d.ts +13 -0
  35. package/dist/core/__tests__/fixtures/fake-engine.js +20 -0
  36. package/dist/core/__tests__/full/spike-harness.test.js +8 -5
  37. package/dist/core/__tests__/grace-clock.test.js +18 -2
  38. package/dist/core/__tests__/host-teardown-process-group.test.js +465 -0
  39. package/dist/core/__tests__/prune-to-limit.test.js +14 -0
  40. package/dist/core/__tests__/review-model-floor.test.js +32 -0
  41. package/dist/core/__tests__/revive.test.js +18 -4
  42. package/dist/core/__tests__/session-cycles.test.js +77 -0
  43. package/dist/core/__tests__/worktree.test.js +85 -0
  44. package/dist/core/canvas/boot.js +12 -7
  45. package/dist/core/canvas/canvas.d.ts +27 -8
  46. package/dist/core/canvas/canvas.js +54 -26
  47. package/dist/core/canvas/db.js +14 -0
  48. package/dist/core/canvas/history.js +1 -0
  49. package/dist/core/canvas/paths.d.ts +10 -9
  50. package/dist/core/canvas/paths.js +10 -9
  51. package/dist/core/canvas/pid.d.ts +155 -1
  52. package/dist/core/canvas/pid.js +306 -1
  53. package/dist/core/canvas/status-glyph.d.ts +7 -0
  54. package/dist/core/canvas/status-glyph.js +10 -1
  55. package/dist/core/canvas/types.d.ts +34 -0
  56. package/dist/core/fault-classifier.js +5 -1
  57. package/dist/core/profiles/select.d.ts +4 -2
  58. package/dist/core/profiles/select.js +30 -4
  59. package/dist/core/runtime/bearings.js +4 -0
  60. package/dist/core/runtime/branded-host.d.ts +7 -0
  61. package/dist/core/runtime/branded-host.js +44 -17
  62. package/dist/core/runtime/broker-sdk.js +28 -68
  63. package/dist/core/runtime/broker.js +35 -4
  64. package/dist/core/runtime/host.d.ts +3 -3
  65. package/dist/core/runtime/host.js +148 -33
  66. package/dist/core/runtime/launch.d.ts +15 -15
  67. package/dist/core/runtime/launch.js +58 -4
  68. package/dist/core/runtime/naming.js +3 -2
  69. package/dist/core/runtime/nodes.d.ts +3 -1
  70. package/dist/core/runtime/nodes.js +1 -0
  71. package/dist/core/runtime/pi-cli.d.ts +2 -0
  72. package/dist/core/runtime/pi-cli.js +51 -0
  73. package/dist/core/runtime/placement.d.ts +1 -1
  74. package/dist/core/runtime/placement.js +5 -2
  75. package/dist/core/runtime/recap.js +2 -1
  76. package/dist/core/runtime/revive.d.ts +21 -15
  77. package/dist/core/runtime/revive.js +87 -52
  78. package/dist/core/runtime/session-cycles.d.ts +30 -0
  79. package/dist/core/runtime/session-cycles.js +77 -0
  80. package/dist/core/runtime/spawn.d.ts +4 -0
  81. package/dist/core/runtime/spawn.js +152 -100
  82. package/dist/core/runtime/tmux.d.ts +7 -10
  83. package/dist/core/runtime/tmux.js +9 -11
  84. package/dist/core/worktree.d.ts +35 -0
  85. package/dist/core/worktree.js +158 -0
  86. package/dist/daemon/crtrd.d.ts +17 -8
  87. package/dist/daemon/crtrd.js +191 -40
  88. package/dist/index.d.ts +3 -0
  89. package/dist/index.js +7 -0
  90. package/dist/pi-extensions/canvas-context-intro.d.ts +1 -0
  91. package/dist/pi-extensions/canvas-context-intro.js +34 -23
  92. package/dist/pi-extensions/canvas-stophook.js +11 -5
  93. package/dist/web-client/assets/{index-CbO8L0mN.js → index-B00YpRQ1.js} +20 -20
  94. package/dist/web-client/assets/index-DrkcvANq.css +2 -0
  95. package/dist/web-client/index.html +2 -2
  96. package/docs/compat/hearth-crtr-v1.md +191 -0
  97. package/docs/public-api.md +75 -0
  98. package/package.json +3 -4
  99. package/dist/core/__tests__/hearth-bootstrap.test.js +0 -136
  100. package/dist/core/hearth/__tests__/model-auth-guest.test.js +0 -151
  101. package/dist/core/hearth/config.d.ts +0 -3
  102. package/dist/core/hearth/config.js +0 -108
  103. package/dist/core/hearth/guest-env.d.ts +0 -9
  104. package/dist/core/hearth/guest-env.js +0 -27
  105. package/dist/core/hearth/index.d.ts +0 -4
  106. package/dist/core/hearth/index.js +0 -4
  107. package/dist/core/hearth/model-auth-guest.d.ts +0 -8
  108. package/dist/core/hearth/model-auth-guest.js +0 -430
  109. package/dist/core/hearth/provider.d.ts +0 -36
  110. package/dist/core/hearth/provider.js +0 -10
  111. package/dist/core/hearth/providers/__tests__/sweep-and-release.test.js +0 -362
  112. package/dist/core/hearth/providers/blaxel-bootstrap.d.ts +0 -12
  113. package/dist/core/hearth/providers/blaxel-bootstrap.js +0 -147
  114. package/dist/core/hearth/providers/blaxel-home.d.ts +0 -60
  115. package/dist/core/hearth/providers/blaxel-home.js +0 -405
  116. package/dist/core/hearth/providers/blaxel.d.ts +0 -36
  117. package/dist/core/hearth/providers/blaxel.js +0 -364
  118. package/dist/core/hearth/providers/types.d.ts +0 -93
  119. package/dist/core/hearth/types.d.ts +0 -155
  120. package/dist/hearth/control-plane/__tests__/error-serialization.test.js +0 -29
  121. package/dist/hearth/control-plane/__tests__/node-message.test.js +0 -60
  122. package/dist/hearth/control-plane/__tests__/oauth-serving-marker.test.d.ts +0 -1
  123. package/dist/hearth/control-plane/__tests__/oauth-serving-marker.test.js +0 -44
  124. package/dist/hearth/control-plane/__tests__/rate-limit-recurrence.test.d.ts +0 -1
  125. package/dist/hearth/control-plane/__tests__/rate-limit-recurrence.test.js +0 -49
  126. package/dist/hearth/control-plane/__tests__/relay-security.test.d.ts +0 -1
  127. package/dist/hearth/control-plane/__tests__/relay-security.test.js +0 -314
  128. package/dist/hearth/control-plane/__tests__/scheduler-scan-loop.test.d.ts +0 -1
  129. package/dist/hearth/control-plane/__tests__/scheduler-scan-loop.test.js +0 -133
  130. package/dist/hearth/control-plane/__tests__/trigger-delivery.test.d.ts +0 -1
  131. package/dist/hearth/control-plane/__tests__/trigger-delivery.test.js +0 -170
  132. package/dist/hearth/control-plane/__tests__/wake-roll.test.d.ts +0 -1
  133. package/dist/hearth/control-plane/__tests__/wake-roll.test.js +0 -230
  134. package/dist/hearth/control-plane/__tests__/webhook-ingress.test.d.ts +0 -1
  135. package/dist/hearth/control-plane/__tests__/webhook-ingress.test.js +0 -167
  136. package/dist/hearth/control-plane/config.d.ts +0 -21
  137. package/dist/hearth/control-plane/config.js +0 -77
  138. package/dist/hearth/control-plane/db.d.ts +0 -30
  139. package/dist/hearth/control-plane/db.js +0 -561
  140. package/dist/hearth/control-plane/hearth-target.d.ts +0 -23
  141. package/dist/hearth/control-plane/hearth-target.js +0 -68
  142. package/dist/hearth/control-plane/ingress/rate-limit.d.ts +0 -24
  143. package/dist/hearth/control-plane/ingress/rate-limit.js +0 -100
  144. package/dist/hearth/control-plane/ingress/route-store.d.ts +0 -31
  145. package/dist/hearth/control-plane/ingress/route-store.js +0 -61
  146. package/dist/hearth/control-plane/ingress/webhook-delivery-store.d.ts +0 -41
  147. package/dist/hearth/control-plane/ingress/webhook-delivery-store.js +0 -69
  148. package/dist/hearth/control-plane/ingress/webhook-route.d.ts +0 -55
  149. package/dist/hearth/control-plane/ingress/webhook-route.js +0 -285
  150. package/dist/hearth/control-plane/main.d.ts +0 -1
  151. package/dist/hearth/control-plane/main.js +0 -88
  152. package/dist/hearth/control-plane/node-message.d.ts +0 -31
  153. package/dist/hearth/control-plane/node-message.js +0 -98
  154. package/dist/hearth/control-plane/register.d.ts +0 -15
  155. package/dist/hearth/control-plane/register.js +0 -34
  156. package/dist/hearth/control-plane/registry.d.ts +0 -22
  157. package/dist/hearth/control-plane/registry.js +0 -168
  158. package/dist/hearth/control-plane/relay.d.ts +0 -44
  159. package/dist/hearth/control-plane/relay.js +0 -711
  160. package/dist/hearth/control-plane/scheduler/fire-store.d.ts +0 -36
  161. package/dist/hearth/control-plane/scheduler/fire-store.js +0 -73
  162. package/dist/hearth/control-plane/scheduler/recurrence.d.ts +0 -7
  163. package/dist/hearth/control-plane/scheduler/recurrence.js +0 -58
  164. package/dist/hearth/control-plane/scheduler/scan-loop.d.ts +0 -38
  165. package/dist/hearth/control-plane/scheduler/scan-loop.js +0 -138
  166. package/dist/hearth/control-plane/scheduler/schedule-store.d.ts +0 -32
  167. package/dist/hearth/control-plane/scheduler/schedule-store.js +0 -66
  168. package/dist/hearth/control-plane/secrets.d.ts +0 -31
  169. package/dist/hearth/control-plane/secrets.js +0 -134
  170. package/dist/hearth/control-plane/server.d.ts +0 -27
  171. package/dist/hearth/control-plane/server.js +0 -482
  172. package/dist/hearth/control-plane/serving.d.ts +0 -15
  173. package/dist/hearth/control-plane/serving.js +0 -106
  174. package/dist/hearth/control-plane/session.d.ts +0 -68
  175. package/dist/hearth/control-plane/session.js +0 -273
  176. package/dist/hearth/control-plane/triggers/acl.d.ts +0 -14
  177. package/dist/hearth/control-plane/triggers/acl.js +0 -52
  178. package/dist/hearth/control-plane/triggers/audit-store.d.ts +0 -38
  179. package/dist/hearth/control-plane/triggers/audit-store.js +0 -79
  180. package/dist/hearth/control-plane/triggers/deliver.d.ts +0 -43
  181. package/dist/hearth/control-plane/triggers/deliver.js +0 -76
  182. package/dist/hearth/control-plane/triggers/envelope.d.ts +0 -29
  183. package/dist/hearth/control-plane/triggers/envelope.js +0 -38
  184. package/dist/hearth/control-plane/types.d.ts +0 -86
  185. package/dist/hearth/control-plane/types.js +0 -1
  186. package/dist/hearth/control-plane/wake.d.ts +0 -86
  187. package/dist/hearth/control-plane/wake.js +0 -550
  188. package/dist/web-client/assets/index-DwO46Cs5.css +0 -2
  189. /package/dist/{core/__tests__/hearth-bootstrap.test.d.ts → commands/__tests__/revive-now-gate.test.d.ts} +0 -0
  190. /package/dist/{core/hearth/__tests__/model-auth-guest.test.d.ts → commands/sys/__tests__/setup-core.test.d.ts} +0 -0
  191. /package/dist/core/{hearth/providers/__tests__/sweep-and-release.test.d.ts → __tests__/fault-classifier.test.d.ts} +0 -0
  192. /package/dist/core/{hearth/providers/types.js → __tests__/host-teardown-process-group.test.d.ts} +0 -0
  193. /package/dist/core/{hearth/types.js → __tests__/review-model-floor.test.d.ts} +0 -0
  194. /package/dist/{hearth/control-plane/__tests__/error-serialization.test.d.ts → core/__tests__/session-cycles.test.d.ts} +0 -0
  195. /package/dist/{hearth/control-plane/__tests__/node-message.test.d.ts → core/__tests__/worktree.test.d.ts} +0 -0
@@ -3,8 +3,10 @@
3
3
  // Bypasses the daemon: directly relaunches the broker engine for a node that is
4
4
  // done, idle, or dead. Default behavior resumes the saved pi conversation by its
5
5
  // absolute session-file path (--session <path>) when that .jsonl still exists,
6
- // else falls through to a fresh launch; pass --fresh to force a clean pi session
7
- // against the context dir.
6
+ // else falls through to a fresh launch. `--fresh` skips the true resume: if the
7
+ // `.jsonl` already exists it CYCLES in place instead (same file, a fresh
8
+ // `crtr-cycle` branch — `resumed:false`), and only starts a genuinely clean
9
+ // session when no `.jsonl` exists yet (also `resumed:false`).
8
10
  //
9
11
  // `--all` sweeps EVERY disconnected node (engine not running, resumable session)
10
12
  // in one shot — the recovery for a reboot / mass-crash / daemon-down-a-while
@@ -19,6 +21,22 @@ import { getNode, fullName } from '../core/canvas/index.js';
19
21
  import { isPidAlive } from '../core/canvas/pid.js';
20
22
  import { readFault } from '../core/runtime/fault.js';
21
23
  import { isBusy } from '../core/runtime/busy.js';
24
+ import { faultNeedsYou } from '../core/canvas/status-glyph.js';
25
+ /** --now eligibility for a live, non-busy fault: a daemon-owned AUTO retry
26
+ * (kick it early instead of waiting out the schedule), or any fault the
27
+ * canvas dashboard flags "needs you" (`faultNeedsYou`, status-glyph.ts) — the
28
+ * disposition that otherwise has NO sanctioned recovery verb (issue #115).
29
+ * Both are scoped to `pi→provider`: refuses a client-owned auto retry
30
+ * (redialing) already in flight, and refuses any OTHER link — notably a
31
+ * `daemon→node` wedge fault, which --now can't clear (SIGTERM doesn't touch
32
+ * the runaway subprocess; see `wedged-child-on-runaway-bash`). */
33
+ export function isNowEligibleFault(fault) {
34
+ if (fault === null || fault.link !== 'pi→provider')
35
+ return false;
36
+ if (faultNeedsYou(fault))
37
+ return true;
38
+ return fault.retry.by === 'daemon';
39
+ }
22
40
  // ---------------------------------------------------------------------------
23
41
  // revive node
24
42
  // ---------------------------------------------------------------------------
@@ -50,7 +68,7 @@ export const reviveLeaf = defineLeaf({
50
68
  type: 'bool',
51
69
  required: false,
52
70
  default: false,
53
- constraint: 'When set, start a clean pi session (no --session). Default: resume the saved conversation. Ignored with --all (which always resumes).',
71
+ constraint: 'When set, do NOT do a true resume. If the node already has a session `.jsonl`, this instead CYCLES it in place — reuses that SAME file, resets the tree leaf, and roots a fresh `crtr-cycle` branch (a clean-context restart that still carries the file\'s history, output `resumed:false`). Only when no `.jsonl` exists yet does this start a genuinely brand-new session (also `resumed:false`). Default (no --fresh): a true resume — replays the existing `.jsonl` as-is (`resumed:true`). Ignored with --all (which always resumes).',
54
72
  },
55
73
  {
56
74
  kind: 'flag',
@@ -58,7 +76,7 @@ export const reviveLeaf = defineLeaf({
58
76
  type: 'bool',
59
77
  required: false,
60
78
  default: false,
61
- constraint: 'On-demand kick of an active-fault node (parked on a provider-owned fault, broker still alive). Its broker pid is alive, so an ordinary revive no-ops (double-launch guard) — --now SIGTERMs the live broker instead, so the daemon\'s crash→grace→resume path brings it back in ~20s instead of waiting out the auto-recovery grace. Only valid for a node that has a live daemon-owned auto provider fault and a live pid. Mutually exclusive with --all/--fresh.',
79
+ constraint: 'On-demand kick of an active-fault node (parked on a `pi→provider` fault, broker still alive). Its broker pid is alive, so an ordinary revive no-ops (double-launch guard) — --now SIGTERMs the live broker instead, so the daemon\'s crash→grace→resume path brings it back in ~20s. Valid for a live `pi→provider` fault that is either a daemon-owned auto retry (kicks it early), or ANY fault the canvas dashboard flags "needs you" (e.g. exhausted retries, an auth fault you just fixed) — that disposition otherwise has no recovery verb. Mutually exclusive with --all/--fresh.',
62
80
  },
63
81
  // --force is a DELIBERATELY UNDOCUMENTED confirmation gate for --all (hidden
64
82
  // from -h; see FlagParam.hidden). Silas's intent (2026-06-13): a mass revive
@@ -80,7 +98,7 @@ export const reviveLeaf = defineLeaf({
80
98
  output: [
81
99
  { name: 'window', type: 'string', required: false, constraint: 'Always null — the revived broker is headless and opens no tmux window. Kept for caller back-compat.' },
82
100
  { name: 'session', type: 'string', required: false, constraint: 'The node\'s last live location session, or null — the headless broker has no tmux session of its own.' },
83
- { name: 'resumed', type: 'boolean', required: false, constraint: 'True when pi was told to --session the saved conversation. Single-node revive only.' },
101
+ { name: 'resumed', type: 'boolean', required: false, constraint: 'True ONLY for a true resume (the existing `.jsonl` replayed as-is). False for a --fresh revive, whether that landed as an in-place CYCLE (an existing `.jsonl` reused with a fresh `crtr-cycle` branch — pi is still told --session, but this is not a resume) or a genuinely fresh launch (no `.jsonl` existed). Single-node revive only.' },
84
102
  { name: 'ready', type: 'boolean', required: false, constraint: 'True when the revived broker\'s view.sock accepted a connection before return — the node is immediately attachable/drivable. Single-node revive only.' },
85
103
  { name: 'mode', type: 'string', required: false, constraint: '"preview" (the --all candidate list, nothing launched), "revived" (the --all sweep ran), or absent for a single-node revive.' },
86
104
  { name: 'candidates', type: 'string', required: false, constraint: '--all preview: the node ids that WOULD be revived (newline-joined). Empty when none are disconnected.' },
@@ -145,8 +163,8 @@ export const reviveLeaf = defineLeaf({
145
163
  // reviveNode would no-op the double-launch guard) — SIGTERM it so the daemon's
146
164
  // ordinary crash→grace→resume path recovers it on the saved session, the same
147
165
  // thing the daemon does at the auto-recovery grace, just on demand. Gated on
148
- // a live pid and a daemon-owned provider fault so it can't be used to nuke a
149
- // healthy node.
166
+ // a live pid and an eligible pi→provider fault (`isNowEligibleFault` above) so
167
+ // it can't be used to nuke a healthy node.
150
168
  if (now) {
151
169
  const pid = meta.pi_pid;
152
170
  const stall = readFault(nodeId);
@@ -157,11 +175,11 @@ export const reviveLeaf = defineLeaf({
157
175
  next: 'Revive a dormant node with `crtr canvas revive ' + nodeId + '` (no --now).',
158
176
  });
159
177
  }
160
- if (stall === null || stall.link !== 'pi→provider' || stall.retry.by !== 'daemon' || stall.retry.disposition !== 'auto') {
178
+ if (!isNowEligibleFault(stall)) {
161
179
  throw new InputError({
162
180
  error: 'not_hanging',
163
- message: `${nodeId} is live but not an active daemon-owned provider fault — --now refuses to SIGTERM a healthy node.`,
164
- next: 'Use --now only on a node the canvas shows as hanging (⚠). For a routine relaunch, omit --now.',
181
+ message: `${nodeId} is live but has no pi→provider fault eligible for --now (either healthy, or a client-owned retry / non-provider fault already being handled elsewhere) — --now refuses to SIGTERM a node that isn't stuck on a kickable provider fault.`,
182
+ next: 'Use --now only on a node the canvas shows as hanging (⚠) with a `pi→provider` fault. For a routine relaunch, omit --now.',
165
183
  });
166
184
  }
167
185
  // The daemon never kills a BUSY engine (its verdict paths all return 'leave'
@@ -0,0 +1,19 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { existsSync } from 'node:fs';
4
+ import { resolveBundledPiCliPath } from '../../../core/runtime/pi-cli.js';
5
+ test('resolveBundledPiCliPath uses crouter bundled pi instead of a standalone pi on PATH', () => {
6
+ const prevPath = process.env.PATH;
7
+ process.env.PATH = '';
8
+ try {
9
+ const cliPath = resolveBundledPiCliPath();
10
+ assert.equal(existsSync(cliPath), true);
11
+ assert.match(cliPath, /@earendil-works[\/]pi-coding-agent[\/]dist[\/]cli\.js$/);
12
+ }
13
+ finally {
14
+ if (prevPath === undefined)
15
+ delete process.env.PATH;
16
+ else
17
+ process.env.PATH = prevPath;
18
+ }
19
+ });
@@ -71,7 +71,7 @@ function faultRemediation(fault) {
71
71
  auth: 're-authenticate',
72
72
  protocol: 'fix the protocol mismatch',
73
73
  other: 'fix the underlying issue',
74
- wedged: 'read `crtr memory read wedged-child-on-runaway-bash` and, if a runaway subprocess is to blame, kill the subprocess (not the node)',
74
+ wedged: 'read `crtr memory read wedged-child-on-runaway-bash` if a runaway subprocess is to blame, kill it (not the node); if the broker had no subprocess, the daemon already SIGTERM-kicked it to resume on its own (recheck node status; nudge it if it came back idle)',
75
75
  };
76
76
  const progress = fault.retry.disposition === 'auto'
77
77
  ? fault.retry.by === 'client'
@@ -4,6 +4,7 @@ import { homedir } from 'node:os';
4
4
  import { basename, dirname, join, resolve } from 'node:path';
5
5
  import { builtinPiPackageDir } from '../../core/scope.js';
6
6
  import { readJsonIfExists } from '../../core/fs-utils.js';
7
+ import { resolveBundledPiCliPath } from '../../core/runtime/pi-cli.js';
7
8
  const PACKAGE_COPY = {
8
9
  'crtr-extensions': {
9
10
  name: 'pi-crtr-extensions',
@@ -285,8 +286,8 @@ function runShellCommand(command) {
285
286
  throw new Error(`command failed: ${command}`);
286
287
  }
287
288
  }
288
- function installPiPackage(source) {
289
- const result = spawnSync('pi', ['install', source], { stdio: 'inherit' });
289
+ function installPiPackage(source, piCliPath = resolveBundledPiCliPath()) {
290
+ const result = spawnSync(process.execPath, [piCliPath, 'install', source], { stdio: 'inherit' });
290
291
  if (result.status !== 0) {
291
292
  throw new Error(`pi install failed: ${source}`);
292
293
  }
@@ -506,7 +506,7 @@ export const sysSetupLeaf = defineLeaf({
506
506
  outputKind: 'object',
507
507
  effects: [
508
508
  'Opens a pi-tui wizard that collects companion package installs, system dependency installs, and an Exa API key.',
509
- 'Runs pi installs for the selected bundled packages, writes EXA_API_KEY to ~/.crouter/exa.key when provided, and prints a summary.',
509
+ 'Runs the bundled pi CLI to install the selected companion packages, writes EXA_API_KEY to ~/.crouter/exa.key when provided, and prints a summary.',
510
510
  ],
511
511
  },
512
512
  run: async () => {
@@ -0,0 +1,2 @@
1
+ import { type BranchDef } from '../core/command.js';
2
+ export declare function registerWorktree(): BranchDef;
@@ -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 {