@crouton-kit/crouter 0.3.15 → 0.3.17

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 (101) hide show
  1. package/dist/builtin-personas/developer/orchestrator.md +1 -1
  2. package/dist/builtin-personas/orchestration-kernel.md +6 -6
  3. package/dist/builtin-personas/plan/base.md +1 -1
  4. package/dist/builtin-personas/plan/orchestrator.md +1 -1
  5. package/dist/builtin-personas/spec/base.md +1 -1
  6. package/dist/commands/canvas-browse.d.ts +2 -0
  7. package/dist/commands/canvas-browse.js +45 -0
  8. package/dist/commands/canvas-prune.js +11 -2
  9. package/dist/commands/canvas.js +3 -2
  10. package/dist/commands/chord.js +1 -1
  11. package/dist/commands/human/shared.js +1 -1
  12. package/dist/commands/node.js +14 -2
  13. package/dist/commands/skill/author.js +2 -2
  14. package/dist/commands/tmux-spread.js +2 -3
  15. package/dist/core/__tests__/cascade-close.test.js +199 -0
  16. package/dist/core/__tests__/close.test.js +2 -2
  17. package/dist/core/__tests__/daemon-boot.test.js +7 -0
  18. package/dist/core/__tests__/daemon-liveness.test.js +59 -4
  19. package/dist/core/__tests__/dead-pane-regression.test.js +151 -0
  20. package/dist/core/__tests__/fixtures/fake-pi-host.d.ts +2 -0
  21. package/dist/core/__tests__/fixtures/fake-pi-host.js +301 -0
  22. package/dist/core/__tests__/flagship-lifecycle.test.js +273 -0
  23. package/dist/core/__tests__/focuses.test.js +5 -68
  24. package/dist/core/__tests__/grace-clock.test.d.ts +1 -0
  25. package/dist/core/__tests__/grace-clock.test.js +115 -0
  26. package/dist/core/__tests__/helpers/harness.d.ts +78 -0
  27. package/dist/core/__tests__/helpers/harness.js +406 -0
  28. package/dist/core/__tests__/home-session.test.js +1 -1
  29. package/dist/core/__tests__/lifecycle.test.js +6 -13
  30. package/dist/core/__tests__/live-mutation.test.d.ts +1 -0
  31. package/dist/core/__tests__/live-mutation.test.js +341 -0
  32. package/dist/core/__tests__/placement-focus.test.js +106 -46
  33. package/dist/core/__tests__/placement-teardown.test.js +4 -9
  34. package/dist/core/__tests__/relaunch.test.js +22 -16
  35. package/dist/core/__tests__/reset.test.js +11 -6
  36. package/dist/core/__tests__/spike-harness.test.d.ts +1 -0
  37. package/dist/core/__tests__/spike-harness.test.js +241 -0
  38. package/dist/core/__tests__/subscription-delivery.test.d.ts +1 -0
  39. package/dist/core/__tests__/subscription-delivery.test.js +233 -0
  40. package/dist/core/__tests__/tmux-surface.test.js +8 -9
  41. package/dist/core/canvas/browse/__tests__/model.test.d.ts +1 -0
  42. package/dist/core/canvas/browse/__tests__/model.test.js +142 -0
  43. package/dist/core/canvas/browse/__tests__/render.test.d.ts +1 -0
  44. package/dist/core/canvas/browse/__tests__/render.test.js +102 -0
  45. package/dist/core/canvas/browse/app.d.ts +4 -0
  46. package/dist/core/canvas/browse/app.js +349 -0
  47. package/dist/core/canvas/browse/model.d.ts +97 -0
  48. package/dist/core/canvas/browse/model.js +258 -0
  49. package/dist/core/canvas/browse/render.d.ts +41 -0
  50. package/dist/core/canvas/browse/render.js +387 -0
  51. package/dist/core/canvas/browse/terminal.d.ts +23 -0
  52. package/dist/core/canvas/browse/terminal.js +100 -0
  53. package/dist/core/canvas/canvas.d.ts +9 -2
  54. package/dist/core/canvas/canvas.js +41 -3
  55. package/dist/core/canvas/db.js +2 -3
  56. package/dist/core/canvas/focuses.d.ts +2 -2
  57. package/dist/core/canvas/focuses.js +4 -3
  58. package/dist/core/canvas/render.d.ts +10 -0
  59. package/dist/core/canvas/render.js +25 -1
  60. package/dist/core/canvas/types.d.ts +1 -1
  61. package/dist/core/feed/inbox.d.ts +0 -3
  62. package/dist/core/feed/inbox.js +1 -5
  63. package/dist/core/runtime/busy.d.ts +8 -0
  64. package/dist/core/runtime/busy.js +46 -0
  65. package/dist/core/runtime/close.js +2 -2
  66. package/dist/core/runtime/demote.js +2 -7
  67. package/dist/core/runtime/launch.d.ts +3 -1
  68. package/dist/core/runtime/launch.js +4 -1
  69. package/dist/core/runtime/lifecycle.d.ts +1 -1
  70. package/dist/core/runtime/lifecycle.js +12 -4
  71. package/dist/core/runtime/naming.d.ts +3 -3
  72. package/dist/core/runtime/naming.js +6 -6
  73. package/dist/core/runtime/nodes.d.ts +7 -0
  74. package/dist/core/runtime/nodes.js +10 -1
  75. package/dist/core/runtime/placement.d.ts +39 -10
  76. package/dist/core/runtime/placement.js +100 -44
  77. package/dist/core/runtime/reset.d.ts +11 -8
  78. package/dist/core/runtime/reset.js +36 -31
  79. package/dist/core/runtime/revive.d.ts +1 -1
  80. package/dist/core/runtime/revive.js +2 -2
  81. package/dist/core/runtime/spawn.js +3 -3
  82. package/dist/core/runtime/tmux-chrome.d.ts +1 -0
  83. package/dist/core/runtime/tmux-chrome.js +4 -0
  84. package/dist/core/runtime/tmux.d.ts +13 -6
  85. package/dist/core/runtime/tmux.js +21 -12
  86. package/dist/daemon/crtrd.js +43 -21
  87. package/dist/pi-extensions/canvas-nav.js +40 -28
  88. package/dist/pi-extensions/canvas-resume.d.ts +21 -0
  89. package/dist/pi-extensions/canvas-resume.js +82 -0
  90. package/dist/pi-extensions/canvas-stophook.d.ts +1 -1
  91. package/dist/pi-extensions/canvas-stophook.js +21 -9
  92. package/dist/prompts/skill.js +6 -1
  93. package/package.json +2 -2
  94. package/dist/commands/__tests__/skill.test.js +0 -290
  95. package/dist/core/__tests__/pkg.test.js +0 -218
  96. package/dist/core/__tests__/sys.test.js +0 -208
  97. package/dist/core/runtime/presence.d.ts +0 -30
  98. package/dist/core/runtime/presence.js +0 -178
  99. /package/dist/{commands/__tests__/skill.test.d.ts → core/__tests__/cascade-close.test.d.ts} +0 -0
  100. /package/dist/core/__tests__/{pkg.test.d.ts → dead-pane-regression.test.d.ts} +0 -0
  101. /package/dist/core/__tests__/{sys.test.d.ts → flagship-lifecycle.test.d.ts} +0 -0
