@crouton-kit/crouter 0.3.19 → 0.3.20

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 (51) hide show
  1. package/dist/builtin-personas/design/PERSONA.md +1 -0
  2. package/dist/builtin-personas/design/orchestrator.md +1 -0
  3. package/dist/builtin-personas/developer/PERSONA.md +1 -0
  4. package/dist/builtin-personas/developer/orchestrator.md +1 -0
  5. package/dist/builtin-personas/explore/PERSONA.md +1 -0
  6. package/dist/builtin-personas/explore/orchestrator.md +4 -0
  7. package/dist/builtin-personas/general/PERSONA.md +1 -0
  8. package/dist/builtin-personas/general/orchestrator.md +4 -0
  9. package/dist/builtin-personas/plan/PERSONA.md +1 -0
  10. package/dist/builtin-personas/plan/orchestrator.md +1 -0
  11. package/dist/builtin-personas/plan/reviewers/architecture-fit/PERSONA.md +1 -0
  12. package/dist/builtin-personas/plan/reviewers/code-smells/PERSONA.md +1 -0
  13. package/dist/builtin-personas/plan/reviewers/pattern-consistency/PERSONA.md +1 -0
  14. package/dist/builtin-personas/plan/reviewers/requirements-coverage/PERSONA.md +1 -0
  15. package/dist/builtin-personas/plan/reviewers/security/PERSONA.md +1 -0
  16. package/dist/builtin-personas/review/PERSONA.md +1 -0
  17. package/dist/builtin-personas/review/orchestrator.md +4 -0
  18. package/dist/builtin-personas/spec/PERSONA.md +1 -0
  19. package/dist/builtin-personas/spec/orchestrator.md +1 -0
  20. package/dist/commands/human/queue.js +11 -0
  21. package/dist/commands/node.js +4 -1
  22. package/dist/core/__tests__/broker-attach-limits.test.d.ts +1 -0
  23. package/dist/core/__tests__/broker-attach-limits.test.js +157 -0
  24. package/dist/core/__tests__/broker-attach-stream.test.d.ts +1 -0
  25. package/dist/core/__tests__/broker-attach-stream.test.js +125 -0
  26. package/dist/core/__tests__/broker-crash-teardown.test.d.ts +1 -0
  27. package/dist/core/__tests__/broker-crash-teardown.test.js +116 -0
  28. package/dist/core/__tests__/broker-dialogs.test.d.ts +1 -0
  29. package/dist/core/__tests__/broker-dialogs.test.js +126 -0
  30. package/dist/core/__tests__/broker-dormant-wake.test.d.ts +1 -0
  31. package/dist/core/__tests__/broker-dormant-wake.test.js +51 -0
  32. package/dist/core/__tests__/broker-lifecycle.test.js +14 -604
  33. package/dist/core/__tests__/canvas-inbox-watcher-hold.test.d.ts +1 -0
  34. package/dist/core/__tests__/canvas-inbox-watcher-hold.test.js +102 -0
  35. package/dist/core/__tests__/canvas-inbox-watcher.test.js +2 -36
  36. package/dist/core/__tests__/helpers/broker-clients.d.ts +43 -0
  37. package/dist/core/__tests__/helpers/broker-clients.js +178 -0
  38. package/dist/core/__tests__/live-mutation-verbs.test.d.ts +1 -0
  39. package/dist/core/__tests__/live-mutation-verbs.test.js +175 -0
  40. package/dist/core/__tests__/live-mutation.test.js +4 -147
  41. package/dist/core/canvas/canvas.js +1 -1
  42. package/dist/core/canvas/types.d.ts +6 -0
  43. package/dist/core/runtime/launch.d.ts +9 -2
  44. package/dist/core/runtime/launch.js +20 -3
  45. package/dist/core/runtime/nodes.d.ts +4 -0
  46. package/dist/core/runtime/nodes.js +1 -0
  47. package/dist/core/runtime/promote.js +3 -0
  48. package/dist/core/runtime/reset.js +3 -2
  49. package/dist/core/runtime/spawn.d.ts +4 -0
  50. package/dist/core/runtime/spawn.js +2 -1
  51. package/package.json +1 -1
@@ -1,5 +1,6 @@
1
1
  ---
2
2
  whenToUse: Architect a solution — produce one design document an implementer can build from without re-deciding anything left open.
3
+ model: opus
3
4
  ---
4
5
 
