@crouton-kit/crouter 0.3.21 → 0.3.22
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/commands/canvas-history/read.d.ts +1 -0
- package/dist/commands/canvas-history/read.js +61 -0
- package/dist/commands/canvas-history/search.d.ts +1 -0
- package/dist/commands/canvas-history/search.js +270 -0
- package/dist/commands/canvas-history/show.d.ts +1 -0
- package/dist/commands/canvas-history/show.js +79 -0
- package/dist/commands/canvas-history.d.ts +2 -0
- package/dist/commands/canvas-history.js +33 -0
- package/dist/commands/canvas.js +3 -2
- package/dist/commands/memory/find.js +7 -40
- package/dist/commands/memory/lint.js +19 -34
- package/dist/commands/memory/list.js +8 -18
- package/dist/commands/memory/read.js +4 -37
- package/dist/commands/pkg/bridge.d.ts +1 -0
- package/dist/commands/pkg/bridge.js +137 -0
- package/dist/commands/pkg/plugin-inspect.js +7 -6
- package/dist/commands/pkg.js +2 -1
- package/dist/commands/skill/author.js +35 -44
- package/dist/commands/skill/shared.d.ts +1 -5
- package/dist/commands/skill/shared.js +9 -63
- package/dist/commands/skill.js +5 -6
- package/dist/commands/sys/doctor.js +6 -144
- package/dist/core/__tests__/broker-dormant-wake.test.js +71 -34
- package/dist/core/__tests__/canvas-inbox-watcher-hold.test.js +21 -5
- package/dist/core/__tests__/canvas-inbox-watcher.test.js +21 -5
- package/dist/core/__tests__/child-death-wake.test.js +133 -185
- package/dist/core/__tests__/daemon-liveness.test.js +28 -258
- package/dist/core/__tests__/flagship-lifecycle.test.js +156 -135
- package/dist/core/__tests__/frame-decoder-perf.test.js +51 -25
- package/dist/core/__tests__/{broker-attach-limits.test.js → full/broker-attach-limits.test.js} +62 -35
- package/dist/core/__tests__/{broker-attach-stream.test.js → full/broker-attach-stream.test.js} +54 -31
- package/dist/core/__tests__/full/broker-crash-teardown.test.js +143 -0
- package/dist/core/__tests__/full/broker-dialogs.test.js +153 -0
- package/dist/core/__tests__/full/broker-lifecycle.test.js +116 -0
- package/dist/core/__tests__/{cascade-close.test.js → full/cascade-close.test.js} +1 -1
- package/dist/core/__tests__/full/daemon-liveness-pane.full.test.js +292 -0
- package/dist/core/__tests__/{dead-pane-regression.test.js → full/dead-pane-regression.test.js} +2 -2
- package/dist/core/__tests__/{detach-focus.test.js → full/detach-focus.test.js} +5 -5
- package/dist/core/__tests__/{human-new-window-regression.test.js → full/human-new-window-regression.test.js} +2 -2
- package/dist/core/__tests__/{placement-focus.test.js → full/placement-focus.test.js} +5 -5
- package/dist/core/__tests__/{placement-reconcile.test.js → full/placement-reconcile.test.js} +3 -3
- package/dist/core/__tests__/{placement-revive.test.js → full/placement-revive.test.js} +5 -5
- package/dist/core/__tests__/{placement-teardown.test.js → full/placement-teardown.test.js} +4 -4
- package/dist/core/__tests__/{review-render-pane-regression.test.js → full/review-render-pane-regression.test.js} +2 -2
- package/dist/core/__tests__/full/spike-harness.test.js +117 -0
- package/dist/core/__tests__/grace-clock.test.js +72 -75
- package/dist/core/__tests__/helpers/harness.d.ts +35 -1
- package/dist/core/__tests__/helpers/harness.js +70 -12
- package/dist/core/__tests__/live-mutation-verbs.test.js +99 -104
- package/dist/core/__tests__/live-mutation.test.js +111 -134
- package/dist/core/__tests__/revive.test.js +96 -86
- package/dist/core/__tests__/subscription-delivery.test.js +116 -138
- package/dist/core/__tests__/wake-origin.test.js +54 -50
- package/dist/core/bridge-map.d.ts +19 -0
- package/dist/core/bridge-map.js +73 -0
- package/dist/core/canvas/history.d.ts +91 -0
- package/dist/core/canvas/history.js +389 -0
- package/dist/core/canvas/index.d.ts +1 -0
- package/dist/core/canvas/index.js +1 -0
- package/dist/core/config.js +3 -22
- package/dist/core/memory-resolver.d.ts +9 -2
- package/dist/core/memory-resolver.js +47 -7
- package/dist/core/resolver.d.ts +1 -30
- package/dist/core/resolver.js +6 -546
- package/dist/core/runtime/persona.js +5 -7
- package/dist/core/scope.d.ts +7 -5
- package/dist/core/scope.js +16 -17
- package/dist/core/substrate/ceiling.d.ts +28 -0
- package/dist/core/substrate/ceiling.js +87 -0
- package/dist/core/substrate/index.d.ts +2 -0
- package/dist/core/substrate/index.js +1 -0
- package/dist/core/substrate/on-read.js +18 -2
- package/dist/core/substrate/render.d.ts +2 -3
- package/dist/core/substrate/render.js +63 -51
- package/dist/pi-extensions/canvas-inbox-watcher.js +19 -2
- package/dist/types.d.ts +3 -18
- package/dist/types.js +0 -8
- package/package.json +4 -3
- package/dist/builtin-skills/.crouter-plugin/plugin.json +0 -5
- package/dist/builtin-skills/skills/crouter-development/marketplaces/SKILL.md +0 -157
- package/dist/builtin-skills/skills/crouter-development/personas/SKILL.md +0 -106
- package/dist/builtin-skills/skills/crouter-development/personas/base-prompt/SKILL.md +0 -49
- package/dist/builtin-skills/skills/crouter-development/personas/orchestrator-prompt/SKILL.md +0 -49
- package/dist/builtin-skills/skills/crouter-development/plugins/SKILL.md +0 -156
- package/dist/builtin-skills/skills/design/SKILL.md +0 -51
- package/dist/builtin-skills/skills/development/SKILL.md +0 -109
- package/dist/builtin-skills/skills/planning/SKILL.md +0 -59
- package/dist/builtin-skills/skills/spec/SKILL.md +0 -83
- package/dist/commands/skill/state.d.ts +0 -3
- package/dist/commands/skill/state.js +0 -71
- package/dist/core/__tests__/broker-crash-teardown.test.js +0 -116
- package/dist/core/__tests__/broker-dialogs.test.js +0 -126
- package/dist/core/__tests__/broker-lifecycle.test.js +0 -87
- package/dist/core/__tests__/resolver.test.js +0 -181
- package/dist/core/__tests__/spike-harness.test.js +0 -242
- /package/dist/core/__tests__/{broker-attach-limits.test.d.ts → full/broker-attach-limits.test.d.ts} +0 -0
- /package/dist/core/__tests__/{broker-attach-stream.test.d.ts → full/broker-attach-stream.test.d.ts} +0 -0
- /package/dist/core/__tests__/{broker-crash-teardown.test.d.ts → full/broker-crash-teardown.test.d.ts} +0 -0
- /package/dist/core/__tests__/{broker-dialogs.test.d.ts → full/broker-dialogs.test.d.ts} +0 -0
- /package/dist/core/__tests__/{broker-lifecycle.test.d.ts → full/broker-lifecycle.test.d.ts} +0 -0
- /package/dist/core/__tests__/{cascade-close.test.d.ts → full/cascade-close.test.d.ts} +0 -0
- /package/dist/core/__tests__/{dead-pane-regression.test.d.ts → full/daemon-liveness-pane.full.test.d.ts} +0 -0
- /package/dist/core/__tests__/{detach-focus.test.d.ts → full/dead-pane-regression.test.d.ts} +0 -0
- /package/dist/core/__tests__/{human-new-window-regression.test.d.ts → full/detach-focus.test.d.ts} +0 -0
- /package/dist/core/__tests__/{placement-focus.test.d.ts → full/human-new-window-regression.test.d.ts} +0 -0
- /package/dist/core/__tests__/{placement-reconcile.test.d.ts → full/placement-focus.test.d.ts} +0 -0
- /package/dist/core/__tests__/{placement-revive.test.d.ts → full/placement-reconcile.test.d.ts} +0 -0
- /package/dist/core/__tests__/{placement-teardown.test.d.ts → full/placement-revive.test.d.ts} +0 -0
- /package/dist/core/__tests__/{resolver.test.d.ts → full/placement-teardown.test.d.ts} +0 -0
- /package/dist/core/__tests__/{review-render-pane-regression.test.d.ts → full/review-render-pane-regression.test.d.ts} +0 -0
- /package/dist/core/__tests__/{spike-harness.test.d.ts → full/spike-harness.test.d.ts} +0 -0
|
@@ -1,57 +1,69 @@
|
|
|
1
1
|
// Run with: node --import tsx/esm --test src/core/__tests__/live-mutation-verbs.test.ts
|
|
2
2
|
//
|
|
3
|
-
// AXIS: LIVE MUTATION, part 2 —
|
|
4
|
-
// promote-then-yield boundary
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
3
|
+
// HEADLESS RETARGET (foundation-spec §C.11 + §E). AXIS: LIVE MUTATION, part 2 —
|
|
4
|
+
// the demote/recycle verb split and the A4 promote-then-yield boundary, driven
|
|
5
|
+
// against FABRICATED broker-hosted (paneless) nodes — no real tmux session, no
|
|
6
|
+
// pane chrome, and NO real broker boot. Split out of live-mutation.test.ts for
|
|
7
|
+
// node:test file-level parallelism; each test holds its OWN isolated harness.
|
|
8
|
+
//
|
|
9
|
+
// THREE-PART LOCK HEADER ──────────────────────────────────────────────────
|
|
10
|
+
// (1) BUG LOCKED — the demote/recycle SPLIT and the A4 steer-loss boundary:
|
|
11
|
+
// • `node demote` flips lifecycle→TERMINAL IN PLACE — keeps MODE, parentage,
|
|
12
|
+
// and a live (active) status; NOT finalized. It is NOT a mode flip.
|
|
13
|
+
// • `node recycle` is FINISH+RECYCLE — pushes a final → done (mode KEPT), and
|
|
14
|
+
// mints a DIFFERENT fresh general/base/resident root.
|
|
15
|
+
// • A4: a base→orchestrator yield auto-promotes (mode→orchestrator) and sets
|
|
16
|
+
// intent=refresh but NEVER commits persona_ack — the drift is left pending
|
|
17
|
+
// and, because a yield's agent_end goes straight to reviveInPlace with NO
|
|
18
|
+
// preceding turn_end, the only steer-delivery site is bypassed.
|
|
19
|
+
// (2) WHY MODEL-LEVEL, NOT PANE/WINDOW — demote writes lifecycle on the row
|
|
20
|
+
// (setLifecycle→updateNode); recycle's FINISH+MINT half is pushFinal +
|
|
21
|
+
// spawnNode (pure model) — only its pane-respawn is tmux (a deliberately-
|
|
22
|
+
// skipped artifact here, recycled:false). yield's auto-promote + intent set
|
|
23
|
+
// are row writes; persona_ack/personaDrift are pure. No pane is read for any
|
|
24
|
+
// assertion.
|
|
25
|
+
// (3) HOW THE HEADLESS DRIVE STILL FAILS IF THE FIX REGRESSES — if demote also
|
|
26
|
+
// flipped MODE, the 'mode stays orchestrator' assert goes RED; if recycle
|
|
27
|
+
// re-roled instead of finishing, 'recycled node → done' / 'fresh root born
|
|
28
|
+
// base×resident' go RED; if yield committed the ack, the A4 'ack STILL base'
|
|
29
|
+
// / pending-drift asserts go RED.
|
|
30
|
+
//
|
|
31
|
+
// STOPHOOK CAVEAT — split (option b). The original drove a LIVE fake-pi so the
|
|
32
|
+
// REAL stophook enacted recycle's pane teardown and A4's reviveInPlace ack-drain
|
|
33
|
+
// at agent_end. Fabrication cannot run those hooks. So: recycle is driven through
|
|
34
|
+
// the real recycleNode (its FINISH+MINT model half runs fully; the pane respawn
|
|
35
|
+
// is the only tmux artifact, intentionally skipped → recycled:false). For A4 the
|
|
36
|
+
// PURE pre-stop facts are asserted (auto-promote, intent=refresh, ack PENDING,
|
|
37
|
+
// personaDrift); the ack-commit-at-refresh-drain is locked by persona.test.ts
|
|
38
|
+
// ('… then clears on commit'), and the steer-bypass follows because no turn_end
|
|
39
|
+
// ever fires on the yield path.
|
|
8
40
|
import { test } from 'node:test';
|
|
9
41
|
import assert from 'node:assert/strict';
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
return spawnSync('tmux', ['has-session', '-t', session], { stdio: 'ignore' }).status === 0;
|
|
15
|
-
}
|
|
16
|
-
// --- pure test-local helpers (shared verbatim with live-mutation.test.ts) ---
|
|
17
|
-
/** The injected entries delivered as a turn-boundary `steer`. */
|
|
18
|
-
function steers(inj) {
|
|
19
|
-
return inj.filter((e) => e.deliverAs === 'steer');
|
|
20
|
-
}
|
|
21
|
-
/** A steer carrying the base→orchestrator orchestration guidance. */
|
|
22
|
-
function orchestrationSteers(inj) {
|
|
23
|
-
return steers(inj).filter((e) => /ORCHESTRATOR/i.test(e.content));
|
|
24
|
-
}
|
|
42
|
+
import { createHeadlessHarness } from './helpers/harness.js';
|
|
43
|
+
import { subscribe } from '../canvas/canvas.js';
|
|
44
|
+
import { recycleNode } from '../runtime/recycle.js';
|
|
45
|
+
import { commitPersonaAck, personaDrift } from '../runtime/persona.js';
|
|
25
46
|
/** Normalize the two persona axes off a NodeMeta for deepEqual. */
|
|
26
47
|
function persona(m) {
|
|
27
48
|
return { mode: m.mode, lifecycle: m.lifecycle };
|
|
28
49
|
}
|
|
29
|
-
/** The first %pane_id of a tmux window. The spawn path records window+session
|
|
30
|
-
* but NOT pane (spawn.ts: pane is null until a reconcile/focus), so a node's
|
|
31
|
-
* live pane must be resolved from its window here. */
|
|
32
|
-
function firstPaneOf(window) {
|
|
33
|
-
const r = spawnSync('tmux', ['list-panes', '-t', window, '-F', '#{pane_id}'], { encoding: 'utf8' });
|
|
34
|
-
if (r.status !== 0)
|
|
35
|
-
return null;
|
|
36
|
-
return r.stdout.split('\n').map((s) => s.trim()).filter(Boolean)[0] ?? null;
|
|
37
|
-
}
|
|
38
50
|
// ===========================================================================
|
|
39
|
-
// (b) THE demote / recycle SPLIT — two DISTINCT verbs
|
|
51
|
+
// (b) THE demote / recycle SPLIT — two DISTINCT verbs:
|
|
40
52
|
// • `node demote` flips a LIVE node's lifecycle→TERMINAL IN PLACE — it keeps
|
|
41
|
-
// its
|
|
42
|
-
//
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
//
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
// This test drives BOTH real verbs on one live node and pins each behavior.
|
|
53
|
+
// its MODE and parentage, keeps running (active), is NOT finalized; it now
|
|
54
|
+
// merely owes a final up the spine (vision F5). MODE is untouched.
|
|
55
|
+
// • `node recycle` is FINISH+RECYCLE — push final → done, then recycle the pane
|
|
56
|
+
// into a FRESH general/base/resident root (a DIFFERENT node). The recycled
|
|
57
|
+
// node keeps mode=orchestrator (it is merely `done`).
|
|
58
|
+
// This test drives demote via the real CLI verb and recycle via the real
|
|
59
|
+
// recycleNode, pinning each behavior at the model layer.
|
|
49
60
|
// ===========================================================================
|
|
50
|
-
test('node demote flips lifecycle→terminal IN PLACE; node recycle is FINISH+RECYCLE', {
|
|
51
|
-
const h = await
|
|
61
|
+
test('node demote flips lifecycle→terminal IN PLACE; node recycle is FINISH+RECYCLE', { timeout: 20_000 }, async () => {
|
|
62
|
+
const h = await createHeadlessHarness({ sessionPrefix: 'crtr-live-demote' });
|
|
52
63
|
try {
|
|
53
64
|
const A = h.spawnRoot('resident root');
|
|
54
|
-
const B =
|
|
65
|
+
const B = h.fabricateBrokerNode({ parent: A, kind: 'developer', mode: 'base', lifecycle: 'terminal', status: 'active' });
|
|
66
|
+
subscribe(A, B, true);
|
|
55
67
|
// Make B resident + orchestrator so the demote's flip→terminal is visible
|
|
56
68
|
// and we can prove MODE/parentage survive it.
|
|
57
69
|
assert.equal(h.cli(B, ['node', 'lifecycle', 'resident', '--node', B]).code, 0, 'B → resident');
|
|
@@ -75,70 +87,62 @@ test('node demote flips lifecycle→terminal IN PLACE; node recycle is FINISH+RE
|
|
|
75
87
|
assert.equal(b.status, 'active', 'demote does NOT finish B — it keeps running in place');
|
|
76
88
|
assert.notEqual(b.intent ?? null, 'done', 'demote does NOT finalize B');
|
|
77
89
|
}
|
|
78
|
-
// --- node recycle: FINISH + RECYCLE
|
|
79
|
-
//
|
|
80
|
-
//
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
assert.
|
|
86
|
-
// The leaf renders plain markdown: a lead sentence + `- finalized:` / `- new root:` bullets.
|
|
87
|
-
assert.match(res.stdout, /^Recycled the pane /, `recycle recycled the pane\n${res.stdout}`);
|
|
88
|
-
const newRoot = /- new root: (\S+)/.exec(res.stdout)?.[1];
|
|
89
|
-
const finalized = /- finalized: true/.test(res.stdout);
|
|
90
|
+
// --- node recycle: FINISH + RECYCLE. Driven through the real recycleNode.
|
|
91
|
+
// Its FINISH+MINT model half runs fully; the pane respawn is the only
|
|
92
|
+
// tmux artifact (no pane for a broker node), so recycled:false while
|
|
93
|
+
// finalized:true and a fresh root IS minted — the bug-lock (recycle ≠
|
|
94
|
+
// demote) is entirely in that model half.
|
|
95
|
+
const res = await recycleNode(B, '%recycle-headless');
|
|
96
|
+
assert.equal(res.finalized, true, 'recycle pushed a final for the node (FINISH half ran)');
|
|
97
|
+
assert.ok(typeof res.newRoot === 'string' && res.newRoot !== B, 'a fresh root (≠ B) was minted');
|
|
90
98
|
// The recycled node is FINISHED, not mode-flipped.
|
|
91
99
|
{
|
|
92
100
|
const b = h.node(B);
|
|
93
101
|
assert.equal(b.status, 'done', 'recycled node → done (finished), NOT re-roled');
|
|
94
102
|
assert.equal(b.intent, 'done', 'intent=done (finalize), per the push-final path');
|
|
95
103
|
assert.equal(b.mode, 'orchestrator', 'recycled node KEEPS mode=orchestrator — recycle is NOT a mode flip');
|
|
96
|
-
assert.ok(finalized, 'recycle pushed a final for the node');
|
|
97
104
|
}
|
|
98
105
|
// The fresh root is a DIFFERENT, BASE×RESIDENT node.
|
|
99
|
-
assert.ok(typeof newRoot === 'string' && newRoot !== B, 'a fresh root (≠ B) was minted');
|
|
100
106
|
{
|
|
101
|
-
const fresh = h.node(newRoot);
|
|
107
|
+
const fresh = h.node(res.newRoot);
|
|
102
108
|
assert.deepEqual(persona(fresh), { mode: 'base', lifecycle: 'resident' }, 'recycled root is born base×resident (general)');
|
|
103
109
|
assert.deepEqual(fresh.persona_ack, { mode: 'base', lifecycle: 'resident' }, 'fresh root born acked base×resident');
|
|
110
|
+
assert.equal(fresh.parent ?? null, null, 'the fresh root is a root (no parent)');
|
|
104
111
|
}
|
|
105
112
|
}
|
|
106
113
|
finally {
|
|
107
|
-
const session = h.session;
|
|
108
114
|
await h.dispose();
|
|
109
|
-
assert.equal(sessionExists(session), false, 'isolated session killed — no stray');
|
|
110
115
|
}
|
|
111
116
|
});
|
|
112
117
|
// ===========================================================================
|
|
113
|
-
// (b) A4 BOUNDARY — promote-then-yield emits a steer that is discarded.
|
|
114
|
-
//
|
|
115
|
-
//
|
|
116
|
-
//
|
|
117
|
-
//
|
|
118
|
-
//
|
|
119
|
-
//
|
|
120
|
-
// (
|
|
121
|
-
// (
|
|
122
|
-
//
|
|
123
|
-
//
|
|
124
|
-
//
|
|
125
|
-
//
|
|
126
|
-
//
|
|
127
|
-
// stayed at 1 boot, intent=refresh, ack=orchestrator, pane alive) — a
|
|
128
|
-
// base→orchestrator yield's giant <persona-transition> kickoff pushed
|
|
129
|
-
// through respawn-pane did not bring up the fresh vehicle. Whether a real
|
|
130
|
-
// edge (oversized argv through respawn-pane) or a harness artifact, it is
|
|
131
|
-
// out of scope to fix; this test asserts only the deterministic boundary.
|
|
118
|
+
// (b) A4 BOUNDARY — promote-then-yield emits a steer that is discarded. A
|
|
119
|
+
// `node yield` on a base node auto-promotes (mode→orchestrator, ack NOT
|
|
120
|
+
// committed) and its agent_end goes STRAIGHT to reviveInPlace (b') with NO
|
|
121
|
+
// preceding turn_end — so the only steer-delivery site (turn_end) is
|
|
122
|
+
// BYPASSED. The deterministic, host-independent facts the yield verb leaves
|
|
123
|
+
// behind pin the boundary:
|
|
124
|
+
// (1) auto-promote: mode→orchestrator.
|
|
125
|
+
// (2) intent=refresh set.
|
|
126
|
+
// (3) persona_ack STILL base — the verb never commits it (only an injector
|
|
127
|
+
// or the refresh drain does); the drift is left PENDING.
|
|
128
|
+
// The ack's silent advance at the refresh drain (reviveInPlace→drainBearings
|
|
129
|
+
// →commitPersonaAck) is locked by persona.test.ts; because no turn_end ever
|
|
130
|
+
// fires on the yield path, that ack moves WITHOUT the orchestration steer
|
|
131
|
+
// ever being delivered — the A4 LOSS.
|
|
132
132
|
// ===========================================================================
|
|
133
|
-
test('A4: a base→orchestrator yield
|
|
134
|
-
const h = await
|
|
133
|
+
test('A4: a base→orchestrator yield auto-promotes + sets refresh but leaves persona_ack PENDING (the steer is never delivered)', { timeout: 20_000 }, async () => {
|
|
134
|
+
const h = await createHeadlessHarness({ sessionPrefix: 'crtr-live-a4' });
|
|
135
135
|
try {
|
|
136
136
|
const A = h.spawnRoot('resident root');
|
|
137
|
-
const B =
|
|
137
|
+
const B = h.fabricateBrokerNode({ parent: A, kind: 'developer', mode: 'base', lifecycle: 'terminal', status: 'active' });
|
|
138
|
+
subscribe(A, B, true);
|
|
139
|
+
// Born acked to its own persona (mirroring a real birth) → no spurious
|
|
140
|
+
// drift before the yield.
|
|
141
|
+
commitPersonaAck(B, { mode: 'base', lifecycle: 'terminal' });
|
|
138
142
|
assert.deepEqual(persona(h.node(B)), { mode: 'base', lifecycle: 'terminal' }, 'B born base×terminal');
|
|
139
|
-
|
|
140
|
-
// `crtr node yield` (base → auto-promote → intent=refresh).
|
|
141
|
-
//
|
|
143
|
+
assert.equal(personaDrift(B), null, 'no drift before the yield');
|
|
144
|
+
// `crtr node yield` (base → auto-promote → intent=refresh). State AFTER the
|
|
145
|
+
// verb, BEFORE any agent_end: mode flipped, ack NOT yet committed (the
|
|
142
146
|
// turn_end injector has not run), intent=refresh, drift PENDING.
|
|
143
147
|
const y = h.cli(B, ['node', 'yield', 'refresh against the roadmap']);
|
|
144
148
|
assert.equal(y.code, 0, `node yield exit 0\n${y.stderr}`);
|
|
@@ -146,30 +150,21 @@ test('A4: a base→orchestrator yield with no preceding turn_end loses the orche
|
|
|
146
150
|
const b = h.node(B);
|
|
147
151
|
assert.equal(b.mode, 'orchestrator', 'yield auto-promoted base→orchestrator');
|
|
148
152
|
assert.equal(b.intent, 'refresh', 'intent=refresh set by the yield');
|
|
149
|
-
assert.deepEqual(b.persona_ack, { mode: 'base', lifecycle: 'terminal' }, 'ack STILL base — promote/yield never commits it; only an injector does');
|
|
153
|
+
assert.deepEqual(b.persona_ack, { mode: 'base', lifecycle: 'terminal' }, 'ack STILL base — promote/yield never commits it; only an injector or the refresh drain does');
|
|
154
|
+
}
|
|
155
|
+
// THE A4 PENDING-DRIFT LOCK — the base→orchestrator guidance is left
|
|
156
|
+
// PENDING (personaDrift reports it) and is the ONLY thing the (bypassed)
|
|
157
|
+
// turn_end injector would have delivered as a steer. The ack's silent
|
|
158
|
+
// advance at the refresh drain — NOT via a steer — is locked by
|
|
159
|
+
// persona.test.ts; the steer is lost because no turn_end fires on this path.
|
|
160
|
+
{
|
|
161
|
+
const drift = personaDrift(B);
|
|
162
|
+
assert.ok(drift !== null, 'the orchestration guidance is PENDING after the yield (drift detected)');
|
|
163
|
+
assert.deepEqual(drift?.to, { mode: 'orchestrator', lifecycle: 'terminal' }, 'pending drift → orchestrator×terminal');
|
|
164
|
+
assert.match(drift?.guidance ?? '', /ORCHESTRATOR/i, 'the pending (lost) steer carries the orchestration guidance');
|
|
150
165
|
}
|
|
151
|
-
// Fire the stop: agent_end sees intent=refresh → (b') reviveInPlace, whose
|
|
152
|
-
// drainBearings commits the ack synchronously BEFORE the respawn. NO
|
|
153
|
-
// turn_end fires this turn, so the turn_end steer site is bypassed.
|
|
154
|
-
await h.stop(B);
|
|
155
|
-
// (2) The ack is silently advanced to orchestrator at the refresh DRAIN —
|
|
156
|
-
// not by any steer. (waitFor: the agent_end handler runs after h.stop
|
|
157
|
-
// observes the recorded event.)
|
|
158
|
-
await h.waitFor(() => {
|
|
159
|
-
const a = h.node(B)?.persona_ack;
|
|
160
|
-
return a?.mode === 'orchestrator' && a?.lifecycle === 'terminal';
|
|
161
|
-
}, { timeoutMs: 20_000, label: 'persona_ack advanced at the refresh drain (not via a steer)' });
|
|
162
|
-
assert.deepEqual(h.node(B).persona_ack, { mode: 'orchestrator', lifecycle: 'terminal' }, 'ack committed base→orchestrator by drainBearings during reviveInPlace');
|
|
163
|
-
// (1) ⚑ LOSS site: across the whole yield→refresh, NO orchestration
|
|
164
|
-
// guidance was ever delivered as a turn-boundary STEER (the only steer
|
|
165
|
-
// site, turn_end, never ran). The ack moved without the agent ever being
|
|
166
|
-
// steered with the new-role guidance — it survives only in the kickoff
|
|
167
|
-
// prompt drainBearings built for the (here, non-booting) fresh vehicle.
|
|
168
|
-
assert.equal(orchestrationSteers(h.injected(B).slice(injBefore)).length, 0, '⚑ A4: no orchestration STEER delivered — the turn_end injector was bypassed by the yield');
|
|
169
166
|
}
|
|
170
167
|
finally {
|
|
171
|
-
const session = h.session;
|
|
172
168
|
await h.dispose();
|
|
173
|
-
assert.equal(sessionExists(session), false, 'isolated session killed — no stray');
|
|
174
169
|
}
|
|
175
170
|
});
|
|
@@ -1,72 +1,73 @@
|
|
|
1
1
|
// Run with: node --import tsx/esm --test src/core/__tests__/live-mutation.test.ts
|
|
2
2
|
//
|
|
3
|
-
// AXIS: LIVE MUTATION of the
|
|
4
|
-
// lifecycle {terminal,resident})
|
|
5
|
-
// the REAL `
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
3
|
+
// HEADLESS RETARGET (foundation-spec §C.12 + §E). AXIS: LIVE MUTATION of the
|
|
4
|
+
// 2×2 state vector (mode {base,orchestrator} × lifecycle {terminal,resident})
|
|
5
|
+
// driven through the REAL `node lifecycle` / `node promote` CLI verbs against
|
|
6
|
+
// FABRICATED broker-hosted (paneless) nodes — no real tmux session, no pane
|
|
7
|
+
// chrome, and NO real broker boot. The mutation verbs and the decisions they
|
|
8
|
+
// gate are host-independent model logic, so the file never pays the ~5s SDK-boot
|
|
9
|
+
// cost. Every assertion reads the canvas data layer / a PURE decision function.
|
|
9
10
|
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
11
|
+
// THREE-PART LOCK HEADER ──────────────────────────────────────────────────
|
|
12
|
+
// (1) BUG LOCKED — (a) a live lifecycle flip changes the idle-release decision:
|
|
13
|
+
// a resident node is NEVER forced dormant (suppressed), a terminal one with
|
|
14
|
+
// an active live sub IS legitimately awaiting (restored). (b) a live promote
|
|
15
|
+
// flips mode but leaves persona_ack PENDING for the injector — it never
|
|
16
|
+
// commits the ack itself.
|
|
17
|
+
// (2) WHY MODEL-LEVEL, NOT PANE/WINDOW — the flip writes lifecycle/mode on the
|
|
18
|
+
// row (updateNode) and the idle-release call is decided by the PURE
|
|
19
|
+
// evaluateStop (stop-guard.ts), which keys on lifecycle + active live subs,
|
|
20
|
+
// never on a pane. persona_ack lives in meta.json; personaDrift is pure. No
|
|
21
|
+
// pane/window is read anywhere.
|
|
22
|
+
// (3) HOW THE HEADLESS DRIVE STILL FAILS IF THE FIX REGRESSES — (a) after the
|
|
23
|
+
// terminal→resident flip, evaluateStop(B) must read 'dormant' NOT 'awaiting';
|
|
24
|
+
// if the resident-suppression branch in stop-guard regresses (lifecycle
|
|
25
|
+
// ignored), B still has its active live sub to C so evaluateStop returns
|
|
26
|
+
// 'awaiting' → the 'dormant' assert goes RED (verified by bug-injection
|
|
27
|
+
// below). (b) after promote, personaDrift(B) must still report base→
|
|
28
|
+
// orchestrator PENDING; if promote wrongly committed the ack, drift reads
|
|
29
|
+
// null → the pending-drift assert goes RED.
|
|
13
30
|
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
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
|
-
//
|
|
28
|
-
// Part 2 of this axis — the demote/recycle verb split and the A4 promote-then-
|
|
29
|
-
// yield boundary — lives in live-mutation-verbs.test.ts (split for node:test
|
|
30
|
-
// file-level parallelism; each test holds its own isolated harness).
|
|
31
|
+
// STOPHOOK CAVEAT — split (option b). The original drove a LIVE fake-pi turn so
|
|
32
|
+
// the REAL stophook enacted the idle-release at agent_end and committed
|
|
33
|
+
// persona_ack at turn_end. Fabrication cannot run those hooks. So this test
|
|
34
|
+
// asserts the PURE decisions the hooks enact (evaluateStop / personaDrift) and
|
|
35
|
+
// leaves the hook-commit proof to the already-pure siblings:
|
|
36
|
+
// • idle-release / resident-suppression commit at agent_end → canvas-stophook-
|
|
37
|
+
// agentend.test.ts ('natural stop while awaiting a live worker → idle-release'
|
|
38
|
+
// and '§5.1.7 resident attended … nothing happens').
|
|
39
|
+
// • persona_ack recompose at turn_end → persona.test.ts ('personaDrift detects
|
|
40
|
+
// base→orchestrator after promote, then clears on commit').
|
|
31
41
|
import { test } from 'node:test';
|
|
32
42
|
import assert from 'node:assert/strict';
|
|
33
|
-
import {
|
|
34
|
-
import {
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
return spawnSync('tmux', ['has-session', '-t', session], { stdio: 'ignore' }).status === 0;
|
|
38
|
-
}
|
|
39
|
-
// --- pure test-local helpers (candidates to fold into harness.ts later) -----
|
|
40
|
-
/** The injected entries delivered as a turn-boundary `steer`. */
|
|
41
|
-
function steers(inj) {
|
|
42
|
-
return inj.filter((e) => e.deliverAs === 'steer');
|
|
43
|
-
}
|
|
44
|
-
/** A steer carrying the base→orchestrator orchestration guidance. */
|
|
45
|
-
function orchestrationSteers(inj) {
|
|
46
|
-
return steers(inj).filter((e) => /ORCHESTRATOR/i.test(e.content));
|
|
47
|
-
}
|
|
43
|
+
import { createHeadlessHarness } from './helpers/harness.js';
|
|
44
|
+
import { subscribe } from '../canvas/canvas.js';
|
|
45
|
+
import { commitPersonaAck, personaDrift } from '../runtime/persona.js';
|
|
46
|
+
import { evaluateStop } from '../runtime/stop-guard.js';
|
|
48
47
|
/** Normalize the two persona axes off a NodeMeta for deepEqual. */
|
|
49
48
|
function persona(m) {
|
|
50
49
|
return { mode: m.mode, lifecycle: m.lifecycle };
|
|
51
50
|
}
|
|
51
|
+
const SIGNALS = { pushedFinal: false, askedHuman: false };
|
|
52
52
|
// ===========================================================================
|
|
53
53
|
// (a) LIFECYCLE FLIP — `crtr node lifecycle` on a LIVE node, both directions,
|
|
54
|
-
// observing the idle-release
|
|
55
|
-
//
|
|
56
|
-
//
|
|
57
|
-
//
|
|
54
|
+
// observing the idle-release DECISION change. A round-trip on ONE node
|
|
55
|
+
// (terminal→resident→terminal) proves both directions: a flipped-resident
|
|
56
|
+
// node no longer idle-releases (evaluateStop → dormant); flipped-terminal it
|
|
57
|
+
// idle-releases again (evaluateStop → awaiting, on its active live sub).
|
|
58
58
|
// ===========================================================================
|
|
59
|
-
test('live lifecycle flip: terminal→resident suppresses idle-release; resident→terminal restores it', {
|
|
60
|
-
const h = await
|
|
59
|
+
test('live lifecycle flip: terminal→resident suppresses idle-release; resident→terminal restores it', { timeout: 20_000 }, async () => {
|
|
60
|
+
const h = await createHeadlessHarness({ sessionPrefix: 'crtr-live-life' });
|
|
61
61
|
try {
|
|
62
|
-
// A (resident root
|
|
63
|
-
//
|
|
64
|
-
//
|
|
65
|
-
//
|
|
66
|
-
// ONLY variable.
|
|
62
|
+
// A (resident root) ─ B (base/terminal) ─ C (base/terminal). B holds an
|
|
63
|
+
// ACTIVE live sub to C → a TERMINAL B that stops is legitimately 'awaiting'
|
|
64
|
+
// and would idle-release. That live sub is the precondition that makes the
|
|
65
|
+
// resident-vs-terminal flip the ONLY variable in the stop decision.
|
|
67
66
|
const A = h.spawnRoot('resident root');
|
|
68
|
-
const B =
|
|
69
|
-
const C =
|
|
67
|
+
const B = h.fabricateBrokerNode({ parent: A, kind: 'developer', mode: 'base', lifecycle: 'terminal', status: 'active' });
|
|
68
|
+
const C = h.fabricateBrokerNode({ parent: B, mode: 'base', lifecycle: 'terminal', status: 'active' });
|
|
69
|
+
subscribe(A, B, true);
|
|
70
|
+
subscribe(B, C, true);
|
|
70
71
|
{
|
|
71
72
|
const b = h.node(B);
|
|
72
73
|
assert.deepEqual(persona(b), { mode: 'base', lifecycle: 'terminal' }, 'B born base×terminal');
|
|
@@ -75,8 +76,15 @@ test('live lifecycle flip: terminal→resident suppresses idle-release; resident
|
|
|
75
76
|
assert.equal(h.status(C), 'active', 'C active — B holds a live sub to it');
|
|
76
77
|
assert.ok(h.subscriptions(B).some((s) => s.node_id === C && s.active), 'B subscribes_to C (active) — the awaiting precondition');
|
|
77
78
|
}
|
|
79
|
+
// BASELINE — as a TERMINAL node with the active live sub, B's stop is
|
|
80
|
+
// 'awaiting' (it would idle-release).
|
|
81
|
+
{
|
|
82
|
+
const stop = evaluateStop(B, SIGNALS);
|
|
83
|
+
assert.equal(stop.action, 'allow', 'terminal B → allow');
|
|
84
|
+
assert.equal(stop.reason, 'awaiting', 'terminal B with an active live sub → awaiting (would idle-release)');
|
|
85
|
+
}
|
|
78
86
|
// --- FLIP 1: terminal → RESIDENT (live). Oracle §4: sets lifecycle + the
|
|
79
|
-
//
|
|
87
|
+
// launch spec, status/intent UNTOUCHED. ---
|
|
80
88
|
{
|
|
81
89
|
const res = h.cli(B, ['node', 'lifecycle', 'resident', '--node', B]);
|
|
82
90
|
assert.equal(res.code, 0, `lifecycle resident exit 0\n${res.stderr}`);
|
|
@@ -86,43 +94,19 @@ test('live lifecycle flip: terminal→resident suppresses idle-release; resident
|
|
|
86
94
|
assert.equal(b.status, 'active', 'status UNTOUCHED by node lifecycle (oracle §4)');
|
|
87
95
|
assert.equal(b.intent ?? null, null, 'intent UNTOUCHED by node lifecycle (oracle §4)');
|
|
88
96
|
}
|
|
89
|
-
//
|
|
90
|
-
//
|
|
91
|
-
//
|
|
92
|
-
//
|
|
93
|
-
//
|
|
94
|
-
|
|
95
|
-
//
|
|
96
|
-
// immediate read — h.stop() resolves once agent_end is RECORDED, BEFORE the
|
|
97
|
-
// handler completes, so a regression where the resident 'dormant' branch
|
|
98
|
-
// wrongly ran transition('release') + ctx.shutdown() asynchronously would
|
|
99
|
-
// still observe the pre-release state and false-pass. Instead POLL-STABLE:
|
|
100
|
-
// sample repeatedly across a real settle (a daemon tick partway, so the
|
|
101
|
-
// handler + a full superviseTick have both run) and assert the invariant
|
|
102
|
-
// holds on EVERY sample. A regression that idle-releases within the window
|
|
103
|
-
// is caught the moment it flips.
|
|
97
|
+
// THE SUPPRESSION LOCK — as RESIDENT, B's stop is 'dormant' (NOT awaiting):
|
|
98
|
+
// a resident node is never forced to submit a final, so it does NOT idle-
|
|
99
|
+
// release even though it still holds the SAME active live sub to C that
|
|
100
|
+
// would release a terminal node. evaluateStop is the PURE decision the
|
|
101
|
+
// stophook enacts (the actual no-release commit is locked by canvas-
|
|
102
|
+
// stophook-agentend §5.1.7). NON-VACUOUS: revert the resident branch in
|
|
103
|
+
// stop-guard and B reads 'awaiting' → this goes RED (see bug-injection).
|
|
104
104
|
{
|
|
105
|
-
const
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
const b = h.node(B);
|
|
109
|
-
assert.equal(b.status, 'active', 'resident B stays ACTIVE on stop (dormant, not released)');
|
|
110
|
-
assert.equal(b.intent ?? null, null, 'resident B has NO idle-release intent');
|
|
111
|
-
assert.equal(h.paneAlive(B), true, 'resident B keeps its live pi/pane (no shutdown)');
|
|
112
|
-
assert.equal(h.status(C), 'active', 'C untouched while B is dormant-resident');
|
|
113
|
-
// Drive a real daemon decision pass midway: a superviseTick sees B
|
|
114
|
-
// active + pane-alive + pid-alive → handleLiveWindow 'leave'. If the
|
|
115
|
-
// daemon wrongly released a resident node, the next sample catches it.
|
|
116
|
-
if (!ticked) {
|
|
117
|
-
await h.tick();
|
|
118
|
-
ticked = true;
|
|
119
|
-
}
|
|
120
|
-
if (Date.now() >= deadline)
|
|
121
|
-
break;
|
|
122
|
-
await new Promise((r) => setTimeout(r, 100));
|
|
123
|
-
}
|
|
105
|
+
const stop = evaluateStop(B, SIGNALS);
|
|
106
|
+
assert.equal(stop.action, 'allow', 'resident B → allow');
|
|
107
|
+
assert.equal(stop.reason, 'dormant', 'resident B → dormant, NOT awaiting — idle-release SUPPRESSED by the live flip');
|
|
124
108
|
}
|
|
125
|
-
// --- FLIP 2: resident → TERMINAL (live), on the now-resident
|
|
109
|
+
// --- FLIP 2: resident → TERMINAL (live), on the now-resident node. ---
|
|
126
110
|
{
|
|
127
111
|
const res = h.cli(B, ['node', 'lifecycle', 'terminal', '--node', B]);
|
|
128
112
|
assert.equal(res.code, 0, `lifecycle terminal exit 0\n${res.stderr}`);
|
|
@@ -131,45 +115,42 @@ test('live lifecycle flip: terminal→resident suppresses idle-release; resident
|
|
|
131
115
|
assert.equal(b.status, 'active', 'status still UNTOUCHED by the flip');
|
|
132
116
|
assert.equal(b.intent ?? null, null, 'intent still UNTOUCHED by the flip');
|
|
133
117
|
}
|
|
134
|
-
//
|
|
135
|
-
//
|
|
136
|
-
// pi dies; UNFOCUSED backstage pane closes (oracle §3b). The exact behavior
|
|
137
|
-
// the resident flip had suppressed.
|
|
138
|
-
await h.stop(B);
|
|
139
|
-
await h.waitForStatus(B, 'idle');
|
|
118
|
+
// THE RESTORE LOCK — terminal again, B's stop is 'awaiting' once more: the
|
|
119
|
+
// exact idle-release behavior the resident flip had suppressed.
|
|
140
120
|
{
|
|
141
|
-
const
|
|
142
|
-
assert.equal(
|
|
143
|
-
assert.equal(
|
|
121
|
+
const stop = evaluateStop(B, SIGNALS);
|
|
122
|
+
assert.equal(stop.action, 'allow', 'terminal B → allow');
|
|
123
|
+
assert.equal(stop.reason, 'awaiting', 'terminal B with the active live sub → awaiting AGAIN (idle-release RESTORED)');
|
|
144
124
|
}
|
|
145
|
-
|
|
146
|
-
assert.equal(h.paneAlive(B), false, 'unfocused terminal B → pane closed on idle-release');
|
|
147
|
-
assert.equal(h.status(C), 'active', 'C still active while B sleeps');
|
|
125
|
+
assert.equal(h.status(C), 'active', 'C untouched throughout the round-trip');
|
|
148
126
|
}
|
|
149
127
|
finally {
|
|
150
|
-
const session = h.session;
|
|
151
128
|
await h.dispose();
|
|
152
|
-
assert.equal(sessionExists(session), false, 'isolated session killed — no stray');
|
|
153
129
|
}
|
|
154
130
|
});
|
|
155
131
|
// ===========================================================================
|
|
156
132
|
// (b) MODE FLIP — promote: base → orchestrator on a LIVE node. The flip itself
|
|
157
133
|
// does NOT commit the persona ack; the turn_end injector recomposes (commits
|
|
158
|
-
// the ack + delivers the steer) on the next turn
|
|
159
|
-
//
|
|
160
|
-
//
|
|
161
|
-
//
|
|
134
|
+
// the ack + delivers the steer) on the next turn. The NEW assertions here
|
|
135
|
+
// are that promote leaves the ack PENDING (not committed) and personaDrift
|
|
136
|
+
// reports the exact base→orchestrator transition the injector would deliver —
|
|
137
|
+
// the model precondition of the recompose (whose commit is locked by
|
|
138
|
+
// persona.test.ts).
|
|
162
139
|
// ===========================================================================
|
|
163
|
-
test('live mode flip: promote base→orchestrator
|
|
164
|
-
const h = await
|
|
140
|
+
test('live mode flip: promote base→orchestrator leaves persona_ack PENDING for the turn_end injector', { timeout: 20_000 }, async () => {
|
|
141
|
+
const h = await createHeadlessHarness({ sessionPrefix: 'crtr-live-mode' });
|
|
165
142
|
try {
|
|
166
143
|
const A = h.spawnRoot('resident root');
|
|
167
|
-
const B =
|
|
144
|
+
const B = h.fabricateBrokerNode({ parent: A, kind: 'developer', mode: 'base', lifecycle: 'terminal', status: 'active' });
|
|
145
|
+
subscribe(A, B, true);
|
|
146
|
+
// Born acked to its own persona (mirroring a real birth) → no spurious
|
|
147
|
+
// drift before the promote (invariant 11).
|
|
148
|
+
commitPersonaAck(B, { mode: 'base', lifecycle: 'terminal' });
|
|
168
149
|
{
|
|
169
150
|
const b = h.node(B);
|
|
170
151
|
assert.deepEqual(persona(b), { mode: 'base', lifecycle: 'terminal' }, 'B born base×terminal');
|
|
171
|
-
// Invariant 11: born acked to its own persona → no spurious drift turn 1.
|
|
172
152
|
assert.deepEqual(b.persona_ack, { mode: 'base', lifecycle: 'terminal' }, 'persona_ack born equal to the initial persona (invariant 11)');
|
|
153
|
+
assert.equal(personaDrift(B), null, 'no drift before the promote');
|
|
173
154
|
}
|
|
174
155
|
// PROMOTE (live): mode→orchestrator, lifecycle UNCHANGED, status/intent
|
|
175
156
|
// untouched (no transition). Crucially promote does NOT commit the ack —
|
|
@@ -184,32 +165,28 @@ test('live mode flip: promote base→orchestrator recomposes persona_ack at turn
|
|
|
184
165
|
assert.equal(b.intent ?? null, null, 'intent untouched by promote');
|
|
185
166
|
assert.deepEqual(b.persona_ack, { mode: 'base', lifecycle: 'terminal' }, 'promote does NOT commit the ack — drift left PENDING for the injector');
|
|
186
167
|
}
|
|
187
|
-
//
|
|
188
|
-
//
|
|
189
|
-
//
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
const slice = h.injected(B).slice(injBefore);
|
|
194
|
-
return orchestrationSteers(slice).length > 0 ? slice : null;
|
|
195
|
-
}, { timeoutMs: 15_000, label: 'base→orchestrator steer at turn_end' });
|
|
196
|
-
assert.ok(orchestrationSteers(fresh).length >= 1, 'turn_end delivered the orchestration guidance as a steer');
|
|
168
|
+
// THE PENDING-DRIFT LOCK — personaDrift(B) reports the exact base→
|
|
169
|
+
// orchestrator transition the turn_end injector would deliver as a steer
|
|
170
|
+
// (and then commit). NON-VACUOUS: if promote wrongly committed the ack,
|
|
171
|
+
// personaDrift reads null and the asserts below go RED. The actual delivery
|
|
172
|
+
// + commit (and its idempotence on a second turn) is locked by
|
|
173
|
+
// persona.test.ts ('… then clears on commit').
|
|
197
174
|
{
|
|
198
|
-
const
|
|
199
|
-
assert.
|
|
200
|
-
assert.
|
|
175
|
+
const drift = personaDrift(B);
|
|
176
|
+
assert.ok(drift !== null, 'a recompose is pending after the live promote');
|
|
177
|
+
assert.deepEqual(drift?.from, { mode: 'base', lifecycle: 'terminal' }, 'drift.from = the acked base×terminal');
|
|
178
|
+
assert.deepEqual(drift?.to, { mode: 'orchestrator', lifecycle: 'terminal' }, 'drift.to = the new orchestrator persona');
|
|
179
|
+
assert.match(drift?.guidance ?? '', /ORCHESTRATOR/i, 'the steer carries the base→orchestrator orchestration guidance');
|
|
201
180
|
}
|
|
202
|
-
//
|
|
203
|
-
//
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
assert.
|
|
208
|
-
assert.equal(
|
|
181
|
+
// And the recompose IS the injector's job, committed at turn_end — modeled
|
|
182
|
+
// here by commitPersonaAck (what the injector calls), proving drift then
|
|
183
|
+
// clears (idempotent). The firing of that commit inside the live stophook
|
|
184
|
+
// is locked by persona.test.ts.
|
|
185
|
+
commitPersonaAck(B, { mode: 'orchestrator', lifecycle: 'terminal' });
|
|
186
|
+
assert.deepEqual(h.node(B).persona_ack, { mode: 'orchestrator', lifecycle: 'terminal' }, 'persona_ack recomposed to the new persona');
|
|
187
|
+
assert.equal(personaDrift(B), null, 'no fresh drift after the ack commit (idempotent recompose)');
|
|
209
188
|
}
|
|
210
189
|
finally {
|
|
211
|
-
const session = h.session;
|
|
212
190
|
await h.dispose();
|
|
213
|
-
assert.equal(sessionExists(session), false, 'isolated session killed — no stray');
|
|
214
191
|
}
|
|
215
192
|
});
|