@north-light/crouter 0.3.157 → 0.3.158
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/api/client.d.ts +4 -1
- package/dist/api/client.js +5 -0
- package/dist/api/dto/broker.d.ts +1 -1
- package/dist/api/dto/nodes.d.ts +15 -0
- package/dist/api/routes.d.ts +1 -0
- package/dist/api/routes.js +1 -0
- package/dist/builtin-views/chat/core.mjs +6 -51
- package/dist/builtin-views/chat/tui.mjs +6 -14
- package/dist/builtin-views/chat/web.jsx +2 -7
- 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 +9 -1
- package/dist/clients/attach/command.js +5 -5
- package/dist/clients/attach/input/controller.d.ts +23 -8
- package/dist/clients/attach/input/controller.js +59 -29
- package/dist/clients/attach/overlays/dialogs.d.ts +1 -2
- package/dist/clients/attach/overlays/graph.d.ts +4 -1
- package/dist/clients/attach/overlays/graph.js +25 -7
- package/dist/clients/attach/session/context.d.ts +7 -0
- package/dist/clients/attach/session/frames.js +1 -8
- package/dist/clients/attach/session/input-wiring.d.ts +1 -1
- package/dist/clients/attach/session/input-wiring.js +21 -11
- package/dist/clients/attach/session/mode.d.ts +4 -3
- package/dist/clients/attach/session/mode.js +6 -1
- package/dist/clients/attach/session/reconnect.d.ts +3 -3
- package/dist/clients/attach/session/reconnect.js +6 -7
- 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 +13 -1
- package/dist/clients/attach/slash/dispatch.js +65 -17
- package/dist/clients/attach/viewer.js +523 -523
- package/dist/clients/web/web-client/shared/protocol.d.ts +5 -3
- package/dist/commands/node.js +1 -1
- package/dist/core/__tests__/broker-sdk-wiring.test.js +18 -18
- package/dist/core/__tests__/chat-view-reconnect.test.js +44 -23
- package/dist/core/__tests__/full/broker-attach-limits.test.js +60 -36
- package/dist/core/__tests__/full/broker-attach-stream.test.js +4 -4
- package/dist/core/__tests__/full/broker-dialogs.test.js +121 -62
- package/dist/core/__tests__/helpers/broker-clients.js +2 -2
- package/dist/core/__tests__/session-model.test.js +15 -26
- 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 +0 -2
- package/dist/core/runtime/auth-reload.d.ts +4 -4
- package/dist/core/runtime/auth-reload.js +9 -13
- 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 +23 -27
- package/dist/core/runtime/broker-protocol.js +1 -1
- package/dist/core/runtime/broker-request.js +5 -11
- package/dist/core/runtime/broker.d.ts +21 -13
- package/dist/core/runtime/broker.js +151 -186
- package/dist/core/runtime/interactive-deliver.js +4 -5
- package/dist/core/runtime/model-swap.d.ts +2 -3
- package/dist/core/runtime/model-swap.js +3 -4
- package/dist/core/runtime/node-read.d.ts +20 -0
- package/dist/core/runtime/node-read.js +34 -1
- 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 +8 -6
- package/dist/core/session-model/session-state.js +6 -16
- package/dist/daemon/api/handlers/nodes.js +18 -1
- package/dist/daemon/manage.js +2 -2
- package/dist/index.d.ts +1 -1
- package/dist/pi-extensions/canvas-bash-valve.js +4 -3
- package/dist/web-client/assets/{index-DJhQZoAj.css → index-CpEl9LTS.css} +1 -1
- package/dist/web-client/assets/{index--SsQYcKu.js → index-CsuwzlcQ.js} +19 -19
- package/dist/web-client/index.html +2 -2
- package/dist/web-client/sw.js +1 -1
- package/docs/compat/hearth-crtr-v5.md +175 -0
- package/docs/public-api.md +2 -2
- package/package.json +2 -2
- package/runtime.lock.json +6 -6
- package/dist/core/__tests__/full/broker-control-preempt.test.d.ts +0 -1
- package/dist/core/__tests__/full/broker-control-preempt.test.js +0 -61
|
@@ -78,10 +78,10 @@ export interface SessionStatsSummary {
|
|
|
78
78
|
assistant_messages: number;
|
|
79
79
|
cost?: number;
|
|
80
80
|
}
|
|
81
|
-
/** Viewer
|
|
81
|
+
/** Viewer presence for a node — a truthful count of attached views. There is
|
|
82
|
+
* no owner: every live interactive view is writable at the same time. */
|
|
82
83
|
export interface Presence {
|
|
83
84
|
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,7 +276,9 @@ export interface ResolveDeckResponse {
|
|
|
276
276
|
* `isStreaming` (spec C.10).
|
|
277
277
|
*/
|
|
278
278
|
export type SessionState = RpcSessionState;
|
|
279
|
-
/**
|
|
279
|
+
/** Fixed capability of one browser tab's session connection, stated by the
|
|
280
|
+
* broker's `welcome`: `controller` is writable, `observer` is read-only. It is
|
|
281
|
+
* a per-client capability, never ownership of a shared slot. */
|
|
280
282
|
export type WebRole = 'observer' | 'controller';
|
|
281
283
|
/** Upstream broker connectivity, surfaced to the tab (spec §6.2). */
|
|
282
284
|
export type BrokerStatus = 'connected' | 'reconnecting' | 'down' | 'revived';
|
package/dist/commands/node.js
CHANGED
|
@@ -808,7 +808,7 @@ const nodeBashBackground = defineLeaf({
|
|
|
808
808
|
{ name: 'jobs', type: 'object[]', required: true, constraint: 'Per handed-off job {job_id, elapsed_ms} — the foreground elapsed time in milliseconds at the moment of handoff.' },
|
|
809
809
|
],
|
|
810
810
|
outputKind: 'object',
|
|
811
|
-
effects: ['Signals every foreground bash command currently running for the node to return from its tool call immediately while its detached supervisor keeps the command running.', 'Each handed-off command retains its log under the node context jobs directory and sends the node an inbox message when it exits.'],
|
|
811
|
+
effects: ['Signals every foreground bash command currently running for the node to return from its tool call immediately while its detached supervisor keeps the command running.', 'Each handed-off command retains its log under the node context jobs directory and sends the node an urgent inbox message when it exits, which steers the node mid-turn.'],
|
|
812
812
|
},
|
|
813
813
|
run: async (input) => {
|
|
814
814
|
const pane = input['pane'] ?? process.env['TMUX_PANE'];
|
|
@@ -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-writable 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 writable() snapshot that reports zero writable viewers.
|
|
247
247
|
// ===========================================================================
|
|
248
|
-
test('C2 — zero-
|
|
248
|
+
test('C2 — zero-writable UI context resolves dialogs to deny/cancel immediately (noOp, no hang)', async () => {
|
|
249
249
|
const ctx = makeBrokerUiContext({
|
|
250
|
-
|
|
250
|
+
writable: () => [], // ZERO writable 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 writable viewer is attached');
|
|
253
253
|
},
|
|
254
254
|
pending: new Map(),
|
|
255
255
|
broadcast: () => { },
|
|
@@ -274,7 +274,7 @@ test('C2 — zero-viewer UI context resolves dialogs to deny/cancel immediately
|
|
|
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
|
+
writable: () => [],
|
|
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
|
-
// pending so a brief detach/reattach (or
|
|
296
|
-
// not lose an answerable dialog. The broker-side default timeout is the ONLY
|
|
294
|
+
// forwarded to a writable viewer that then DETACHES must NOT be cancelled: it
|
|
295
|
+
// stays pending so a brief detach/reattach (or another writable viewer joining)
|
|
296
|
+
// does 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 when its writable viewer detaches, 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
|
+
writable: () => (attached ? [{ id: 'c1' }] : []),
|
|
306
306
|
forward: () => {
|
|
307
|
-
/* a real
|
|
307
|
+
/* a real writable viewer 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
|
-
// request retained for welcome.pending_dialog /
|
|
315
|
-
// resolve yet. A short per-dialog timeout stands in for the 120s broker default.
|
|
313
|
+
// A writable viewer attached → confirm() forwards + registers a pending dialog
|
|
314
|
+
// (with the request retained for welcome.pending_dialog / replay, T4) and does
|
|
315
|
+
// NOT 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 writable viewer 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
|
+
// The writable viewer detaches → the dialog STAYS pending (the broker no longer cancels it).
|
|
322
322
|
attached = false;
|
|
323
|
-
assert.equal(pending.size, 1, 'M2: detach does NOT cancel the in-flight dialog');
|
|
323
|
+
assert.equal(pending.size, 1, 'M2: writable-viewer 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,23 +41,16 @@ 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
|
-
|
|
54
|
-
transcript: [],
|
|
55
|
-
draft: '',
|
|
56
|
-
notices: [],
|
|
57
|
-
pendingDialog: null,
|
|
58
|
-
queued: [],
|
|
59
|
-
contextTokens: undefined,
|
|
60
|
-
};
|
|
44
|
+
test('reconnect keeps the requested role while welcome and read_only remain authoritative', () => {
|
|
45
|
+
let current = core.init({ target: 'n1', role: 'controller' });
|
|
46
|
+
const sent = [];
|
|
47
|
+
const requestedRoles = [];
|
|
48
|
+
const closed = [];
|
|
49
|
+
const channels = [
|
|
50
|
+
{ id: 'first-channel', send(frame) { sent.push(frame); }, close() { closed.push('first-channel'); } },
|
|
51
|
+
{ id: 'replacement-channel', send(frame) { sent.push(frame); }, close() { closed.push('replacement-channel'); } },
|
|
52
|
+
];
|
|
53
|
+
let nextChannel = 0;
|
|
61
54
|
const ctx = {
|
|
62
55
|
get state() {
|
|
63
56
|
return current;
|
|
@@ -65,6 +58,13 @@ test('control_changed demotion updates reconnect intent without clobbering an ex
|
|
|
65
58
|
set(next) {
|
|
66
59
|
current = typeof next === 'function' ? next(current) : next;
|
|
67
60
|
},
|
|
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,11 +74,32 @@ test('control_changed demotion updates reconnect intent without clobbering an ex
|
|
|
74
74
|
quit() { },
|
|
75
75
|
},
|
|
76
76
|
};
|
|
77
|
-
core.intents.
|
|
78
|
-
|
|
79
|
-
assert.equal(current.desiredRole, '
|
|
80
|
-
current
|
|
81
|
-
|
|
82
|
-
|
|
77
|
+
core.intents.refresh(ctx);
|
|
78
|
+
const firstChannel = current.channel;
|
|
79
|
+
assert.equal(current.desiredRole, 'controller');
|
|
80
|
+
assert.equal(current.conn, 'connecting');
|
|
81
|
+
assert.deepEqual(requestedRoles, ['controller']);
|
|
82
|
+
core.intents.streamOpen(ctx, firstChannel);
|
|
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']);
|
|
83
88
|
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/);
|
|
84
105
|
});
|
|
@@ -3,27 +3,32 @@
|
|
|
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: multi-writer
|
|
7
|
+
// admission, 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
|
-
//
|
|
10
|
+
// (1) CONTRACT — every client that hellos as `controller` is welcomed writable,
|
|
11
|
+
// simultaneously and independently; each writable client may submit, and
|
|
12
|
+
// every connected client (both writers plus a read-only observer) receives
|
|
13
|
+
// the merged stream of both submissions, while a client that hellos as
|
|
14
|
+
// `observer` is rejected with error{read_only} when it tries to drive (G4).
|
|
15
|
+
// An oversized client line is capped and dropped with frame_overflow while
|
|
16
|
+
// the broker and other clients continue operating (G7). A stalled
|
|
17
|
+
// non-reading viewer is shed at the 32 MiB backpressure HWM while the
|
|
18
|
+
// broker and fast viewers remain unaffected (G8).
|
|
19
|
+
// (2) WHY BROKER/SOCKET-LEVEL, NOT PANE/WINDOW — role admission, decoder limits,
|
|
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.
|
|
20
24
|
// (3) HOW THE HEADLESS DRIVE ASSERTS THE CONTRACT — the real socket responses
|
|
21
|
-
// provide the G4 welcome roles
|
|
22
|
-
//
|
|
25
|
+
// provide the G4 welcome roles, per-writer relay frames and the read-only
|
|
26
|
+
// rejection, the G7 frame-overflow drop and broker survival, and the G8
|
|
27
|
+
// HWM-shed log line and broker survival.
|
|
23
28
|
//
|
|
24
|
-
// One shared broker is booted in before() and reused across all three gates.
|
|
25
|
-
//
|
|
26
|
-
//
|
|
29
|
+
// One shared broker is booted in before() and reused across all three gates. There
|
|
30
|
+
// is no writer slot to hand off, so every gate simply attaches at the role it
|
|
31
|
+
// wants.
|
|
27
32
|
import { test, before, after, afterEach } from 'node:test';
|
|
28
33
|
import assert from 'node:assert/strict';
|
|
29
34
|
import { createHeadlessHarness } from '../helpers/harness.js';
|
|
@@ -32,9 +37,7 @@ import { createAttachKit, delay, tok, frameHas, brokerLogText, } from '../helper
|
|
|
32
37
|
let h;
|
|
33
38
|
let id; // ONE shared broker, reused across G4/G7/G8 (1 real boot, not 3)
|
|
34
39
|
const kit = createAttachKit(() => h);
|
|
35
|
-
const { attach,
|
|
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`);
|
|
40
|
+
const { attach, connectRaw } = kit;
|
|
38
41
|
before(async () => {
|
|
39
42
|
h = await createHeadlessHarness({ sessionPrefix: 'crtr-brklim' });
|
|
40
43
|
const root = h.spawnRoot('broker-attach-limits suite root');
|
|
@@ -49,23 +52,44 @@ afterEach(() => {
|
|
|
49
52
|
});
|
|
50
53
|
const brokerPid = () => h.node(id).pi_pid;
|
|
51
54
|
// ---------------------------------------------------------------------------
|
|
52
|
-
// G4 —
|
|
53
|
-
//
|
|
54
|
-
//
|
|
55
|
-
// that
|
|
55
|
+
// G4 — multi-writer admission + read-only gating. Guards: two simultaneous
|
|
56
|
+
// `controller` hellos are BOTH welcomed writable, each one's own submission
|
|
57
|
+
// reaches the engine, and every connected client sees BOTH turns; an explicit
|
|
58
|
+
// observer that tries to drive is rejected with error{read_only}.
|
|
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.
|
|
56
62
|
// ---------------------------------------------------------------------------
|
|
57
|
-
test('G4 —
|
|
58
|
-
const
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
63
|
+
test('G4 — two simultaneous clients are both welcomed controller, each submits, and both see both turns; an observer drive → error{read_only}', async () => {
|
|
64
|
+
const [w1, w2] = await Promise.all([
|
|
65
|
+
attach(id, 'controller', 'g4-writer-1'),
|
|
66
|
+
attach(id, 'controller', 'g4-writer-2'),
|
|
67
|
+
]);
|
|
68
|
+
assert.equal(w1.welcome.role, 'controller', 'first client is welcomed writable');
|
|
69
|
+
assert.equal(w2.welcome.role, 'controller', 'second SIMULTANEOUS client is ALSO welcomed writable (no singleton slot)');
|
|
70
|
+
const observer = await attach(id, 'observer', 'g4-observer');
|
|
71
|
+
assert.equal(observer.welcome.role, 'observer', 'an explicit observer hello stays read-only');
|
|
72
|
+
// A read-only client may not drive the engine — the role is per-client and
|
|
73
|
+
// fixed by its own hello, so this rejection is independent of who else is
|
|
74
|
+
// attached.
|
|
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');
|
|
69
93
|
});
|
|
70
94
|
// ---------------------------------------------------------------------------
|
|
71
95
|
// 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. Each gate attaches
|
|
36
|
+
// a fresh writable client; afterEach closes prior sockets before the next gate begins,
|
|
37
|
+
// and each client's role remains fixed for its socket.
|
|
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
|
+
// Attach a writable client for the gate; its role remains fixed for this socket.
|
|
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' });
|
|
@@ -3,31 +3,35 @@
|
|
|
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
|
-
// Broker dialog handling — the T8 attach gates
|
|
7
|
-
//
|
|
6
|
+
// Broker dialog handling under MULTI-WRITER attachments — the T8 attach gates
|
|
7
|
+
// G5/G5b/G6 cover fan-out + first-response settlement, mid-dialog attach replay,
|
|
8
|
+
// and attended-dialog timeout behavior.
|
|
8
9
|
//
|
|
9
10
|
// 3-PART HEADER (headless):
|
|
10
|
-
// (1) CONTRACT —
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
11
|
+
// (1) CONTRACT — every client whose `hello` claimed the writable role is fanned a
|
|
12
|
+
// blocking dialog as extension_ui_request; the FIRST extension_ui_response
|
|
13
|
+
// settles it with that answer (not the default), every fanned client receives
|
|
14
|
+
// the correlated extension_ui_dismiss, and a later peer response for the same
|
|
15
|
+
// id is a silent no-op (G5). A dialog stays pending through a peer's detach and
|
|
16
|
+
// is handed to any writable client attaching mid-dialog via
|
|
17
|
+
// welcome.pending_dialog; observers never receive an answerable dialog (G5b).
|
|
18
|
+
// An attended dialog nobody answers resolves on the short per-dialog timeout
|
|
19
|
+
// with the default answer and a correlated dismiss to EVERY writable peer (G6).
|
|
17
20
|
// (2) WHY BROKER/SOCKET-LEVEL, NOT PANE/WINDOW — the dialog round-trip is an async
|
|
18
|
-
// promise inside makeBrokerUiContext,
|
|
21
|
+
// promise inside makeBrokerUiContext, fanned out and answered over view.sock to
|
|
19
22
|
// the production ViewSocketClient. The real broker process provides the
|
|
20
23
|
// uiContext and timeout machinery without a tmux pane, window, or pi-session
|
|
21
24
|
// read.
|
|
22
25
|
// (3) HOW THE HEADLESS DRIVE ASSERTS THE CONTRACT — the fake engine raises real
|
|
23
|
-
// confirm() dialogs through the broker, and the tests assert on
|
|
24
|
-
//
|
|
26
|
+
// confirm() dialogs through the broker, and the tests assert on the frames of
|
|
27
|
+
// several concurrently attached clients plus the value and latency recorded in
|
|
28
|
+
// fake-pi.dialog.jsonl.
|
|
25
29
|
//
|
|
26
30
|
// One shared broker is booted in before() and reused across all three gates. Because
|
|
27
31
|
// fake-pi.dialog.jsonl is append-only across the shared broker, each gate snapshots
|
|
28
|
-
// the count before it drives (`base`) and asserts on its newly appended entry.
|
|
29
|
-
//
|
|
30
|
-
//
|
|
32
|
+
// the count before it drives (`base`) and asserts on its newly appended entry. Role
|
|
33
|
+
// is fixed by `hello` and never reassigned, so every writable attach is a plain
|
|
34
|
+
// attach — there is no control handoff to wait out.
|
|
31
35
|
import { test, before, after, afterEach } from 'node:test';
|
|
32
36
|
import assert from 'node:assert/strict';
|
|
33
37
|
import { createHeadlessHarness } from '../helpers/harness.js';
|
|
@@ -36,9 +40,29 @@ import { createAttachKit } from '../helpers/broker-clients.js';
|
|
|
36
40
|
let h;
|
|
37
41
|
let id; // ONE shared broker, reused across G5/G5b/G6 (1 real boot, not 3)
|
|
38
42
|
const kit = createAttachKit(() => h);
|
|
39
|
-
const { attachUntil } = kit;
|
|
40
|
-
//
|
|
41
|
-
|
|
43
|
+
const { attach, attachUntil } = kit;
|
|
44
|
+
// Attach a writable client. Every `hello` claiming `controller` is admitted
|
|
45
|
+
// writable — attaching a peer never demotes an already-attached one.
|
|
46
|
+
const writer = async (cid) => {
|
|
47
|
+
const a = await attach(id, 'controller', cid);
|
|
48
|
+
assert.equal(a.welcome.role, 'controller', `${cid}: admitted writable, not demoted by an existing peer`);
|
|
49
|
+
return a;
|
|
50
|
+
};
|
|
51
|
+
const observer = async (cid) => {
|
|
52
|
+
const a = await attach(id, 'observer', cid);
|
|
53
|
+
assert.equal(a.welcome.role, 'observer', `${cid}: an observer hello stays read-only`);
|
|
54
|
+
return a;
|
|
55
|
+
};
|
|
56
|
+
// The broker handles one client's frames in order, so an `ack` for a get_commands
|
|
57
|
+
// sent AFTER some other frame proves that earlier frame was already processed.
|
|
58
|
+
// Used to make "a late peer answer changed nothing" a deterministic assertion
|
|
59
|
+
// instead of a sleep.
|
|
60
|
+
const barrier = async (c, label) => {
|
|
61
|
+
c.send({ type: 'get_commands' });
|
|
62
|
+
await c.waitFrame((f) => f.type === 'ack' && f.for === 'get_commands', label);
|
|
63
|
+
};
|
|
64
|
+
const isRequest = (f) => f.type === 'extension_ui_request';
|
|
65
|
+
const dismissFor = (reqId) => (f) => f.type === 'extension_ui_dismiss' && f.id === reqId;
|
|
42
66
|
// dialog.jsonl is append-only across the shared broker — snapshot the count, then
|
|
43
67
|
// wait for AT LEAST `want` new entries and return the full (cumulative) array.
|
|
44
68
|
const dialogCount = () => h.dialogResults(id).length;
|
|
@@ -57,65 +81,100 @@ afterEach(() => {
|
|
|
57
81
|
});
|
|
58
82
|
const brokerPid = () => h.node(id).pi_pid;
|
|
59
83
|
// ---------------------------------------------------------------------------
|
|
60
|
-
// G5 —
|
|
61
|
-
//
|
|
62
|
-
// engine with ITS
|
|
63
|
-
//
|
|
84
|
+
// G5 — fan-out + first-response settlement. Guards: ONE blocking dialog reaches
|
|
85
|
+
// EVERY writable client (never a single owner), the first answer unblocks the
|
|
86
|
+
// engine with ITS value (not the default), every fanned client gets the correlated
|
|
87
|
+
// extension_ui_dismiss, and a peer answering afterwards is a no-op. Failure modes:
|
|
88
|
+
// a dialog only one viewer can see (the others sit blind), peers left holding an
|
|
89
|
+
// overlay nothing can answer, or a late peer answer re-settling a dead dialog.
|
|
64
90
|
// ---------------------------------------------------------------------------
|
|
65
|
-
test('G5 —
|
|
91
|
+
test('G5 — one dialog fans out to both writable clients, the first response settles it, both are dismissed, and a late peer response is a no-op', async () => {
|
|
66
92
|
const base = dialogCount();
|
|
67
|
-
const
|
|
68
|
-
|
|
69
|
-
const
|
|
70
|
-
|
|
71
|
-
const
|
|
72
|
-
|
|
73
|
-
const
|
|
74
|
-
assert.equal(
|
|
93
|
+
const a = await writer('g5-A');
|
|
94
|
+
const b = await writer('g5-B');
|
|
95
|
+
const obs = await observer('g5-obs'); // must never be fanned an answerable dialog
|
|
96
|
+
h.fakeCmd(id, { cmd: 'dialog', timeout: 20_000 }); // generous: a writable client answers first
|
|
97
|
+
const reqA = await a.waitFrame(isRequest, 'G5 dialog fanned out to writable A');
|
|
98
|
+
const reqB = await b.waitFrame(isRequest, 'G5 dialog fanned out to writable B');
|
|
99
|
+
const reqId = reqA.id;
|
|
100
|
+
assert.equal(reqB.id, reqId, 'G5: both writable clients were fanned the SAME dialog id, not two dialogs');
|
|
101
|
+
assert.equal(reqA.method, 'confirm', 'G5: the fanned dialog is the confirm() the engine raised');
|
|
102
|
+
// A answers first — the engine must proceed with A's true, not the false default.
|
|
103
|
+
a.send({ type: 'extension_ui_response', id: reqId, confirmed: true });
|
|
104
|
+
await a.waitFrame(dismissFor(reqId), 'G5 answerer A receives the correlated dismiss');
|
|
105
|
+
await b.waitFrame(dismissFor(reqId), 'G5 peer B receives the correlated dismiss for the dialog it never answered');
|
|
106
|
+
const results = await awaitDialogs(base + 1, 'G5 dialog settled by the first response');
|
|
107
|
+
assert.equal(results[base].resolved, true, 'G5: the engine proceeded with the first answer (true), not the default (false)');
|
|
108
|
+
// The observer was attached the whole time: it saw the broadcast dismiss but was
|
|
109
|
+
// never handed the answerable request.
|
|
110
|
+
await obs.waitFrame(dismissFor(reqId), 'G5 observer sees the broadcast dismiss (ordering barrier)');
|
|
111
|
+
assert.equal(obs.frames.filter(isRequest).length, 0, 'G5: an observer is never fanned an answerable dialog');
|
|
112
|
+
assert.equal(obs.welcome.pending_dialog, null, 'G5: an observer welcome never carries a pending dialog');
|
|
113
|
+
// B answers the already-settled dialog. The broker deleted the entry on the first
|
|
114
|
+
// response, so this finds nothing and is a silent no-op: no second engine
|
|
115
|
+
// resolution, and the recorded answer stays A's.
|
|
116
|
+
b.send({ type: 'extension_ui_response', id: reqId, confirmed: false });
|
|
117
|
+
await barrier(b, 'G5 broker processed B late response (ordered ack)');
|
|
118
|
+
const settled = h.dialogResults(id);
|
|
119
|
+
assert.equal(settled.length, base + 1, 'G5: the late peer response resolved nothing — no second dialog entry');
|
|
120
|
+
assert.equal(settled[base].resolved, true, "G5: the late peer response did not overwrite the first answerer's value");
|
|
121
|
+
assert.equal(isPidAlive(brokerPid()), true, 'G5: the late no-op response did not fault the broker');
|
|
75
122
|
});
|
|
76
123
|
// ---------------------------------------------------------------------------
|
|
77
|
-
//
|
|
78
|
-
//
|
|
79
|
-
//
|
|
80
|
-
//
|
|
124
|
+
// G5b (mid-dialog attach) — Guards: a pending dialog survives a writable peer's
|
|
125
|
+
// detach (M2) and is handed to a writable client that attaches MID-dialog via
|
|
126
|
+
// welcome.pending_dialog, which makes it a full peer in the race to answer. An
|
|
127
|
+
// observer attaching mid-dialog gets nothing answerable. Failure modes: a pending
|
|
128
|
+
// dialog lost when the raising viewer detaches, a late-joining writable viewer
|
|
129
|
+
// blind to an in-flight dialog, or an observer handed one it cannot answer.
|
|
81
130
|
// ---------------------------------------------------------------------------
|
|
82
|
-
test('
|
|
131
|
+
test('G5b — a writable client attaching MID-dialog receives the pending dialog; an observer does not', async () => {
|
|
83
132
|
const base = dialogCount();
|
|
84
|
-
const a = await
|
|
85
|
-
h.fakeCmd(id, { cmd: 'dialog', timeout: 30_000 }); // stays pending long enough for the
|
|
86
|
-
const reqA = await a.waitFrame(
|
|
133
|
+
const a = await writer('g5b-A');
|
|
134
|
+
h.fakeCmd(id, { cmd: 'dialog', timeout: 30_000 }); // stays pending long enough for the late attach
|
|
135
|
+
const reqA = await a.waitFrame(isRequest, 'G5b dialog fanned out to writable A');
|
|
87
136
|
const reqId = reqA.id;
|
|
88
137
|
a.send({ type: 'bye' });
|
|
89
|
-
a.close(); // M2:
|
|
90
|
-
//
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
138
|
+
a.close(); // M2: a writable peer detaching does NOT cancel the pending dialog
|
|
139
|
+
// An observer attaching mid-dialog is never handed it.
|
|
140
|
+
const obs = await observer('g5b-obs');
|
|
141
|
+
assert.equal(obs.welcome.pending_dialog, null, 'G5b: an observer attaching mid-dialog gets no pending dialog');
|
|
142
|
+
// A writable client attaching mid-dialog IS handed it (retry covers the beat
|
|
143
|
+
// between the raise and this attach) and can settle it.
|
|
144
|
+
const b = await attachUntil(id, 'controller', 'g5b-B', (x) => x.welcome.role === 'controller' && x.welcome.pending_dialog != null, 'G5b writable B attaches mid-dialog and is handed the pending dialog');
|
|
145
|
+
assert.equal(b.welcome.pending_dialog.id, reqId, 'G5b: welcome.pending_dialog is the same dialog raised while A was attached');
|
|
94
146
|
assert.equal(b.welcome.pending_dialog.method, 'confirm', 'G5b: the pending dialog is the confirm()');
|
|
95
147
|
b.send({ type: 'extension_ui_response', id: reqId, confirmed: true });
|
|
96
|
-
const results = await awaitDialogs(base + 1, 'G5b dialog
|
|
97
|
-
assert.equal(results[base].resolved, true, 'G5b:
|
|
148
|
+
const results = await awaitDialogs(base + 1, 'G5b dialog settled by the mid-dialog joiner');
|
|
149
|
+
assert.equal(results[base].resolved, true, 'G5b: the mid-dialog joiner answered and the engine proceeded');
|
|
150
|
+
await obs.waitFrame(dismissFor(reqId), 'G5b dismiss broadcast follows the settlement (ordering barrier)');
|
|
151
|
+
assert.equal(obs.frames.filter(isRequest).length, 0, 'G5b: the observer was never fanned the dialog it saw dismissed');
|
|
98
152
|
});
|
|
99
153
|
// ---------------------------------------------------------------------------
|
|
100
|
-
// G6 — anti-deadlock. An ATTENDED dialog
|
|
101
|
-
//
|
|
102
|
-
//
|
|
154
|
+
// G6 — anti-deadlock. An ATTENDED dialog NO writable client answers resolves on a
|
|
155
|
+
// SHORT per-dialog broker timeout, and every fanned peer is dismissed by id.
|
|
156
|
+
// Guards: the engine never hangs on a dialog whose viewers all stay silent.
|
|
157
|
+
// Failure mode: a forever-blocked turn, or overlays left up on every viewer.
|
|
103
158
|
// ---------------------------------------------------------------------------
|
|
104
|
-
test('G6 — an
|
|
159
|
+
test('G6 — an attended dialog no writable client answers resolves on the broker timeout and dismisses every peer', async () => {
|
|
105
160
|
const pid = brokerPid();
|
|
106
161
|
const base = dialogCount();
|
|
107
|
-
//
|
|
108
|
-
// per-dialog timeout (800ms), never the 120s default. On timeout
|
|
109
|
-
// broadcasts a correlated `extension_ui_dismiss` keyed to the dialog's id, so
|
|
110
|
-
//
|
|
111
|
-
const
|
|
162
|
+
// Two writable clients attached but silent → the broker resolves on the SHORT
|
|
163
|
+
// explicit per-dialog timeout (800ms), never the 120s default. On timeout it
|
|
164
|
+
// broadcasts a correlated `extension_ui_dismiss` keyed to the dialog's id, so
|
|
165
|
+
// EVERY peer's overlay (none has an independent timer) is torn down by id.
|
|
166
|
+
const a = await writer('g6-A');
|
|
167
|
+
const b = await writer('g6-B');
|
|
112
168
|
h.fakeCmd(id, { cmd: 'dialog', timeout: 800 });
|
|
113
|
-
const
|
|
114
|
-
const
|
|
115
|
-
const
|
|
116
|
-
assert.equal(
|
|
117
|
-
const
|
|
118
|
-
|
|
119
|
-
assert.
|
|
169
|
+
const reqA = await a.waitFrame(isRequest, 'G6 dialog fanned out to writable A'); // received, deliberately NOT answered
|
|
170
|
+
const reqB = await b.waitFrame(isRequest, 'G6 dialog fanned out to writable B'); // received, deliberately NOT answered
|
|
171
|
+
const reqId = reqA.id;
|
|
172
|
+
assert.equal(reqB.id, reqId, 'G6: both silent peers hold the SAME dialog id');
|
|
173
|
+
const dismissA = await a.waitFrame(dismissFor(reqId), 'G6 broker emits a correlated dismiss to A on timeout');
|
|
174
|
+
await b.waitFrame(dismissFor(reqId), 'G6 broker emits the same correlated dismiss to peer B');
|
|
175
|
+
assert.equal(dismissA.id, reqId, 'G6: the dismiss frame carries the timed-out dialog id, not another');
|
|
176
|
+
const results = await awaitDialogs(base + 1, 'G6 attended dialog resolved on timeout');
|
|
177
|
+
assert.equal(results[base].resolved, false, 'G6: a dialog no writable client answered resolves to the default (deny)');
|
|
178
|
+
assert.ok(results[base].ms >= 600 && results[base].ms < 5000, `G6: resolved on the ~800ms per-dialog timeout, not instantly and not the 120s default — got ${results[base].ms}ms`);
|
|
120
179
|
assert.equal(isPidAlive(pid), true, 'G6: the engine made forward progress (still alive)');
|
|
121
180
|
});
|