@@ -0,0 +1,273 @@
1
+ // Run with: node --import tsx/esm --test src/core/__tests__/flagship-lifecycle.test.ts
2
+ //
3
+ // FLAGSHIP end-to-end lifecycle test — the faithful integration harness driving
4
+ // a full node-graph scenario through the REAL CLI, a REAL isolated tmux session,
5
+ // the REAL extension hooks (fired inside the fake-pi vehicle), and the REAL
6
+ // daemon decision pass (superviseTick, in-process). Every assertion reads the
7
+ // canvas data layer and is checked against the state-model ORACLE.
8
+ //
9
+ // Scenario (each hop asserted):
10
+ // A (base→orchestrator, RESIDENT root) spawns terminal child B.
11
+ // B spawns child C, then goes DORMANT (terminal idle-release, unfocused→pane closes).
12
+ // C finishes → ONE-HOP wakes B (A is NOT woken — the oracle's wake contract).
13
+ // B PROMOTES to orchestrator (terminal), takes a turn (persona drift), then
14
+ // YIELDS and revives in place (refresh-yield).
15
+ // B spawns child D; D finishes; B pushes its final up to A.
16
+ //
17
+ // 2×2 coverage (mode {base,orchestrator} × lifecycle {terminal,resident}):
18
+ // • base×resident — A's PERSISTED ROW SHAPE at S1 (a harness-set fixture,
19
+ // NOT a real bootRoot — see S1 + MINOR-1 caveat below).
20
+ // The resident BEHAVIOR (does not idle-release) is
21
+ // exercised faithfully in live-mutation.test.ts.
22
+ // • orchestrator×resident — A after promote (top-level orchestrator)
23
+ // • base×terminal — B/C/D workers (GENUINE managed-child birth at S3)
24
+ // • orchestrator×terminal — B after promote (sub-orchestrator)
25
+ //
26
+ // ── Known coverage boundaries (DELIBERATE, documented gaps) ────────────────
27
+ // This flagship + the live-mutation/cascade/subscription siblings cover the
28
+ // HAPPY-PATH lifecycle and the live-mutation axis faithfully. The following
29
+ // fault/grace/focus paths are OUT OF SCOPE for the faithful tier in this pass
30
+ // (some are backstopped at the in-process unit tier, cited):
31
+ // • crash → dead (a vehicle that boots then its pane vanishes mid-run) — unit:
32
+ // daemon-liveness.test.ts "pane GONE … crash → dead".
33
+ // • boot-failure push (a vehicle that never boots → surfaceBootFailure urgent
34
+ // push up the spine) — no faithful coverage here.
35
+ // • focused-FREEZE (F3: a focused-dormant node frozen via remain-on-exit,
36
+ // pane-alive but pi-dead) — unit: daemon-liveness.test.ts "idle-release +
37
+ // live (frozen) pane …"; the grace-window double-spawn guard around it is
38
+ // exercised faithfully in grace-clock.test.ts.
39
+ // • node lifecycle --detach (A3: orphaned-focus-row hazard) — untested.
40
+ // • node msg / focus / cycle wake of a dormant node (A7) — untested faithfully.
41
+ // These are intentional boundaries, not oversights.
42
+ import { test } from 'node:test';
43
+ import assert from 'node:assert/strict';
44
+ import { spawnSync } from 'node:child_process';
45
+ import { createHarness, hasTmux } from './helpers/harness.js';
46
+ import { STALL_REPROMPT } from '../runtime/stop-guard.js';
47
+ function sessionExists(session) {
48
+ return spawnSync('tmux', ['has-session', '-t', session], { stdio: 'ignore' }).status === 0;
49
+ }
50
+ test('flagship: full node lifecycle — spawn, dormancy, one-hop wake, promote, yield, finish', { skip: !hasTmux() ? 'tmux unavailable' : false, timeout: 180_000 }, async () => {
51
+ const h = await createHarness({ sessionPrefix: 'crtr-flagship' });
52
+ try {
53
+ // ===================================================================
54
+ // S1 — A's PERSISTED ROW SHAPE: base × RESIDENT (the user's front door).
55
+ // ⚠ MINOR-1 caveat: this is NOT a real root birth. The harness mints A
56
+ // via in-process createNode over a hand-built meta whose defaults ARE
57
+ // mode:'base'/lifecycle:'resident' — the REAL birth/boot path (bootRoot)
58
+ // execs pi inline and never returns, so it is structurally unreachable
59
+ // from the harness (harness-design Wall #7). These four asserts therefore
60
+ // PIN THE PERSISTED ROW SHAPE the rest of the scenario builds on — they
61
+ // do NOT prove the real resident-birth path. The resident-at-birth 2×2
62
+ // quadrant is out of reach; the resident *behavior* (no idle-release) is
63
+ // exercised faithfully in live-mutation.test.ts, and the genuine real
64
+ // birth of a managed child (→ terminal) IS exercised at S3 below.
65
+ // ===================================================================
66
+ const A = h.spawnRoot('top-level orchestrator');
67
+ {
68
+ const a = h.node(A);
69
+ assert.equal(a.mode, 'base', 'A row shape: base');
70
+ assert.equal(a.lifecycle, 'resident', 'A row shape: resident (fixture, not a real boot)');
71
+ assert.equal(a.status, 'active', 'A active');
72
+ assert.equal(a.intent ?? null, null, 'A no intent');
73
+ }
74
+ // ===================================================================
75
+ // S2 — A promotes: orchestrator × RESIDENT (top-level orchestrator).
76
+ // mode flips; lifecycle/status/intent untouched (no transition()).
77
+ // ===================================================================
78
+ {
79
+ const res = h.cli(A, ['node', 'promote']);
80
+ assert.equal(res.code, 0, `promote A exit 0\n${res.stderr}`);
81
+ const a = h.node(A);
82
+ assert.equal(a.mode, 'orchestrator', 'A → orchestrator');
83
+ assert.equal(a.lifecycle, 'resident', 'A stays resident (no --resident)');
84
+ assert.equal(a.status, 'active', 'A still active (promote does not transition)');
85
+ assert.equal(a.intent ?? null, null, 'A intent untouched by promote');
86
+ }
87
+ // ===================================================================
88
+ // S3 — A spawns terminal child B: base × TERMINAL. The spawn seed wires
89
+ // the spine — A auto-subscribes ACTIVE to B, B spawned_by A.
90
+ // ===================================================================
91
+ const B = await h.spawnChild(A, 'do the work', { kind: 'developer' });
92
+ {
93
+ const b = h.node(B);
94
+ assert.equal(b.mode, 'base', 'B born base');
95
+ assert.equal(b.lifecycle, 'terminal', 'B born terminal (managed child)');
96
+ assert.equal(b.status, 'active', 'B active after boot');
97
+ assert.equal(b.intent ?? null, null, 'B no intent');
98
+ assert.equal(b.parent, A, 'B spawned_by / parent = A');
99
+ // The load-bearing spine seed: A subscribes_to B (active).
100
+ const subsToB = h.subscribers(B);
101
+ assert.deepEqual(subsToB, [{ node_id: A, active: true }], 'A is B\'s sole ACTIVE subscriber (spawn seed)');
102
+ assert.equal(h.inbox(A).length, 0, 'A inbox empty (spawn pushes nothing)');
103
+ }
104
+ // ===================================================================
105
+ // S4 — B spawns child C (base × terminal). B now holds an active live
106
+ // subscription to C → B is legitimately "awaiting" if it stops.
107
+ // ===================================================================
108
+ const C = await h.spawnChild(B, 'a subtask');
109
+ {
110
+ const c = h.node(C);
111
+ assert.equal(c.mode, 'base', 'C base');
112
+ assert.equal(c.lifecycle, 'terminal', 'C terminal');
113
+ assert.equal(c.status, 'active', 'C active');
114
+ assert.deepEqual(h.subscribers(C), [{ node_id: B, active: true }], 'B is C\'s sole ACTIVE subscriber (spawn seed)');
115
+ assert.deepEqual(h.subscriptions(B), [{ node_id: C, active: true }], 'B subscribes_to C (active)');
116
+ assert.equal(h.status(B), 'active', 'B still active');
117
+ }
118
+ // ===================================================================
119
+ // S5 — B goes DORMANT. It stops while awaiting C → terminal idle-release:
120
+ // transition('release') → idle / idle-release; ctx.shutdown kills pi;
121
+ // UNFOCUSED → the backstage pane CLOSES (fully dormant).
122
+ // ===================================================================
123
+ await h.stop(B);
124
+ await h.waitForStatus(B, 'idle');
125
+ {
126
+ const b = h.node(B);
127
+ assert.equal(b.status, 'idle', 'B idle (released)');
128
+ assert.equal(b.intent, 'idle-release', 'B intent=idle-release');
129
+ }
130
+ await h.waitForPaneGone(B);
131
+ assert.equal(h.paneAlive(B), false, 'B unfocused → pane closed on idle-release');
132
+ assert.equal(h.status(C), 'active', 'C still active while B sleeps');
133
+ // A untouched — a dormant terminal worker does not disturb its manager.
134
+ assert.equal(h.status(A), 'active', 'A still active');
135
+ assert.equal(h.inbox(A).length, 0, 'A inbox still empty');
136
+ // ===================================================================
137
+ // S6 — C FINISHES (push final). Pointer fans to subscribersOf(C) = {B}
138
+ // ONLY. C → done/done. A is NOT a subscriber of C → A inbox stays
139
+ // empty. This is the one-hop wake contract at the push site.
140
+ // ===================================================================
141
+ await h.finish(C, 'C result body');
142
+ {
143
+ const c = h.node(C);
144
+ assert.equal(c.status, 'done', 'C done');
145
+ assert.equal(c.intent, 'done', 'C intent=done');
146
+ assert.equal(h.paneAlive(C), false, 'C pane closed on done');
147
+ const bInbox = h.inbox(B);
148
+ const cFinal = bInbox.find((e) => e.from === C && e.kind === 'final');
149
+ assert.ok(cFinal, 'B inbox received C\'s final pointer (one hop)');
150
+ // ORACLE: ONE-HOP. A is not woken — only B (the direct subscriber) hears C.
151
+ assert.equal(h.inbox(A).length, 0, 'A inbox empty — A NOT woken by C (one-hop)');
152
+ }
153
+ // ===================================================================
154
+ // S7 — WAKE B. The in-process daemon second pass sees B idle/idle-release,
155
+ // pi dead, an unseen inbox entry → reviveNode(resume). B → active,
156
+ // intent cleared (revive). Its FRESH watcher delivers C's report.
157
+ // ===================================================================
158
+ const injBeforeWake = h.injected(B).length;
159
+ await h.tick(); // one superviseTick: 1st pass nulls the stale window, 2nd revives on inbox
160
+ await h.waitForStatus(B, 'active');
161
+ {
162
+ const b = h.node(B);
163
+ assert.equal(b.status, 'active', 'B revived → active');
164
+ assert.equal(b.intent ?? null, null, 'B intent cleared by revive');
165
+ }
166
+ await h.awaitBoot(B, { minCount: 2 }); // the resume boot (spawn boot + wake boot)
167
+ const wakeDigests = await h.awaitWake(B, { sinceCount: injBeforeWake, match: /C result body/ });
168
+ assert.ok(wakeDigests.some((d) => /C result body/.test(d)), 'B\'s real inbox-watcher delivered C\'s report after the wake');
169
+ // Still one-hop: A remained dormant-as-resident and was never touched.
170
+ assert.equal(h.inbox(A).length, 0, 'A inbox STILL empty after B woke (one-hop confirmed)');
171
+ assert.equal(h.status(A), 'active', 'A unchanged');
172
+ // ===================================================================
173
+ // S8 — B PROMOTES: base → orchestrator, lifecycle TERMINAL unchanged
174
+ // (no --resident). orchestrator × terminal (the sub-orchestrator).
175
+ // ===================================================================
176
+ {
177
+ const res = h.cli(B, ['node', 'promote', '--kind', 'developer']);
178
+ assert.equal(res.code, 0, `promote B exit 0\n${res.stderr}`);
179
+ const b = h.node(B);
180
+ assert.equal(b.mode, 'orchestrator', 'B → orchestrator');
181
+ assert.equal(b.lifecycle, 'terminal', 'B stays TERMINAL (promote is mode-only)');
182
+ assert.equal(b.status, 'active', 'B still active');
183
+ assert.equal(b.intent ?? null, null, 'B intent untouched by promote');
184
+ }
185
+ // ===================================================================
186
+ // S9 — B takes a TURN. turn_end detects the base→orchestrator persona
187
+ // drift and injects the orchestration guidance as a 'steer', then
188
+ // commits the ack. agent_end then stalls (no live sub, no final) →
189
+ // STALL_REPROMPT (followUp). Both are real stophook branches.
190
+ // ===================================================================
191
+ const injBeforeTurn = h.injected(B).length;
192
+ await h.turn(B, 'orchestrating');
193
+ const turnInjected = await h.waitFor(() => {
194
+ const fresh = h.injected(B).slice(injBeforeTurn);
195
+ // MINOR-3: match the steer by CONTENT (/ORCHESTRATOR/i), not deliverAs
196
+ // alone — mirroring live-mutation.test.ts's orchestrationSteers() — so
197
+ // this pins the orchestration guidance specifically, not "some steer."
198
+ const steer = fresh.find((e) => e.deliverAs === 'steer' && /ORCHESTRATOR/i.test(e.content));
199
+ const reprompt = fresh.find((e) => e.content.includes(STALL_REPROMPT));
200
+ return steer && reprompt ? fresh : null;
201
+ }, { timeoutMs: 15_000, label: 'B turn injected persona-drift steer + stall reprompt' });
202
+ assert.ok(turnInjected.some((e) => e.deliverAs === 'steer' && /ORCHESTRATOR/i.test(e.content)), 'turn_end injected the base→orchestrator persona-drift guidance (steer with ORCHESTRATOR content)');
203
+ assert.ok(turnInjected.some((e) => e.content.includes(STALL_REPROMPT)), 'agent_end stalled (orchestrator, no live sub, no final) → STALL_REPROMPT');
204
+ assert.equal(h.status(B), 'active', 'B still active after the turn (reprompt keeps it alive)');
205
+ // ===================================================================
206
+ // S10 — B YIELDS + revives. node yield → intent=refresh (active kept);
207
+ // agent_end (b') runs reviveInPlace (respawn-pane -k) IN the fake-pi
208
+ // pane; the fresh pi's session_start clears refresh → active. mode
209
+ // and lifecycle survive the refresh.
210
+ // ===================================================================
211
+ await h.yieldNode(B, 'refresh against the roadmap');
212
+ {
213
+ const b = h.node(B);
214
+ assert.equal(b.status, 'active', 'B active after refresh-yield');
215
+ assert.equal(b.intent ?? null, null, 'B intent=refresh cleared by the fresh boot');
216
+ assert.equal(b.mode, 'orchestrator', 'B mode survives the refresh');
217
+ assert.equal(b.lifecycle, 'terminal', 'B lifecycle survives the refresh');
218
+ }
219
+ // ===================================================================
220
+ // S11 — B spawns child D (base × terminal). New spine seed B→D active.
221
+ // ===================================================================
222
+ const D = await h.spawnChild(B, 'a second subtask');
223
+ {
224
+ const d = h.node(D);
225
+ assert.equal(d.mode, 'base', 'D base');
226
+ assert.equal(d.lifecycle, 'terminal', 'D terminal');
227
+ assert.equal(d.status, 'active', 'D active');
228
+ assert.ok(h.subscriptions(B).some((s) => s.node_id === D && s.active), 'B subscribes_to D (active)');
229
+ }
230
+ // ===================================================================
231
+ // S12 — D FINISHES → pointer to B (its only subscriber).
232
+ // ===================================================================
233
+ await h.finish(D, 'D result body');
234
+ {
235
+ assert.equal(h.node(D).status, 'done', 'D done');
236
+ const dFinal = h.inbox(B).find((e) => e.from === D && e.kind === 'final');
237
+ assert.ok(dFinal, 'B inbox received D\'s final pointer');
238
+ }
239
+ // ===================================================================
240
+ // S13 — B pushes its FINAL up the spine → done/done; the pointer fans to
241
+ // subscribersOf(B) = {A}. NOW A finally hears B (its own explicit
242
+ // push — the only way the chain propagates a hop).
243
+ // ===================================================================
244
+ {
245
+ const res = h.cli(B, ['push', 'final', 'all work complete']);
246
+ assert.equal(res.code, 0, `B push final exit 0\n${res.stderr}`);
247
+ const b = h.node(B);
248
+ assert.equal(b.status, 'done', 'B done after push final');
249
+ assert.equal(b.intent, 'done', 'B intent=done');
250
+ const aInbox = h.inbox(A);
251
+ assert.equal(aInbox.length, 1, 'A inbox now has exactly B\'s final (one hop, on B\'s push)');
252
+ assert.equal(aInbox[0].from, B, 'A\'s entry is from B');
253
+ assert.equal(aInbox[0].kind, 'final', 'A\'s entry is a final');
254
+ }
255
+ // Close B's window faithfully (done branch shutdown).
256
+ await h.stop(B);
257
+ await h.waitForPaneGone(B);
258
+ assert.equal(h.paneAlive(B), false, 'B pane closed on done');
259
+ // A — the resident top-level orchestrator — is still alive and well.
260
+ {
261
+ const a = h.node(A);
262
+ assert.equal(a.status, 'active', 'A still active at the end');
263
+ assert.equal(a.mode, 'orchestrator', 'A still orchestrator');
264
+ assert.equal(a.lifecycle, 'resident', 'A still resident');
265
+ }
266
+ }
267
+ finally {
268
+ const session = h.session;
269
+ await h.dispose();
270
+ // Teardown leaves NO stray session.
271
+ assert.equal(sessionExists(session), false, 'isolated session killed — no stray');
272
+ }
273
+ });
@@ -1,9 +1,8 @@
1
1
  // Run with: node --import tsx/esm --test src/core/__tests__/focuses.test.ts
