@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,21 +1,44 @@
1
1
  // Run with: node --import tsx/esm --test src/core/__tests__/revive.test.ts
2
2
  //
3
- // Covers the session-picker fix: a revive must resume by ABSOLUTE session-file
4
- // path (cwd-immune) when one was captured, falling back to the bare uuid for
5
- // older nodes and the double-revive guard that keeps two pi processes off one
6
- // session file. The argv selection is unit-tested via the pure `resumeArgs` +
7
- // `buildPiArgv`; the guard is exercised against a REAL tmux window (gated on
8
- // tmux availability, like daemon-liveness.test.ts).
3
+ // HEADLESS RETARGET (foundation-spec §C.13 + §E). Covers the session-picker fix
4
+ // (resume by ABSOLUTE session-file path, cwd-immune, falling back to the bare
5
+ // uuid for older nodes) AND the double-revive guard that keeps two pi processes
6
+ // off one session file. The argv selection is unit-tested via the pure
7
+ // `resumeArgs` + `buildPiArgv`; the guard + the cycle-counter bump are now driven
8
+ // against a fabricated BROKER-hosted node — ZERO real tmux, ZERO real boot.
9
+ //
10
+ // (1) BUG LOCKED — the double-revive guard (revive.ts:114): a node already up
11
+ // (its engine container alive AND its pi pid live) must NOT be re-launched —
12
+ // a second pi on the same .jsonl corrupts the conversation. The complement:
13
+ // a revive that DOES proceed bumps the node's `cycles` BEFORE it spawns
14
+ // (revive.ts cycles++, then transition('revive'), then launch).
15
+ //
16
+ // (2) WHY MODEL-LEVEL, NOT TMUX CHROME — the guard's `isAlive` is host-keyed: a
17
+ // BROKER's container liveness IS `isPidAlive(pi_pid)` (host.ts headlessBrokerHost),
18
+ // so a fabricated broker row carrying a LIVE pid reproduces the exact "already
19
+ // up" state the guard short-circuits on — no pane, no window, no tmux server.
20
+ // The old tmux assertion ("no NEW window opened") was a tmux artifact; the
21
+ // real invariant is the model effect: `cycles` UNCHANGED on the guard path,
22
+ // and bumped to 1 when the revive proceeds (revive.ts bumps cycles
23
+ // synchronously before the detached launch — observable INSTANTLY, no
24
+ // awaitBoot). The taint-ignoring WINDOW placement decision is a genuine tmux
25
+ // concern and stays in full/placement-revive.test.ts.
26
+ //
27
+ // (3) HOW THE DRIVE STILL FAILS IF THE BUG REGRESSES — remove the guard's
28
+ // short-circuit and the live-pid broker revive bumps cycles (1, not 0) → the
29
+ // guard assert goes RED; break the cycles++ and the proceed test (expecting 1)
30
+ // goes RED.
9
31
  import { test, before, after, beforeEach } from 'node:test';
10
32
  import assert from 'node:assert/strict';
11
33
  import { mkdtempSync, rmSync } from 'node:fs';
12
34
  import { tmpdir } from 'node:os';
13
35
  import { join } from 'node:path';
14
- import { spawnSync } from 'node:child_process';
15
- import { createNode, getNode } from '../canvas/canvas.js';
36
+ import { spawn, spawnSync } from 'node:child_process';
37
+ import { createNode } from '../canvas/canvas.js';
16
38
  import { closeDb } from '../canvas/db.js';
17
39
  import { buildPiArgv } from '../runtime/launch.js';
18
40
  import { resumeArgs, reviveNode } from '../runtime/revive.js';
41
+ import { createHarness } from './helpers/harness.js';
19
42
  let home;
