@crouton-kit/crouter 0.3.19 → 0.3.21
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-memory/crouter-development/marketplaces.md +2 -4
- package/dist/builtin-memory/crouter-development/personas/base-prompt.md +3 -5
- package/dist/builtin-memory/crouter-development/personas/orchestrator-prompt.md +3 -6
- package/dist/builtin-memory/crouter-development/personas.md +2 -6
- package/dist/builtin-memory/crouter-development/plugins.md +2 -4
- package/dist/builtin-memory/design.md +2 -4
- package/dist/builtin-memory/development.md +3 -3
- package/dist/builtin-memory/planning.md +3 -4
- package/dist/builtin-memory/spec.md +3 -10
- 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/orchestration-kernel.md +1 -1
- 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 +13 -2
- package/dist/commands/memory/__tests__/lint-schema.test.d.ts +1 -0
- package/dist/commands/memory/__tests__/lint-schema.test.js +29 -0
- package/dist/commands/memory/find.js +10 -15
- package/dist/commands/memory/lint.d.ts +7 -0
- package/dist/commands/memory/lint.js +10 -1
- package/dist/commands/memory/shared.js +1 -2
- package/dist/commands/memory/write.js +6 -9
- package/dist/commands/node.js +51 -26
- package/dist/commands/push.js +10 -15
- package/dist/commands/tmux-spread.js +16 -1
- package/dist/commands/view-cycle.js +9 -5
- package/dist/commands/view-run.js +3 -2
- 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__/cascade-close.test.js +3 -2
- package/dist/core/__tests__/context-intro.test.js +23 -27
- package/dist/core/__tests__/detach-focus.test.js +2 -2
- 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/__tests__/memory.test.js +4 -4
- package/dist/core/__tests__/subscription-delivery.test.js +3 -3
- package/dist/core/bootstrap.js +18 -14
- package/dist/core/canvas/canvas.js +1 -1
- package/dist/core/canvas/types.d.ts +6 -0
- package/dist/core/help.d.ts +2 -2
- package/dist/core/help.js +1 -1
- package/dist/core/render.d.ts +4 -3
- package/dist/core/render.js +38 -41
- package/dist/core/resolver.js +1 -1
- package/dist/core/runtime/bearings.d.ts +2 -2
- package/dist/core/runtime/bearings.js +2 -2
- 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/dist/core/substrate/render.d.ts +1 -2
- package/dist/core/substrate/render.js +9 -12
- package/dist/core/substrate/schema.d.ts +18 -13
- package/dist/core/substrate/schema.js +12 -11
- package/dist/pi-extensions/canvas-context-intro.js +5 -3
- 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
|
});
|
|
@@ -103,8 +103,9 @@ test('cascade close: middle-node close reaps EXACTLY its subtree (canceled), anc
|
|
|
103
103
|
// ===================================================================
|
|
104
104
|
const closeRes = h.cli(A, ['node', 'close', '--node', B]);
|
|
105
105
|
assert.equal(closeRes.code, 0, `node close exit 0\n${closeRes.stderr}`);
|
|
106
|
-
// The CLI's own rendered report: cascade root B, exactly
|
|
107
|
-
|
|
106
|
+
// The CLI's own rendered report (plain markdown): cascade root B, exactly
|
|
107
|
+
// 4 closed, 0 spared (no spared clause is emitted when none were spared).
|
|
108
|
+
assert.match(closeRes.stdout, new RegExp(`^Closed ${B} and its exclusive subtree — 4 node\\(s\\) closed\\.`), `close report names B as root, count=4, spared=0\n${closeRes.stdout}`);
|
|
108
109
|
// ===================================================================
|
|
109
110
|
// ASSERT 1 — the A5 DELIVERABLE: the EXACT terminal status of every
|
|
110
111
|
// cascade-reaped node. CURRENT behavior: status='canceled', intent=null,
|
|
@@ -1,10 +1,8 @@
|
|
|
1
1
|
// Tests for the <crtr-context> bearings preamble:
|
|
2
2
|
// 1. Worker and orchestrator bearings carry the `## References` block
|
|
3
|
-
// (substrate reference docs + node-local docs as `###` sub-sections)
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
// across-cycles framing; promotion delivers that same orchestrator
|
|
7
|
-
// context-dir framing to a node that spawned base.
|
|
3
|
+
// (substrate reference docs + node-local docs as `###` sub-sections).
|
|
4
|
+
// Orchestrators add the across-cycles framing; promotion delivers that
|
|
5
|
+
// same orchestrator context-dir framing to a node that spawned base.
|
|
8
6
|
// 2. canvas-context-intro injects the block as its own session message at
|
|
9
7
|
// session_start (before the first prompt), idempotent across resumes.
|
|
10
8
|
//
|
|
@@ -36,53 +34,51 @@ after(() => {
|
|
|
36
34
|
delete process.env['CRTR_HOME'];
|
|
37
35
|
delete process.env['CRTR_NODE_ID'];
|
|
38
36
|
});
|
|
39
|
-
test('worker bearings: base framing + ## References block
|
|
40
|
-
// Bug-regression: review finding M1 — buildContextBearings
|
|
41
|
-
//
|
|
42
|
-
//
|
|
43
|
-
// locks in the new contract.
|
|
37
|
+
test('worker bearings: base framing + ## References block, NO across-cycles framing', () => {
|
|
38
|
+
// Bug-regression: review finding M1 — buildContextBearings renders the
|
|
39
|
+
// ## References block (renderReferencesBlock): ## References + ### <name>
|
|
40
|
+
// sub-sections. This test locks in that contract.
|
|
44
41
|
const meta = spawnNode({ kind: 'general', cwd: '/tmp/work', parent: null });
|
|
45
42
|
// Seed a node-local substrate doc so the ## References block is non-empty.
|
|
46
43
|
const dir = memoryDir(meta.node_id);
|
|
47
44
|
mkdirSync(dir, { recursive: true });
|
|
48
|
-
writeFileSync(join(dir, 'test-ref.md'), '---\nkind: reference\nwhen:
|
|
45
|
+
writeFileSync(join(dir, 'test-ref.md'), '---\nkind: reference\nwhen-and-why-to-read: When testing, this reference should be read because it is a regression fixture\nsystem-prompt-visibility: preview\n---\nTest body.\n');
|
|
49
46
|
const block = buildContextIntro(meta.node_id);
|
|
50
47
|
assert.match(block, new RegExp(`<crtr-context dir="${contextDir(meta.node_id)}">`));
|
|
51
48
|
assert.match(block, /shared document store, not a task tracker/, 'base = shared docs, not tasks');
|
|
52
|
-
//
|
|
49
|
+
// Reference content renders ONLY as ## References + ### sub-sections.
|
|
53
50
|
assert.doesNotMatch(block, /<memory>/, 'no <memory> block');
|
|
54
|
-
//
|
|
51
|
+
// Per-store stanza headers (label · dir) never appear.
|
|
55
52
|
assert.doesNotMatch(block, /user-global · /, 'no user-global label·dir stanza');
|
|
56
53
|
assert.doesNotMatch(block, /node-local · /, 'no node-local label·dir stanza');
|
|
57
|
-
//
|
|
54
|
+
// No (empty) placeholder marker.
|
|
58
55
|
assert.doesNotMatch(block, /\(empty\)/, 'no (empty) placeholder');
|
|
59
|
-
// Reference content renders as ## References + ### sub-sections — no other shape.
|
|
60
56
|
assert.match(block, /## References\n\n###/, '## References followed by ### sub-sections');
|
|
61
57
|
// A terminal worker must NOT carry the orchestrator across-cycles framing.
|
|
62
58
|
assert.doesNotMatch(block, /refresh cycles/, 'no across-cycles framing for a terminal worker');
|
|
63
59
|
assert.match(block, /<\/crtr-context>/);
|
|
64
60
|
});
|
|
65
|
-
test('orchestrator bearings: across-cycles framing + node-local substrate docs ride into ## References;
|
|
66
|
-
// Bug-regression: review finding M1 — buildContextBearings
|
|
67
|
-
//
|
|
68
|
-
//
|
|
61
|
+
test('orchestrator bearings: across-cycles framing + node-local substrate docs ride into ## References; a non-substrate .md file is never inlined', () => {
|
|
62
|
+
// Bug-regression: review finding M1 — buildContextBearings renders ## References.
|
|
63
|
+
// Node-local substrate docs render as ### sub-sections at their rung; a
|
|
64
|
+
// non-substrate .md file (no frontmatter `kind`) never surfaces.
|
|
69
65
|
const meta = spawnNode({ kind: 'general', cwd: '/tmp/work', parent: null });
|
|
70
66
|
promote(meta.node_id); // flip to orchestrator mode — the across-cycles gate
|
|
71
67
|
const dir = memoryDir(meta.node_id);
|
|
72
68
|
mkdirSync(dir, { recursive: true });
|
|
73
|
-
// Write a MEMORY.md file to verify it is never surfaced in the block.
|
|
69
|
+
// Write a non-substrate MEMORY.md file to verify it is never surfaced in the block.
|
|
74
70
|
const legacyIndexPath = join(dir, 'MEMORY.md');
|
|
75
71
|
writeFileSync(legacyIndexPath, '# memory index — one pointer line per memory; how-to in "Your long-term memory".\n\n- [Flaky build](flaky-build.md) — first run fails\n');
|
|
76
72
|
// A node-local substrate doc DOES ride into ## References at its rung.
|
|
77
|
-
writeFileSync(join(dir, 'flaky-build.md'), '---\nkind: reference\nwhen:
|
|
73
|
+
writeFileSync(join(dir, 'flaky-build.md'), '---\nkind: reference\nwhen-and-why-to-read: When the build flakes, this reference should be read because the first run fails\nsystem-prompt-visibility: preview\n---\nFirst run always fails; rerun once.\n');
|
|
78
74
|
const block = buildContextIntro(meta.node_id);
|
|
79
75
|
assert.match(block, /shared document store, not a task tracker/, 'still carries the base framing');
|
|
80
76
|
assert.match(block, /refresh cycles/, 'orchestrator gets the across-cycles framing');
|
|
81
77
|
assert.doesNotMatch(block, /<memory>/, 'no <memory> block');
|
|
82
78
|
assert.match(block, /## References\n/, 'references block present');
|
|
83
79
|
assert.match(block, /### flaky-build\n/, 'node-local doc gets its ### sub-section');
|
|
84
|
-
assert.match(block, /
|
|
85
|
-
// The
|
|
80
|
+
assert.match(block, /When the build flakes, this reference should be read because the first run fails\./, 'preview rung renders the routing line');
|
|
81
|
+
// The non-substrate file never renders: no header line, no pointer line, no path.
|
|
86
82
|
assert.ok(!block.includes('# memory index'), 'the index header comment is NOT inlined');
|
|
87
83
|
assert.ok(!block.includes('- [Flaky build](flaky-build.md)'), 'index pointer lines are NOT inlined');
|
|
88
84
|
assert.ok(!block.includes(legacyIndexPath), 'no absolute index (MEMORY.md) path');
|
|
@@ -90,9 +86,9 @@ test('orchestrator bearings: across-cycles framing + node-local substrate docs r
|
|
|
90
86
|
assert.match(block, /<\/crtr-context>/);
|
|
91
87
|
});
|
|
92
88
|
test('orchestrator bearings: no per-store stanzas or (empty) markers; a rung-none node-local doc still surfaces as a ### title stub', () => {
|
|
93
|
-
// Bug-regression: review findings M1 + M6 — the
|
|
94
|
-
// stanzas
|
|
95
|
-
// on rung: a migrated node-local reference defaults
|
|
89
|
+
// Bug-regression: review findings M1 + M6 — the ## References block carries no
|
|
90
|
+
// per-store `label · dir` stanzas or (empty) markers, and node-local docs are
|
|
91
|
+
// NOT filtered on rung: a migrated node-local reference defaults
|
|
96
92
|
// system-prompt-visibility: none and must still ride into ## References as
|
|
97
93
|
// its bare title (never its body).
|
|
98
94
|
const meta = spawnNode({ kind: 'general', cwd: '/tmp/work', parent: null });
|
|
@@ -101,7 +97,7 @@ test('orchestrator bearings: no per-store stanzas or (empty) markers; a rung-non
|
|
|
101
97
|
mkdirSync(dir, { recursive: true });
|
|
102
98
|
writeFileSync(join(dir, 'rung-none-fact.md'), '---\nkind: reference\n---\nbody that must not render at the none rung\n');
|
|
103
99
|
const block = buildContextIntro(meta.node_id);
|
|
104
|
-
// No per-store stanza headers, no (empty) markers, no
|
|
100
|
+
// No per-store stanza headers, no (empty) markers, no MEMORY.md paths.
|
|
105
101
|
assert.ok(!block.includes(`node-local · ${memoryDir(meta.node_id)}`), 'no node-local stanza header');
|
|
106
102
|
assert.ok(!block.includes('user-global · '), 'no user-global stanza header');
|
|
107
103
|
assert.ok(!block.includes('project · '), 'no project stanza header');
|
|
@@ -109,7 +109,7 @@ test('node demote --detach on a FOCUSED node: flips terminal, relocates the pane
|
|
|
109
109
|
// breaks the pane back into h.session.
|
|
110
110
|
const res = h.cli(B, ['node', 'demote', '--node', B, '--pane', bPane, '--detach']);
|
|
111
111
|
assert.equal(res.code, 0, `node demote --detach exit 0\n${res.stderr}`);
|
|
112
|
-
assert.match(res.stdout, /
|
|
112
|
+
assert.match(res.stdout, /relocated to the background/, `the agent was detached\n${res.stdout}`);
|
|
113
113
|
closeDb();
|
|
114
114
|
const b = getNode(B);
|
|
115
115
|
// (a) lifecycle flipped to terminal IN PLACE.
|
|
@@ -180,7 +180,7 @@ test('node demote --detach on a NON-GENERATING focused node: RELEASES to dormant
|
|
|
180
180
|
// --- Drive the REAL verb on the non-generating focused node.
|
|
181
181
|
const res = h.cli(B, ['node', 'demote', '--node', B, '--pane', bPane, '--detach']);
|
|
182
182
|
assert.equal(res.code, 0, `node demote --detach exit 0\n${res.stderr}`);
|
|
183
|
-
assert.match(res.stdout, /
|
|
183
|
+
assert.match(res.stdout, /relocated to the background/, `the agent was detached\n${res.stdout}`);
|
|
184
184
|
closeDb();
|
|
185
185
|
const b = getNode(B);
|
|
186
186
|
// (b) RELEASED to dormant — idle + intent='idle-release' (revivable via inbox
|
|
@@ -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 {};
|