2
2
  //
3
- // STEP 4 of the placement/focus migration: the `focuses` table + canvas setters
4
- // + placement reads + the transitional focus.ptr dual-write bridge. Purely
5
- // ADDITIVE: the table is populated in lockstep with the legacy `focus.ptr`, but
6
- // nothing reads it as authority yet (that switch is Step 6). Covers:
3
+ // The `focuses` table (canvas.db, migration v6) + its canvas setters + the
4
+ // placement reads that compose over them. The table is the CANONICAL focus store
5
+ // there is no focus.ptr file and no dual-write bridge. Covers:
7
6
  // - migration v6 adds `focuses` to a fresh db (and a legacy v5 db migrates up);
8
7
  // idempotent / forward-only on re-run + re-open
9
8
  // - canvas setters/reads round-trip: open / setOccupant / setPane / close;
@@ -13,23 +12,17 @@
13
12
  // - independent focus rows don't contend
14
13
  // - placement focusOf / isFocused / focusByPane / focusedNodes / listFocuses
15
14
  // agree with the rows
16
- // - dual-write: setFocus populates the table; getFocus falls back to the table
17
- // when focus.ptr is absent; setFocus('') clears both
18
15
  import { test, before, beforeEach, after } from 'node:test';
19
16
  import assert from 'node:assert/strict';
