@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.
- package/dist/commands/canvas-history/read.d.ts +1 -0
- package/dist/commands/canvas-history/read.js +61 -0
- package/dist/commands/canvas-history/search.d.ts +1 -0
- package/dist/commands/canvas-history/search.js +270 -0
- package/dist/commands/canvas-history/show.d.ts +1 -0
- package/dist/commands/canvas-history/show.js +79 -0
- package/dist/commands/canvas-history.d.ts +2 -0
- package/dist/commands/canvas-history.js +33 -0
- package/dist/commands/canvas.js +3 -2
- package/dist/commands/memory/find.js +7 -40
- package/dist/commands/memory/lint.js +19 -34
- package/dist/commands/memory/list.js +8 -18
- package/dist/commands/memory/read.js +4 -37
- package/dist/commands/pkg/bridge.d.ts +1 -0
- package/dist/commands/pkg/bridge.js +137 -0
- package/dist/commands/pkg/plugin-inspect.js +7 -6
- package/dist/commands/pkg.js +2 -1
- package/dist/commands/skill/author.js +35 -44
- package/dist/commands/skill/shared.d.ts +1 -5
- package/dist/commands/skill/shared.js +9 -63
- package/dist/commands/skill.js +5 -6
- package/dist/commands/sys/doctor.js +6 -144
- package/dist/core/__tests__/broker-dormant-wake.test.js +71 -34
- package/dist/core/__tests__/canvas-inbox-watcher-hold.test.js +21 -5
- package/dist/core/__tests__/canvas-inbox-watcher.test.js +21 -5
- package/dist/core/__tests__/child-death-wake.test.js +133 -185
- package/dist/core/__tests__/daemon-liveness.test.js +28 -258
- package/dist/core/__tests__/flagship-lifecycle.test.js +156 -135
- package/dist/core/__tests__/frame-decoder-perf.test.js +51 -25
- package/dist/core/__tests__/{broker-attach-limits.test.js → full/broker-attach-limits.test.js} +62 -35
- package/dist/core/__tests__/{broker-attach-stream.test.js → full/broker-attach-stream.test.js} +54 -31
- package/dist/core/__tests__/full/broker-crash-teardown.test.js +143 -0
- package/dist/core/__tests__/full/broker-dialogs.test.js +153 -0
- package/dist/core/__tests__/full/broker-lifecycle.test.js +116 -0
- package/dist/core/__tests__/{cascade-close.test.js → full/cascade-close.test.js} +1 -1
- package/dist/core/__tests__/full/daemon-liveness-pane.full.test.js +292 -0
- package/dist/core/__tests__/{dead-pane-regression.test.js → full/dead-pane-regression.test.js} +2 -2
- package/dist/core/__tests__/{detach-focus.test.js → full/detach-focus.test.js} +5 -5
- package/dist/core/__tests__/{human-new-window-regression.test.js → full/human-new-window-regression.test.js} +2 -2
- package/dist/core/__tests__/{placement-focus.test.js → full/placement-focus.test.js} +5 -5
- package/dist/core/__tests__/{placement-reconcile.test.js → full/placement-reconcile.test.js} +3 -3
- package/dist/core/__tests__/{placement-revive.test.js → full/placement-revive.test.js} +5 -5
- package/dist/core/__tests__/{placement-teardown.test.js → full/placement-teardown.test.js} +4 -4
- package/dist/core/__tests__/{review-render-pane-regression.test.js → full/review-render-pane-regression.test.js} +2 -2
- package/dist/core/__tests__/full/spike-harness.test.js +117 -0
- package/dist/core/__tests__/grace-clock.test.js +72 -75
- package/dist/core/__tests__/helpers/harness.d.ts +35 -1
- package/dist/core/__tests__/helpers/harness.js +70 -12
- package/dist/core/__tests__/live-mutation-verbs.test.js +99 -104
- package/dist/core/__tests__/live-mutation.test.js +111 -134
- package/dist/core/__tests__/revive.test.js +96 -86
- package/dist/core/__tests__/subscription-delivery.test.js +116 -138
- package/dist/core/__tests__/wake-origin.test.js +54 -50
- package/dist/core/bridge-map.d.ts +19 -0
- package/dist/core/bridge-map.js +73 -0
- package/dist/core/canvas/history.d.ts +91 -0
- package/dist/core/canvas/history.js +389 -0
- package/dist/core/canvas/index.d.ts +1 -0
- package/dist/core/canvas/index.js +1 -0
- package/dist/core/config.js +3 -22
- package/dist/core/memory-resolver.d.ts +9 -2
- package/dist/core/memory-resolver.js +47 -7
- package/dist/core/resolver.d.ts +1 -30
- package/dist/core/resolver.js +6 -546
- package/dist/core/runtime/persona.js +5 -7
- package/dist/core/scope.d.ts +7 -5
- package/dist/core/scope.js +16 -17
- package/dist/core/substrate/ceiling.d.ts +28 -0
- package/dist/core/substrate/ceiling.js +87 -0
- package/dist/core/substrate/index.d.ts +2 -0
- package/dist/core/substrate/index.js +1 -0
- package/dist/core/substrate/on-read.js +18 -2
- package/dist/core/substrate/render.d.ts +2 -3
- package/dist/core/substrate/render.js +63 -51
- package/dist/pi-extensions/canvas-inbox-watcher.js +19 -2
- package/dist/types.d.ts +3 -18
- package/dist/types.js +0 -8
- package/package.json +4 -3
- package/dist/builtin-skills/.crouter-plugin/plugin.json +0 -5
- package/dist/builtin-skills/skills/crouter-development/marketplaces/SKILL.md +0 -157
- package/dist/builtin-skills/skills/crouter-development/personas/SKILL.md +0 -106
- package/dist/builtin-skills/skills/crouter-development/personas/base-prompt/SKILL.md +0 -49
- package/dist/builtin-skills/skills/crouter-development/personas/orchestrator-prompt/SKILL.md +0 -49
- package/dist/builtin-skills/skills/crouter-development/plugins/SKILL.md +0 -156
- package/dist/builtin-skills/skills/design/SKILL.md +0 -51
- package/dist/builtin-skills/skills/development/SKILL.md +0 -109
- package/dist/builtin-skills/skills/planning/SKILL.md +0 -59
- package/dist/builtin-skills/skills/spec/SKILL.md +0 -83
- package/dist/commands/skill/state.d.ts +0 -3
- package/dist/commands/skill/state.js +0 -71
- package/dist/core/__tests__/broker-crash-teardown.test.js +0 -116
- package/dist/core/__tests__/broker-dialogs.test.js +0 -126
- package/dist/core/__tests__/broker-lifecycle.test.js +0 -87
- package/dist/core/__tests__/resolver.test.js +0 -181
- package/dist/core/__tests__/spike-harness.test.js +0 -242
- /package/dist/core/__tests__/{broker-attach-limits.test.d.ts → full/broker-attach-limits.test.d.ts} +0 -0
- /package/dist/core/__tests__/{broker-attach-stream.test.d.ts → full/broker-attach-stream.test.d.ts} +0 -0
- /package/dist/core/__tests__/{broker-crash-teardown.test.d.ts → full/broker-crash-teardown.test.d.ts} +0 -0
- /package/dist/core/__tests__/{broker-dialogs.test.d.ts → full/broker-dialogs.test.d.ts} +0 -0
- /package/dist/core/__tests__/{broker-lifecycle.test.d.ts → full/broker-lifecycle.test.d.ts} +0 -0
- /package/dist/core/__tests__/{cascade-close.test.d.ts → full/cascade-close.test.d.ts} +0 -0
- /package/dist/core/__tests__/{dead-pane-regression.test.d.ts → full/daemon-liveness-pane.full.test.d.ts} +0 -0
- /package/dist/core/__tests__/{detach-focus.test.d.ts → full/dead-pane-regression.test.d.ts} +0 -0
- /package/dist/core/__tests__/{human-new-window-regression.test.d.ts → full/detach-focus.test.d.ts} +0 -0
- /package/dist/core/__tests__/{placement-focus.test.d.ts → full/human-new-window-regression.test.d.ts} +0 -0
- /package/dist/core/__tests__/{placement-reconcile.test.d.ts → full/placement-focus.test.d.ts} +0 -0
- /package/dist/core/__tests__/{placement-revive.test.d.ts → full/placement-reconcile.test.d.ts} +0 -0
- /package/dist/core/__tests__/{placement-teardown.test.d.ts → full/placement-revive.test.d.ts} +0 -0
- /package/dist/core/__tests__/{resolver.test.d.ts → full/placement-teardown.test.d.ts} +0 -0
- /package/dist/core/__tests__/{review-render-pane-regression.test.d.ts → full/review-render-pane-regression.test.d.ts} +0 -0
- /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
|
-
//
|
|
4
|
-
//
|
|
5
|
-
// older nodes
|
|
6
|
-
// session file. The argv selection is unit-tested via the pure
|
|
7
|
-
// `buildPiArgv`; the guard
|
|
8
|
-
// tmux
|
|
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
|
|
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
|
-
|
|
34
|
-
|
|
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
|
|
135
|
-
// (
|
|
136
|
-
//
|
|
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
|
-
|
|
139
|
-
|
|
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
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
-
|
|
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
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
pi_pid:
|
|
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
|
-
|
|
190
|
-
|
|
191
|
-
reviveNode(
|
|
192
|
-
assert.equal(
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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
|
|
5
|
-
//
|
|
6
|
-
//
|
|
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
|
-
//
|
|
10
|
-
// (which
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
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
|
|
34
|
-
// P→K active (
|
|
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
|
|
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
|
-
|
|
44
|
-
|
|
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
|
|
47
|
-
//
|
|
48
|
-
//
|
|
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', {
|
|
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
|
|
64
|
-
//
|
|
65
|
-
//
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
const
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
//
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
//
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
//
|
|
92
|
-
//
|
|
93
|
-
//
|
|
94
|
-
//
|
|
95
|
-
//
|
|
96
|
-
//
|
|
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 =
|
|
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
|
|
139
|
-
assert.equal(
|
|
140
|
-
assert.equal(
|
|
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
|
-
|
|
143
|
-
assert.equal(h.
|
|
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
|
|
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
|
-
//
|
|
152
|
-
// subscribersOf(T) = {B(active), P(passive)} ONLY, by the
|
|
153
|
-
// split: B's lands in inbox.jsonl, P's in passive.jsonl. A
|
|
154
|
-
// grandparent, NOT a subscriber of T) gets NOTHING — one-hop
|
|
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
|
|
146
|
+
await pushFinal(T, TARGET_FINAL);
|
|
147
|
+
closeDb();
|
|
158
148
|
{
|
|
159
|
-
|
|
160
|
-
assert.equal(
|
|
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
|
|
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
|
-
//
|
|
179
|
-
//
|
|
180
|
-
//
|
|
181
|
-
//
|
|
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 —
|
|
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.
|
|
190
|
-
assert.equal(b.
|
|
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
|
-
//
|
|
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
|
});
|