@crouton-kit/crouter 0.3.16 → 0.3.17

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 (72) hide show
  1. package/dist/builtin-personas/orchestration-kernel.md +6 -6
  2. package/dist/builtin-personas/plan/base.md +1 -1
  3. package/dist/builtin-personas/spec/base.md +1 -1
  4. package/dist/commands/canvas-browse.d.ts +2 -0
  5. package/dist/commands/canvas-browse.js +45 -0
  6. package/dist/commands/canvas-prune.js +11 -2
  7. package/dist/commands/canvas.js +3 -2
  8. package/dist/commands/node.js +13 -0
  9. package/dist/commands/skill/author.js +2 -2
  10. package/dist/core/__tests__/cascade-close.test.js +199 -0
  11. package/dist/core/__tests__/daemon-boot.test.js +7 -0
  12. package/dist/core/__tests__/daemon-liveness.test.js +59 -4
  13. package/dist/core/__tests__/dead-pane-regression.test.js +151 -0
  14. package/dist/core/__tests__/fixtures/fake-pi-host.d.ts +2 -0
  15. package/dist/core/__tests__/fixtures/fake-pi-host.js +301 -0
  16. package/dist/core/__tests__/flagship-lifecycle.test.js +273 -0
  17. package/dist/core/__tests__/grace-clock.test.d.ts +1 -0
  18. package/dist/core/__tests__/grace-clock.test.js +115 -0
  19. package/dist/core/__tests__/helpers/harness.d.ts +78 -0
  20. package/dist/core/__tests__/helpers/harness.js +406 -0
  21. package/dist/core/__tests__/lifecycle.test.js +6 -13
  22. package/dist/core/__tests__/live-mutation.test.d.ts +1 -0
  23. package/dist/core/__tests__/live-mutation.test.js +341 -0
  24. package/dist/core/__tests__/placement-focus.test.js +53 -15
  25. package/dist/core/__tests__/relaunch.test.js +12 -12
  26. package/dist/core/__tests__/reset.test.js +11 -6
  27. package/dist/core/__tests__/spike-harness.test.d.ts +1 -0
  28. package/dist/core/__tests__/spike-harness.test.js +241 -0
  29. package/dist/core/__tests__/subscription-delivery.test.d.ts +1 -0
  30. package/dist/core/__tests__/subscription-delivery.test.js +233 -0
  31. package/dist/core/canvas/browse/__tests__/model.test.d.ts +1 -0
  32. package/dist/core/canvas/browse/__tests__/model.test.js +142 -0
  33. package/dist/core/canvas/browse/__tests__/render.test.d.ts +1 -0
  34. package/dist/core/canvas/browse/__tests__/render.test.js +102 -0
  35. package/dist/core/canvas/browse/app.d.ts +4 -0
  36. package/dist/core/canvas/browse/app.js +349 -0
  37. package/dist/core/canvas/browse/model.d.ts +97 -0
  38. package/dist/core/canvas/browse/model.js +258 -0
  39. package/dist/core/canvas/browse/render.d.ts +41 -0
  40. package/dist/core/canvas/browse/render.js +387 -0
  41. package/dist/core/canvas/browse/terminal.d.ts +23 -0
  42. package/dist/core/canvas/browse/terminal.js +100 -0
  43. package/dist/core/canvas/canvas.d.ts +9 -2
  44. package/dist/core/canvas/canvas.js +41 -3
  45. package/dist/core/canvas/render.d.ts +10 -0
  46. package/dist/core/canvas/render.js +25 -1
  47. package/dist/core/feed/inbox.d.ts +0 -3
  48. package/dist/core/feed/inbox.js +1 -5
  49. package/dist/core/runtime/busy.d.ts +8 -0
  50. package/dist/core/runtime/busy.js +46 -0
  51. package/dist/core/runtime/lifecycle.d.ts +1 -1
  52. package/dist/core/runtime/lifecycle.js +12 -4
  53. package/dist/core/runtime/naming.d.ts +3 -3
  54. package/dist/core/runtime/naming.js +6 -6
  55. package/dist/core/runtime/placement.d.ts +22 -5
  56. package/dist/core/runtime/placement.js +44 -13
  57. package/dist/core/runtime/reset.d.ts +11 -8
  58. package/dist/core/runtime/reset.js +23 -18
  59. package/dist/daemon/crtrd.js +43 -21
  60. package/dist/pi-extensions/canvas-nav.js +29 -25
  61. package/dist/pi-extensions/canvas-resume.d.ts +0 -1
  62. package/dist/pi-extensions/canvas-resume.js +35 -126
  63. package/dist/pi-extensions/canvas-stophook.d.ts +1 -1
  64. package/dist/pi-extensions/canvas-stophook.js +16 -0
  65. package/dist/prompts/skill.js +6 -1
  66. package/package.json +1 -1
  67. package/dist/commands/__tests__/skill.test.js +0 -290
  68. package/dist/core/__tests__/pkg.test.js +0 -218
  69. package/dist/core/__tests__/sys.test.js +0 -208
  70. /package/dist/{commands/__tests__/skill.test.d.ts → core/__tests__/cascade-close.test.d.ts} +0 -0
  71. /package/dist/core/__tests__/{pkg.test.d.ts → dead-pane-regression.test.d.ts} +0 -0
  72. /package/dist/core/__tests__/{sys.test.d.ts → flagship-lifecycle.test.d.ts} +0 -0
