@crouton-kit/crouter 0.3.40 → 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 -362
  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 -60
  108. package/dist/core/hearth/providers/blaxel-home.js +0 -405
  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
@@ -1,4 +1,4 @@
1
- import { type NodeRow } from '../core/canvas/index.js';
1
+ import { type NodeRow, type NodeMeta } from '../core/canvas/index.js';
2
2
  import { FAULT_QUIET_MS } from '../core/runtime/fault.js';
3
3
  export { FAULT_QUIET_MS };
4
4
  import { isPidAlive } from '../core/canvas/pid.js';
@@ -35,14 +35,15 @@ export declare function wedgeVerdict(input: {
35
35
  quietForMs: number | null;
36
36
  cpuPercent: number | null;
37
37
  }): WedgeVerdict;
38
- /** Pure: sum CPU% across a process's entire tree (itself + every descendant).
39
- * Parses `ps -ax -o pid=,ppid=,pcpu=` output, builds a ppid→children map, and
40
- * DFS's from `rootPid` collecting root + all descendants; malformed/blank
41
- * lines are skipped. Returns `null` if `rootPid` never appears in the output
42
- * (can't measure at all — treated as UNKNOWN by wedgeVerdict, never as
43
- * “low”) or the output has no parsable rows. Exported for direct unit testing
44
- * — no process access of its own, mirroring wedgeVerdict's purity. */
45
38
  export declare function sumTreeCpu(psOutput: string, rootPid: number): number | null;
39
+ /** Pure (issue #119): does `rootPid` have ANY descendant in this ps snapshot
40
+ * — a runaway subprocess to kill — or does the broker stand ALONE in its own
41
+ * tree (nothing to kill; the engine itself stalled)? Shares `parsePsTree`
42
+ * with `sumTreeCpu` so the two facts the wedge remediation needs come from
43
+ * one `ps` sample. Null when `rootPid` isn't in the sample at all (can't
44
+ * tell — same "unknown, never treated as a fact" stance `sumTreeCpu` takes on
45
+ * a missing root). Exported for direct unit testing. */
46
+ export declare function treeHasDescendants(psOutput: string, rootPid: number): boolean | null;
46
47
  export type LivenessVerdict = 'leave' | 'pending' | 'revive';
47
48
  /** Decide what to do with a node whose engine pid is DEAD, from how long it's
48
49
  * been dead. Pure — the time/revive side effects live in handleNodeLiveness;
@@ -51,6 +52,14 @@ export type LivenessVerdict = 'leave' | 'pending' | 'revive';
51
52
  * upstream by handleNodeLiveness.)
52
53
  * deadFor: ms since first observed dead, or null on the first observation. */
53
54
  export declare function livenessVerdict(piPidAlive: boolean | null, deadFor: number | null): LivenessVerdict;
55
+ /** Whether a crash-retry of a dead/stranded engine should resume STRICTLY
56
+ * (true) or retry the SAME launch attempt's cycling mode (false). Mirrors
57
+ * reviveNode's own cycle_pending flag (set true right before every cycling
58
+ * launch, cleared the instant session_start confirms boot) — so a cycling
59
+ * revive that died pre-session_start is retried AS a cycle (re-appending the
60
+ * marker + kickoff), never stranded as a strict resume into an empty marker
61
+ * branch. Pure so it's unit-testable without a fabricated node. */
62
+ export declare function retryResumeMode(meta: Pick<NodeMeta, 'cycle_pending'> | null): boolean;
54
63
  export declare const DEAD_REAP_GRACE_MS: number;
55
64
  /** Read the pid stored in the pidfile, or null if absent / malformed. */
56
65
  export declare function readPidfile(): number | null;
@@ -18,7 +18,10 @@
18
18
  // • pid dead + intent==='refresh' → fresh respawn (node asked to yield).
19
19
  // • pid dead + intent==='idle-release' → dormant by choice; the second pass
20
20
  // revives (resume) when its inbox gains an unseen entry.
21
- // • pid dead + any other intent → grace-revive RESUME on the saved session.
21
+ // • pid dead + any other intent → grace-revive RESUME on the saved session,
22
+ // UNLESS the dead engine's last launch attempt was itself an in-flight
23
+ // cycle (cycle_pending) — then retry it AS a cycle instead of stranding it
24
+ // as a strict resume into an empty marker branch.
22
25
  //
23
26
  // §H refresh-authority: a stale in-process stophook (pi extensions never reload,
24
27
  // so a days-old resident carries an old hook) can leave intent='refresh' on a
@@ -38,14 +41,16 @@
38
41
  // daemon keeps the live broker parked and retries it via the inbox-based
39
42
  // fault-recovery pass so the ordinary dead-pid crash path never needs an
40
43
  // out-of-band launch.
