@crouton-kit/crouter 0.3.20 → 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/builtin-memory/crouter-development/marketplaces.md +2 -4
- package/dist/builtin-memory/crouter-development/personas/base-prompt.md +3 -5
- package/dist/builtin-memory/crouter-development/personas/orchestrator-prompt.md +3 -6
- package/dist/builtin-memory/crouter-development/personas.md +2 -6
- package/dist/builtin-memory/crouter-development/plugins.md +2 -4
- package/dist/builtin-memory/design.md +2 -4
- package/dist/builtin-memory/development.md +3 -3
- package/dist/builtin-memory/planning.md +3 -4
- package/dist/builtin-memory/spec.md +3 -10
- package/dist/builtin-personas/orchestration-kernel.md +1 -1
- 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/human/queue.js +2 -2
- package/dist/commands/memory/__tests__/lint-schema.test.js +29 -0
- package/dist/commands/memory/find.js +14 -52
- package/dist/commands/memory/lint.d.ts +7 -0
- package/dist/commands/memory/lint.js +29 -35
- package/dist/commands/memory/list.js +8 -18
- package/dist/commands/memory/read.js +4 -37
- package/dist/commands/memory/shared.js +1 -2
- package/dist/commands/memory/write.js +6 -9
- package/dist/commands/node.js +47 -25
- 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/push.js +10 -15
- 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/commands/tmux-spread.js +16 -1
- package/dist/commands/view-cycle.js +9 -5
- package/dist/commands/view-run.js +3 -2
- 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__/context-intro.test.js +23 -27
- 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} +4 -3
- 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} +7 -7
- 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.d.ts +1 -0
- 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 +100 -105
- package/dist/core/__tests__/live-mutation.test.js +111 -134
- package/dist/core/__tests__/memory.test.js +4 -4
- 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/bootstrap.js +18 -14
- 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/help.d.ts +2 -2
- package/dist/core/help.js +1 -1
- package/dist/core/memory-resolver.d.ts +9 -2
- package/dist/core/memory-resolver.js +47 -7
- package/dist/core/render.d.ts +4 -3
- package/dist/core/render.js +38 -41
- package/dist/core/resolver.d.ts +1 -30
- package/dist/core/resolver.js +6 -546
- package/dist/core/runtime/bearings.d.ts +2 -2
- package/dist/core/runtime/bearings.js +2 -2
- 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 +3 -5
- package/dist/core/substrate/render.js +68 -59
- package/dist/core/substrate/schema.d.ts +18 -13
- package/dist/core/substrate/schema.js +12 -11
- package/dist/pi-extensions/canvas-context-intro.js +5 -3
- 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 → commands/memory/__tests__/lint-schema.test.d.ts} +0 -0
- /package/dist/core/__tests__/{broker-attach-stream.test.d.ts → full/broker-attach-limits.test.d.ts} +0 -0
- /package/dist/core/__tests__/{broker-crash-teardown.test.d.ts → full/broker-attach-stream.test.d.ts} +0 -0
- /package/dist/core/__tests__/{broker-dialogs.test.d.ts → full/broker-crash-teardown.test.d.ts} +0 -0
- /package/dist/core/__tests__/{broker-lifecycle.test.d.ts → full/broker-dialogs.test.d.ts} +0 -0
- /package/dist/core/__tests__/{cascade-close.test.d.ts → full/broker-lifecycle.test.d.ts} +0 -0
- /package/dist/core/__tests__/{dead-pane-regression.test.d.ts → full/cascade-close.test.d.ts} +0 -0
- /package/dist/core/__tests__/{detach-focus.test.d.ts → full/daemon-liveness-pane.full.test.d.ts} +0 -0
- /package/dist/core/__tests__/{human-new-window-regression.test.d.ts → full/dead-pane-regression.test.d.ts} +0 -0
- /package/dist/core/__tests__/{placement-focus.test.d.ts → full/detach-focus.test.d.ts} +0 -0
- /package/dist/core/__tests__/{placement-reconcile.test.d.ts → full/human-new-window-regression.test.d.ts} +0 -0
- /package/dist/core/__tests__/{placement-revive.test.d.ts → full/placement-focus.test.d.ts} +0 -0
- /package/dist/core/__tests__/{placement-teardown.test.d.ts → full/placement-reconcile.test.d.ts} +0 -0
- /package/dist/core/__tests__/{resolver.test.d.ts → full/placement-revive.test.d.ts} +0 -0
- /package/dist/core/__tests__/{review-render-pane-regression.test.d.ts → full/placement-teardown.test.d.ts} +0 -0
- /package/dist/core/__tests__/{spike-harness.test.d.ts → full/review-render-pane-regression.test.d.ts} +0 -0
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// Run with: node --import tsx/esm --test src/core/__tests__/full/spike-harness.test.ts
|
|
2
|
+
//
|
|
3
|
+
// HEADLESS RETARGET (foundation-spec §C.14 + §E). The throwaway-grade POC that a
|
|
4
|
+
// faithful integration harness for the node/canvas runtime is feasible — now on
|
|
5
|
+
// the BROKER path, tmux-free (no hasTmux() gate). FULL TIER: it boots a REAL
|
|
6
|
+
// broker process (~5s pi-SDK load), so it lives in full/ (CI), not the fast loop. It drives the
|
|
7
|
+
// REAL `crtr` CLI to spawn a `--headless` node onto the REAL headless broker host,
|
|
8
|
+
// which boots a REAL detached broker PROCESS hosting the fake SDK engine via the
|
|
9
|
+
// CRTR_BROKER_ENGINE seam — proving the broker exec's with the right argv+env,
|
|
10
|
+
// loads the REAL `-e` canvas extensions, and that real lifecycle hooks drive real
|
|
11
|
+
// canvas transitions.
|
|
12
|
+
//
|
|
13
|
+
// Milestones (de-risk order):
|
|
14
|
+
// 1. SEAM — piCommand substitutes CRTR_PI_BINARY only when set (pure unit).
|
|
15
|
+
// 2. ROUND-TRIP— real `node new --headless` → real broker boots with CRTR_NODE_ID
|
|
16
|
+
// + the -e canvas extensions intact (GO/NO-GO).
|
|
17
|
+
// 3. REAL HOOKS— the broker loads the real stophook: session_start captures
|
|
18
|
+
// pi_session_id + recordPid(broker pid); a `push --final` agent_end
|
|
19
|
+
// drives status=done through the stophook's clean-exit branch.
|
|
20
|
+
//
|
|
21
|
+
// (1) BUG LOCKED — harness faithfulness. A real `node new --headless` must boot a
|
|
22
|
+
// real broker that loads EVERY canvas `-e` extension with none failing, the
|
|
23
|
+
// REAL stophook must capture the session id on session_start, and the REAL
|
|
24
|
+
// stophook agent_end clean-exit branch must transition the node to done.
|
|
25
|
+
//
|
|
26
|
+
// (2) WHY MODEL-LEVEL / HEADLESS — the broker hosts the fake engine over the SDK
|
|
27
|
+
// seam (no real LLM), boots DETACHED supervised by pid (no tmux pane), and
|
|
28
|
+
// every proof is a file the broker writes (fake-pi.boots.jsonl) or a canvas
|
|
29
|
+
// row — nothing reads a pane. ONE real boot is inherent: the POC's whole
|
|
30
|
+
// point is that a real broker PROCESS loads real extensions + fires real hooks.
|
|
31
|
+
// (The fake engine models a clean exit via the broker's shutdownHandler rather
|
|
32
|
+
// than a pi `session_shutdown` frame, so the clean done transition is driven
|
|
33
|
+
// through the stophook's `push --final` agent_end branch — the same real
|
|
34
|
+
// stophook code path that shuts a done broker down.)
|
|
35
|
+
//
|
|
36
|
+
// (3) HOW THE HEADLESS DRIVE STILL FAILS IF THE BUG REGRESSES — if the seam or an
|
|
37
|
+
// extension load broke, failedExt is non-empty or extPaths drifts from
|
|
38
|
+
// CANVAS_EXTENSIONS → RED; if the stophook didn't capture the session,
|
|
39
|
+
// pi_session_id stays null → RED; if the clean-exit branch didn't shut the
|
|
40
|
+
// broker down, status never reaches done → the finish wait times out.
|
|
41
|
+
import { test, before, after } from 'node:test';
|
|
42
|
+
import assert from 'node:assert/strict';
|
|
43
|
+
import { createHarness } from '../helpers/harness.js';
|
|
44
|
+
import { piCommand } from '../../runtime/placement.js';
|
|
45
|
+
import { CANVAS_EXTENSIONS } from '../../runtime/launch.js';
|
|
46
|
+
let h;
|
|
47
|
+
let root;
|
|
48
|
+
before(async () => {
|
|
49
|
+
h = await createHarness({ headless: true, sessionPrefix: 'crtr-spike' });
|
|
50
|
+
root = h.spawnRoot('spike acting-root');
|
|
51
|
+
});
|
|
52
|
+
after(async () => {
|
|
53
|
+
if (h !== undefined)
|
|
54
|
+
await h.dispose();
|
|
55
|
+
});
|
|
56
|
+
// ===========================================================================
|
|
57
|
+
// MILESTONE 1 — the CRTR_PI_BINARY seam (pure unit; no boot, no tmux).
|
|
58
|
+
// ===========================================================================
|
|
59
|
+
test('M1 seam: piCommand exec\'s `pi` when CRTR_PI_BINARY is unset, substitutes when set', () => {
|
|
60
|
+
const saved = process.env['CRTR_PI_BINARY'];
|
|
61
|
+
try {
|
|
62
|
+
delete process.env['CRTR_PI_BINARY'];
|
|
63
|
+
const unset = piCommand(['-e', '/abs/ext.ts', '-n', 'label']);
|
|
64
|
+
assert.equal(unset, "pi '-e' '/abs/ext.ts' '-n' 'label'", 'unset → identical to exec pi');
|
|
65
|
+
assert.ok(unset.startsWith('pi '), 'unset → leads with the literal pi binary');
|
|
66
|
+
process.env['CRTR_PI_BINARY'] = '/tmp/fake-pi';
|
|
67
|
+
const set = piCommand(['-e', '/abs/ext.ts']);
|
|
68
|
+
assert.ok(set.startsWith('/tmp/fake-pi '), 'set → leads with the substituted binary');
|
|
69
|
+
assert.ok(!set.startsWith('pi '), 'set → no longer the literal pi');
|
|
70
|
+
assert.equal(set, "/tmp/fake-pi '-e' '/abs/ext.ts'", 'argv still shell-quoted after the substitution');
|
|
71
|
+
// A multi-word launcher is spliced verbatim (argv stays quoted).
|
|
72
|
+
process.env['CRTR_PI_BINARY'] = 'node --import tsx/esm host.ts';
|
|
73
|
+
assert.equal(piCommand(['-n', 'x']), "node --import tsx/esm host.ts '-n' 'x'", 'multi-word binary spliced ahead of the quoted argv');
|
|
74
|
+
// An explicit binary arg still overrides the env.
|
|
75
|
+
assert.ok(piCommand(['-n', 'x'], 'pi').startsWith('pi '), 'explicit binary arg wins over the env seam');
|
|
76
|
+
}
|
|
77
|
+
finally {
|
|
78
|
+
if (saved === undefined)
|
|
79
|
+
delete process.env['CRTR_PI_BINARY'];
|
|
80
|
+
else
|
|
81
|
+
process.env['CRTR_PI_BINARY'] = saved;
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
// ===========================================================================
|
|
85
|
+
// MILESTONES 2 + 3 — real `node new --headless` → real broker → real hooks.
|
|
86
|
+
// THE GO/NO-GO. One spawned broker drives both milestones (one real boot).
|
|
87
|
+
// ===========================================================================
|
|
88
|
+
test('M2+M3 round-trip: real `node new --headless` boots the broker via the seam, real hooks drive status=done', async () => {
|
|
89
|
+
const id = await h.spawnHeadlessChild(root, 'spike task');
|
|
90
|
+
// ---- MILESTONE 2: the round-trip reached the real broker -----------------
|
|
91
|
+
const boot = await h.awaitBoot(id);
|
|
92
|
+
const env = boot.env;
|
|
93
|
+
assert.equal(env['CRTR_NODE_ID'], id, 'CRTR_NODE_ID is the CHILD id, intact');
|
|
94
|
+
assert.equal(env['CRTR_HOME'], h.home, 'CRTR_HOME isolated value intact');
|
|
95
|
+
assert.ok(env['CRTR_KIND'], 'CRTR_KIND present');
|
|
96
|
+
assert.ok(env['CRTR_MODE'], 'CRTR_MODE present');
|
|
97
|
+
assert.ok(env['CRTR_LIFECYCLE'], 'CRTR_LIFECYCLE present');
|
|
98
|
+
assert.equal(env['CRTR_FRONT_DOOR'], '1', 'CRTR_FRONT_DOOR overlay present (broker host)');
|
|
99
|
+
// argv from buildPiArgv arrived: every canvas -e extension + the kickoff. Assert
|
|
100
|
+
// against the live CANVAS_EXTENSIONS count so this never drifts again.
|
|
101
|
+
assert.equal(boot.extPaths.length, CANVAS_EXTENSIONS.length, `all ${CANVAS_EXTENSIONS.length} canvas -e extension paths in argv`);
|
|
102
|
+
assert.ok(boot.loaded.some((p) => p.includes('canvas-stophook')), 'real stophook module loaded by the broker');
|
|
103
|
+
assert.ok(boot.loaded.some((p) => p.includes('canvas-inbox-watcher')), 'real inbox-watcher module loaded by the broker');
|
|
104
|
+
assert.equal(boot.failedExt.length, 0, `no extension failed to load: ${JSON.stringify(boot.failedExt)}`);
|
|
105
|
+
assert.equal(boot.resuming, false, 'fresh start (no --session)');
|
|
106
|
+
assert.equal(boot.prompt, 'spike task', 'kickoff prompt is the last positional');
|
|
107
|
+
// The REAL stophook session_start handler ran inside the broker and wrote shared
|
|
108
|
+
// canvas state (proves the hook chain, not just the boot).
|
|
109
|
+
const afterBoot = h.node(id);
|
|
110
|
+
assert.equal(afterBoot.pi_session_id, boot.sessionId, 'stophook captured pi_session_id');
|
|
111
|
+
assert.equal(afterBoot.status, 'active', 'child active after boot');
|
|
112
|
+
// ---- MILESTONE 3: a clean finish drives a real transition to done --------
|
|
113
|
+
// push --final flips status=done; the broker's agent_end runs the REAL stophook
|
|
114
|
+
// clean-exit branch (status already done → ctx.shutdown → broker disposes/exits).
|
|
115
|
+
await h.finish(id, 'spike done');
|
|
116
|
+
assert.equal(h.status(id), 'done', 'real stophook agent_end resolved the node to done');
|
|
117
|
+
});
|
|
@@ -1,45 +1,45 @@
|
|
|
1
1
|
// Run with: node --import tsx/esm --test src/core/__tests__/grace-clock.test.ts
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
3
|
+
// HEADLESS RETARGET (foundation-spec §C.10 + §E). Drives a broker-hosted
|
|
4
|
+
// (paneless) node fabricated DIRECTLY in the canvas — no real tmux session, no
|
|
5
|
+
// remain-on-exit pane, and NO real broker boot. The grace decision is pure
|
|
6
|
+
// daemon logic, so the test never pays the ~5s SDK-boot cost.
|
|
5
7
|
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
// exercised end-to-end. daemon-liveness.test.ts pins livenessVerdict purely and
|
|
12
|
-
// drives superviseTick with a FABRICATED pi-death (deadPid); this drives a REAL
|
|
13
|
-
// fake-pi boot, kills it under a FROZEN (remain-on-exit) pane so the pane stays
|
|
14
|
-
// alive while pi is genuinely dead, then walks a deterministic clock across the
|
|
15
|
-
// grace boundary.
|
|
8
|
+
// (1) BUG LOCKED — the REVIVE_GRACE_MS double-spawn guard (daemon invariant 10,
|
|
9
|
+
// MINOR-4). A node whose engine pid is observed DEAD must pend through the
|
|
10
|
+
// REVIVE_GRACE_MS window before the daemon revives it; revive too early and
|
|
11
|
+
// it lands in the transient old-pi-dies→fresh-pi-boots gap and DOUBLE-SPAWNS
|
|
12
|
+
// a second vehicle on the same .jsonl.
|
|
16
13
|
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
14
|
+
// (2) WHY MODEL-LEVEL, NOT TMUX CHROME — the guard lives entirely in pure daemon
|
|
15
|
+
// logic: livenessVerdict(piPidAlive,deadFor) → REVIVE_GRACE_MS → the
|
|
16
|
+
// unhealthySince clock → revivedThisTick (crtrd.ts). For a broker the dead-pid
|
|
17
|
+
// path routes handleBrokerLiveness → handleLiveWindow VERBATIM (intent is
|
|
18
|
+
// neither refresh nor idle-release), exercising the SAME grace machinery as a
|
|
19
|
+
// tmux node. A broker is supervised purely by row.pi_pid, so a fabricated row
|
|
20
|
+
// carrying a known-DEAD pid + a recorded pi_session_id (so it routes the
|
|
21
|
+
// crash/grace path, NOT the never-booted boot-failure branch) reproduces the
|
|
22
|
+
// exact "dead but supervised" state with no process at all.
|
|
23
|
+
//
|
|
24
|
+
// (3) HOW THE FABRICATED DRIVE STILL FAILS IF THE BUG REGRESSES — we walk
|
|
25
|
+
// h.tick(now) across the grace boundary with a deterministic clock and assert
|
|
26
|
+
// getNode(B).cycles (reviveNode bumps cycles + runs transition('revive')
|
|
27
|
+
// BEFORE it spawns — revive.ts:114,145 — so a revive's model effect is
|
|
28
|
+
// observable INSTANTLY, no awaitBoot needed). If the pending branch is removed
|
|
29
|
+
// (revive fires on first-observed-dead), the WITHIN-GRACE tick revives →
|
|
30
|
+
// cycles bumps early → the "cycles unchanged" asserts go RED. Verified by
|
|
31
|
+
// reverting livenessVerdict's `deadFor < REVIVE_GRACE_MS` pending branch
|
|
32
|
+
// (see bug-injection report).
|
|
21
33
|
import { test } from 'node:test';
|
|
22
34
|
import assert from 'node:assert/strict';
|
|
23
35
|
import { spawnSync } from 'node:child_process';
|
|
24
|
-
import {
|
|
25
|
-
import { join } from 'node:path';
|
|
26
|
-
import { createHarness, hasTmux } from './helpers/harness.js';
|
|
36
|
+
import { createHarness } from './helpers/harness.js';
|
|
27
37
|
import { isPidAlive } from '../../daemon/crtrd.js';
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
* fake-pi.boots.jsonl) — the observable for "did a revive double-spawn?". */
|
|
34
|
-
function bootCount(home, id) {
|
|
35
|
-
try {
|
|
36
|
-
return readFileSync(join(home, 'nodes', id, 'fake-pi.boots.jsonl'), 'utf8')
|
|
37
|
-
.split('\n')
|
|
38
|
-
.filter((l) => l.trim() !== '').length;
|
|
39
|
-
}
|
|
40
|
-
catch {
|
|
41
|
-
return 0;
|
|
42
|
-
}
|
|
38
|
+
// A pid that is reaped (dead) by the time spawnSync returns — the "dead but
|
|
39
|
+
// supervised" pid the grace path judges.
|
|
40
|
+
function deadPid() {
|
|
41
|
+
const r = spawnSync('true', [], { stdio: 'ignore' });
|
|
42
|
+
return r.pid ?? 0x7ffffffe; // fall back to an implausibly-high pid
|
|
43
43
|
}
|
|
44
44
|
// REVIVE_GRACE_MS is 20_000 (crtrd.ts). We choose offsets well inside / well
|
|
45
45
|
// past it so the test is robust to the exact value without importing it.
|
|
@@ -47,69 +47,66 @@ const NOW = 5_000_000;
|
|
|
47
47
|
const WITHIN_GRACE = NOW + 10_000; // < 20s after first-observed-dead → still pending
|
|
48
48
|
const PAST_GRACE = NOW + 25_000; // > 20s after first-observed-dead → revive
|
|
49
49
|
// ===========================================================================
|
|
50
|
-
// The grace window guards against a double-spawn: while a node's
|
|
51
|
-
//
|
|
52
|
-
//
|
|
53
|
-
//
|
|
54
|
-
//
|
|
50
|
+
// The grace window guards against a double-spawn: while a node's engine pid has
|
|
51
|
+
// been observed dead for LESS than REVIVE_GRACE_MS, the daemon must NOT revive
|
|
52
|
+
// (a revive there would race the in-flight respawn and spawn a second vehicle on
|
|
53
|
+
// the same conversation). Once the pid has been dead PAST the grace, a revive
|
|
54
|
+
// proceeds and cycles advances.
|
|
55
55
|
// ===========================================================================
|
|
56
|
-
test('grace clock: a dead-pi
|
|
57
|
-
const h = await createHarness({ sessionPrefix: 'crtr-grace' });
|
|
56
|
+
test('grace clock: a dead-pi broker does NOT revive within REVIVE_GRACE_MS, but DOES once it elapses', { timeout: 30_000 }, async () => {
|
|
57
|
+
const h = await createHarness({ headless: true, sessionPrefix: 'crtr-grace' });
|
|
58
58
|
try {
|
|
59
|
-
const
|
|
60
|
-
|
|
61
|
-
//
|
|
62
|
-
//
|
|
63
|
-
// handleLiveWindow
|
|
64
|
-
|
|
59
|
+
const dead = deadPid();
|
|
60
|
+
assert.equal(isPidAlive(dead), false, 'fabrication precondition: the supervised pid is dead');
|
|
61
|
+
// Fabricate the EXACT "dead but supervised" broker state directly — no
|
|
62
|
+
// boot. pi_session_id is SET so handleBrokerLiveness routes the crash/grace
|
|
63
|
+
// path (handleLiveWindow), NOT the never-booted boot-failure branch; intent
|
|
64
|
+
// is null so it is neither the refresh nor the idle-release early-return.
|
|
65
|
+
const B = h.fabricateBrokerNode({
|
|
66
|
+
kind: 'developer',
|
|
67
|
+
status: 'active',
|
|
68
|
+
intent: null,
|
|
69
|
+
pi_pid: dead,
|
|
70
|
+
pi_session_id: 'sess-grace-B',
|
|
71
|
+
});
|
|
65
72
|
const b0 = h.node(B);
|
|
66
|
-
assert.equal(b0.
|
|
67
|
-
assert.equal(b0.
|
|
68
|
-
assert.equal(b0.
|
|
69
|
-
assert.equal(
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
//
|
|
73
|
-
const ro = spawnSync('tmux', ['set-window-option', '-t', b0.window, 'remain-on-exit', 'on'], { stdio: 'ignore' });
|
|
74
|
-
assert.equal(ro.status, 0, 'armed remain-on-exit on B\'s window');
|
|
75
|
-
// Kill the fake-pi. pi dies; the frozen pane stays alive.
|
|
76
|
-
process.kill(boot.pid, 'SIGKILL');
|
|
77
|
-
await h.waitFor(() => !isPidAlive(boot.pid), { timeoutMs: 10_000, label: 'fake-pi pid dead' });
|
|
78
|
-
assert.equal(h.paneAlive(B), true, 'pane is FROZEN alive after pi death (remain-on-exit)');
|
|
79
|
-
// --- TICK 1 @ NOW: first observation of the dead pi → 'pending'. The
|
|
73
|
+
assert.equal(b0.host_kind, 'broker', 'B is broker-hosted (paneless)');
|
|
74
|
+
assert.equal(b0.status, 'active', 'B active');
|
|
75
|
+
assert.equal(b0.intent ?? null, null, 'B intent=null (the grace path, not the idle-release early-return)');
|
|
76
|
+
assert.equal(b0.pi_pid, dead, 'row pi_pid == the known-dead pid');
|
|
77
|
+
assert.equal(b0.window ?? null, null, 'no tmux window — broker host');
|
|
78
|
+
assert.equal(b0.cycles ?? 0, 0, 'no revive yet — cycles at 0');
|
|
79
|
+
// --- TICK 1 @ NOW: first observation of the dead pid → 'pending'. The
|
|
80
80
|
// daemon records first-observed-dead and does NOT revive. ---
|
|
81
81
|
await h.tick(NOW);
|
|
82
82
|
{
|
|
83
83
|
const b = h.node(B);
|
|
84
|
-
assert.equal(
|
|
85
|
-
assert.equal(b.pi_pid,
|
|
84
|
+
assert.equal(b.cycles ?? 0, 0, 'first tick: NO revive on first-observed-dead (cycles still 0)');
|
|
85
|
+
assert.equal(b.pi_pid, dead, 'pi_pid unchanged — no fresh vehicle launched');
|
|
86
86
|
assert.equal(b.status, 'active', 'B left active (pending, not revived)');
|
|
87
|
-
assert.equal(h.paneAlive(B), true, 'frozen pane still alive');
|
|
88
87
|
}
|
|
89
88
|
// --- TICK 2 @ NOW+10s (WITHIN the 20s grace): STILL pending. This is the
|
|
90
89
|
// double-spawn guard: a revive here would land in the respawn gap. ---
|
|
91
90
|
await h.tick(WITHIN_GRACE);
|
|
92
91
|
{
|
|
93
92
|
const b = h.node(B);
|
|
94
|
-
assert.equal(
|
|
95
|
-
assert.equal(b.pi_pid,
|
|
93
|
+
assert.equal(b.cycles ?? 0, 0, 'within REVIVE_GRACE_MS: NO double-spawn — the dead pi must pend, not revive (cycles still 0)');
|
|
94
|
+
assert.equal(b.pi_pid, dead, 'pi_pid STILL the dead pid — guard held');
|
|
96
95
|
assert.equal(b.status, 'active', 'B still active inside the grace window');
|
|
97
96
|
}
|
|
98
|
-
// --- TICK 3 @ NOW+25s (PAST the 20s grace): now a revive proceeds —
|
|
99
|
-
//
|
|
97
|
+
// --- TICK 3 @ NOW+25s (PAST the 20s grace): now a revive proceeds —
|
|
98
|
+
// reviveNode bumps cycles + transition('revive') BEFORE the (detached,
|
|
99
|
+
// unawaited) broker spawn, so the model effect is visible immediately. ---
|
|
100
100
|
await h.tick(PAST_GRACE);
|
|
101
|
-
await h.awaitBoot(B, { minCount: 2, timeoutMs: 30_000 });
|
|
102
|
-
assert.ok(bootCount(h.home, B) >= 2, 'past REVIVE_GRACE_MS: the dead pi is revived → a fresh vehicle boots');
|
|
103
|
-
await h.waitForStatus(B, 'active');
|
|
104
101
|
{
|
|
105
102
|
const b = h.node(B);
|
|
106
|
-
assert.equal(b.
|
|
107
|
-
assert.
|
|
103
|
+
assert.equal(b.cycles ?? 0, 1, 'past REVIVE_GRACE_MS: the dead pi is revived → cycles bumped to 1');
|
|
104
|
+
assert.equal(b.status, 'active', 'B active after the grace-window revive (transition(revive))');
|
|
105
|
+
assert.equal(b.intent ?? null, null, 'intent cleared by the revive');
|
|
106
|
+
assert.equal(b.pi_pid ?? null, null, 'pi_pid cleared by the window-backed revive (clearPid)');
|
|
108
107
|
}
|
|
109
108
|
}
|
|
110
109
|
finally {
|
|
111
|
-
const session = h.session;
|
|
112
110
|
await h.dispose();
|
|
113
|
-
assert.equal(sessionExists(session), false, 'isolated session killed — no stray');
|
|
114
111
|
}
|
|
115
112
|
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { NodeMeta, NodeStatus, Mode, Lifecycle } from '../../canvas/types.js';
|
|
1
|
+
import type { NodeMeta, NodeStatus, Mode, Lifecycle, ExitIntent } from '../../canvas/types.js';
|
|
2
2
|
import type { InboxEntry } from '../../feed/inbox.js';
|
|
3
3
|
/** True when a usable tmux is on PATH — tests gate on this and SKIP otherwise. */
|
|
4
4
|
export declare function hasTmux(): boolean;
|
|
@@ -30,6 +30,18 @@ export interface CliResult {
|
|
|
30
30
|
}
|
|
31
31
|
export interface HarnessOpts {
|
|
32
32
|
sessionPrefix?: string;
|
|
33
|
+
/** HEADLESS mode: drive ONLY broker-hosted (paneless) nodes. When set the
|
|
34
|
+
* harness does NOT gate on tmux, does NOT eagerly create the isolated tmux
|
|
35
|
+
* session, and skips the per-pane `set-environment` seeding — nothing in a
|
|
36
|
+
* headless run opens a window. CRTR_NODE_SESSION is still set to the isolated
|
|
37
|
+
* name (and dispose still kill-sessions it) because `spawnChild` in spawn.ts
|
|
38
|
+
* calls `ensureSession()` UNCONDITIONALLY to record each node's durable
|
|
39
|
+
* `home_session` revive target — even a broker birth, which then ignores it.
|
|
40
|
+
* So `node new --headless` may LAZILY create that one isolated session as a
|
|
41
|
+
* side effect, but never a window/pane; the broker boots detached, supervised
|
|
42
|
+
* by pid. Headless tests must use `spawnHeadlessChild`, never `spawnChild`
|
|
43
|
+
* (which opens a real window). See createHeadlessHarness(). */
|
|
44
|
+
headless?: boolean;
|
|
33
45
|
}
|
|
34
46
|
export interface SpawnOpts {
|
|
35
47
|
kind?: string;
|
|
@@ -37,10 +49,30 @@ export interface SpawnOpts {
|
|
|
37
49
|
lifecycle?: Lifecycle;
|
|
38
50
|
id?: string;
|
|
39
51
|
}
|
|
52
|
+
/** Fabricate a broker-hosted node row directly — tmux-free, boot-free, NO
|
|
53
|
+
* subprocess. Builds a full NodeMeta (identity ∪ runtime) and calls createNode,
|
|
54
|
+
* which seeds BOTH halves in one statement (seedRow writes status/intent/pi_pid
|
|
55
|
+
* from the meta; writeMeta persists pi_session_id). The result is a row the
|
|
56
|
+
* daemon supervises by pid exactly as if a real broker had booted — so a test
|
|
57
|
+
* can drive superviseTick(now) liveness decisions against arbitrary liveness
|
|
58
|
+
* state (alive/dead pid, with/without session, any intent) with ZERO real boot.
|
|
59
|
+
* See foundation-spec §E.2. */
|
|
60
|
+
export interface FabricateOpts {
|
|
61
|
+
parent?: string | null;
|
|
62
|
+
kind?: string;
|
|
63
|
+
mode?: Mode;
|
|
64
|
+
lifecycle?: Lifecycle;
|
|
65
|
+
status?: NodeStatus;
|
|
66
|
+
intent?: ExitIntent;
|
|
67
|
+
pi_pid?: number | null;
|
|
68
|
+
pi_session_id?: string | null;
|
|
69
|
+
id?: string;
|
|
70
|
+
}
|
|
40
71
|
export interface Harness {
|
|
41
72
|
home: string;
|
|
42
73
|
session: string;
|
|
43
74
|
spawnRoot(task: string, o?: SpawnOpts): string;
|
|
75
|
+
fabricateBrokerNode(o?: FabricateOpts): string;
|
|
44
76
|
spawnChild(parentId: string, task: string, o?: SpawnOpts): Promise<string>;
|
|
45
77
|
cli(nodeId: string | null, args: string[]): CliResult;
|
|
46
78
|
spawnHeadlessChild(parentId: string, task: string, o?: SpawnOpts): Promise<string>;
|
|
@@ -84,4 +116,6 @@ export interface Harness {
|
|
|
84
116
|
}[];
|
|
85
117
|
dispose(): Promise<void>;
|
|
86
118
|
}
|
|
119
|
+
/** Thin wrapper: a headless-only harness (broker-hosted, paneless nodes). */
|
|
120
|
+
export declare function createHeadlessHarness(opts?: HarnessOpts): Promise<Harness>;
|
|
87
121
|
export declare function createHarness(opts?: HarnessOpts): Promise<Harness>;
|
|
@@ -103,8 +103,13 @@ async function waitFor(probe, opts = {}) {
|
|
|
103
103
|
await new Promise((r) => setTimeout(r, intervalMs));
|
|
104
104
|
}
|
|
105
105
|
}
|
|
106
|
+
/** Thin wrapper: a headless-only harness (broker-hosted, paneless nodes). */
|
|
107
|
+
export async function createHeadlessHarness(opts = {}) {
|
|
108
|
+
return createHarness({ ...opts, headless: true });
|
|
109
|
+
}
|
|
106
110
|
export async function createHarness(opts = {}) {
|
|
107
|
-
|
|
111
|
+
const headless = opts.headless === true;
|
|
112
|
+
if (!headless && !hasTmux())
|
|
108
113
|
throw new Error('createHarness: tmux not available');
|
|
109
114
|
const origHome = process.env['CRTR_HOME'];
|
|
110
115
|
const origPiBinary = process.env['CRTR_PI_BINARY'];
|
|
@@ -162,17 +167,23 @@ export async function createHarness(opts = {}) {
|
|
|
162
167
|
// a custom-socket server (`tmux -L foo`) would be invisible to it; this harness
|
|
163
168
|
// therefore assumes the default socket and only ever kill-sessions, never the
|
|
164
169
|
// server.
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
//
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
170
|
+
// HEADLESS: skip ALL eager tmux setup. No node in a headless run opens a
|
|
171
|
+
// window, so there is no pane to pre-seed an env into and no session to pre-
|
|
172
|
+
// create. (`node new --headless` may still lazily ensureSession() the isolated
|
|
173
|
+
// name for home_session — dispose kill-sessions it regardless.)
|
|
174
|
+
if (!headless) {
|
|
175
|
+
spawnSync('tmux', ['new-session', '-d', '-s', session, '-c', CROUTER, 'sleep 100000'], {
|
|
176
|
+
stdio: 'ignore',
|
|
177
|
+
});
|
|
178
|
+
// Put CRTR_PI_BINARY in the SESSION environment so EVERY pane spawned in this
|
|
179
|
+
// session inherits it — critically the fake-pi's OWN process, so when its real
|
|
180
|
+
// stophook fires reviveInPlace (respawn-pane -k on its own pane, the refresh-
|
|
181
|
+
// yield path) the in-process piCommand there substitutes the fake-pi too.
|
|
182
|
+
spawnSync('tmux', ['set-environment', '-t', session, 'CRTR_PI_BINARY', FAKE_PI_BINARY], {
|
|
183
|
+
stdio: 'ignore',
|
|
184
|
+
});
|
|
185
|
+
spawnSync('tmux', ['set-environment', '-t', session, 'CRTR_HOME', home], { stdio: 'ignore' });
|
|
186
|
+
}
|
|
176
187
|
const pidsToKill = new Set();
|
|
177
188
|
let nextRootSeq = 0;
|
|
178
189
|
// -- env for a subprocess CLI invocation -----------------------------------
|
|
@@ -286,6 +297,27 @@ export async function createHarness(opts = {}) {
|
|
|
286
297
|
closeDb();
|
|
287
298
|
return id;
|
|
288
299
|
},
|
|
300
|
+
fabricateBrokerNode(o = {}) {
|
|
301
|
+
const id = o.id ?? `brk-${process.pid}-${nextRootSeq++}`;
|
|
302
|
+
const meta = {
|
|
303
|
+
node_id: id,
|
|
304
|
+
name: id,
|
|
305
|
+
created: new Date().toISOString(),
|
|
306
|
+
cwd: CROUTER,
|
|
307
|
+
host_kind: 'broker',
|
|
308
|
+
kind: o.kind ?? 'developer',
|
|
309
|
+
mode: o.mode ?? 'base',
|
|
310
|
+
lifecycle: o.lifecycle ?? 'terminal',
|
|
311
|
+
parent: o.parent ?? null,
|
|
312
|
+
pi_session_id: o.pi_session_id ?? null,
|
|
313
|
+
status: o.status ?? 'active',
|
|
314
|
+
intent: o.intent ?? null,
|
|
315
|
+
pi_pid: o.pi_pid ?? null,
|
|
316
|
+
};
|
|
317
|
+
createNode(meta);
|
|
318
|
+
closeDb();
|
|
319
|
+
return id;
|
|
320
|
+
},
|
|
289
321
|
async spawnChild(parentId, task, o = {}) {
|
|
290
322
|
const before = new Set(nodeDirs());
|
|
291
323
|
const args = ['node', 'new', task, '--parent', parentId, '--cwd', CROUTER];
|
|
@@ -479,6 +511,32 @@ export async function createHarness(opts = {}) {
|
|
|
479
511
|
},
|
|
480
512
|
async dispose() {
|
|
481
513
|
spawnSync('tmux', ['kill-session', '-t', session], { stdio: 'ignore' });
|
|
514
|
+
// Best-effort: a superviseTick-triggered revive of a fabricated node fires
|
|
515
|
+
// a DETACHED throwaway broker (it loads the real SDK; the rmSync of home
|
|
516
|
+
// below makes a mid-boot one self-terminate when its files vanish). Kill
|
|
517
|
+
// any that recorded a pid first — scan every node's row pid + recorded
|
|
518
|
+
// boot pids and add them to the kill set.
|
|
519
|
+
closeDb();
|
|
520
|
+
for (const d of nodeDirs()) {
|
|
521
|
+
try {
|
|
522
|
+
const n = getNode(d);
|
|
523
|
+
if (n?.pi_pid != null)
|
|
524
|
+
pidsToKill.add(n.pi_pid);
|
|
525
|
+
}
|
|
526
|
+
catch {
|
|
527
|
+
/* row gone — fine */
|
|
528
|
+
}
|
|
529
|
+
for (const line of readLines(join(nodeDir(d), 'fake-pi.boots.jsonl'))) {
|
|
530
|
+
try {
|
|
531
|
+
const p = JSON.parse(line).pid;
|
|
532
|
+
if (typeof p === 'number')
|
|
533
|
+
pidsToKill.add(p);
|
|
534
|
+
}
|
|
535
|
+
catch {
|
|
536
|
+
/* skip */
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
}
|
|
482
540
|
for (const p of pidsToKill) {
|
|
483
541
|
try {
|
|
484
542
|
process.kill(p, 'SIGKILL');
|