@crouton-kit/crouter 0.3.21 → 0.3.23

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