41
- // dead-pid crash path grace-revives RESUME on the saved session the manual
44
+ // dead-pid crash path grace-revives RESUME on the saved session (or, for a
45
+ // crash mid-cycle, retries AS a cycle — see cycle_pending above) — the manual
42
46
  // recovery (kill broker + canvas revive) that fixes these by hand.
43
47
  //
44
48
  // Single-instance guarantee
45
49
  // A PID file prevents double-runs. On start, if the file exists and the
46
- // recorded pid is alive, we refuse to start (exit 0). Hearth guests set
47
- // CRTR_PIDFILE to keep this process-local on recreate; otherwise it defaults
48
- // to crtrHome()/crtrd.pid. On stop (SIGINT/SIGTERM/exit) we remove the file.
50
+ // recorded pid is alive, we refuse to start (exit 0). Remote/guest deployments
51
+ // set CRTR_PIDFILE to keep this process-local on recreate; otherwise it
52
+ // defaults to crtrHome()/crtrd.pid. On stop (SIGINT/SIGTERM/exit) we remove
53
+ // the file.
49
54
  import { writeFileSync, readFileSync, rmSync, existsSync, mkdirSync, statSync, } from 'node:fs';
50
55
  import { dirname, join } from 'node:path';
51
56
  import { homedir } from 'node:os';
