@crouton-kit/crouter 0.3.18 → 0.3.20

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 (289) hide show
  1. package/dist/build-root.js +6 -0
  2. package/dist/builtin-memory/crouter-development/marketplaces.md +164 -0
  3. package/dist/builtin-memory/crouter-development/personas/base-prompt.md +58 -0
  4. package/dist/builtin-memory/crouter-development/personas/orchestrator-prompt.md +60 -0
  5. package/dist/builtin-memory/crouter-development/personas.md +117 -0
  6. package/dist/builtin-memory/crouter-development/plugins.md +163 -0
  7. package/dist/builtin-memory/design.md +60 -0
  8. package/dist/builtin-memory/development.md +116 -0
  9. package/dist/builtin-memory/planning.md +66 -0
  10. package/dist/builtin-memory/spec.md +99 -0
  11. package/dist/builtin-personas/design/PERSONA.md +1 -0
  12. package/dist/builtin-personas/design/orchestrator.md +2 -1
  13. package/dist/builtin-personas/developer/PERSONA.md +1 -0
  14. package/dist/builtin-personas/developer/orchestrator.md +2 -1
  15. package/dist/builtin-personas/explore/PERSONA.md +1 -0
  16. package/dist/builtin-personas/explore/orchestrator.md +4 -0
  17. package/dist/builtin-personas/general/PERSONA.md +1 -0
  18. package/dist/builtin-personas/general/orchestrator.md +4 -0
  19. package/dist/builtin-personas/lifecycle/resident.md +1 -1
  20. package/dist/builtin-personas/lifecycle/terminal.md +5 -2
  21. package/dist/builtin-personas/orchestration-kernel.md +15 -24
  22. package/dist/builtin-personas/plan/PERSONA.md +1 -0
  23. package/dist/builtin-personas/plan/orchestrator.md +2 -1
  24. package/dist/builtin-personas/plan/reviewers/architecture-fit/PERSONA.md +1 -0
  25. package/dist/builtin-personas/plan/reviewers/code-smells/PERSONA.md +1 -0
  26. package/dist/builtin-personas/plan/reviewers/pattern-consistency/PERSONA.md +1 -0
  27. package/dist/builtin-personas/plan/reviewers/requirements-coverage/PERSONA.md +1 -0
  28. package/dist/builtin-personas/plan/reviewers/security/PERSONA.md +1 -0
  29. package/dist/builtin-personas/review/PERSONA.md +1 -0
  30. package/dist/builtin-personas/review/orchestrator.md +4 -0
  31. package/dist/builtin-personas/runtime-base.md +6 -3
  32. package/dist/builtin-personas/spec/PERSONA.md +1 -0
  33. package/dist/builtin-personas/spec/orchestrator.md +2 -1
  34. package/dist/builtin-personas/waiting.md +8 -0
  35. package/dist/builtin-skills/skills/crouter-development/personas/orchestrator-prompt/SKILL.md +1 -1
  36. package/dist/builtin-skills/skills/crouter-development/plugins/SKILL.md +2 -2
  37. package/dist/builtin-views/_lib/states.mjs +161 -0
  38. package/dist/builtin-views/canvas/client.mjs +303 -0
  39. package/dist/builtin-views/canvas/view.mjs +576 -0
  40. package/dist/builtin-views/git-pr/client.mjs +440 -0
  41. package/dist/builtin-views/git-pr/view.mjs +675 -0
  42. package/dist/builtin-views/inbox/_lib/render.mjs +177 -0
  43. package/dist/builtin-views/inbox/sources/gmail.mjs +965 -0
  44. package/dist/builtin-views/inbox/sources/linkedin.mjs +427 -0
  45. package/dist/builtin-views/inbox/view.mjs +889 -0
  46. package/dist/builtin-views/linkedin/client.mjs +610 -0
  47. package/dist/builtin-views/linkedin/view.mjs +1171 -0
  48. package/dist/clients/attach/attach-cmd.d.ts +2 -0
  49. package/dist/clients/attach/attach-cmd.js +354 -0
  50. package/dist/clients/attach/chat-view.d.ts +77 -0
  51. package/dist/clients/attach/chat-view.js +450 -0
  52. package/dist/clients/attach/clipboard-image.d.ts +16 -0
  53. package/dist/clients/attach/clipboard-image.js +113 -0
  54. package/dist/clients/attach/config-load.d.ts +31 -0
  55. package/dist/clients/attach/config-load.js +113 -0
  56. package/dist/clients/attach/extension-dialogs.d.ts +29 -0
  57. package/dist/clients/attach/extension-dialogs.js +101 -0
  58. package/dist/clients/attach/input-controller.d.ts +54 -0
  59. package/dist/clients/attach/input-controller.js +204 -0
  60. package/dist/clients/attach/slash-commands.d.ts +36 -0
  61. package/dist/clients/attach/slash-commands.js +200 -0
  62. package/dist/clients/attach/view-socket.d.ts +48 -0
  63. package/dist/clients/attach/view-socket.js +126 -0
  64. package/dist/commands/attention.js +3 -3
  65. package/dist/commands/canvas-prune.js +1 -1
  66. package/dist/commands/daemon.js +3 -2
  67. package/dist/commands/human/prompts.js +1 -1
  68. package/dist/commands/human/queue.js +54 -8
  69. package/dist/commands/human/shared.d.ts +2 -0
  70. package/dist/commands/memory/find.d.ts +1 -0
  71. package/dist/commands/memory/find.js +180 -0
  72. package/dist/commands/memory/lint.d.ts +1 -0
  73. package/dist/commands/memory/lint.js +140 -0
  74. package/dist/commands/memory/list.d.ts +1 -0
  75. package/dist/commands/memory/list.js +79 -0
  76. package/dist/commands/memory/read.js +103 -0
  77. package/dist/commands/memory/shared.d.ts +30 -0
  78. package/dist/commands/memory/shared.js +122 -0
  79. package/dist/commands/memory/write.d.ts +1 -0
  80. package/dist/commands/memory/write.js +85 -0
  81. package/dist/commands/memory.d.ts +2 -0
  82. package/dist/commands/memory.js +27 -0
  83. package/dist/commands/node.d.ts +3 -2
  84. package/dist/commands/node.js +610 -61
  85. package/dist/commands/pkg/market-manage.js +1 -1
  86. package/dist/commands/push.js +6 -6
  87. package/dist/commands/revive.js +1 -1
  88. package/dist/commands/skill/author.js +1 -1
  89. package/dist/commands/skill/shared.d.ts +1 -8
  90. package/dist/commands/skill/shared.js +2 -55
  91. package/dist/commands/skill.js +9 -14
  92. package/dist/commands/sys/doctor.js +1 -1
  93. package/dist/commands/sys/update.js +1 -1
  94. package/dist/commands/view-cycle.d.ts +2 -0
  95. package/dist/commands/view-cycle.js +125 -0
  96. package/dist/commands/view-list.d.ts +2 -0
  97. package/dist/commands/view-list.js +66 -0
  98. package/dist/commands/view-new.d.ts +2 -0
  99. package/dist/commands/view-new.js +70 -0
  100. package/dist/commands/view-pick.d.ts +2 -0
  101. package/dist/commands/view-pick.js +119 -0
  102. package/dist/commands/view-run.d.ts +2 -0
  103. package/dist/commands/view-run.js +191 -0
  104. package/dist/commands/view.d.ts +2 -0
  105. package/dist/commands/view.js +29 -0
  106. package/dist/core/__tests__/broker-attach-limits.test.d.ts +1 -0
  107. package/dist/core/__tests__/broker-attach-limits.test.js +157 -0
  108. package/dist/core/__tests__/broker-attach-stream.test.d.ts +1 -0
  109. package/dist/core/__tests__/broker-attach-stream.test.js +125 -0
  110. package/dist/core/__tests__/broker-crash-teardown.test.d.ts +1 -0
  111. package/dist/core/__tests__/broker-crash-teardown.test.js +116 -0
  112. package/dist/core/__tests__/broker-dialogs.test.d.ts +1 -0
  113. package/dist/core/__tests__/broker-dialogs.test.js +126 -0
  114. package/dist/core/__tests__/broker-dormant-wake.test.d.ts +1 -0
  115. package/dist/core/__tests__/broker-dormant-wake.test.js +51 -0
  116. package/dist/core/__tests__/broker-lifecycle.test.d.ts +1 -0
  117. package/dist/core/__tests__/broker-lifecycle.test.js +87 -0
  118. package/dist/core/__tests__/broker-sdk-wiring.test.d.ts +1 -0
  119. package/dist/core/__tests__/broker-sdk-wiring.test.js +166 -0
  120. package/dist/core/__tests__/canvas-inbox-watcher-hold.test.d.ts +1 -0
  121. package/dist/core/__tests__/canvas-inbox-watcher-hold.test.js +102 -0
  122. package/dist/core/__tests__/canvas-inbox-watcher.test.js +2 -36
  123. package/dist/core/__tests__/cascade-close.test.js +12 -2
  124. package/dist/core/__tests__/child-death-wake.test.d.ts +1 -0
  125. package/dist/core/__tests__/child-death-wake.test.js +245 -0
  126. package/dist/core/__tests__/context-intro.test.js +76 -62
  127. package/dist/core/__tests__/daemon-boot.test.js +14 -5
  128. package/dist/core/__tests__/daemon-liveness.test.js +34 -9
  129. package/dist/core/__tests__/detach-focus.test.d.ts +1 -0
  130. package/dist/core/__tests__/detach-focus.test.js +206 -0
  131. package/dist/core/__tests__/draw-style.test.d.ts +1 -0
  132. package/dist/core/__tests__/draw-style.test.js +258 -0
  133. package/dist/core/__tests__/fixtures/c3-custom-provider-ext.d.ts +2 -0
  134. package/dist/core/__tests__/fixtures/c3-custom-provider-ext.js +18 -0
  135. package/dist/core/__tests__/fixtures/fake-engine.d.ts +138 -0
  136. package/dist/core/__tests__/fixtures/fake-engine.js +614 -0
  137. package/dist/core/__tests__/fixtures/fake-pi-host.js +1 -0
  138. package/dist/core/__tests__/flagship-lifecycle.test.js +7 -1
  139. package/dist/core/__tests__/frame-decoder-perf.test.d.ts +1 -0
  140. package/dist/core/__tests__/frame-decoder-perf.test.js +198 -0
  141. package/dist/core/__tests__/helpers/broker-clients.d.ts +43 -0
  142. package/dist/core/__tests__/helpers/broker-clients.js +178 -0
  143. package/dist/core/__tests__/helpers/harness.d.ts +9 -0
  144. package/dist/core/__tests__/helpers/harness.js +111 -1
  145. package/dist/core/__tests__/home-session.test.js +41 -8
  146. package/dist/core/__tests__/human-new-window-regression.test.d.ts +1 -0
  147. package/dist/core/__tests__/human-new-window-regression.test.js +101 -0
  148. package/dist/core/__tests__/kickoff.test.js +37 -3
  149. package/dist/core/__tests__/live-mutation-verbs.test.d.ts +1 -0
  150. package/dist/core/__tests__/live-mutation-verbs.test.js +175 -0
  151. package/dist/core/__tests__/live-mutation.test.js +6 -132
  152. package/dist/core/__tests__/memory.test.js +23 -115
  153. package/dist/core/__tests__/placement-focus.test.js +5 -0
  154. package/dist/core/__tests__/placement-teardown.test.js +54 -11
  155. package/dist/core/__tests__/relaunch.test.js +4 -3
  156. package/dist/core/__tests__/review-render-pane-regression.test.d.ts +1 -0
  157. package/dist/core/__tests__/review-render-pane-regression.test.js +133 -0
  158. package/dist/core/__tests__/spawn-root.test.js +10 -0
  159. package/dist/core/__tests__/spike-harness.test.js +1 -0
  160. package/dist/core/__tests__/wake-bearings.test.d.ts +1 -0
  161. package/dist/core/__tests__/wake-bearings.test.js +156 -0
  162. package/dist/core/__tests__/wake-origin.test.d.ts +1 -0
  163. package/dist/core/__tests__/wake-origin.test.js +110 -0
  164. package/dist/core/bootstrap.js +1 -1
  165. package/dist/core/canvas/browse/__tests__/render.test.js +1 -0
  166. package/dist/core/canvas/browse/app.js +24 -2
  167. package/dist/core/canvas/browse/model.d.ts +38 -2
  168. package/dist/core/canvas/browse/model.js +134 -10
  169. package/dist/core/canvas/browse/render.d.ts +6 -12
  170. package/dist/core/canvas/browse/render.js +72 -104
  171. package/dist/core/canvas/canvas.js +16 -23
  172. package/dist/core/canvas/db.js +46 -0
  173. package/dist/core/canvas/index.d.ts +1 -0
  174. package/dist/core/canvas/index.js +1 -0
  175. package/dist/core/canvas/pid.d.ts +4 -0
  176. package/dist/core/canvas/pid.js +23 -0
  177. package/dist/core/canvas/render.d.ts +11 -2
  178. package/dist/core/canvas/render.js +69 -0
  179. package/dist/core/canvas/types.d.ts +91 -3
  180. package/dist/core/canvas/wakeups.d.ts +76 -0
  181. package/dist/core/canvas/wakeups.js +185 -0
  182. package/dist/core/config.js +4 -1
  183. package/dist/core/frontmatter.js +37 -124
  184. package/dist/core/memory-resolver.d.ts +49 -0
  185. package/dist/core/memory-resolver.js +141 -0
  186. package/dist/core/personas/loader.d.ts +7 -0
  187. package/dist/core/personas/loader.js +36 -5
  188. package/dist/core/personas/resolve.js +13 -3
  189. package/dist/core/predicate.d.ts +63 -0
  190. package/dist/core/predicate.js +189 -0
  191. package/dist/core/resolver.js +26 -5
  192. package/dist/core/runtime/bearings.d.ts +53 -12
  193. package/dist/core/runtime/bearings.js +132 -59
  194. package/dist/core/runtime/broker-cli.d.ts +1 -0
  195. package/dist/core/runtime/broker-cli.js +46 -0
  196. package/dist/core/runtime/broker-protocol.d.ts +332 -0
  197. package/dist/core/runtime/broker-protocol.js +153 -0
  198. package/dist/core/runtime/broker-sdk.d.ts +48 -0
  199. package/dist/core/runtime/broker-sdk.js +72 -0
  200. package/dist/core/runtime/broker.d.ts +55 -0
  201. package/dist/core/runtime/broker.js +1128 -0
  202. package/dist/core/runtime/close.js +35 -6
  203. package/dist/core/runtime/host.d.ts +53 -0
  204. package/dist/core/runtime/host.js +186 -0
  205. package/dist/core/runtime/kickoff.d.ts +2 -1
  206. package/dist/core/runtime/kickoff.js +91 -5
  207. package/dist/core/runtime/launch.d.ts +54 -4
  208. package/dist/core/runtime/launch.js +85 -5
  209. package/dist/core/runtime/lifecycle.js +23 -6
  210. package/dist/core/runtime/memory.d.ts +2 -42
  211. package/dist/core/runtime/memory.js +11 -162
  212. package/dist/core/runtime/nodes.d.ts +37 -0
  213. package/dist/core/runtime/nodes.js +60 -1
  214. package/dist/core/runtime/persona.js +21 -11
  215. package/dist/core/runtime/pi-vendored.d.ts +18 -0
  216. package/dist/core/runtime/pi-vendored.js +49 -0
  217. package/dist/core/runtime/placement.d.ts +32 -14
  218. package/dist/core/runtime/placement.js +206 -52
  219. package/dist/core/runtime/promote.d.ts +0 -6
  220. package/dist/core/runtime/promote.js +4 -12
  221. package/dist/core/runtime/{demote.d.ts → recycle.d.ts} +5 -5
  222. package/dist/core/runtime/{demote.js → recycle.js} +27 -11
  223. package/dist/core/runtime/reset.js +11 -8
  224. package/dist/core/runtime/revive.d.ts +2 -0
  225. package/dist/core/runtime/revive.js +34 -34
  226. package/dist/core/runtime/spawn.d.ts +23 -0
  227. package/dist/core/runtime/spawn.js +77 -23
  228. package/dist/core/runtime/stop-guard.d.ts +1 -1
  229. package/dist/core/runtime/stop-guard.js +6 -1
  230. package/dist/core/runtime/tmux-chrome.d.ts +1 -1
  231. package/dist/core/runtime/tmux-chrome.js +1 -1
  232. package/dist/core/runtime/tmux.d.ts +28 -0
  233. package/dist/core/runtime/tmux.js +80 -6
  234. package/dist/core/scope.d.ts +11 -0
  235. package/dist/core/scope.js +39 -0
  236. package/dist/core/spawn.d.ts +5 -0
  237. package/dist/core/spawn.js +20 -2
  238. package/dist/core/substrate/gate.d.ts +13 -0
  239. package/dist/core/substrate/gate.js +21 -0
  240. package/dist/core/substrate/index.d.ts +7 -0
  241. package/dist/core/substrate/index.js +18 -0
  242. package/dist/core/substrate/on-read.d.ts +14 -0
  243. package/dist/core/substrate/on-read.js +292 -0
  244. package/dist/core/substrate/render.d.ts +25 -0
  245. package/dist/core/substrate/render.js +256 -0
  246. package/dist/core/substrate/schema.d.ts +76 -0
  247. package/dist/core/substrate/schema.js +124 -0
  248. package/dist/core/substrate/session-cache.d.ts +30 -0
  249. package/dist/core/substrate/session-cache.js +77 -0
  250. package/dist/core/substrate/subject.d.ts +41 -0
  251. package/dist/core/substrate/subject.js +54 -0
  252. package/dist/core/tui/contract.d.ts +83 -0
  253. package/dist/core/tui/contract.js +8 -0
  254. package/dist/core/tui/draw.d.ts +96 -0
  255. package/dist/core/tui/draw.js +339 -0
  256. package/dist/core/tui/host.d.ts +29 -0
  257. package/dist/core/tui/host.js +379 -0
  258. package/dist/core/tui/loader.d.ts +16 -0
  259. package/dist/core/tui/loader.js +94 -0
  260. package/dist/core/{canvas/browse → tui}/terminal.js +7 -6
  261. package/dist/core/wake.d.ts +86 -0
  262. package/dist/core/wake.js +308 -0
  263. package/dist/daemon/crtrd.d.ts +29 -4
  264. package/dist/daemon/crtrd.js +662 -46
  265. package/dist/pi-extensions/__tests__/canvas-context-intro.test.d.ts +1 -0
  266. package/dist/pi-extensions/__tests__/canvas-context-intro.test.js +171 -0
  267. package/dist/pi-extensions/__tests__/canvas-stophook-agentend.test.js +19 -12
  268. package/dist/pi-extensions/canvas-commands.d.ts +3 -0
  269. package/dist/pi-extensions/canvas-commands.js +10 -0
  270. package/dist/pi-extensions/canvas-context-intro.d.ts +17 -0
  271. package/dist/pi-extensions/canvas-context-intro.js +55 -15
  272. package/dist/pi-extensions/canvas-doc-substrate.d.ts +44 -0
  273. package/dist/pi-extensions/canvas-doc-substrate.js +112 -0
  274. package/dist/pi-extensions/canvas-nav.d.ts +3 -0
  275. package/dist/pi-extensions/canvas-nav.js +87 -23
  276. package/dist/pi-extensions/canvas-stophook.js +17 -8
  277. package/dist/pi-extensions/canvas-view.d.ts +21 -0
  278. package/dist/pi-extensions/canvas-view.js +75 -0
  279. package/dist/prompts/skill.js +19 -26
  280. package/dist/prompts/view.d.ts +7 -0
  281. package/dist/prompts/view.js +101 -0
  282. package/dist/types.d.ts +4 -0
  283. package/dist/types.js +1 -0
  284. package/package.json +9 -4
  285. package/dist/commands/skill/find.d.ts +0 -4
  286. package/dist/commands/skill/find.js +0 -257
  287. package/dist/commands/skill/read.js +0 -91
  288. /package/dist/commands/{skill → memory}/read.d.ts +0 -0
  289. /package/dist/core/{canvas/browse → tui}/terminal.d.ts +0 -0
