@north-light/crouter 0.3.154 → 0.3.157
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/README.md +2 -1
- package/dist/api/client.d.ts +1 -4
- package/dist/api/client.js +0 -5
- package/dist/api/dto/broker.d.ts +1 -1
- package/dist/api/dto/nodes.d.ts +0 -15
- package/dist/api/routes.d.ts +0 -1
- package/dist/api/routes.js +0 -1
- package/dist/builtin-views/chat/core.mjs +51 -6
- package/dist/builtin-views/chat/tui.mjs +14 -6
- package/dist/builtin-views/chat/web.jsx +7 -2
- package/dist/clients/attach/__tests__/oauth-dialog-lifecycle.test.js +1 -1
- package/dist/clients/attach/chrome/roster.d.ts +1 -1
- package/dist/clients/attach/chrome/roster.js +1 -9
- package/dist/clients/attach/command.js +5 -5
- package/dist/clients/attach/input/controller.d.ts +8 -23
- package/dist/clients/attach/input/controller.js +29 -59
- package/dist/clients/attach/overlays/dialogs.d.ts +2 -1
- package/dist/clients/attach/overlays/graph.d.ts +1 -4
- package/dist/clients/attach/overlays/graph.js +7 -25
- package/dist/clients/attach/session/context.d.ts +0 -7
- package/dist/clients/attach/session/frames.js +8 -1
- package/dist/clients/attach/session/input-wiring.d.ts +1 -1
- package/dist/clients/attach/session/input-wiring.js +11 -21
- package/dist/clients/attach/session/mode.d.ts +3 -4
- package/dist/clients/attach/session/mode.js +1 -6
- package/dist/clients/attach/session/reconnect.d.ts +3 -3
- package/dist/clients/attach/session/reconnect.js +7 -6
- package/dist/clients/attach/session/state-sync.d.ts +1 -1
- package/dist/clients/attach/session/state-sync.js +2 -2
- package/dist/clients/attach/slash/dispatch.d.ts +1 -13
- package/dist/clients/attach/slash/dispatch.js +17 -65
- package/dist/clients/attach/viewer.js +523 -523
- package/dist/clients/web/web-client/shared/protocol.d.ts +3 -5
- package/dist/core/__tests__/broker-sdk-wiring.test.js +18 -18
- package/dist/core/__tests__/chat-view-reconnect.test.js +23 -44
- package/dist/core/__tests__/full/broker-attach-limits.test.js +36 -60
- package/dist/core/__tests__/full/broker-attach-stream.test.js +4 -4
- package/dist/core/__tests__/full/broker-control-preempt.test.d.ts +1 -0
- package/dist/core/__tests__/full/broker-control-preempt.test.js +61 -0
- package/dist/core/__tests__/full/broker-dialogs.test.js +62 -121
- package/dist/core/__tests__/helpers/broker-clients.js +2 -2
- package/dist/core/__tests__/session-model.test.js +26 -15
- package/dist/core/keybindings/__tests__/resolve.test.js +1 -1
- package/dist/core/keybindings/catalog.d.ts +2 -2
- package/dist/core/keybindings/catalog.js +2 -0
- package/dist/core/runtime/auth-reload.d.ts +4 -4
- package/dist/core/runtime/auth-reload.js +13 -9
- package/dist/core/runtime/boot-root.d.ts +2 -2
- package/dist/core/runtime/boot-root.js +7 -7
- package/dist/core/runtime/broker-protocol.d.ts +27 -23
- package/dist/core/runtime/broker-protocol.js +1 -1
- package/dist/core/runtime/broker-request.js +11 -5
- package/dist/core/runtime/broker.d.ts +13 -21
- package/dist/core/runtime/broker.js +186 -151
- package/dist/core/runtime/interactive-deliver.js +5 -4
- package/dist/core/runtime/model-swap.d.ts +3 -2
- package/dist/core/runtime/model-swap.js +4 -3
- package/dist/core/runtime/node-read.d.ts +0 -20
- package/dist/core/runtime/node-read.js +1 -34
- package/dist/core/runtime/resume-root.d.ts +1 -1
- package/dist/core/runtime/resume-root.js +6 -6
- package/dist/core/runtime/spawn.js +3 -3
- package/dist/core/session-model/session-state.d.ts +6 -8
- package/dist/core/session-model/session-state.js +16 -6
- package/dist/daemon/api/handlers/nodes.js +1 -18
- package/dist/daemon/manage.js +2 -2
- package/dist/index.d.ts +1 -1
- package/dist/web-client/assets/index--SsQYcKu.js +79 -0
- package/dist/web-client/assets/{index-CpEl9LTS.css → index-DJhQZoAj.css} +1 -1
- package/dist/web-client/index.html +2 -2
- package/dist/web-client/sw.js +1 -1
- package/docs/compat/hearth-crtr-v1.md +1 -1
- package/docs/compat/hearth-crtr-v2.md +1 -1
- package/docs/compat/hearth-crtr-v3.md +1 -1
- package/docs/compat/hearth-crtr-v4.md +3 -1
- package/docs/public-api.md +2 -2
- package/package.json +4 -4
- package/runtime.lock.json +2 -2
- package/dist/web-client/assets/index-BpyZGBhI.js +0 -79
- package/docs/compat/hearth-crtr-v5.md +0 -175
|
@@ -78,10 +78,10 @@ export interface SessionStatsSummary {
|
|
|
78
78
|
assistant_messages: number;
|
|
79
79
|
cost?: number;
|
|
80
80
|
}
|
|
81
|
-
/** Viewer presence for a node
|
|
82
|
-
* no owner: every live interactive view is writable at the same time. */
|
|
81
|
+
/** Viewer/controller presence for a node. */
|
|
83
82
|
export interface Presence {
|
|
84
83
|
viewers: number;
|
|
84
|
+
controller: string | null;
|
|
85
85
|
}
|
|
86
86
|
/** Git working-tree change counts for the meta strip. */
|
|
87
87
|
export interface GitStatus {
|
|
@@ -276,9 +276,7 @@ export interface ResolveDeckResponse {
|
|
|
276
276
|
* `isStreaming` (spec C.10).
|
|
277
277
|
*/
|
|
278
278
|
export type SessionState = RpcSessionState;
|
|
279
|
-
/**
|
|
280
|
-
* broker's `welcome`: `controller` is writable, `observer` is read-only. It is
|
|
281
|
-
* a per-client capability, never ownership of a shared slot. */
|
|
279
|
+
/** Web role of one browser tab's session connection. */
|
|
282
280
|
export type WebRole = 'observer' | 'controller';
|
|
283
281
|
/** Upstream broker connectivity, surfaced to the tab (spec §6.2). */
|
|
284
282
|
export type BrokerStatus = 'connected' | 'reconnecting' | 'down' | 'revived';
|
|
@@ -240,16 +240,16 @@ test('C3h — a cooling managed launch target boots model-less rather than falli
|
|
|
240
240
|
// `<project_context>` block is environment-only. Covered by
|
|
241
241
|
// context-intro.test.ts's environment-only regression.)
|
|
242
242
|
// ===========================================================================
|
|
243
|
-
// C2 — zero-
|
|
243
|
+
// C2 — zero-viewer dialogs resolve to deny/cancel IMMEDIATELY (noOp), never
|
|
244
244
|
// hanging the turn and never waiting on a per-dialog timeout. Drives the REAL
|
|
245
245
|
// makeBrokerUiContext (the exact ExtensionUIContext the SDK hands extensions),
|
|
246
|
-
// with a
|
|
246
|
+
// with a controller() that reports zero viewers.
|
|
247
247
|
// ===========================================================================
|
|
248
|
-
test('C2 — zero-
|
|
248
|
+
test('C2 — zero-viewer UI context resolves dialogs to deny/cancel immediately (noOp, no hang)', async () => {
|
|
249
249
|
const ctx = makeBrokerUiContext({
|
|
250
|
-
|
|
250
|
+
controller: () => null, // ZERO viewers attached
|
|
251
251
|
forward: () => {
|
|
252
|
-
throw new Error('C2: must NOT forward a dialog when no
|
|
252
|
+
throw new Error('C2: must NOT forward a dialog when no controller is attached');
|
|
253
253
|
},
|
|
254
254
|
pending: new Map(),
|
|
255
255
|
broadcast: () => { },
|
|
@@ -274,7 +274,7 @@ test('C2 — zero-writable UI context resolves dialogs to deny/cancel immediatel
|
|
|
274
274
|
test('broker UI exposes the live provider pin policy to package extensions', () => {
|
|
275
275
|
let pinned = false;
|
|
276
276
|
const ctx = makeBrokerUiContext({
|
|
277
|
-
|
|
277
|
+
controller: () => null,
|
|
278
278
|
forward: () => { },
|
|
279
279
|
pending: new Map(),
|
|
280
280
|
broadcast: () => { },
|
|
@@ -291,36 +291,36 @@ test('broker UI exposes the live provider pin policy to package extensions', ()
|
|
|
291
291
|
});
|
|
292
292
|
// ===========================================================================
|
|
293
293
|
// M2 (review mq5wkqep / T4) — REPLACES the Wave-0 M-1 cancel-on-detach. A dialog
|
|
294
|
-
// forwarded to a
|
|
295
|
-
//
|
|
296
|
-
//
|
|
294
|
+
// forwarded to a controller that then DETACHES must NOT be cancelled: it stays
|
|
295
|
+
// pending so a brief detach/reattach (or a handoff to another controller) does
|
|
296
|
+
// not lose an answerable dialog. The broker-side default timeout is the ONLY
|
|
297
297
|
// non-answer resolution (proven here with a short per-dialog timeout standing in
|
|
298
298
|
// for the 120s default). Guards against regressing to the over-eager cancel.
|
|
299
299
|
// ===========================================================================
|
|
300
|
-
test('M2 — a forwarded dialog stays pending
|
|
300
|
+
test('M2 — a forwarded dialog stays pending on controller detach, resolving only on the broker-side timeout', async () => {
|
|
301
301
|
const pending = new Map();
|
|
302
302
|
let attached = true;
|
|
303
303
|
const ctx = makeBrokerUiContext({
|
|
304
304
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
305
|
-
|
|
305
|
+
controller: () => (attached ? { id: 'c1' } : null),
|
|
306
306
|
forward: () => {
|
|
307
|
-
/* a real
|
|
307
|
+
/* a real controller would receive the request over its socket */
|
|
308
308
|
},
|
|
309
309
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
310
310
|
pending: pending,
|
|
311
311
|
broadcast: () => { },
|
|
312
312
|
});
|
|
313
|
-
//
|
|
314
|
-
//
|
|
315
|
-
//
|
|
313
|
+
// Controller attached → confirm() forwards + registers a pending dialog (with the
|
|
314
|
+
// request retained for welcome.pending_dialog / re-route, T4) and does NOT
|
|
315
|
+
// resolve yet. A short per-dialog timeout stands in for the 120s broker default.
|
|
316
316
|
const p = ctx.confirm('proceed?', 'really?', { timeout: 80 });
|
|
317
|
-
assert.equal(pending.size, 1, 'M2: a forwarded dialog is pending while a
|
|
317
|
+
assert.equal(pending.size, 1, 'M2: a forwarded dialog is pending while a controller is attached');
|
|
318
318
|
const entry = [...pending.values()][0];
|
|
319
319
|
assert.ok(entry.request, 'M2: the pending entry retains the request (welcome/re-route need it)');
|
|
320
320
|
assert.equal(entry.cancel, undefined, 'M2: there is no cancel-on-detach path anymore');
|
|
321
|
-
//
|
|
321
|
+
// Controller detaches → the dialog STAYS pending (the broker no longer cancels it).
|
|
322
322
|
attached = false;
|
|
323
|
-
assert.equal(pending.size, 1, 'M2:
|
|
323
|
+
assert.equal(pending.size, 1, 'M2: detach does NOT cancel the in-flight dialog');
|
|
324
324
|
// Only the broker-side timeout resolves it, to the SAFE default (deny).
|
|
325
325
|
const resolved = await Promise.race([
|
|
326
326
|
p,
|
|
@@ -41,16 +41,23 @@ test('local invalid/no-node stream failures close asynchronously after the chann
|
|
|
41
41
|
assert.equal(events[1]?.startsWith('close:'), true, `${target}: follows with streamClose`);
|
|
42
42
|
}
|
|
43
43
|
});
|
|
44
|
-
test('
|
|
45
|
-
let current =
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
44
|
+
test('control_changed demotion updates reconnect intent without clobbering an explicit controller request', () => {
|
|
45
|
+
let current = {
|
|
46
|
+
target: 'n1',
|
|
47
|
+
desiredRole: 'controller',
|
|
48
|
+
channel: null,
|
|
49
|
+
clientId: 'client-1',
|
|
50
|
+
conn: 'open',
|
|
51
|
+
role: 'controller',
|
|
52
|
+
controllerId: 'client-1',
|
|
53
|
+
session: null,
|
|
54
|
+
transcript: [],
|
|
55
|
+
draft: '',
|
|
56
|
+
notices: [],
|
|
57
|
+
pendingDialog: null,
|
|
58
|
+
queued: [],
|
|
59
|
+
contextTokens: undefined,
|
|
60
|
+
};
|
|
54
61
|
const ctx = {
|
|
55
62
|
get state() {
|
|
56
63
|
return current;
|
|
@@ -58,13 +65,6 @@ test('reconnect keeps the requested role while welcome and read_only remain auth
|
|
|
58
65
|
set(next) {
|
|
59
66
|
current = typeof next === 'function' ? next(current) : next;
|
|
60
67
|
},
|
|
61
|
-
connect(_stream, opts) {
|
|
62
|
-
requestedRoles.push(opts.role);
|
|
63
|
-
return channels[nextChannel++];
|
|
64
|
-
},
|
|
65
|
-
dispatch(intent) {
|
|
66
|
-
return core.intents[intent](ctx);
|
|
67
|
-
},
|
|
68
68
|
signal: {
|
|
69
69
|
clearBanner() { },
|
|
70
70
|
setBanner() { },
|
|
@@ -74,32 +74,11 @@ test('reconnect keeps the requested role while welcome and read_only remain auth
|
|
|
74
74
|
quit() { },
|
|
75
75
|
},
|
|
76
76
|
};
|
|
77
|
-
core.intents.
|
|
78
|
-
|
|
79
|
-
assert.equal(current.desiredRole, '
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
core.intents.streamFrame(ctx, { type: 'welcome', role: 'controller', snapshot: { state: {}, messages: [] } });
|
|
84
|
-
assert.equal(current.role, 'controller');
|
|
85
|
-
assert.equal(current.conn, 'open');
|
|
86
|
-
core.intents.reconnect(ctx);
|
|
87
|
-
assert.deepEqual(closed, ['first-channel']);
|
|
77
|
+
core.intents.streamFrame(ctx, { type: 'control_changed', controller_id: 'other-client' });
|
|
78
|
+
assert.equal(current.role, 'observer');
|
|
79
|
+
assert.equal(current.desiredRole, 'observer');
|
|
80
|
+
current = { ...current, role: 'observer', desiredRole: 'controller' };
|
|
81
|
+
core.intents.streamFrame(ctx, { type: 'control_changed', controller_id: 'other-client' });
|
|
82
|
+
assert.equal(current.role, 'observer');
|
|
88
83
|
assert.equal(current.desiredRole, 'controller');
|
|
89
|
-
assert.equal(current.channel?.id, 'replacement-channel');
|
|
90
|
-
assert.deepEqual(requestedRoles, ['controller', 'controller']);
|
|
91
|
-
const replacementChannel = current.channel;
|
|
92
|
-
core.intents.streamOpen(ctx, replacementChannel);
|
|
93
|
-
core.intents.streamFrame(ctx, { type: 'welcome', role: 'observer', snapshot: { state: {}, messages: [] } });
|
|
94
|
-
assert.equal(current.role, 'observer', 'welcome.role is authoritative for the active connection');
|
|
95
|
-
assert.equal(current.desiredRole, 'controller', 'the requested controller role survives reconnect');
|
|
96
|
-
assert.equal(current.conn, 'open');
|
|
97
|
-
core.intents.streamClose(ctx, { channelId: 'first-channel', reason: 'closed' });
|
|
98
|
-
assert.equal(current.channel, replacementChannel, 'a stale close cannot clear the replacement channel');
|
|
99
|
-
assert.equal(current.conn, 'open');
|
|
100
|
-
core.intents.streamFrame(ctx, { type: 'error', code: 'read_only' });
|
|
101
|
-
core.intents.setDraft(ctx, 'hello');
|
|
102
|
-
core.intents.submitDraft(ctx);
|
|
103
|
-
assert.deepEqual(sent, [], 'read-only welcome state prevents writes');
|
|
104
|
-
assert.match(current.notices.at(-1).text, /read-only — sending is disabled/);
|
|
105
84
|
});
|
|
@@ -3,32 +3,27 @@
|
|
|
3
3
|
// FULL TIER (real-boot-bound): tmux-free, but it boots a REAL broker process
|
|
4
4
|
// (~5s pi-SDK load), so it lives in full/ (CI), not the fast local loop.
|
|
5
5
|
//
|
|
6
|
-
// T8 — the `crtr surface attach` acceptance gate for G4/G7/G8:
|
|
7
|
-
//
|
|
6
|
+
// T8 — the `crtr surface attach` acceptance gate for G4/G7/G8: controller
|
|
7
|
+
// arbitration, decoder overflow isolation, and backpressure shedding.
|
|
8
8
|
//
|
|
9
9
|
// 3-PART HEADER (headless):
|
|
10
|
-
// (1) CONTRACT —
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
// and the backpressure HWM are broker-process and view.sock contracts. They
|
|
21
|
-
// are exercised over a pure Unix socket using the production ViewSocketClient
|
|
22
|
-
// and raw node:net peers, with a real broker process and no tmux pane or
|
|
23
|
-
// window.
|
|
10
|
+
// (1) CONTRACT — the first client is the controller and a second client is an
|
|
11
|
+
// admitted read-only observer; observer prompts are rejected while both
|
|
12
|
+
// clients receive relay frames (G4). An oversized client line is capped and
|
|
13
|
+
// dropped with frame_overflow while the broker and other clients continue
|
|
14
|
+
// operating (G7). A stalled non-reading viewer is shed at the 32 MiB
|
|
15
|
+
// backpressure HWM while the broker and fast viewers remain unaffected (G8).
|
|
16
|
+
// (2) WHY BROKER/SOCKET-LEVEL, NOT PANE/WINDOW — arbitration, decoder limits, and
|
|
17
|
+
// the backpressure HWM are broker-process and view.sock contracts. They are
|
|
18
|
+
// exercised over a pure Unix socket using the production ViewSocketClient and
|
|
19
|
+
// raw node:net peers, with a real broker process and no tmux pane or window.
|
|
24
20
|
// (3) HOW THE HEADLESS DRIVE ASSERTS THE CONTRACT — the real socket responses
|
|
25
|
-
// provide the G4 welcome roles
|
|
26
|
-
//
|
|
27
|
-
// HWM-shed log line and broker survival.
|
|
21
|
+
// provide the G4 welcome roles and relay frames, the G7 frame-overflow drop
|
|
22
|
+
// and broker survival, and the G8 HWM-shed log line and broker survival.
|
|
28
23
|
//
|
|
29
|
-
// One shared broker is booted in before() and reused across all three gates.
|
|
30
|
-
//
|
|
31
|
-
//
|
|
24
|
+
// One shared broker is booted in before() and reused across all three gates. Gates
|
|
25
|
+
// whose first attach must hold control use attachUntil(controller) so a prior gate's
|
|
26
|
+
// controller-detach handoff settles deterministically before they drive.
|
|
32
27
|
import { test, before, after, afterEach } from 'node:test';
|
|
33
28
|
import assert from 'node:assert/strict';
|
|
34
29
|
import { createHeadlessHarness } from '../helpers/harness.js';
|
|
@@ -37,7 +32,9 @@ import { createAttachKit, delay, tok, frameHas, brokerLogText, } from '../helper
|
|
|
37
32
|
let h;
|
|
38
33
|
let id; // ONE shared broker, reused across G4/G7/G8 (1 real boot, not 3)
|
|
39
34
|
const kit = createAttachKit(() => h);
|
|
40
|
-
const { attach, connectRaw } = kit;
|
|
35
|
+
const { attach, attachUntil, connectRaw } = kit;
|
|
36
|
+
// Admit a controller, waiting out any prior gate's controller-detach handoff.
|
|
37
|
+
const ctrl = (cid) => attachUntil(id, 'controller', cid, (a) => a.welcome.role === 'controller', `${cid} admitted controller`);
|
|
41
38
|
before(async () => {
|
|
42
39
|
h = await createHeadlessHarness({ sessionPrefix: 'crtr-brklim' });
|
|
43
40
|
const root = h.spawnRoot('broker-attach-limits suite root');
|
|
@@ -52,44 +49,23 @@ afterEach(() => {
|
|
|
52
49
|
});
|
|
53
50
|
const brokerPid = () => h.node(id).pi_pid;
|
|
54
51
|
// ---------------------------------------------------------------------------
|
|
55
|
-
// G4 —
|
|
56
|
-
//
|
|
57
|
-
//
|
|
58
|
-
//
|
|
59
|
-
// Failure mode: a singleton writer slot demoting the second client, a writer
|
|
60
|
-
// whose prompt is silently dropped, fan-out that misses a viewer, or a
|
|
61
|
-
// read-only client that can drive the engine.
|
|
52
|
+
// G4 — arbitration + observer read. Guards: 2nd client is admitted observer, an
|
|
53
|
+
// observer prompt is rejected not_controller, BOTH clients receive the relay.
|
|
54
|
+
// Failure mode: two controllers, or an observer driving the engine, or fan-out
|
|
55
|
+
// that misses a viewer.
|
|
62
56
|
// ---------------------------------------------------------------------------
|
|
63
|
-
test('G4 —
|
|
64
|
-
const
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
observer.send({ type: 'prompt', text: 'a read-only client must not drive' });
|
|
76
|
-
const err = await observer.waitFrame((f) => f.type === 'error', 'G4 observer prompt rejected');
|
|
77
|
-
assert.equal(err.code, 'read_only', 'G4: observer prompt → error{read_only}');
|
|
78
|
-
// Each writer submits its OWN token. Submissions are sequenced (a prompt sent
|
|
79
|
-
// mid-stream routes as a steer and produces no second turn), so each turn is
|
|
80
|
-
// driven to its terminal agent_end before the next writer submits.
|
|
81
|
-
const tokenA = tok('G4-WRITER-1');
|
|
82
|
-
w1.send({ type: 'prompt', text: tokenA });
|
|
83
|
-
await w1.waitFrame((f) => f.type === 'agent_end' && frameHas(f, tokenA), 'G4 writer 1 saw its own turn');
|
|
84
|
-
const tokenB = tok('G4-WRITER-2');
|
|
85
|
-
w2.send({ type: 'prompt', text: tokenB });
|
|
86
|
-
await w2.waitFrame((f) => f.type === 'agent_end' && frameHas(f, tokenB), 'G4 writer 2 saw its own turn');
|
|
87
|
-
// The merged stream: EVERY connected client — both writers and the read-only
|
|
88
|
-
// observer — receives BOTH writers' turns.
|
|
89
|
-
await w2.waitFrame((f) => f.type === 'agent_end' && frameHas(f, tokenA), 'G4 writer 2 ALSO received writer 1 turn');
|
|
90
|
-
await w1.waitFrame((f) => f.type === 'agent_end' && frameHas(f, tokenB), 'G4 writer 1 ALSO received writer 2 turn');
|
|
91
|
-
await observer.waitFrame((f) => f.type === 'agent_end' && frameHas(f, tokenA), 'G4 observer received writer 1 turn');
|
|
92
|
-
await observer.waitFrame((f) => f.type === 'agent_end' && frameHas(f, tokenB), 'G4 observer received writer 2 turn');
|
|
57
|
+
test('G4 — second client is observer; observer prompt → error{not_controller}; both receive the stream', async () => {
|
|
58
|
+
const c1 = await ctrl('g4-ctrl');
|
|
59
|
+
assert.equal(c1.welcome.role, 'controller', 'first client holds control');
|
|
60
|
+
const c2 = await attach(id, 'controller', 'g4-second'); // requests control; held → observer
|
|
61
|
+
assert.equal(c2.welcome.role, 'observer', 'second client is admitted read-only observer (first-attach-wins)');
|
|
62
|
+
c2.send({ type: 'prompt', text: 'observer must not drive' });
|
|
63
|
+
const err = await c2.waitFrame((f) => f.type === 'error', 'G4 observer prompt rejected');
|
|
64
|
+
assert.equal(err.code, 'not_controller', 'G4: observer prompt → error{not_controller}');
|
|
65
|
+
const token = tok('G4-BROADCAST');
|
|
66
|
+
c1.send({ type: 'prompt', text: token });
|
|
67
|
+
await c1.waitFrame((f) => f.type === 'agent_end' && frameHas(f, token), 'G4 controller received the stream');
|
|
68
|
+
await c2.waitFrame((f) => f.type === 'agent_end' && frameHas(f, token), 'G4 observer ALSO received the stream');
|
|
93
69
|
});
|
|
94
70
|
// ---------------------------------------------------------------------------
|
|
95
71
|
// G7 — decoder overflow (guards C5 OOM). A client line over BROKER_READ_CAPS is
|
|
@@ -32,9 +32,9 @@
|
|
|
32
32
|
// The engine is hosted IN-PROCESS by the broker, so engine pid == broker pid ==
|
|
33
33
|
// node.pi_pid == boot.pid; "engine pid unchanged" == broker pid unchanged + no new
|
|
34
34
|
// boot. ONE shared broker is booted in before() and reused across all three gates
|
|
35
|
-
// (each just attaches fresh clients) — 1 real boot total, not 3.
|
|
36
|
-
//
|
|
37
|
-
//
|
|
35
|
+
// (each just attaches fresh clients) — 1 real boot total, not 3. A gate whose
|
|
36
|
+
// first attach must hold control uses attachUntil(controller) so the prior gate's
|
|
37
|
+
// controller-detach handoff settles deterministically before it drives.
|
|
38
38
|
import { test, before, after, afterEach } from 'node:test';
|
|
39
39
|
import assert from 'node:assert/strict';
|
|
40
40
|
import { createHeadlessHarness } from '../helpers/harness.js';
|
|
@@ -44,7 +44,7 @@ let h;
|
|
|
44
44
|
let id; // ONE shared broker, reused across G1/G1b/G2 (1 real boot, not 3)
|
|
45
45
|
const kit = createAttachKit(() => h);
|
|
46
46
|
const { attach, attachUntil } = kit;
|
|
47
|
-
//
|
|
47
|
+
// Admit a controller, waiting out any prior gate's controller-detach handoff.
|
|
48
48
|
const ctrl = (cid) => attachUntil(id, 'controller', cid, (a) => a.welcome.role === 'controller', `${cid} admitted controller`);
|
|
49
49
|
before(async () => {
|
|
50
50
|
h = await createHeadlessHarness({ sessionPrefix: 'crtr-brkstrm' });
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// Run with: node --import tsx/esm --test src/core/__tests__/full/broker-control-preempt.test.ts
|
|
2
|
+
// (FULL tier — boots a real isolated tmux session + a real headless broker.)
|
|
3
|
+
//
|
|
4
|
+
// BUG-REGRESSION (broker-universal-host cut, design §D preemptive handoff; U1
|
|
5
|
+
// report mq8ftenr-5d55313d). Before the cut, `request_control` granted control
|
|
6
|
+
// ONLY when controllerId===null and otherwise returned a `control_held` error —
|
|
7
|
+
// so a second client could NEVER preempt a live (and usually idle/abandoned)
|
|
8
|
+
// controller. With every node now broker-hosted, a tmux pane and a web tab are
|
|
9
|
+
// true peers: either must be able to take control of a node the other currently
|
|
10
|
+
// drives. The fix makes request_control preemptive last-requester-wins — it
|
|
11
|
+
// ALWAYS succeeds, reassigning control to the requester and broadcasting a
|
|
12
|
+
// control_changed that demotes the prior controller. There is no `control_held`
|
|
13
|
+
// error frame anymore.
|
|
14
|
+
//
|
|
15
|
+
// Drive: controller A attaches and holds control; observer B then requests
|
|
16
|
+
// control. Assert control reassigns to B, a control_changed broadcasts demoting
|
|
17
|
+
// A, and B got NO error frame back (the old `control_held` reject is gone).
|
|
18
|
+
// Regression check: restore the controllerId!==null reject and B's request is
|
|
19
|
+
// refused (controller_id stays A / B receives an error) → these asserts go RED.
|
|
20
|
+
import { test, before, after, afterEach } from 'node:test';
|
|
21
|
+
import assert from 'node:assert/strict';
|
|
22
|
+
import { createHeadlessHarness } from '../helpers/harness.js';
|
|
23
|
+
import { createAttachKit } from '../helpers/broker-clients.js';
|
|
24
|
+
let h;
|
|
25
|
+
let id;
|
|
26
|
+
const kit = createAttachKit(() => h);
|
|
27
|
+
const { attach, attachUntil } = kit;
|
|
28
|
+
before(async () => {
|
|
29
|
+
h = await createHeadlessHarness({ sessionPrefix: 'crtr-ctrlpreempt' });
|
|
30
|
+
const root = h.spawnRoot('control-preempt suite root');
|
|
31
|
+
id = await h.spawnHeadlessChild(root, 'headless worker — control preemption');
|
|
32
|
+
});
|
|
33
|
+
after(async () => {
|
|
34
|
+
if (h !== undefined)
|
|
35
|
+
await h.dispose();
|
|
36
|
+
});
|
|
37
|
+
afterEach(() => {
|
|
38
|
+
kit.closeAll();
|
|
39
|
+
});
|
|
40
|
+
test('D3 — a second client preempts the live controller (last-requester-wins); the prior controller is demoted, no control_held reject', async () => {
|
|
41
|
+
// A attaches and is granted control (controllerId was null). attachUntil
|
|
42
|
+
// suffixes the client_id per retry, so the broker-assigned id is the welcome's.
|
|
43
|
+
const bId = 'd3-B';
|
|
44
|
+
const a = await attachUntil(id, 'controller', 'd3-A', (x) => x.welcome.role === 'controller', 'A admitted controller');
|
|
45
|
+
const aId = a.welcome.controller_id;
|
|
46
|
+
assert.ok(aId !== null, 'A holds control after attach');
|
|
47
|
+
// B attaches as an observer while A holds control (its welcome reflects A).
|
|
48
|
+
const b = await attach(id, 'observer', bId);
|
|
49
|
+
assert.equal(b.welcome.role, 'observer', 'B starts as an observer (A holds control)');
|
|
50
|
+
assert.equal(b.welcome.controller_id, aId, 'B sees A as the current controller');
|
|
51
|
+
// B preempts. Under the OLD model this returned a control_held error and B
|
|
52
|
+
// never became controller; under §D it ALWAYS succeeds.
|
|
53
|
+
b.send({ type: 'request_control' });
|
|
54
|
+
// A is demoted via a broadcast control_changed naming B as the new controller.
|
|
55
|
+
const changed = await a.waitFrame((f) => f.type === 'control_changed' && f.controller_id === bId, 'A receives control_changed demoting it to B');
|
|
56
|
+
assert.equal(changed.controller_id, bId, 'control reassigned to B (last-requester-wins)');
|
|
57
|
+
// B sees itself as the controller too (same broadcast).
|
|
58
|
+
await b.waitFrame((f) => f.type === 'control_changed' && f.controller_id === bId, 'B observes itself become controller');
|
|
59
|
+
// The old `control_held` reject is gone: B's request drew NO error frame.
|
|
60
|
+
assert.ok(!b.frames.some((f) => f.type === 'error'), 'B received NO error frame — there is no control_held reject in the preemptive model');
|
|
61
|
+
});
|