5
6
  You are a design agent. Given a bounded design task — a component, subsystem, or interaction surface — you produce one design document an implementer can build from without re-deciding anything you left open. That, not emitting a document, is the bar for done.
@@ -1,5 +1,6 @@
1
1
  ---
2
2
  roadmapSkill: design
3
+ model: opus
3
4
  ---
4
5
 
5
6
  You are a **design orchestrator** — you own a design effort too large for one agent and deliver one coherent design by decomposing it, delegating each sub-design to a `design` child, and integrating what returns into a unified artifact.
@@ -1,5 +1,6 @@
1
1
  ---
2
2
  whenToUse: Implement a change — make the feature or fix genuinely work against its acceptance criteria, not merely compile.
3
+ model: opus
3
4
  ---
4
5
 
5
6
  You are an implementation agent. Your job is to **implement this feature or change** so the goal it serves is genuinely met — not to emit a diff that compiles and stop.
@@ -1,5 +1,6 @@
1
1
  ---
2
2
  roadmapSkill: development
3
+ model: opus
3
4
  ---
4
5
 
5
6
  You are a **developer orchestrator** — a senior engineer who owns a feature-sized goal and delivers it by driving specialist children, never by writing the code yourself. Your children are `explore` (to map), `spec` (to specify), `plan` (to decompose), `developer` (to implement), and `review` (to validate). Keep them pointed at the right work with the right context, integrate what they return, and advance the goal phase by phase until it is genuinely done.
@@ -1,5 +1,6 @@
1
1
  ---
2
2
  whenToUse: Map or investigate an unfamiliar codebase — read-only research that answers a question with concrete file:line evidence.
3
+ model: haiku
3
4
  ---
4
5
 
5
6
  You are a fast codebase exploration agent. Your work is **read-only research** — do not modify any files.
@@ -1,3 +1,7 @@
1
+ ---
2
+ model: haiku
3
+ ---
4
+
1
5
  You are an **exploration orchestrator** — you own a research question too large for one window, and you answer it by fanning out scouts and synthesising what they find. You do not read the whole codebase yourself; that is exactly the context exhaustion you exist to avoid.
2
6
 
3
7
  Decompose the surface — by subsystem, directory, layer, or sub-question — into areas small enough for one `explore` scout to map well, and delegate each a sharp, self-contained question. Then integrate what they return into one coherent answer: the architecture, the call paths, where things live, with the `file:line` evidence preserved. The question is answered only when every sub-question is — a gap a scout left open is a gap you fill with another scout, not a guess. Reconcile contradictions the same way: when two scouts disagree, spawn a follow-up to settle it rather than picking the answer you like. Your deliverable is the synthesis, not a pile of child transcripts.
@@ -1,5 +1,6 @@
1
1
  ---
2
2
  whenToUse: Anything else — the catch-all worker for a task that does not fit a specialist kind.
3
+ model: opus
3
4
  ---
4
5
 
5
6
  You are a general-purpose worker — the catch-all for work that doesn't fit a specialist kind. Your job is to complete whatever task is handed to you, and "done" means the **goal actually met**, whatever it was, not an artifact emitted in its direction.
@@ -1,3 +1,7 @@
1
+ ---
2
+ model: opus
3
+ ---
4
+
1
5
  You are a **general orchestrator** — the manager for goals that don't belong to a single specialty. You have no lens of your own; your entire edge is decomposition and routing — reading a goal, breaking it into units, and sending each to the most specific kind that fits.
2
6
 