@@ -0,0 +1,245 @@
1
+ // Run with: node --import tsx/esm --test src/core/__tests__/child-death-wake.test.ts
2
+ //
3
+ // D-1 BUG REGRESSION + node/canvas LIFECYCLE — "the runtime wakes a dormant
4
+ // parent on ANY terminal child outcome."
5
+ //
6
+ // The bug (D-1 finding, context/d1-finding.md): a parent that delegates and
7
+ // then JUST STOPS — retaining its inbox-wait, arming no self-wake — was woken
8
+ // ONLY when a child `push final`s or never boots. A child that crashes AFTER
9
+ // booting, is daemon-finalized after a quiet turn, or is `node close`d marked
10
+ // the child dead/done/canceled with NO push to the parent, so a purely-inbox-
11
+ // waiting parent hung dormant forever. The wake doctrine ("delegate and stop;
12
+ // the runtime wakes you") is only honest if EVERY terminal child outcome reaches
13
+ // the parent — this locks that in.
14
+ //
15
+ // The fix fans a system inbox entry to subscribersOf(child) on three previously-
16
+ // silent terminal outcomes — mirroring surfaceBootFailure:
17
+ // • daemon CRASH after boot (crtrd pane-gone, mid-generation → 'dead')
18
+ // • daemon FINALIZE on quiet (crtrd pane-gone, turn ended, nothing live → 'done')
19
+ // • `node close` (close.ts, the surviving manager outside the set)
20
+ //
21
+ // THE CRUX (the whole risk): the wake must fire ONLY on genuine death/close —
22
+ // NEVER on healthy dormancy. A child that ended its turn STILL waiting (an active
23
+ // live grandchild, or a pending self-wake) is alive-and-dormant, not dead; waking
24
+ // its parent then would re-create the spurious-wake storm the doctrine exists to
25
+ // kill (the concrete case: a developer child that finished its edits, spawned its
26
+ // own reviewer, and went dormant waiting on it — its parent must stay asleep).
27
+ // The daemon draws that boundary EXACTLY where the stop-guard does:
28
+ // hasActiveLiveSubscription || hasPendingSelfWake ⇒ healthy dormancy ⇒ release,
29
+ // no wake.
30
+ //
31
+ // Faithful: REAL canvas data layer, REAL tmux panes, the REAL daemon decision
32
+ // pass (superviseTick) and the REAL closeNode. The wake travels through the ONE
33
+ // channel the d1-finding identified — an entry appended to the parent's
34
+ // inbox.jsonl — so each direction asserts that channel: the system entry appears
35
+ // (parent woken: its dormant pi is then revived by the daemon's second pass,
36
+ // observed via a cycles bump) or it does NOT (parent stays asleep, cycles flat).
37
+ import { test, before, after, beforeEach } from 'node:test';
38
+ import assert from 'node:assert/strict';
39
+ import { mkdtempSync, rmSync } from 'node:fs';
40
+ import { tmpdir } from 'node:os';
41
+ import { join } from 'node:path';
42
+ import { spawnSync } from 'node:child_process';
43
+ import { createNode, getNode, subscribe } from '../canvas/canvas.js';
44
+ import { armWake } from '../canvas/wakeups.js';
45
+ import { closeDb } from '../canvas/db.js';
46
+ import { readInboxSince } from '../feed/inbox.js';
47
+ import { markBusy, clearBusy } from '../runtime/busy.js';
48
+ import { closeNode } from '../runtime/close.js';
49
+ import { superviseTick } from '../../daemon/crtrd.js';
50
+ let home;
51
+ function node(id, over = {}) {
52
+ return {
53
+ node_id: id,
54
+ name: id,
55
+ created: new Date().toISOString(),
56
+ cwd: '/tmp/work',
57
+ kind: 'general',
58
+ mode: 'base',
59
+ lifecycle: 'terminal',
60
+ status: 'active',
61
+ ...over,
62
+ };
63
+ }
64
+ function hasTmux() {
65
+ return spawnSync('tmux', ['-V'], { stdio: 'ignore' }).status === 0;
66
+ }
67
+ /** A pid that is guaranteed dead. */
68
+ function deadPid() {
69
+ const r = spawnSync('true', [], { stdio: 'ignore' });
70
+ return r.pid ?? 0x7ffffffe;
71
+ }
72
+ const cyclesOf = (id) => getNode(id)?.cycles ?? 0;
73
+ before(() => {
74
+ home = mkdtempSync(join(tmpdir(), 'crtr-child-death-wake-'));
75
+ process.env['CRTR_HOME'] = home;
76
+ });
77
+ beforeEach(() => {
78
+ closeDb();
79
+ rmSync(home, { recursive: true, force: true });
80
+ });
81
+ after(() => {
82
+ closeDb();
83
+ rmSync(home, { recursive: true, force: true });
84
+ delete process.env['CRTR_HOME'];
85
+ });
86
+ /** Run `fn` with a real, live tmux session + its window's live `%pane_id`. The
87
+ * PARENT occupies this (frozen) pane while pi-dead + idle-released — the
88
+ * faithful shape of an unfocused dormant orchestrator the daemon's second pass
89
+ * revives on an unseen inbox entry. */
90
+ async function withLivePane(tag, fn) {
91
+ const session = `crtr-cdw-${process.pid}-${tag}`;
92
+ spawnSync('tmux', ['new-session', '-d', '-s', session, '-c', '/tmp', 'sleep 600']);
93
+ try {
94
+ const w = spawnSync('tmux', ['list-windows', '-t', session, '-F', '#{window_id}'], { encoding: 'utf8' });
95
+ const window = (w.stdout ?? '').trim().split('\n')[0];
96
+ const p = spawnSync('tmux', ['display-message', '-p', '-t', `${session}:${window}`, '#{pane_id}'], { encoding: 'utf8' });
97
+ const pane = (p.stdout ?? '').trim();
98
+ await fn(session, window, pane);
99
+ }
100
+ finally {
101
+ spawnSync('tmux', ['kill-session', '-t', session], { stdio: 'ignore' });
102
+ }
103
+ }
104
+ /** A guaranteed-DEAD pane id inside `window`: split a fresh pane, then kill it.
105
+ * The window survives via its original pane. This is how a child's pane reads
106
+ * "gone" to the daemon while the session/window are still alive. */
107
+ function deadPaneIn(session, window) {
108
+ const sp = spawnSync('tmux', ['split-window', '-d', '-P', '-F', '#{pane_id}', '-t', `${session}:${window}`, 'sleep 600'], { encoding: 'utf8' });
109
+ const dead = (sp.stdout ?? '').trim();
110
+ spawnSync('tmux', ['kill-pane', '-t', dead], { stdio: 'ignore' });
111
+ return dead;
112
+ }
113
+ /** A dormant, unfocused PARENT: pi-dead, idle-released, frozen on the live pane,
114
+ * home_session set so the daemon's second pass can revive it. */
115
+ function dormantParent(id, session, window, pane) {
116
+ createNode(node(id, {
117
+ pane,
118
+ tmux_session: session,
119
+ window,
120
+ pi_pid: deadPid(),
121
+ pi_session_id: 'booted',
122
+ intent: 'idle-release',
123
+ status: 'idle',
124
+ home_session: session,
125
+ }));
126
+ }
127
+ // ===========================================================================
128
+ // POSITIVE — a genuine terminal child outcome WAKES the dormant parent.
129
+ // ===========================================================================
130
+ test('CRASH after boot (mid-generation, pane gone) wakes the dormant inbox-waiting parent', { skip: !hasTmux() ? 'tmux unavailable' : false, timeout: 60_000 }, async () => {
131
+ await withLivePane('crash', async (session, window, pane) => {
132
+ dormantParent('PARENT', session, window, pane);
133
+ const dead = deadPaneIn(session, window);
134
+ createNode(node('CHILD', {
135
+ pane: dead, tmux_session: session, window,
136
+ pi_pid: deadPid(), pi_session_id: 'booted', intent: null, status: 'active',
137
+ }));
138
+ subscribe('PARENT', 'CHILD', true); // the spawn-time spine edge
139
+ markBusy('CHILD'); // pane died INSIDE a turn → genuine mid-run crash
140
+ const before = cyclesOf('PARENT');
141
+ await superviseTick();
142
+ assert.equal(getNode('CHILD').status, 'dead', 'CHILD crashed → dead');
143
+ // THE WAKE CHANNEL: a system entry from CHILD lands in PARENT's inbox.
144
+ const wake = readInboxSince('PARENT').find((e) => e.from === 'CHILD');
145
+ assert.ok(wake, 'crash fanned a system inbox entry to the dormant parent (mirrors surfaceBootFailure)');
146
+ assert.match(wake.label, /died/i, 'the entry tells the parent WHICH child died and how');
147
+ // END-TO-END: the dormant parent is REVIVED on the same tick (second pass).
148
+ assert.equal(cyclesOf('PARENT'), before + 1, 'dormant PARENT revived on the same tick — no longer hangs');
149
+ clearBusy('CHILD');
150
+ });
151
+ });
152
+ test('quiet-turn FINALIZE (pane gone, nothing live to wait for) wakes the dormant parent', { skip: !hasTmux() ? 'tmux unavailable' : false, timeout: 60_000 }, async () => {
153
+ await withLivePane('finalize', async (session, window, pane) => {
154
+ dormantParent('PARENT', session, window, pane);
155
+ const dead = deadPaneIn(session, window);
156
+ // Booted, turn finished (NO busy marker), no live subscription, no pending
157
+ // self-wake → the daemon finalizes it (done): a dismissal of a finished node.
158
+ createNode(node('CHILD', {
159
+ pane: dead, tmux_session: session, window,
160
+ pi_pid: deadPid(), pi_session_id: 'booted', intent: null, status: 'active',
161
+ }));
162
+ subscribe('PARENT', 'CHILD', true);
163
+ const before = cyclesOf('PARENT');
164
+ await superviseTick();
165
+ assert.equal(getNode('CHILD').status, 'done', 'CHILD finalized → done');
166
+ const wake = readInboxSince('PARENT').find((e) => e.from === 'CHILD');
167
+ assert.ok(wake, 'finalize fanned a system inbox entry to the dormant parent');
168
+ assert.match(wake.label, /without a final report/i, 'the entry tells the parent the child ended with no final');
169
+ assert.equal(cyclesOf('PARENT'), before + 1, 'dormant PARENT revived on the same tick');
170
+ });
171
+ });
172
+ test('node close of a child wakes its SURVIVING manager (the parent outside the closing set)', { skip: !hasTmux() ? 'tmux unavailable' : false, timeout: 60_000 }, async () => {
173
+ await withLivePane('close', async (session, window, pane) => {
174
+ dormantParent('PARENT', session, window, pane);
175
+ const childPane = deadPaneIn(session, window); // any pane handle; closeNode tears it down
176
+ createNode(node('CHILD', {
177
+ pane: childPane, tmux_session: session, window,
178
+ pi_session_id: 'booted', status: 'active',
179
+ }));
180
+ subscribe('PARENT', 'CHILD', true); // PARENT is a manager OUTSIDE the close set
181
+ // Closing CHILD: closingSet({CHILD}) cannot pull in PARENT (PARENT is a
182
+ // SUBSCRIBER of CHILD, never a descendant), so PARENT survives + must wake.
183
+ const res = closeNode('CHILD');
184
+ assert.deepEqual(res.closed, ['CHILD'], 'only CHILD is closed; PARENT spared');
185
+ assert.equal(getNode('CHILD').status, 'canceled', 'CHILD canceled by close');
186
+ const wake = readInboxSince('PARENT').find((e) => e.from === 'CHILD');
187
+ assert.ok(wake, 'node close fanned a child-closed entry to the surviving manager (D-1: previously none)');
188
+ assert.match(wake.label, /closed/i, 'the entry names the closed child');
189
+ // The dormant parent is then revived by the daemon's second pass.
190
+ const before = cyclesOf('PARENT');
191
+ await superviseTick();
192
+ assert.equal(cyclesOf('PARENT'), before + 1, 'dormant PARENT revived on the close entry — no longer hangs');
193
+ });
194
+ });
195
+ // ===========================================================================
196
+ // NEGATIVE — HEALTHY DORMANCY must NOT wake the parent (the correctness crux).
197
+ // ===========================================================================
198
+ test('CRUX: a child dormant while awaiting its OWN live grandchild does NOT wake the parent', { skip: !hasTmux() ? 'tmux unavailable' : false, timeout: 60_000 }, async () => {
199
+ await withLivePane('dorm-sub', async (session, window, pane) => {
200
+ dormantParent('PARENT', session, window, pane);
201
+ const dead = deadPaneIn(session, window);
202
+ // CHILD finished its turn (no busy) and its pane is gone, BUT it actively
203
+ // subscribes to a LIVE grandchild — exactly the developer-spawned-reviewer
204
+ // case. This is HEALTHY dormancy, not death.
205
+ createNode(node('CHILD', {
206
+ pane: dead, tmux_session: session, window,
207
+ pi_pid: deadPid(), pi_session_id: 'booted', intent: null, status: 'active',
208
+ }));
209
+ createNode(node('GRANDCHILD', { status: 'active', pi_session_id: 'booted' })); // live, no pane (inline)
210
+ subscribe('PARENT', 'CHILD', true);
211
+ subscribe('CHILD', 'GRANDCHILD', true); // CHILD awaits a LIVE grandchild
212
+ const before = cyclesOf('PARENT');
213
+ await superviseTick();
214
+ // CHILD is RELEASED (revivable), NOT finalized — the daemon must not orphan
215
+ // its in-flight grandchild and must not wake the parent.
216
+ assert.equal(getNode('CHILD').status, 'idle', 'CHILD released (revivable), NOT finalized');
217
+ assert.equal(getNode('CHILD').intent, 'idle-release', 'CHILD routed to idle-release');
218
+ assert.equal(readInboxSince('PARENT').length, 0, 'PARENT inbox EMPTY — healthy dormancy raises no wake');
219
+ assert.equal(cyclesOf('PARENT'), before, 'dormant PARENT stays asleep (no revive) — no spurious wake');
220
+ });
221
+ });
222
+ test('CRUX: a child dormant on a pending self-wake (no live sub) does NOT wake the parent', { skip: !hasTmux() ? 'tmux unavailable' : false, timeout: 60_000 }, async () => {
223
+ await withLivePane('dorm-wake', async (session, window, pane) => {
224
+ dormantParent('PARENT', session, window, pane);
225
+ const dead = deadPaneIn(session, window);
226
+ createNode(node('CHILD', {
227
+ pane: dead, tmux_session: session, window,
228
+ pi_pid: deadPid(), pi_session_id: 'booted', intent: null, status: 'active',
229
+ }));
230
+ subscribe('PARENT', 'CHILD', true);
231
+ // CHILD has NO live subscription, but holds a pending self-wake (far-future
232
+ // so the wakeups pass never fires it this tick). hasPendingSelfWake(CHILD)
233
+ // ⇒ the daemon treats it as STILL WAITING (the stop-guard's boundary), so
234
+ // it RELEASES instead of finalizing. NON-VACUOUS: drop the hasPendingSelfWake
235
+ // guard and CHILD finalizes → PARENT is spuriously woken.
236
+ const future = new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toISOString();
237
+ armWake({ wakeup_id: 'wk-cdw-child', node_id: 'CHILD', owner_id: 'CHILD', fire_at: future, kind: 'bare' });
238
+ const before = cyclesOf('PARENT');
239
+ await superviseTick();
240
+ assert.equal(getNode('CHILD').status, 'idle', 'CHILD with a pending self-wake is RELEASED, not finalized');
241
+ assert.equal(getNode('CHILD').intent, 'idle-release', 'CHILD routed to idle-release (its clock will wake it)');
242
+ assert.equal(readInboxSince('PARENT').length, 0, 'PARENT inbox EMPTY — a pending clock is healthy dormancy');
243
+ assert.equal(cyclesOf('PARENT'), before, 'dormant PARENT stays asleep — pending self-wake is not death');
244
+ });
245
+ });
@@ -1,28 +1,25 @@
1
- // Tests for the <crtr-context> bearings preamble + scoped memory:
2
- // 1. A plain spawned node gets NO MEMORY.md; promotion seeds it (the template).
3
- // 2. seedMemory is idempotent (never clobbers an evolved memory).
4
- // 3. Worker bearings = shared-doc framing only (no <memory> block);
5
- // orchestrator bearings add the across-cycles framing + a <memory> block
6
- // that MERGES the applicable stores, each a `label · dir` header over its
7
- // live index POINTER LINES only (the how-to boilerplate is dropped — it
8
- // lives once in the kernel); promotion delivers that same orchestrator
1
+ // Tests for the <crtr-context> bearings preamble:
2
+ // 1. Worker and orchestrator bearings carry the `## References` block
3
+ // (substrate reference docs + node-local docs as `###` sub-sections) in
4
+ // place of the removed `<memory>` block no `label · dir` stanzas, no
5
+ // `(empty)` markers, no MEMORY.md index inlining. Orchestrators add the
6
+ // across-cycles framing; promotion delivers that same orchestrator
9
7
  // context-dir framing to a node that spawned base.
