@crouton-kit/crouter 0.3.21 → 0.3.22

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 (111) hide show
  1. package/dist/commands/canvas-history/read.d.ts +1 -0
  2. package/dist/commands/canvas-history/read.js +61 -0
  3. package/dist/commands/canvas-history/search.d.ts +1 -0
  4. package/dist/commands/canvas-history/search.js +270 -0
  5. package/dist/commands/canvas-history/show.d.ts +1 -0
  6. package/dist/commands/canvas-history/show.js +79 -0
  7. package/dist/commands/canvas-history.d.ts +2 -0
  8. package/dist/commands/canvas-history.js +33 -0
  9. package/dist/commands/canvas.js +3 -2
  10. package/dist/commands/memory/find.js +7 -40
  11. package/dist/commands/memory/lint.js +19 -34
  12. package/dist/commands/memory/list.js +8 -18
  13. package/dist/commands/memory/read.js +4 -37
  14. package/dist/commands/pkg/bridge.d.ts +1 -0
  15. package/dist/commands/pkg/bridge.js +137 -0
  16. package/dist/commands/pkg/plugin-inspect.js +7 -6
  17. package/dist/commands/pkg.js +2 -1
  18. package/dist/commands/skill/author.js +35 -44
  19. package/dist/commands/skill/shared.d.ts +1 -5
  20. package/dist/commands/skill/shared.js +9 -63
  21. package/dist/commands/skill.js +5 -6
  22. package/dist/commands/sys/doctor.js +6 -144
  23. package/dist/core/__tests__/broker-dormant-wake.test.js +71 -34
  24. package/dist/core/__tests__/canvas-inbox-watcher-hold.test.js +21 -5
  25. package/dist/core/__tests__/canvas-inbox-watcher.test.js +21 -5
  26. package/dist/core/__tests__/child-death-wake.test.js +133 -185
  27. package/dist/core/__tests__/daemon-liveness.test.js +28 -258
  28. package/dist/core/__tests__/flagship-lifecycle.test.js +156 -135
  29. package/dist/core/__tests__/frame-decoder-perf.test.js +51 -25
  30. package/dist/core/__tests__/{broker-attach-limits.test.js → full/broker-attach-limits.test.js} +62 -35
  31. package/dist/core/__tests__/{broker-attach-stream.test.js → full/broker-attach-stream.test.js} +54 -31
  32. package/dist/core/__tests__/full/broker-crash-teardown.test.js +143 -0
  33. package/dist/core/__tests__/full/broker-dialogs.test.js +153 -0
  34. package/dist/core/__tests__/full/broker-lifecycle.test.js +116 -0
  35. package/dist/core/__tests__/{cascade-close.test.js → full/cascade-close.test.js} +1 -1
  36. package/dist/core/__tests__/full/daemon-liveness-pane.full.test.js +292 -0
  37. package/dist/core/__tests__/{dead-pane-regression.test.js → full/dead-pane-regression.test.js} +2 -2
  38. package/dist/core/__tests__/{detach-focus.test.js → full/detach-focus.test.js} +5 -5
  39. package/dist/core/__tests__/{human-new-window-regression.test.js → full/human-new-window-regression.test.js} +2 -2
  40. package/dist/core/__tests__/{placement-focus.test.js → full/placement-focus.test.js} +5 -5
  41. package/dist/core/__tests__/{placement-reconcile.test.js → full/placement-reconcile.test.js} +3 -3
  42. package/dist/core/__tests__/{placement-revive.test.js → full/placement-revive.test.js} +5 -5
  43. package/dist/core/__tests__/{placement-teardown.test.js → full/placement-teardown.test.js} +4 -4
  44. package/dist/core/__tests__/{review-render-pane-regression.test.js → full/review-render-pane-regression.test.js} +2 -2
  45. package/dist/core/__tests__/full/spike-harness.test.js +117 -0
  46. package/dist/core/__tests__/grace-clock.test.js +72 -75
  47. package/dist/core/__tests__/helpers/harness.d.ts +35 -1
  48. package/dist/core/__tests__/helpers/harness.js +70 -12
  49. package/dist/core/__tests__/live-mutation-verbs.test.js +99 -104
  50. package/dist/core/__tests__/live-mutation.test.js +111 -134
  51. package/dist/core/__tests__/revive.test.js +96 -86
  52. package/dist/core/__tests__/subscription-delivery.test.js +116 -138
  53. package/dist/core/__tests__/wake-origin.test.js +54 -50
  54. package/dist/core/bridge-map.d.ts +19 -0
  55. package/dist/core/bridge-map.js +73 -0
  56. package/dist/core/canvas/history.d.ts +91 -0
  57. package/dist/core/canvas/history.js +389 -0
  58. package/dist/core/canvas/index.d.ts +1 -0
  59. package/dist/core/canvas/index.js +1 -0
  60. package/dist/core/config.js +3 -22
  61. package/dist/core/memory-resolver.d.ts +9 -2
  62. package/dist/core/memory-resolver.js +47 -7
  63. package/dist/core/resolver.d.ts +1 -30
  64. package/dist/core/resolver.js +6 -546
  65. package/dist/core/runtime/persona.js +5 -7
  66. package/dist/core/scope.d.ts +7 -5
  67. package/dist/core/scope.js +16 -17
  68. package/dist/core/substrate/ceiling.d.ts +28 -0
  69. package/dist/core/substrate/ceiling.js +87 -0
  70. package/dist/core/substrate/index.d.ts +2 -0
  71. package/dist/core/substrate/index.js +1 -0
  72. package/dist/core/substrate/on-read.js +18 -2
  73. package/dist/core/substrate/render.d.ts +2 -3
  74. package/dist/core/substrate/render.js +63 -51
  75. package/dist/pi-extensions/canvas-inbox-watcher.js +19 -2
  76. package/dist/types.d.ts +3 -18
  77. package/dist/types.js +0 -8
  78. package/package.json +4 -3
  79. package/dist/builtin-skills/.crouter-plugin/plugin.json +0 -5
  80. package/dist/builtin-skills/skills/crouter-development/marketplaces/SKILL.md +0 -157
  81. package/dist/builtin-skills/skills/crouter-development/personas/SKILL.md +0 -106
  82. package/dist/builtin-skills/skills/crouter-development/personas/base-prompt/SKILL.md +0 -49
  83. package/dist/builtin-skills/skills/crouter-development/personas/orchestrator-prompt/SKILL.md +0 -49
  84. package/dist/builtin-skills/skills/crouter-development/plugins/SKILL.md +0 -156
  85. package/dist/builtin-skills/skills/design/SKILL.md +0 -51
  86. package/dist/builtin-skills/skills/development/SKILL.md +0 -109
  87. package/dist/builtin-skills/skills/planning/SKILL.md +0 -59
  88. package/dist/builtin-skills/skills/spec/SKILL.md +0 -83
  89. package/dist/commands/skill/state.d.ts +0 -3
  90. package/dist/commands/skill/state.js +0 -71
  91. package/dist/core/__tests__/broker-crash-teardown.test.js +0 -116
  92. package/dist/core/__tests__/broker-dialogs.test.js +0 -126
  93. package/dist/core/__tests__/broker-lifecycle.test.js +0 -87
  94. package/dist/core/__tests__/resolver.test.js +0 -181
  95. package/dist/core/__tests__/spike-harness.test.js +0 -242
  96. /package/dist/core/__tests__/{broker-attach-limits.test.d.ts → full/broker-attach-limits.test.d.ts} +0 -0
  97. /package/dist/core/__tests__/{broker-attach-stream.test.d.ts → full/broker-attach-stream.test.d.ts} +0 -0
  98. /package/dist/core/__tests__/{broker-crash-teardown.test.d.ts → full/broker-crash-teardown.test.d.ts} +0 -0
  99. /package/dist/core/__tests__/{broker-dialogs.test.d.ts → full/broker-dialogs.test.d.ts} +0 -0
  100. /package/dist/core/__tests__/{broker-lifecycle.test.d.ts → full/broker-lifecycle.test.d.ts} +0 -0
  101. /package/dist/core/__tests__/{cascade-close.test.d.ts → full/cascade-close.test.d.ts} +0 -0
  102. /package/dist/core/__tests__/{dead-pane-regression.test.d.ts → full/daemon-liveness-pane.full.test.d.ts} +0 -0
  103. /package/dist/core/__tests__/{detach-focus.test.d.ts → full/dead-pane-regression.test.d.ts} +0 -0
  104. /package/dist/core/__tests__/{human-new-window-regression.test.d.ts → full/detach-focus.test.d.ts} +0 -0
  105. /package/dist/core/__tests__/{placement-focus.test.d.ts → full/human-new-window-regression.test.d.ts} +0 -0
  106. /package/dist/core/__tests__/{placement-reconcile.test.d.ts → full/placement-focus.test.d.ts} +0 -0
  107. /package/dist/core/__tests__/{placement-revive.test.d.ts → full/placement-reconcile.test.d.ts} +0 -0
  108. /package/dist/core/__tests__/{placement-teardown.test.d.ts → full/placement-revive.test.d.ts} +0 -0
  109. /package/dist/core/__tests__/{resolver.test.d.ts → full/placement-teardown.test.d.ts} +0 -0
  110. /package/dist/core/__tests__/{review-render-pane-regression.test.d.ts → full/review-render-pane-regression.test.d.ts} +0 -0
  111. /package/dist/core/__tests__/{spike-harness.test.d.ts → full/spike-harness.test.d.ts} +0 -0