20
- import { mkdtempSync, rmSync, existsSync, unlinkSync } from 'node:fs';
17
+ import { mkdtempSync, rmSync } from 'node:fs';
21
18
  import { tmpdir } from 'node:os';
22
19
  import { join } from 'node:path';
23
20
  import { DatabaseSync } from 'node:sqlite';
24
21
  import { openFocusRow, setFocusOccupant, setFocusPane, closeFocusRow, getFocusByNode, getFocusByPane, getFocusById, listFocuses, } from '../canvas/focuses.js';
25
22
  import { openDb, closeDb, migrate, MIGRATIONS } from '../canvas/db.js';
26
- import { canvasDbPath, ensureHome, crtrHome } from '../canvas/paths.js';
23
+ import { canvasDbPath, ensureHome } from '../canvas/paths.js';
27
24
  import { focusOf, isFocused, focusByPane, focusedNodes, listFocuses as placementListFocuses, } from '../runtime/placement.js';
28
- import { setFocus, getFocus } from '../runtime/presence.js';
29
25
  let home;
30
- // Saved/restored so the bridge always exercises its deterministic no-tmux path
31
- // regardless of whether the suite is run from inside a tmux session.
32
- let savedTmux;
33
26
  function userVersion(db) {
34
27
  return db.prepare('PRAGMA user_version').get().user_version;
35
28
  }
