@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.
Files changed (80) hide show
  1. package/README.md +2 -1
  2. package/dist/api/client.d.ts +1 -4
  3. package/dist/api/client.js +0 -5
  4. package/dist/api/dto/broker.d.ts +1 -1
  5. package/dist/api/dto/nodes.d.ts +0 -15
  6. package/dist/api/routes.d.ts +0 -1
  7. package/dist/api/routes.js +0 -1
  8. package/dist/builtin-views/chat/core.mjs +51 -6
  9. package/dist/builtin-views/chat/tui.mjs +14 -6
  10. package/dist/builtin-views/chat/web.jsx +7 -2
  11. package/dist/clients/attach/__tests__/oauth-dialog-lifecycle.test.js +1 -1
  12. package/dist/clients/attach/chrome/roster.d.ts +1 -1
  13. package/dist/clients/attach/chrome/roster.js +1 -9
  14. package/dist/clients/attach/command.js +5 -5
  15. package/dist/clients/attach/input/controller.d.ts +8 -23
  16. package/dist/clients/attach/input/controller.js +29 -59
  17. package/dist/clients/attach/overlays/dialogs.d.ts +2 -1
  18. package/dist/clients/attach/overlays/graph.d.ts +1 -4
  19. package/dist/clients/attach/overlays/graph.js +7 -25
  20. package/dist/clients/attach/session/context.d.ts +0 -7
  21. package/dist/clients/attach/session/frames.js +8 -1
  22. package/dist/clients/attach/session/input-wiring.d.ts +1 -1
  23. package/dist/clients/attach/session/input-wiring.js +11 -21
  24. package/dist/clients/attach/session/mode.d.ts +3 -4
  25. package/dist/clients/attach/session/mode.js +1 -6
  26. package/dist/clients/attach/session/reconnect.d.ts +3 -3
  27. package/dist/clients/attach/session/reconnect.js +7 -6
  28. package/dist/clients/attach/session/state-sync.d.ts +1 -1
  29. package/dist/clients/attach/session/state-sync.js +2 -2
  30. package/dist/clients/attach/slash/dispatch.d.ts +1 -13
  31. package/dist/clients/attach/slash/dispatch.js +17 -65
  32. package/dist/clients/attach/viewer.js +523 -523
  33. package/dist/clients/web/web-client/shared/protocol.d.ts +3 -5
  34. package/dist/core/__tests__/broker-sdk-wiring.test.js +18 -18
  35. package/dist/core/__tests__/chat-view-reconnect.test.js +23 -44
  36. package/dist/core/__tests__/full/broker-attach-limits.test.js +36 -60
  37. package/dist/core/__tests__/full/broker-attach-stream.test.js +4 -4
  38. package/dist/core/__tests__/full/broker-control-preempt.test.d.ts +1 -0
  39. package/dist/core/__tests__/full/broker-control-preempt.test.js +61 -0
  40. package/dist/core/__tests__/full/broker-dialogs.test.js +62 -121
  41. package/dist/core/__tests__/helpers/broker-clients.js +2 -2
  42. package/dist/core/__tests__/session-model.test.js +26 -15
  43. package/dist/core/keybindings/__tests__/resolve.test.js +1 -1
  44. package/dist/core/keybindings/catalog.d.ts +2 -2
  45. package/dist/core/keybindings/catalog.js +2 -0
  46. package/dist/core/runtime/auth-reload.d.ts +4 -4
  47. package/dist/core/runtime/auth-reload.js +13 -9
  48. package/dist/core/runtime/boot-root.d.ts +2 -2
  49. package/dist/core/runtime/boot-root.js +7 -7
  50. package/dist/core/runtime/broker-protocol.d.ts +27 -23
  51. package/dist/core/runtime/broker-protocol.js +1 -1
  52. package/dist/core/runtime/broker-request.js +11 -5
  53. package/dist/core/runtime/broker.d.ts +13 -21
  54. package/dist/core/runtime/broker.js +186 -151
  55. package/dist/core/runtime/interactive-deliver.js +5 -4
  56. package/dist/core/runtime/model-swap.d.ts +3 -2
  57. package/dist/core/runtime/model-swap.js +4 -3
  58. package/dist/core/runtime/node-read.d.ts +0 -20
  59. package/dist/core/runtime/node-read.js +1 -34
  60. package/dist/core/runtime/resume-root.d.ts +1 -1
  61. package/dist/core/runtime/resume-root.js +6 -6
  62. package/dist/core/runtime/spawn.js +3 -3
  63. package/dist/core/session-model/session-state.d.ts +6 -8
  64. package/dist/core/session-model/session-state.js +16 -6
  65. package/dist/daemon/api/handlers/nodes.js +1 -18
  66. package/dist/daemon/manage.js +2 -2
  67. package/dist/index.d.ts +1 -1
  68. package/dist/web-client/assets/index--SsQYcKu.js +79 -0
  69. package/dist/web-client/assets/{index-CpEl9LTS.css → index-DJhQZoAj.css} +1 -1
  70. package/dist/web-client/index.html +2 -2
  71. package/dist/web-client/sw.js +1 -1
  72. package/docs/compat/hearth-crtr-v1.md +1 -1
  73. package/docs/compat/hearth-crtr-v2.md +1 -1
  74. package/docs/compat/hearth-crtr-v3.md +1 -1
  75. package/docs/compat/hearth-crtr-v4.md +3 -1
  76. package/docs/public-api.md +2 -2
  77. package/package.json +4 -4
  78. package/runtime.lock.json +2 -2
  79. package/dist/web-client/assets/index-BpyZGBhI.js +0 -79
  80. 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 — a truthful count of attached views. There is
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
- /** 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. */
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-writable dialogs resolve to deny/cancel IMMEDIATELY (noOp), never
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 writable() snapshot that reports zero writable viewers.
246
+ // with a controller() that reports zero viewers.
247
247
  // ===========================================================================
248
- test('C2 — zero-writable UI context resolves dialogs to deny/cancel immediately (noOp, no hang)', async () => {
248
+ test('C2 — zero-viewer UI context resolves dialogs to deny/cancel immediately (noOp, no hang)', async () => {
249
249
  const ctx = makeBrokerUiContext({
250
- writable: () => [], // ZERO writable viewers attached
250
+ controller: () => null, // ZERO viewers attached
251
251
  forward: () => {
252
- throw new Error('C2: must NOT forward a dialog when no writable viewer is attached');
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
- writable: () => [],
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 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
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 when its writable viewer detaches, resolving only on the broker-side timeout', async () => {
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
- writable: () => (attached ? [{ id: 'c1' }] : []),
305
+ controller: () => (attached ? { id: 'c1' } : null),
306
306
  forward: () => {
307
- /* a real writable viewer would receive the request over its socket */
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
- // 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.
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 writable viewer is attached');
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
- // The writable viewer detaches → the dialog STAYS pending (the broker no longer cancels it).
321
+ // Controller detaches → the dialog STAYS pending (the broker no longer cancels it).
322
322
  attached = false;
323
- assert.equal(pending.size, 1, 'M2: writable-viewer detach does NOT cancel the in-flight dialog');
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('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;
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.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']);
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: multi-writer
7
- // admission, decoder overflow isolation, and backpressure shedding.
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 — 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.
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, 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.
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. There
30
- // is no writer slot to hand off, so every gate simply attaches at the role it
31
- // wants.
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 — 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.
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 — 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');
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. 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.
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
- // Attach a writable client for the gate; its role remains fixed for this socket.
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,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
+ });