@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,73 +1,80 @@
1
1
  // Run with: node --import tsx/esm --test src/core/__tests__/flagship-lifecycle.test.ts
2
2
  //
3
- // FLAGSHIP end-to-end lifecycle test the faithful integration harness driving
4
- // a full node-graph scenario through the REAL CLI, a REAL isolated tmux session,
5
- // the REAL extension hooks (fired inside the fake-pi vehicle), and the REAL
6
- // daemon decision pass (superviseTick, in-process). Every assertion reads the
7
- // canvas data layer and is checked against the state-model ORACLE.
3
+ // HEADLESS RETARGET (foundation-spec §C.9 + §E). The flagship end-to-end
4
+ // lifecycle scenario, driven entirely at the MODEL layer against FABRICATED
5
+ // broker-hosted (paneless) nodes no real tmux session, no pane chrome, and NO
6
+ // real broker boot. The full status×intent×lifecycle×mode state machine is host-
7
+ // independent: every hop is a CLI lifecycle verb (promote/yield) or a pure model
8
+ // op (fabricate + subscribe + transition + pushFinal + superviseTick), so the
9
+ // scenario never pays the ~5s SDK-boot cost.
8
10
  //
9
- // Scenario (each hop asserted):
10
- // A (base→orchestrator, RESIDENT root) spawns terminal child B.
11
- // B spawns child C, then goes DORMANT (terminal idle-release, unfocusedpane closes).
12
- // C finishes ONE-HOP wakes B (A is NOT woken — the oracle's wake contract).
13
- // B PROMOTES to orchestrator (terminal), takes a turn (persona drift), then
14
- // YIELDS and revives in place (refresh-yield).
15
- // B spawns child D; D finishes; B pushes its final up to A.
11
+ // THREE-PART LOCK HEADER ──────────────────────────────────────────────────
12
+ // (1) BUG LOCKED the full lifecycle state machine (state-model §2): each hop
13
+ // (spawn terminal idle-release dormancy one-hop wake promote yield-
14
+ // refreshfinish) must land in the correct durable (status,intent,
15
+ // lifecycle,mode) and route completion ONE HOP up the spine (a push fans to
16
+ // DIRECT subscribers only; an indirect ancestor hears nothing until a middle
17
+ // node re-pushes).
18
+ // (2) WHY MODEL-LEVEL, NOT PANE/WINDOW — every transition lives in canvas.db +
19
+ // the lifecycle table (transition()) + the daemon's second-pass wake gate +
20
+ // the feed fan-out. A tmux node showed dormancy as a closed pane; a broker
21
+ // shows it as an EXITED process (status=idle, intent=idle-release, pi_pid
22
+ // dead) — fabricated directly. The only thing a pane ever added was a visible
23
+ // dormancy signal, which a broker expresses as a dead pid. No pane is read.
24
+ // (3) HOW THE HEADLESS DRIVE STILL FAILS IF THE FIX REGRESSES — the one-hop wake
25
+ // is asserted via getNode(B).cycles (reviveNode bumps cycles +
26
+ // transition('revive') BEFORE the detached, unawaited broker spawn — revive.
27
+ // ts:114,145 — so the wake is observable INSTANTLY). If the daemon's second-
28
+ // pass reviveNode is removed, the post-inbox tick never bumps cycles → S7's
29
+ // "B revived" asserts go RED. If a push wrongly fanned past its direct
30
+ // subscriber, the "A inbox empty" one-hop asserts go RED.
16
31
  //
17
- // 2×2 coverage (mode {base,orchestrator} × lifecycle {terminal,resident}):
18
- // base×resident — A's PERSISTED ROW SHAPE at S1 (a harness-set fixture,
19
- // NOT a real bootRoot see S1 + MINOR-1 caveat below).
20
- // The resident BEHAVIOR (does not idle-release) is
21
- // exercised faithfully in live-mutation.test.ts.
22
- // orchestrator×resident A after promote (top-level orchestrator)
23
- // base×terminal — B/C/D workers (GENUINE managed-child birth at S3)
24
- // • orchestrator×terminal B after promote (sub-orchestrator)
32
+ // STOPHOOK CAVEAT — split (option b). The original leaned on the REAL stophook
33
+ // committing idle-release / the stall reprompt / the persona steer INSIDE a live
34
+ // broker at agent_end/turn_end. Fabrication can set the post-stop row but cannot
35
+ // run that hook. So for each such point this test fabricates the post-stop MODEL
36
+ // state (or asserts the PURE decision the hook enacts — evaluateStop /
37
+ // personaDrift) and asserts the downstream lifecycle/daemon CONSEQUENCE, leaving
38
+ // the hook-commit proof to the already-pure siblings:
39
+ // • idle-release commit at agent_end canvas-stophook-agentend.test.ts
40
+ // ('natural stop while awaiting a live worker → idle-release …').
41
+ // • stall reprompt at agent_end → canvas-stophook-agentend.test.ts
42
+ // ('stalled leaf … is still reprompted').
43
+ // • persona-drift steer + ack commit at turn_end → persona.test.ts
44
+ // ('personaDrift detects base→orchestrator after promote, then clears on
45
+ // commit'). Here we assert only the PENDING drift the injector would deliver.
25
46
  //
