@crouton-kit/crouter 0.3.15 → 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.
- package/dist/builtin-personas/developer/orchestrator.md +1 -1
- package/dist/builtin-personas/orchestration-kernel.md +6 -6
- package/dist/builtin-personas/plan/base.md +1 -1
- package/dist/builtin-personas/plan/orchestrator.md +1 -1
- package/dist/builtin-personas/spec/base.md +1 -1
- package/dist/commands/canvas-browse.d.ts +2 -0
- package/dist/commands/canvas-browse.js +45 -0
- package/dist/commands/canvas-prune.js +11 -2
- package/dist/commands/canvas.js +3 -2
- package/dist/commands/chord.js +1 -1
- package/dist/commands/human/shared.js +1 -1
- package/dist/commands/node.js +14 -2
- package/dist/commands/skill/author.js +2 -2
- package/dist/commands/tmux-spread.js +2 -3
- package/dist/core/__tests__/cascade-close.test.js +199 -0
- package/dist/core/__tests__/close.test.js +2 -2
- package/dist/core/__tests__/daemon-boot.test.js +7 -0
- package/dist/core/__tests__/daemon-liveness.test.js +59 -4
- package/dist/core/__tests__/dead-pane-regression.test.js +151 -0
- package/dist/core/__tests__/fixtures/fake-pi-host.d.ts +2 -0
- package/dist/core/__tests__/fixtures/fake-pi-host.js +301 -0
- package/dist/core/__tests__/flagship-lifecycle.test.js +273 -0
- package/dist/core/__tests__/focuses.test.js +5 -68
- package/dist/core/__tests__/grace-clock.test.d.ts +1 -0
- package/dist/core/__tests__/grace-clock.test.js +115 -0
- package/dist/core/__tests__/helpers/harness.d.ts +78 -0
- package/dist/core/__tests__/helpers/harness.js +406 -0
- package/dist/core/__tests__/home-session.test.js +1 -1
- package/dist/core/__tests__/lifecycle.test.js +6 -13
- package/dist/core/__tests__/live-mutation.test.d.ts +1 -0
- package/dist/core/__tests__/live-mutation.test.js +341 -0
- package/dist/core/__tests__/placement-focus.test.js +106 -46
- package/dist/core/__tests__/placement-teardown.test.js +4 -9
- package/dist/core/__tests__/relaunch.test.js +22 -16
- package/dist/core/__tests__/reset.test.js +11 -6
- package/dist/core/__tests__/spike-harness.test.d.ts +1 -0
- package/dist/core/__tests__/spike-harness.test.js +241 -0
- package/dist/core/__tests__/subscription-delivery.test.d.ts +1 -0
- package/dist/core/__tests__/subscription-delivery.test.js +233 -0
- package/dist/core/__tests__/tmux-surface.test.js +8 -9
- package/dist/core/canvas/browse/__tests__/model.test.d.ts +1 -0
- package/dist/core/canvas/browse/__tests__/model.test.js +142 -0
- package/dist/core/canvas/browse/__tests__/render.test.d.ts +1 -0
- package/dist/core/canvas/browse/__tests__/render.test.js +102 -0
- package/dist/core/canvas/browse/app.d.ts +4 -0
- package/dist/core/canvas/browse/app.js +349 -0
- package/dist/core/canvas/browse/model.d.ts +97 -0
- package/dist/core/canvas/browse/model.js +258 -0
- package/dist/core/canvas/browse/render.d.ts +41 -0
- package/dist/core/canvas/browse/render.js +387 -0
- package/dist/core/canvas/browse/terminal.d.ts +23 -0
- package/dist/core/canvas/browse/terminal.js +100 -0
- package/dist/core/canvas/canvas.d.ts +9 -2
- package/dist/core/canvas/canvas.js +41 -3
- package/dist/core/canvas/db.js +2 -3
- package/dist/core/canvas/focuses.d.ts +2 -2
- package/dist/core/canvas/focuses.js +4 -3
- package/dist/core/canvas/render.d.ts +10 -0
- package/dist/core/canvas/render.js +25 -1
- package/dist/core/canvas/types.d.ts +1 -1
- package/dist/core/feed/inbox.d.ts +0 -3
- package/dist/core/feed/inbox.js +1 -5
- package/dist/core/runtime/busy.d.ts +8 -0
- package/dist/core/runtime/busy.js +46 -0
- package/dist/core/runtime/close.js +2 -2
- package/dist/core/runtime/demote.js +2 -7
- package/dist/core/runtime/launch.d.ts +3 -1
- package/dist/core/runtime/launch.js +4 -1
- package/dist/core/runtime/lifecycle.d.ts +1 -1
- package/dist/core/runtime/lifecycle.js +12 -4
- package/dist/core/runtime/naming.d.ts +3 -3
- package/dist/core/runtime/naming.js +6 -6
- package/dist/core/runtime/nodes.d.ts +7 -0
- package/dist/core/runtime/nodes.js +10 -1
- package/dist/core/runtime/placement.d.ts +39 -10
- package/dist/core/runtime/placement.js +100 -44
- package/dist/core/runtime/reset.d.ts +11 -8
- package/dist/core/runtime/reset.js +36 -31
- package/dist/core/runtime/revive.d.ts +1 -1
- package/dist/core/runtime/revive.js +2 -2
- package/dist/core/runtime/spawn.js +3 -3
- package/dist/core/runtime/tmux-chrome.d.ts +1 -0
- package/dist/core/runtime/tmux-chrome.js +4 -0
- package/dist/core/runtime/tmux.d.ts +13 -6
- package/dist/core/runtime/tmux.js +21 -12
- package/dist/daemon/crtrd.js +43 -21
- package/dist/pi-extensions/canvas-nav.js +40 -28
- package/dist/pi-extensions/canvas-resume.d.ts +21 -0
- package/dist/pi-extensions/canvas-resume.js +82 -0
- package/dist/pi-extensions/canvas-stophook.d.ts +1 -1
- package/dist/pi-extensions/canvas-stophook.js +21 -9
- package/dist/prompts/skill.js +6 -1
- package/package.json +2 -2
- package/dist/commands/__tests__/skill.test.js +0 -290
- package/dist/core/__tests__/pkg.test.js +0 -218
- package/dist/core/__tests__/sys.test.js +0 -208
- package/dist/core/runtime/presence.d.ts +0 -30
- package/dist/core/runtime/presence.js +0 -178
- /package/dist/{commands/__tests__/skill.test.d.ts → core/__tests__/cascade-close.test.d.ts} +0 -0
- /package/dist/core/__tests__/{pkg.test.d.ts → dead-pane-regression.test.d.ts} +0 -0
- /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
|
+
});
|
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
// Run with: node --import tsx/esm --test src/core/__tests__/placement-focus.test.ts
|
|
2
2
|
//
|
|
3
3
|
// STEP 6 of the placement/focus migration: retargetFocus / openFocus / focus +
|
|
4
|
-
// remain-on-exit + root-boot focus #1
|
|
4
|
+
// remain-on-exit + root-boot focus #1.
|
|
5
5
|
//
|
|
6
6
|
// Two proof tiers (mirrors placement-revive.test.ts):
|
|
7
|
-
// 1. PURE (no tmux): outgoingDisposition (backstage-vs-kill)
|
|
8
|
-
//
|
|
9
|
-
// (the Step-6 bridge fix). Each is provably non-vacuous (a wrong impl fails).
|
|
7
|
+
// 1. PURE (no tmux): outgoingDisposition (backstage-vs-kill). Provably
|
|
8
|
+
// non-vacuous (a wrong impl fails).
|
|
10
9
|
// 2. Gated real-tmux: the hot-swap itself — screen position invariant (ZERO new
|
|
11
10
|
// user windows), the two post-swap LOCATIONs, outgoing backstaged (still
|
|
12
11
|
// generating) vs reaped (dormant), the Q5 vacate-old-focus path, openFocus
|
|
@@ -15,16 +14,15 @@
|
|
|
15
14
|
// a live server; gated {skip:!hasTmux()} like §5.2.
|
|
16
15
|
import { test, before, after, beforeEach } from 'node:test';
|
|
17
16
|
import assert from 'node:assert/strict';
|
|
18
|
-
import { mkdtempSync, rmSync
|
|
17
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
19
18
|
import { tmpdir } from 'node:os';
|
|
20
19
|
import { join } from 'node:path';
|
|
21
20
|
import { spawnSync } from 'node:child_process';
|
|
22
21
|
import { createNode, getNode, setPresence } from '../canvas/canvas.js';
|
|
23
22
|
import { openFocusRow, getFocusByNode, getFocusById, listFocuses, } from '../canvas/focuses.js';
|
|
24
23
|
import { closeDb } from '../canvas/db.js';
|
|
25
|
-
import {
|
|
26
|
-
import {
|
|
27
|
-
import { setFocus, getFocus } from '../runtime/presence.js';
|
|
24
|
+
import { outgoingDisposition, retargetFocus, openFocus, focus as placementFocus, registerRootFocus, focusByPane, detachToBackground, } from '../runtime/placement.js';
|
|
25
|
+
import { markBusy, isBusy } from '../runtime/busy.js';
|
|
28
26
|
let home;
|
|
29
27
|
let savedTmux;
|
|
30
28
|
function node(id, over = {}) {
|
|
@@ -61,41 +59,27 @@ after(() => {
|
|
|
61
59
|
process.env['TMUX'] = savedTmux;
|
|
62
60
|
});
|
|
63
61
|
// ---------------------------------------------------------------------------
|
|
64
|
-
// 1a. PURE: outgoingDisposition —
|
|
62
|
+
// 1a. PURE: outgoingDisposition — the 4-field truth table (kill / backstage /
|
|
63
|
+
// release). Each branch is provably non-vacuous (a wrong impl fails).
|
|
65
64
|
// ---------------------------------------------------------------------------
|
|
66
|
-
test('outgoingDisposition: a still-generating node → BACKSTAGE (F2: keeps running off-screen)', () => {
|
|
67
|
-
assert.deepEqual(outgoingDisposition({ exists: true, generating: true }), { kind: 'backstage' });
|
|
68
|
-
});
|
|
69
|
-
test('outgoingDisposition: a dormant/done node → KILL (Invariant P: not-focused + not-generating ⇒ no pane)', () => {
|
|
70
|
-
assert.deepEqual(outgoingDisposition({ exists: true, generating: false }), { kind: 'kill' });
|
|
71
|
-
});
|
|
72
65
|
test('outgoingDisposition: a HOLDER / vanished node (no row) → KILL (never backstaged)', () => {
|
|
73
66
|
// A wrong impl that backstaged a holder would leak a sleep pane into crtr.
|
|
74
|
-
assert.deepEqual(outgoingDisposition({ exists: false, generating:
|
|
75
|
-
assert.deepEqual(outgoingDisposition({ exists: false, generating:
|
|
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' });
|
|
76
69
|
});
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
//
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
assert.
|
|
90
|
-
assert.equal(row?.focus_id, 'real-f', 'the REAL row survived (not replaced by a bridge row)');
|
|
91
|
-
assert.equal(row?.pane, '%a', 'the pane-correct row is intact');
|
|
92
|
-
assert.equal(listFocuses().length, 1, 'no duplicate bridge row was inserted (UNIQUE node_id)');
|
|
93
|
-
});
|
|
94
|
-
test('focus.ptr bridge: a plain setFocus (no real row) still creates the bridge row + getFocus reads it', () => {
|
|
95
|
-
setFocus('Z');
|
|
96
|
-
if (existsSync(join(crtrHome(), 'focus.ptr')))
|
|
97
|
-
unlinkSync(join(crtrHome(), 'focus.ptr'));
|
|
98
|
-
assert.equal(getFocus(), 'Z', 'getFocus falls back to the canonical bridge row when the ptr is gone');
|
|
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' });
|
|
99
83
|
});
|
|
100
84
|
// ---------------------------------------------------------------------------
|
|
101
85
|
// 2. Gated real-tmux: the hot-swap. Two isolated sessions: `user` (the user's
|
|
@@ -147,9 +131,11 @@ test('retargetFocus: outgoing GENERATING → backstaged; the viewport stays put
|
|
|
147
131
|
await withSessions('gen', async ({ user, back, userWindow, backWindow }) => {
|
|
148
132
|
const focusPane = livePane(user, userWindow); // R's focus pane (the viewport)
|
|
149
133
|
const backPane = livePane(back, backWindow); // A's live backstage pane
|
|
150
|
-
// R is the outgoing occupant, generating
|
|
151
|
-
|
|
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 }));
|
|
152
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)
|
|
153
139
|
openFocusRow('f1', focusPane, user, 'R');
|
|
154
140
|
const userBefore = windowIds(user).length;
|
|
155
141
|
const res = retargetFocus('f1', 'A', NOREVIVE);
|
|
@@ -168,21 +154,47 @@ test('retargetFocus: outgoing GENERATING → backstaged; the viewport stays put
|
|
|
168
154
|
assert.equal(paneExistsReal(focusPane), true, 'R\'s pane is alive (NOT reaped — it is still generating)');
|
|
169
155
|
assert.equal(paneSession(focusPane), back, 'R\'s pane physically moved to the backstage');
|
|
170
156
|
assert.equal(getNode('R').tmux_session, back, 'R\'s LOCATION session is the backstage');
|
|
171
|
-
assert.equal(
|
|
157
|
+
assert.equal(focusByPane(backPane)?.node_id, 'A', 'the focus row tracks A after the retarget');
|
|
172
158
|
});
|
|
173
159
|
});
|
|
174
|
-
test('retargetFocus: outgoing
|
|
160
|
+
test('retargetFocus: outgoing DONE/dead (not live) → its now-backstage pane is REAPED (Invariant P)', { skip: !hasTmux() }, async () => {
|
|
175
161
|
await withSessions('kill', async ({ user, back, userWindow, backWindow }) => {
|
|
176
162
|
const focusPane = livePane(user, userWindow);
|
|
177
163
|
const backPane = livePane(back, backWindow);
|
|
178
|
-
// R is NOT
|
|
179
|
-
|
|
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 }));
|
|
180
167
|
createNode(node('A', { pane: backPane, tmux_session: back, window: backWindow, status: 'active', pi_pid: process.pid, home_session: back }));
|
|
181
168
|
openFocusRow('f1', focusPane, user, 'R');
|
|
182
169
|
retargetFocus('f1', 'A', NOREVIVE);
|
|
183
170
|
assert.equal(getFocusByNode('A')?.focus_id, 'f1', 'A took the viewport');
|
|
184
171
|
assert.equal(paneSession(backPane), user, 'A\'s pane is in the viewport');
|
|
185
|
-
assert.equal(paneExistsReal(focusPane), false, 'R\'s now-backstage pane was KILLED (
|
|
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 }));
|
|
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)');
|
|
189
|
+
openFocusRow('f1', focusPane, user, 'R');
|
|
190
|
+
retargetFocus('f1', 'A', NOREVIVE);
|
|
191
|
+
assert.equal(getFocusByNode('A')?.focus_id, 'f1', 'A took the viewport');
|
|
192
|
+
assert.equal(paneSession(backPane), user, 'A\'s pane is in the viewport');
|
|
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)');
|
|
186
198
|
assert.equal(getNode('R').pane, null, 'R\'s LOCATION was nulled (Invariant P)');
|
|
187
199
|
assert.equal(getNode('R').tmux_session, null);
|
|
188
200
|
});
|
|
@@ -242,3 +254,51 @@ test('focus front-door: round-trip open(register #1) → retarget in place → t
|
|
|
242
254
|
assert.equal(focusByPane(aPane)?.node_id, 'A', 'the focus row tracks A\'s pane');
|
|
243
255
|
});
|
|
244
256
|
});
|
|
257
|
+
// ---------------------------------------------------------------------------
|
|
258
|
+
// Regression guards (review findings on HEAD=ccc3ee2): a detach/failed-open must
|
|
259
|
+
// not leave a dangling focus row or an orphan viewport pane (Invariant P / F4).
|
|
260
|
+
// ---------------------------------------------------------------------------
|
|
261
|
+
test('detachToBackground: a FOCUSED node sent to the backstage CLOSES its focus row (Invariant P — no phantom viewport)', { skip: !hasTmux() }, async () => {
|
|
262
|
+
await withSessions('detach', async ({ user, back, userWindow }) => {
|
|
263
|
+
const prev = process.env['CRTR_NODE_SESSION'];
|
|
264
|
+
process.env['CRTR_NODE_SESSION'] = back; // detach relocates into THIS backstage session
|
|
265
|
+
try {
|
|
266
|
+
const focusPane = livePane(user, userWindow); // the node's foreground viewport pane
|
|
267
|
+
createNode(node('N', { pane: focusPane, tmux_session: user, window: userWindow, status: 'active', pi_pid: process.pid, home_session: back }));
|
|
268
|
+
openFocusRow('f1', focusPane, user, 'N'); // N is the focus occupant
|
|
269
|
+
assert.equal(getFocusByNode('N')?.focus_id, 'f1', 'precondition: N is focused');
|
|
270
|
+
const ok = detachToBackground('N', focusPane);
|
|
271
|
+
assert.equal(ok, true, 'the break to the backstage succeeded');
|
|
272
|
+
// The fix: N is now generating-but-UNFOCUSED, so its focus row is CLOSED.
|
|
273
|
+
assert.equal(getFocusByNode('N'), null, 'N no longer occupies any focus (row CLOSED — Invariant P)');
|
|
274
|
+
assert.equal(focusByPane(focusPane), null, 'NO phantom focus resolves on the relocated pane (%id survives the break)');
|
|
275
|
+
assert.equal(listFocuses().length, 0, 'no dangling focus rows remain');
|
|
276
|
+
// The pi keeps running — the pane is alive, just moved off-screen.
|
|
277
|
+
assert.equal(paneExistsReal(focusPane), true, 'N\'s pi keeps generating (pane alive, relocated not killed)');
|
|
278
|
+
assert.equal(paneSession(focusPane), back, 'N\'s pane physically moved to the backstage session');
|
|
279
|
+
}
|
|
280
|
+
finally {
|
|
281
|
+
if (prev === undefined)
|
|
282
|
+
delete process.env['CRTR_NODE_SESSION'];
|
|
283
|
+
else
|
|
284
|
+
process.env['CRTR_NODE_SESSION'] = prev;
|
|
285
|
+
}
|
|
286
|
+
});
|
|
287
|
+
});
|
|
288
|
+
test('focus --new-pane: a FAILED retarget reaps the holder pane + focus row (no orphan viewport — F4/Invariant P)', { skip: !hasTmux() }, async () => {
|
|
289
|
+
await withSessions('newpane-fail', async ({ user, userWindow }) => {
|
|
290
|
+
const callerPane = livePane(user, userWindow); // the caller's pane we split beside
|
|
291
|
+
createNode(node('D', { status: 'active', pi_pid: null })); // D is DORMANT — no live pane
|
|
292
|
+
const winBefore = windowIds(user).length;
|
|
293
|
+
const panesIn = (s, w) => tmuxOut(['list-panes', '-t', `${s}:${w}`, '-F', '#{pane_id}']).split('\n').filter((x) => x !== '').length;
|
|
294
|
+
const panesBefore = panesIn(user, userWindow);
|
|
295
|
+
// A reviver that does NOTHING → D stays dormant, retargetFocus finds no live
|
|
296
|
+
// pin and returns focused:false. The just-opened holder must be reaped.
|
|
297
|
+
const noopRevive = () => { };
|
|
298
|
+
const res = placementFocus('D', { pane: callerPane, newPane: true, revive: noopRevive });
|
|
299
|
+
assert.equal(res.focused, false, 'D could not be placed → the --new-pane focus failed');
|
|
300
|
+
assert.equal(listFocuses().length, 0, 'the just-opened focus row was REAPED (no phantom)');
|
|
301
|
+
assert.equal(panesIn(user, userWindow), panesBefore, 'the holder split pane was REAPED (no leaked sleep pane)');
|
|
302
|
+
assert.equal(windowIds(user).length, winBefore, 'no window leaked');
|
|
303
|
+
});
|
|
304
|
+
});
|
|
@@ -9,8 +9,7 @@
|
|
|
9
9
|
// not-already-focused manager; false (caller closes the focus) in each of the
|
|
10
10
|
// three guard cases. Each guard is asserted distinctly.
|
|
11
11
|
// • tearDownNode(nodeId) — close/reset teardown: close the focus row it
|
|
12
|
-
// occupies
|
|
13
|
-
// focus.
|
|
12
|
+
// occupies and null its LOCATION.
|
|
14
13
|
import { test, before, after, beforeEach } from 'node:test';
|
|
15
14
|
import assert from 'node:assert/strict';
|
|
16
15
|
import { mkdtempSync, rmSync } from 'node:fs';
|
|
@@ -21,7 +20,6 @@ import { createNode, getNode } from '../canvas/canvas.js';
|
|
|
21
20
|
import { openFocusRow, getFocusByNode, getFocusById } from '../canvas/focuses.js';
|
|
22
21
|
import { closeDb } from '../canvas/db.js';
|
|
23
22
|
import { handFocusToManager, tearDownNode } from '../runtime/placement.js';
|
|
24
|
-
import { setFocus, getFocus } from '../runtime/presence.js';
|
|
25
23
|
let home;
|
|
26
24
|
function node(id, over = {}) {
|
|
27
25
|
return {
|
|
@@ -166,18 +164,15 @@ test('handFocusToManager: DORMANT manager (dead pane) → occupant repointed, NO
|
|
|
166
164
|
// ---------------------------------------------------------------------------
|
|
167
165
|
// tearDownNode (pure DB; no tmux — pane is null so closePane never runs).
|
|
168
166
|
// ---------------------------------------------------------------------------
|
|
169
|
-
test('tearDownNode: closes the focus row
|
|
167
|
+
test('tearDownNode: closes the focus row M occupied and nulls its LOCATION', () => {
|
|
170
168
|
createNode(node('M', { pane: null, window: null }));
|
|
171
|
-
openFocusRow('fM', null, 'Sa', 'M');
|
|
172
|
-
setFocus('M'); // M is the current focus.ptr
|
|
169
|
+
openFocusRow('fM', null, 'Sa', 'M'); // M occupies a focus row
|
|
173
170
|
tearDownNode('M');
|
|
174
171
|
assert.equal(getFocusByNode('M'), null, 'the focus row M occupied is closed');
|
|
175
172
|
const m = getNode('M');
|
|
176
173
|
assert.equal(m.pane ?? null, null, 'pane nulled');
|
|
177
174
|
assert.equal(m.window ?? null, null, 'window nulled');
|
|
178
175
|
assert.equal(m.tmux_session ?? null, null, 'session nulled');
|
|
179
|
-
const cur = getFocus();
|
|
180
|
-
assert.ok(cur === null || cur === '', 'focus.ptr cleared (M was the current focus)');
|
|
181
176
|
// Non-vacuous: an impl that skips closeFocusRow leaves fM → getFocusByNode('M')
|
|
182
|
-
// is non-null
|
|
177
|
+
// is non-null.
|
|
183
178
|
});
|