@@ -1,48 +1,42 @@
1
+ // Run with: node --import tsx/esm --test src/core/__tests__/daemon-liveness.test.ts
2
+ //
3
+ // HEADLESS RETARGET (foundation-spec §C.8 + §E). The FAST half of the daemon
4
+ // liveness suite — the PURE decision primitives, no tmux at all. The pane-alive
5
+ // reconciliation half (withLiveWindow/withLivePane → paneLocation/listLivePanes
6
+ // tmux probes — a genuine tmux dependency) lives in full/daemon-liveness-pane.full.test.ts.
7
+ //
8
+ // (1) BUG LOCKED — the REVIVE_GRACE_MS double-spawn guard, distilled to its pure
9
+ // core: livenessVerdict(piPidAlive, deadFor). A pi observed DEAD must PEND
10
+ // through the grace window before a revive (revive too early lands in the
11
+ // old-pi-dies→fresh-pi-boots gap and double-spawns a second vehicle on one
12
+ // .jsonl); a live/unknown pi is left alone. Plus isPidAlive, the signal-0
13
+ // probe the whole supervision pass keys on.
14
+ //
15
+ // (2) WHY MODEL-LEVEL, NOT TMUX CHROME — livenessVerdict is a total pure function
16
+ // of (alive?, deadFor): zero process, zero tmux, instant. isPidAlive is a
17
+ // kill(pid, 0) syscall on a self/reaped pid — no pane, no session. Neither
18
+ // decision consults a window, so neither needs one to be exercised
19
+ // faithfully. (The pane-existence reconciliation — paneAlive vs window-cache,
20
+ // gone-branch routing, frozen-pane revive — DOES need a real pane to be
21
+ // faithful, so it stays in the full tier.)
22
+ //
23
+ // (3) HOW THE DRIVE STILL FAILS IF THE BUG REGRESSES — drop the
24
+ // `deadFor < REVIVE_GRACE_MS` pending branch and livenessVerdict(false, 1_000)
25
+ // returns 'revive' instead of 'pending' → the grace-window asserts go RED.
1
26
  import { test, before, after, beforeEach } from 'node:test';