26
- // ── Known coverage boundaries (DELIBERATE, documented gaps) ────────────────
27
- // This flagship + the live-mutation/cascade/subscription siblings cover the
28
- // HAPPY-PATH lifecycle and the live-mutation axis faithfully. The following
29
- // fault/grace/focus paths are OUT OF SCOPE for the faithful tier in this pass
30
- // (some are backstopped at the in-process unit tier, cited):
31
- // • crash dead (a vehicle that boots then its pane vanishes mid-run) — unit:
32
- // daemon-liveness.test.ts "pane GONE crash dead".
33
- // • boot-failure push (a vehicle that never boots → surfaceBootFailure urgent
34
- // push up the spine) — no faithful coverage here.
35
- // • focused-FREEZE (F3: a focused-dormant node frozen via remain-on-exit,
36
- // pane-alive but pi-dead) — unit: daemon-liveness.test.ts "idle-release +
37
- // live (frozen) pane …"; the grace-window double-spawn guard around it is
38
- // exercised faithfully in grace-clock.test.ts.
39
- // • node lifecycle --detach (A3: orphaned-focus-row hazard) — faithful E2E:
40
- // detach-focus.test.ts (the real verb on a FOCUSED live node → terminal +
41
- // pane relocated to the backstage + focus row CLOSED).
42
- // • focused-finish → manager-TAKEOVER (handFocusToManager swap) — NOT harness-
43
- // reachable: the harness root is a paneless never-booted row, so the done-
44
- // branch only ever hits the paneless-manager false guard (closeFocusToShell),
45
- // never a live/dormant-idle-release takeover. Unit: placement-teardown.test.ts.
46
- // • node msg / focus / cycle wake of a dormant node (A7) — untested faithfully.
47
- // These are intentional boundaries, not oversights.
47
+ // 2×2 coverage (mode {base,orchestrator} × lifecycle {terminal,resident}):
48
+ // base×resident — A's fabricated row shape at S1 (a spawnRoot
49
+ // fixture; the real resident-birth boot path is
50
+ // structurally unreachable headlessly).
51
+ // orchestrator×resident A after promote (top-level orchestrator).
52
+ // • base×terminal — B/C/D workers (fabricated managed children).
53
+ // orchestrator×terminal B after promote (sub-orchestrator).
48
54
  import { test } from 'node:test';
49
55
  import assert from 'node:assert/strict';
50
56
  import { spawnSync } from 'node:child_process';