10
- // 4. canvas-context-intro injects the block as its own session message at
8
+ // 2. canvas-context-intro injects the block as its own session message at
11
9
  // session_start (before the first prompt), idempotent across resumes.
12
10
  //
13
11
  // Run: node --import tsx/esm --test src/core/__tests__/context-intro.test.ts
14
12
  import { test, before, beforeEach, after } from 'node:test';
15
13
  import assert from 'node:assert/strict';
16
- import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
14
+ import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
17
15
  import { tmpdir } from 'node:os';
18
16
  import { join } from 'node:path';
19
- import { existsSync } from 'node:fs';
20
17
  import { closeDb } from '../canvas/db.js';
21
18
  import { contextDir } from '../canvas/paths.js';
22
19
  import { spawnNode } from '../runtime/nodes.js';
23
20
  import { promote } from '../runtime/promote.js';
24
21
  import { personaDrift } from '../runtime/persona.js';
25
- import { memoryPath, memoryDir, readMemory, seedMemory, hasMemory, MEMORY_TEMPLATE, userMemoryDir, userMemoryPath, projectMemoryDir, projectMemoryPath, } from '../runtime/memory.js';
22
+ import { memoryDir } from '../runtime/memory.js';
26
23
  import registerCanvasContextIntro, { buildContextIntro, renderContextMessage, CONTEXT_INTRO_CUSTOM_TYPE, } from '../../pi-extensions/canvas-context-intro.js';