@@ -0,0 +1,341 @@
1
+ // Run with: node --import tsx/esm --test src/core/__tests__/live-mutation.test.ts
2
+ //
3
+ // AXIS: LIVE MUTATION of the 2×2 state vector (mode {base,orchestrator} ×
4
+ // lifecycle {terminal,resident}) while a node is ACTIVE/LIVE — driven through
5
+ // the REAL `crtr node lifecycle` / `node promote` / `node demote` CLI verbs
6
+ // against a live fake-pi, with the REAL stophook / kickoff / daemon hooks doing
7
+ // the work. Every assertion reads the canvas data layer and is checked against
8
+ // the state-model ORACLE (mq1su40t .../state-model.md).
9
+ //
10
+ // This file is ADDITIVE and uses ONLY the public Harness API + a couple of pure
11
+ // test-local helpers (noted below). It does not edit harness.ts / fake-pi-host.ts
12
+ // or any production file.
13
+ //
14
+ // Coverage rationale (grep of src/core/__tests__/ before writing):
15
+ // • persona.test.ts — UNIT-level personaDrift/transitionGuidance (pure
16
+ // functions; no live pi, no turn_end firing).
17
+ // • stop-guard.test.ts — UNIT-level evaluateStop (no live flip via CLI).
18
+ // • daemon-liveness.test.ts — superviseTick over BORN-terminal idle-release
19
+ // rows (never a live lifecycle FLIP).
20
+ // • flagship-lifecycle — B born terminal idle-releases; A born resident
21
+ // stays live; promote+turn fires the steer ONCE.
22
+ // GENUINELY MISSING (this file): the LIVE FLIP itself — flipping a running
23
+ // node's lifecycle/mode through the real verb and proving the runtime behavior
24
+ // (idle-release vs dormant; persona-ack recompose; the A4 boundary) changes
25
+ // accordingly. None of the above drives `crtr node lifecycle` on a live node,
26
+ // nor asserts persona_ack mutation across a live promote, nor the A4 loss site.
27
+ import { test } from 'node:test';
28
+ import assert from 'node:assert/strict';
29
+ import { spawnSync } from 'node:child_process';
30
+ import { createHarness, hasTmux } from './helpers/harness.js';
31
+ const SKIP = !hasTmux() ? 'tmux unavailable' : false;
32
+ function sessionExists(session) {
33
+ return spawnSync('tmux', ['has-session', '-t', session], { stdio: 'ignore' }).status === 0;
34
+ }
35
+ // --- pure test-local helpers (candidates to fold into harness.ts later) -----
36
+ /** The injected entries delivered as a turn-boundary `steer`. */
37
+ function steers(inj) {
38
+ return inj.filter((e) => e.deliverAs === 'steer');
39
+ }
40
+ /** A steer carrying the base→orchestrator orchestration guidance. */
41
+ function orchestrationSteers(inj) {
42
+ return steers(inj).filter((e) => /ORCHESTRATOR/i.test(e.content));
43
+ }
44
+ /** Normalize the two persona axes off a NodeMeta for deepEqual. */
45
+ function persona(m) {
46
+ return { mode: m.mode, lifecycle: m.lifecycle };
47
+ }
48
+ /** The first %pane_id of a tmux window. The spawn path records window+session
49
+ * but NOT pane (spawn.ts: pane is null until a reconcile/focus), so a node's
50
+ * live pane must be resolved from its window here. */
51
+ function firstPaneOf(window) {
52
+ const r = spawnSync('tmux', ['list-panes', '-t', window, '-F', '#{pane_id}'], { encoding: 'utf8' });
53
+ if (r.status !== 0)
54
+ return null;
55
+ return r.stdout.split('\n').map((s) => s.trim()).filter(Boolean)[0] ?? null;
56
+ }
57
+ // ===========================================================================
58
+ // (a) LIFECYCLE FLIP — `crtr node lifecycle` on a LIVE node, both directions,
59
+ // observing the idle-release behavior change. A round-trip on ONE live
60
+ // node (terminal→resident→terminal) proves both directions faithfully:
61
+ // a flipped-resident node no longer idle-releases (stays live, daemon does
62
+ // NOT release it); flipped-terminal it idle-releases again on pi-death.
63
+ // ===========================================================================
64
+ test('live lifecycle flip: terminal→resident suppresses idle-release; resident→terminal restores it', { skip: SKIP, timeout: 120_000 }, async () => {
65
+ const h = await createHarness({ sessionPrefix: 'crtr-live-life' });
66
+ try {
67
+ // A (resident root, data-layer) ─ B (base/terminal, live) ─ C (base/terminal, live).
68
+ // B holds an ACTIVE live subscription to C → a TERMINAL B that stops is
69
+ // legitimately 'awaiting' (stop-guard) and would idle-release. That live
70
+ // sub is the precondition that makes the resident-vs-terminal flip the
71
+ // ONLY variable.
72
+ const A = h.spawnRoot('resident root');
73
+ const B = await h.spawnChild(A, 'do the work', { kind: 'developer' });
74
+ const C = await h.spawnChild(B, 'a subtask');
75
+ {
76
+ const b = h.node(B);
77
+ assert.deepEqual(persona(b), { mode: 'base', lifecycle: 'terminal' }, 'B born base×terminal');
78
+ assert.equal(b.status, 'active', 'B active');
79
+ assert.equal(b.intent ?? null, null, 'B no intent');
80
+ assert.equal(h.status(C), 'active', 'C active — B holds a live sub to it');
81
+ assert.ok(h.subscriptions(B).some((s) => s.node_id === C && s.active), 'B subscribes_to C (active) — the awaiting precondition');
82
+ }
83
+ // --- FLIP 1: terminal → RESIDENT (live). Oracle §4: sets lifecycle + the
84
+ // launch spec, status/intent UNTOUCHED. ---
85
+ {
86
+ const res = h.cli(B, ['node', 'lifecycle', 'resident', '--node', B]);
87
+ assert.equal(res.code, 0, `lifecycle resident exit 0\n${res.stderr}`);
88
+ const b = h.node(B);
89
+ assert.equal(b.lifecycle, 'resident', 'B → resident');
90
+ assert.equal(b.mode, 'base', 'mode UNCHANGED by a lifecycle flip (orthogonal axis)');
91
+ assert.equal(b.status, 'active', 'status UNTOUCHED by node lifecycle (oracle §4)');
92
+ assert.equal(b.intent ?? null, null, 'intent UNTOUCHED by node lifecycle (oracle §4)');
93
+ }
94
+ // B stops AS RESIDENT: agent_end runs the stop-guard, which keys on
95
+ // lifecycle==='resident' → 'dormant' → the handler does NOT shut pi down
96
+ // (oracle §3a). So B stays active, pi alive, pane held — it does NOT
97
+ // idle-release despite holding the same live sub that would release a
98
+ // terminal node.
99
+ await h.stop(B);
100
+ // MINOR-2: asserting a NON-event (B must NOT idle-release) cannot be a single
101
+ // immediate read — h.stop() resolves once agent_end is RECORDED, BEFORE the
102
+ // handler completes, so a regression where the resident 'dormant' branch
103
+ // wrongly ran transition('release') + ctx.shutdown() asynchronously would
104
+ // still observe the pre-release state and false-pass. Instead POLL-STABLE:
105
+ // sample repeatedly across a real settle (a daemon tick partway, so the
106
+ // handler + a full superviseTick have both run) and assert the invariant
107
+ // holds on EVERY sample. A regression that idle-releases within the window
108
+ // is caught the moment it flips.
109
+ {
110
+ const deadline = Date.now() + 2_000;
111
+ let ticked = false;
112
+ for (;;) {
113
+ const b = h.node(B);
114
+ assert.equal(b.status, 'active', 'resident B stays ACTIVE on stop (dormant, not released)');
115
+ assert.equal(b.intent ?? null, null, 'resident B has NO idle-release intent');
116
+ assert.equal(h.paneAlive(B), true, 'resident B keeps its live pi/pane (no shutdown)');
117
+ assert.equal(h.status(C), 'active', 'C untouched while B is dormant-resident');
118
+ // Drive a real daemon decision pass midway: a superviseTick sees B
119
+ // active + pane-alive + pid-alive → handleLiveWindow 'leave'. If the
120
+ // daemon wrongly released a resident node, the next sample catches it.
121
+ if (!ticked) {
122
+ await h.tick();
123
+ ticked = true;
124
+ }
125
+ if (Date.now() >= deadline)
126
+ break;
127
+ await new Promise((r) => setTimeout(r, 100));
128
+ }
129
+ }
130
+ // --- FLIP 2: resident → TERMINAL (live), on the now-resident live node. ---
131
+ {
132
+ const res = h.cli(B, ['node', 'lifecycle', 'terminal', '--node', B]);
133
+ assert.equal(res.code, 0, `lifecycle terminal exit 0\n${res.stderr}`);
134
+ const b = h.node(B);
135
+ assert.equal(b.lifecycle, 'terminal', 'B → terminal');
136
+ assert.equal(b.status, 'active', 'status still UNTOUCHED by the flip');
137
+ assert.equal(b.intent ?? null, null, 'intent still UNTOUCHED by the flip');
138
+ }
139
+ // B stops AS TERMINAL now: stop-guard sees terminal + an active live sub to
140
+ // C → 'awaiting' → transition('release') + ctx.shutdown(). idle/idle-release;
141
+ // pi dies; UNFOCUSED backstage pane closes (oracle §3b). The exact behavior
142
+ // the resident flip had suppressed.
143
+ await h.stop(B);
144
+ await h.waitForStatus(B, 'idle');
145
+ {
146
+ const b = h.node(B);
147
+ assert.equal(b.status, 'idle', 'terminal B idle-releases on stop');
148
+ assert.equal(b.intent, 'idle-release', 'intent=idle-release (transition release)');
149
+ }
150
+ await h.waitForPaneGone(B);
151
+ assert.equal(h.paneAlive(B), false, 'unfocused terminal B → pane closed on idle-release');
152
+ assert.equal(h.status(C), 'active', 'C still active while B sleeps');
153
+ }
154
+ finally {
155
+ const session = h.session;
156
+ await h.dispose();
157
+ assert.equal(sessionExists(session), false, 'isolated session killed — no stray');
158
+ }
159
+ });
160
+ // ===========================================================================
161
+ // (b) MODE FLIP — promote: base → orchestrator on a LIVE node. The flip itself
162
+ // does NOT commit the persona ack; the turn_end injector recomposes (commits
163
+ // the ack + delivers the steer) on the next turn, and a second turn is a
164
+ // no-op (drift cleared). Flagship asserts the steer fires once; the NEW
165
+ // assertions here are the persona_ack MUTATION across the live flip and the
166
+ // idempotence — neither is covered elsewhere.
167
+ // ===========================================================================
168
+ test('live mode flip: promote base→orchestrator recomposes persona_ack at turn_end (and is idempotent)', { skip: SKIP, timeout: 120_000 }, async () => {
169
+ const h = await createHarness({ sessionPrefix: 'crtr-live-mode' });
170
+ try {
171
+ const A = h.spawnRoot('resident root');
172
+ const B = await h.spawnChild(A, 'do the work', { kind: 'developer' });
173
+ {
174
+ const b = h.node(B);
175
+ assert.deepEqual(persona(b), { mode: 'base', lifecycle: 'terminal' }, 'B born base×terminal');
176
+ // Invariant 11: born acked to its own persona → no spurious drift turn 1.
177
+ assert.deepEqual(b.persona_ack, { mode: 'base', lifecycle: 'terminal' }, 'persona_ack born equal to the initial persona (invariant 11)');
178
+ }
179
+ // PROMOTE (live): mode→orchestrator, lifecycle UNCHANGED, status/intent
180
+ // untouched (no transition). Crucially promote does NOT commit the ack —
181
+ // the drift is left PENDING for the injector.
182
+ {
183
+ const res = h.cli(B, ['node', 'promote', '--kind', 'developer']);
184
+ assert.equal(res.code, 0, `promote exit 0\n${res.stderr}`);
185
+ const b = h.node(B);
186
+ assert.equal(b.mode, 'orchestrator', 'B → orchestrator');
187
+ assert.equal(b.lifecycle, 'terminal', 'lifecycle UNCHANGED (promote is mode-only)');
188
+ assert.equal(b.status, 'active', 'status untouched by promote');
189
+ assert.equal(b.intent ?? null, null, 'intent untouched by promote');
190
+ assert.deepEqual(b.persona_ack, { mode: 'base', lifecycle: 'terminal' }, 'promote does NOT commit the ack — drift left PENDING for the injector');
191
+ }
192
+ // A TURN fires turn_end: personaDrift base→orchestrator → inject the
193
+ // orchestration guidance as a STEER, then commitPersonaAck. agent_end then
194
+ // stalls (orchestrator, no live sub, no final) → reprompt → B stays alive.
195
+ const injBefore = h.injected(B).length;
196
+ await h.turn(B, 'orchestrating');
197
+ const fresh = await h.waitFor(() => {
198
+ const slice = h.injected(B).slice(injBefore);
199
+ return orchestrationSteers(slice).length > 0 ? slice : null;
200
+ }, { timeoutMs: 15_000, label: 'base→orchestrator steer at turn_end' });
201
+ assert.ok(orchestrationSteers(fresh).length >= 1, 'turn_end delivered the orchestration guidance as a steer');
202
+ {
203
+ const b = h.node(B);
204
+ assert.deepEqual(b.persona_ack, { mode: 'orchestrator', lifecycle: 'terminal' }, 'persona RECOMPOSE committed: persona_ack advanced to the new persona at turn_end');
205
+ assert.equal(b.status, 'active', 'B not stranded — reprompt keeps it alive');
206
+ }
207
+ // IDEMPOTENCE: a SECOND turn finds no drift (ack already committed) → NO
208
+ // new persona steer is injected.
209
+ const injBeforeSecond = h.injected(B).length;
210
+ await h.turn(B, 'orchestrating again');
211
+ const afterSecond = h.injected(B).slice(injBeforeSecond);
212
+ assert.equal(orchestrationSteers(afterSecond).length, 0, 'no fresh orchestration steer on the second turn — drift cleared (idempotent recompose)');
213
+ assert.equal(h.node(B).mode, 'orchestrator', 'B still orchestrator');
214
+ }
215
+ finally {
216
+ const session = h.session;
217
+ await h.dispose();
218
+ assert.equal(sessionExists(session), false, 'isolated session killed — no stray');
219
+ }
220
+ });
221
+ // ===========================================================================
222
+ // (b) MODE FLIP — demote. ⚑ FLAG vs the task framing ("demote orchestrator back
223
+ // to base; assert the mode field changes"): the `node demote` verb does NOT
224
+ // flip the SAME live node's mode orchestrator→base. Per ORACLE §4 (which
225
+ // matches the code: demote.ts) it FINISHES the node (push final → done) and
226
+ // RECYCLES the pane into a FRESH general/base/resident root — a DIFFERENT
227
+ // node. The demoted node keeps mode=orchestrator (it is merely `done`).
228
+ // There is NO live verb that flips a node orchestrator→base, so the
229
+ // persona.ts `baseModeGuidance` (orchestrator→base) is effectively
230
+ // UNREACHABLE via live mutation. This test pins the real behavior so the
231
+ // contradiction is visible; production is NOT changed.
232
+ // ===========================================================================
233
+ test('node demote is FINISH+RECYCLE, not an orchestrator→base mode flip (current behavior vs task framing)', { skip: SKIP, timeout: 120_000 }, async () => {
234
+ const h = await createHarness({ sessionPrefix: 'crtr-live-demote' });
235
+ try {
236
+ const A = h.spawnRoot('resident root');
237
+ const B = await h.spawnChild(A, 'do the work', { kind: 'developer' });
238
+ // Promote B so it is genuinely an orchestrator before we demote it.
239
+ assert.equal(h.cli(B, ['node', 'promote', '--kind', 'developer']).code, 0, 'promote B');
240
+ const b0 = h.node(B);
241
+ assert.equal(b0.mode, 'orchestrator', 'B is orchestrator before demote');
242
+ // Resolve B's live %pane_id from its window (the row's `pane` is null until
243
+ // a reconcile; the spawn path records only window+session).
244
+ const pane = firstPaneOf(b0.window);
245
+ assert.ok(typeof pane === 'string' && pane !== '', 'B has a live pane to recycle');
246
+ // DEMOTE via the real verb (TMUX_PANE is scrubbed from child env → pass --pane).
247
+ const res = h.cli(B, ['node', 'demote', '--node', B, '--pane', pane]);
248
+ assert.equal(res.code, 0, `demote exit 0\n${res.stderr}`);
249
+ // The leaf renders `<demoted ... finalized=".." new_root=".."/>` (not JSON).
250
+ assert.match(res.stdout, /<demoted /, `demote recycled the pane\n${res.stdout}`);
251
+ const newRoot = /new_root="([^"]+)"/.exec(res.stdout)?.[1];
252
+ const finalized = /finalized="true"/.test(res.stdout);
253
+ // ⚑ The demoted node is FINISHED, not mode-flipped.
254
+ {
255
+ const b = h.node(B);
256
+ assert.equal(b.status, 'done', 'demoted node → done (finished), NOT re-roled');
257
+ assert.equal(b.intent, 'done', 'intent=done (finalize), per the push-final path');
258
+ assert.equal(b.mode, 'orchestrator', '⚑ demoted node KEEPS mode=orchestrator — demote is NOT an orchestrator→base flip');
259
+ assert.ok(finalized, 'demote pushed a final for the node');
260
+ }
261
+ // The fresh root is a DIFFERENT, BASE×RESIDENT node — that is where "base"
262
+ // comes from, not a mutation of B.
263
+ assert.ok(typeof newRoot === 'string' && newRoot !== B, 'a fresh root (≠ B) was minted');
264
+ {
265
+ const fresh = h.node(newRoot);
266
+ assert.deepEqual(persona(fresh), { mode: 'base', lifecycle: 'resident' }, 'recycled root is born base×resident (general)');
267
+ // Born acked to its own persona → it will never see an orchestrator→base
268
+ // drift steer: that persona path is unreachable through live mutation.
269
+ assert.deepEqual(fresh.persona_ack, { mode: 'base', lifecycle: 'resident' }, 'fresh root born acked base×resident — no orchestrator→base drift will ever fire');
270
+ }
271
+ }
272
+ finally {
273
+ const session = h.session;
274
+ await h.dispose();
275
+ assert.equal(sessionExists(session), false, 'isolated session killed — no stray');
276
+ }
277
+ });
278
+ // ===========================================================================
279
+ // (b) A4 BOUNDARY — promote-then-yield emits a steer that is discarded. The
280
+ // oracle/flagship boundary: the base→orchestrator guidance lands as a STEER
281
+ // only if a turn_end fires while the drift is pending. A `node yield` on a
282
+ // base node auto-promotes (mode→orchestrator, ack NOT committed) and its
283
+ // agent_end goes STRAIGHT to reviveInPlace (b') with NO preceding turn_end —
284
+ // so the only steer-delivery site (turn_end) is BYPASSED. Two deterministic
285
+ // facts pin the boundary, both confirmed by direct observation:
286
+ // (1) NO orchestration STEER is ever delivered (the LOSS).
287
+ // (2) The ack is silently advanced base→orchestrator at the refresh DRAIN
288
+ // (reviveInPlace→drainBearings→commitPersonaAck), NOT via a steer — so
289
+ // neither this turn nor (per A4) the fresh revive re-offers it as a
290
+ // steer; the guidance survives only in the kickoff PROMPT it built.
291
+ // ⚑ FLAGGED (not fixed): the in-place refresh of this LARGE pending-drift
292
+ // kickoff prompt did NOT complete a fresh fake-pi boot in the harness (it
293
+ // stayed at 1 boot, intent=refresh, ack=orchestrator, pane alive) — a
294
+ // base→orchestrator yield's giant <persona-transition> kickoff pushed
295
+ // through respawn-pane did not bring up the fresh vehicle. Whether a real
296
+ // edge (oversized argv through respawn-pane) or a harness artifact, it is
297
+ // out of scope to fix; this test asserts only the deterministic boundary.
298
+ // ===========================================================================
299
+ test('A4: a base→orchestrator yield with no preceding turn_end loses the orchestration STEER (ack advances silently at the refresh drain)', { skip: SKIP, timeout: 120_000 }, async () => {
300
+ const h = await createHarness({ sessionPrefix: 'crtr-live-a4' });
301
+ try {
302
+ const A = h.spawnRoot('resident root');
303
+ const B = await h.spawnChild(A, 'do the work', { kind: 'developer' });
304
+ assert.deepEqual(persona(h.node(B)), { mode: 'base', lifecycle: 'terminal' }, 'B born base×terminal');
305
+ const injBefore = h.injected(B).length;
306
+ // `crtr node yield` (base → auto-promote → intent=refresh). INTERMEDIATE
307
+ // state, BEFORE any agent_end: mode flipped, ack NOT yet committed (the
308
+ // turn_end injector has not run), intent=refresh, drift PENDING.
309
+ const y = h.cli(B, ['node', 'yield', 'refresh against the roadmap']);
310
+ assert.equal(y.code, 0, `node yield exit 0\n${y.stderr}`);
311
+ {
312
+ const b = h.node(B);
313
+ assert.equal(b.mode, 'orchestrator', 'yield auto-promoted base→orchestrator');
314
+ assert.equal(b.intent, 'refresh', 'intent=refresh set by the yield');
315
+ assert.deepEqual(b.persona_ack, { mode: 'base', lifecycle: 'terminal' }, 'ack STILL base — promote/yield never commits it; only an injector does');
316
+ }
317
+ // Fire the stop: agent_end sees intent=refresh → (b') reviveInPlace, whose
318
+ // drainBearings commits the ack synchronously BEFORE the respawn. NO
319
+ // turn_end fires this turn, so the turn_end steer site is bypassed.
320
+ await h.stop(B);
321
+ // (2) The ack is silently advanced to orchestrator at the refresh DRAIN —
322
+ // not by any steer. (waitFor: the agent_end handler runs after h.stop
323
+ // observes the recorded event.)
324
+ await h.waitFor(() => {
325
+ const a = h.node(B)?.persona_ack;
326
+ return a?.mode === 'orchestrator' && a?.lifecycle === 'terminal';
327
+ }, { timeoutMs: 20_000, label: 'persona_ack advanced at the refresh drain (not via a steer)' });
328
+ assert.deepEqual(h.node(B).persona_ack, { mode: 'orchestrator', lifecycle: 'terminal' }, 'ack committed base→orchestrator by drainBearings during reviveInPlace');
329
+ // (1) ⚑ LOSS site: across the whole yield→refresh, NO orchestration
330
+ // guidance was ever delivered as a turn-boundary STEER (the only steer
331
+ // site, turn_end, never ran). The ack moved without the agent ever being
332
+ // steered with the new-role guidance — it survives only in the kickoff
333
+ // prompt drainBearings built for the (here, non-booting) fresh vehicle.
334
+ assert.equal(orchestrationSteers(h.injected(B).slice(injBefore)).length, 0, '⚑ A4: no orchestration STEER delivered — the turn_end injector was bypassed by the yield');
335
+ }
336
+ finally {
337
+ const session = h.session;
338
+ await h.dispose();
339
+ assert.equal(sessionExists(session), false, 'isolated session killed — no stray');
340
+ }
341
+ });
@@ -22,6 +22,7 @@ import { createNode, getNode, setPresence } from '../canvas/canvas.js';
22
22
  import { openFocusRow, getFocusByNode, getFocusById, listFocuses, } from '../canvas/focuses.js';
