@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
@@ -0,0 +1,158 @@
1
+ import { existsSync, mkdirSync, rmdirSync } from 'node:fs';
2
+ import { gitSync } from './git.js';
3
+ import { crtrHome } from './canvas/paths.js';
4
+ import { getNode, updateNode } from './canvas/index.js';
5
+ export class WorktreeError extends Error {
6
+ code;
7
+ next;
8
+ detail;
9
+ constructor(code, message, next, detail) {
10
+ super(message);
11
+ this.name = 'WorktreeError';
12
+ this.code = code;
13
+ this.next = next;
14
+ this.detail = detail;
15
+ }
16
+ }
17
+ function runGit(cwd, args, code, next) {
18
+ const res = gitSync(args, cwd);
19
+ if (res.status !== 0) {
20
+ const detail = (res.stderr.trim() || res.stdout.trim()).trim();
21
+ throw new WorktreeError(code, `git ${args.join(' ')} failed`, next, detail);
22
+ }
23
+ return res.stdout.trim();
24
+ }
25
+ export function managedWorktreesRoot() {
26
+ return `${crtrHome()}/worktrees`;
27
+ }
28
+ export function managedWorktreePath(nodeId) {
29
+ return `${managedWorktreesRoot()}/${nodeId}`;
30
+ }
31
+ export function hasOpenManagedWorktree(node) {
32
+ return node?.managed_worktree?.state === 'open';
33
+ }
34
+ export function openManagedWorktreeForNode(nodeId) {
35
+ const node = getNode(nodeId);
36
+ const wt = node?.managed_worktree;
37
+ return wt?.state === 'open' ? wt : null;
38
+ }
39
+ function repoRootFor(cwd) {
40
+ return runGit(cwd, ['rev-parse', '--show-toplevel'], 'not_git_repo', 'Pass --cwd pointing inside a git repository.');
41
+ }
42
+ function requireOriginMain(repoRoot) {
43
+ return runGit(repoRoot, ['rev-parse', 'origin/main'], 'missing_origin_main', 'Managed worktrees land by rebasing onto origin/main. Fetch/create origin/main, then retry.');
44
+ }
45
+ export function createManagedWorktree(cwd, nodeId) {
46
+ const repoRoot = repoRootFor(cwd);
47
+ const baseSha = requireOriginMain(repoRoot);
48
+ const path = managedWorktreePath(nodeId);
49
+ if (existsSync(path)) {
50
+ throw new WorktreeError('worktree_path_exists', `managed worktree path already exists: ${path}`, 'Remove the stale path or choose a fresh node id.');
51
+ }
52
+ const branch = `crtr/${nodeId}`;
53
+ runGit(repoRoot, ['worktree', 'add', '-b', branch, path, 'origin/main'], 'worktree_add_failed', 'Inspect the git error, fix the repository/worktree state, then retry spawning with --worktree.');
54
+ return {
55
+ state: 'open',
56
+ path,
57
+ branch,
58
+ repo_root: repoRoot,
59
+ base_ref: 'origin/main',
60
+ base_sha: baseSha,
61
+ created: new Date().toISOString(),
62
+ };
63
+ }
64
+ function statusPorcelain(cwd) {
65
+ return runGit(cwd, ['status', '--porcelain'], 'status_failed', 'Inspect the worktree manually, then retry.');
66
+ }
67
+ function currentBranch(cwd) {
68
+ return runGit(cwd, ['rev-parse', '--abbrev-ref', 'HEAD'], 'branch_check_failed', 'Inspect the worktree manually, then retry.');
69
+ }
70
+ function gitPath(cwd, logicalPath) {
71
+ return runGit(cwd, ['rev-parse', '--git-path', logicalPath], 'git_path_failed', 'Inspect the worktree manually, then retry.');
72
+ }
73
+ export function isRebaseInProgress(cwd) {
74
+ return existsSync(gitPath(cwd, 'rebase-merge')) || existsSync(gitPath(cwd, 'rebase-apply'));
75
+ }
76
+ export function closeManagedWorktree(nodeId) {
77
+ const node = getNode(nodeId);
78
+ if (node === null) {
79
+ throw new WorktreeError('unknown_node', `unknown node: ${nodeId}`, 'Run this from a live node, or pass a real node id once that command supports it.');
80
+ }
81
+ const wt = openManagedWorktreeForNode(nodeId);
82
+ if (wt === null) {
83
+ throw new WorktreeError('no_managed_worktree', `node ${nodeId} has no open managed worktree`, 'Nothing to close for this node.');
84
+ }
85
+ if (!existsSync(wt.path)) {
86
+ throw new WorktreeError('worktree_missing', `managed worktree path is missing: ${wt.path}`, 'Ask the human with `crtr human ask` before changing node state; the checkout disappeared outside crouter.');
87
+ }
88
+ if (isRebaseInProgress(wt.path)) {
89
+ throw new WorktreeError('rebase_in_progress', 'this managed worktree is mid-rebase', `Resolve conflicts in ${wt.path}, run \`git rebase --continue\` there, then rerun \`crtr worktree close\`.`);
90
+ }
91
+ const dirty = statusPorcelain(wt.path);
92
+ if (dirty !== '') {
93
+ throw new WorktreeError('dirty_worktree', 'managed worktree has uncommitted changes', `Commit the work in ${wt.path} (or ask the human with \`crtr human ask\` if you do not know whether to keep it), then rerun \`crtr worktree close\`.`);
94
+ }
95
+ const branch = currentBranch(wt.path);
96
+ if (branch !== wt.branch) {
97
+ const found = branch === 'HEAD' ? 'detached HEAD' : branch;
98
+ throw new WorktreeError('wrong_branch', `managed worktree is not on its recorded branch (expected ${wt.branch}, found ${found})`, `Check out ${wt.branch} in ${wt.path} before closing, or ask the human with \`crtr human ask\` if you are unsure why it changed, then rerun \`crtr worktree close\`.`);
99
+ }
100
+ runGit(wt.path, ['fetch', 'origin', 'main'], 'fetch_failed', 'Network/remote state blocked landing. Ask the human with `crtr human ask` if this is not a transient git/network issue.');
101
+ const rebase = gitSync(['rebase', 'origin/main'], wt.path);
102
+ if (rebase.status !== 0) {
103
+ const detail = (rebase.stderr.trim() || rebase.stdout.trim()).trim();
104
+ throw new WorktreeError('rebase_failed', 'rebase onto origin/main did not complete', `Resolve conflicts in ${wt.path}, run \`git rebase --continue\` there, then rerun \`crtr worktree close\`. If the blocker is not mechanical, ask the human with \`crtr human ask\`.`, detail);
105
+ }
106
+ const push = gitSync(['push', 'origin', 'HEAD:main'], wt.path);
107
+ if (push.status !== 0) {
108
+ const detail = (push.stderr.trim() || push.stdout.trim()).trim();
109
+ throw new WorktreeError('push_main_failed', 'could not fast-forward/push origin/main from this worktree', 'Do not force-push main. Ask the human with `crtr human ask` how to land this managed worktree.', detail);
110
+ }
111
+ // The push landing IS the point of no return: persist `state: 'closed'`
112
+ // IMMEDIATELY, before reading the landed sha or attempting any cleanup below.
113
+ // Worktree removal and branch deletion are best-effort from here on — their
114
+ // failure is cosmetic (a stray checkout / local ref) and must never re-strand
115
+ // `state: 'open'` behind a dead end that permanently blocks push-final.
116
+ updateNode(nodeId, { managed_worktree: { ...wt, state: 'closed', closed: new Date().toISOString() } });
117
+ const pushedSha = runGit(wt.path, ['rev-parse', 'HEAD'], 'pushed_sha_failed', 'The branch landed and the node is already marked closed, but crouter could not read the landed commit SHA. Inspect the checkout manually to confirm the landed commit; no further close action is needed.');
118
+ const remove = gitSync(['worktree', 'remove', wt.path], wt.repo_root);
119
+ const worktreeRemoved = remove.status === 0;
120
+ if (worktreeRemoved && existsSync(wt.path)) {
121
+ // `git worktree remove` can leave the (now empty) mount dir behind; tidy it best-effort.
122
+ try {
123
+ rmdirSync(wt.path);
124
+ }
125
+ catch { /* non-empty or busy — leave it */ }
126
+ }
127
+ // wt.path IS the calling node's own pi-process cwd — a process can never chdir
128
+ // itself out of a directory that vanishes out from under it, so once removal
129
+ // deletes it every subsequent bash/chdir in this node fails permanently and the
130
+ // mandated post-close `crtr push final` can never run. Recreate it as an empty
131
+ // tombstone directory so the path still resolves; the branch is already landed
132
+ // and `state: 'closed'` already persisted above, so this is purely about keeping
133
+ // the node's cwd alive, not about worktree bookkeeping.
134
+ if (!existsSync(wt.path)) {
135
+ try {
136
+ mkdirSync(wt.path, { recursive: true });
137
+ }
138
+ catch { /* best-effort; leaves cwd broken only if this also fails */ }
139
+ }
140
+ const worktreeRemoveError = worktreeRemoved
141
+ ? undefined
142
+ : `local worktree checkout at ${wt.path} landed but could not be removed automatically (${(remove.stderr.trim() || remove.stdout.trim()).trim()}); run \`git -C ${wt.repo_root} worktree remove ${wt.path}\` after confirming it is safe to delete.`;
143
+ const branchDelete = gitSync(['branch', '-d', wt.branch], wt.repo_root);
144
+ const branchDeleted = branchDelete.status === 0;
145
+ const branchDeleteError = branchDeleted
146
+ ? undefined
147
+ : `local branch ${wt.branch} landed but could not be deleted automatically; run \`git -C ${wt.repo_root} branch -d ${wt.branch}\` after confirming it is merged.`;
148
+ return {
149
+ node_id: nodeId,
150
+ branch: wt.branch,
151
+ worktree_path: wt.path,
152
+ pushed_sha: pushedSha,
153
+ worktree_removed: worktreeRemoved,
154
+ ...(worktreeRemoveError !== undefined ? { worktree_remove_error: worktreeRemoveError } : {}),
155
+ branch_deleted: branchDeleted,
156
+ ...(branchDeleteError !== undefined ? { branch_delete_error: branchDeleteError } : {}),
157
+ };
158
+ }
@@ -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 {