@@ -55,7 +60,7 @@ import { listNodes, getRow, getNode, dueClockTriggers, consumeTrigger, advanceTr
55
60
  import { fullName } from '../core/canvas/labels.js';
56
61
  import { transition } from '../core/runtime/lifecycle.js';
57
62
  import { isBusy, busySince } from '../core/runtime/busy.js';
58
- import { FAULT_QUIET_MS, recordFault } from '../core/runtime/fault.js';
63
+ import { FAULT_QUIET_MS, readFault, recordFault } from '../core/runtime/fault.js';
59
64
  import { fanDoctrineWake } from '../core/runtime/close.js';
60
65
  import { logger } from '../core/log.js';
61
66
  export { FAULT_QUIET_MS };
@@ -220,12 +225,19 @@ function handleYieldStall(row, pid, now) {
220
225
  // with no wake and no visible fault marker (`node inspect list --hanging`
221
226
  // returns nothing — there's no fault recorded, just silence).
222
227
  //
223
- // Detection, not recovery: this NOTIFIES (fans a doctrine wake + records a
224
- // `daemon→node`/'wedged' fault so the node shows ⌘ “wedged · needs you” on the
225
- // canvas graph views, same surface `node inspect list --hanging` already reads)
226
- // it never kills or restarts the engine. Killing the runaway SUBPROCESS (not
227
- // the node) is the sanctioned recovery; see `crtr memory read
228
- // wedged-child-on-runaway-bash`.
228
+ // Detection always NOTIFIES (fans a doctrine wake + records a `daemon→node`/
229
+ // 'wedged' fault so the node shows ⌘ “wedged · needs you” on the canvas graph
230
+ // views, same surface `node inspect list --hanging` already reads). Recovery
231
+ // then branches on whether the broker's process TREE has a descendant:
232
+ // descendants present → a runaway SUBPROCESS is the likely cause; the
233
+ // daemon does NOT touch the engine — kill the subprocess (not the node);
234
+ // see `crtr memory read wedged-child-on-runaway-bash`.
235
+ // • no descendants (the broker pid stands alone — e.g. the engine itself
236
+ // stalled after a model_change, with nothing to kill) → the daemon
237
+ // performs the ONLY remaining remediation itself: SIGTERM the broker, the
238
+ // same on-demand kick `canvas revive --now` does, so its own dead-pid
239
+ // crash-grace path (REVIVE_GRACE_MS) resumes it on the saved session with
240
+ // no lost context.
229
241
  //
230
242
  // Two corroborating signals, both generous, so a routine long-running bash/
231
243
  // build/test tool call is never mistaken for a wedge:
@@ -256,11 +268,14 @@ function handleYieldStall(row, pid, now) {
256
268
  // ACCEPTED residual: a legitimate subprocess blocked on silent I/O (e.g. a
257
269
  // 20-minute download emitting no output, a long `sleep`) reads ~0% tree
258
270
  // CPU and is indistinguishable from a real wedge at the OS level — no CPU
259
- // signal can tell a hung child from an I/O-blocked one. This is accepted,
260
- // not deferred, because detection only NOTIFIES and never kills: the cost
261
- // of that rare false positive is one doctrine wake to a parent, and 20+
262
- // minutes of total silence with near-zero tree CPU is itself worth
263
- // surfacing either way.
271
+ // signal can tell a hung child from an I/O-blocked one. This is accepted:
272
+ // that case still HAS a descendant, so the daemon only notifies (never
273
+ // kills) — the cost of the rare false positive is one doctrine wake, and
274
+ // 20+ minutes of total silence with near-zero tree CPU is itself worth
275
+ // surfacing either way. Only the no-descendants branch ever acts, and
276
+ // SIGTERM-then-resume can't lose context (the saved session is resumed
277
+ // unchanged), so a false positive there costs one unnecessary restart, not
278
+ // lost work.
264
279
  // ---------------------------------------------------------------------------
265
280
  // Generous: routine long tool calls settle well under this; only a turn with
266
281
  // zero observed engine progress for 20+ minutes is even a wedge CANDIDATE.
@@ -295,6 +310,15 @@ export function wedgeVerdict(input) {
295
310
  * repeat notice), which is the same tradeoff `notifiedTriggerFailures` and
296
311
  * `unhealthySince` already accept. */
297
312
  const wedgeNotifiedAt = new Map();
313
+ /** Fatal-provider-fault notify latch (issue #94), keyed on node id → the
314
+ * `fault.since` we already fanned a wake for. A fatal fault (dead OAuth grant,
315
+ * bad key, protocol) has NO retry consumer, so an errored turn just leaves the
316
+ * node `active` with a live-but-dormant broker and, until now, woke nobody —
317
+ * the manager waiting on it hung and later scheduled wakes silently re-failed.
318
+ * Deduped on `fault.since` so one episode fans exactly one wake; a later
319
+ * episode (fresh `since`) or a cleared fault re-arms. In-memory like every
320
+ * other latch here. */
321
+ const fatalFaultNotifiedAt = new Map();
298
322
  /** Pure: sum CPU% across a process's entire tree (itself + every descendant).
299
323
  * Parses `ps -ax -o pid=,ppid=,pcpu=` output, builds a ppid→children map, and
300
324
  * DFS's from `rootPid` collecting root + all descendants; malformed/blank
@@ -302,7 +326,7 @@ const wedgeNotifiedAt = new Map();
302
326
  * (can't measure at all — treated as UNKNOWN by wedgeVerdict, never as
303
327
  * “low”) or the output has no parsable rows. Exported for direct unit testing
304
328
  * — no process access of its own, mirroring wedgeVerdict's purity. */
305
- export function sumTreeCpu(psOutput, rootPid) {
329
+ function parsePsTree(psOutput) {
306
330
  const byPid = new Map(); // pid → pcpu
307
331
  const childrenOf = new Map(); // ppid → child pids
308
332
  for (const line of psOutput.split('\n')) {
@@ -324,6 +348,10 @@ export function sumTreeCpu(psOutput, rootPid) {
324
348
  else
325
349
  siblings.push(pid);
326
350
  }
351
+ return { byPid, childrenOf };
352
+ }
353
+ export function sumTreeCpu(psOutput, rootPid) {
354
+ const { byPid, childrenOf } = parsePsTree(psOutput);
327
355
  if (!byPid.has(rootPid))
328
356
  return null;
329
357
  let total = 0;
@@ -341,19 +369,39 @@ export function sumTreeCpu(psOutput, rootPid) {
341
369
  }
342
370
  return total;
343
371
  }
372
+ /** Pure (issue #119): does `rootPid` have ANY descendant in this ps snapshot
373
+ * — a runaway subprocess to kill — or does the broker stand ALONE in its own
374
+ * tree (nothing to kill; the engine itself stalled)? Shares `parsePsTree`
375
+ * with `sumTreeCpu` so the two facts the wedge remediation needs come from
376
+ * one `ps` sample. Null when `rootPid` isn't in the sample at all (can't
377
+ * tell — same "unknown, never treated as a fact" stance `sumTreeCpu` takes on
378
+ * a missing root). Exported for direct unit testing. */
379
+ export function treeHasDescendants(psOutput, rootPid) {
380
+ const { byPid, childrenOf } = parsePsTree(psOutput);
381
+ if (!byPid.has(rootPid))
382
+ return null;
383
+ return (childrenOf.get(rootPid) ?? []).length > 0;
384
+ }
344
385
  /** Sample the broker's WHOLE PROCESS TREE — the broker pid plus every
345
- * descendant, summed — via one `ps` shell-out, the OS-level signal that
346
- * corroborates the heartbeat silence (see the section banner above). Null on
347
- * ANY failure (the shell-out itself failing, `ps` missing, or `sumTreeCpu`
348
- * finding the root pid absent/output unparseable) wedgeVerdict treats null
349
- * exactly like “too high”, so a broken probe can only ever suppress a false
350
- * fire, never cause one. */
351
- function readTreeCpuPercent(pid) {
386
+ * descendant — via ONE `ps` shell-out, yielding both signals the wedge path
387
+ * needs: the summed tree CPU% (corroborates the heartbeat silence) and
388
+ * whether the tree has any descendant at all (issue #119 — decides whether
389
+ * remediation is "kill the subprocess" or "kick the broker"). Null on ANY
390
+ * failure (the shell-out itself failing, `ps` missing, or the root pid
391
+ * absent/output unparseable) wedgeVerdict treats a null cpuPercent exactly
392
+ * like “too high”, so a broken probe can only ever suppress a false fire,
393
+ * never cause one; a null sample also means handleWedgeDetection never
394
+ * attempts the SIGTERM kick on unconfirmed information. */
395
+ function readTreeSample(pid) {
352
396
  try {
353
397
  const r = spawnSync('ps', ['-ax', '-o', 'pid=,ppid=,pcpu='], { encoding: 'utf8', timeout: 2000 });
354
398
  if (r.status !== 0 || typeof r.stdout !== 'string')
355
399
  return null;
356
- return sumTreeCpu(r.stdout, pid);
400
+ const cpuPercent = sumTreeCpu(r.stdout, pid);
401
+ const hasDescendants = treeHasDescendants(r.stdout, pid);
402
+ if (cpuPercent === null || hasDescendants === null)
403
+ return null;
404
+ return { cpuPercent, hasDescendants };
357
405
  }
358
406
  catch {
359
407
  return null;
@@ -372,7 +420,8 @@ function handleWedgeDetection(id, pid, now) {
372
420
  }
373
421
  const since = busySince(id);
374
422
  const quietForMs = since === null ? null : now - since;
375
- const cpuPercent = quietForMs !== null && quietForMs >= WEDGE_QUIET_MS ? readTreeCpuPercent(pid) : null;
423
+ const sample = quietForMs !== null && quietForMs >= WEDGE_QUIET_MS ? readTreeSample(pid) : null;
424
+ const cpuPercent = sample?.cpuPercent ?? null;
376
425
  const verdict = wedgeVerdict({ busy: true, quietForMs, cpuPercent });
377
426
  if (verdict !== 'wedged')
378
427
  return;
@@ -383,11 +432,43 @@ function handleWedgeDetection(id, pid, now) {
383
432
  const meta = getNode(id);
384
433
  const name = meta !== null ? fullName(meta) : id;
385
434
  const minutes = Math.round((quietForMs ?? WEDGE_QUIET_MS) / 60_000);
386
- const label = `Child wedged ${name} (${id}) is alive and mid-turn but has shown NO engine progress for ` +
387
- `${minutes}+ minutes and its process is near-idle. It will NOT recover on its own read ` +
388
- `\`crtr memory read wedged-child-on-runaway-bash\` and, if a runaway subprocess is to blame, kill ` +
389
- `the SUBPROCESS (not this node) so its turn can resume with no lost context.`;
390
- process.stderr.write(`[crtrd] wedged ${id} (busy ${minutes}+ min with no engine progress, cpu~0%)\n`);
435
+ // A null sample can't reach here wedgeVerdict only fires 'wedged' on a
436
+ // confirmed near-zero cpuPercent, which requires a non-null sampleso
437
+ // `sample.hasDescendants` is always defined at this point; the `?? true`
438
+ // is a type-level fallback only, biased toward the SAFER branch (notify,
439
+ // don't SIGTERM) if that invariant is ever violated.
440
+ const hasDescendants = sample?.hasDescendants ?? true;
441
+ let label;
442
+ if (hasDescendants) {
443
+ // A runaway subprocess is the likely cause — the daemon does not touch the
444
+ // engine; the sanctioned recovery is manual (issue #110).
445
+ label =
446
+ `Child wedged — ${name} (${id}) is alive and mid-turn but has shown NO engine progress for ` +
447
+ `${minutes}+ minutes and its process is near-idle. It will NOT recover on its own — read ` +
448
+ `\`crtr memory read wedged-child-on-runaway-bash\` and kill the runaway SUBPROCESS (not this ` +
449
+ `node) so its turn can resume with no lost context.`;
450
+ }
451
+ else {
452
+ // No descendant to kill — the engine itself stalled (e.g. mid model_change)
453
+ // with nothing a human could kill. The daemon performs the same kick
454
+ // `canvas revive --now` does on demand: SIGTERM the broker, then its own
455
+ // dead-pid crash-grace path (REVIVE_GRACE_MS) resumes it on the saved
456
+ // session (issue #119).
457
+ label =
458
+ `Child wedged — ${name} (${id}) is alive and mid-turn but has shown NO engine progress for ` +
459
+ `${minutes}+ minutes, its process is near-idle, AND it has no subprocess to kill (the engine ` +
460
+ `itself stalled). Kicking it now: SIGTERM to the broker — the daemon's own crash-grace path ` +
461
+ `resumes it on the saved session within ~${Math.round(REVIVE_GRACE_MS / 1000)}s, no lost context. ` +
462
+ `If it comes back idle rather than resuming its turn, nudge it (see \`crtr memory read ` +
463
+ `wedged-child-on-runaway-bash\`).`;
464
+ try {
465
+ process.kill(pid, 'SIGTERM');
466
+ }
467
+ catch {
468
+ /* already gone — the dead-pid path picks it up on the very next tick regardless */
469
+ }
470
+ }
471
+ process.stderr.write(`[crtrd] wedged ${id} (busy ${minutes}+ min with no engine progress, cpu~0%, descendants=${hasDescendants})${hasDescendants ? '' : ' — kicked'}\n`);
391
472
  try {
392
473
  recordFault(id, {
393
474
  link: 'daemon→node',
@@ -406,6 +487,52 @@ function handleWedgeDetection(id, pid, now) {
406
487
  child: id,
407
488
  quiet_ms: quietForMs,
408
489
  cpu_percent: cpuPercent,
490
+ kicked: !hasDescendants,
491
+ });
492
+ }
493
+ catch {
494
+ /* best-effort, mirrors every other fan-out in this file */
495
+ }
496
+ }
497
+ /** Enact fatal-provider-fault notification for a LIVE node (issue #94; called
498
+ * from the pid-alive branch of handleNodeLiveness). A FATAL fault (auth /
499
+ * protocol / other on `pi→provider`) has no auto-retry consumer — the errored
500
+ * turn already ended, so the wedge detector (busy-only) never fires and the
501
+ * node sits `active` forever with a dormant broker. The daemon owns the
502
+ * analogous wedge fan, so it owns this too: fan ONE doctrine wake per fault
503
+ * episode telling subscribers the child will not self-recover (and, for auth,
504
+ * to re-authenticate so its next wake succeeds). Detection only — the node is
505
+ * deliberately NOT terminalized: the auth-reload fan + next wake are its real
506
+ * recovery path after a re-login. */
507
+ function handleFatalFault(id, now) {
508
+ void now;
509
+ const fault = readFault(id);
510
+ if (fault === null) {
511
+ fatalFaultNotifiedAt.delete(id); // fault cleared — next episode re-arms
512
+ return;
513
+ }
514
+ if (fault.retry.disposition !== 'fatal' || fault.link !== 'pi→provider')
515
+ return;
516
+ if (fatalFaultNotifiedAt.get(id) === fault.since)
517
+ return; // already fanned THIS episode
518
+ fatalFaultNotifiedAt.set(id, fault.since);
519
+ const meta = getNode(id);
520
+ const name = meta !== null ? fullName(meta) : id;
521
+ const isAuth = fault.kind === 'auth';
522
+ const firstLine = (fault.message.split('\n')[0] ?? '').slice(0, 200);
523
+ const label = `Child hit a fatal provider fault — ${name} (${id}) got a non-retryable ${fault.kind} error and will NOT recover on its own; ` +
524
+ `it stays alive but every wake re-fails until the fault clears. ` +
525
+ (isAuth
526
+ ? `Its provider credential is dead — re-authenticate (\`/login\`, or re-auth crtr); the child recovers on its next wake once the auth reloads. `
527
+ : ``) +
528
+ `Error: ${firstLine}`;
529
+ process.stderr.write(`[crtrd] fatal-fault ${id} (${fault.kind}, ${fault.link}) — fanning notice to subscribers\n`);
530
+ try {
531
+ fanDoctrineWake(id, subscribersOf(id), label, {
532
+ reason: 'child-fault-fatal',
533
+ child: id,
534
+ kind: fault.kind,
535
+ message: firstLine,
409
536
  });
410
537
  }
411
538
  catch {
@@ -497,6 +624,16 @@ export function livenessVerdict(piPidAlive, deadFor) {
497
624
  return 'pending';
498
625
  return 'revive';
499
626
  }
627
+ /** Whether a crash-retry of a dead/stranded engine should resume STRICTLY
628
+ * (true) or retry the SAME launch attempt's cycling mode (false). Mirrors
629
+ * reviveNode's own cycle_pending flag (set true right before every cycling
630
+ * launch, cleared the instant session_start confirms boot) — so a cycling
631
+ * revive that died pre-session_start is retried AS a cycle (re-appending the
632
+ * marker + kickoff), never stranded as a strict resume into an empty marker
633
+ * branch. Pure so it's unit-testable without a fabricated node. */
634
+ export function retryResumeMode(meta) {
635
+ return meta?.cycle_pending !== true;
636
+ }
500
637
  /** The ONE liveness path for EVERY node. Every node runs on a detached headless
501
638
  * broker; a tmux pane is only a viewer and is never consulted here — liveness is
502
639
  * the recorded engine pid (signal-0) alone. Reuses the existing supervision
@@ -523,7 +660,10 @@ export function livenessVerdict(piPidAlive, deadFor) {
523
660
  * pi_pid right after launch) → leave through a boot grace; a healthy engine
524
661
  * re-records its pid well within it. If the grace ELAPSES with still no pid,
525
662
  * the relaunch died after the pid-clear → grace-revive RESUME on the saved
526
- * session (else the node strands 'active' with no engine forever).
663
+ * session, UNLESS that dead relaunch was itself a cycling attempt
664
+ * (cycle_pending) — then retry it AS a cycle instead (retryResumeMode), so
665
+ * it doesn't strand in an empty marker branch (else the node strands
666
+ * 'active' with no engine forever).
527
667
  * • pid null + pi_session_id null (NEVER booted) → normally the sub-second SDK
528
668
  * boot gap, but a broker that throws BEFORE session_start records no pid and
529
669
  * no session ever — so after a boot grace with STILL nothing, crash +
@@ -533,7 +673,10 @@ export function livenessVerdict(piPidAlive, deadFor) {
533
673
  * • pid dead + intent==='idle-release' → dormant by choice; leave, the second
534
674
  * pass revives (resume) on the next unseen inbox entry.
535
675
  * • pid dead + any other intent (a crash) → grace-revive RESUME on the saved
536
- * session (livenessVerdict → REVIVE_GRACE_MS → unhealthySince). */
676
+ * session (livenessVerdict → REVIVE_GRACE_MS → unhealthySince), UNLESS the
677
+ * dead engine's last launch attempt was itself a cycling revive that never
678
+ * reached session_start (cycle_pending) — then retry it AS a cycle instead
679
+ * (retryResumeMode). */
537
680
  async function handleNodeLiveness(row, now, revivedThisTick) {
538
681
  const id = row.node_id;
539
682
  const pid = row.pi_pid;
@@ -545,6 +688,7 @@ async function handleNodeLiveness(row, now, revivedThisTick) {
545
688
  unhealthySince.delete(id);
546
689
  handleYieldStall(row, pid, now);
547
690
  handleWedgeDetection(id, pid, now);
691
+ handleFatalFault(id, now);
548
692
  return;
549
693
  }
550
694
  if (pid == null) {
@@ -594,9 +738,12 @@ async function handleNodeLiveness(row, now, revivedThisTick) {
594
738
  unhealthySince.delete(id);
595
739
  if (meta.pi_session_id != null) {
596
740
  // Grace elapsed with a session but still no pid → a relaunch that died after
597
- // the pid-clear, before re-record. Resume it on the saved session.
741
+ // the pid-clear, before re-record. Resume it on the saved session — unless
742
+ // that dead relaunch was itself a cycling attempt (cycle_pending), in which
743
+ // case retry it AS a cycle (retryResumeMode) instead of stranding it as a
744
+ // strict resume into an empty marker branch.
598
745
  process.stderr.write(`[crtrd] revive ${id} (stranded relaunch — pid never re-recorded)\n`);
599
- reviveNode(id, { resume: true });
746
+ reviveNode(id, { resume: retryResumeMode(meta) });
600
747
  revivedThisTick.add(id); // third-pass bare double-spawn guard (Maj-4)
601
748
  return;
602
749
  }
@@ -629,9 +776,13 @@ async function handleNodeLiveness(row, now, revivedThisTick) {
629
776
  unhealthySince.delete(id);
630
777
  return;
631
778
  }
632
- // Any other intent → a crash: grace-revive RESUME on the saved session.
633
- // reviveNode clears pi_pid until the fresh engine re-records it, so the next
634
- // tick won't re-fire on this stale pid.
779
+ // Any other intent → a crash: grace-revive RESUME on the saved session
780
+ // unless the dead engine's last launch attempt was itself a cycling revive
781
+ // that never reached session_start (cycle_pending), in which case retry it AS
782
+ // a cycle instead of stranding it as a strict resume into an empty marker
783
+ // branch. reviveNode clears pi_pid until the fresh engine re-records it, so
784
+ // the next tick won't re-fire on this stale pid.
785
+ const meta = getNode(id);
635
786
  const since = unhealthySince.get(id);
636
787
  const verdict = livenessVerdict(false, since === undefined ? null : now - since);
637
788
  if (verdict === 'pending') {
@@ -641,7 +792,7 @@ async function handleNodeLiveness(row, now, revivedThisTick) {
641
792
  }
642
793
  unhealthySince.delete(id);
643
794
  process.stderr.write(`[crtrd] revive ${id} (engine dead, intent=${String(row.intent)})\n`);
644
- reviveNode(id, { resume: true });
795
+ reviveNode(id, { resume: retryResumeMode(meta) });
645
796
  revivedThisTick.add(id); // third-pass bare double-spawn guard (Maj-4)
646
797
  }
647
798
  /** Fail loud for a drifted/broken trigger (design §6.6/Q5): wake the ARMER
package/dist/index.d.ts CHANGED
@@ -17,3 +17,6 @@ export { ViewSocketClient, BrokerUnavailableError } from './clients/attach/view-
17
17
  export { encodeFrame, FrameDecoder, FrameOverflowError, CLIENT_READ_CAPS, BROKER_READ_CAPS, } from './core/runtime/broker-protocol.js';
18
18
  export type { FrameDecoderCaps, BrokerSnapshot, ClientRole, WelcomeFrame, ControlChangedFrame, AckFrame, ErrorFrame, ExtensionUIRequestFrame, BrokerToClient, HelloFrame, PromptFrame, SteerFrame, FollowUpFrame, AbortFrame, RequestControlFrame, ReleaseControlFrame, ByeFrame, ShutdownFrame, SetModelFrame, CycleModelFrame, SetThinkingLevelFrame, SetAutoRetryFrame, SetAutoCompactionFrame, CompactFrame, NewSessionFrame, SwitchSessionFrame, ForkFrame, SetSessionNameFrame, GetCommandsFrame, NavigateTreeFrame, ReloadFrame, ExportFrame, ClientToBroker, RpcExtensionUIRequest, RpcExtensionUIResponse, ExtensionUIResponseFrame, } from './core/runtime/broker-protocol.js';
19
19
  export type { SessionStats } from '@earendil-works/pi-coding-agent';
20
+ export { general } from './core/errors.js';
21
+ export type { CrtrError } from './core/errors.js';
22
+ export { nowIso } from './core/fs-utils.js';
package/dist/index.js CHANGED
@@ -25,3 +25,10 @@ export { readTelemetry, readContextTokens } from './core/canvas/telemetry.js';
25
25
  export { ViewSocketClient, BrokerUnavailableError } from './clients/attach/view-socket.js';
26
26
  // ── Broker wire protocol (codec + frame/protocol types) ──────────────────
27
27
  export { encodeFrame, FrameDecoder, FrameOverflowError, CLIENT_READ_CAPS, BROKER_READ_CAPS, } from './core/runtime/broker-protocol.js';
28
+ // ── Utility exports ───────────────────────────────────────────────────────
29
+ // Small side-effect-free helpers consumed by external callers that assemble
30
+ // on top of crtr primitives (e.g. a guest-side process reading crtr's error
31
+ // shape and timestamp convention). Intentionally narrow: only `general` (not
32
+ // `CrtrError`/`notFound`/`usage`/the other error helpers) and `nowIso`.
33
+ export { general } from './core/errors.js';
34
+ export { nowIso } from './core/fs-utils.js';
@@ -20,6 +20,7 @@ interface SessionEntryLike {
20
20
  interface SessionStartCtxLike {
21
21
  sessionManager: {
22
22
  getEntries: () => SessionEntryLike[];
23
+ getBranch?: (fromId?: string) => SessionEntryLike[];
23
24
  };
24
25
  }
25
26
  interface CustomMessageLike {
@@ -24,18 +24,23 @@
24
24
  // thing a terminal worker's bearings drop). The prose lives in
25
25
  // core/runtime/bearings.ts (shared with the promotion guidance dump).
26
26
  //
27
- // IDEMPOTENT across resumes, but FORK-AWARE: a `--session` relaunch restores OUR
28
- // conversation (whose bearings name OUR node id), so the session_start handler
29
- // sees it via `sessionManager.getEntries()` and skips it never accumulates. A
30
- // `--fork-from` boot, by contrast, COPIES the source node's whole conversation
31
- // (whose bearings name the SOURCE's node id), so the handler must NOT treat that
32
- // inherited block as ours; it only skips when a bearings block belonging to OUR
33
- // node is already present (exact `details.nodeId` stamp, content-match
34
- // fallback), otherwise it injects ours whose identity assertion (leading the
35
- // <crtr-bearings> block) reasserts the fork's identity over the inherited persona. This is ONE of two reinforcing
36
- // channels: spawn.ts ALSO prepends the same identity assertion to a fork's
37
- // kickoff prompt (the turn-triggering message), so the override does not rest on
38
- // a single trailing custom_message.
27
+ // IDEMPOTENT across resumes, but FORK-AWARE and BRANCH-AWARE: the idempotency
28
+ // scan walks only the ACTIVE BRANCH (root-first from the current leaf), not
29
+ // every entry in the file. A `--session` relaunch that is a true resume keeps
30
+ // the same branch, so the scan finds OUR prior bearings (named by OUR node id)
31
+ // and skips it never accumulates. A post-yield CYCLE roots a fresh branch (the
32
+ // `crtr-cycle` marker), so the scan correctly finds nothing on that fresh branch
33
+ // and injects a new intro, even though an OLDER cycle's intro still sits
34
+ // elsewhere in the same `.jsonl`. A `--fork-from` boot COPIES the source node's
35
+ // whole conversation onto ITS OWN branch (bearings naming the SOURCE's node id,
36
+ // not ours), so the handler must NOT treat that inherited block as ours; it only
37
+ // skips when a bearings block belonging to OUR node is already present on the
38
+ // active branch (exact `details.nodeId` stamp, content-match fallback),
39
+ // otherwise it injects ours — whose identity assertion (leading the
40
+ // <crtr-bearings> block) reasserts the fork's identity over the inherited
41
+ // persona. This is ONE of two reinforcing channels: spawn.ts ALSO prepends the
42
+ // same identity assertion to a fork's kickoff prompt (the turn-triggering
43
+ // message), so the override does not rest on a single trailing custom_message.
39
44
  //
40
45
  // COLLAPSED BY DEFAULT: a `registerMessageRenderer` keyed to our customType
41
46
  // renders the block as a single one-line stub; the full body only appears when
@@ -166,22 +171,28 @@ export function registerCanvasContextIntro(pi) {
166
171
  const nodeId = process.env['CRTR_NODE_ID'];
167
172
  if (nodeId === undefined || nodeId.trim() === '')
168
173
  return; // not a canvas node
169
- // Idempotent on RESUME, but NOT fooled by a FORK. A `--session` relaunch
170
- // restores OUR conversation, whose bearings name OUR node id skip then,
171
- // so the block never accumulates. A `--fork-from` boot instead copies the
172
- // SOURCE node's whole conversation (its bearings name ITS node id, not
173
- // ours), so a naive "any crtr-context present?" check would suppress our
174
- // own intro and let the fork inherit and impersonate — the source. So we
175
- // only skip when a bearings block belonging to OUR node is already present.
176
- // Primary discriminator: the EXACT `details.nodeId` stamp (machine-
174
+ // Branch-aware idempotency: scan only the ACTIVE branch (root-first from
175
+ // the current leaf), not every entry in the file. A post-yield cycle roots a
176
+ // FRESH branch (the crtr-cycle marker), so scanning the whole file would
177
+ // find a PRIOR cycle's intro and wrongly skip injecting this cycle's own
178
+ // the node's first turn would lack its crtr-context bearings entirely.
179
+ // Falls back to a whole-file scan only if the engine doesn't expose
180
+ // getBranch (older/degraded engine). Not fooled by a FORK either way: a
181
+ // `--fork-from` boot copies the source's whole conversation onto ITS OWN
182
+ // branch (bearings naming the SOURCE's node id, not ours), so a naive
183
+ // "any crtr-context present?" check would suppress our own intro and let
184
+ // the fork inherit — and impersonate — the source. So we only skip when a
185
+ // bearings block belonging to OUR node is already present on the scanned
186
+ // branch. Primary discriminator: the EXACT `details.nodeId` stamp (machine-
177
187
  // readable, copied on fork, never sent to the LLM). Fallback: a substring
178
188
  // match on the block text (our id appears in the identity line AND the
179
189
  // context-dir path `…/nodes/<nodeId>/context`) — covers legacy blocks
180
190
  // persisted before the stamp existed. Either match ⇒ this is our own
181
191
  // resume, not an inherited fork block, so skip.
182
- const ours = ctx.sessionManager
183
- .getEntries()
184
- .some((e) => e.type === 'custom_message' &&
192
+ const scan = typeof ctx.sessionManager.getBranch === 'function'
193
+ ? ctx.sessionManager.getBranch()
194
+ : ctx.sessionManager.getEntries();
195
+ const ours = scan.some((e) => e.type === 'custom_message' &&
185
196
  e.customType === CONTEXT_INTRO_CUSTOM_TYPE &&
186
197
  (e.details?.nodeId === nodeId || entryText(e).includes(nodeId)));
187
198
  if (ours)
@@ -314,16 +314,22 @@ export function registerCanvasStophook(pi) {
314
314
  }
315
315
  // Boot / startup / resume / reload / fork → (re)bind this process to its
316
316
  // session id, record our OS pid (the daemon's broker-liveness signal), and
317
- // CONFIRM any pending refresh-yield. Reaching session_start proves a fresh
318
- // engine actually booted, so it is now safe to clear intent='refresh' — a
319
- // refresh shuts the broker down and the daemon relaunches it FRESH, which
320
- // leaves intent='refresh' set until this boot clears it (the only proof the
321
- // relaunch worked; otherwise a failed relaunch would look like a success).
317
+ // CONFIRM any pending refresh-yield / pending cycle. Reaching session_start
318
+ // proves a fresh engine actually booted, so it is now safe to clear
319
+ // intent='refresh' — a refresh shuts the broker down and the daemon
320
+ // relaunches it FRESH, which leaves intent='refresh' set until this boot
321
+ // clears it (the only proof the relaunch worked; otherwise a failed relaunch
322
+ // would look like a success). Clearing `cycle_pending` here is the same
323
+ // proof for reviveNode's cycling-attempt marker (revive.ts) — it is always
324
+ // safe to clear on ANY confirmed boot (cycling, resuming, or fresh), since a
325
+ // pre-session_start crash-retry (crtrd.ts) only needs the flag while boot is
326
+ // still unconfirmed.
322
327
  const existing = getNode(nodeId);
323
328
  // Identity (session id/file) → meta; runtime (pid, intent) → atomic row setters.
324
329
  updateNode(nodeId, {
325
330
  pi_session_id: id,
326
331
  pi_session_file: sessionFile,
332
+ cycle_pending: false,
327
333
  });
328
334
  recordPid(nodeId, process.pid);
329
335
  if (existing?.intent === 'refresh')