@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
package/dist/core/__tests__/{broker-attach-limits.test.js → full/broker-attach-limits.test.js}
RENAMED
|
@@ -1,30 +1,61 @@
|
|
|
1
|
-
// Run with: node --import tsx/esm --test src/core/__tests__/broker-attach-limits.test.ts
|
|
1
|
+
// Run with: node --import tsx/esm --test src/core/__tests__/full/broker-attach-limits.test.ts
|
|
2
|
+
//
|
|
3
|
+
// FULL TIER (real-boot-bound): tmux-free, but it boots a REAL broker process
|
|
4
|
+
// (~5s pi-SDK load), so it lives in full/ (CI), not the fast local loop.
|
|
2
5
|
//
|
|
3
6
|
// T8 — the `crtr attach` acceptance gate, gates G4/G7/G8/G9 (controller
|
|
4
7
|
// arbitration, decoder overflow caps, backpressure shedding, and the load-bearing
|
|
5
|
-
// ONE-WRITER proof). Split out of broker-lifecycle.test.ts
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
8
|
+
// ONE-WRITER proof). Split out of broker-lifecycle.test.ts; the gates are the
|
|
9
|
+
// original acceptance gate, unchanged.
|
|
10
|
+
//
|
|
11
|
+
// 3-PART HEADER (headless-retarget):
|
|
12
|
+
// (1) BUG IT LOCKS — the broker's client arbitration + resource caps + one-writer
|
|
13
|
+
// invariant: a 2nd client is an admitted read-only observer and an observer
|
|
14
|
+
// prompt is rejected not_controller while BOTH clients still receive the relay
|
|
15
|
+
// (G4); an oversized client line is cap-and-dropped (frame_overflow) without
|
|
16
|
+
// OOMing the broker (G7); a stalled non-reading viewer is shed at the 32 MiB
|
|
17
|
+
// backpressure HWM while the broker + fast viewers are unaffected (G8); and
|
|
18
|
+
// across attach→detach→reattach the broker/engine pid is STABLE with no second
|
|
19
|
+
// engine ever spawned and the viewer holding ONLY the socket, never the
|
|
20
|
+
// session .jsonl (G9). Regression = two controllers / an observer driving the
|
|
21
|
+
// engine / an unbounded decoder or per-viewer queue (broker OOM) / a viewer
|
|
22
|
+
// that forks a second writer onto the same .jsonl — the corruption headless
|
|
23
|
+
// one-writer exists to prevent.
|
|
24
|
+
// (2) WHY BROKER/SOCKET-LEVEL, NOT PANE/WINDOW — arbitration, the bounded decoder,
|
|
25
|
+
// the backpressure HWM, and one-writer all live in the broker PROCESS and its
|
|
26
|
+
// view.sock codec. They are exercised over a pure unix socket (the production
|
|
27
|
+
// ViewSocketClient + raw node:net peers), with the engine hosted IN-PROCESS by
|
|
28
|
+
// the broker, so engine pid == broker pid == node.pi_pid == boot.pid. No tmux
|
|
29
|
+
// pane/window is involved, so the lock holds headlessly (createHeadlessHarness,
|
|
30
|
+
// no tmux). It needs a REAL broker process (not model state), so this file
|
|
31
|
+
// keeps ONE real boot, shared across all four gates.
|
|
32
|
+
// (3) HOW THE HEADLESS DRIVE STILL FAILS ON REGRESSION — each gate drives the real
|
|
33
|
+
// broker over the real socket and asserts on its observable response: the
|
|
34
|
+
// welcome role + relay frames (G4), the broker's frame-overflow drop + pid
|
|
35
|
+
// survival (G7), the broker's HWM-shed log line + pid survival (G8), and pid /
|
|
36
|
+
// bootCount stability + the .jsonl holder set (G9). A regressed guard fails
|
|
37
|
+
// these exactly as it would under tmux; nothing here depended on a pane.
|
|
38
|
+
//
|
|
39
|
+
// ONE shared broker is booted in before() and reused across all four gates — 1 real
|
|
40
|
+
// boot total, not 4. Gates whose first attach must hold control use
|
|
41
|
+
// attachUntil(controller) so a prior gate's controller-detach handoff settles
|
|
42
|
+
// deterministically before they drive.
|
|
13
43
|
import { test, before, after, afterEach } from 'node:test';
|
|
14
44
|
import assert from 'node:assert/strict';
|
|
15
45
|
import { join } from 'node:path';
|
|
16
|
-
import {
|
|
17
|
-
import { isPidAlive } from '
|
|
18
|
-
import { createAttachKit, delay, tok, frameHas, brokerLogText, lsofHolders, } from '
|
|
46
|
+
import { createHeadlessHarness } from '../helpers/harness.js';
|
|
47
|
+
import { isPidAlive } from '../../canvas/pid.js';
|
|
48
|
+
import { createAttachKit, delay, tok, frameHas, brokerLogText, lsofHolders, } from '../helpers/broker-clients.js';
|
|
19
49
|
let h;
|
|
20
|
-
let
|
|
50
|
+
let id; // ONE shared broker, reused across G4/G7/G8/G9 (1 real boot, not 4)
|
|
21
51
|
const kit = createAttachKit(() => h);
|
|
22
52
|
const { attach, attachUntil, connectRaw } = kit;
|
|
53
|
+
// Admit a controller, waiting out any prior gate's controller-detach handoff.
|
|
54
|
+
const ctrl = (cid) => attachUntil(id, 'controller', cid, (a) => a.welcome.role === 'controller', `${cid} admitted controller`);
|
|
23
55
|
before(async () => {
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
root = h.spawnRoot('broker-attach-limits suite root');
|
|
56
|
+
h = await createHeadlessHarness({ sessionPrefix: 'crtr-brklim' });
|
|
57
|
+
const root = h.spawnRoot('broker-attach-limits suite root');
|
|
58
|
+
id = await h.spawnHeadlessChild(root, 'headless worker — attach-limits gates');
|
|
28
59
|
});
|
|
29
60
|
after(async () => {
|
|
30
61
|
if (h !== undefined)
|
|
@@ -33,16 +64,15 @@ after(async () => {
|
|
|
33
64
|
afterEach(() => {
|
|
34
65
|
kit.closeAll();
|
|
35
66
|
});
|
|
36
|
-
const brokerPid = (
|
|
67
|
+
const brokerPid = () => h.node(id).pi_pid;
|
|
37
68
|
// ---------------------------------------------------------------------------
|
|
38
69
|
// G4 — arbitration + observer read. Guards: 2nd client is admitted observer, an
|
|
39
70
|
// observer prompt is rejected not_controller, BOTH clients receive the relay.
|
|
40
71
|
// Failure mode: two controllers, or an observer driving the engine, or fan-out
|
|
41
72
|
// that misses a viewer.
|
|
42
73
|
// ---------------------------------------------------------------------------
|
|
43
|
-
test('G4 — second client is observer; observer prompt → error{not_controller}; both receive the stream',
|
|
44
|
-
const
|
|
45
|
-
const c1 = await attach(id, 'controller', 'g4-ctrl');
|
|
74
|
+
test('G4 — second client is observer; observer prompt → error{not_controller}; both receive the stream', async () => {
|
|
75
|
+
const c1 = await ctrl('g4-ctrl');
|
|
46
76
|
assert.equal(c1.welcome.role, 'controller', 'first client holds control');
|
|
47
77
|
const c2 = await attach(id, 'controller', 'g4-second'); // requests control; held → observer
|
|
48
78
|
assert.equal(c2.welcome.role, 'observer', 'second client is admitted read-only observer (first-attach-wins)');
|
|
@@ -59,9 +89,8 @@ test('G4 — second client is observer; observer prompt → error{not_controller
|
|
|
59
89
|
// cap-and-dropped; the broker survives and other clients are unaffected. Failure
|
|
60
90
|
// mode: an unbounded decoder buffer growing the broker to OOM.
|
|
61
91
|
// ---------------------------------------------------------------------------
|
|
62
|
-
test('G7 — an oversized client line is dropped (frame_overflow), the broker survives, other clients unaffected',
|
|
63
|
-
const
|
|
64
|
-
const pid = brokerPid(id);
|
|
92
|
+
test('G7 — an oversized client line is dropped (frame_overflow), the broker survives, other clients unaffected', async () => {
|
|
93
|
+
const pid = brokerPid();
|
|
65
94
|
const boots = h.bootCount(id);
|
|
66
95
|
const survivor = await attach(id, 'observer', 'g7-survivor');
|
|
67
96
|
// 26 MiB with NO newline > BROKER_READ_CAPS.maxLineBytes (24 MiB) → the bounded
|
|
@@ -75,7 +104,7 @@ test('G7 — an oversized client line is dropped (frame_overflow), the broker su
|
|
|
75
104
|
h.fakeCmd(id, { cmd: 'stream', text: token });
|
|
76
105
|
await survivor.waitFrame((f) => f.type === 'agent_end' && frameHas(f, token), 'G7 survivor still receives live frames');
|
|
77
106
|
assert.equal(isPidAlive(pid), true, 'G7: the broker survived the overflow');
|
|
78
|
-
assert.equal(brokerPid(
|
|
107
|
+
assert.equal(brokerPid(), pid, 'G7: broker pid unchanged');
|
|
79
108
|
assert.equal(h.bootCount(id), boots, 'G7: no reboot');
|
|
80
109
|
});
|
|
81
110
|
// ---------------------------------------------------------------------------
|
|
@@ -84,9 +113,8 @@ test('G7 — an oversized client line is dropped (frame_overflow), the broker su
|
|
|
84
113
|
// Failure mode: an indefinitely-growing per-viewer queue (broker OOM) or a slow
|
|
85
114
|
// viewer back-pressuring the shared engine.
|
|
86
115
|
// ---------------------------------------------------------------------------
|
|
87
|
-
test('G8 — a stalled viewer is dropped at the backpressure HWM; the broker + fast viewers are unaffected',
|
|
88
|
-
const
|
|
89
|
-
const pid = brokerPid(id);
|
|
116
|
+
test('G8 — a stalled viewer is dropped at the backpressure HWM; the broker + fast viewers are unaffected', async () => {
|
|
117
|
+
const pid = brokerPid();
|
|
90
118
|
const fast = await attach(id, 'observer', 'g8-fast'); // reads normally
|
|
91
119
|
// A helloed viewer that NEVER reads (paused socket). It must be in the broadcast
|
|
92
120
|
// set before the flood; hello, then a short beat for the broker to process it.
|
|
@@ -108,7 +136,7 @@ test('G8 — a stalled viewer is dropped at the backpressure HWM; the broker + f
|
|
|
108
136
|
// proving only the stalled viewer was shed, while the fast one was unaffected.
|
|
109
137
|
await fast.waitFrame((f) => f.type === 'agent_end' && frameHas(f, token), 'G8 fast viewer received the whole stream', 30_000);
|
|
110
138
|
assert.equal(isPidAlive(pid), true, 'G8: the broker survived the slow-viewer flood');
|
|
111
|
-
assert.equal(brokerPid(
|
|
139
|
+
assert.equal(brokerPid(), pid, 'G8: broker pid unchanged (the engine was not back-pressured into a restart)');
|
|
112
140
|
});
|
|
113
141
|
// ---------------------------------------------------------------------------
|
|
114
142
|
// G9 — the load-bearing ONE-WRITER assertion. Across attach→detach→reattach the
|
|
@@ -117,18 +145,17 @@ test('G8 — a stalled viewer is dropped at the backpressure HWM; the broker + f
|
|
|
117
145
|
// Failure mode: a viewer that spawns/forks a second engine or opens the session
|
|
118
146
|
// .jsonl — the corruption the headless design exists to prevent.
|
|
119
147
|
// ---------------------------------------------------------------------------
|
|
120
|
-
test('G9 — one-writer: broker/engine pid stable across attach→detach→reattach; no second engine; viewer holds only the socket',
|
|
121
|
-
const
|
|
122
|
-
const pid0 = brokerPid(id);
|
|
148
|
+
test('G9 — one-writer: broker/engine pid stable across attach→detach→reattach; no second engine; viewer holds only the socket', async () => {
|
|
149
|
+
const pid0 = brokerPid();
|
|
123
150
|
const boots0 = h.bootCount(id);
|
|
124
|
-
const c1 = await
|
|
151
|
+
const c1 = await ctrl('g9-a');
|
|
125
152
|
const a = tok('G9-A');
|
|
126
153
|
c1.send({ type: 'prompt', text: a });
|
|
127
154
|
await c1.waitFrame((f) => f.type === 'agent_end' && frameHas(f, a), 'G9 first turn relayed');
|
|
128
155
|
c1.send({ type: 'bye' });
|
|
129
156
|
c1.close();
|
|
130
157
|
await delay(300);
|
|
131
|
-
assert.equal(brokerPid(
|
|
158
|
+
assert.equal(brokerPid(), pid0, 'G9: broker/engine pid unchanged after detach');
|
|
132
159
|
assert.equal(isPidAlive(pid0), true, 'G9: the one engine is still alive after detach');
|
|
133
160
|
assert.equal(h.bootCount(id), boots0, 'G9: no second engine spawned across detach');
|
|
134
161
|
// attachUntil (not a fixed sleep) synchronizes on the controller handoff: the
|
|
@@ -139,7 +166,7 @@ test('G9 — one-writer: broker/engine pid stable across attach→detach→reatt
|
|
|
139
166
|
const b = tok('G9-B');
|
|
140
167
|
c2.send({ type: 'prompt', text: b });
|
|
141
168
|
await c2.waitFrame((f) => f.type === 'agent_end' && frameHas(f, b), 'G9 reattached controller drives the SAME engine');
|
|
142
|
-
assert.equal(brokerPid(
|
|
169
|
+
assert.equal(brokerPid(), pid0, 'G9: STILL the same broker/engine pid after reattach (one writer, never two)');
|
|
143
170
|
assert.equal(isPidAlive(pid0), true, 'G9: the single engine is alive across the full cycle');
|
|
144
171
|
assert.equal(h.bootCount(id), boots0, 'G9: exactly one engine boot across attach→detach→reattach');
|
|
145
172
|
// fd-check (best-effort): the viewer host (this test process) must NOT hold the
|
package/dist/core/__tests__/{broker-attach-stream.test.js → full/broker-attach-stream.test.js}
RENAMED
|
@@ -1,29 +1,56 @@
|
|
|
1
|
-
// Run with: node --import tsx/esm --test src/core/__tests__/broker-attach-stream.test.ts
|
|
1
|
+
// Run with: node --import tsx/esm --test src/core/__tests__/full/broker-attach-stream.test.ts
|
|
2
|
+
//
|
|
3
|
+
// FULL TIER (real-boot-bound): tmux-free, but it boots a REAL broker process
|
|
4
|
+
// (~5s pi-SDK load), so it lives in full/ (CI), not the fast local loop.
|
|
2
5
|
//
|
|
3
6
|
// T8 — the `crtr attach` acceptance gate, gates G1/G1b/G2/G3 (controller drive,
|
|
4
7
|
// relay/coalescing, detach survival, catch-up snapshot). Split out of
|
|
5
|
-
// broker-lifecycle.test.ts
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
8
|
+
// broker-lifecycle.test.ts; the gates are the original acceptance gate, unchanged.
|
|
9
|
+
//
|
|
10
|
+
// 3-PART HEADER (headless-retarget):
|
|
11
|
+
// (1) BUG IT LOCKS — the broker's client fan-out: a controller `prompt` must run
|
|
12
|
+
// the engine and the streamed AgentSessionEvents must relay VERBATIM and IN
|
|
13
|
+
// ORDER to every viewer (G1); message_update coalescing must flush BEFORE any
|
|
14
|
+
// other event so a stale update can never land after message_end, and the
|
|
15
|
+
// last coalesced update must not be dropped (G1b, the F2 typing-lag fix);
|
|
16
|
+
// detach (bye+close) must drop ONE listener, never the engine (G2); and
|
|
17
|
+
// messages produced with no viewer attached must be in welcome.snapshot on
|
|
18
|
+
// reattach (G3). Regression = a viewer that sees nothing / sees stale text /
|
|
19
|
+
// kills the engine on detach / reattaches blind to history.
|
|
20
|
+
// (2) WHY BROKER/SOCKET-LEVEL, NOT PANE/WINDOW — the relay is a pure unix-socket
|
|
21
|
+
// path: ViewSocketClient ⇄ broker view.sock ⇄ in-process fake engine's
|
|
22
|
+
// subscribe channel. No tmux pane, window, or pi session read is involved, so
|
|
23
|
+
// the lock holds headlessly (createHeadlessHarness, no tmux). It needs a REAL
|
|
24
|
+
// broker PROCESS (not just model state) because it proves the cross-process
|
|
25
|
+
// frame codec + fan-out + coalescer, which is why this file keeps ONE real
|
|
26
|
+
// boot — shared across all four gates to stay off the fast-tier long pole.
|
|
27
|
+
// (3) HOW THE HEADLESS DRIVE STILL FAILS ON REGRESSION — each gate attaches the
|
|
28
|
+
// PRODUCTION ViewSocketClient over the real socket and asserts on the frames
|
|
29
|
+
// the real broker relays (order, types, snapshot, pid stability). A broken
|
|
30
|
+
// relay/coalescer/detach path fails the frame assertions exactly as it would
|
|
31
|
+
// under tmux; nothing here depended on a pane.
|
|
32
|
+
//
|
|
33
|
+
// The engine is hosted IN-PROCESS by the broker, so engine pid == broker pid ==
|
|
34
|
+
// node.pi_pid == boot.pid; "engine pid unchanged" == broker pid unchanged + no new
|
|
35
|
+
// boot. ONE shared broker is booted in before() and reused across all four gates
|
|
36
|
+
// (each just attaches fresh clients) — 1 real boot total, not 4. A gate whose
|
|
37
|
+
// first attach must hold control uses attachUntil(controller) so the prior gate's
|
|
38
|
+
// controller-detach handoff settles deterministically before it drives.
|
|
13
39
|
import { test, before, after, afterEach } from 'node:test';
|
|
14
40
|
import assert from 'node:assert/strict';
|
|
15
|
-
import {
|
|
16
|
-
import { isPidAlive } from '
|
|
17
|
-
import { createAttachKit, delay, tok, frameHas } from '
|
|
41
|
+
import { createHeadlessHarness } from '../helpers/harness.js';
|
|
42
|
+
import { isPidAlive } from '../../canvas/pid.js';
|
|
43
|
+
import { createAttachKit, delay, tok, frameHas } from '../helpers/broker-clients.js';
|
|
18
44
|
let h;
|
|
19
|
-
let
|
|
45
|
+
let id; // ONE shared broker, reused across G1/G1b/G2/G3 (1 real boot, not 4)
|
|
20
46
|
const kit = createAttachKit(() => h);
|
|
21
47
|
const { attach, attachUntil } = kit;
|
|
48
|
+
// Admit a controller, waiting out any prior gate's controller-detach handoff.
|
|
49
|
+
const ctrl = (cid) => attachUntil(id, 'controller', cid, (a) => a.welcome.role === 'controller', `${cid} admitted controller`);
|
|
22
50
|
before(async () => {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
root = h.spawnRoot('broker-attach-stream suite root');
|
|
51
|
+
h = await createHeadlessHarness({ sessionPrefix: 'crtr-brkstrm' });
|
|
52
|
+
const root = h.spawnRoot('broker-attach-stream suite root');
|
|
53
|
+
id = await h.spawnHeadlessChild(root, 'headless worker — attach-stream gates');
|
|
27
54
|
});
|
|
28
55
|
after(async () => {
|
|
29
56
|
if (h !== undefined)
|
|
@@ -32,15 +59,14 @@ after(async () => {
|
|
|
32
59
|
afterEach(() => {
|
|
33
60
|
kit.closeAll();
|
|
34
61
|
});
|
|
35
|
-
const brokerPid = (
|
|
62
|
+
const brokerPid = () => h.node(id).pi_pid;
|
|
36
63
|
// ---------------------------------------------------------------------------
|
|
37
64
|
// G1 — controller drive + live relay. Guards: a controller's `prompt` runs the
|
|
38
65
|
// engine AND the streaming AgentSessionEvents are fanned out to the client
|
|
39
66
|
// VERBATIM. Failure mode: a broken relay/fan-out (viewer sees nothing).
|
|
40
67
|
// ---------------------------------------------------------------------------
|
|
41
|
-
test('G1 — controller prompt runs the engine and the streamed AgentSessionEvents relay to the client',
|
|
42
|
-
const
|
|
43
|
-
const c = await attach(id, 'controller', 'g1-ctrl');
|
|
68
|
+
test('G1 — controller prompt runs the engine and the streamed AgentSessionEvents relay to the client', async () => {
|
|
69
|
+
const c = await ctrl('g1-ctrl');
|
|
44
70
|
assert.equal(c.welcome.role, 'controller', 'first controller is admitted as controller');
|
|
45
71
|
const token = tok('G1-PROMPT');
|
|
46
72
|
c.send({ type: 'prompt', text: token });
|
|
@@ -62,9 +88,8 @@ test('G1 — controller prompt runs the engine and the streamed AgentSessionEven
|
|
|
62
88
|
// be silently dropped at end-of-turn. Failure modes: a flush ordered after the
|
|
63
89
|
// non-update event, or a pending update discarded when message_end wins the race.
|
|
64
90
|
// ---------------------------------------------------------------------------
|
|
65
|
-
test('G1b — coalesced message_update never arrives after message_end; updates still relayed',
|
|
66
|
-
const
|
|
67
|
-
const c = await attach(id, 'controller', 'g1b-ctrl');
|
|
91
|
+
test('G1b — coalesced message_update never arrives after message_end; updates still relayed', async () => {
|
|
92
|
+
const c = await ctrl('g1b-ctrl');
|
|
68
93
|
const token = tok('G1B');
|
|
69
94
|
// 12 updates at the fake engine's setImmediate pace — far faster than the 75ms
|
|
70
95
|
// coalesce window, so coalescing genuinely engages (fewer relayed than emitted).
|
|
@@ -86,11 +111,10 @@ test('G1b — coalesced message_update never arrives after message_end; updates
|
|
|
86
111
|
// G2 — detach leaves the engine running. Guards: `bye`/close drops ONE listener,
|
|
87
112
|
// never the engine. Failure mode: a detach that disposes the broker/engine.
|
|
88
113
|
// ---------------------------------------------------------------------------
|
|
89
|
-
test('G2 — detach (bye+close) leaves the broker alive + the engine still emitting; engine pid unchanged',
|
|
90
|
-
const
|
|
91
|
-
const pid = brokerPid(id);
|
|
114
|
+
test('G2 — detach (bye+close) leaves the broker alive + the engine still emitting; engine pid unchanged', async () => {
|
|
115
|
+
const pid = brokerPid();
|
|
92
116
|
const boots = h.bootCount(id);
|
|
93
|
-
const c = await
|
|
117
|
+
const c = await ctrl('g2-ctrl');
|
|
94
118
|
const p = tok('G2-PROMPT');
|
|
95
119
|
c.send({ type: 'prompt', text: p });
|
|
96
120
|
await c.waitFrame((f) => f.type === 'agent_end' && frameHas(f, p), 'G2 turn relayed before detach');
|
|
@@ -98,7 +122,7 @@ test('G2 — detach (bye+close) leaves the broker alive + the engine still emitt
|
|
|
98
122
|
c.close();
|
|
99
123
|
await delay(300); // let the broker process the 'close'
|
|
100
124
|
assert.equal(isPidAlive(pid), true, 'G2: broker still alive after detach');
|
|
101
|
-
assert.equal(brokerPid(
|
|
125
|
+
assert.equal(brokerPid(), pid, 'G2: engine (== broker) pid UNCHANGED across detach');
|
|
102
126
|
assert.equal(h.bootCount(id), boots, 'G2: no reboot — no second engine spawned');
|
|
103
127
|
// Still emitting: drive a turn with no client, then a fresh observer's snapshot
|
|
104
128
|
// carries it (the engine kept running and processing after the detach).
|
|
@@ -113,8 +137,7 @@ test('G2 — detach (bye+close) leaves the broker alive + the engine still emitt
|
|
|
113
137
|
// in welcome.snapshot on reattach, and live events resume. Failure mode: a viewer
|
|
114
138
|
// that reattaches blind to history / gets no further events.
|
|
115
139
|
// ---------------------------------------------------------------------------
|
|
116
|
-
test('G3 — messages produced while detached appear in welcome.snapshot on reattach; live events resume',
|
|
117
|
-
const id = await h.spawnHeadlessChild(root, 'headless worker — G3');
|
|
140
|
+
test('G3 — messages produced while detached appear in welcome.snapshot on reattach; live events resume', async () => {
|
|
118
141
|
const past = tok('G3-DETACHED');
|
|
119
142
|
h.fakeCmd(id, { cmd: 'stream', text: past }); // produced with zero viewers attached
|
|
120
143
|
const o = await attachUntil(id, 'observer', 'g3-obs', (a) => JSON.stringify(a.welcome.snapshot.messages).includes(past), 'G3 detached message in snapshot');
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
// Run with: node --import tsx/esm --test src/core/__tests__/full/broker-crash-teardown.test.ts
|
|
2
|
+
//
|
|
3
|
+
// HEADLESS RETARGET (foundation-spec §C.3 + §E). Broker crash / teardown / boot-
|
|
4
|
+
// failure — acceptance items 4–6 of the headless-broker migration plus the
|
|
5
|
+
// M-1/M-2 boot-failure regression — tmux-free (no hasTmux() gate). FULL TIER: the
|
|
6
|
+
// crash + teardown items genuinely exercise a real broker PROCESS so they share
|
|
7
|
+
// ONE spawn + ONE grace-revive (2 boots, ~5s each), which is why the file lives
|
|
8
|
+
// in full/ (CI), not the fast local loop. (The boot-failure lock is PURE daemon
|
|
9
|
+
// logic, fabricated with ZERO boots.)
|
|
10
|
+
//
|
|
11
|
+
// (1) BUG LOCKED — two locks:
|
|
12
|
+
// • items 4–6: a CRASHED broker (pid killed out from under the daemon) must
|
|
13
|
+
// grace-revive RESUMING the saved .jsonl, ONE-WRITER (the crashed pid is
|
|
14
|
+
// dead BEFORE the revive launches and never resurrects; the revived pid is
|
|
15
|
+
// distinct); and a clean `node close` tears the broker down (shutdown frame
|
|
16
|
+
// → exit, socket unlinked, status canceled, daemon leaves it).
|
|
17
|
+
// • M-1/M-2: a broker that DIES before session_start records NO pid and NO
|
|
18
|
+
// session EVER. Read pid==null unconditionally as "still booting" and the
|
|
19
|
+
// node strands 'active' with no engine FOREVER and its parent waits on a
|
|
20
|
+
// dead child. The daemon must instead, after REVIVE_GRACE_MS with still no
|
|
21
|
+
// pid AND no session, crash it (→ dead) and surface a boot-failure push up
|
|
22
|
+
// the spine (handleBrokerLiveness, crtrd.ts).
|
|
23
|
+
//
|
|
24
|
+
// (2) WHY MODEL-LEVEL vs A REAL BOOT — the M-1/M-2 grace-surface is pure daemon
|
|
25
|
+
// logic: pid==null && pi_session_id==null past the boot grace → transition
|
|
26
|
+
// ('crash') + surfaceBootFailure → pushUrgent fanned to the active parent.
|
|
27
|
+
// A broker is supervised purely by its row (pi_pid/pi_session_id), so this is
|
|
28
|
+
// fabricated DIRECTLY — a parent-subscribed broker row with NO pid and NO
|
|
29
|
+
// session, ticked across the grace boundary — with ZERO real boots and no
|
|
30
|
+
// crashing subprocess at all. The crash/teardown items, by contrast, ARE the
|
|
31
|
+
// broker process under test (a real pid to kill, a real socket to unlink), so
|
|
32
|
+
// they pay one real spawn + the grace-revive boot.
|
|
33
|
+
//
|
|
34
|
+
// (3) HOW THE HEADLESS DRIVE STILL FAILS IF THE BUG REGRESSES — for crash/teardown,
|
|
35
|
+
// an early revive (no grace) would land a second writer in the respawn gap, or
|
|
36
|
+
// a broken teardown would leave the socket / pid alive — the one-writer / socket
|
|
37
|
+
// / canceled asserts go RED. For M-1/M-2, remove the grace-surface branch (read
|
|
38
|
+
// pid==null as "still booting" forever) and the fabricated node NEVER goes
|
|
39
|
+
// 'dead' and the parent NEVER receives the notice → the boot-failure asserts go
|
|
40
|
+
// RED. Verified by reverting that branch (see bug-injection report).
|
|
41
|
+
import { test, before, after } from 'node:test';
|
|
42
|
+
import assert from 'node:assert/strict';
|
|
43
|
+
import { existsSync } from 'node:fs';
|
|
44
|
+
import { createHarness } from '../helpers/harness.js';
|
|
45
|
+
import { subscribe } from '../../canvas/canvas.js';
|
|
46
|
+
import { isPidAlive } from '../../canvas/pid.js';
|
|
47
|
+
// crtrd.ts module const (not exported): the fresh-pi-boot grace window the daemon
|
|
48
|
+
// waits before grace-reviving / boot-failing a pi observed dead. Reference:
|
|
49
|
+
// crtrd.ts `REVIVE_GRACE_MS = 20_000`.
|
|
50
|
+
const REVIVE_GRACE_MS = 20_000;
|
|
51
|
+
let h;
|
|
52
|
+
let root;
|
|
53
|
+
before(async () => {
|
|
54
|
+
h = await createHarness({ headless: true, sessionPrefix: 'crtr-brkcrash' });
|
|
55
|
+
root = h.spawnRoot('broker-crash suite root');
|
|
56
|
+
});
|
|
57
|
+
after(async () => {
|
|
58
|
+
if (h !== undefined)
|
|
59
|
+
await h.dispose();
|
|
60
|
+
});
|
|
61
|
+
// ===========================================================================
|
|
62
|
+
// Items 4 + 5 + 6 — one real broker lifecycle, ONE spawn + ONE grace-revive:
|
|
63
|
+
// crash → grace-revive RESUME (one-writer) → close → clean teardown.
|
|
64
|
+
// ===========================================================================
|
|
65
|
+
test('CRASH → grace-revive RESUME (one-writer), then clean teardown on close', async () => {
|
|
66
|
+
const id = await h.spawnHeadlessChild(root, 'headless worker — crash+teardown');
|
|
67
|
+
const oldPid = h.node(id).pi_pid;
|
|
68
|
+
assert.equal(isPidAlive(oldPid), true, 'broker alive before the crash');
|
|
69
|
+
assert.equal(h.node(id).intent ?? null, null, 'fresh broker has a null intent (not refresh/idle-release)');
|
|
70
|
+
const boots = h.bootCount(id);
|
|
71
|
+
// --- Items 4 + 6: kill the broker out from under the daemon (a crash). Drive a
|
|
72
|
+
// FIXED clock so the grace window is exercised deterministically. ---
|
|
73
|
+
process.kill(oldPid, 'SIGKILL');
|
|
74
|
+
await h.waitFor(() => !isPidAlive(oldPid), { label: 'crashed broker pid is dead' });
|
|
75
|
+
// ONE-WRITER: the old engine pid is dead BEFORE any revive can launch.
|
|
76
|
+
assert.equal(isPidAlive(oldPid), false, 'crashed pid dead before the daemon revives');
|
|
77
|
+
const NOW = 5_000_000;
|
|
78
|
+
await h.tick(NOW); // pid dead, intent null → handleBrokerLiveness → handleLiveWindow marks pending
|
|
79
|
+
assert.equal(h.bootCount(id), boots, 'inside the grace window → NOT yet revived');
|
|
80
|
+
await h.tick(NOW + REVIVE_GRACE_MS + 1); // grace elapsed → reviveNode(resume:true)
|
|
81
|
+
const boot2 = await h.awaitBoot(id, { minCount: boots + 1 });
|
|
82
|
+
assert.equal(boot2.resuming, true, 'grace-revive RESUMES the saved .jsonl');
|
|
83
|
+
const newPid = h.node(id).pi_pid;
|
|
84
|
+
assert.ok(newPid != null && isPidAlive(newPid), 'the revived broker pid is alive');
|
|
85
|
+
assert.notEqual(newPid, oldPid, 'the revived pid is distinct from the crashed one');
|
|
86
|
+
assert.equal(isPidAlive(oldPid), false, 'one-writer: the crashed pid never resurrected');
|
|
87
|
+
// --- Item 5: clean teardown of the (revived, live) broker. node close marks
|
|
88
|
+
// canceled BEFORE teardown (crash-safe order), then hostFor(broker).teardown
|
|
89
|
+
// sends the `shutdown` frame → the broker disposes + exits, socket unlinked. ---
|
|
90
|
+
const sock = h.brokerSock(id);
|
|
91
|
+
await h.waitFor(() => existsSync(sock), { label: 'revived broker created its view.sock' });
|
|
92
|
+
const res = h.cli(root, ['node', 'close', '--node', id]);
|
|
93
|
+
assert.equal(res.code, 0, `node close should exit 0\n--stderr--\n${res.stderr}`);
|
|
94
|
+
await h.waitFor(() => !isPidAlive(newPid), { label: 'broker process exited on shutdown frame' });
|
|
95
|
+
await h.waitFor(() => !existsSync(sock), { label: 'broker unlinked its socket on exit' });
|
|
96
|
+
assert.equal(h.status(id), 'canceled', 'the closed node is canceled');
|
|
97
|
+
// listNodes only surfaces active|idle, so a canceled broker is never supervised.
|
|
98
|
+
await h.tick();
|
|
99
|
+
assert.equal(h.status(id), 'canceled', 'still canceled after a daemon tick');
|
|
100
|
+
assert.equal(isPidAlive(newPid), false, 'the broker stays dead — never revived');
|
|
101
|
+
});
|
|
102
|
+
// ===========================================================================
|
|
103
|
+
// M-1 / M-2 — a broker that DIES before session_start (NO pid, NO session EVER)
|
|
104
|
+
// must be grace-surfaced as a boot failure, not stranded 'active' forever.
|
|
105
|
+
// PURE MODEL DRIVE: fabricate the exact strand state directly + a parent
|
|
106
|
+
// subscription, tick across the boot grace, assert crash + the up-spine push.
|
|
107
|
+
// ZERO real boots — no crashing subprocess needed.
|
|
108
|
+
// ===========================================================================
|
|
109
|
+
test('boot failure — a broker that never records a pid/session is grace-surfaced, not stranded (review M-1/M-2)', async () => {
|
|
110
|
+
// The exact "never booted" strand: status=active, NO pid, NO session ever
|
|
111
|
+
// (pi_session_id null routes the boot-failure branch, NOT relaunch-in-flight).
|
|
112
|
+
const id = h.fabricateBrokerNode({
|
|
113
|
+
parent: root,
|
|
114
|
+
status: 'active',
|
|
115
|
+
intent: null,
|
|
116
|
+
pi_pid: null,
|
|
117
|
+
pi_session_id: null,
|
|
118
|
+
});
|
|
119
|
+
// The parent is an ACTIVE subscriber of the child (what spawn auto-wires), so
|
|
120
|
+
// surfaceBootFailure's pushUrgent reaches it on its inbox.
|
|
121
|
+
subscribe(root, id, true);
|
|
122
|
+
{
|
|
123
|
+
const n0 = h.node(id);
|
|
124
|
+
assert.equal(n0.host_kind, 'broker', 'broker-hosted (paneless)');
|
|
125
|
+
assert.equal(n0.pi_pid ?? null, null, 'no broker pid was ever recorded');
|
|
126
|
+
assert.equal(n0.pi_session_id ?? null, null, 'no session was ever recorded');
|
|
127
|
+
assert.equal(n0.status, 'active', 'starts stranded active');
|
|
128
|
+
}
|
|
129
|
+
// Inside the boot grace the daemon LEAVES it (could be the sub-second boot gap):
|
|
130
|
+
// tick 1 starts the boot-grace clock, tick 2 still within grace.
|
|
131
|
+
const NOW = 9_000_000;
|
|
132
|
+
await h.tick(NOW);
|
|
133
|
+
assert.equal(h.status(id), 'active', 'first observation → boot-grace clock started, left active');
|
|
134
|
+
await h.tick(NOW + 10_000);
|
|
135
|
+
assert.equal(h.status(id), 'active', 'inside the boot grace → daemon leaves it (boot gap)');
|
|
136
|
+
// Past the boot grace with STILL no pid and no session → crash + boot failure.
|
|
137
|
+
await h.tick(NOW + REVIVE_GRACE_MS + 1);
|
|
138
|
+
assert.equal(h.status(id), 'dead', 'a never-booted broker is reaped, not stranded active forever');
|
|
139
|
+
// …and the parent (an active subscriber of the child) was told up the spine.
|
|
140
|
+
const note = h.inbox(root).find((e) => /never started/.test(e.label ?? ''));
|
|
141
|
+
assert.ok(note !== undefined, 'the parent received a boot-failure notice up the spine');
|
|
142
|
+
assert.equal(note.tier, 'urgent', 'the boot-failure notice is urgent');
|
|
143
|
+
});
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
// Run with: node --import tsx/esm --test src/core/__tests__/full/broker-dialogs.test.ts
|
|
2
|
+
//
|
|
3
|
+
// FULL TIER (real-boot-bound): tmux-free, but it boots a REAL broker process
|
|
4
|
+
// (~5s pi-SDK load), so it lives in full/ (CI), not the fast local loop.
|
|
5
|
+
//
|
|
6
|
+
// Broker dialog handling — the §5.4 C2 forward-progress proof plus the T8 attach
|
|
7
|
+
// gates G5/G5b/G6. Split out of broker-lifecycle.test.ts; the gates are the
|
|
8
|
+
// original acceptance gate, unchanged.
|
|
9
|
+
//
|
|
10
|
+
// 3-PART HEADER (headless-retarget):
|
|
11
|
+
// (1) BUG IT LOCKS — the broker's blocking-dialog anti-deadlock contract: an
|
|
12
|
+
// unattended (zero-viewer) confirm() resolves to its default (false)
|
|
13
|
+
// IMMEDIATELY via noOp, never arming/awaiting a timeout (C2/G6a); a controller
|
|
14
|
+
// receives the dialog as extension_ui_request and its extension_ui_response
|
|
15
|
+
// unblocks the engine with ITS answer, not the default (G5); a dialog raised
|
|
16
|
+
// under a prior controller survives that controller's detach and is delivered
|
|
17
|
+
// to whoever takes control next via welcome.pending_dialog (G5b); and an
|
|
18
|
+
// ATTENDED dialog the controller never answers resolves on a SHORT per-dialog
|
|
19
|
+
// broker timeout, never the 120s default (G6b). Regression = an engine that
|
|
20
|
+
// hangs forever on a dialog with no answerer, or a pending dialog lost on
|
|
21
|
+
// controller handoff.
|
|
22
|
+
// (2) WHY BROKER/SOCKET-LEVEL, NOT PANE/WINDOW — the dialog round-trip is a pure
|
|
23
|
+
// async promise inside the broker's makeBrokerUiContext, forwarded/answered
|
|
24
|
+
// over the view.sock to the production ViewSocketClient. No tmux pane/window
|
|
25
|
+
// or pi session read is involved, so the lock holds headlessly
|
|
26
|
+
// (createHeadlessHarness, no tmux). It needs a REAL broker process (the real
|
|
27
|
+
// uiContext + timeout machinery), so this file keeps ONE real boot, shared
|
|
28
|
+
// across all four gates.
|
|
29
|
+
// (3) HOW THE HEADLESS DRIVE STILL FAILS ON REGRESSION — each gate drives the fake
|
|
30
|
+
// engine to raise a real confirm() through the real broker and asserts on the
|
|
31
|
+
// resolution recorded in fake-pi.dialog.jsonl (value + latency) and the frames
|
|
32
|
+
// the real controller receives. A regressed timeout/noOp/handoff path fails
|
|
33
|
+
// these exactly as it would under tmux; nothing here depended on a pane.
|
|
34
|
+
//
|
|
35
|
+
// ONE shared broker is booted in before() and reused across all four gates — 1 real
|
|
36
|
+
// boot total, not 4. Because fake-pi.dialog.jsonl is APPEND-ONLY across the shared
|
|
37
|
+
// broker, each gate snapshots the count before it drives (`base`) and asserts on
|
|
38
|
+
// the entries IT produced (results[base], results[base+1]); gates whose first
|
|
39
|
+
// attach must hold control use attachUntil(controller) to settle a prior gate's
|
|
40
|
+
// controller-detach handoff.
|
|
41
|
+
import { test, before, after, afterEach } from 'node:test';
|
|
42
|
+
import assert from 'node:assert/strict';
|
|
43
|
+
import { createHeadlessHarness } from '../helpers/harness.js';
|
|
44
|
+
import { isPidAlive } from '../../canvas/pid.js';
|
|
45
|
+
import { createAttachKit } from '../helpers/broker-clients.js';
|
|
46
|
+
let h;
|
|
47
|
+
let id; // ONE shared broker, reused across C2/G5/G5b/G6 (1 real boot, not 4)
|
|
48
|
+
const kit = createAttachKit(() => h);
|
|
49
|
+
const { attach, attachUntil } = kit;
|
|
50
|
+
// Admit a controller, waiting out any prior gate's controller-detach handoff.
|
|
51
|
+
const ctrl = (cid) => attachUntil(id, 'controller', cid, (a) => a.welcome.role === 'controller', `${cid} admitted controller`);
|
|
52
|
+
// dialog.jsonl is append-only across the shared broker — snapshot the count, then
|
|
53
|
+
// wait for AT LEAST `want` new entries and return the full (cumulative) array.
|
|
54
|
+
const dialogCount = () => h.dialogResults(id).length;
|
|
55
|
+
const awaitDialogs = (atLeast, label) => h.waitFor(() => (h.dialogResults(id).length >= atLeast ? h.dialogResults(id) : null), { label, timeoutMs: 15_000 });
|
|
56
|
+
before(async () => {
|
|
57
|
+
h = await createHeadlessHarness({ sessionPrefix: 'crtr-brkdlg' });
|
|
58
|
+
const root = h.spawnRoot('broker-dialogs suite root');
|
|
59
|
+
id = await h.spawnHeadlessChild(root, 'headless worker — dialog gates');
|
|
60
|
+
});
|
|
61
|
+
after(async () => {
|
|
62
|
+
if (h !== undefined)
|
|
63
|
+
await h.dispose();
|
|
64
|
+
});
|
|
65
|
+
afterEach(() => {
|
|
66
|
+
kit.closeAll();
|
|
67
|
+
});
|
|
68
|
+
const brokerPid = () => h.node(id).pi_pid;
|
|
69
|
+
// ===========================================================================
|
|
70
|
+
// C2 forward-progress (zero-viewer path) — an unattended blocking dialog
|
|
71
|
+
// resolves to its default (false) IMMEDIATELY: with no controller connected the
|
|
72
|
+
// broker's REAL makeBrokerUiContext falls back to noOp resolution, so the engine
|
|
73
|
+
// never deadlocks AND never waits on a per-dialog timeout (the design §5.4
|
|
74
|
+
// timeout premise is false). Red/green: the OLD broker armed a setTimeout and
|
|
75
|
+
// resolved only after `timeout` ms; the fixed broker resolves in ~0ms.
|
|
76
|
+
// ===========================================================================
|
|
77
|
+
test('C2 — unattended dialog resolves to its default IMMEDIATELY (noOp), never waits on a timeout', async () => {
|
|
78
|
+
const pid = brokerPid();
|
|
79
|
+
const base = dialogCount();
|
|
80
|
+
// Drive the fake engine to call uiContext.confirm(..., { timeout: 5000 }) with
|
|
81
|
+
// NO controller. C2 fix: makeBrokerUiContext resolves the default (false) AT
|
|
82
|
+
// ONCE — it does NOT arm/await the timeout. A generous < 2000ms bound is still a
|
|
83
|
+
// hard fail against the old ~5000ms timeout-wait while staying robust on slow CI.
|
|
84
|
+
h.fakeCmd(id, { cmd: 'dialog', timeout: 5000 });
|
|
85
|
+
const results = await awaitDialogs(base + 1, 'unattended dialog resolved');
|
|
86
|
+
assert.equal(results[base].resolved, false, 'unattended confirm resolves to its default (false / deny)');
|
|
87
|
+
assert.ok(results[base].ms < 2000, `C2: resolved IMMEDIATELY (noOp), not after the 5000ms timeout — got ${results[base].ms}ms`);
|
|
88
|
+
assert.equal(isPidAlive(pid), true, 'the broker made forward progress (still alive, did not hang or exit)');
|
|
89
|
+
});
|
|
90
|
+
// ---------------------------------------------------------------------------
|
|
91
|
+
// G5 — dialog forward + answer. Guards: a blocking dialog reaches the controller
|
|
92
|
+
// as extension_ui_request and the controller's extension_ui_response unblocks the
|
|
93
|
+
// engine with ITS answer (not the default). Failure mode: a dialog the controller
|
|
94
|
+
// can't see/answer (silent deadlock).
|
|
95
|
+
// ---------------------------------------------------------------------------
|
|
96
|
+
test('G5 — controller receives an extension_ui_request, answers it, and the engine proceeds with that answer', async () => {
|
|
97
|
+
const base = dialogCount();
|
|
98
|
+
const c = await ctrl('g5-ctrl');
|
|
99
|
+
h.fakeCmd(id, { cmd: 'dialog', timeout: 20_000 }); // generous: the controller answers first
|
|
100
|
+
const req = await c.waitFrame((f) => f.type === 'extension_ui_request', 'G5 dialog forwarded to controller');
|
|
101
|
+
assert.equal(req.method, 'confirm', 'G5: the forwarded dialog is the confirm() the engine raised');
|
|
102
|
+
const reqId = req.id;
|
|
103
|
+
c.send({ type: 'extension_ui_response', id: reqId, confirmed: true });
|
|
104
|
+
const results = await awaitDialogs(base + 1, 'G5 dialog resolved by the controller');
|
|
105
|
+
assert.equal(results[base].resolved, true, 'G5: the engine proceeded with the controller answer (true), not the default (false)');
|
|
106
|
+
});
|
|
107
|
+
// ---------------------------------------------------------------------------
|
|
108
|
+
// G5 (mid-dialog attach) — Guards: a dialog raised under a prior controller stays
|
|
109
|
+
// pending across that controller's detach (M2) and is delivered to whoever takes
|
|
110
|
+
// control next via welcome.pending_dialog. Failure mode: a pending dialog lost on
|
|
111
|
+
// controller handoff.
|
|
112
|
+
// ---------------------------------------------------------------------------
|
|
113
|
+
test('G5 — a controller attaching MID-dialog receives the pending dialog via welcome.pending_dialog', async () => {
|
|
114
|
+
const base = dialogCount();
|
|
115
|
+
const a = await ctrl('g5b-A');
|
|
116
|
+
h.fakeCmd(id, { cmd: 'dialog', timeout: 30_000 }); // stays pending long enough for the handoff
|
|
117
|
+
const reqA = await a.waitFrame((f) => f.type === 'extension_ui_request', 'G5b dialog forwarded to controller A');
|
|
118
|
+
const reqId = reqA.id;
|
|
119
|
+
a.send({ type: 'bye' });
|
|
120
|
+
a.close(); // M2: detach frees control but does NOT cancel the pending dialog
|
|
121
|
+
// Controller B takes control (retry covers the close→controllerId=null beat) and
|
|
122
|
+
// its welcome carries the still-pending dialog.
|
|
123
|
+
const b = await attachUntil(id, 'controller', 'g5b-B', (x) => x.welcome.role === 'controller' && x.welcome.pending_dialog != null, 'G5b controller B takes control with the pending dialog');
|
|
124
|
+
assert.equal(b.welcome.pending_dialog.id, reqId, 'G5b: welcome.pending_dialog is the same dialog raised under A');
|
|
125
|
+
assert.equal(b.welcome.pending_dialog.method, 'confirm', 'G5b: the pending dialog is the confirm()');
|
|
126
|
+
b.send({ type: 'extension_ui_response', id: reqId, confirmed: true });
|
|
127
|
+
const results = await awaitDialogs(base + 1, 'G5b dialog resolved by controller B');
|
|
128
|
+
assert.equal(results[base].resolved, true, 'G5b: controller B answered the handed-off dialog and the engine proceeded');
|
|
129
|
+
});
|
|
130
|
+
// ---------------------------------------------------------------------------
|
|
131
|
+
// G6 — anti-deadlock. (a) a zero-viewer dialog resolves to its default AT ONCE
|
|
132
|
+
// (noOp). (b) an ATTENDED dialog the controller never answers resolves on a SHORT
|
|
133
|
+
// per-dialog broker timeout. Guards: the engine never hangs on a dialog with no
|
|
134
|
+
// answerer. Failure mode: a forever-blocked turn.
|
|
135
|
+
// ---------------------------------------------------------------------------
|
|
136
|
+
test('G6 — zero-viewer dialog resolves immediately; an unanswered attended dialog resolves on the broker timeout', async () => {
|
|
137
|
+
const pid = brokerPid();
|
|
138
|
+
const base = dialogCount();
|
|
139
|
+
// (a) zero viewers → immediate noOp default (NOT the 5000ms timeout).
|
|
140
|
+
h.fakeCmd(id, { cmd: 'dialog', timeout: 5000 });
|
|
141
|
+
const r1 = await awaitDialogs(base + 1, 'G6a zero-viewer dialog resolved');
|
|
142
|
+
assert.equal(r1[base].resolved, false, 'G6a: zero-viewer dialog resolves to the default (deny)');
|
|
143
|
+
assert.ok(r1[base].ms < 2000, `G6a: resolved immediately (noOp), not after the 5000ms timeout — got ${r1[base].ms}ms`);
|
|
144
|
+
// (b) controller attached but silent → the broker resolves on the SHORT explicit
|
|
145
|
+
// per-dialog timeout (800ms), never the 120s default.
|
|
146
|
+
const c = await ctrl('g6-ctrl');
|
|
147
|
+
h.fakeCmd(id, { cmd: 'dialog', timeout: 800 });
|
|
148
|
+
await c.waitFrame((f) => f.type === 'extension_ui_request', 'G6b dialog forwarded to controller'); // received, deliberately NOT answered
|
|
149
|
+
const r2 = await awaitDialogs(base + 2, 'G6b attended dialog resolved on timeout');
|
|
150
|
+
assert.equal(r2[base + 1].resolved, false, 'G6b: an unanswered attended dialog resolves to the default (deny)');
|
|
151
|
+
assert.ok(r2[base + 1].ms >= 600 && r2[base + 1].ms < 5000, `G6b: resolved on the ~800ms per-dialog timeout, not instantly and not the 120s default — got ${r2[base + 1].ms}ms`);
|
|
152
|
+
assert.equal(isPidAlive(pid), true, 'G6: the engine made forward progress on both dialogs (still alive)');
|
|
153
|
+
});
|