@@ -39,8 +32,6 @@ function tableNames(db) {
39
32
  before(() => {
40
33
  home = mkdtempSync(join(tmpdir(), 'crtr-focuses-'));
41
34
  process.env['CRTR_HOME'] = home;
42
- savedTmux = process.env['TMUX'];
43
- delete process.env['TMUX'];
44
35
  });
45
36
  beforeEach(() => {
46
37
  closeDb();
@@ -50,8 +41,6 @@ after(() => {
50
41
  closeDb();
51
42
  rmSync(home, { recursive: true, force: true });
52
43
  delete process.env['CRTR_HOME'];
53
- if (savedTmux !== undefined)
54
- process.env['TMUX'] = savedTmux;
55
44
  });
56
45
  // ---------------------------------------------------------------------------
57
46
  // Migration v6 — the additive `focuses` table.
@@ -205,55 +194,3 @@ test('placement focus reads agree with the focus rows', () => {
205
194
  assert.deepEqual(focusedNodes(), new Set(['A', 'B']));
206
195
  assert.deepEqual(placementListFocuses().map((f) => f.node_id), ['A', 'B']);
207
196
  });
208
- // ---------------------------------------------------------------------------
209
- // Dual-write bridge — setFocus populates the table; getFocus falls back to the
210
- // table when focus.ptr is absent; setFocus('') clears both.
211
- // ---------------------------------------------------------------------------
212
- function focusPtrPath() {
213
- return join(crtrHome(), 'focus.ptr');
214
- }
215
- test('setFocus populates the focuses table in lockstep with focus.ptr', () => {
216
- openDb();
217
- setFocus('A');
218
- assert.equal(getFocus(), 'A', 'focus.ptr reads back');
219
- const row = getFocusByNode('A');
220
- assert.ok(row, 'a canonical focus row mirrors the current focus');
221
- assert.equal(row?.node_id, 'A');
222
- assert.equal(isFocused('A'), true, 'placement.isFocused agrees');
223
- assert.deepEqual(focusOf('A')?.node_id, 'A', 'placement.focusOf agrees with getFocus');
224
- // Re-focusing a different node re-points the SAME canonical row (no stray rows,
225
- // UNIQUE(node_id) upheld).
226
- setFocus('B');
227
- assert.equal(getFocus(), 'B');
228
- assert.equal(getFocusByNode('A'), null, 'the old occupant is dropped');
229
- assert.equal(getFocusByNode('B')?.node_id, 'B');
230
- assert.equal(listFocuses().length, 1, 'still exactly one canonical row');
231
- });
232
- test('getFocus falls back to the table when focus.ptr is absent', () => {
233
- openDb();
234
- setFocus('A'); // writes both focus.ptr and the canonical row
235
- // Simulate a missing pointer (a writer that reached only the table, or a lost
236
- // file): delete focus.ptr and confirm getFocus recovers the focus from the row.
237
- if (existsSync(focusPtrPath()))
238
- unlinkSync(focusPtrPath());
239
- assert.equal(getFocus(), 'A', 'getFocus recovers the focus from the table');
240
- });
241
- test("setFocus('') clears both the pointer and the canonical focus row", () => {
242
- openDb();
243
- setFocus('A');
244
- assert.equal(getFocus(), 'A');
245
- assert.ok(getFocusByNode('A'), 'precondition: row present');
246
- setFocus('');
247
- assert.equal(getFocus(), null, 'getFocus is null after clear (ptr empty, no row)');
248
- assert.equal(getFocusByNode('A'), null, 'the canonical row was closed');
249
- assert.deepEqual(listFocuses(), [], 'no focus rows remain');
250
- });
251
- test('a focus row written directly (no focus.ptr) is visible through getFocus + placement', () => {
252
- openDb();
253
- // A writer that reached only the table (the canonical bridge row), with no
254
- // focus.ptr on disk at all.
255
- openFocusRow('__focus_ptr__', null, null, 'X');
256
- assert.ok(!existsSync(focusPtrPath()), 'precondition: no focus.ptr file');
257
- assert.equal(getFocus(), 'X', 'getFocus falls back to the canonical row');
258
- assert.equal(isFocused('X'), true);
259
- });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,115 @@
1
+ // Run with: node --import tsx/esm --test src/core/__tests__/grace-clock.test.ts
2
+ //
3
+ // AXIS: the REVIVE_GRACE_MS double-spawn guard (daemon invariant 10), exercised
4
+ // FAITHFULLY with a CONTROLLED CLOCK via the harness's injectable tick(now).
5
+ //
6
+ // Why this exists (MINOR-4): the harness exposes superviseTick's injectable
7
+ // `now` through h.tick(now), but every other faithful test calls h.tick() with
8
+ // no arg — so the grace window (the guard that a pi observed dead-while-its-pane-
9
+ // lives must pend through REVIVE_GRACE_MS before a revive, lest a revive land in
10
+ // the transient old-pi-dies→fresh-pi-boots gap and DOUBLE-SPAWN) was never
11
+ // exercised end-to-end. daemon-liveness.test.ts pins livenessVerdict purely and
12
+ // drives superviseTick with a FABRICATED pi-death (deadPid); this drives a REAL
13
+ // fake-pi boot, kills it under a FROZEN (remain-on-exit) pane so the pane stays
14
+ // alive while pi is genuinely dead, then walks a deterministic clock across the
15
+ // grace boundary.
16
+ //
17
+ // This file is ADDITIVE and uses ONLY the public Harness API + h.tick(now) +
18
+ // test-local tmux/file reads (the same shape as live-mutation.test.ts's
19
+ // firstPaneOf/demote helpers). It does NOT edit harness.ts / fake-pi-host.ts or
20
+ // any production file, and adds no harness helper.
21
+ import { test } from 'node:test';
22
+ import assert from 'node:assert/strict';
23
+ import { spawnSync } from 'node:child_process';
24
+ import { readFileSync } from 'node:fs';
25
+ import { join } from 'node:path';
26
+ import { createHarness, hasTmux } from './helpers/harness.js';
27
+ import { isPidAlive } from '../../daemon/crtrd.js';
28
+ const SKIP = !hasTmux() ? 'tmux unavailable' : false;
29
+ function sessionExists(session) {
30
+ return spawnSync('tmux', ['has-session', '-t', session], { stdio: 'ignore' }).status === 0;
31
+ }
32
+ /** Count the fake-pi boots recorded for a node (one line per boot in
33
+ * fake-pi.boots.jsonl) — the observable for "did a revive double-spawn?". */
34
+ function bootCount(home, id) {
35
+ try {
36
+ return readFileSync(join(home, 'nodes', id, 'fake-pi.boots.jsonl'), 'utf8')
37
+ .split('\n')
38
+ .filter((l) => l.trim() !== '').length;
39
+ }
40
+ catch {
41
+ return 0;
42
+ }
43
+ }
44
+ // REVIVE_GRACE_MS is 20_000 (crtrd.ts). We choose offsets well inside / well
45
+ // past it so the test is robust to the exact value without importing it.
46
+ const NOW = 5_000_000;
47
+ const WITHIN_GRACE = NOW + 10_000; // < 20s after first-observed-dead → still pending
48
+ const PAST_GRACE = NOW + 25_000; // > 20s after first-observed-dead → revive
49
+ // ===========================================================================
50
+ // The grace window guards against a double-spawn: while a node's pane is alive
51
+ // but its pi has been observed dead for LESS than REVIVE_GRACE_MS, the daemon
52
+ // must NOT revive (a revive there would race the in-flight respawn and spawn a
53
+ // second vehicle on the same pane). Once the pi has been dead PAST the grace, a
54
+ // revive proceeds.
55
+ // ===========================================================================
56
+ test('grace clock: a dead-pi/alive-pane node does NOT revive within REVIVE_GRACE_MS, but DOES once it elapses', { skip: SKIP, timeout: 120_000 }, async () => {
57
+ const h = await createHarness({ sessionPrefix: 'crtr-grace' });
58
+ try {
59
+ const A = h.spawnRoot('resident root');
60
+ const B = await h.spawnChild(A, 'do the work', { kind: 'developer' });
61
+ // The boot proof carries the live fake-pi pid; the row records it via the
62
+ // real session_start hook. Both are the SAME process — that pid is what
63
+ // handleLiveWindow judges liveness on.
64
+ const boot = await h.awaitBoot(B);
65
+ const b0 = h.node(B);
66
+ assert.equal(b0.status, 'active', 'B active after boot');
67
+ assert.equal(b0.intent ?? null, null, 'B intent=null (NOT idle-release — the grace path, not the frozen early-return)');
68
+ assert.equal(b0.pi_pid, boot.pid, 'row pi_pid == the live fake-pi pid (recorded at session_start)');
69
+ assert.equal(bootCount(h.home, B), 1, 'exactly one boot so far');
70
+ // Arm remain-on-exit on B's window so that when we kill its pi the PANE
71
+ // survives (frozen) rather than closing — that is the only way to produce
72
+ // the "pane alive but pi dead" state handleLiveWindow's grace path judges.
73
+ const ro = spawnSync('tmux', ['set-window-option', '-t', b0.window, 'remain-on-exit', 'on'], { stdio: 'ignore' });
74
+ assert.equal(ro.status, 0, 'armed remain-on-exit on B\'s window');
75
+ // Kill the fake-pi. pi dies; the frozen pane stays alive.
76
+ process.kill(boot.pid, 'SIGKILL');
77
+ await h.waitFor(() => !isPidAlive(boot.pid), { timeoutMs: 10_000, label: 'fake-pi pid dead' });
78
+ assert.equal(h.paneAlive(B), true, 'pane is FROZEN alive after pi death (remain-on-exit)');
79
+ // --- TICK 1 @ NOW: first observation of the dead pi → 'pending'. The
80
+ // daemon records first-observed-dead and does NOT revive. ---
81
+ await h.tick(NOW);
82
+ {
83
+ const b = h.node(B);
84
+ assert.equal(bootCount(h.home, B), 1, 'first tick: NO revive on first-observed-dead (still 1 boot)');
85
+ assert.equal(b.pi_pid, boot.pid, 'pi_pid unchanged — no fresh vehicle spawned');
86
+ assert.equal(b.status, 'active', 'B left active (pending, not revived)');
87
+ assert.equal(h.paneAlive(B), true, 'frozen pane still alive');
88
+ }
89
+ // --- TICK 2 @ NOW+10s (WITHIN the 20s grace): STILL pending. This is the
90
+ // double-spawn guard: a revive here would land in the respawn gap. ---
91
+ await h.tick(WITHIN_GRACE);
92
+ {
93
+ const b = h.node(B);
94
+ assert.equal(bootCount(h.home, B), 1, 'within REVIVE_GRACE_MS: NO double-spawn — the dead pi must pend, not revive');
95
+ assert.equal(b.pi_pid, boot.pid, 'pi_pid STILL the dead pid — guard held');
96
+ assert.equal(b.status, 'active', 'B still active inside the grace window');
97
+ }
98
+ // --- TICK 3 @ NOW+25s (PAST the 20s grace): now a revive proceeds — a
99
+ // FRESH fake-pi boots in the frozen pane (respawn-pane -k resume). ---
100
+ await h.tick(PAST_GRACE);
101
+ await h.awaitBoot(B, { minCount: 2, timeoutMs: 30_000 });
102
+ assert.ok(bootCount(h.home, B) >= 2, 'past REVIVE_GRACE_MS: the dead pi is revived → a fresh vehicle boots');
103
+ await h.waitForStatus(B, 'active');
104
+ {
105
+ const b = h.node(B);
106
+ assert.equal(b.status, 'active', 'B active after the grace-window revive');
107
+ assert.notEqual(b.pi_pid, boot.pid, 'pi_pid advanced to the fresh vehicle — the revive landed');
108
+ }
109
+ }
110
+ finally {
111
+ const session = h.session;
112
+ await h.dispose();
113
+ assert.equal(sessionExists(session), false, 'isolated session killed — no stray');
114
+ }
115
+ });
@@ -0,0 +1,78 @@
1
+ import type { NodeMeta, NodeStatus, Mode, Lifecycle } from '../../canvas/types.js';
2
+ import type { InboxEntry } from '../../feed/inbox.js';
3
+ /** True when a usable tmux is on PATH — tests gate on this and SKIP otherwise. */
4
+ export declare function hasTmux(): boolean;
5
+ export interface WaitOpts {
6
+ timeoutMs?: number;
7
+ intervalMs?: number;
8
+ label?: string;
9
+ }
10
+ export interface Injected {
11
+ content: string;
12
+ deliverAs?: string;
13
+ }
14
+ export interface BootProof {
15
+ pid: number;
16
+ nodeId: string;
17
+ resuming: boolean;
18
+ prompt: string | null;
19
+ extPaths: string[];
20
+ loaded: string[];
21
+ failedExt: string[];
22
+ env: Record<string, string | null>;
23
+ [k: string]: unknown;
24
+ }
25
+ export interface CliResult {
26
+ code: number;
27
+ stdout: string;
28
+ stderr: string;
29
+ json?: unknown;
30
+ }
31
+ export interface HarnessOpts {
32
+ sessionPrefix?: string;
33
+ }
34
+ export interface SpawnOpts {
35
+ kind?: string;
36
+ mode?: Mode;
37
+ lifecycle?: Lifecycle;
38
+ id?: string;
39
+ }
40
+ export interface Harness {
41
+ home: string;
42
+ session: string;
43
+ spawnRoot(task: string, o?: SpawnOpts): string;
44
+ spawnChild(parentId: string, task: string, o?: SpawnOpts): Promise<string>;
45
+ cli(nodeId: string | null, args: string[]): CliResult;
46
+ turn(nodeId: string, text?: string): Promise<void>;
47
+ stop(nodeId: string, reason?: 'stop' | 'length' | 'aborted' | 'error'): Promise<void>;
48
+ finish(nodeId: string, finalText: string): Promise<void>;
49
+ yieldNode(nodeId: string, note: string): Promise<void>;
50
+ tick(now?: number): Promise<void>;
51
+ awaitBoot(nodeId: string, o?: {
52
+ minCount?: number;
53
+ timeoutMs?: number;
54
+ }): Promise<BootProof>;
55
+ awaitWake(nodeId: string, o?: {
56
+ sinceCount?: number;
57
+ timeoutMs?: number;
58
+ match?: RegExp;
59
+ }): Promise<string[]>;
60
+ waitForStatus(nodeId: string, status: NodeStatus, timeoutMs?: number): Promise<void>;
61
+ waitForPaneGone(nodeId: string, timeoutMs?: number): Promise<void>;
62
+ waitFor<T>(probe: () => T | undefined | null | false, o?: WaitOpts): Promise<T>;
63
+ node(nodeId: string): NodeMeta | null;
64
+ status(nodeId: string): NodeStatus | null;
65
+ paneAlive(nodeId: string): boolean;
66
+ inbox(nodeId: string): InboxEntry[];
67
+ injected(nodeId: string): Injected[];
68
+ subscribers(nodeId: string): {
69
+ node_id: string;
70
+ active: boolean;
71
+ }[];
72
+ subscriptions(nodeId: string): {
73
+ node_id: string;
74
+ active: boolean;
75
+ }[];
76
+ dispose(): Promise<void>;
77
+ }
78
+ export declare function createHarness(opts?: HarnessOpts): Promise<Harness>;