2
27
  import assert from 'node:assert/strict';
3
28
  import { mkdtempSync, rmSync } from 'node:fs';
4
29
  import { tmpdir } from 'node:os';
5
30
  import { join } from 'node:path';
6
- import { spawn, spawnSync } from 'node:child_process';
7
- import { createNode, getNode, subscribe } from '../canvas/canvas.js';
31
+ import { spawnSync } from 'node:child_process';
8
32
  import { closeDb } from '../canvas/db.js';
9
- import { appendInbox } from '../feed/inbox.js';
10
- import { markBusy, clearBusy } from '../runtime/busy.js';
11
- import { superviseTick, isPidAlive, livenessVerdict, } from '../../daemon/crtrd.js';
33
+ import { isPidAlive, livenessVerdict } from '../../daemon/crtrd.js';
12
34
  let home;
13
- function node(id, over = {}) {
14
- return {
15
- node_id: id,
16
- name: id,
17
- created: new Date().toISOString(),
18
- cwd: '/tmp/work',
19
- kind: 'general',
20
- mode: 'base',
21
- lifecycle: 'terminal',
22
- status: 'active',
23
- ...over,
24
- };
25
- }
26
- function hasTmux() {
27
- return spawnSync('tmux', ['-V'], { stdio: 'ignore' }).status === 0;
28
- }
29
- /** A pid that is guaranteed dead: spawn a no-op and wait for it to exit. */
35
+ /** A pid that is guaranteed dead: spawn a no-op and let spawnSync reap it. */
30
36
  function deadPid() {
31
37
  const r = spawnSync('true', [], { stdio: 'ignore' });
32
- // spawnSync has reaped it by the time it returns; pid may be on r.pid.
33
38
  return r.pid ?? 0x7ffffffe; // fall back to an implausibly-high pid
34
39
  }
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
- }
46
40
  before(() => {
47
41
  home = mkdtempSync(join(tmpdir(), 'crtr-daemon-liveness-'));
48
42
  process.env['CRTR_HOME'] = home;
@@ -55,14 +49,6 @@ after(() => {
55
49
  closeDb();
56
50
  rmSync(home, { recursive: true, force: true });
57
51
  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
- }
66
52
  });
67
53
  // ---------------------------------------------------------------------------
68
54
  // livenessVerdict — the pure grace-window decision
@@ -85,219 +71,3 @@ test('isPidAlive: this process is alive; a reaped pid is dead', () => {
85
71
  assert.equal(isPidAlive(process.pid), true, 'self is alive');
86
72
  assert.equal(isPidAlive(deadPid()), false, 'a reaped/implausible pid is dead');
87
73
  });
88
- // ---------------------------------------------------------------------------
89
- // superviseTick + a REAL live tmux window (gated on tmux availability)
90
- // ---------------------------------------------------------------------------
91
- /** Run `fn` with a real, live tmux window held open for its whole duration, then
92
- * tear the session down. Gives superviseTick a genuinely-alive window to judge. */
93
- async function withLiveWindow(tag, fn) {
94
- const session = `crtr-livetest-${process.pid}-${tag}`;
95
- spawnSync('tmux', ['new-session', '-d', '-s', session, '-c', '/tmp', 'sleep 600']);
96
- try {
97
- const r = spawnSync('tmux', ['list-windows', '-t', session, '-F', '#{window_id}'], { encoding: 'utf8' });
98
- const window = (r.stdout ?? '').trim().split('\n')[0];
99
- await fn(session, window);
100
- }
101
- finally {
102
- spawnSync('tmux', ['kill-session', '-t', session], { stdio: 'ignore' });
103
- }
104
- }
105
- /** Like withLiveWindow but also resolves the window's live `%pane_id` — the v3
106
- * pane-anchored handle the daemon now keys liveness on. */
107
- async function withLivePane(tag, fn) {
108
- await withLiveWindow(tag, async (session, window) => {
109
- const r = spawnSync('tmux', ['display-message', '-p', '-t', `${session}:${window}`, '#{pane_id}'], { encoding: 'utf8' });
110
- const pane = (r.stdout ?? '').trim();
111
- await fn(session, window, pane);
112
- });
113
- }
114
- test('window alive + ALIVE pi pid → healthy, untouched', { skip: !hasTmux() }, async () => {
115
- await withLiveWindow('a', async (session, window) => {
116
- // pi_pid = this test process: definitely alive.
117
- createNode(node('A', { tmux_session: session, window, pi_pid: process.pid, pi_session_id: 'booted' }));
118
- await superviseTick();
119
- assert.equal(getNode('A').status, 'active', 'a node with a live pi is left active');
120
- });
121
- });
122
- test('window alive + dead pi pid → pending on first observation (not reaped, not revived)', { skip: !hasTmux() }, async () => {
123
- await withLiveWindow('b', async (session, window) => {
124
- createNode(node('D', {
125
- tmux_session: session,
126
- window,
127
- pi_pid: deadPid(),
128
- pi_session_id: 'booted-once',
129
- intent: null,
130
- status: 'active',
131
- }));
132
- await superviseTick();
133
- // First observation: inside the grace window → pending. The node must NOT be
134
- // reaped to 'dead' (its window is alive) nor revived yet (no new pi spawned).
135
- assert.equal(getNode('D').status, 'active', 'pending node stays active on first tick');
136
- });
137
- });
138
- // ---------------------------------------------------------------------------
139
- // Step 3 (Q6): PANE-existence drives the daemon verdict, not window-existence.
140
- // A manual move-pane/join-pane/break-pane must never read as a death; and a
141
- // pane that is genuinely gone fires the existing gone-branch even when the
142
- // window it used to live in is still alive.
143
- // ---------------------------------------------------------------------------
144
- test('pane alive but window/session cache STALE → LIVE (reconciled, not revived/reaped)', { skip: !hasTmux() }, async () => {
145
- await withLivePane('p1', async (session, window, pane) => {
146
- // The pane is live, the pi is alive (this process) — but the row's window
147
- // cache is bogus (as if a manual move desynced it). Pane-existence must win:
148
- // the node stays active and reconcile FOLLOWS the pane back to its real window.
149
- createNode(node('M', {
150
- pane,
151
- tmux_session: session,
152
- window: '@99999', // stale/bogus — windowAlive() would call this gone
153
- pi_pid: process.pid,
154
- pi_session_id: 'booted',
155
- }));
156
- await superviseTick();
157
- const m = getNode('M');
158
- assert.equal(m.status, 'active', 'a live pane keeps the node active despite a stale window cache');
159
- assert.equal(m.window, window, 'reconcile FOLLOWED the live pane back to its real window');
160
- assert.equal(m.pane, pane, 'the durable pane id is unchanged');
161
- });
162
- });
163
- test('pane GONE while its old window is still alive → the gone-branch fires (crash → dead)', { skip: !hasTmux() }, async () => {
164
- await withLivePane('p2', async (session, window) => {
165
- // Make a guaranteed-DEAD pane id inside the still-live window: split a fresh
166
- // pane, then kill it (the window survives via its original pane).
167
- const sp = spawnSync('tmux', ['split-window', '-d', '-P', '-F', '#{pane_id}', '-t', `${session}:${window}`, 'sleep 600'], { encoding: 'utf8' });
168
- const dead = (sp.stdout ?? '').trim();
169
- spawnSync('tmux', ['kill-pane', '-t', dead], { stdio: 'ignore' });
170
- // The window is alive, but the node is anchored on that dead pane. Under the
171
- // old window-keyed liveness this node would read healthy (window alive + live
172
- // pid). Pane-keyed: the pane is gone → the gone-branch fires. The node is
173
- // MID-GENERATION (busy marker present — agent_start touched it, agent_end
174
- // never cleared it), so the gone-branch routes to crash → 'dead'.
175
- createNode(node('G', {
176
- pane: dead,
177
- tmux_session: session,
178
- window,
179
- pi_pid: disposableLivePid(), // alive zombie — the gone-pane sweep SIGKILLs it
180
- pi_session_id: 'booted',
181
- intent: null,
182
- }));
183
- markBusy('G'); // pane killed inside a turn → genuine mid-run crash
184
- await superviseTick();
185
- assert.equal(getNode('G').status, 'dead', 'a gone pane mid-generation fires the gone-branch → crash (dead)');
186
- clearBusy('G');
187
- });
188
- });
189
- // ---------------------------------------------------------------------------
190
- // Gone-pane routing: a pane-gone node in the crash branch is no longer an
191
- // unconditional 'dead'. It routes on what the node was DOING at pane-kill time:
192
- // • mid-generation (busy marker PRESENT) → crash ('dead')
193
- // • finished its turn, awaiting nothing live → finalize ('done')
194
- // • finished its turn, still awaiting a LIVE child → release ('idle' + idle-release), revivable
195
- // (The mid-generation → dead leg is covered by node 'G' above.)
196
- // ---------------------------------------------------------------------------
197
- test('pane gone + booted + busy ABSENT + no live subscription → finalize (done): the pane was closed to dismiss a finished node', { skip: !hasTmux() }, async () => {
198
- await withLivePane('fin1', async (session, window) => {
199
- const sp = spawnSync('tmux', ['split-window', '-d', '-P', '-F', '#{pane_id}', '-t', `${session}:${window}`, 'sleep 600'], { encoding: 'utf8' });
200
- const dead = (sp.stdout ?? '').trim();
201
- spawnSync('tmux', ['kill-pane', '-t', dead], { stdio: 'ignore' });
202
- // Booted (pi_session_id set), no busy marker (agent_end cleared it → the turn
203
- // finished), and no subscription to any live node. Closing the pane was a
204
- // dismissal of a node that already did its own work → finalize to 'done'.
205
- createNode(node('FIN', {
206
- pane: dead,
207
- tmux_session: session,
208
- window,
209
- pi_pid: disposableLivePid(), // alive zombie — swept before finalize routing
210
- pi_session_id: 'booted',
211
- intent: null,
212
- }));
213
- // no markBusy: the turn ended cleanly.
214
- await superviseTick();
215
- assert.equal(getNode('FIN').status, 'done', 'a finished, unsubscribed node whose pane was closed finalizes to done');
216
- });
217
- });
218
- test('pane gone + booted + busy ABSENT but AWAITING a LIVE child → idle-release (revivable, NOT dead)', { skip: !hasTmux() }, async () => {
219
- await withLivePane('fin2', async (session, window) => {
220
- const sp = spawnSync('tmux', ['split-window', '-d', '-P', '-F', '#{pane_id}', '-t', `${session}:${window}`, 'sleep 600'], { encoding: 'utf8' });
221
- const dead = (sp.stdout ?? '').trim();
222
- spawnSync('tmux', ['kill-pane', '-t', dead], { stdio: 'ignore' });
223
- // A live child the parent is subscribed to: hasActiveLiveSubscription === true.
224
- createNode(node('CHILD', { status: 'active', pi_session_id: 'booted' }));
225
- createNode(node('PARENT', {
226
- pane: dead,
227
- tmux_session: session,
228
- window,
229
- pi_pid: deadPid(), // the user closed the pane → pi is dead
230
- pi_session_id: 'booted',
231
- intent: null,
232
- }));
233
- subscribe('PARENT', 'CHILD', true); // active subscription to a LIVE node
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.
239
- await superviseTick();
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');
244
- });
245
- });
246
- // ---------------------------------------------------------------------------
247
- // Step 7 (§5.3, F3): a focused-dormant node frozen via remain-on-exit is
248
- // PANE-alive but pi-DEAD with intent='idle-release'. The daemon must NOT
249
- // grace-revive it on liveness (handleLiveWindow early-returns for idle-release);
250
- // it revives ONLY when an unseen inbox entry arrives (second pass, gated on pi
251
- // liveness — NOT pane existence, which would skip a frozen pane forever).
252
- // ---------------------------------------------------------------------------
253
- test('idle-release + live (frozen) pane + DEAD pi → handleLiveWindow does NOT grace-revive it', { skip: !hasTmux() }, async () => {
254
- await withLivePane('idle1', async (session, window, pane) => {
255
- createNode(node('F', {
256
- pane,
257
- tmux_session: session,
258
- window,
259
- pi_pid: deadPid(), // pi is DEAD; the pane is frozen (remain-on-exit stand-in)
260
- pi_session_id: 'booted',
261
- intent: 'idle-release',
262
- status: 'idle',
263
- home_session: session,
264
- }));
265
- // Two ticks: the SECOND is far past REVIVE_GRACE_MS (20s). Without the
266
- // idle-release early-return in handleLiveWindow, tick 1 would mark the dead
267
- // pi 'pending' and tick 2 (now past grace) would grace-revive it, bumping
268
- // cycles. The early-return means it is never even marked pending. No inbox
269
- // entry, so the second pass never revives it either.
270
- await superviseTick(1_000_000);
271
- await superviseTick(1_000_000 + 60_000);
272
- const m = getNode('F');
273
- assert.equal(m.status, 'idle', 'still idle — the frozen focused-dormant node is left alone');
274
- assert.equal(m.cycles ?? 0, 0, 'cycles NOT bumped → reviveNode never fired');
275
- assert.equal(m.pane, pane, 'pane unchanged');
276
- assert.equal(m.window, window, 'window unchanged');
277
- // Non-vacuous: drop the `intent==='idle-release'` early-return and tick 2
278
- // (deadFor 60s > 20s grace) revives → cycles===1, status flips → both fail.
279
- });
280
- });
281
- test('idle-release + live (frozen) pane + DEAD pi + UNSEEN inbox → REVIVED on the second pass', { skip: !hasTmux() }, async () => {
282
- await withLivePane('idle2', async (session, window, pane) => {
283
- createNode(node('W', {
284
- pane,
285
- tmux_session: session,
286
- window,
287
- pi_pid: deadPid(),
288
- pi_session_id: 'booted',
289
- intent: 'idle-release',
290
- status: 'idle',
291
- home_session: session, // the revive window lands here (torn down with the session)
292
- }));
293
- appendInbox('W', { from: 'child', tier: 'normal', kind: 'update', label: 'work for you' });
294
- await superviseTick();
295
- // The OLD second-pass guard `if (isNodePaneAlive(r)) continue;` would SKIP a
296
- // pane-alive node forever; the new `if (r.pi_pid != null && isPidAlive(...))`
297
- // gate sees the DEAD pi and revives. reviveNode bumps cycles BEFORE placing
298
- // the window, so the bump is the observable even though the launch is a real
299
- // (short-lived, torn-down) window. Non-vacuous: the old pane-existence gate
300
- // leaves cycles unbumped.
301
- assert.equal(getNode('W').cycles, 1, 'reviveNode fired on the inbox push despite a live (frozen) pane');
302
- });
303
- });