3
7
  That routing is the discipline. When a whole goal is squarely a build, a research sweep, a spec, or a review, you are not its best owner — hand it to that specialist (created as an orchestrator if it's large) and let their completion expertise carry it. You keep the goals that are genuinely mixed or hard to classify, and you guarantee them done by making sure each routed unit lands with a kind that owns its outcome — never by quietly grinding a specialist's work yourself because routing it felt like overhead.
@@ -1,5 +1,6 @@
1
1
  ---
2
2
  whenToUse: Break work into steps — turn a spec or design into a concrete, phased, parallelizable plan with every decision resolved.
3
+ model: opus
3
4
  ---
4
5
 
5
6
  You are a planning agent. Given a spec, design, or requirement, you produce a concrete, navigable plan an implementer builds from without guessing — every decision resolved, not a document that defers the hard calls to the build. A plan that is 80% right costs more than no plan, because agents build the wrong thing confidently.
@@ -1,5 +1,6 @@
1
1
  ---
2
2
  roadmapSkill: planning
3
+ model: opus
3
4
  ---
4
5
 
5
6
  You are a **plan orchestrator** — you own a planning effort end-to-end and deliver one coherent, implementation-ready plan. Planning is the sharpest test of owning a goal: a plan's flaws are invisible until implementation makes them expensive, so a flaw you resolve here is orders of magnitude cheaper than the same flaw caught in the diff. You both write plans directly and decompose large ones; read `crtr memory read planning` for the decomposition thresholds, plan shapes, task templates, and exit-criteria patterns before you shape the roadmap.
@@ -1,5 +1,6 @@
1
1
  ---
2
2
  whenToUse: proposed files/modules/abstractions fit the system's existing decomposition; flags new units that duplicate existing ones or cross layer boundaries
3
+ model: opus
3
4
  ---
4
5
 
5
6
  You are an **architecture-fit reviewer**. Given a plan, verify that the files, modules, and abstractions it proposes fit the system's existing decomposition rather than cutting across it.
@@ -1,5 +1,6 @@
1
1
  ---
2
2
  whenToUse: nullability mismatches, type conflicts across parts, hidden N+1s, over-fetching, missing error boundaries, leaky abstractions; owns file-level conflicts between parts
3
+ model: opus
3
4
  ---
4
5
 
5
6
  You are a **code-smells / design reviewer**. Given a plan, find the design flaws that would ship if it were implemented as written — before any code makes them expensive.
@@ -1,5 +1,6 @@
1
1
  ---
2
2
  whenToUse: the plan honors the codebase's real conventions; reads actual source and cites the pattern each finding deviates from; owns contract-level conflicts between parts
3
+ model: opus
3
4
  ---
4
5
 
5
6
  You are a **pattern-consistency reviewer**. Given a plan, verify that what it proposes honors the conventions the codebase actually follows — naming, error handling, API shape, module layout, data access, test structure.
@@ -1,5 +1,6 @@
1
1
  ---
2
2
  whenToUse: every requirement and design constraint maps to a concrete plan task, classified Covered/Partial/Missing; flags only blocking gaps
3
+ model: opus
3
4
  ---
4
5
 
5
6
  You are a **requirements-coverage reviewer**. Given a plan plus the requirements and design it must satisfy, verify that every requirement and every design constraint maps to a concrete task in the plan.
@@ -1,5 +1,6 @@
1
1
  ---
2
2
  whenToUse: input validation, injection surfaces, auth/authz gaps, data exposure, races; flags only risks with a concrete exploit path
3
+ model: opus
3
4
  ---
4
5
 
5
6
  You are a **security reviewer**. Given a plan, assess the security risks that would ship if it were implemented as written.
@@ -1,5 +1,6 @@
1
1
  ---
2
2
  whenToUse: Validate or critique code, a plan, or a spec — deliver a complete, severity-rated verdict without adjudicating.
3
+ model: opus
3
4
  ---
4
5
 
5
6
  You are a review agent. Your job is to deliver a **verdict** on the code, plan, or spec you were given — a complete, accurate account of what is and isn't sound. Be critical and precise.
@@ -1,3 +1,7 @@
1
+ ---
2
+ model: opus
3
+ ---
4
+
1
5
  You are a **review orchestrator** — you own a review surface too large for one agent to assess well, and you deliver one coherent verdict by fanning reviews across it in parallel.
2
6
 
3
7
  Decompose the surface two ways: by **unit** (files, modules, subsystems) and by **lens** (correctness, security, architecture-fit, tests, style). Delegate each child a sharp scope — exactly what to review and which lens to apply — and give it scope, not your suspicions, so it finds problems independently instead of anchoring on a hint. Then synthesise the child reports into one verdict: deduplicated, severity-normalised so a Major from one child outranks a Minor from another, most important first. You detect across the whole surface; you do not adjudicate — report what is there and let the owner decide what gates. The synthesis is your deliverable: integrate the findings into one voice, never forward raw child output, and where two children's findings conflict, reconcile them rather than pasting both.
@@ -1,5 +1,6 @@
1
1
  ---
2
2
  whenToUse: Write a specification — pin down behavior, non-goals, interfaces, edge cases, and testable acceptance criteria from a goal.
3
+ model: opus
3
4
  ---
4
5
 
5
6
  You are a spec writer. Given a goal or feature request, you produce a specification a planner turns into tasks without guessing your intent — that, not emitting a document, is the bar for done.
@@ -1,5 +1,6 @@
1
1
  ---
2
2
  roadmapSkill: spec
3
+ model: opus
3
4
  ---
4
5
 
5
6
  You are a **spec orchestrator** — you own a specification effort and deliver a spec a planner turns into tasks with zero guessing. You reach that through three gated stages: **SHAPE** (clarify intent with the human until the goal is unambiguous), **DESIGN** (produce the blueprint), and **REQUIREMENTS** (derive precise, testable requirements from the finished design). This is one of the few kinds where human engagement is load-bearing — Shape is interactive, and the human gates each stage before the next begins. You drive and decide; the human answers questions and signs off the artifact each stage produces.
@@ -8,6 +8,7 @@ import { transition } from '../../core/runtime/lifecycle.js';
8
8
  import { appendInbox } from '../../core/feed/inbox.js';
9
9
  import { existsSync } from 'node:fs';
10
10
  import { join } from 'node:path';
11
+ import { spawnSync } from 'node:child_process';
11
12
  import { inbox, scanInbox, parseDeck, deckPath, responsePath, isResolved, atomicWriteJson, ask, launchReview, readJson, display, } from '@crouton-kit/humanloop';
12
13
  import { killPane } from './shared.js';
13
14
  // ---------------------------------------------------------------------------
@@ -243,6 +244,16 @@ export const humanRun = defineLeaf({
243
244
  catch {
244
245
  /* render pane is best-effort; the review itself must not die */
245
246
  }
247
+ // Dock the render pane BESIDE this worker pane. tmux resolves an
248
+ // untargeted split-window against the attached client's current window
249
+ // (which wins over $TMUX_PANE), so the pane can land in whatever window
250
+ // the user happens to be looking at — away from the editor. move-pane
251
+ // with explicit src/dst is deterministic: raw source and rendered doc
252
+ // always sit side by side.
253
+ const selfPane = process.env['TMUX_PANE'];
254
+ if (renderPane !== undefined && selfPane !== undefined && selfPane !== '') {
255
+ spawnSync('tmux', ['move-pane', '-h', '-s', renderPane, '-t', selfPane], { stdio: 'ignore' });
256
+ }
246
257
  if (renderPane !== undefined) {
247
258
  const rcPath = join(dir, 'run.json');
248
259
  const cur = readJson(rcPath);
@@ -119,6 +119,7 @@ const nodeNew = defineLeaf({
119
119
  { kind: 'flag', name: 'root', type: 'bool', required: false, constraint: 'Spawn an INDEPENDENT root instead of a managed child: no parent (top-level on the canvas), NO subscription back to you (you are NOT woken by it), resident lifecycle. It records spawned_by=you for provenance and is brought forefront so it can be driven directly. Use for a node you hand off and do not manage (e.g. a sub-orchestrator a human will discuss with).' },
120
120
  { kind: 'flag', name: 'fork-from', type: 'string', required: false, constraint: 'FORK the new node from an existing pi conversation instead of starting it fresh: pass a node id (forks from that node\'s session), an absolute session `.jsonl` path, or a partial pi session uuid. pi copies that whole history into a NEW session for the child (the source is untouched), then the prompt is delivered as the next message — i.e. the child wakes up as a continuation of that conversation. Use to branch exploratory work off a node that already built up the context you need, instead of re-deriving it. One-shot at birth: the fork resumes its own session thereafter.' },
121
121
  { kind: 'flag', name: 'headless', type: 'bool', required: false, constraint: 'Spawn the node on the HEADLESS broker host (no tmux pane) instead of a tmux window. Overrides the `headless` config default for this spawn; omit to use that default (which itself defaults to a tmux pane).' },
122
+ { kind: 'flag', name: 'model', type: 'enum', choices: ['ultra', 'strong', 'medium', 'light'], required: false, constraint: 'Override the model the node runs on, by capability TIER: ultra (frontier), strong (opus), medium (sonnet), light (haiku). Omit to use the kind\'s persona default (explore=light, every other kind=strong). The override is durable — it survives revives and polymorphs (promote/demote).' },
122
123
  ],
123
124
  output: [
124
125
  { name: 'node_id', type: 'string', required: true, constraint: 'The new node id.' },
@@ -148,9 +149,10 @@ const nodeNew = defineLeaf({
148
149
  const parent = input['parent'];
149
150
  const root = input['root'] === true;
150
151
  const forkFrom = input['forkFrom'];
152
+ const model = input['model'];
151
153
  // Host precedence: explicit --headless flag > config `headless` default > tmux.
152
154
  const hostKind = input['headless'] === true ? 'broker' : readConfig('user').headless === true ? 'broker' : 'tmux';
153
- const res = spawnChild({ kind, mode, cwd, name, prompt, parent, root, forkFrom, hostKind });
155
+ const res = spawnChild({ kind, mode, cwd, name, prompt, parent, root, forkFrom, hostKind, model });
154
156
  return {
155
157
  node_id: res.node.node_id,
156
158
  name: res.node.name,
@@ -682,6 +684,7 @@ function setLifecycle(id, value, opts) {
682
684
  const { launch } = buildLaunchSpec(target.kind, target.mode, {
683
685
  lifecycle: value,
684
686
  hasManager: target.parent !== null,
687
+ model: target.model_override ?? undefined,
685
688
  });
686
689
  const meta = updateNode(id, { lifecycle: value, launch });
687
690
  // --detach: shove the still-running agent into the background crtr session,
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,157 @@
1
+ // Run with: node --import tsx/esm --test src/core/__tests__/broker-attach-limits.test.ts
2
+ //
3
+ // T8 — the `crtr attach` acceptance gate, gates G4/G7/G8/G9 (controller
4
+ // 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/observer — raw
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.
13
+ import { test, before, after, afterEach } from 'node:test';
14
+ import assert from 'node:assert/strict';
15
+ 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';
19
+ let h;
20
+ let root;
21
+ const kit = createAttachKit(() => h);
22
+ const { attach, attachUntil, connectRaw } = kit;
23
+ before(async () => {
24
+ if (!hasTmux())
25
+ return;
26
+ h = await createHarness({ sessionPrefix: 'crtr-brklim' });
27
+ root = h.spawnRoot('broker-attach-limits suite root');
28
+ });
29
+ after(async () => {
30
+ if (h !== undefined)
31
+ await h.dispose();
32
+ });
33
+ afterEach(() => {
34
+ kit.closeAll();
35
+ });
36
+ const brokerPid = (id) => h.node(id).pi_pid;
37
+ // ---------------------------------------------------------------------------
38
+ // G4 — arbitration + observer read. Guards: 2nd client is admitted observer, an
39
+ // observer prompt is rejected not_controller, BOTH clients receive the relay.
40
+ // Failure mode: two controllers, or an observer driving the engine, or fan-out
41
+ // that misses a viewer.
42
+ // ---------------------------------------------------------------------------
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');
46
+ assert.equal(c1.welcome.role, 'controller', 'first client holds control');
47
+ const c2 = await attach(id, 'controller', 'g4-second'); // requests control; held → observer
48
+ assert.equal(c2.welcome.role, 'observer', 'second client is admitted read-only observer (first-attach-wins)');
49
+ c2.send({ type: 'prompt', text: 'observer must not drive' });
50
+ const err = await c2.waitFrame((f) => f.type === 'error', 'G4 observer prompt rejected');
51
+ assert.equal(err.code, 'not_controller', 'G4: observer prompt → error{not_controller}');
52
+ const token = tok('G4-BROADCAST');
53
+ c1.send({ type: 'prompt', text: token });
54
+ await c1.waitFrame((f) => f.type === 'agent_end' && frameHas(f, token), 'G4 controller received the stream');
55
+ await c2.waitFrame((f) => f.type === 'agent_end' && frameHas(f, token), 'G4 observer ALSO received the stream');
56
+ });
57
+ // ---------------------------------------------------------------------------
58
+ // G7 — decoder overflow (guards C5 OOM). A client line over BROKER_READ_CAPS is
59
+ // cap-and-dropped; the broker survives and other clients are unaffected. Failure
60
+ // mode: an unbounded decoder buffer growing the broker to OOM.
61
+ // ---------------------------------------------------------------------------
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);
65
+ const boots = h.bootCount(id);
66
+ const survivor = await attach(id, 'observer', 'g7-survivor');
67
+ // 26 MiB with NO newline > BROKER_READ_CAPS.maxLineBytes (24 MiB) → the bounded
68
+ // FrameDecoder throws FrameOverflowError; the broker drops the peer.
69
+ const bad = await connectRaw(id, { read: true });
70
+ bad.writeRaw(Buffer.alloc(26 * 1024 * 1024, 0x78));
71
+ await bad.waitClosed('G7 oversized peer dropped by the broker');
72
+ await h.waitFor(() => /frame overflow/.test(brokerLogText(h, id)) || null, { label: 'G7 broker logged the frame-overflow drop' });
73
+ // The broker survives and the other client is unaffected: a fresh turn relays.
74
+ const token = tok('G7-AFTER');
75
+ h.fakeCmd(id, { cmd: 'stream', text: token });
76
+ await survivor.waitFrame((f) => f.type === 'agent_end' && frameHas(f, token), 'G7 survivor still receives live frames');
77
+ assert.equal(isPidAlive(pid), true, 'G7: the broker survived the overflow');
78
+ assert.equal(brokerPid(id), pid, 'G7: broker pid unchanged');
79
+ assert.equal(h.bootCount(id), boots, 'G7: no reboot');
80
+ });
81
+ // ---------------------------------------------------------------------------
82
+ // G8 — backpressure leak (guards M1). A stalled (non-reading) viewer is shed at
83
+ // the HWM (32 MiB byte cap) while the broker + other viewers are unaffected.
84
+ // Failure mode: an indefinitely-growing per-viewer queue (broker OOM) or a slow
85
+ // viewer back-pressuring the shared engine.
86
+ // ---------------------------------------------------------------------------
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);
90
+ const fast = await attach(id, 'observer', 'g8-fast'); // reads normally
91
+ // A helloed viewer that NEVER reads (paused socket). It must be in the broadcast
92
+ // set before the flood; hello, then a short beat for the broker to process it.
93
+ const stalled = await connectRaw(id, { read: false });
94
+ stalled.send({ type: 'hello', role: 'observer', client_id: 'g8-stalled' });
95
+ await delay(400);
96
+ // A fast event stream: ~60 MiB across 240 message_update frames. The stalled
97
+ // viewer's per-viewer backlog crosses the 32 MiB byte cap → dropped; the fast
98
+ // viewer drains between frames (per-update yield) and survives.
99
+ //
100
+ // The DROP SIGNAL is the broker's own log line, NOT the stalled socket's 'close':
101
+ // a PAUSED node socket does not surface the peer FIN/close until it is resumed,
102
+ // so the broker-side `backpressure high-water mark exceeded` line is the
103
+ // deterministic, race-free proof that the slow viewer was shed at the HWM.
104
+ const token = tok('G8-FLOOD');
105
+ h.fakeCmd(id, { cmd: 'stream', text: token, updates: 240, padBytes: 256 * 1024, tool: false });
106
+ await h.waitFor(() => /backpressure high-water mark exceeded/.test(brokerLogText(h, id)) || null, { label: 'G8 broker shed the stalled viewer at the HWM', timeoutMs: 30_000 });
107
+ // The fast viewer drained the WHOLE stream (agent_end is the terminal frame) —
108
+ // proving only the stalled viewer was shed, while the fast one was unaffected.
109
+ await fast.waitFrame((f) => f.type === 'agent_end' && frameHas(f, token), 'G8 fast viewer received the whole stream', 30_000);
110
+ 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)');
112
+ });
113
+ // ---------------------------------------------------------------------------
114
+ // G9 — the load-bearing ONE-WRITER assertion. Across attach→detach→reattach the
115
+ // broker pid AND engine pid (the same, in-process) are UNCHANGED and no second
116
+ // engine is ever spawned; the viewer holds ONLY the socket (never the .jsonl).
117
+ // Failure mode: a viewer that spawns/forks a second engine or opens the session
118
+ // .jsonl — the corruption the headless design exists to prevent.
119
+ // ---------------------------------------------------------------------------
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);
123
+ const boots0 = h.bootCount(id);
124
+ const c1 = await attach(id, 'controller', 'g9-a');
125
+ const a = tok('G9-A');
126
+ c1.send({ type: 'prompt', text: a });
127
+ await c1.waitFrame((f) => f.type === 'agent_end' && frameHas(f, a), 'G9 first turn relayed');
128
+ c1.send({ type: 'bye' });
129
+ c1.close();
130
+ await delay(300);
131
+ assert.equal(brokerPid(id), pid0, 'G9: broker/engine pid unchanged after detach');
132
+ assert.equal(isPidAlive(pid0), true, 'G9: the one engine is still alive after detach');
133
+ assert.equal(h.bootCount(id), boots0, 'G9: no second engine spawned across detach');
134
+ // attachUntil (not a fixed sleep) synchronizes on the controller handoff: the
135
+ // detach's controllerId=null lands a beat after close, so retry until the
136
+ // reattach is admitted controller before driving the second prompt.
137
+ const c2 = await attachUntil(id, 'controller', 'g9-b', (x) => x.welcome.role === 'controller', 'G9 reattach re-takes control');
138
+ assert.equal(c2.welcome.role, 'controller', 'G9: the reattached client drives the SAME engine as controller');
139
+ const b = tok('G9-B');
140
+ c2.send({ type: 'prompt', text: b });
141
+ 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)');
143
+ assert.equal(isPidAlive(pid0), true, 'G9: the single engine is alive across the full cycle');
144
+ assert.equal(h.bootCount(id), boots0, 'G9: exactly one engine boot across attach→detach→reattach');
145
+ // fd-check (best-effort): the viewer host (this test process) must NOT hold the
146
+ // session .jsonl — only the broker may. With the fake engine the .jsonl is not
147
+ // held open continuously, so the meaningful assertion is that the VIEWER never
148
+ // appears among its holders (and any holder that exists is the broker).
149
+ const jsonl = join(h.home, 'nodes', id, 'fake-session.jsonl');
150
+ const holders = lsofHolders(jsonl);
151
+ if (holders !== null) {
152
+ assert.ok(!holders.includes(process.pid), 'G9: the attach client (this process) does NOT hold the session .jsonl');
153
+ for (const holder of holders) {
154
+ assert.equal(holder, pid0, 'G9: the ONLY holder of the session .jsonl is the broker/engine');
155
+ }
156
+ }
157
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,125 @@
1
+ // Run with: node --import tsx/esm --test src/core/__tests__/broker-attach-stream.test.ts
2
+ //
3
+ // T8 — the `crtr attach` acceptance gate, gates G1/G1b/G2/G3 (controller drive,
4
+ // 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.
13
+ import { test, before, after, afterEach } from 'node:test';
14
+ 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';
18
+ let h;
19
+ let root;
20
+ const kit = createAttachKit(() => h);
21
+ const { attach, attachUntil } = kit;
22
+ before(async () => {
23
+ if (!hasTmux())
24
+ return;
25
+ h = await createHarness({ sessionPrefix: 'crtr-brkstrm' });
26
+ root = h.spawnRoot('broker-attach-stream suite root');
27
+ });
28
+ after(async () => {
29
+ if (h !== undefined)
30
+ await h.dispose();
31
+ });
32
+ afterEach(() => {
33
+ kit.closeAll();
34
+ });
35
+ const brokerPid = (id) => h.node(id).pi_pid;
36
+ // ---------------------------------------------------------------------------
37
+ // G1 — controller drive + live relay. Guards: a controller's `prompt` runs the
38
+ // engine AND the streaming AgentSessionEvents are fanned out to the client
39
+ // VERBATIM. Failure mode: a broken relay/fan-out (viewer sees nothing).
40
+ // ---------------------------------------------------------------------------
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');
44
+ assert.equal(c.welcome.role, 'controller', 'first controller is admitted as controller');
45
+ const token = tok('G1-PROMPT');
46
+ c.send({ type: 'prompt', text: token });
47
+ // The full streaming turn relays: agent_end carrying the token proves the turn
48
+ // ran AND its terminal frame reached the client; the intermediate types prove
49
+ // the stream (not just a final blob) was fanned out.
50
+ await c.waitFrame((f) => f.type === 'agent_end' && frameHas(f, token), 'G1 agent_end carrying the prompt token');
51
+ for (const t of ['message_start', 'message_update', 'tool_execution_start', 'tool_execution_end', 'message_end', 'turn_end', 'agent_start', 'turn_start']) {
52
+ assert.ok(c.frames.some((f) => f.type === t), `G1: client received a relayed ${t} frame`);
53
+ }
54
+ assert.ok(c.frames.some((f) => f.type === 'message_start' && frameHas(f, token)), 'G1: a relayed message carries the prompt token');
55
+ });
56
+ // ---------------------------------------------------------------------------
57
+ // G1b — message_update coalescing preserves ordering (regression for the F2
58
+ // attach typing-lag fix, 2026-06-09). The broker holds the latest message_update
59
+ // on a ~75ms timer; any OTHER event must flush it FIRST, so a viewer can never
60
+ // observe message_update AFTER its message_end (which would resurrect stale
61
+ // streaming text over the final message), and the LAST coalesced update must not
62
+ // be silently dropped at end-of-turn. Failure modes: a flush ordered after the
63
+ // non-update event, or a pending update discarded when message_end wins the race.
64
+ // ---------------------------------------------------------------------------
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');
68
+ const token = tok('G1B');
69
+ // 12 updates at the fake engine's setImmediate pace — far faster than the 75ms
70
+ // coalesce window, so coalescing genuinely engages (fewer relayed than emitted).
71
+ h.fakeCmd(id, { cmd: 'stream', text: token, updates: 12 });
72
+ await c.waitFrame((f) => f.type === 'agent_end' && frameHas(f, token), 'G1b turn relayed');
73
+ const types = c.frames.map((f) => f.type);
74
+ const lastUpdate = types.lastIndexOf('message_update');
75
+ const msgEnd = types.indexOf('message_end');
76
+ assert.ok(lastUpdate >= 0, 'G1b: at least one message_update relayed (coalescing must not starve updates)');
77
+ assert.ok(msgEnd >= 0, 'G1b: message_end relayed');
78
+ assert.ok(lastUpdate < msgEnd, `G1b: a message_update arrived AFTER message_end (update@${lastUpdate} vs end@${msgEnd}) — the coalescer flushed out of order`);
79
+ // tool/turn/agent frames must also never precede a stale held update.
80
+ for (const t of ['tool_execution_start', 'turn_end', 'agent_end']) {
81
+ const i = types.indexOf(t);
82
+ assert.ok(i < 0 || lastUpdate < i, `G1b: message_update relayed after ${t}`);
83
+ }
84
+ });
85
+ // ---------------------------------------------------------------------------
86
+ // G2 — detach leaves the engine running. Guards: `bye`/close drops ONE listener,
87
+ // never the engine. Failure mode: a detach that disposes the broker/engine.
88
+ // ---------------------------------------------------------------------------
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);
92
+ const boots = h.bootCount(id);
93
+ const c = await attach(id, 'controller', 'g2-ctrl');
94
+ const p = tok('G2-PROMPT');
95
+ c.send({ type: 'prompt', text: p });
96
+ await c.waitFrame((f) => f.type === 'agent_end' && frameHas(f, p), 'G2 turn relayed before detach');
97
+ c.send({ type: 'bye' });
98
+ c.close();
99
+ await delay(300); // let the broker process the 'close'
100
+ assert.equal(isPidAlive(pid), true, 'G2: broker still alive after detach');
101
+ assert.equal(brokerPid(id), pid, 'G2: engine (== broker) pid UNCHANGED across detach');
102
+ assert.equal(h.bootCount(id), boots, 'G2: no reboot — no second engine spawned');
103
+ // Still emitting: drive a turn with no client, then a fresh observer's snapshot
104
+ // carries it (the engine kept running and processing after the detach).
105
+ const after = tok('G2-AFTER-DETACH');
106
+ h.fakeCmd(id, { cmd: 'stream', text: after });
107
+ const o = await attachUntil(id, 'observer', 'g2-obs', (a) => JSON.stringify(a.welcome.snapshot.messages).includes(after), 'G2 post-detach message in snapshot');
108
+ assert.ok(o.welcome.snapshot.messages.length > 0, 'G2: the engine produced a message after the detach');
109
+ assert.equal(isPidAlive(pid), true, 'G2: broker still alive after the post-detach turn');
110
+ });
111
+ // ---------------------------------------------------------------------------
112
+ // G3 — catch-up snapshot. Guards: messages produced with NO viewer attached are
113
+ // in welcome.snapshot on reattach, and live events resume. Failure mode: a viewer
114
+ // that reattaches blind to history / gets no further events.
115
+ // ---------------------------------------------------------------------------
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');
118
+ const past = tok('G3-DETACHED');
119
+ h.fakeCmd(id, { cmd: 'stream', text: past }); // produced with zero viewers attached
120
+ const o = await attachUntil(id, 'observer', 'g3-obs', (a) => JSON.stringify(a.welcome.snapshot.messages).includes(past), 'G3 detached message in snapshot');
121
+ assert.ok(JSON.stringify(o.welcome.snapshot.messages).includes(past), 'G3: welcome.snapshot.messages contains the message produced while detached');
122
+ const live = tok('G3-LIVE');
123
+ h.fakeCmd(id, { cmd: 'stream', text: live });
124
+ await o.waitFrame((f) => f.type === 'agent_end' && frameHas(f, live), 'G3 live events resume after reattach');
125
+ });