@crouton-kit/crouter 0.3.16 → 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 (72) hide show
  1. package/dist/builtin-personas/orchestration-kernel.md +6 -6
  2. package/dist/builtin-personas/plan/base.md +1 -1
  3. package/dist/builtin-personas/spec/base.md +1 -1
  4. package/dist/commands/canvas-browse.d.ts +2 -0
  5. package/dist/commands/canvas-browse.js +45 -0
  6. package/dist/commands/canvas-prune.js +11 -2
  7. package/dist/commands/canvas.js +3 -2
  8. package/dist/commands/node.js +13 -0
  9. package/dist/commands/skill/author.js +2 -2
  10. package/dist/core/__tests__/cascade-close.test.js +199 -0
  11. package/dist/core/__tests__/daemon-boot.test.js +7 -0
  12. package/dist/core/__tests__/daemon-liveness.test.js +59 -4
  13. package/dist/core/__tests__/dead-pane-regression.test.js +151 -0
  14. package/dist/core/__tests__/fixtures/fake-pi-host.d.ts +2 -0
  15. package/dist/core/__tests__/fixtures/fake-pi-host.js +301 -0
  16. package/dist/core/__tests__/flagship-lifecycle.test.js +273 -0
  17. package/dist/core/__tests__/grace-clock.test.d.ts +1 -0
  18. package/dist/core/__tests__/grace-clock.test.js +115 -0
  19. package/dist/core/__tests__/helpers/harness.d.ts +78 -0
  20. package/dist/core/__tests__/helpers/harness.js +406 -0
  21. package/dist/core/__tests__/lifecycle.test.js +6 -13
  22. package/dist/core/__tests__/live-mutation.test.d.ts +1 -0
  23. package/dist/core/__tests__/live-mutation.test.js +341 -0
  24. package/dist/core/__tests__/placement-focus.test.js +53 -15
  25. package/dist/core/__tests__/relaunch.test.js +12 -12
  26. package/dist/core/__tests__/reset.test.js +11 -6
  27. package/dist/core/__tests__/spike-harness.test.d.ts +1 -0
  28. package/dist/core/__tests__/spike-harness.test.js +241 -0
  29. package/dist/core/__tests__/subscription-delivery.test.d.ts +1 -0
  30. package/dist/core/__tests__/subscription-delivery.test.js +233 -0
  31. package/dist/core/canvas/browse/__tests__/model.test.d.ts +1 -0
  32. package/dist/core/canvas/browse/__tests__/model.test.js +142 -0
  33. package/dist/core/canvas/browse/__tests__/render.test.d.ts +1 -0
  34. package/dist/core/canvas/browse/__tests__/render.test.js +102 -0
  35. package/dist/core/canvas/browse/app.d.ts +4 -0
  36. package/dist/core/canvas/browse/app.js +349 -0
  37. package/dist/core/canvas/browse/model.d.ts +97 -0
  38. package/dist/core/canvas/browse/model.js +258 -0
  39. package/dist/core/canvas/browse/render.d.ts +41 -0
  40. package/dist/core/canvas/browse/render.js +387 -0
  41. package/dist/core/canvas/browse/terminal.d.ts +23 -0
  42. package/dist/core/canvas/browse/terminal.js +100 -0
  43. package/dist/core/canvas/canvas.d.ts +9 -2
  44. package/dist/core/canvas/canvas.js +41 -3
  45. package/dist/core/canvas/render.d.ts +10 -0
  46. package/dist/core/canvas/render.js +25 -1
  47. package/dist/core/feed/inbox.d.ts +0 -3
  48. package/dist/core/feed/inbox.js +1 -5
  49. package/dist/core/runtime/busy.d.ts +8 -0
  50. package/dist/core/runtime/busy.js +46 -0
  51. package/dist/core/runtime/lifecycle.d.ts +1 -1
  52. package/dist/core/runtime/lifecycle.js +12 -4
  53. package/dist/core/runtime/naming.d.ts +3 -3
  54. package/dist/core/runtime/naming.js +6 -6
  55. package/dist/core/runtime/placement.d.ts +22 -5
  56. package/dist/core/runtime/placement.js +44 -13
  57. package/dist/core/runtime/reset.d.ts +11 -8
  58. package/dist/core/runtime/reset.js +23 -18
  59. package/dist/daemon/crtrd.js +43 -21
  60. package/dist/pi-extensions/canvas-nav.js +29 -25
  61. package/dist/pi-extensions/canvas-resume.d.ts +0 -1
  62. package/dist/pi-extensions/canvas-resume.js +35 -126
  63. package/dist/pi-extensions/canvas-stophook.d.ts +1 -1
  64. package/dist/pi-extensions/canvas-stophook.js +16 -0
  65. package/dist/prompts/skill.js +6 -1
  66. package/package.json +1 -1
  67. package/dist/commands/__tests__/skill.test.js +0 -290
  68. package/dist/core/__tests__/pkg.test.js +0 -218
  69. package/dist/core/__tests__/sys.test.js +0 -208
  70. /package/dist/{commands/__tests__/skill.test.d.ts → core/__tests__/cascade-close.test.d.ts} +0 -0
  71. /package/dist/core/__tests__/{pkg.test.d.ts → dead-pane-regression.test.d.ts} +0 -0
  72. /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
+ });
@@ -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>;