@crouton-kit/crouter 0.3.19 → 0.3.20
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/builtin-personas/design/PERSONA.md +1 -0
- package/dist/builtin-personas/design/orchestrator.md +1 -0
- package/dist/builtin-personas/developer/PERSONA.md +1 -0
- package/dist/builtin-personas/developer/orchestrator.md +1 -0
- package/dist/builtin-personas/explore/PERSONA.md +1 -0
- package/dist/builtin-personas/explore/orchestrator.md +4 -0
- package/dist/builtin-personas/general/PERSONA.md +1 -0
- package/dist/builtin-personas/general/orchestrator.md +4 -0
- package/dist/builtin-personas/plan/PERSONA.md +1 -0
- package/dist/builtin-personas/plan/orchestrator.md +1 -0
- package/dist/builtin-personas/plan/reviewers/architecture-fit/PERSONA.md +1 -0
- package/dist/builtin-personas/plan/reviewers/code-smells/PERSONA.md +1 -0
- package/dist/builtin-personas/plan/reviewers/pattern-consistency/PERSONA.md +1 -0
- package/dist/builtin-personas/plan/reviewers/requirements-coverage/PERSONA.md +1 -0
- package/dist/builtin-personas/plan/reviewers/security/PERSONA.md +1 -0
- package/dist/builtin-personas/review/PERSONA.md +1 -0
- package/dist/builtin-personas/review/orchestrator.md +4 -0
- package/dist/builtin-personas/spec/PERSONA.md +1 -0
- package/dist/builtin-personas/spec/orchestrator.md +1 -0
- package/dist/commands/human/queue.js +11 -0
- package/dist/commands/node.js +4 -1
- package/dist/core/__tests__/broker-attach-limits.test.d.ts +1 -0
- package/dist/core/__tests__/broker-attach-limits.test.js +157 -0
- package/dist/core/__tests__/broker-attach-stream.test.d.ts +1 -0
- package/dist/core/__tests__/broker-attach-stream.test.js +125 -0
- package/dist/core/__tests__/broker-crash-teardown.test.d.ts +1 -0
- package/dist/core/__tests__/broker-crash-teardown.test.js +116 -0
- package/dist/core/__tests__/broker-dialogs.test.d.ts +1 -0
- package/dist/core/__tests__/broker-dialogs.test.js +126 -0
- package/dist/core/__tests__/broker-dormant-wake.test.d.ts +1 -0
- package/dist/core/__tests__/broker-dormant-wake.test.js +51 -0
- package/dist/core/__tests__/broker-lifecycle.test.js +14 -604
- package/dist/core/__tests__/canvas-inbox-watcher-hold.test.d.ts +1 -0
- package/dist/core/__tests__/canvas-inbox-watcher-hold.test.js +102 -0
- package/dist/core/__tests__/canvas-inbox-watcher.test.js +2 -36
- package/dist/core/__tests__/helpers/broker-clients.d.ts +43 -0
- package/dist/core/__tests__/helpers/broker-clients.js +178 -0
- package/dist/core/__tests__/live-mutation-verbs.test.d.ts +1 -0
- package/dist/core/__tests__/live-mutation-verbs.test.js +175 -0
- package/dist/core/__tests__/live-mutation.test.js +4 -147
- package/dist/core/canvas/canvas.js +1 -1
- package/dist/core/canvas/types.d.ts +6 -0
- package/dist/core/runtime/launch.d.ts +9 -2
- package/dist/core/runtime/launch.js +20 -3
- package/dist/core/runtime/nodes.d.ts +4 -0
- package/dist/core/runtime/nodes.js +1 -0
- package/dist/core/runtime/promote.js +3 -0
- package/dist/core/runtime/reset.js +3 -2
- package/dist/core/runtime/spawn.d.ts +4 -0
- package/dist/core/runtime/spawn.js +2 -1
- package/package.json +1 -1
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// Tests for the canvas-node inbox watcher pi extension, part 2 — the
|
|
2
|
+
// refresh-yield HOLD path and idle delivery. Split out of
|
|
3
|
+
// canvas-inbox-watcher.test.ts (see its header) so node:test's file-level
|
|
4
|
+
// parallelism applies; the tests and the scaffold are moved here UNCHANGED.
|
|
5
|
+
//
|
|
6
|
+
// Run with: node --import tsx/esm --test src/core/__tests__/canvas-inbox-watcher-hold.test.ts
|
|
7
|
+
import { test, describe, before, after, afterEach } from 'node:test';
|
|
8
|
+
import assert from 'node:assert/strict';
|
|
9
|
+
import { mkdirSync, rmSync } from 'node:fs';
|
|
10
|
+
import { tmpdir } from 'node:os';
|
|
11
|
+
import { join } from 'node:path';
|
|
12
|
+
import registerCanvasInboxWatcher from '../../pi-extensions/canvas-inbox-watcher.js';
|
|
13
|
+
import { appendInbox } from '../feed/inbox.js';
|
|
14
|
+
import { createNode, setIntent } from '../canvas/canvas.js';
|
|
15
|
+
import { closeDb } from '../canvas/db.js';
|
|
16
|
+
// Mirror the watcher's internal cadence (TICK_MS=800, DEBOUNCE_MS=1000): allow a
|
|
17
|
+
// resolve+seed tick, a read tick, and the debounce window before asserting.
|
|
18
|
+
const TICK_MS = 800;
|
|
19
|
+
const DEBOUNCE_MS = 1000;
|
|
20
|
+
const SETTLE_MS = TICK_MS * 2 + DEBOUNCE_MS + 500;
|
|
21
|
+
let origHome;
|
|
22
|
+
let origNode;
|
|
23
|
+
const homes = [];
|
|
24
|
+
const disposers = [];
|
|
25
|
+
/** Point CRTR_HOME at a fresh temp canvas root and bind CRTR_NODE_ID. */
|
|
26
|
+
function freshNode(nodeId) {
|
|
27
|
+
const home = join(tmpdir(), `crtr-canvas-watcher-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
|
28
|
+
mkdirSync(home, { recursive: true });
|
|
29
|
+
homes.push(home);
|
|
30
|
+
process.env['CRTR_HOME'] = home;
|
|
31
|
+
process.env['CRTR_NODE_ID'] = nodeId;
|
|
32
|
+
}
|
|
33
|
+
function makeFakePi() {
|
|
34
|
+
const handlers = {};
|
|
35
|
+
return {
|
|
36
|
+
injected: [],
|
|
37
|
+
on(e, h) { handlers[e] = h; },
|
|
38
|
+
sendUserMessage(content, options) { this.injected.push({ content, deliverAs: options?.deliverAs }); },
|
|
39
|
+
fire(e, ev, ctx) { handlers[e]?.(ev, ctx); },
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
43
|
+
before(() => {
|
|
44
|
+
origHome = process.env['CRTR_HOME'];
|
|
45
|
+
origNode = process.env['CRTR_NODE_ID'];
|
|
46
|
+
});
|
|
47
|
+
afterEach(() => {
|
|
48
|
+
while (disposers.length > 0)
|
|
49
|
+
disposers.pop()();
|
|
50
|
+
});
|
|
51
|
+
after(() => {
|
|
52
|
+
if (origHome === undefined)
|
|
53
|
+
delete process.env['CRTR_HOME'];
|
|
54
|
+
else
|
|
55
|
+
process.env['CRTR_HOME'] = origHome;
|
|
56
|
+
if (origNode === undefined)
|
|
57
|
+
delete process.env['CRTR_NODE_ID'];
|
|
58
|
+
else
|
|
59
|
+
process.env['CRTR_NODE_ID'] = origNode;
|
|
60
|
+
for (const h of homes) {
|
|
61
|
+
try {
|
|
62
|
+
rmSync(h, { recursive: true, force: true });
|
|
63
|
+
}
|
|
64
|
+
catch { /* noop */ }
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
describe('canvas inbox watcher — hold + idle delivery', () => {
|
|
68
|
+
test('refresh-yield in flight: inbox entries are HELD, then delivered once intent clears (no loss)', async () => {
|
|
69
|
+
freshNode('node-refresh');
|
|
70
|
+
closeDb(); // rebind the canvas db to this test's fresh home
|
|
71
|
+
const meta = {
|
|
72
|
+
node_id: 'node-refresh', name: 'r', created: new Date().toISOString(),
|
|
73
|
+
cwd: '/tmp', kind: 'general', mode: 'base', lifecycle: 'resident',
|
|
74
|
+
status: 'active', intent: 'refresh',
|
|
75
|
+
};
|
|
76
|
+
createNode(meta);
|
|
77
|
+
const pi = makeFakePi();
|
|
78
|
+
disposers.push(registerCanvasInboxWatcher(pi));
|
|
79
|
+
// Streaming (mid-turn) when a child finishes — normally this would steer.
|
|
80
|
+
pi.fire('agent_start', { type: 'agent_start' }, { isIdle: () => false });
|
|
81
|
+
await wait(TICK_MS + 100);
|
|
82
|
+
appendInbox('node-refresh', { from: 'child-x', tier: 'urgent', kind: 'final', label: 'done' });
|
|
83
|
+
await wait(SETTLE_MS);
|
|
84
|
+
assert.equal(pi.injected.length, 0, 'entries are held while a refresh-yield is in flight (no steer-hijack)');
|
|
85
|
+
// The fresh pi clears intent on boot; the held entry must then be delivered
|
|
86
|
+
// — the cursor was never advanced, so nothing is lost.
|
|
87
|
+
setIntent('node-refresh', null);
|
|
88
|
+
await wait(SETTLE_MS);
|
|
89
|
+
assert.equal(pi.injected.length, 1, 'the held entry is delivered once the refresh clears');
|
|
90
|
+
});
|
|
91
|
+
test('idle: a finished node triggers a fresh turn (no deliverAs)', async () => {
|
|
92
|
+
freshNode('node-idle');
|
|
93
|
+
const pi = makeFakePi();
|
|
94
|
+
disposers.push(registerCanvasInboxWatcher(pi));
|
|
95
|
+
// No agent_start fired → watcher treats the node as idle.
|
|
96
|
+
await wait(TICK_MS + 100);
|
|
97
|
+
appendInbox('node-idle', { from: 'child-3', tier: 'normal', kind: 'final', label: 'done while idle' });
|
|
98
|
+
await wait(SETTLE_MS);
|
|
99
|
+
assert.equal(pi.injected.length, 1);
|
|
100
|
+
assert.equal(pi.injected[0].deliverAs, undefined, 'idle → sendUserMessage triggers a turn, no deliverAs');
|
|
101
|
+
});
|
|
102
|
+
});
|
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
//
|
|
5
5
|
// Focus: a finished node (push final → InboxEntry kind 'final') must STEER a
|
|
6
6
|
// mid-stream subscriber, not queue behind its current turn as a follow-up.
|
|
7
|
+
// Part 2 — the refresh-yield HOLD path and idle delivery — lives in
|
|
8
|
+
// canvas-inbox-watcher-hold.test.ts (split for node:test file-level parallelism).
|
|
7
9
|
import { test, describe, before, after, afterEach } from 'node:test';
|
|
8
10
|
import assert from 'node:assert/strict';
|
|
9
11
|
import { mkdirSync, rmSync } from 'node:fs';
|
|
@@ -11,8 +13,6 @@ import { tmpdir } from 'node:os';
|
|
|
11
13
|
import { join } from 'node:path';
|
|
12
14
|
import registerCanvasInboxWatcher from '../../pi-extensions/canvas-inbox-watcher.js';
|
|
13
15
|
import { appendInbox } from '../feed/inbox.js';
|
|
14
|
-
import { createNode, setIntent } from '../canvas/canvas.js';
|
|
15
|
-
import { closeDb } from '../canvas/db.js';
|
|
16
16
|
// Mirror the watcher's internal cadence (TICK_MS=800, DEBOUNCE_MS=1000): allow a
|
|
17
17
|
// resolve+seed tick, a read tick, and the debounce window before asserting.
|
|
18
18
|
const TICK_MS = 800;
|
|
@@ -88,38 +88,4 @@ describe('canvas inbox watcher — finished-node delivery', () => {
|
|
|
88
88
|
assert.equal(pi.injected.length, 1);
|
|
89
89
|
assert.equal(pi.injected[0].deliverAs, 'followUp', 'a normal update is not urgent → followUp');
|
|
90
90
|
});
|
|
91
|
-
test('refresh-yield in flight: inbox entries are HELD, then delivered once intent clears (no loss)', async () => {
|
|
92
|
-
freshNode('node-refresh');
|
|
93
|
-
closeDb(); // rebind the canvas db to this test's fresh home
|
|
94
|
-
const meta = {
|
|
95
|
-
node_id: 'node-refresh', name: 'r', created: new Date().toISOString(),
|
|
96
|
-
cwd: '/tmp', kind: 'general', mode: 'base', lifecycle: 'resident',
|
|
97
|
-
status: 'active', intent: 'refresh',
|
|
98
|
-
};
|
|
99
|
-
createNode(meta);
|
|
100
|
-
const pi = makeFakePi();
|
|
101
|
-
disposers.push(registerCanvasInboxWatcher(pi));
|
|
102
|
-
// Streaming (mid-turn) when a child finishes — normally this would steer.
|
|
103
|
-
pi.fire('agent_start', { type: 'agent_start' }, { isIdle: () => false });
|
|
104
|
-
await wait(TICK_MS + 100);
|
|
105
|
-
appendInbox('node-refresh', { from: 'child-x', tier: 'urgent', kind: 'final', label: 'done' });
|
|
106
|
-
await wait(SETTLE_MS);
|
|
107
|
-
assert.equal(pi.injected.length, 0, 'entries are held while a refresh-yield is in flight (no steer-hijack)');
|
|
108
|
-
// The fresh pi clears intent on boot; the held entry must then be delivered
|
|
109
|
-
// — the cursor was never advanced, so nothing is lost.
|
|
110
|
-
setIntent('node-refresh', null);
|
|
111
|
-
await wait(SETTLE_MS);
|
|
112
|
-
assert.equal(pi.injected.length, 1, 'the held entry is delivered once the refresh clears');
|
|
113
|
-
});
|
|
114
|
-
test('idle: a finished node triggers a fresh turn (no deliverAs)', async () => {
|
|
115
|
-
freshNode('node-idle');
|
|
116
|
-
const pi = makeFakePi();
|
|
117
|
-
disposers.push(registerCanvasInboxWatcher(pi));
|
|
118
|
-
// No agent_start fired → watcher treats the node as idle.
|
|
119
|
-
await wait(TICK_MS + 100);
|
|
120
|
-
appendInbox('node-idle', { from: 'child-3', tier: 'normal', kind: 'final', label: 'done while idle' });
|
|
121
|
-
await wait(SETTLE_MS);
|
|
122
|
-
assert.equal(pi.injected.length, 1);
|
|
123
|
-
assert.equal(pi.injected[0].deliverAs, undefined, 'idle → sendUserMessage triggers a turn, no deliverAs');
|
|
124
|
-
});
|
|
125
91
|
});
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { type Socket } from 'node:net';
|
|
2
|
+
import type { Harness } from './harness.js';
|
|
3
|
+
import { ViewSocketClient } from '../../../clients/attach/view-socket.js';
|
|
4
|
+
import { type BrokerToClient, type ClientToBroker, type ClientRole, type WelcomeFrame } from '../../runtime/broker-protocol.js';
|
|
5
|
+
export declare const delay: (ms: number) => Promise<void>;
|
|
6
|
+
export declare const tok: (s: string) => string;
|
|
7
|
+
export declare const frameHas: (f: BrokerToClient, token: string) => boolean;
|
|
8
|
+
export declare function brokerLogText(h: Harness, id: string): string;
|
|
9
|
+
/** lsof the holders of `path`, or null when lsof is unavailable (skip the fd
|
|
10
|
+
* check). Exit-non-zero with empty stdout means "no holders". */
|
|
11
|
+
export declare function lsofHolders(path: string): number[] | null;
|
|
12
|
+
export interface Attached {
|
|
13
|
+
client: ViewSocketClient;
|
|
14
|
+
frames: BrokerToClient[];
|
|
15
|
+
welcome: WelcomeFrame;
|
|
16
|
+
send(frame: ClientToBroker): void;
|
|
17
|
+
waitFrame(pred: (f: BrokerToClient) => boolean, label: string, timeoutMs?: number): Promise<BrokerToClient>;
|
|
18
|
+
close(): void;
|
|
19
|
+
}
|
|
20
|
+
export interface RawClient {
|
|
21
|
+
socket: Socket;
|
|
22
|
+
frames: BrokerToClient[];
|
|
23
|
+
closed: () => boolean;
|
|
24
|
+
send(frame: ClientToBroker): void;
|
|
25
|
+
writeRaw(data: Buffer | string): void;
|
|
26
|
+
waitClosed(label: string, timeoutMs?: number): Promise<void>;
|
|
27
|
+
close(): void;
|
|
28
|
+
}
|
|
29
|
+
export interface AttachKit {
|
|
30
|
+
/** Connect a ViewSocketClient to a node's running broker, hello, await welcome. */
|
|
31
|
+
attach(id: string, role: ClientRole, clientId: string): Promise<Attached>;
|
|
32
|
+
/** Attach (as `role`) and retry until the welcome satisfies `pred`. */
|
|
33
|
+
attachUntil(id: string, role: ClientRole, clientId: string, pred: (a: Attached) => boolean, label: string): Promise<Attached>;
|
|
34
|
+
/** A raw node:net peer. read:false leaves the socket PAUSED (G8 stalled viewer). */
|
|
35
|
+
connectRaw(id: string, opts: {
|
|
36
|
+
read: boolean;
|
|
37
|
+
}): Promise<RawClient>;
|
|
38
|
+
/** Close every client opened since the last closeAll — wire into afterEach. */
|
|
39
|
+
closeAll(): void;
|
|
40
|
+
}
|
|
41
|
+
/** Build the attach kit against a lazily-resolved harness (the harness is
|
|
42
|
+
* created in the file's before() hook, after the kit is constructed). */
|
|
43
|
+
export declare function createAttachKit(getH: () => Harness): AttachKit;
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
// helpers/broker-clients.ts — the shared attach-client kit for the broker
|
|
2
|
+
// lifecycle/attach-gate suite (plan T8/T11). Extracted VERBATIM from the original
|
|
3
|
+
// single-file broker-lifecycle.test.ts when it was split into per-area files so
|
|
4
|
+
// node:test's file-level parallelism applies (each file holds its own isolated
|
|
5
|
+
// harness); the helpers are unchanged in behavior.
|
|
6
|
+
//
|
|
7
|
+
// The kit wraps the PRODUCTION attach client (pure node:net + the broker codec,
|
|
8
|
+
// no TUI) as the in-test controller/observer so the G1–G9 gate exercises the REAL
|
|
9
|
+
// client too (§0 one-writer: a viewer holds ONLY a socket), plus a raw node:net
|
|
10
|
+
// peer for the cases where the client lifecycle is awkward (G7 oversized line,
|
|
11
|
+
// G8 stalled viewer).
|
|
12
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
13
|
+
import { join } from 'node:path';
|
|
14
|
+
import { createConnection } from 'node:net';
|
|
15
|
+
import { spawnSync } from 'node:child_process';
|
|
16
|
+
import { ViewSocketClient } from '../../../clients/attach/view-socket.js';
|
|
17
|
+
import { CLIENT_READ_CAPS, FrameDecoder, encodeFrame, } from '../../runtime/broker-protocol.js';
|
|
18
|
+
export const delay = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
19
|
+
export const tok = (s) => `${s}-${Math.random().toString(36).slice(2, 8)}`;
|
|
20
|
+
export const frameHas = (f, token) => JSON.stringify(f).includes(token);
|
|
21
|
+
export function brokerLogText(h, id) {
|
|
22
|
+
try {
|
|
23
|
+
return readFileSync(join(h.home, 'nodes', id, 'job', 'broker.log'), 'utf8');
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return '';
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
/** lsof the holders of `path`, or null when lsof is unavailable (skip the fd
|
|
30
|
+
* check). Exit-non-zero with empty stdout means "no holders". */
|
|
31
|
+
export function lsofHolders(path) {
|
|
32
|
+
if (spawnSync('which', ['lsof'], { stdio: 'ignore' }).status !== 0)
|
|
33
|
+
return null;
|
|
34
|
+
const out = (spawnSync('lsof', ['-t', '--', path], { encoding: 'utf8' }).stdout ?? '').trim();
|
|
35
|
+
if (out === '')
|
|
36
|
+
return [];
|
|
37
|
+
return out
|
|
38
|
+
.split('\n')
|
|
39
|
+
.map((l) => Number(l.trim()))
|
|
40
|
+
.filter((n) => Number.isFinite(n));
|
|
41
|
+
}
|
|
42
|
+
/** Build the attach kit against a lazily-resolved harness (the harness is
|
|
43
|
+
* created in the file's before() hook, after the kit is constructed). */
|
|
44
|
+
export function createAttachKit(getH) {
|
|
45
|
+
const liveClients = [];
|
|
46
|
+
// Connect a ViewSocketClient to a node's running broker, hello, await welcome.
|
|
47
|
+
// awaitBoot returns once the boot proof is written — which is BEFORE the broker's
|
|
48
|
+
// server.listen() binds view.sock — so a fresh attach can momentarily race the
|
|
49
|
+
// bind; retry the connect on BrokerUnavailable until it is listening.
|
|
50
|
+
async function attach(id, role, clientId) {
|
|
51
|
+
const h = getH();
|
|
52
|
+
await h.waitFor(() => existsSync(h.brokerSock(id)), { label: `view.sock for ${id}`, timeoutMs: 20_000 });
|
|
53
|
+
const frames = [];
|
|
54
|
+
let client;
|
|
55
|
+
for (let attempt = 0;; attempt++) {
|
|
56
|
+
client = new ViewSocketClient(id);
|
|
57
|
+
client.on('frame', (f) => frames.push(f));
|
|
58
|
+
try {
|
|
59
|
+
await new Promise((resolve, reject) => {
|
|
60
|
+
client.once('connect', resolve);
|
|
61
|
+
client.once('error', reject);
|
|
62
|
+
client.connect();
|
|
63
|
+
});
|
|
64
|
+
break;
|
|
65
|
+
}
|
|
66
|
+
catch (err) {
|
|
67
|
+
client.close();
|
|
68
|
+
if (attempt >= 30)
|
|
69
|
+
throw err;
|
|
70
|
+
await delay(100);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
client.on('error', () => { }); // post-connect error sink (never throw uncaught)
|
|
74
|
+
// Register cleanup the instant the socket is connected — BEFORE the hello/welcome
|
|
75
|
+
// round-trip — so a welcome timeout cannot leak a connected socket past the test.
|
|
76
|
+
liveClients.push({ close: () => client.close() });
|
|
77
|
+
const waitFrame = (pred, label, timeoutMs = 15_000) => h.waitFor(() => frames.find(pred) ?? null, { label, timeoutMs });
|
|
78
|
+
client.send({ type: 'hello', role, client_id: clientId });
|
|
79
|
+
let welcome;
|
|
80
|
+
try {
|
|
81
|
+
welcome = (await waitFrame((f) => f.type === 'welcome', `welcome for ${clientId}`));
|
|
82
|
+
}
|
|
83
|
+
catch (err) {
|
|
84
|
+
client.close();
|
|
85
|
+
throw err;
|
|
86
|
+
}
|
|
87
|
+
return { client, frames, welcome, send: (f) => client.send(f), waitFrame, close: () => client.close() };
|
|
88
|
+
}
|
|
89
|
+
// Attach (as `role`) and retry until the welcome satisfies `pred` — used where the
|
|
90
|
+
// observable lands a beat after a prior action (G3 snapshot accrual, G5b control
|
|
91
|
+
// handoff after a controller detaches). Deterministic: it polls an observable.
|
|
92
|
+
async function attachUntil(id, role, clientId, pred, label) {
|
|
93
|
+
for (let attempt = 0;; attempt++) {
|
|
94
|
+
const a = await attach(id, role, `${clientId}-${attempt}`);
|
|
95
|
+
if (pred(a))
|
|
96
|
+
return a;
|
|
97
|
+
a.close();
|
|
98
|
+
if (attempt >= 40)
|
|
99
|
+
throw new Error(`attachUntil timed out: ${label}`);
|
|
100
|
+
await delay(150);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
// A raw node:net peer. read:true decodes incoming frames; read:false leaves the
|
|
104
|
+
// socket PAUSED (no 'data' listener, never resumed) so it never drains — the
|
|
105
|
+
// stalled viewer (G8) whose backlog the broker must shed at the HWM.
|
|
106
|
+
async function connectRaw(id, opts) {
|
|
107
|
+
const h = getH();
|
|
108
|
+
await h.waitFor(() => existsSync(h.brokerSock(id)), { label: `view.sock for ${id}`, timeoutMs: 20_000 });
|
|
109
|
+
const frames = [];
|
|
110
|
+
const decoder = new FrameDecoder(CLIENT_READ_CAPS);
|
|
111
|
+
let isClosed = false;
|
|
112
|
+
const socket = await new Promise((resolve, reject) => {
|
|
113
|
+
const s = createConnection(h.brokerSock(id));
|
|
114
|
+
s.once('connect', () => resolve(s));
|
|
115
|
+
s.once('error', reject);
|
|
116
|
+
});
|
|
117
|
+
socket.on('close', () => {
|
|
118
|
+
isClosed = true;
|
|
119
|
+
});
|
|
120
|
+
socket.on('error', () => {
|
|
121
|
+
/* close follows */
|
|
122
|
+
});
|
|
123
|
+
if (opts.read) {
|
|
124
|
+
socket.on('data', (chunk) => {
|
|
125
|
+
try {
|
|
126
|
+
for (const f of decoder.push(chunk))
|
|
127
|
+
frames.push(f);
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
/* a client-side overflow is irrelevant here */
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
const rc = {
|
|
135
|
+
socket,
|
|
136
|
+
frames,
|
|
137
|
+
closed: () => isClosed,
|
|
138
|
+
send: (f) => {
|
|
139
|
+
try {
|
|
140
|
+
socket.write(encodeFrame(f));
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
/* dead */
|
|
144
|
+
}
|
|
145
|
+
},
|
|
146
|
+
writeRaw: (d) => {
|
|
147
|
+
try {
|
|
148
|
+
socket.write(d);
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
/* dead */
|
|
152
|
+
}
|
|
153
|
+
},
|
|
154
|
+
waitClosed: (label, timeoutMs = 15_000) => h.waitFor(() => (isClosed ? true : null), { label, timeoutMs }).then(() => undefined),
|
|
155
|
+
close: () => {
|
|
156
|
+
try {
|
|
157
|
+
socket.destroy();
|
|
158
|
+
}
|
|
159
|
+
catch {
|
|
160
|
+
/* ignore */
|
|
161
|
+
}
|
|
162
|
+
},
|
|
163
|
+
};
|
|
164
|
+
liveClients.push(rc);
|
|
165
|
+
return rc;
|
|
166
|
+
}
|
|
167
|
+
function closeAll() {
|
|
168
|
+
for (const c of liveClients.splice(0)) {
|
|
169
|
+
try {
|
|
170
|
+
c.close();
|
|
171
|
+
}
|
|
172
|
+
catch {
|
|
173
|
+
/* already gone */
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return { attach, attachUntil, connectRaw, closeAll };
|
|
178
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
// Run with: node --import tsx/esm --test src/core/__tests__/live-mutation-verbs.test.ts
|
|
2
|
+
//
|
|
3
|
+
// AXIS: LIVE MUTATION, part 2 — the demote/recycle verb split and the A4
|
|
4
|
+
// promote-then-yield boundary. Split out of live-mutation.test.ts (see its
|
|
5
|
+
// header for the coverage rationale and the oracle reference) so node:test's
|
|
6
|
+
// file-level parallelism applies; each test builds its OWN isolated harness and
|
|
7
|
+
// is moved here UNCHANGED.
|
|
8
|
+
import { test } from 'node:test';
|
|
9
|
+
import assert from 'node:assert/strict';
|
|
10
|
+
import { spawnSync } from 'node:child_process';
|
|
11
|
+
import { createHarness, hasTmux } from './helpers/harness.js';
|
|
12
|
+
const SKIP = !hasTmux() ? 'tmux unavailable' : false;
|
|
13
|
+
function sessionExists(session) {
|
|
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
|
+
}
|
|
25
|
+
/** Normalize the two persona axes off a NodeMeta for deepEqual. */
|
|
26
|
+
function persona(m) {
|
|
27
|
+
return { mode: m.mode, lifecycle: m.lifecycle };
|
|
28
|
+
}
|
|
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
|
+
// ===========================================================================
|
|
39
|
+
// (b) THE demote / recycle SPLIT — two DISTINCT verbs after the rename:
|
|
40
|
+
// • `node demote` flips a LIVE node's lifecycle→TERMINAL IN PLACE — it keeps
|
|
41
|
+
// its pane, its MODE, and its parentage, keeps running, is NOT finalized; it
|
|
42
|
+
// now merely owes a final up the spine (vision F5). It is NOT an
|
|
43
|
+
// orchestrator→base mode flip — MODE is untouched (so persona.ts
|
|
44
|
+
// `baseModeGuidance` stays unreachable via live mutation, as before).
|
|
45
|
+
// • `node recycle` is FINISH+RECYCLE — push final → done, then recycle the
|
|
46
|
+
// pane into a FRESH general/base/resident root (a DIFFERENT node). The
|
|
47
|
+
// recycled node keeps mode=orchestrator (it is merely `done`).
|
|
48
|
+
// This test drives BOTH real verbs on one live node and pins each behavior.
|
|
49
|
+
// ===========================================================================
|
|
50
|
+
test('node demote flips lifecycle→terminal IN PLACE; node recycle is FINISH+RECYCLE', { skip: SKIP, timeout: 120_000 }, async () => {
|
|
51
|
+
const h = await createHarness({ sessionPrefix: 'crtr-live-demote' });
|
|
52
|
+
try {
|
|
53
|
+
const A = h.spawnRoot('resident root');
|
|
54
|
+
const B = await h.spawnChild(A, 'do the work', { kind: 'developer' });
|
|
55
|
+
// Make B resident + orchestrator so the demote's flip→terminal is visible
|
|
56
|
+
// and we can prove MODE/parentage survive it.
|
|
57
|
+
assert.equal(h.cli(B, ['node', 'lifecycle', 'resident', '--node', B]).code, 0, 'B → resident');
|
|
58
|
+
assert.equal(h.cli(B, ['node', 'promote', '--kind', 'developer']).code, 0, 'promote B');
|
|
59
|
+
{
|
|
60
|
+
const b = h.node(B);
|
|
61
|
+
assert.equal(b.lifecycle, 'resident', 'B resident before demote');
|
|
62
|
+
assert.equal(b.mode, 'orchestrator', 'B orchestrator before demote');
|
|
63
|
+
}
|
|
64
|
+
const bParent = h.node(B).parent;
|
|
65
|
+
// --- node demote: flip-to-terminal IN PLACE. Keeps B alive, MODE/parentage
|
|
66
|
+
// untouched, NOT finalized.
|
|
67
|
+
const dem = h.cli(B, ['node', 'demote', '--node', B]);
|
|
68
|
+
assert.equal(dem.code, 0, `node demote exit 0\n${dem.stderr}`);
|
|
69
|
+
assert.match(dem.stdout, /<demoted /, `demote rendered\n${dem.stdout}`);
|
|
70
|
+
{
|
|
71
|
+
const b = h.node(B);
|
|
72
|
+
assert.equal(b.lifecycle, 'terminal', 'demote flips lifecycle→terminal IN PLACE');
|
|
73
|
+
assert.equal(b.mode, 'orchestrator', 'demote leaves MODE untouched (not an orchestrator→base flip)');
|
|
74
|
+
assert.equal(b.parent, bParent, 'demote leaves parentage unchanged');
|
|
75
|
+
assert.equal(b.status, 'active', 'demote does NOT finish B — it keeps running in place');
|
|
76
|
+
assert.notEqual(b.intent ?? null, 'done', 'demote does NOT finalize B');
|
|
77
|
+
}
|
|
78
|
+
// --- node recycle: FINISH + RECYCLE the SAME pane into a fresh root.
|
|
79
|
+
// Resolve B's live %pane_id from its window (the row's `pane` is null
|
|
80
|
+
// until a reconcile; the spawn path records only window+session).
|
|
81
|
+
const pane = firstPaneOf(h.node(B).window);
|
|
82
|
+
assert.ok(typeof pane === 'string' && pane !== '', 'B has a live pane to recycle');
|
|
83
|
+
// RECYCLE via the real verb (TMUX_PANE is scrubbed from child env → pass --pane).
|
|
84
|
+
const res = h.cli(B, ['node', 'recycle', '--node', B, '--pane', pane]);
|
|
85
|
+
assert.equal(res.code, 0, `recycle exit 0\n${res.stderr}`);
|
|
86
|
+
// The leaf renders `<recycled ... finalized=".." new_root=".."/>` (not JSON).
|
|
87
|
+
assert.match(res.stdout, /<recycled /, `recycle recycled the pane\n${res.stdout}`);
|
|
88
|
+
const newRoot = /new_root="([^"]+)"/.exec(res.stdout)?.[1];
|
|
89
|
+
const finalized = /finalized="true"/.test(res.stdout);
|
|
90
|
+
// The recycled node is FINISHED, not mode-flipped.
|
|
91
|
+
{
|
|
92
|
+
const b = h.node(B);
|
|
93
|
+
assert.equal(b.status, 'done', 'recycled node → done (finished), NOT re-roled');
|
|
94
|
+
assert.equal(b.intent, 'done', 'intent=done (finalize), per the push-final path');
|
|
95
|
+
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
|
+
}
|
|
98
|
+
// The fresh root is a DIFFERENT, BASE×RESIDENT node.
|
|
99
|
+
assert.ok(typeof newRoot === 'string' && newRoot !== B, 'a fresh root (≠ B) was minted');
|
|
100
|
+
{
|
|
101
|
+
const fresh = h.node(newRoot);
|
|
102
|
+
assert.deepEqual(persona(fresh), { mode: 'base', lifecycle: 'resident' }, 'recycled root is born base×resident (general)');
|
|
103
|
+
assert.deepEqual(fresh.persona_ack, { mode: 'base', lifecycle: 'resident' }, 'fresh root born acked base×resident');
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
finally {
|
|
107
|
+
const session = h.session;
|
|
108
|
+
await h.dispose();
|
|
109
|
+
assert.equal(sessionExists(session), false, 'isolated session killed — no stray');
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
// ===========================================================================
|
|
113
|
+
// (b) A4 BOUNDARY — promote-then-yield emits a steer that is discarded. The
|
|
114
|
+
// oracle/flagship boundary: the base→orchestrator guidance lands as a STEER
|
|
115
|
+
// only if a turn_end fires while the drift is pending. A `node yield` on a
|
|
116
|
+
// base node auto-promotes (mode→orchestrator, ack NOT committed) and its
|
|
117
|
+
// agent_end goes STRAIGHT to reviveInPlace (b') with NO preceding turn_end —
|
|
118
|
+
// so the only steer-delivery site (turn_end) is BYPASSED. Two deterministic
|
|
119
|
+
// facts pin the boundary, both confirmed by direct observation:
|
|
120
|
+
// (1) NO orchestration STEER is ever delivered (the LOSS).
|
|
121
|
+
// (2) The ack is silently advanced base→orchestrator at the refresh DRAIN
|
|
122
|
+
// (reviveInPlace→drainBearings→commitPersonaAck), NOT via a steer — so
|
|
123
|
+
// neither this turn nor (per A4) the fresh revive re-offers it as a
|
|
124
|
+
// steer; the guidance survives only in the kickoff PROMPT it built.
|
|
125
|
+
// ⚑ FLAGGED (not fixed): the in-place refresh of this LARGE pending-drift
|
|
126
|
+
// kickoff prompt did NOT complete a fresh fake-pi boot in the harness (it
|
|
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.
|
|
132
|
+
// ===========================================================================
|
|
133
|
+
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 () => {
|
|
134
|
+
const h = await createHarness({ sessionPrefix: 'crtr-live-a4' });
|
|
135
|
+
try {
|
|
136
|
+
const A = h.spawnRoot('resident root');
|
|
137
|
+
const B = await h.spawnChild(A, 'do the work', { kind: 'developer' });
|
|
138
|
+
assert.deepEqual(persona(h.node(B)), { mode: 'base', lifecycle: 'terminal' }, 'B born base×terminal');
|
|
139
|
+
const injBefore = h.injected(B).length;
|
|
140
|
+
// `crtr node yield` (base → auto-promote → intent=refresh). INTERMEDIATE
|
|
141
|
+
// state, BEFORE any agent_end: mode flipped, ack NOT yet committed (the
|
|
142
|
+
// turn_end injector has not run), intent=refresh, drift PENDING.
|
|
143
|
+
const y = h.cli(B, ['node', 'yield', 'refresh against the roadmap']);
|
|
144
|
+
assert.equal(y.code, 0, `node yield exit 0\n${y.stderr}`);
|
|
145
|
+
{
|
|
146
|
+
const b = h.node(B);
|
|
147
|
+
assert.equal(b.mode, 'orchestrator', 'yield auto-promoted base→orchestrator');
|
|
148
|
+
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');
|
|
150
|
+
}
|
|
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
|
+
}
|
|
170
|
+
finally {
|
|
171
|
+
const session = h.session;
|
|
172
|
+
await h.dispose();
|
|
173
|
+
assert.equal(sessionExists(session), false, 'isolated session killed — no stray');
|
|
174
|
+
}
|
|
175
|
+
});
|