27
24
  let home;
28
25
  before(() => {
@@ -39,69 +36,80 @@ after(() => {
39
36
  delete process.env['CRTR_HOME'];
40
37
  delete process.env['CRTR_NODE_ID'];
41
38
  });
42
- test('a plain spawned node gets NO memory; promotion seeds the index + dir', () => {
43
- const meta = spawnNode({ kind: 'general', cwd: '/tmp/work', parent: null });
44
- assert.equal(hasMemory(meta.node_id), false, 'no memory before promotion');
45
- promote(meta.node_id);
46
- assert.ok(hasMemory(meta.node_id), 'MEMORY.md index exists after promotion');
47
- assert.ok(existsSync(memoryDir(meta.node_id)), 'memory dir created for direct writes');
48
- assert.ok(memoryPath(meta.node_id).endsWith('/memory/MEMORY.md'), 'index lives inside the memory dir');
49
- assert.equal(readMemory(meta.node_id), MEMORY_TEMPLATE);
50
- });
51
- test('seedMemory is idempotent — never clobbers an evolved memory', () => {
52
- const meta = spawnNode({ kind: 'general', cwd: '/tmp/work', parent: null });
53
- seedMemory(meta.node_id);
54
- const evolved = '# Memory\n\n## Lessons\n- never trust the cache\n';
55
- writeFileSync(memoryPath(meta.node_id), evolved);
56
- assert.equal(seedMemory(meta.node_id), false, 'returns false when one exists');
57
- assert.equal(readMemory(meta.node_id), evolved, 'left untouched');
58
- });
59
- test('worker bearings: shared-doc framing only, no orchestrator/memory framing', () => {
39
+ test('worker bearings: base framing + ## References block (no <memory>, no label·dir stanzas), NO across-cycles framing', () => {
40
+ // Bug-regression: review finding M1 buildContextBearings was changed from
41
+ // buildMemoryBlock (<memory> + label·dir per-store stanzas) to
42
+ // renderReferencesBlock (## References + ### <name> sub-sections). This test
43
+ // locks in the new contract.
60
44
  const meta = spawnNode({ kind: 'general', cwd: '/tmp/work', parent: null });
45
+ // Seed a node-local substrate doc so the ## References block is non-empty.
46
+ const dir = memoryDir(meta.node_id);
47
+ mkdirSync(dir, { recursive: true });
48
+ writeFileSync(join(dir, 'test-ref.md'), '---\nkind: reference\nwhen: when testing\nwhy: regression fixture\nsystem-prompt-visibility: preview\n---\nTest body.\n');
61
49
  const block = buildContextIntro(meta.node_id);
62
50
  assert.match(block, new RegExp(`<crtr-context dir="${contextDir(meta.node_id)}">`));
63
51
  assert.match(block, /shared document store, not a task tracker/, 'base = shared docs, not tasks');
64
- assert.doesNotMatch(block, /MEMORY\.md/, 'no memory pointer for a memory-less worker');
65
- assert.doesNotMatch(block, /<memory>/, 'no memory block for a memory-less worker');
52
+ // New contract: NO <memory> block replaced by ## References.
53
+ assert.doesNotMatch(block, /<memory>/, 'no <memory> block');
54
+ // New contract: NO label·dir stanza headers (the removed per-store format).
55
+ assert.doesNotMatch(block, /user-global · /, 'no user-global label·dir stanza');
56
+ assert.doesNotMatch(block, /node-local · /, 'no node-local label·dir stanza');
57
+ // New contract: NO (empty) placeholder (the removed empty-store marker).
58
+ assert.doesNotMatch(block, /\(empty\)/, 'no (empty) placeholder');
59
+ // Reference content renders as ## References + ### sub-sections — no other shape.
60
+ assert.match(block, /## References\n\n###/, '## References followed by ### sub-sections');
61
+ // A terminal worker must NOT carry the orchestrator across-cycles framing.
66
62
  assert.doesNotMatch(block, /refresh cycles/, 'no across-cycles framing for a terminal worker');
67
63
  assert.match(block, /<\/crtr-context>/);
68
64
  });
69
- test('orchestrator bearings: across-cycles framing + a <memory> block whose body is the live pointer lines under `label · dir`', () => {
65
+ test('orchestrator bearings: across-cycles framing + node-local substrate docs ride into ## References; the MEMORY.md index never renders', () => {
66
+ // Bug-regression: review finding M1 — buildContextBearings replaced the old
67
+ // <memory> pointer-line block with ## References. Node-local substrate docs
68
+ // render as ### sub-sections at their rung; the old index file never surfaces.
70
69
  const meta = spawnNode({ kind: 'general', cwd: '/tmp/work', parent: null });
71
- seedMemory(meta.node_id);
72
- // The seeded index carries the how-to comment + the empty marker; an evolved
73
- // index adds a pointer line. The block must render ONLY the pointer line.
74
- writeFileSync(memoryPath(meta.node_id), '# memory index one pointer line per memory; how-to in "Your long-term memory".\n\n- [Flaky build](flaky-build.md) — first run fails\n');
70
+ promote(meta.node_id); // flip to orchestrator mode — the across-cycles gate
71
+ const dir = memoryDir(meta.node_id);
72
+ mkdirSync(dir, { recursive: true });
73
+ // Write a MEMORY.md file to verify it is never surfaced in the block.
74
+ const legacyIndexPath = join(dir, 'MEMORY.md');
75
+ writeFileSync(legacyIndexPath, '# memory index — one pointer line per memory; how-to in "Your long-term memory".\n\n- [Flaky build](flaky-build.md) — first run fails\n');
76
+ // A node-local substrate doc DOES ride into ## References at its rung.
77
+ writeFileSync(join(dir, 'flaky-build.md'), '---\nkind: reference\nwhen: when the build flakes\nwhy: first run fails\nsystem-prompt-visibility: preview\n---\nFirst run always fails; rerun once.\n');
75
78
  const block = buildContextIntro(meta.node_id);
76
79
  assert.match(block, /shared document store, not a task tracker/, 'still carries the base framing');
77
80
  assert.match(block, /refresh cycles/, 'orchestrator gets the across-cycles framing');
78
- assert.match(block, /<memory>/, 'has a memory block');
79
- // (a) the store renders just its pointer line under a `label · dir` header.
80
- assert.ok(block.includes(`node-local · ${memoryDir(meta.node_id)}`), 'compact `label · dir` header');
81
- assert.match(block, /- \[Flaky build\]\(flaky-build\.md\) first run fails/, 'the live pointer line is inlined');
82
- // (c) no `index:` line, no absolute index path, no inlined how-to boilerplate.
83
- assert.ok(!block.includes('index:'), 'no index: substring');
84
- assert.ok(!block.includes(memoryPath(meta.node_id)), 'no absolute index (MEMORY.md) path');
85
- assert.ok(!block.includes('how-to in'), 'the index how-to boilerplate is NOT inlined');
81
+ assert.doesNotMatch(block, /<memory>/, 'no <memory> block');
82
+ assert.match(block, /## References\n/, 'references block present');
83
+ assert.match(block, /### flaky-build\n/, 'node-local doc gets its ### sub-section');
84
+ assert.match(block, /when the build flakes, read this reference\. first run fails\./, 'preview rung renders the routing line');
85
+ // The index file itself never renders: no header comment, no pointer line, no path.
86
86
  assert.ok(!block.includes('# memory index'), 'the index header comment is NOT inlined');
87
+ assert.ok(!block.includes('- [Flaky build](flaky-build.md)'), 'index pointer lines are NOT inlined');
88
+ assert.ok(!block.includes(legacyIndexPath), 'no absolute index (MEMORY.md) path');
89
+ assert.ok(!block.includes('node-local · '), 'no label·dir stanza header');
87
90
  assert.match(block, /<\/crtr-context>/);
88
91
  });
89
- test('orchestrator bearings: a promoted node merges all THREE stores; template-only stores render (empty)', () => {
92
+ test('orchestrator bearings: no per-store stanzas or (empty) markers; a rung-none node-local doc still surfaces as a ### title stub', () => {
93
+ // Bug-regression: review findings M1 + M6 — the three-store `label · dir`
94
+ // stanzas and (empty) markers are gone, and node-local docs are NOT filtered
95
+ // on rung: a migrated node-local reference defaults
96
+ // system-prompt-visibility: none and must still ride into ## References as
97
+ // its bare title (never its body).
90
98
  const meta = spawnNode({ kind: 'general', cwd: '/tmp/work', parent: null });
91
- promote(meta.node_id); // seeds user-global + project + node-local (all template-only)
99
+ promote(meta.node_id); // flip to orchestrator mode
100
+ const dir = memoryDir(meta.node_id);
101
+ mkdirSync(dir, { recursive: true });
102
+ writeFileSync(join(dir, 'rung-none-fact.md'), '---\nkind: reference\n---\nbody that must not render at the none rung\n');
92
103
  const block = buildContextIntro(meta.node_id);
93
- // Each scope present as a `label · dir` header.
94
- assert.ok(block.includes(`user-global · ${userMemoryDir()}`), 'merges the user-global store');
95
- assert.ok(block.includes(`project · ${projectMemoryDir('/tmp/work')}`), 'merges the project store');
96
- assert.ok(block.includes(`node-local · ${memoryDir(meta.node_id)}`), 'merges the node-local store');
97
- // (b) a template-only / not-yet-written store renders the (empty) marker.
98
- assert.match(block, /user-global · .*\n\(empty\)/, 'template-only user store renders (empty)');
99
- assert.equal(block.match(/\(empty\)/g)?.length, 3, 'all three template-only stores render (empty)');
100
- // (c) no absolute index paths leak in.
101
- assert.ok(!block.includes(userMemoryPath()), 'no user-global MEMORY.md path');
102
- assert.ok(!block.includes(projectMemoryPath('/tmp/work')), 'no project MEMORY.md path');
103
- // The type→store mapping is taught by the kernel; the block points at it.
104
- assert.match(block, /type/, 'the block references the type that decides the store');
104
+ // No per-store stanza headers, no (empty) markers, no index paths.
105
+ assert.ok(!block.includes(`node-local · ${memoryDir(meta.node_id)}`), 'no node-local stanza header');
106
+ assert.ok(!block.includes('user-global · '), 'no user-global stanza header');
107
+ assert.ok(!block.includes('project · '), 'no project stanza header');
108
+ assert.doesNotMatch(block, /\(empty\)/, 'no (empty) markers');
109
+ assert.ok(!block.includes('MEMORY.md'), 'no MEMORY.md path in the block');
110
+ // M6: rung-none node-local doc surfaces as its title stub only.
111
+ assert.match(block, /### rung-none-fact\s*\n/, 'rung-none node-local doc surfaces as its title stub');
112
+ assert.ok(!block.includes('body that must not render'), 'none rung renders the title only, not the body');
105
113
  });
106
114
  test('promotion guidance delivers the orchestrator context-dir framing', () => {
107
115
  // A node spawns as a base worker (no orchestrator bearings). On promotion the
@@ -145,12 +153,18 @@ test('session_start injects the block as the first message of an empty session',
145
153
  test('session_start is idempotent across resume (skips if already in history)', () => {
146
154
  const meta = spawnNode({ kind: 'general', cwd: '/tmp/work', parent: null });
147
155
  process.env['CRTR_NODE_ID'] = meta.node_id;
148
- // Restored session already carries the block → no duplicate injection.
156
+ // Restored session already carries OUR block (which names this node id) → no
157
+ // duplicate injection. The content matters: the guard is fork-aware and only
158
+ // skips when the present bearings name THIS node (a fork inherits the
159
+ // SOURCE's bearings, which name a different node — see canvas-context-intro
160
+ // tests), so a realistic resume entry must carry the real block content.
149
161
  const pi = makeFakePi();
150
162
  registerCanvasContextIntro(pi);
151
- const entries = [{ type: 'custom_message', customType: CONTEXT_INTRO_CUSTOM_TYPE }];
163
+ const entries = [
164
+ { type: 'custom_message', customType: CONTEXT_INTRO_CUSTOM_TYPE, content: buildContextIntro(meta.node_id) },
165
+ ];
152
166
  pi.handler({ reason: 'resume' }, fakeCtx(entries));
153
- assert.equal(pi.sent.length, 0, 'block already in history → skip');
167
+ assert.equal(pi.sent.length, 0, 'our own block already in history → skip');
154
168
  });
155
169
  test('session_start is inert when CRTR_NODE_ID is absent', () => {
156
170
  delete process.env['CRTR_NODE_ID'];
@@ -64,10 +64,14 @@ test('never-booted node is marked dead and surfaced to the parent as urgent', as
64
64
  assert.equal(inbox[0].from, 'C');
65
65
  assert.match(inbox[0].label, /Spawn failed/);
66
66
  });
67
- // A node that HAD booted (pi_session_id set) then lost its window is a genuine
68
- // crash, not a boot failure it is marked dead but NOT surfaced as a spawn
69
- // failure (no false alarm to the parent).
70
- test('a crash after boot is marked dead without a boot-failure push', async () => {
67
+ // A node that HAD booted (pi_session_id set) then died mid-generation is a
68
+ // genuine crash, not a boot failure. It is marked dead AND wakes the inbox-
69
+ // waiting parent but with a "child died" notice (surfaceChildDeath), NOT a
70
+ // "Spawn failed" boot-failure pointer (surfaceBootFailure). The parent-wake
71
+ // itself was added by main's b5abf6e (parent-wake on child death) and is locked
72
+ // in by child-death-wake.test.ts; this test preserves the discriminating intent
73
+ // — a crash is never surfaced as a false spawn-failure alarm.
74
+ test('a crash after boot wakes the parent with a child-death notice, not a boot-failure push', async () => {
71
75
  createNode(node('P2', { kind: 'developer', lifecycle: 'resident' }));
72
76
  createNode(node('C2', {
73
77
  parent: 'P2',
@@ -85,7 +89,12 @@ test('a crash after boot is marked dead without a boot-failure push', async () =
85
89
  markBusy('C2');
86
90
  await superviseTick();
87
91
  assert.equal(getNode('C2').status, 'dead', 'crashed child is dead');
88
- assert.equal(readInboxSince('P2').length, 0, 'a real crash does not push a boot-failure pointer');
92
+ const inbox = readInboxSince('P2');
93
+ assert.equal(inbox.length, 1, 'a genuine mid-run crash wakes the inbox-waiting parent (D-1 parent-wake)');
94
+ assert.equal(inbox[0].tier, 'urgent', 'mid-run crash is delivered as urgent');
95
+ assert.equal(inbox[0].from, 'C2');
96
+ assert.match(inbox[0].label, /died/i, 'the wake is a child-death notice (surfaceChildDeath), not silence');
97
+ assert.doesNotMatch(inbox[0].label, /Spawn failed/, 'a crash is NOT surfaced as a false boot-failure alarm');
89
98
  clearBusy('C2');
90
99
  });
91
100
  // A still-booting node whose window is alive must be left untouched — boot is
@@ -3,7 +3,7 @@ import assert from 'node:assert/strict';
3
3
  import { mkdtempSync, rmSync } from 'node:fs';
4
4
  import { tmpdir } from 'node:os';
5
5
  import { join } from 'node:path';
6
- import { spawnSync } from 'node:child_process';
6
+ import { spawn, spawnSync } from 'node:child_process';
7
7
  import { createNode, getNode, subscribe } from '../canvas/canvas.js';
8
8
  import { closeDb } from '../canvas/db.js';
9
9
  import { appendInbox } from '../feed/inbox.js';
@@ -32,6 +32,17 @@ function deadPid() {
32
32
  // spawnSync has reaped it by the time it returns; pid may be on r.pid.
33
33
  return r.pid ?? 0x7ffffffe; // fall back to an implausibly-high pid
34
34
  }
35
+ /** A pid that is ALIVE for the test's duration but expendable. Pane-gone
36
+ * fixtures must NEVER use process.pid for an alive pi: the gone-pane zombie
37
+ * sweep (b92082e) SIGKILLs a pane-gone node's live pid, which took the whole
38
+ * test runner down with it. The daemon (or the after hook) reaps these. */
39
+ const livePids = [];
40
+ function disposableLivePid() {
41
+ const child = spawn('sleep', ['600'], { stdio: 'ignore', detached: true });
42
+ child.unref();
43
+ livePids.push(child.pid);
44
+ return child.pid;
45
+ }
35
46
  before(() => {
36
47
  home = mkdtempSync(join(tmpdir(), 'crtr-daemon-liveness-'));
37
48
  process.env['CRTR_HOME'] = home;
@@ -44,6 +55,14 @@ after(() => {
44
55
  closeDb();
45
56
  rmSync(home, { recursive: true, force: true });
46
57
  delete process.env['CRTR_HOME'];
58
+ for (const pid of livePids) {
59
+ try {
60
+ process.kill(pid, 'SIGKILL');
61
+ }
62
+ catch {
63
+ /* already reaped by the daemon's zombie sweep — fine */
64
+ }
65
+ }
47
66
  });
48
67
  // ---------------------------------------------------------------------------
49
68
  // livenessVerdict — the pure grace-window decision
@@ -157,7 +176,7 @@ test('pane GONE while its old window is still alive → the gone-branch fires (c
157
176
  pane: dead,
158
177
  tmux_session: session,
159
178
  window,
160
- pi_pid: process.pid, // alive — irrelevant once the pane reads gone
179
+ pi_pid: disposableLivePid(), // alive zombie — the gone-pane sweep SIGKILLs it
161
180
  pi_session_id: 'booted',
162
181
  intent: null,
163
182
  }));
@@ -172,7 +191,7 @@ test('pane GONE while its old window is still alive → the gone-branch fires (c
172
191
  // unconditional 'dead'. It routes on what the node was DOING at pane-kill time:
173
192
  // • mid-generation (busy marker PRESENT) → crash ('dead')
174
193
  // • finished its turn, awaiting nothing live → finalize ('done')
175
- // • finished its turn, still awaiting a LIVE child → crash ('dead'), for now
194
+ // • finished its turn, still awaiting a LIVE child → release ('idle' + idle-release), revivable
176
195
  // (The mid-generation → dead leg is covered by node 'G' above.)
177
196
  // ---------------------------------------------------------------------------
178
197
  test('pane gone + booted + busy ABSENT + no live subscription → finalize (done): the pane was closed to dismiss a finished node', { skip: !hasTmux() }, async () => {
@@ -187,7 +206,7 @@ test('pane gone + booted + busy ABSENT + no live subscription → finalize (done
187
206
  pane: dead,
188
207
  tmux_session: session,
189
208
  window,
190
- pi_pid: process.pid,
209
+ pi_pid: disposableLivePid(), // alive zombie — swept before finalize routing
191
210
  pi_session_id: 'booted',
192
211
  intent: null,
193
212
  }));
@@ -196,7 +215,7 @@ test('pane gone + booted + busy ABSENT + no live subscription → finalize (done
196
215
  assert.equal(getNode('FIN').status, 'done', 'a finished, unsubscribed node whose pane was closed finalizes to done');
197
216
  });
198
217
  });
199
- test('pane gone + booted + busy ABSENT but AWAITING a LIVE child → crash (dead), for now', { skip: !hasTmux() }, async () => {
218
+ test('pane gone + booted + busy ABSENT but AWAITING a LIVE child → idle-release (revivable, NOT dead)', { skip: !hasTmux() }, async () => {
200
219
  await withLivePane('fin2', async (session, window) => {
201
220
  const sp = spawnSync('tmux', ['split-window', '-d', '-P', '-F', '#{pane_id}', '-t', `${session}:${window}`, 'sleep 600'], { encoding: 'utf8' });
202
221
  const dead = (sp.stdout ?? '').trim();
@@ -207,15 +226,21 @@ test('pane gone + booted + busy ABSENT but AWAITING a LIVE child → crash (dead
207
226
  pane: dead,
208
227
  tmux_session: session,
209
228
  window,
210
- pi_pid: process.pid,
229
+ pi_pid: deadPid(), // the user closed the pane → pi is dead
211
230
  pi_session_id: 'booted',
212
231
  intent: null,
213
232
  }));
214
233
  subscribe('PARENT', 'CHILD', true); // active subscription to a LIVE node
215
- // Parent finished its turn (no busy marker) but is still awaiting a live
216
- // child NOT a finalize. Routes to crash ('dead') for now.
234
+ // REGRESSION: closing the pane of a waiting orchestrator must NOT kill it.
235
+ // PARENT finished its turn (no busy marker) but still awaits a live child, so
236
+ // a hard 'dead' would orphan the child's report. It routes to idle-release
237
+ // (revivable); the second pass revives it on the next inbox push. No inbox
238
+ // entry here, so it stays dormant (idle + idle-release) — revivable, not dead.
217
239
  await superviseTick();
218
- assert.equal(getNode('PARENT').status, 'dead', 'a finished node still awaiting a live child crashes (dead), not finalizes');
240
+ const p = getNode('PARENT');
241
+ assert.equal(p.status, 'idle', 'a finished node still awaiting a live child is RELEASED, not killed');
242
+ assert.equal(p.intent, 'idle-release', 'routed to idle-release so a child push revives it');
243
+ assert.equal(p.window ?? null, null, 'the stale window ref is dropped');
219
244
  });
220
245
  });
221
246
  // ---------------------------------------------------------------------------
@@ -0,0 +1 @@
1
+ export {};