20
43
  function node(id, over = {}) {
21
44
  return {
@@ -30,8 +53,20 @@ function node(id, over = {}) {
30
53
  ...over,
31
54
  };
32
55
  }
33
- function hasTmux() {
34
- return spawnSync('tmux', ['-V'], { stdio: 'ignore' }).status === 0;
56
+ /** A pid that is guaranteed dead — a crashed broker the guard must NOT see as up. */
57
+ function deadPid() {
58
+ const r = spawnSync('true', [], { stdio: 'ignore' });
59
+ return r.pid ?? 0x7ffffffe;
60
+ }
61
+ /** A pid ALIVE for the test but EXPENDABLE — NEVER process.pid: the harness's
62
+ * dispose() SIGKILLs every recorded pi_pid, which would take the test runner
63
+ * down. A detached `sleep` is the live-engine stand-in; dispose reaps it. */
64
+ const livePids = [];
65
+ function disposableLivePid() {
66
+ const c = spawn('sleep', ['600'], { stdio: 'ignore', detached: true });
67
+ c.unref();
68
+ livePids.push(c.pid);
69
+ return c.pid;
35
70
  }
36
71
  before(() => {
37
72
  home = mkdtempSync(join(tmpdir(), 'crtr-revive-'));
@@ -45,6 +80,14 @@ after(() => {
45
80
  closeDb();
46
81
  rmSync(home, { recursive: true, force: true });
47
82
  delete process.env['CRTR_HOME'];
83
+ for (const pid of livePids) {
84
+ try {
85
+ process.kill(pid, 'SIGKILL');
86
+ }
87
+ catch {
88
+ /* already reaped by dispose() — fine */
89
+ }
90
+ }
48
91
  });
49
92
  // ---------------------------------------------------------------------------
50
93
  // resumeArgs — the pure resume-source selection
@@ -131,87 +174,54 @@ test('a resumed revive replays the persisted LaunchSpec (model, tools, extension
131
174
  assert.equal(env['CRTR_MODE'], 'orchestrator');
132
175
  });
133
176
  // ---------------------------------------------------------------------------
134
- // Double-revive guard — reviveNode no-ops when the window is already alive
135
- // (gated on tmux, which CI may not have; the guard needs a genuinely-live window
136
- // to short-circuit before any tmux mutation).
177
+ // Double-revive guard — reviveNode no-ops when the node's engine is already up
178
+ // (container alive AND pi pid live). Fabricated as a BROKER node, whose engine-
179
+ // container liveness IS its pi-pid liveness (host.ts), so the guard is exercised
180
+ // with NO tmux and NO real boot. A live pid → guard short-circuits (cycles flat);
181
+ // a dead pid → revive proceeds (cycles bumped before the detached launch).
137
182
  // ---------------------------------------------------------------------------
138
- /** Hold a real, live tmux window open for the duration of `fn`, then tear down. */
139
- async function withLiveWindow(tag, fn) {
140
- const session = `crtr-revivetest-${process.pid}-${tag}`;
141
- spawnSync('tmux', ['new-session', '-d', '-s', session, '-c', '/tmp', 'sleep 600']);
183
+ test('reviveNode no-ops when the broker engine is already up (live pid) double-revive guard', async () => {
184
+ const h = await createHarness({ headless: true, sessionPrefix: 'crtr-revive-guard' });
142
185
  try {
143
- const r = spawnSync('tmux', ['list-windows', '-t', session, '-F', '#{window_id}'], { encoding: 'utf8' });
144
- const window = (r.stdout ?? '').trim().split('\n')[0];
145
- await fn(session, window);
186
+ // pi_pid = this process: a genuinely LIVE engine. The guard keys on the host's
187
+ // isAlive (a broker's IS isPidAlive(pi_pid)) AND pi-alive, so this models
188
+ // "another path already revived it" — re-launching would double-spawn onto the
189
+ // same .jsonl. reviveNode only READS isPidAlive here (never signals), so
190
+ // process.pid is NOT safe (dispose SIGKILLs every recorded pi_pid), so use a
191
+ // disposable live sleep as the alive-engine stand-in.
192
+ const M = h.fabricateBrokerNode({
193
+ status: 'active',
194
+ intent: null,
195
+ pi_pid: disposableLivePid(),
196
+ pi_session_id: 'uuid-1',
197
+ });
198
+ const before = h.node(M).cycles ?? 0;
199
+ const res = reviveNode(M, { resume: true });
200
+ assert.equal(res.resumed, false, 'guard path does not re-resume');
201
+ assert.equal(h.node(M).cycles ?? 0, before, 'cycle counter NOT bumped (guard returned early)');
146
202
  }
147
203
  finally {
148
- spawnSync('tmux', ['kill-session', '-t', session], { stdio: 'ignore' });
204
+ await h.dispose();
149
205
  }
150
- }
151
- function windowCount(session) {
152
- const r = spawnSync('tmux', ['list-windows', '-t', session, '-F', '#{window_id}'], { encoding: 'utf8' });
153
- return (r.stdout ?? '').trim().split('\n').filter((s) => s !== '').length;
154
- }
155
- test('reviveNode no-ops when the node pane is alive AND its pi is LIVE (double-revive guard)', { skip: !hasTmux() }, async () => {
156
- await withLiveWindow('guard', async (session, window) => {
157
- // pi_pid = this process: a genuinely LIVE pi. The guard now keys on pane-
158
- // alive AND pi-alive (Step 7), so this models "another path already revived
159
- // it" — a no-op (re-launching would double-spawn onto the same session file).
160
- createNode(node('M', {
161
- tmux_session: session,
162
- window,
163
- cycles: 3,
164
- pi_pid: process.pid,
165
- pi_session_id: 'uuid-1',
166
- pi_session_file: '/abs/m.jsonl',
167
- }));
168
- const before = windowCount(session);
169
- const res = reviveNode('M', { resume: true });
170
- assert.equal(res.window, window, 'returns the existing live window');
171
- assert.equal(res.session, session, 'returns the existing session');
172
- assert.equal(res.resumed, false, 'guard path does not re-resume');
173
- assert.equal(windowCount(session), before, 'no new window opened');
174
- assert.equal(getNode('M')?.cycles, 3, 'cycle counter NOT bumped (guard returned early)');
175
- });
176
206
  });
177
- test('reviveNode PROCEEDS when the pane is alive but the pi is DEAD (F3 frozen-pane resume)', { skip: !hasTmux() }, async () => {
178
- // The Step-7 guard fix: a FROZEN focus pane (remain-on-exit) is pane-alive but
179
- // pi-DEAD — the resume-into-focus case. The OLD pane-only guard would no-op
180
- // here (the bug that left a frozen focused-dormant node stuck); the new guard
181
- // gates on pi liveness too, so reviveNode proceeds (bumps the cycle counter).
182
- await withLiveWindow('frozen', async (session, window) => {
183
- createNode(node('M', {
184
- tmux_session: session,
185
- window,
186
- cycles: 3,
187
- pi_pid: 0x7ffffffe, // implausible/dead pid → a frozen pane with no live pi
207
+ test('reviveNode PROCEEDS when the broker engine is DOWN (dead pid) cycle counter bumps 0→1', async () => {
208
+ const h = await createHarness({ headless: true, sessionPrefix: 'crtr-revive-proceed' });
209
+ try {
210
+ // A crashed broker: container DOWN (dead pid) the guard does NOT short-
211
+ // circuit reviveNode proceeds. It bumps cycles BEFORE the detached (fake-
212
+ // engine) launch, so the bump is observable instantly; the throwaway broker is
213
+ // killed by dispose().
214
+ const M = h.fabricateBrokerNode({
215
+ status: 'active',
216
+ intent: null,
217
+ pi_pid: deadPid(),
188
218
  pi_session_id: 'uuid-1',
189
- pi_session_file: '/abs/m.jsonl',
190
- }));
191
- reviveNode('M', { resume: true });
192
- assert.equal(getNode('M')?.cycles, 4, 'cycle counter BUMPED the guard did NOT short-circuit a frozen pane');
193
- });
194
- });
195
- // ---------------------------------------------------------------------------
196
- // Step 5 (§5.3): reviveNode DELEGATES placement to reviveIntoPlacement — a
197
- // non-focused node targets its home_session, NEVER its (focus-tainted)
198
- // tmux_session. This is the reviveNode-level bug-kill proof. Gated to run when
199
- // tmux is ABSENT, so openNodeWindow no-ops (returns null) and no real pi is
200
- // launched — the placement DECISION (session + LOCATION) is set synchronously
201
- // regardless of whether a window actually opens, so the assertions are exact.
202
- // (The WITH-tmux window-placement behaviour is proven in placement-revive.test.ts.)
203
- // ---------------------------------------------------------------------------
204
- test('reviveNode delegates to home_session for a non-focused node, IGNORING the tainted tmux_session (§5.3)', { skip: hasTmux() }, async () => {
205
- const back = `crtr-back-${process.pid}`;
206
- const tainted = `crtr-user-${process.pid}`; // the focus taint that must be ignored
207
- // A non-focused child: home_session is the backstage; tmux_session was tainted
208
- // to a user session by a prior focus and never corrected. No focus row exists.
209
- createNode(node('M', { home_session: back, tmux_session: tainted, window: '@7', pane: null }));
210
- const res = reviveNode('M', { resume: false });
211
- assert.equal(res.session, back, 'revive targets home_session, not the tainted user session');
212
- assert.notEqual(res.session, tainted, 'NEVER the tainted tmux_session');
213
- const m = getNode('M');
214
- assert.equal(m.tmux_session, back, 'LOCATION repointed to the backstage (taint overwritten)');
215
- assert.equal(m.window, null, 'no tmux present → openNodeWindow no-op, window null (decision still recorded)');
216
- assert.equal(m.cycles, 1, 'a real (non-guard) revive bumped the cycle counter');
219
+ });
220
+ assert.equal(h.node(M).cycles ?? 0, 0, 'fabricated at cycle 0 (no revive yet)');
221
+ reviveNode(M, { resume: true });
222
+ assert.equal(h.node(M).cycles, 1, 'a real (non-guard) revive bumped the cycle counter to 1');
223
+ }
224
+ finally {
225
+ await h.dispose();
226
+ }
217
227
  });
@@ -1,18 +1,38 @@
1
1
  // Run with: node --import tsx/esm --test src/core/__tests__/subscription-delivery.test.ts
2
2
  //
3
3
  // MULTI-LEVEL SUBSCRIPTION DELIVERY — the DELIVERY-vs-WAKE distinction across a
4
- // graph >=2 levels deep, driven through the FAITHFUL integration harness (real
5
- // CLI, real isolated tmux, real extension hooks in the fake-pi vehicle, real
6
- // daemon decision pass via superviseTick). Every assertion reads the canvas
4
+ // graph >=2 levels deep. The whole split is pure canvas/feed/daemon model logic,
5
+ // so this is driven by FABRICATING the topology + dormant states directly
6
+ // (foundation-spec §E.2) and exercising the real model layer (feed.push,
7
+ // evaluateStop, superviseTick) — NO real tmux session, NO pane chrome, and NO
8
+ // real broker boot (which would cost ~5s each). Every assertion reads the canvas
7
9
  // data layer and is checked against the state-model ORACLE (§3b, §5, §6).
8
10
  //
9
- // This is the multi-level companion to the UNIT-level passive-subscription.test.ts
10
- // (which pins push→inbox vs push→passive.jsonl routing in-process). What that test
11
- // CANNOT show and what is added here is the runtime WAKE consequence of the
12
- // split when BOTH an active and a passive subscriber sit on the SAME target and
13
- // BOTH are DORMANT (terminal idle-released): the active one is daemon-REVIVED on
14
- // its unseen inbox entry; the passive one is NOT (its pointer lands in the passive
15
- // accumulator the daemon never reads), so it stays idle.
11
+ // HEADLESS / FABRICATED RETARGET (foundation-spec §C.15 + §E). All nodes are
12
+ // broker-hosted (paneless). Dormancy, which a tmux node shows as a closed pane,
13
+ // a broker shows as an EXITED process (status=idle, intent=idle-release, pi_pid
14
+ // dead) fabricated directly. A daemon revive is observed via getNode().cycles
15
+ // (reviveNode bumps cycles + transition('revive') BEFORE it spawns revive.ts:
16
+ // 114,145 so the model effect is instant, no awaitBoot), never a real boot.
17
+ //
18
+ // (1) BUG LOCKED — the DELIVERY-vs-WAKE split (Invariant D + stop-guard): an
19
+ // ACTIVE subscriber's pointer lands in inbox.jsonl and the daemon's 2nd pass
20
+ // REVIVES it on that unseen entry; a PASSIVE subscriber's pointer lands in
21
+ // passive.jsonl and NEVER wakes; a PASSIVE-only tie does NOT legitimize an
22
+ // idle-release (hasActiveLiveSubscription filters active=1).
23
+ // (2) WHY MODEL-LEVEL, NOT TMUX CHROME — routing (feed.ts), the active/passive
24
+ // edge filter (canvas.ts), the 2nd-pass inbox gate (crtrd.ts), and one-hop
25
+ // fan-out are all pure data-layer; the only thing a pane ever provided was a
26
+ // visible "dormant" signal, which a broker expresses as a dead pid — and
27
+ // which fabrication sets directly.
28
+ // (3) HOW THE FABRICATED DRIVE STILL FAILS IF THE BUG REGRESSES — after T's
29
+ // pushFinal, one h.tick() must revive the ACTIVE subscriber B (cycles bumps,
30
+ // status→active) while the PASSIVE subscriber P stays idle with cycles==0. If
31
+ // inbox-vs-passive routing breaks, either B's entry never reaches its inbox
32
+ // (the 2nd-pass gate finds nothing → B not revived → cycles stays 0) or P
33
+ // gets an inbox entry and IS woken (its cycles/status asserts go RED).
34
+ // Verified by routing passive deliveries into the inbox (see bug-injection
35
+ // report).
16
36
  //
17
37
  // THE ORACLE CONTRACT under test:
18
38
  // • feed.push fans to subscribersOf(target): active → inbox.jsonl (wakes),
@@ -30,24 +50,27 @@
30
50
  // ├── T (terminal) B→T active seed ── THE TARGET (level 2)
31
51
  // ├── K (terminal) B→K active seed ── keepalive (stays active)
32
52
  // └── P (terminal) B→P active seed ── PASSIVE subscriber of T
33
- // P→T passive (wired via `crtr node subscribe --passive`)
34
- // P→K active (wired — the ONLY tie that legitimizes P's release;
53
+ // P→T passive (wired via subscribe(P,T,false))
54
+ // P→K active (the ONLY tie that legitimizes P's release;
35
55
  // P→T passive is excluded from the stop-guard)
36
56
  import { test } from 'node:test';
37
57
  import assert from 'node:assert/strict';
38
58
  import { spawnSync } from 'node:child_process';
39
- import { createHarness, hasTmux } from './helpers/harness.js';
59
+ import { createHarness } from './helpers/harness.js';
40
60
  import { closeDb } from '../canvas/db.js';
61
+ import { subscribe } from '../canvas/canvas.js';
62
+ import { pushFinal, pushUpdate } from '../feed/feed.js';
41
63
  import { readPassive } from '../feed/passive.js';
42
- import { STALL_REPROMPT } from '../runtime/stop-guard.js';
43
- function sessionExists(session) {
44
- return spawnSync('tmux', ['has-session', '-t', session], { stdio: 'ignore' }).status === 0;
64
+ import { evaluateStop, STALL_REPROMPT } from '../runtime/stop-guard.js';
65
+ // A pid reaped (dead) by the time spawnSync returns — a released broker's exited
66
+ // process (the headless analog of a closed pane).
67
+ function deadPid() {
68
+ const r = spawnSync('true', [], { stdio: 'ignore' });
69
+ return r.pid ?? 0x7ffffffe;
45
70
  }
46
- // LOCAL helper (candidate for harness consolidation see report): read a node's
47
- // PASSIVE accumulator (passive.jsonl) straight off the data layer, mirroring the
48
- // harness's own `inbox()` reader. closeDb() keeps it consistent with the harness's
49
- // cross-process WAL discipline (the push that wrote passive.jsonl ran in a `cli`
50
- // subprocess). The harness exposes inbox() but NOT passive() — this fills that gap.
71
+ // LOCAL helper: read a node's PASSIVE accumulator (passive.jsonl) straight off
72
+ // the data layer, mirroring the harness's own `inbox()` reader. The harness
73
+ // exposes inbox() but NOT passive() this fills that gap.
51
74
  function passive(nodeId) {
52
75
  closeDb();
53
76
  return readPassive(nodeId);
@@ -56,113 +79,77 @@ function passive(nodeId) {
56
79
  function edgeSet(arr) {
57
80
  return new Set(arr.map((e) => `${e.node_id}:${e.active ? 'active' : 'passive'}`));
58
81
  }
59
- test('multi-level subscription delivery: active subscriber WOKEN vs passive subscriber DELIVERED-not-woken, on the same dormant target', { skip: !hasTmux() ? 'tmux unavailable' : false, timeout: 180_000 }, async () => {
60
- const h = await createHarness({ sessionPrefix: 'crtr-subdeliv' });
82
+ test('multi-level subscription delivery: active subscriber WOKEN vs passive subscriber DELIVERED-not-woken, on the same dormant target', { timeout: 30_000 }, async () => {
83
+ const h = await createHarness({ headless: true, sessionPrefix: 'crtr-subdeliv' });
61
84
  try {
62
85
  // ===================================================================
63
- // S1 — Build the graph. A (resident root, virtual) B {T,K,P}.
64
- // Each spawn seeds an ACTIVE parent→child subscription (the spine).
65
- // ===================================================================
66
- const A = h.spawnRoot('front door');
67
- const B = await h.spawnChild(A, 'mid manager', { kind: 'developer' });
68
- const T = await h.spawnChild(B, 'the target worker');
69
- const K = await h.spawnChild(B, 'a keepalive worker');
70
- const P = await h.spawnChild(B, 'the passive observer');
71
- // Spine seeds: A→B, B→T, B→K, B→P all ACTIVE. T's sole subscriber so far is B.
72
- assert.deepEqual(edgeSet(h.subscribers(T)), new Set([`${B}:active`]), "T's only subscriber at birth is B (active spawn seed)");
73
- assert.deepEqual(edgeSet(h.subscribers(B)), new Set([`${A}:active`]), 'A→B active seed');
74
- // ===================================================================
75
- // S2 Wire the PASSIVE subscriber. P passively subscribes to T (the axis
76
- // under test), and ACTIVELY subscribes to K (the only tie that will
77
- // legitimize P's own idle-release — its passive tie to T does NOT count).
78
- // ===================================================================
79
- {
80
- const passiveRes = h.cli(P, ['node', 'subscribe', T, '--passive']);
81
- assert.equal(passiveRes.code, 0, `PT passive subscribe exit 0\n${passiveRes.stderr}`);
82
- assert.match(passiveRes.stdout, /\(passive\)/, 'P→T wired PASSIVE');
83
- const activeRes = h.cli(P, ['node', 'subscribe', K]);
84
- assert.equal(activeRes.code, 0, `P→K active subscribe exit 0\n${activeRes.stderr}`);
85
- assert.match(activeRes.stdout, /\(active\)/, 'P→K wired ACTIVE');
86
- }
87
- // T now has BOTH an active (B) and a passive (P) subscriber — the crux.
86
+ // S1 — Build the graph by FABRICATION (no boots). A is a resident root
87
+ // (inline, daemon-untouched); B/T/K/P/X are broker nodes whose
88
+ // liveness fields are set directly. Each parent→child spine edge is
89
+ // wired ACTIVE.
90
+ // ===================================================================
91
+ const A = h.spawnRoot('front door'); // resident root, active, no placement
92
+ // T (the target) and K (keepalive) are LIVE-active workers; B and P start
93
+ // already DORMANT (idle-released, broker exited = dead pid) — exactly the
94
+ // post-stop state the delivery split is judged against.
95
+ const T = h.fabricateBrokerNode({ status: 'active', pi_pid: deadPid(), pi_session_id: 'sess-T' });
96
+ const K = h.fabricateBrokerNode({ status: 'active', pi_pid: deadPid(), pi_session_id: 'sess-K' });
97
+ const B = h.fabricateBrokerNode({ status: 'idle', intent: 'idle-release', pi_pid: deadPid(), pi_session_id: 'sess-B' });
98
+ const P = h.fabricateBrokerNode({ status: 'idle', intent: 'idle-release', pi_pid: deadPid(), pi_session_id: 'sess-P' });
99
+ // Spine seeds: A→B, B→T, B→K, B→P all ACTIVE (subscriber, publisher).
100
+ subscribe(A, B, true);
101
+ subscribe(B, T, true);
102
+ subscribe(B, K, true);
103
+ subscribe(B, P, true);
104
+ // PASSIVE axis under test: P passively subscribes to T; P actively to K
105
+ // (the only tie that legitimizes P's own idle-release).
106
+ subscribe(P, T, false);
107
+ subscribe(P, K, true);
88
108
  assert.deepEqual(edgeSet(h.subscribers(T)), new Set([`${B}:active`, `${P}:passive`]), 'T has B(active) + P(passive) as subscribers — the same-target split');
109
+ assert.deepEqual(edgeSet(h.subscribers(B)), new Set([`${A}:active`]), 'A→B active seed');
89
110
  assert.deepEqual(edgeSet(h.subscriptions(P)), new Set([`${T}:passive`, `${K}:active`]), 'P subscribes_to T(passive) + K(active)');
90
111
  // ===================================================================
91
- // S2b — PASSIVE-EXCLUSION, demonstrated directly (the stop-guard half of
92
- // the active/passive split). X subscribes PASSIVE-ONLY to the very
93
- // same live K that P subscribes to ACTIVELY. On stop, X holds NO
94
- // active live subscription hasActiveLiveSubscription filters
95
- // active=1 (canvas.ts), so the passive K-tie is invisible to it →
96
- // the stop-guard returns 'stalled', NOT 'awaiting': X is re-prompted
97
- // (STALL_REPROMPT) and stays ACTIVE; it does NOT idle-release. Same
98
- // publisher K, opposite outcome purely by edge mode — ACTIVE holds a
99
- // node alive to await, PASSIVE does not (oracle §3b, §5). This is the
100
- // discriminating negative the rest of the test relies on.
112
+ // S2 — PASSIVE-EXCLUSION via the PURE stop-guard (the active/passive split
113
+ // half). evaluateStop is pure given the canvas; X holds a PASSIVE-only
114
+ // tie to the same live K that P holds ACTIVELY. hasActiveLiveSubscription
115
+ // filters active=1 (canvas.ts), so X's passive K-tie is invisible →
116
+ // 'stalled' (STALL_REPROMPT); P's active K-tie 'awaiting'. Same
117
+ // publisher K, opposite outcome purely by edge mode (oracle §3b, §5).
101
118
  // ===================================================================
102
- const X = await h.spawnChild(B, 'a passive-only observer');
103
- {
104
- const res = h.cli(X, ['node', 'subscribe', K, '--passive']);
105
- assert.equal(res.code, 0, `X→K passive subscribe exit 0\n${res.stderr}`);
106
- assert.match(res.stdout, /\(passive\)/, 'X→K wired PASSIVE-only (its ONLY subscription)');
107
- }
119
+ const X = h.fabricateBrokerNode({ status: 'active' });
120
+ subscribe(X, K, false); // X→K PASSIVE-only — its ONLY subscription
108
121
  assert.deepEqual(edgeSet(h.subscriptions(X)), new Set([`${K}:passive`]), 'X holds exactly one tie: K(passive) — no active live subscription');
109
- const injBeforeStall = h.injected(X).length;
110
- await h.stop(X);
111
- // The PASSIVE tie does NOT legitimize a release → 'stalled' → re-prompt.
112
- await h.waitFor(() => h.injected(X).slice(injBeforeStall).find((e) => e.content.includes(STALL_REPROMPT)), { timeoutMs: 15_000, label: 'X (passive-only) → STALL_REPROMPT, not awaiting' });
113
- assert.equal(h.status(X), 'active', 'X stays ACTIVE — a PASSIVE-only tie does NOT hold it as awaiting');
114
- assert.equal(h.node(X).intent ?? null, null, 'X intent untouched — it did NOT idle-release');
115
- // ===================================================================
116
- // S3 — P goes DORMANT. P stops while holding an ACTIVE live sub to K →
117
- // stop-guard 'awaiting' → idle-release. (Its PASSIVE sub to T is
118
- // EXCLUDED from hasActiveLiveSubscription — proven directly by S2b:
119
- // a passive-only tie stalls; only the ACTIVE K-tie releases P here.)
120
- // ===================================================================
121
- await h.stop(P);
122
- await h.waitForStatus(P, 'idle');
123
- {
124
- const p = h.node(P);
125
- assert.equal(p.status, 'idle', 'P idle (released on the strength of its ACTIVE K-sub)');
126
- assert.equal(p.intent, 'idle-release', 'P intent=idle-release');
127
- }
128
- await h.waitForPaneGone(P);
129
- assert.equal(h.paneAlive(P), false, 'P unfocused → pane closed on idle-release (dormant)');
130
- // ===================================================================
131
- // S4 — B (the ACTIVE subscriber of T) goes DORMANT too. B awaits T,K
132
- // (active) and P (now idle) → 'awaiting' → idle-release, pane closes.
133
- // Now BOTH subscribers of T are dormant — one active, one passive.
134
- // ===================================================================
135
- await h.stop(B);
136
- await h.waitForStatus(B, 'idle');
137
122
  {
138
- const b = h.node(B);
139
- assert.equal(b.status, 'idle', 'B idle (released, awaiting its live children)');
140
- assert.equal(b.intent, 'idle-release', 'B intent=idle-release');
123
+ const xStop = evaluateStop(X, { pushedFinal: false, askedHuman: false });
124
+ assert.equal(xStop.action, 'reprompt', 'X (passive-only) reprompt, NOT awaiting');
125
+ assert.equal(xStop.reason, 'stalled', 'X classified stalled — passive tie does not hold it');
126
+ assert.equal(xStop.message, STALL_REPROMPT, 'X gets the stall re-prompt');
127
+ const pStop = evaluateStop(P, { pushedFinal: false, askedHuman: false });
128
+ assert.equal(pStop.action, 'allow', 'P (active K-tie) → allow');
129
+ assert.equal(pStop.reason, 'awaiting', 'P classified awaiting on its ACTIVE K-sub — the discriminating positive');
141
130
  }
142
- await h.waitForPaneGone(B);
143
- assert.equal(h.paneAlive(B), false, 'B unfocused pane closed on idle-release');
144
- // Pre-push baseline: the keepalive is up, the grandparent is untouched.
145
- assert.equal(h.status(K), 'active', 'K still active (keepalive holds P legitimately dormant)');
131
+ // Pre-push baseline: keepalive up, grandparent untouched, nothing delivered.
132
+ assert.equal(h.status(K), 'active', 'K active (keepalive holds P legitimately dormant)');
146
133
  assert.equal(h.status(A), 'active', 'A (resident root) untouched');
147
134
  assert.equal(h.inbox(A).length, 0, 'A inbox empty before the push');
148
- assert.equal(h.inbox(B).length, 0, 'B inbox empty before the push (nothing delivered yet)');
135
+ assert.equal(h.inbox(B).length, 0, 'B inbox empty before the push');
149
136
  assert.equal(passive(P).length, 0, 'P passive accumulator empty before the push');
137
+ assert.equal(h.node(B).cycles ?? 0, 0, 'B not yet revived');
138
+ assert.equal(h.node(P).cycles ?? 0, 0, 'P not yet revived');
150
139
  // ===================================================================
151
- // S5 — T FINISHES (push final). feed.push fans the pointer to
152
- // subscribersOf(T) = {B(active), P(passive)} ONLY, by the delivery
153
- // split: B's lands in inbox.jsonl, P's in passive.jsonl. A (the
154
- // grandparent, NOT a subscriber of T) gets NOTHING — one-hop fan-out.
140
+ // S3 — T FINISHES via the model layer (pushFinal). feed.push fans the
141
+ // pointer to subscribersOf(T) = {B(active), P(passive)} ONLY, by the
142
+ // delivery split: B's lands in inbox.jsonl, P's in passive.jsonl. A
143
+ // (the grandparent, NOT a subscriber of T) gets NOTHING — one-hop.
155
144
  // ===================================================================
156
145
  const TARGET_FINAL = 'TARGET-FINAL-BODY: the worker completed';
157
- await h.finish(T, TARGET_FINAL);
146
+ await pushFinal(T, TARGET_FINAL);
147
+ closeDb();
158
148
  {
159
- const t = h.node(T);
160
- assert.equal(t.status, 'done', 'T done after push final');
161
- assert.equal(t.intent, 'done', 'T intent=done');
162
- assert.equal(h.paneAlive(T), false, 'T pane closed on done');
149
+ assert.equal(h.status(T), 'done', 'T done after pushFinal (transition finalize)');
150
+ assert.equal(h.node(T).intent, 'done', 'T intent=done');
163
151
  // ACTIVE delivery → B's INBOX (the wake channel).
164
- const bInbox = h.inbox(B);
165
- const bFinal = bInbox.find((e) => e.from === T && e.kind === 'final');
152
+ const bFinal = h.inbox(B).find((e) => e.from === T && e.kind === 'final');
166
153
  assert.ok(bFinal, 'ACTIVE subscriber B: T-final pointer DELIVERED to inbox.jsonl');
167
154
  // PASSIVE delivery → P's ACCUMULATOR, NOT its inbox (no wake channel).
168
155
  const pPassive = passive(P);
@@ -175,46 +162,39 @@ test('multi-level subscription delivery: active subscriber WOKEN vs passive subs
175
162
  assert.equal(h.inbox(K).length, 0, 'K (sibling, not a subscriber of T) NOT delivered');
176
163
  }
177
164
  // ===================================================================
178
- // S6 — The DAEMON decision pass. Its 2nd pass revives an idle-released
179
- // node ONLY on an unseen INBOX entry. B has one (active) → REVIVED
180
- // (resume). P has none (its entry went to passive.jsonl) → stays idle.
181
- // This is the WAKE half of delivery-vs-wake, decided in ONE tick.
165
+ // S4 — The DAEMON decision pass. Its 2nd pass revives an idle-released node
166
+ // ONLY on an unseen INBOX entry. B has one (active) → REVIVED. P has
167
+ // none (its entry went to passive.jsonl) → stays idle. The WAKE half
168
+ // of delivery-vs-wake, decided in ONE tick — observed via cycles.
182
169
  // ===================================================================
183
- const injBeforeWake = h.injected(B).length;
184
170
  await h.tick();
185
- // ACTIVE subscriber: WOKEN — status flips to active and a fresh pi resumes.
186
- await h.waitForStatus(B, 'active');
171
+ // ACTIVE subscriber: WOKEN — reviveNode bumped cycles + transition(revive).
187
172
  {
188
173
  const b = h.node(B);
189
- assert.equal(b.status, 'active', 'ACTIVE subscriber B: daemon-REVIVED active');
190
- assert.equal(b.intent ?? null, null, "B intent cleared by revive");
174
+ assert.equal(b.cycles ?? 0, 1, 'ACTIVE subscriber B: daemon pass-2 REVIVED (cycles bumped to 1)');
175
+ assert.equal(b.status, 'active', 'B revive active');
176
+ assert.equal(b.intent ?? null, null, 'B intent cleared by revive');
177
+ }
178
+ // PASSIVE subscriber: NOT WOKEN — the same tick did NOT revive P.
179
+ {
180
+ const p = h.node(P);
181
+ assert.equal(p.cycles ?? 0, 0, 'PASSIVE subscriber P: NOT revived (cycles still 0)');
182
+ assert.equal(p.status, 'idle', 'P STILL idle — daemon did NOT wake it');
183
+ assert.equal(p.intent, 'idle-release', 'P still intent=idle-release (untouched)');
191
184
  }
192
- await h.awaitBoot(B, { minCount: 2 }); // spawn boot + resume boot
193
- // awaitWake THROWS on a 15s timeout unless an injected entry matching T's
194
- // body arrives — that match-or-timeout IS the load-bearing oracle here. The
195
- // resume-boot injects persona/bearings, never the report body, so only the
196
- // real inbox-watcher delivery of T's final can satisfy the match.
197
- await h.awaitWake(B, { sinceCount: injBeforeWake, match: /TARGET-FINAL-BODY/ });
198
- // PASSIVE subscriber: NOT WOKEN — the synchronous tick that revived B did
199
- // NOT revive P; P remains idle-released with its pane gone. (The tick's
200
- // loop completed before tick() returned, so P's non-revive is settled.)
201
- assert.equal(h.status(P), 'idle', 'PASSIVE subscriber P: STILL idle — daemon did NOT wake it');
202
- assert.equal(h.node(P).intent, 'idle-release', 'P still intent=idle-release (untouched)');
203
- assert.equal(h.paneAlive(P), false, 'P pane STILL gone — no resume happened');
204
185
  assert.equal(passive(P).length, 1, "P's passive entry is still pending (drained only on its next message)");
205
186
  assert.equal(h.inbox(P).length, 0, "P's inbox still empty");
206
187
  // The grandparent is STILL untouched even though B woke — one-hop confirmed.
207
188
  assert.equal(h.inbox(A).length, 0, 'A inbox STILL empty after B woke — wake did not propagate up');
208
189
  assert.equal(h.status(A), 'active', 'A unchanged');
209
190
  // ===================================================================
210
- // S7 — SECOND SITE: one-hop fan-out on a re-push. The indirect ancestor A
191
+ // S5 — SECOND SITE: one-hop fan-out on a re-push. The indirect ancestor A
211
192
  // hears B ONLY when B explicitly re-pushes up its own spine. B (now
212
193
  // active) pushes an UPDATE → it fans to subscribersOf(B) = {A} only.
213
- // This corroborates the flagship one-hop finding at an independent site.
214
194
  // ===================================================================
195
+ await pushUpdate(B, 'B re-pushing the rolled-up result');
196
+ closeDb();
215
197
  {
216
- const res = h.cli(B, ['push', 'update', 'B re-pushing the rolled-up result']);
217
- assert.equal(res.code, 0, `B push update exit 0\n${res.stderr}`);
218
198
  const aInbox = h.inbox(A);
219
199
  assert.equal(aInbox.length, 1, "A inbox now has exactly B's update (one hop, on B's explicit re-push)");
220
200
  assert.equal(aInbox[0].from, B, "A's entry is from B");
@@ -226,8 +206,6 @@ test('multi-level subscription delivery: active subscriber WOKEN vs passive subs
226
206
  assert.equal(passive(P).length, 1, "P's passive backlog unchanged by B's re-push");
227
207
  }
228
208
  finally {
229
- const session = h.session;
230
209
  await h.dispose();
231
- assert.equal(sessionExists(session), false, 'isolated session killed — no stray');
232
210
  }
233
211
  });