23
23
  import { closeDb } from '../canvas/db.js';
24
24
  import { outgoingDisposition, retargetFocus, openFocus, focus as placementFocus, registerRootFocus, focusByPane, detachToBackground, } from '../runtime/placement.js';
25
+ import { markBusy, isBusy } from '../runtime/busy.js';
25
26
  let home;
26
27
  let savedTmux;
27
28
  function node(id, over = {}) {
@@ -58,18 +59,27 @@ after(() => {
58
59
  process.env['TMUX'] = savedTmux;
59
60
  });
60
61
  // ---------------------------------------------------------------------------
61
- // 1a. PURE: outgoingDisposition — backstage (still generating) vs kill.
62
+ // 1a. PURE: outgoingDisposition — the 4-field truth table (kill / backstage /
63
+ // release). Each branch is provably non-vacuous (a wrong impl fails).
62
64
  // ---------------------------------------------------------------------------
63
- test('outgoingDisposition: a still-generating node → BACKSTAGE (F2: keeps running off-screen)', () => {
64
- assert.deepEqual(outgoingDisposition({ exists: true, generating: true }), { kind: 'backstage' });
65
- });
66
- test('outgoingDisposition: a dormant/done node → KILL (Invariant P: not-focused + not-generating ⇒ no pane)', () => {
67
- assert.deepEqual(outgoingDisposition({ exists: true, generating: false }), { kind: 'kill' });
68
- });
69
65
  test('outgoingDisposition: a HOLDER / vanished node (no row) → KILL (never backstaged)', () => {
70
66
  // A wrong impl that backstaged a holder would leak a sleep pane into crtr.
71
- assert.deepEqual(outgoingDisposition({ exists: false, generating: true }), { kind: 'kill' });
72
- assert.deepEqual(outgoingDisposition({ exists: false, generating: false }), { kind: 'kill' });
67
+ assert.deepEqual(outgoingDisposition({ exists: false, live: false, resident: false, generating: false }), { kind: 'kill' });
68
+ assert.deepEqual(outgoingDisposition({ exists: false, live: true, resident: true, generating: true }), { kind: 'kill' });
69
+ });
70
+ test('outgoingDisposition: a done/dead node (not live) → KILL (Invariant P: not-focused + not-live ⇒ no pane)', () => {
71
+ assert.deepEqual(outgoingDisposition({ exists: true, live: false, resident: false, generating: false }), { kind: 'kill' });
72
+ });
73
+ test('outgoingDisposition: a RESIDENT node (editor/root) → BACKSTAGE (human-driven, NEVER despawned on focus-away)', () => {
74
+ // Resident wins even when NOT mid-turn — a wrong impl here KILLS the user\'s editor.
75
+ assert.deepEqual(outgoingDisposition({ exists: true, live: true, resident: true, generating: false }), { kind: 'backstage' });
76
+ assert.deepEqual(outgoingDisposition({ exists: true, live: true, resident: true, generating: true }), { kind: 'backstage' });
77
+ });
78
+ test('outgoingDisposition: a still-generating terminal worker → BACKSTAGE (F2: keeps running off-screen)', () => {
79
+ assert.deepEqual(outgoingDisposition({ exists: true, live: true, resident: false, generating: true }), { kind: 'backstage' });
80
+ });
81
+ test('outgoingDisposition: a PARKED terminal viewer (live, not mid-turn) → RELEASE (despawn to dormant — the stuck-green fix)', () => {
82
+ assert.deepEqual(outgoingDisposition({ exists: true, live: true, resident: false, generating: false }), { kind: 'release' });
73
83
  });
74
84
  // ---------------------------------------------------------------------------
75
85
  // 2. Gated real-tmux: the hot-swap. Two isolated sessions: `user` (the user's
@@ -121,9 +131,11 @@ test('retargetFocus: outgoing GENERATING → backstaged; the viewport stays put
121
131
  await withSessions('gen', async ({ user, back, userWindow, backWindow }) => {
122
132
  const focusPane = livePane(user, userWindow); // R's focus pane (the viewport)
123
133
  const backPane = livePane(back, backWindow); // A's live backstage pane
124
- // R is the outgoing occupant, generating (a live pi_pid). A is incoming.
125
- createNode(node('R', { pane: focusPane, tmux_session: user, window: userWindow, status: 'active', pi_pid: process.pid, home_session: back }));
134
+ // R is the outgoing occupant, generating: a terminal worker MID-TURN (live
135
+ // pi_pid AND the busy marker set). A is incoming.
136
+ createNode(node('R', { pane: focusPane, tmux_session: user, window: userWindow, status: 'active', lifecycle: 'terminal', pi_pid: process.pid, home_session: back }));
126
137
  createNode(node('A', { pane: backPane, tmux_session: back, window: backWindow, status: 'active', pi_pid: process.pid, home_session: back }));
138
+ markBusy('R'); // R is genuinely mid-turn (isGenerating = busy && pidAlive)
127
139
  openFocusRow('f1', focusPane, user, 'R');
128
140
  const userBefore = windowIds(user).length;
129
141
  const res = retargetFocus('f1', 'A', NOREVIVE);
@@ -145,18 +157,44 @@ test('retargetFocus: outgoing GENERATING → backstaged; the viewport stays put
145
157
  assert.equal(focusByPane(backPane)?.node_id, 'A', 'the focus row tracks A after the retarget');
146
158
  });
147
159
  });
148
- test('retargetFocus: outgoing DORMANT (no live pi) → its now-backstage pane is REAPED (Invariant P)', { skip: !hasTmux() }, async () => {
160
+ test('retargetFocus: outgoing DONE/dead (not live) → its now-backstage pane is REAPED (Invariant P)', { skip: !hasTmux() }, async () => {
149
161
  await withSessions('kill', async ({ user, back, userWindow, backWindow }) => {
150
162
  const focusPane = livePane(user, userWindow);
151
163
  const backPane = livePane(back, backWindow);
152
- // R is NOT generating (pi_pid null) → its pane must be reaped after the swap.
153
- createNode(node('R', { pane: focusPane, tmux_session: user, window: userWindow, status: 'active', pi_pid: null, home_session: back }));
164
+ // R is NOT live (status done) → the KILL path: its pane must be reaped after
165
+ // the swap, with NO lifecycle transition.
166
+ createNode(node('R', { pane: focusPane, tmux_session: user, window: userWindow, status: 'done', pi_pid: null, home_session: back }));
167
+ createNode(node('A', { pane: backPane, tmux_session: back, window: backWindow, status: 'active', pi_pid: process.pid, home_session: back }));
168
+ openFocusRow('f1', focusPane, user, 'R');
169
+ retargetFocus('f1', 'A', NOREVIVE);
170
+ assert.equal(getFocusByNode('A')?.focus_id, 'f1', 'A took the viewport');
171
+ assert.equal(paneSession(backPane), user, 'A\'s pane is in the viewport');
172
+ assert.equal(paneExistsReal(focusPane), false, 'R\'s now-backstage pane was KILLED (not live ⇒ no pane)');
173
+ assert.equal(getNode('R').status, 'done', 'R stays done (KILL does not transition status)');
174
+ assert.equal(getNode('R').pane, null, 'R\'s LOCATION was nulled (Invariant P)');
175
+ assert.equal(getNode('R').tmux_session, null);
176
+ });
177
+ });
178
+ test('retargetFocus: outgoing PARKED terminal viewer (live pi, NOT mid-turn) → RELEASED to dormant (status idle, pane reaped) — the stuck-green fix', { skip: !hasTmux() }, async () => {
179
+ await withSessions('park', async ({ user, back, userWindow, backWindow }) => {
180
+ const focusPane = livePane(user, userWindow);
181
+ const backPane = livePane(back, backWindow);
182
+ // R is a terminal node revived only for VIEWING: live pi (pidAlive) but NOT
183
+ // mid-turn (no busy marker). It was the stuck-green bug: misread as
184
+ // generating, backstaged, and left status='active' forever. It must instead
185
+ // be RELEASED to dormant.
186
+ createNode(node('R', { pane: focusPane, tmux_session: user, window: userWindow, status: 'active', lifecycle: 'terminal', pi_pid: process.pid, home_session: back }));
154
187
  createNode(node('A', { pane: backPane, tmux_session: back, window: backWindow, status: 'active', pi_pid: process.pid, home_session: back }));
188
+ assert.equal(isBusy('R'), false, 'precondition: R is parked between turns (not mid-turn)');
155
189
  openFocusRow('f1', focusPane, user, 'R');
156
190
  retargetFocus('f1', 'A', NOREVIVE);
157
191
  assert.equal(getFocusByNode('A')?.focus_id, 'f1', 'A took the viewport');
158
192
  assert.equal(paneSession(backPane), user, 'A\'s pane is in the viewport');
159
- assert.equal(paneExistsReal(focusPane), false, 'R\'s now-backstage pane was KILLED (dormant no pane)');
193
+ // The fix: focus-away flips R back to dormant (idle + idle-release) and reaps
194
+ // its pane — so the editor stops drawing it green/active.
195
+ assert.equal(getNode('R').status, 'idle', 'R was RELEASED back to dormant (no longer stuck active/green)');
196
+ assert.equal(getNode('R').intent, 'idle-release', 'R carries idle-release so the daemon revives it on its inbox');
197
+ assert.equal(paneExistsReal(focusPane), false, 'R\'s now-backstage pane was REAPED (parked viewer ⇒ no pane)');
160
198
  assert.equal(getNode('R').pane, null, 'R\'s LOCATION was nulled (Invariant P)');
161
199
  assert.equal(getNode('R').tmux_session, null);
162
200
  });
@@ -65,7 +65,7 @@ after(() => {
65
65
  // ---------------------------------------------------------------------------
66
66
  // #1 — relaunchRoot parks the old root, keeps edges, creates a fresh root
67
67
  // ---------------------------------------------------------------------------
68
- test('relaunchRoot parks the old root (done, edges intact, no wipe) and mints a fresh root', () => {
68
+ test('relaunchRoot parks the old root (canceled, edges intact, no wipe) and mints a fresh root', () => {
69
69
  createNode(node('root', {
70
70
  parent: null,
71
71
  lifecycle: 'resident',
@@ -91,18 +91,18 @@ test('relaunchRoot parks the old root (done, edges intact, no wipe) and mints a
91
91
  assert.notEqual(newId, 'root', 'a FRESH id, not the old one');
92
92
  // Respawn was dispatched against the NEW node in the given pane.
93
93
  assert.deepEqual(respawn.calls, [{ nodeId: newId, pane: 'test-pane' }]);
94
- // Old root: parked done, window detached, pi_session_id UNCHANGED (resumable).
94
+ // Old root: parked canceled, window detached, pi_session_id UNCHANGED (resumable).
95
95
  const old = getNode('root');
96
- assert.equal(old?.status, 'done', 'old root parked done');
96
+ assert.equal(old?.status, 'canceled', 'old root parked canceled');
97
97
  assert.equal(old?.window, null, 'old root window detached');
98
98
  assert.equal(old?.tmux_session, null, 'old root tmux_session detached');
99
99
  assert.equal(old?.intent, null, 'old root intent cleared');
100
100
  assert.equal(old?.pi_session_id, 'root-sess', 'pi_session_id preserved (resumable)');
101
101
  assert.equal(old?.pi_session_file, '/abs/root-sess.jsonl', 'pi_session_file preserved (resumable by path)');
102
102
  assert.equal(old?.parent, null, 'old root stays a root');
103
- // Descendants: DONE (not dead), but edges intact.
104
- assert.equal(getNode('child')?.status, 'done', 'child marked done (not a fault)');
105
- assert.equal(getNode('grand')?.status, 'done', 'grand marked done (not a fault)');
103
+ // Descendants: CANCELED (not dead), but edges intact.
104
+ assert.equal(getNode('child')?.status, 'canceled', 'child marked canceled (not a fault)');
105
+ assert.equal(getNode('grand')?.status, 'canceled', 'grand marked canceled (not a fault)');
106
106
  assert.deepEqual(subscriptionsOf('root').map((s) => s.node_id), ['child'], 'root→child edge intact');
107
107
  assert.deepEqual(subscriptionsOf('child').map((s) => s.node_id), ['grand'], 'child→grand edge intact');
108
108
  // Old root working state PRESERVED (history, no wipe).
@@ -152,10 +152,10 @@ test('handleNewSession on a root with a pane returns path:relaunch + parks old,
152
152
  assert.deepEqual(respawn.calls, [{ nodeId: res.newNodeId, pane: 'test-pane' }]);
153
153
  // Parked-old + fresh-new end state.
154
154
  const old = getNode('root');
155
- assert.equal(old?.status, 'done', 'old root parked done');
155
+ assert.equal(old?.status, 'canceled', 'old root parked canceled');
156
156
  assert.equal(old?.window, null, 'old root window detached');
157
157
  assert.equal(old?.pi_session_id, 'root-sess', 'pi_session_id preserved (resumable)');
158
- assert.equal(getNode('child')?.status, 'done', 'descendant reaped done');
158
+ assert.equal(getNode('child')?.status, 'canceled', 'descendant reaped canceled');
159
159
  const fresh = getNode(res.newNodeId);
160
160
  assert.equal(fresh?.parent, null, 'new node is a root');
161
161
  assert.equal(fresh?.status, 'active', 'fresh root active');
@@ -198,7 +198,7 @@ test('handleNewSession on a root with no pane falls back to in-place resetRoot',
198
198
  assert.equal(root?.lifecycle, 'resident');
199
199
  assert.equal(root?.pi_session_id, 'newsess');
200
200
  assert.equal(view('root').length, 0, 'root view emptied');
201
- assert.equal(getNode('child')?.status, 'done', 'descendant marked done');
201
+ assert.equal(getNode('child')?.status, 'canceled', 'descendant marked canceled');
202
202
  assert.equal(existsSync(roadmapPath('root')), false, 'working state wiped');
203
203
  });
204
204
  // ---------------------------------------------------------------------------
@@ -210,13 +210,13 @@ test('a second relaunchRoot on an already-parked root is a no-op', () => {
210
210
  const first = relaunchRoot('root', 'test-pane', { relaunchRootInPane: respawn.fn });
211
211
  assert.ok(first !== null, 'first /new parks + relaunches');
212
212
  const afterFirst = listNodes().length;
213
- // Old root is now `done`; a second session_start in the dying old pi must
213
+ // Old root is now `canceled`; a second session_start in the dying old pi must
214
214
  // no-op (no second parked node, no zombie new node).
215
215
  const second = relaunchRoot('root', 'test-pane', { relaunchRootInPane: respawn.fn });
216
216
  assert.equal(second, null, 'second relaunch is a no-op');
217
217
  assert.equal(listNodes().length, afterFirst, 'no second new node minted');
218
218
  assert.equal(respawn.calls.length, 1, 'respawn dispatched only once');
219
- assert.equal(getNode('root')?.status, 'done', 'old root unchanged (still parked)');
219
+ assert.equal(getNode('root')?.status, 'canceled', 'old root unchanged (still parked)');
220
220
  });
221
221
  // ---------------------------------------------------------------------------
222
222
  // #5 — /new before the root ever spawned children
@@ -227,7 +227,7 @@ test('relaunchRoot on a childless root: reap is a no-op, new node minted', () =>
227
227
  const respawn = okRespawn();
228
228
  const res = relaunchRoot('root', 'test-pane', { relaunchRootInPane: respawn.fn });
229
229
  assert.ok(res !== null, 'new node minted without throwing');
230
- assert.equal(getNode('root')?.status, 'done', 'old root parked');
230
+ assert.equal(getNode('root')?.status, 'canceled', 'old root parked');
231
231
  assert.equal(getNode(res.newNodeId)?.status, 'active', 'fresh root active');
232
232
  });
233
233
  // ---------------------------------------------------------------------------
@@ -56,9 +56,14 @@ test('resetRoot empties the root view, reaps descendants, and wipes working stat
56
56
  // Graph is empty from the root's view.
57
57
  assert.equal(view('root').length, 0, 'root view is empty after reset');
58
58
  assert.equal(subscriptionsOf('root').length, 0, 'no outgoing edges remain');
59
- // Descendants are done (clean teardown, not a fault; daemon skips them).
60
- assert.equal(getNode('child')?.status, 'done');
61
- assert.equal(getNode('grand')?.status, 'done');
59
+ // Descendants are CANCELED (A5, human-confirmed 2026-06-06): an externally-
60
+ // reaped node — via reset/relaunch OR close — did not finish its OWN work, so
61
+ // it unifies on `canceled`; `done` is reserved for finalize. Daemon skips them.
62
+ assert.equal(getNode('child')?.status, 'canceled');
63
+ assert.equal(getNode('grand')?.status, 'canceled');
64
+ // Regression: a reset-reaped descendant ends EXACTLY {canceled, null} — byte-
65
+ // for-byte identical to the close path (mirrors cascade-close.test.ts's tuple).
66
+ assert.deepEqual({ status: getNode('grand')?.status, intent: getNode('grand')?.intent ?? null }, { status: 'canceled', intent: null }, 'reset-reaped descendant: (status,intent) === (canceled, null), same as close');
62
67
  // Working state wiped.
63
68
  assert.equal(existsSync(roadmapPath('root')), false, 'roadmap wiped');
64
69
  assert.equal(existsSync(inboxPath('root')), false, 'inbox wiped');
@@ -99,7 +104,7 @@ test('Step 7: resetRoot reaps a FOCUSED descendant through tearDownNode (closes
99
104
  // getFocusByNode('desc') would still return fD.
100
105
  assert.equal(getFocusByNode('desc'), null, 'descendant focus row closed by tearDownNode');
101
106
  const d = getNode('desc');
102
- assert.equal(d.status, 'done', 'descendant reaped (done)');
107
+ assert.equal(d.status, 'canceled', 'descendant reaped (canceled — A5 unified)');
103
108
  assert.equal(d.pane ?? null, null, 'descendant pane nulled');
104
109
  assert.equal(d.tmux_session ?? null, null, 'descendant session nulled');
105
110
  });
@@ -115,10 +120,10 @@ test('reaped descendants keep their meta on disk (orphaned, not deleted)', () =>
115
120
  subscribe('root', 'child', true);
116
121
  setStatus('child', 'idle');
117
122
  resetRoot('root', 'new');
118
- // The node record persists (we detach + mark done, we don't delete the node).
123
+ // The node record persists (we detach + mark canceled, we don't delete the node).
119
124
  const child = getNode('child');
120
125
  assert.ok(child, 'child meta still on disk');
121
- assert.equal(child?.status, 'done');
126
+ assert.equal(child?.status, 'canceled');
122
127
  // It is just unreachable from the root.
123
128
  assert.equal(view('root').length, 0);
124
129
  });
@@ -0,0 +1 @@
1
+ export {};