51
- import { createHarness, hasTmux } from './helpers/harness.js';
52
- import { STALL_REPROMPT } from '../runtime/stop-guard.js';
53
- function sessionExists(session) {
54
- return spawnSync('tmux', ['has-session', '-t', session], { stdio: 'ignore' }).status === 0;
57
+ import { createHeadlessHarness } from './helpers/harness.js';
58
+ import { subscribe } from '../canvas/canvas.js';
59
+ import { transition } from '../runtime/lifecycle.js';
60
+ import { pushFinal } from '../feed/feed.js';
61
+ import { evaluateStop, STALL_REPROMPT } from '../runtime/stop-guard.js';
62
+ import { personaDrift, commitPersonaAck } from '../runtime/persona.js';
63
+ // A pid reaped (dead) by the time spawnSync returns — a released broker's exited
64
+ // process (the headless analog of a closed pane).
65
+ function deadPid() {
66
+ const r = spawnSync('true', [], { stdio: 'ignore' });
67
+ return r.pid ?? 0x7ffffffe;
55
68
  }
56
- test('flagship: full node lifecycle — spawn, dormancy, one-hop wake, promote, yield, finish', { skip: !hasTmux() ? 'tmux unavailable' : false, timeout: 180_000 }, async () => {
57
- const h = await createHarness({ sessionPrefix: 'crtr-flagship' });
69
+ test('flagship: full node lifecycle — spawn, dormancy, one-hop wake, promote, yield, finish (headless)', { timeout: 30_000 }, async () => {
70
+ const h = await createHeadlessHarness({ sessionPrefix: 'crtr-flagship' });
58
71
  try {
59
72
  // ===================================================================
60
- // S1 — A's PERSISTED ROW SHAPE: base × RESIDENT (the user's front door).
61
- // MINOR-1 caveat: this is NOT a real root birth. The harness mints A
62
- // via in-process createNode over a hand-built meta whose defaults ARE
63
- // mode:'base'/lifecycle:'resident' the REAL birth/boot path (bootRoot)
64
- // execs pi inline and never returns, so it is structurally unreachable
65
- // from the harness (harness-design Wall #7). These four asserts therefore
66
- // PIN THE PERSISTED ROW SHAPE the rest of the scenario builds on — they
67
- // do NOT prove the real resident-birth path. The resident-at-birth 2×2
68
- // quadrant is out of reach; the resident *behavior* (no idle-release) is
69
- // exercised faithfully in live-mutation.test.ts, and the genuine real
70
- // birth of a managed child (→ terminal) IS exercised at S3 below.
73
+ // S1 — A's ROW SHAPE: base × RESIDENT (the user's front door). A spawnRoot
74
+ // fixture pins the PERSISTED row shape the scenario builds on; the real
75
+ // resident-birth boot path (bootRoot execs pi inline) is unreachable
76
+ // headlessly, exactly as in the tmux flagship. The resident BEHAVIOR (no
77
+ // idle-release) is exercised in live-mutation.test.ts.
71
78
  // ===================================================================
72
79
  const A = h.spawnRoot('top-level orchestrator');
73
80
  {
@@ -78,8 +85,9 @@ test('flagship: full node lifecycle — spawn, dormancy, one-hop wake, promote,
78
85
  assert.equal(a.intent ?? null, null, 'A no intent');
79
86
  }
80
87
  // ===================================================================
81
- // S2 — A promotes: orchestrator × RESIDENT (top-level orchestrator).
82
- // mode flips; lifecycle/status/intent untouched (no transition()).
88
+ // S2 — A promotes: orchestrator × RESIDENT (top-level orchestrator). The
89
+ // real `node promote` verb; mode flips, lifecycle/status/intent
90
+ // untouched (no transition()).
83
91
  // ===================================================================
84
92
  {
85
93
  const res = h.cli(A, ['node', 'promote']);
@@ -91,88 +99,98 @@ test('flagship: full node lifecycle — spawn, dormancy, one-hop wake, promote,
91
99
  assert.equal(a.intent ?? null, null, 'A intent untouched by promote');
92
100
  }
93
101
  // ===================================================================
94
- // S3 — A spawns terminal child B: base × TERMINAL. The spawn seed wires
95
- // the spine A auto-subscribes ACTIVE to B, B spawned_by A.
102
+ // S3 — A spawns terminal child B: base × TERMINAL. Fabricated as a broker-
103
+ // hosted node (pi_pid dead + pi_session_id set, so its later dormant
104
+ // wake RESUMES that session); the spine seed is wired explicitly
105
+ // (fabrication does not auto-subscribe). B is born acked to its own
106
+ // persona (commitPersonaAck), mirroring a real birth.
96
107
  // ===================================================================
97
- const B = await h.spawnChild(A, 'do the work', { kind: 'developer' });
108
+ const B = h.fabricateBrokerNode({
109
+ parent: A, kind: 'developer', mode: 'base', lifecycle: 'terminal',
110
+ status: 'active', pi_pid: deadPid(), pi_session_id: 'sess-B',
111
+ });
112
+ subscribe(A, B, true); // the load-bearing spine seed: A subscribes_to B (active).
113
+ commitPersonaAck(B, { mode: 'base', lifecycle: 'terminal' }); // born acked to its persona
98
114
  {
99
115
  const b = h.node(B);
116
+ assert.equal(b.host_kind, 'broker', 'B broker-hosted (paneless)');
100
117
  assert.equal(b.mode, 'base', 'B born base');
101
118
  assert.equal(b.lifecycle, 'terminal', 'B born terminal (managed child)');
102
- assert.equal(b.status, 'active', 'B active after boot');
119
+ assert.equal(b.status, 'active', 'B active');
103
120
  assert.equal(b.intent ?? null, null, 'B no intent');
104
121
  assert.equal(b.parent, A, 'B spawned_by / parent = A');
105
- // The load-bearing spine seed: A subscribes_to B (active).
106
- const subsToB = h.subscribers(B);
107
- assert.deepEqual(subsToB, [{ node_id: A, active: true }], 'A is B\'s sole ACTIVE subscriber (spawn seed)');
122
+ assert.deepEqual(h.subscribers(B), [{ node_id: A, active: true }], "A is B's sole ACTIVE subscriber (spine seed)");
108
123
  assert.equal(h.inbox(A).length, 0, 'A inbox empty (spawn pushes nothing)');
109
124
  }
110
125
  // ===================================================================
111
126
  // S4 — B spawns child C (base × terminal). B now holds an active live
112
127
  // subscription to C → B is legitimately "awaiting" if it stops.
113
128
  // ===================================================================
114
- const C = await h.spawnChild(B, 'a subtask');
129
+ const C = h.fabricateBrokerNode({ parent: B, mode: 'base', lifecycle: 'terminal', status: 'active' });
130
+ subscribe(B, C, true);
115
131
  {
116
132
  const c = h.node(C);
117
133
  assert.equal(c.mode, 'base', 'C base');
118
134
  assert.equal(c.lifecycle, 'terminal', 'C terminal');
119
135
  assert.equal(c.status, 'active', 'C active');
120
- assert.deepEqual(h.subscribers(C), [{ node_id: B, active: true }], 'B is C\'s sole ACTIVE subscriber (spawn seed)');
136
+ assert.deepEqual(h.subscribers(C), [{ node_id: B, active: true }], "B is C's sole ACTIVE subscriber");
121
137
  assert.deepEqual(h.subscriptions(B), [{ node_id: C, active: true }], 'B subscribes_to C (active)');
122
138
  assert.equal(h.status(B), 'active', 'B still active');
123
139
  }
124
140
  // ===================================================================
125
- // S5 — B goes DORMANT. It stops while awaiting C terminal idle-release:
126
- // transition('release') idle / idle-release; ctx.shutdown kills pi;
127
- // UNFOCUSED the backstage pane CLOSES (fully dormant).
141
+ // S5 — B goes DORMANT. PURE precondition: B is TERMINAL holding an active
142
+ // live sub to C, so the stop-guard classifies its stop 'awaiting'
143
+ // (legitimate idle-release). We then enact the post-stop MODEL state
144
+ // directly: transition('release') → idle / idle-release (a released
145
+ // broker is a dead pid; pi_pid was fabricated dead at S3). The actual
146
+ // agent_end idle-release COMMIT is locked by canvas-stophook-agentend
147
+ // ('natural stop while awaiting a live worker → idle-release …').
128
148
  // ===================================================================
129
- await h.stop(B);
130
- await h.waitForStatus(B, 'idle');
149
+ {
150
+ const stop = evaluateStop(B, { pushedFinal: false, askedHuman: false });
151
+ assert.equal(stop.action, 'allow', 'awaiting terminal B → allow (legit idle-release)');
152
+ assert.equal(stop.reason, 'awaiting', "B classified awaiting on its ACTIVE live sub to C");
153
+ }
154
+ transition(B, 'release');
131
155
  {
132
156
  const b = h.node(B);
133
157
  assert.equal(b.status, 'idle', 'B idle (released)');
134
158
  assert.equal(b.intent, 'idle-release', 'B intent=idle-release');
135
159
  }
136
- await h.waitForPaneGone(B);
137
- assert.equal(h.paneAlive(B), false, 'B unfocused → pane closed on idle-release');
138
160
  assert.equal(h.status(C), 'active', 'C still active while B sleeps');
139
- // A untouched a dormant terminal worker does not disturb its manager.
140
- assert.equal(h.status(A), 'active', 'A still active');
161
+ assert.equal(h.status(A), 'active', 'A still active (a dormant worker does not disturb its manager)');
141
162
  assert.equal(h.inbox(A).length, 0, 'A inbox still empty');
142
163
  // ===================================================================
143
- // S6 — C FINISHES (push final). Pointer fans to subscribersOf(C) = {B}
164
+ // S6 — C FINISHES (pushFinal). The pointer fans to subscribersOf(C) = {B}
144
165
  // ONLY. C → done/done. A is NOT a subscriber of C → A inbox stays
145
- // empty. This is the one-hop wake contract at the push site.
166
+ // empty the one-hop wake contract at the push site.
146
167
  // ===================================================================
147
- await h.finish(C, 'C result body');
168
+ await pushFinal(C, 'C result body');
148
169
  {
149
170
  const c = h.node(C);
150
171
  assert.equal(c.status, 'done', 'C done');
151
172
  assert.equal(c.intent, 'done', 'C intent=done');
152
- assert.equal(h.paneAlive(C), false, 'C pane closed on done');
153
- const bInbox = h.inbox(B);
154
- const cFinal = bInbox.find((e) => e.from === C && e.kind === 'final');
155
- assert.ok(cFinal, 'B inbox received C\'s final pointer (one hop)');
156
- // ORACLE: ONE-HOP. A is not woken — only B (the direct subscriber) hears C.
173
+ const cFinal = h.inbox(B).find((e) => e.from === C && e.kind === 'final');
174
+ assert.ok(cFinal, "B inbox received C's final pointer (one hop)");
157
175
  assert.equal(h.inbox(A).length, 0, 'A inbox empty — A NOT woken by C (one-hop)');
158
176
  }
159
177
  // ===================================================================
160
178
  // S7 — WAKE B. The in-process daemon second pass sees B idle/idle-release,
161
- // pi dead, an unseen inbox entry → reviveNode(resume). B → active,
162
- // intent cleared (revive). Its FRESH watcher delivers C's report.
179
+ // pi dead, an unseen inbox entry → reviveNode(resume). reviveNode
180
+ // bumps cycles + transition('revive') BEFORE the (detached, unawaited)
181
+ // broker spawn, so the wake is observable INSTANTLY via cycles — no
182
+ // awaitBoot. (The real inbox-watcher's redelivery of C's report on the
183
+ // fresh boot is an engine artifact, out of the model tier; the wake
184
+ // DECISION is locked here by cycles + the S6 inbox entry.)
163
185
  // ===================================================================
164
- const injBeforeWake = h.injected(B).length;
165
- await h.tick(); // one superviseTick: 1st pass nulls the stale window, 2nd revives on inbox
166
- await h.waitForStatus(B, 'active');
186
+ assert.equal(h.node(B).cycles ?? 0, 0, 'B not yet revived');
187
+ await h.tick();
167
188
  {
168
189
  const b = h.node(B);
190
+ assert.equal(b.cycles ?? 0, 1, 'unseen inbox + tick → daemon pass-2 REVIVED (cycles bumped to 1)');
169
191
  assert.equal(b.status, 'active', 'B revived → active');
170
192
  assert.equal(b.intent ?? null, null, 'B intent cleared by revive');
171
193
  }
172
- await h.awaitBoot(B, { minCount: 2 }); // the resume boot (spawn boot + wake boot)
173
- const wakeDigests = await h.awaitWake(B, { sinceCount: injBeforeWake, match: /C result body/ });
174
- assert.ok(wakeDigests.some((d) => /C result body/.test(d)), 'B\'s real inbox-watcher delivered C\'s report after the wake');
175
- // Still one-hop: A remained dormant-as-resident and was never touched.
176
194
  assert.equal(h.inbox(A).length, 0, 'A inbox STILL empty after B woke (one-hop confirmed)');
177
195
  assert.equal(h.status(A), 'active', 'A unchanged');
178
196
  // ===================================================================
@@ -189,35 +207,45 @@ test('flagship: full node lifecycle — spawn, dormancy, one-hop wake, promote,
189
207
  assert.equal(b.intent ?? null, null, 'B intent untouched by promote');
190
208
  }
191
209
  // ===================================================================
192
- // S9 — B takes a TURN. turn_end detects the base→orchestrator persona
193
- // drift and injects the orchestration guidance as a 'steer', then
194
- // commits the ack. agent_end then stalls (no live sub, no final) →
195
- // STALL_REPROMPT (followUp). Both are real stophook branches.
210
+ // S9 — The next turn's hooks, asserted as the PURE decisions they enact:
211
+ // (a) turn_end would inject the base→orchestrator persona-drift steer
212
+ // personaDrift(B) reports that PENDING drift (the guidance the
213
+ // injector delivers). The actual delivery + ack commit is locked
214
+ // by persona.test.ts.
215
+ // (b) agent_end would stall (orchestrator, no live sub — C done, D not
216
+ // yet spawned — no final) → evaluateStop → 'stalled'/STALL_REPROMPT.
217
+ // The actual reprompt injection is locked by canvas-stophook-agentend.
196
218
  // ===================================================================
197
- const injBeforeTurn = h.injected(B).length;
198
- await h.turn(B, 'orchestrating');
199
- const turnInjected = await h.waitFor(() => {
200
- const fresh = h.injected(B).slice(injBeforeTurn);
201
- // MINOR-3: match the steer by CONTENT (/ORCHESTRATOR/i), not deliverAs
202
- // alone mirroring live-mutation.test.ts's orchestrationSteers() so
203
- // this pins the orchestration guidance specifically, not "some steer."
204
- const steer = fresh.find((e) => e.deliverAs === 'steer' && /ORCHESTRATOR/i.test(e.content));
205
- const reprompt = fresh.find((e) => e.content.includes(STALL_REPROMPT));
206
- return steer && reprompt ? fresh : null;
207
- }, { timeoutMs: 15_000, label: 'B turn injected persona-drift steer + stall reprompt' });
208
- assert.ok(turnInjected.some((e) => e.deliverAs === 'steer' && /ORCHESTRATOR/i.test(e.content)), 'turn_end injected the base→orchestrator persona-drift guidance (steer with ORCHESTRATOR content)');
209
- assert.ok(turnInjected.some((e) => e.content.includes(STALL_REPROMPT)), 'agent_end stalled (orchestrator, no live sub, no final) → STALL_REPROMPT');
210
- assert.equal(h.status(B), 'active', 'B still active after the turn (reprompt keeps it alive)');
219
+ {
220
+ const drift = personaDrift(B);
221
+ assert.ok(drift !== null, 'turn_end would inject a persona-drift steer (drift pending)');
222
+ assert.deepEqual(drift?.from, { mode: 'base', lifecycle: 'terminal' }, 'drift.from = the acked base×terminal');
223
+ assert.deepEqual(drift?.to, { mode: 'orchestrator', lifecycle: 'terminal' }, 'drift.to = the new orchestrator persona');
224
+ assert.match(drift?.guidance ?? '', /ORCHESTRATOR/i, 'the steer carries the base→orchestrator guidance');
225
+ const stop = evaluateStop(B, { pushedFinal: false, askedHuman: false });
226
+ assert.equal(stop.action, 'reprompt', 'orchestrator B with no live sub, no final → reprompt');
227
+ assert.equal(stop.reason, 'stalled', 'classified stalled');
228
+ assert.equal(stop.action === 'reprompt' ? stop.message : '', STALL_REPROMPT, 'the stall reprompt nudge');
229
+ }
211
230
  // ===================================================================
212
- // S10 — B YIELDS + revives. node yield intent=refresh (active kept);
213
- // agent_end (b') runs reviveInPlace (respawn-pane -k) IN the fake-pi
214
- // pane; the fresh pi's session_start clears refresh active. mode
215
- // and lifecycle survive the refresh.
231
+ // S10 — B YIELDS. The real `node yield` verb sets intent=refresh (already
232
+ // orchestrator no auto-promote); persona survives the request. We
233
+ // then model the daemon's completed refresh-revive with transition
234
+ // ('revive') (the in-place fresh boot is engine; its model effect —
235
+ // active, intent cleared, persona preserved — is asserted here).
216
236
  // ===================================================================
217
- await h.yieldNode(B, 'refresh against the roadmap');
237
+ {
238
+ const res = h.cli(B, ['node', 'yield', 'refresh against the roadmap']);
239
+ assert.equal(res.code, 0, `node yield exit 0\n${res.stderr}`);
240
+ const b = h.node(B);
241
+ assert.equal(b.intent, 'refresh', 'yield set intent=refresh');
242
+ assert.equal(b.mode, 'orchestrator', 'B mode survives the yield request');
243
+ assert.equal(b.lifecycle, 'terminal', 'B lifecycle survives the yield request');
244
+ }
245
+ transition(B, 'revive'); // model the completed refresh-revive (fresh boot is engine)
218
246
  {
219
247
  const b = h.node(B);
220
- assert.equal(b.status, 'active', 'B active after refresh-yield');
248
+ assert.equal(b.status, 'active', 'B active after the refresh-revive');
221
249
  assert.equal(b.intent ?? null, null, 'B intent=refresh cleared by the fresh boot');
222
250
  assert.equal(b.mode, 'orchestrator', 'B mode survives the refresh');
223
251
  assert.equal(b.lifecycle, 'terminal', 'B lifecycle survives the refresh');
@@ -225,7 +253,8 @@ test('flagship: full node lifecycle — spawn, dormancy, one-hop wake, promote,
225
253
  // ===================================================================
226
254
  // S11 — B spawns child D (base × terminal). New spine seed B→D active.
227
255
  // ===================================================================
228
- const D = await h.spawnChild(B, 'a second subtask');
256
+ const D = h.fabricateBrokerNode({ parent: B, mode: 'base', lifecycle: 'terminal', status: 'active' });
257
+ subscribe(B, D, true);
229
258
  {
230
259
  const d = h.node(D);
231
260
  assert.equal(d.mode, 'base', 'D base');
@@ -236,32 +265,27 @@ test('flagship: full node lifecycle — spawn, dormancy, one-hop wake, promote,
236
265
  // ===================================================================
237
266
  // S12 — D FINISHES → pointer to B (its only subscriber).
238
267
  // ===================================================================
239
- await h.finish(D, 'D result body');
268
+ await pushFinal(D, 'D result body');
240
269
  {
241
270
  assert.equal(h.node(D).status, 'done', 'D done');
242
271
  const dFinal = h.inbox(B).find((e) => e.from === D && e.kind === 'final');
243
- assert.ok(dFinal, 'B inbox received D\'s final pointer');
272
+ assert.ok(dFinal, "B inbox received D's final pointer");
244
273
  }
245
274
  // ===================================================================
246
275
  // S13 — B pushes its FINAL up the spine → done/done; the pointer fans to
247
276
  // subscribersOf(B) = {A}. NOW A finally hears B (its own explicit
248
277
  // push — the only way the chain propagates a hop).
249
278
  // ===================================================================
279
+ await pushFinal(B, 'all work complete');
250
280
  {
251
- const res = h.cli(B, ['push', 'final', 'all work complete']);
252
- assert.equal(res.code, 0, `B push final exit 0\n${res.stderr}`);
253
281
  const b = h.node(B);
254
282
  assert.equal(b.status, 'done', 'B done after push final');
255
283
  assert.equal(b.intent, 'done', 'B intent=done');
256
284
  const aInbox = h.inbox(A);
257
- assert.equal(aInbox.length, 1, 'A inbox now has exactly B\'s final (one hop, on B\'s push)');
258
- assert.equal(aInbox[0].from, B, 'A\'s entry is from B');
259
- assert.equal(aInbox[0].kind, 'final', 'A\'s entry is a final');
285
+ assert.equal(aInbox.length, 1, "A inbox now has exactly B's final (one hop, on B's push)");
286
+ assert.equal(aInbox[0].from, B, "A's entry is from B");
287
+ assert.equal(aInbox[0].kind, 'final', "A's entry is a final");
260
288
  }
261
- // Close B's window faithfully (done branch shutdown).
262
- await h.stop(B);
263
- await h.waitForPaneGone(B);
264
- assert.equal(h.paneAlive(B), false, 'B pane closed on done');
265
289
  // A — the resident top-level orchestrator — is still alive and well.
266
290
  {
267
291
  const a = h.node(A);
@@ -271,9 +295,6 @@ test('flagship: full node lifecycle — spawn, dormancy, one-hop wake, promote,
271
295
  }
272
296
  }
273
297
  finally {
274
- const session = h.session;
275
298
  await h.dispose();
276
- // Teardown leaves NO stray session.
277
- assert.equal(sessionExists(session), false, 'isolated session killed — no stray');
278
299
  }
279
300
  });
@@ -18,7 +18,11 @@
18
18
  // These tests lock in (a) behavioral equivalence with the old decoder across
19
19
  // randomized chunkings — frames, blank-line skip, malformed-JSON drop, multibyte
20
20
  // splits, and both FrameOverflowError caps with identical byte accounting — and
21
- // (b) the linear growth curve, so the quadratic shape cannot silently return.
21
+ // (b) the linear growth curve via a SCALING-RATIO assertion (per-MiB decode cost
22
+ // at 4 MiB vs 16 MiB must stay ~flat), so the quadratic shape cannot silently
23
+ // return. The ratio is measured with min-of-reps and interleaved sizes so it is
24
+ // robust to CPU contention from parallel `test:full` workers — see the perf test
25
+ // for the full rationale.
22
26
  import { test } from 'node:test';
23
27
  import assert from 'node:assert/strict';
24
28
  import { StringDecoder } from 'node:string_decoder';
@@ -167,32 +171,54 @@ test('FrameDecoder — a frame exactly AT each cap passes; one byte over throws
167
171
  }
168
172
  });
169
173
  test('FrameDecoder — large-frame decode is linear, not quadratic (the observed attach-lag regression)', () => {
170
- // Decode a single N-MiB frame in 64 KiB chunks; compare per-MiB cost at 2 MiB
171
- // vs 8 MiB. Quadratic shape per-MiB cost grows ~per 4× size (measured
172
- // 5.5×+ pre-fix); linear stays ~flat. Threshold is far above linear noise
173
- // and far below the quadratic signature, so this fails ONLY on a complexity
174
- // regression, not on a slow CI box.
174
+ // STRATEGY (contention-robust SCALING assertion): decode a single frame in
175
+ // 64 KiB socket-sized chunks at two sizesapart (4 MiB vs 16 MiB) and assert
176
+ // the per-MiB cost stays ~flat. A linear decoder has per-MiB cost independent of
177
+ // size (ratio 1); the O(n²) bug makes per-MiB cost grow with size, so over a
178
+ // size jump it grows ~4× (measured 5.5×+ pre-fix). Threshold 3× sits cleanly
179
+ // between the two signatures: well above linear noise, well below the quadratic
180
+ // signature.
181
+ //
182
+ // Why a RATIO, not an absolute wall-clock budget: an absolute budget is fragile
183
+ // under the parallel `test:full` workers — CPU contention scales every
184
+ // measurement up, so a tight budget flakes red on a loaded box without any code
185
+ // regression. A ratio of two contention-scaled measurements cancels whole-box
186
+ // slowdown, leaving only the algorithmic shape. Two further guards keep the
187
+ // minima clean under load: (1) MIN across REPS — contention only ADDS time, so
188
+ // the minimum approximates the uncontended cost; (2) the two sizes are timed
189
+ // INTERLEAVED within each rep, so a contention burst inflates both numerator and
190
+ // denominator alike instead of skewing the ratio. This fails ONLY on a
191
+ // complexity regression, not on a slow/busy CI box.
175
192
  const CHUNK = 64 * 1024;
176
- const decodeMs = (mib) => {
193
+ const SMALL_MIB = 4;
194
+ const LARGE_MIB = 16; // 4× SMALL — quadratic ⇒ per-MiB ~4×; linear ⇒ ~1×
195
+ const REPS = 7;
196
+ const makeFrame = (mib) => {
177
197
  const overhead = Buffer.byteLength('{"type":"welcome","blob":""}\n');
178
- const frame = Buffer.from(`{"type":"welcome","blob":"${'x'.repeat(mib * 1024 * 1024 - overhead)}"}\n`);
179
- let best = Infinity;
180
- for (let rep = 0; rep < 3; rep++) {
181
- const dec = new FrameDecoder(CLIENT_READ_CAPS);
182
- const t0 = performance.now();
183
- let n = 0;
184
- for (let off = 0; off < frame.length; off += CHUNK) {
185
- n += dec.push(frame.subarray(off, Math.min(off + CHUNK, frame.length))).length;
186
- }
187
- const ms = performance.now() - t0;
188
- assert.equal(n, 1, 'decoded exactly one frame');
189
- if (ms < best)
190
- best = ms;
198
+ return Buffer.from(`{"type":"welcome","blob":"${'x'.repeat(mib * 1024 * 1024 - overhead)}"}\n`);
199
+ };
200
+ const smallFrame = makeFrame(SMALL_MIB);
201
+ const largeFrame = makeFrame(LARGE_MIB);
202
+ const decodeMs = (frame) => {
203
+ const dec = new FrameDecoder(CLIENT_READ_CAPS);
204
+ const t0 = performance.now();
205
+ let n = 0;
206
+ for (let off = 0; off < frame.length; off += CHUNK) {
207
+ n += dec.push(frame.subarray(off, Math.min(off + CHUNK, frame.length))).length;
191
208
  }
192
- return best;
209
+ const ms = performance.now() - t0;
210
+ assert.equal(n, 1, 'decoded exactly one frame');
211
+ return ms;
193
212
  };
194
- decodeMs(1); // warmup
195
- const perMibSmall = decodeMs(2) / 2;
196
- const perMibLarge = decodeMs(8) / 8;
197
- assert.ok(perMibLarge < perMibSmall * 3, `per-MiB decode cost grew superlinearly: ${perMibSmall.toFixed(2)}ms/MiB @2MiB → ${perMibLarge.toFixed(2)}ms/MiB @8MiB (quadratic regression)`);
213
+ decodeMs(smallFrame); // warm JIT on both paths before timing
214
+ decodeMs(largeFrame);
215
+ let bestSmall = Infinity;
216
+ let bestLarge = Infinity;
217
+ for (let rep = 0; rep < REPS; rep++) {
218
+ bestSmall = Math.min(bestSmall, decodeMs(smallFrame));
219
+ bestLarge = Math.min(bestLarge, decodeMs(largeFrame));
220
+ }
221
+ const perMibSmall = bestSmall / SMALL_MIB;
222
+ const perMibLarge = bestLarge / LARGE_MIB;
223
+ assert.ok(perMibLarge < perMibSmall * 3, `per-MiB decode cost grew superlinearly: ${perMibSmall.toFixed(2)}ms/MiB @${SMALL_MIB}MiB → ${perMibLarge.toFixed(2)}ms/MiB @${LARGE_MIB}MiB (ratio ${(perMibLarge / perMibSmall).toFixed(2)}×; quadratic regression)`);
198
224
  });