@animalabs/connectome-host 0.3.1
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/.env.example +20 -0
- package/.github/workflows/publish.yml +65 -0
- package/ARCHITECTURE.md +355 -0
- package/CHANGELOG.md +30 -0
- package/HEADLESS-FLEET-PLAN.md +330 -0
- package/README.md +189 -0
- package/UNIFIED-TREE-PLAN.md +242 -0
- package/bun.lock +288 -0
- package/docs/AGENT-MEMORY-GUIDE.md +214 -0
- package/docs/AGENT-ONBOARDING.md +541 -0
- package/docs/ATTENTION-AND-GATING.md +120 -0
- package/docs/DEPLOYMENTS.md +130 -0
- package/docs/DEV-ENVIRONMENT.md +215 -0
- package/docs/LIBRARY-PIPELINE.md +286 -0
- package/docs/LOCUS-ROUTING-DESIGN.md +115 -0
- package/docs/claudeai-evacuation.md +228 -0
- package/docs/debug-context-api.md +208 -0
- package/docs/webui-deployment.md +219 -0
- package/package.json +33 -0
- package/recipes/SETUP.md +308 -0
- package/recipes/TRIUMVIRATE-SETUP.md +467 -0
- package/recipes/claude-export-revive.json +19 -0
- package/recipes/clerk.json +157 -0
- package/recipes/knowledge-miner.json +174 -0
- package/recipes/knowledge-reviewer.json +76 -0
- package/recipes/mcpl-editor-test.json +38 -0
- package/recipes/prompts/transplant-addendum.md +17 -0
- package/recipes/triumvirate.json +63 -0
- package/recipes/webui-fleet-test.json +27 -0
- package/recipes/webui-test.json +16 -0
- package/recipes/zulip-miner.json +87 -0
- package/scripts/connectome-doctor +373 -0
- package/scripts/evacuator.ts +956 -0
- package/scripts/import-claudeai-export.ts +747 -0
- package/scripts/lib/line-reader.ts +53 -0
- package/scripts/test-historical-thinking.ts +148 -0
- package/scripts/warmup-session.ts +336 -0
- package/src/agent-name.ts +58 -0
- package/src/commands.ts +1074 -0
- package/src/headless.ts +528 -0
- package/src/index.ts +867 -0
- package/src/logging-adapter.ts +208 -0
- package/src/mcpl-config.ts +199 -0
- package/src/modules/activity-module.ts +270 -0
- package/src/modules/channel-mode-module.ts +260 -0
- package/src/modules/fleet-module.ts +1705 -0
- package/src/modules/fleet-types.ts +225 -0
- package/src/modules/lessons-module.ts +465 -0
- package/src/modules/mcpl-admin-module.ts +345 -0
- package/src/modules/retrieval-module.ts +327 -0
- package/src/modules/settings-module.ts +143 -0
- package/src/modules/subagent-module.ts +2074 -0
- package/src/modules/subscription-gc-module.ts +322 -0
- package/src/modules/time-module.ts +85 -0
- package/src/modules/tui-module.ts +76 -0
- package/src/modules/web-ui-curve-page.ts +249 -0
- package/src/modules/web-ui-module.ts +2595 -0
- package/src/recipe.ts +1003 -0
- package/src/session-manager.ts +278 -0
- package/src/state/agent-tree-reducer.ts +431 -0
- package/src/state/fleet-tree-aggregator.ts +195 -0
- package/src/strategies/frontdesk-strategy.ts +364 -0
- package/src/synesthete.ts +68 -0
- package/src/tui.ts +2200 -0
- package/src/types/bun-ffi.d.ts +6 -0
- package/src/web/protocol.ts +648 -0
- package/test/agent-name.test.ts +62 -0
- package/test/agent-tree-fleet-launch.test.ts +64 -0
- package/test/agent-tree-reducer-parity.test.ts +194 -0
- package/test/agent-tree-reducer.test.ts +443 -0
- package/test/agent-tree-rollup.test.ts +109 -0
- package/test/autobio-progress-snapshot.test.ts +40 -0
- package/test/claudeai-export-importer.test.ts +386 -0
- package/test/evacuator.test.ts +332 -0
- package/test/fleet-commands.test.ts +244 -0
- package/test/fleet-no-subfleets.test.ts +147 -0
- package/test/fleet-orchestration.test.ts +353 -0
- package/test/fleet-recipe.test.ts +124 -0
- package/test/fleet-route.test.ts +44 -0
- package/test/fleet-smoke.test.ts +479 -0
- package/test/fleet-subscribe-union-e2e.test.ts +123 -0
- package/test/fleet-subscribe-union.test.ts +85 -0
- package/test/fleet-tree-aggregator-e2e.test.ts +110 -0
- package/test/fleet-tree-aggregator.test.ts +215 -0
- package/test/frontdesk-strategy.test.ts +326 -0
- package/test/headless-describe.test.ts +182 -0
- package/test/headless-smoke.test.ts +190 -0
- package/test/logging-adapter.test.ts +87 -0
- package/test/mcpl-admin-module.test.ts +213 -0
- package/test/mcpl-agent-overlay.test.ts +126 -0
- package/test/mock-headless-child.ts +133 -0
- package/test/recipe-cache-ttl.test.ts +37 -0
- package/test/recipe-env.test.ts +157 -0
- package/test/recipe-path-resolution.test.ts +170 -0
- package/test/subagent-async-timeout.test.ts +381 -0
- package/test/subagent-fork-materialise.test.ts +241 -0
- package/test/subagent-peek-scoping.test.ts +180 -0
- package/test/subagent-peek-zombie.test.ts +148 -0
- package/test/subagent-reaper-in-flight.test.ts +229 -0
- package/test/subscription-gc-module.test.ts +136 -0
- package/test/warmup-handoff.test.ts +150 -0
- package/test/web-ui-bind.test.ts +51 -0
- package/test/web-ui-module.test.ts +246 -0
- package/test/web-ui-protocol.test.ts +0 -0
- package/tsconfig.json +14 -0
- package/web/bun.lock +357 -0
- package/web/index.html +12 -0
- package/web/package.json +24 -0
- package/web/src/App.tsx +1931 -0
- package/web/src/Context.tsx +158 -0
- package/web/src/ContextDocument.tsx +150 -0
- package/web/src/Files.tsx +271 -0
- package/web/src/Lessons.tsx +164 -0
- package/web/src/Mcpl.tsx +310 -0
- package/web/src/Stream.tsx +119 -0
- package/web/src/TreeSidebar.tsx +283 -0
- package/web/src/Usage.tsx +182 -0
- package/web/src/main.tsx +7 -0
- package/web/src/styles.css +26 -0
- package/web/src/tree.ts +268 -0
- package/web/src/wire.ts +120 -0
- package/web/tsconfig.json +21 -0
- package/web/vite.config.ts +32 -0
|
@@ -0,0 +1,479 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Phase 2 smoke test for FleetModule.
|
|
3
|
+
*
|
|
4
|
+
* Drives the module's tool surface directly (no full framework needed) and
|
|
5
|
+
* exercises the end-to-end loop:
|
|
6
|
+
* launch -> ready, list, status, command (/help, offline-safe), peek
|
|
7
|
+
* shows command-output events, kill exits the child cleanly.
|
|
8
|
+
*
|
|
9
|
+
* fleet--send is intentionally NOT tested here — it triggers inference and
|
|
10
|
+
* needs a real ANTHROPIC_API_KEY. That's manual-test territory.
|
|
11
|
+
*/
|
|
12
|
+
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
|
|
13
|
+
import { mkdtempSync, writeFileSync, rmSync, existsSync } from 'node:fs';
|
|
14
|
+
import { tmpdir } from 'node:os';
|
|
15
|
+
import { join, resolve, dirname } from 'node:path';
|
|
16
|
+
import { fileURLToPath } from 'node:url';
|
|
17
|
+
import { FleetModule } from '../src/modules/fleet-module.js';
|
|
18
|
+
|
|
19
|
+
const TEST_DIR = dirname(fileURLToPath(import.meta.url));
|
|
20
|
+
const REPO_ROOT = resolve(TEST_DIR, '..');
|
|
21
|
+
const INDEX_PATH = join(REPO_ROOT, 'src', 'index.ts');
|
|
22
|
+
|
|
23
|
+
const MINIMAL_RECIPE = {
|
|
24
|
+
name: 'Fleet Smoke Test',
|
|
25
|
+
agent: { name: 'leaf', systemPrompt: 'never asked to infer in this test' },
|
|
26
|
+
modules: { subagents: false, lessons: false, retrieval: false, wake: false, workspace: false },
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
async function waitFor(check: () => boolean, timeoutMs: number, label: string): Promise<void> {
|
|
30
|
+
const start = Date.now();
|
|
31
|
+
while (Date.now() - start < timeoutMs) {
|
|
32
|
+
if (check()) return;
|
|
33
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
34
|
+
}
|
|
35
|
+
throw new Error(`waitFor timed out after ${timeoutMs}ms: ${label}`);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
describe('FleetModule — Phase 2', () => {
|
|
39
|
+
let tmpDir: string;
|
|
40
|
+
let recipePath: string;
|
|
41
|
+
let dataDir: string;
|
|
42
|
+
let fleet: FleetModule;
|
|
43
|
+
|
|
44
|
+
beforeAll(() => {
|
|
45
|
+
tmpDir = mkdtempSync(join(tmpdir(), 'fkm-fleet-'));
|
|
46
|
+
recipePath = join(tmpDir, 'recipe.json');
|
|
47
|
+
dataDir = join(tmpDir, 'leaf');
|
|
48
|
+
writeFileSync(recipePath, JSON.stringify(MINIMAL_RECIPE), 'utf-8');
|
|
49
|
+
|
|
50
|
+
// Inject a dummy ANTHROPIC_API_KEY into the env that children inherit;
|
|
51
|
+
// they validate it on startup but never call the API in this test.
|
|
52
|
+
process.env.ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY || 'sk-test-fleet-smoke';
|
|
53
|
+
|
|
54
|
+
fleet = new FleetModule({
|
|
55
|
+
childIndexPath: INDEX_PATH,
|
|
56
|
+
// Snappier timeouts for a hermetic test.
|
|
57
|
+
socketWaitTimeoutMs: 15_000,
|
|
58
|
+
readyTimeoutMs: 10_000,
|
|
59
|
+
gracefulShutdownMs: 5_000,
|
|
60
|
+
sigtermEscalationMs: 2_000,
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
afterAll(async () => {
|
|
65
|
+
// Defensive cleanup — kill any children the test left behind.
|
|
66
|
+
try { await fleet.stop(); } catch { /* noop */ }
|
|
67
|
+
try { rmSync(tmpDir, { recursive: true, force: true }); } catch { /* noop */ }
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('launch → list → status → command/peek → kill round trip', async () => {
|
|
71
|
+
// -- launch --
|
|
72
|
+
const spawnRes = await fleet.handleToolCall({
|
|
73
|
+
id: 't-launch',
|
|
74
|
+
name: 'launch',
|
|
75
|
+
input: { name: 'leaf', recipe: recipePath, dataDir },
|
|
76
|
+
});
|
|
77
|
+
expect(spawnRes.success).toBe(true);
|
|
78
|
+
const spawnData = spawnRes.data as { name: string; pid: number | null; status: string };
|
|
79
|
+
expect(spawnData.name).toBe('leaf');
|
|
80
|
+
expect(spawnData.status).toBe('ready');
|
|
81
|
+
expect(typeof spawnData.pid).toBe('number');
|
|
82
|
+
|
|
83
|
+
// -- list --
|
|
84
|
+
const listRes = await fleet.handleToolCall({ id: 't-list', name: 'list', input: {} });
|
|
85
|
+
expect(listRes.success).toBe(true);
|
|
86
|
+
const listData = listRes.data as Array<{ name: string; status: string; eventCount: number }>;
|
|
87
|
+
expect(listData).toHaveLength(1);
|
|
88
|
+
expect(listData[0]!.name).toBe('leaf');
|
|
89
|
+
expect(listData[0]!.status).toBe('ready');
|
|
90
|
+
|
|
91
|
+
// -- status --
|
|
92
|
+
const statusRes = await fleet.handleToolCall({ id: 't-status', name: 'status', input: { name: 'leaf' } });
|
|
93
|
+
expect(statusRes.success).toBe(true);
|
|
94
|
+
const statusData = statusRes.data as { name: string; status: string; subscription: string[] };
|
|
95
|
+
expect(statusData.status).toBe('ready');
|
|
96
|
+
expect(statusData.subscription).toContain('*'); // default subscription
|
|
97
|
+
|
|
98
|
+
// -- command (/help is offline-safe — no LLM call) --
|
|
99
|
+
const cmdRes = await fleet.handleToolCall({
|
|
100
|
+
id: 't-cmd',
|
|
101
|
+
name: 'command',
|
|
102
|
+
input: { name: 'leaf', command: '/help' },
|
|
103
|
+
});
|
|
104
|
+
expect(cmdRes.success).toBe(true);
|
|
105
|
+
|
|
106
|
+
// Wait for the command-output events to land in the buffer.
|
|
107
|
+
await waitFor(
|
|
108
|
+
() => {
|
|
109
|
+
const child = fleet.getChildren().get('leaf');
|
|
110
|
+
return !!child && child.events.filter((e) => e.type === 'command-output').length >= 5;
|
|
111
|
+
},
|
|
112
|
+
5_000,
|
|
113
|
+
'command-output events from /help',
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
// -- peek --
|
|
117
|
+
const peekRes = await fleet.handleToolCall({
|
|
118
|
+
id: 't-peek',
|
|
119
|
+
name: 'peek',
|
|
120
|
+
input: { name: 'leaf', lines: 30 },
|
|
121
|
+
});
|
|
122
|
+
expect(peekRes.success).toBe(true);
|
|
123
|
+
const peekData = peekRes.data as { name: string; count: number; events: Array<{ type: string }> };
|
|
124
|
+
expect(peekData.name).toBe('leaf');
|
|
125
|
+
expect(peekData.count).toBeGreaterThan(0);
|
|
126
|
+
const cmdOutputs = peekData.events.filter((e) => e.type === 'command-output');
|
|
127
|
+
expect(cmdOutputs.length).toBeGreaterThanOrEqual(5);
|
|
128
|
+
|
|
129
|
+
// -- kill --
|
|
130
|
+
const killRes = await fleet.handleToolCall({ id: 't-kill', name: 'kill', input: { name: 'leaf' } });
|
|
131
|
+
expect(killRes.success).toBe(true);
|
|
132
|
+
|
|
133
|
+
// Final status should be 'exited' (graceful shutdown via socket).
|
|
134
|
+
const child = fleet.getChildren().get('leaf');
|
|
135
|
+
expect(child?.status).toBe('exited');
|
|
136
|
+
expect(child?.exitCode).toBe(0);
|
|
137
|
+
|
|
138
|
+
// Socket file should be removed by the child's own cleanup path.
|
|
139
|
+
expect(existsSync(join(dataDir, 'ipc.sock'))).toBe(false);
|
|
140
|
+
}, 60_000);
|
|
141
|
+
|
|
142
|
+
test('launch rejects duplicate name while child is running', async () => {
|
|
143
|
+
const dataDir2 = join(tmpDir, 'duplicate');
|
|
144
|
+
const first = await fleet.handleToolCall({
|
|
145
|
+
id: 't-dup-1',
|
|
146
|
+
name: 'launch',
|
|
147
|
+
input: { name: 'dup', recipe: recipePath, dataDir: dataDir2 },
|
|
148
|
+
});
|
|
149
|
+
expect(first.success).toBe(true);
|
|
150
|
+
|
|
151
|
+
const second = await fleet.handleToolCall({
|
|
152
|
+
id: 't-dup-2',
|
|
153
|
+
name: 'launch',
|
|
154
|
+
input: { name: 'dup', recipe: recipePath, dataDir: dataDir2 },
|
|
155
|
+
});
|
|
156
|
+
expect(second.success).toBe(false);
|
|
157
|
+
expect(second.error).toMatch(/already/);
|
|
158
|
+
|
|
159
|
+
// Cleanup: kill the running one.
|
|
160
|
+
await fleet.handleToolCall({ id: 't-dup-kill', name: 'kill', input: { name: 'dup' } });
|
|
161
|
+
}, 60_000);
|
|
162
|
+
|
|
163
|
+
test('onChildEvent fans out wire events live (no buffer poll needed)', async () => {
|
|
164
|
+
const dataDir3 = join(tmpDir, 'sub');
|
|
165
|
+
const seen: Array<{ child: string; type: string }> = [];
|
|
166
|
+
const unsub = fleet.onChildEvent('*', (childName, evt) => {
|
|
167
|
+
seen.push({ child: childName, type: evt.type });
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
const spawnRes = await fleet.handleToolCall({
|
|
171
|
+
id: 't-sub-spawn',
|
|
172
|
+
name: 'launch',
|
|
173
|
+
input: { name: 'sub', recipe: recipePath, dataDir: dataDir3 },
|
|
174
|
+
});
|
|
175
|
+
expect(spawnRes.success).toBe(true);
|
|
176
|
+
|
|
177
|
+
// The lifecycle:ready event should have been fanned out by the time
|
|
178
|
+
// launch returned (handleLaunch awaits waitForReady which polls status).
|
|
179
|
+
expect(seen.some((e) => e.child === 'sub' && e.type === 'lifecycle')).toBe(true);
|
|
180
|
+
|
|
181
|
+
await fleet.handleToolCall({ id: 't-sub-cmd', name: 'command', input: { name: 'sub', command: '/help' } });
|
|
182
|
+
|
|
183
|
+
// Wait for command-output events to fan out.
|
|
184
|
+
const start = Date.now();
|
|
185
|
+
while (Date.now() - start < 5_000) {
|
|
186
|
+
if (seen.filter((e) => e.type === 'command-output').length >= 5) break;
|
|
187
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
188
|
+
}
|
|
189
|
+
expect(seen.filter((e) => e.type === 'command-output').length).toBeGreaterThanOrEqual(5);
|
|
190
|
+
|
|
191
|
+
unsub();
|
|
192
|
+
await fleet.handleToolCall({ id: 't-sub-kill', name: 'kill', input: { name: 'sub' } });
|
|
193
|
+
}, 60_000);
|
|
194
|
+
|
|
195
|
+
test('handlers reject unknown child names', async () => {
|
|
196
|
+
const send = await fleet.handleToolCall({ id: 't-u-send', name: 'send', input: { name: 'ghost', content: 'hi' } });
|
|
197
|
+
expect(send.success).toBe(false);
|
|
198
|
+
expect(send.error).toMatch(/Unknown child/);
|
|
199
|
+
|
|
200
|
+
const peek = await fleet.handleToolCall({ id: 't-u-peek', name: 'peek', input: { name: 'ghost' } });
|
|
201
|
+
expect(peek.success).toBe(false);
|
|
202
|
+
|
|
203
|
+
const kill = await fleet.handleToolCall({ id: 't-u-kill', name: 'kill', input: { name: 'ghost' } });
|
|
204
|
+
expect(kill.success).toBe(false);
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
describe('FleetModule — Phase 4 autoStart + allowlist', () => {
|
|
209
|
+
let tmpDir: string;
|
|
210
|
+
let recipePath: string;
|
|
211
|
+
// Each test below builds its own FleetModule and (in the happy path) calls
|
|
212
|
+
// .stop() before returning. But if a test throws or times out — or, like
|
|
213
|
+
// adopt-on-restart, intentionally uses detachMode — children spawned with
|
|
214
|
+
// detached:true survive and orphan to PID 1. Push every fleet here so
|
|
215
|
+
// afterAll can defensively reap whatever the test bodies missed.
|
|
216
|
+
const activeFleets: FleetModule[] = [];
|
|
217
|
+
|
|
218
|
+
beforeAll(() => {
|
|
219
|
+
tmpDir = mkdtempSync(join(tmpdir(), 'fkm-fleet-as-'));
|
|
220
|
+
recipePath = join(tmpDir, 'recipe.json');
|
|
221
|
+
writeFileSync(recipePath, JSON.stringify(MINIMAL_RECIPE), 'utf-8');
|
|
222
|
+
process.env.ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY || 'sk-test-fleet-smoke';
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
afterAll(async () => {
|
|
226
|
+
// Reap in parallel; serial awaits would exceed Bun's hook budget on the
|
|
227
|
+
// worst-case timeout path. undo any leftover detach-mode (adopt-on-restart
|
|
228
|
+
// sets it) so stop() actually kills children, not just disconnects sockets.
|
|
229
|
+
await Promise.all(activeFleets.map(async (f) => {
|
|
230
|
+
try { f.setDetachMode(false); } catch { /* noop */ }
|
|
231
|
+
try { await f.stop(); } catch { /* noop */ }
|
|
232
|
+
}));
|
|
233
|
+
try { rmSync(tmpDir, { recursive: true, force: true }); } catch { /* noop */ }
|
|
234
|
+
}, 30_000);
|
|
235
|
+
|
|
236
|
+
test('autoStart children launch during start() and reach ready', async () => {
|
|
237
|
+
const fleet = new FleetModule({
|
|
238
|
+
childIndexPath: INDEX_PATH,
|
|
239
|
+
autoStart: [
|
|
240
|
+
{ name: 'a', recipe: recipePath, dataDir: join(tmpDir, 'a') },
|
|
241
|
+
{ name: 'b', recipe: recipePath, dataDir: join(tmpDir, 'b') },
|
|
242
|
+
],
|
|
243
|
+
socketWaitTimeoutMs: 15_000,
|
|
244
|
+
readyTimeoutMs: 10_000,
|
|
245
|
+
gracefulShutdownMs: 5_000,
|
|
246
|
+
sigtermEscalationMs: 2_000,
|
|
247
|
+
});
|
|
248
|
+
activeFleets.push(fleet);
|
|
249
|
+
|
|
250
|
+
// Minimal ModuleContext stub — start() only uses ctx for .setState which we don't exercise here.
|
|
251
|
+
await fleet.start({} as unknown as Parameters<typeof fleet.start>[0]);
|
|
252
|
+
|
|
253
|
+
// autoStart is fire-and-forget, so wait for both to reach ready.
|
|
254
|
+
const start = Date.now();
|
|
255
|
+
while (Date.now() - start < 30_000) {
|
|
256
|
+
const ready = [...fleet.getChildren().values()].filter((c) => c.status === 'ready').length;
|
|
257
|
+
if (ready === 2) break;
|
|
258
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const children = [...fleet.getChildren().values()];
|
|
262
|
+
expect(children).toHaveLength(2);
|
|
263
|
+
expect(children.every((c) => c.status === 'ready')).toBe(true);
|
|
264
|
+
|
|
265
|
+
await fleet.stop();
|
|
266
|
+
}, 60_000);
|
|
267
|
+
|
|
268
|
+
test('allowlist rejects recipes outside the list (children recipes are implicitly allowed)', async () => {
|
|
269
|
+
const fleet = new FleetModule({
|
|
270
|
+
childIndexPath: INDEX_PATH,
|
|
271
|
+
autoStart: [],
|
|
272
|
+
allowedRecipes: [recipePath], // only this one path
|
|
273
|
+
socketWaitTimeoutMs: 15_000,
|
|
274
|
+
readyTimeoutMs: 10_000,
|
|
275
|
+
gracefulShutdownMs: 5_000,
|
|
276
|
+
sigtermEscalationMs: 2_000,
|
|
277
|
+
});
|
|
278
|
+
activeFleets.push(fleet);
|
|
279
|
+
await fleet.start({} as unknown as Parameters<typeof fleet.start>[0]);
|
|
280
|
+
|
|
281
|
+
const bogus = join(tmpDir, 'bogus-recipe.json');
|
|
282
|
+
const res = await fleet.handleToolCall({
|
|
283
|
+
id: 't-allow-deny',
|
|
284
|
+
name: 'launch',
|
|
285
|
+
input: { name: 'nope', recipe: bogus, dataDir: join(tmpDir, 'nope') },
|
|
286
|
+
});
|
|
287
|
+
expect(res.success).toBe(false);
|
|
288
|
+
expect(res.error).toMatch(/allowlist/i);
|
|
289
|
+
|
|
290
|
+
// Listed path should be accepted.
|
|
291
|
+
const ok = await fleet.handleToolCall({
|
|
292
|
+
id: 't-allow-ok',
|
|
293
|
+
name: 'launch',
|
|
294
|
+
input: { name: 'ok', recipe: recipePath, dataDir: join(tmpDir, 'ok') },
|
|
295
|
+
});
|
|
296
|
+
expect(ok.success).toBe(true);
|
|
297
|
+
|
|
298
|
+
await fleet.stop();
|
|
299
|
+
}, 60_000);
|
|
300
|
+
|
|
301
|
+
test('subscription filter narrows per-subscriber event stream', async () => {
|
|
302
|
+
const fleet = new FleetModule({
|
|
303
|
+
childIndexPath: INDEX_PATH,
|
|
304
|
+
autoStart: [],
|
|
305
|
+
socketWaitTimeoutMs: 15_000,
|
|
306
|
+
readyTimeoutMs: 10_000,
|
|
307
|
+
gracefulShutdownMs: 5_000,
|
|
308
|
+
sigtermEscalationMs: 2_000,
|
|
309
|
+
});
|
|
310
|
+
activeFleets.push(fleet);
|
|
311
|
+
await fleet.start({} as unknown as Parameters<typeof fleet.start>[0]);
|
|
312
|
+
|
|
313
|
+
const allEvents: string[] = [];
|
|
314
|
+
const filteredEvents: string[] = [];
|
|
315
|
+
const unsubAll = fleet.onChildEvent('*', (_n, e) => { allEvents.push(e.type); });
|
|
316
|
+
const unsubFiltered = fleet.onChildEvent('*', (_n, e) => { filteredEvents.push(e.type); }, ['lifecycle']);
|
|
317
|
+
|
|
318
|
+
const dataDir = join(tmpDir, 'filter');
|
|
319
|
+
const res = await fleet.handleToolCall({
|
|
320
|
+
id: 't-filt-spawn',
|
|
321
|
+
name: 'launch',
|
|
322
|
+
input: { name: 'filt', recipe: recipePath, dataDir },
|
|
323
|
+
});
|
|
324
|
+
expect(res.success).toBe(true);
|
|
325
|
+
|
|
326
|
+
await fleet.handleToolCall({ id: 't-filt-cmd', name: 'command', input: { name: 'filt', command: '/help' } });
|
|
327
|
+
|
|
328
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
329
|
+
|
|
330
|
+
// Unfiltered should include both lifecycle and command-output.
|
|
331
|
+
expect(allEvents.some((t) => t === 'lifecycle')).toBe(true);
|
|
332
|
+
expect(allEvents.some((t) => t === 'command-output')).toBe(true);
|
|
333
|
+
// Filtered should include ONLY lifecycle — no command-output leak-through.
|
|
334
|
+
expect(filteredEvents.some((t) => t === 'lifecycle')).toBe(true);
|
|
335
|
+
expect(filteredEvents.every((t) => t === 'lifecycle')).toBe(true);
|
|
336
|
+
|
|
337
|
+
unsubAll();
|
|
338
|
+
unsubFiltered();
|
|
339
|
+
await fleet.handleToolCall({ id: 't-filt-kill', name: 'kill', input: { name: 'filt' } });
|
|
340
|
+
await fleet.stop();
|
|
341
|
+
}, 60_000);
|
|
342
|
+
|
|
343
|
+
test('adopt-on-restart: second FleetModule with shared state reattaches to running child', async () => {
|
|
344
|
+
// A minimal in-memory ctx that survives across FleetModule instances.
|
|
345
|
+
const store: { fleet?: unknown } = {};
|
|
346
|
+
const stubCtx = {
|
|
347
|
+
setState: <T>(s: T): void => { store.fleet = s; },
|
|
348
|
+
getState: <T>(): T | null => (store.fleet as T | null) ?? null,
|
|
349
|
+
pushEvent: (): void => {},
|
|
350
|
+
getModule: (): null => null,
|
|
351
|
+
} as unknown as Parameters<FleetModule['start']>[0];
|
|
352
|
+
|
|
353
|
+
// First parent: spawn, detach (child keeps running).
|
|
354
|
+
const fleet1 = new FleetModule({
|
|
355
|
+
childIndexPath: INDEX_PATH,
|
|
356
|
+
socketWaitTimeoutMs: 15_000,
|
|
357
|
+
readyTimeoutMs: 10_000,
|
|
358
|
+
// Snappy shutdown so the afterAll safety net stays within hook budget
|
|
359
|
+
// if anything between detach and the explicit kill below throws.
|
|
360
|
+
gracefulShutdownMs: 1_000,
|
|
361
|
+
sigtermEscalationMs: 500,
|
|
362
|
+
});
|
|
363
|
+
activeFleets.push(fleet1);
|
|
364
|
+
await fleet1.start(stubCtx);
|
|
365
|
+
const dataDir = join(tmpDir, 'adopt');
|
|
366
|
+
const res1 = await fleet1.handleToolCall({
|
|
367
|
+
id: 't-adopt-spawn',
|
|
368
|
+
name: 'launch',
|
|
369
|
+
input: { name: 'adoptee', recipe: recipePath, dataDir },
|
|
370
|
+
});
|
|
371
|
+
expect(res1.success).toBe(true);
|
|
372
|
+
const pidBefore = (res1.data as { pid: number }).pid;
|
|
373
|
+
|
|
374
|
+
fleet1.setDetachMode(true);
|
|
375
|
+
await fleet1.stop();
|
|
376
|
+
|
|
377
|
+
// Brief gap to simulate parent restart.
|
|
378
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
379
|
+
|
|
380
|
+
// Second parent: same shared state; should adopt rather than respawn.
|
|
381
|
+
const fleet2 = new FleetModule({
|
|
382
|
+
childIndexPath: INDEX_PATH,
|
|
383
|
+
socketWaitTimeoutMs: 15_000,
|
|
384
|
+
readyTimeoutMs: 10_000,
|
|
385
|
+
gracefulShutdownMs: 1_000,
|
|
386
|
+
sigtermEscalationMs: 500,
|
|
387
|
+
});
|
|
388
|
+
activeFleets.push(fleet2);
|
|
389
|
+
await fleet2.start(stubCtx);
|
|
390
|
+
|
|
391
|
+
// The adopted child should be in the new fleet's map, ready, with the SAME pid.
|
|
392
|
+
const adopted = fleet2.getChildren().get('adoptee');
|
|
393
|
+
expect(adopted).toBeDefined();
|
|
394
|
+
expect(adopted?.status).toBe('ready');
|
|
395
|
+
expect(adopted?.pid).toBe(pidBefore);
|
|
396
|
+
|
|
397
|
+
// Send a command to confirm the socket works end-to-end.
|
|
398
|
+
const cmd = await fleet2.handleToolCall({
|
|
399
|
+
id: 't-adopt-cmd',
|
|
400
|
+
name: 'command',
|
|
401
|
+
input: { name: 'adoptee', command: '/help' },
|
|
402
|
+
});
|
|
403
|
+
expect(cmd.success).toBe(true);
|
|
404
|
+
|
|
405
|
+
await fleet2.handleToolCall({ id: 't-adopt-kill', name: 'kill', input: { name: 'adoptee' } });
|
|
406
|
+
await fleet2.stop();
|
|
407
|
+
}, 60_000);
|
|
408
|
+
|
|
409
|
+
test('relative launch path matches implicit absolute-path allowlist via CWD resolve', async () => {
|
|
410
|
+
// Simulates the conductor's post-fix situation: autoStart children carry
|
|
411
|
+
// absolute paths (resolved at recipe-load time), so the implicit allowlist
|
|
412
|
+
// is absolute; but the agent calls fleet--launch with a CWD-relative
|
|
413
|
+
// string. The launch check must try both forms.
|
|
414
|
+
const fleet = new FleetModule({
|
|
415
|
+
childIndexPath: INDEX_PATH,
|
|
416
|
+
autoStart: [
|
|
417
|
+
// Registers an absolute path in the implicit allowlist without
|
|
418
|
+
// actually starting (autoStart: false).
|
|
419
|
+
{ name: 'placeholder', recipe: recipePath, autoStart: false },
|
|
420
|
+
],
|
|
421
|
+
socketWaitTimeoutMs: 15_000,
|
|
422
|
+
readyTimeoutMs: 10_000,
|
|
423
|
+
gracefulShutdownMs: 5_000,
|
|
424
|
+
sigtermEscalationMs: 2_000,
|
|
425
|
+
});
|
|
426
|
+
activeFleets.push(fleet);
|
|
427
|
+
await fleet.start({} as unknown as Parameters<typeof fleet.start>[0]);
|
|
428
|
+
|
|
429
|
+
// Run the launch from the recipe's directory so CWD-resolving "recipe.json"
|
|
430
|
+
// lands on the absolute path registered above.
|
|
431
|
+
const originalCwd = process.cwd();
|
|
432
|
+
process.chdir(tmpDir);
|
|
433
|
+
try {
|
|
434
|
+
const res = await fleet.handleToolCall({
|
|
435
|
+
id: 't-relative-allow',
|
|
436
|
+
name: 'launch',
|
|
437
|
+
input: { name: 'relauth', recipe: 'recipe.json', dataDir: join(tmpDir, 'relauth') },
|
|
438
|
+
});
|
|
439
|
+
expect(res.success).toBe(true);
|
|
440
|
+
await fleet.handleToolCall({ id: 't-relative-kill', name: 'kill', input: { name: 'relauth' } });
|
|
441
|
+
} finally {
|
|
442
|
+
process.chdir(originalCwd);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
await fleet.stop();
|
|
446
|
+
}, 60_000);
|
|
447
|
+
|
|
448
|
+
test('allowlist prefix wildcard works', async () => {
|
|
449
|
+
const fleet = new FleetModule({
|
|
450
|
+
childIndexPath: INDEX_PATH,
|
|
451
|
+
autoStart: [],
|
|
452
|
+
allowedRecipes: [`${tmpDir}/*`],
|
|
453
|
+
socketWaitTimeoutMs: 15_000,
|
|
454
|
+
readyTimeoutMs: 10_000,
|
|
455
|
+
gracefulShutdownMs: 5_000,
|
|
456
|
+
sigtermEscalationMs: 2_000,
|
|
457
|
+
});
|
|
458
|
+
activeFleets.push(fleet);
|
|
459
|
+
await fleet.start({} as unknown as Parameters<typeof fleet.start>[0]);
|
|
460
|
+
|
|
461
|
+
// Any recipe under tmpDir/ should match.
|
|
462
|
+
const ok = await fleet.handleToolCall({
|
|
463
|
+
id: 't-glob-ok',
|
|
464
|
+
name: 'launch',
|
|
465
|
+
input: { name: 'glob', recipe: recipePath, dataDir: join(tmpDir, 'glob') },
|
|
466
|
+
});
|
|
467
|
+
expect(ok.success).toBe(true);
|
|
468
|
+
|
|
469
|
+
// Something outside tmpDir should not.
|
|
470
|
+
const bad = await fleet.handleToolCall({
|
|
471
|
+
id: 't-glob-bad',
|
|
472
|
+
name: 'launch',
|
|
473
|
+
input: { name: 'bad', recipe: '/somewhere/else.json', dataDir: join(tmpDir, 'bad') },
|
|
474
|
+
});
|
|
475
|
+
expect(bad.success).toBe(false);
|
|
476
|
+
|
|
477
|
+
await fleet.stop();
|
|
478
|
+
}, 60_000);
|
|
479
|
+
});
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* End-to-end check that FleetModule's reducer-required-events union actually
|
|
3
|
+
* reaches the wire. Uses the headless child's own log — it writes a
|
|
4
|
+
* `subscription set: ...` line whenever a subscribe command is processed.
|
|
5
|
+
*
|
|
6
|
+
* Without this guarantee, a recipe like triumvirate.json that subscribes only
|
|
7
|
+
* to ['lifecycle', 'inference:completed', ...] would silently disable the
|
|
8
|
+
* unified-tree rendering: subagent discovery never fires, tool events have
|
|
9
|
+
* nowhere to route, and the parent's tree drifts. The chokepoint in
|
|
10
|
+
* FleetModule.sendToChild forces the missing events back in.
|
|
11
|
+
*/
|
|
12
|
+
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
|
|
13
|
+
import { mkdtempSync, writeFileSync, readFileSync, rmSync, existsSync } from 'node:fs';
|
|
14
|
+
import { tmpdir } from 'node:os';
|
|
15
|
+
import { join, resolve, dirname } from 'node:path';
|
|
16
|
+
import { fileURLToPath } from 'node:url';
|
|
17
|
+
import { FleetModule } from '../src/modules/fleet-module.js';
|
|
18
|
+
|
|
19
|
+
const TEST_DIR = dirname(fileURLToPath(import.meta.url));
|
|
20
|
+
const REPO_ROOT = resolve(TEST_DIR, '..');
|
|
21
|
+
const INDEX_PATH = join(REPO_ROOT, 'src', 'index.ts');
|
|
22
|
+
|
|
23
|
+
const NARROW_RECIPE = {
|
|
24
|
+
name: 'NarrowSubscriptionTest',
|
|
25
|
+
agent: { name: 'commander', systemPrompt: 'never inferred in this test' },
|
|
26
|
+
modules: {
|
|
27
|
+
subagents: false,
|
|
28
|
+
lessons: false,
|
|
29
|
+
retrieval: false,
|
|
30
|
+
wake: false,
|
|
31
|
+
workspace: false,
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
async function waitFor(check: () => boolean, timeoutMs: number, label: string): Promise<void> {
|
|
36
|
+
const start = Date.now();
|
|
37
|
+
while (Date.now() - start < timeoutMs) {
|
|
38
|
+
if (check()) return;
|
|
39
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
40
|
+
}
|
|
41
|
+
throw new Error(`waitFor timed out after ${timeoutMs}ms: ${label}`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
describe('FleetModule subscribe union e2e', () => {
|
|
45
|
+
let tmpDir: string;
|
|
46
|
+
let recipePath: string;
|
|
47
|
+
let fleet: FleetModule;
|
|
48
|
+
|
|
49
|
+
beforeAll(async () => {
|
|
50
|
+
tmpDir = mkdtempSync(join(tmpdir(), 'fkm-sub-union-'));
|
|
51
|
+
recipePath = join(tmpDir, 'recipe.json');
|
|
52
|
+
writeFileSync(recipePath, JSON.stringify(NARROW_RECIPE), 'utf-8');
|
|
53
|
+
|
|
54
|
+
fleet = new FleetModule({
|
|
55
|
+
childRuntimePath: 'bun',
|
|
56
|
+
childIndexPath: INDEX_PATH,
|
|
57
|
+
readyTimeoutMs: 15_000,
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
afterAll(async () => {
|
|
62
|
+
try {
|
|
63
|
+
await fleet.handleToolCall({
|
|
64
|
+
id: 'cleanup',
|
|
65
|
+
name: 'kill',
|
|
66
|
+
input: { name: 'narrow-child' },
|
|
67
|
+
});
|
|
68
|
+
} catch { /* noop */ }
|
|
69
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
70
|
+
try { rmSync(tmpDir, { recursive: true, force: true }); } catch { /* noop */ }
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('narrow recipe subscription gets reducer-required events forced in by FleetModule', async () => {
|
|
74
|
+
const dataDir = join(tmpDir, 'narrow-child-data');
|
|
75
|
+
// The narrow subscription that broke triumvirate's rendering — missing
|
|
76
|
+
// inference:tool_calls_yielded and tool:started among others.
|
|
77
|
+
const narrowSubscription = [
|
|
78
|
+
'lifecycle',
|
|
79
|
+
'inference:completed',
|
|
80
|
+
'inference:speech',
|
|
81
|
+
'tool:completed',
|
|
82
|
+
'tool:failed',
|
|
83
|
+
'inference:failed',
|
|
84
|
+
];
|
|
85
|
+
const result = await fleet.handleToolCall({
|
|
86
|
+
id: 'launch-narrow',
|
|
87
|
+
name: 'launch',
|
|
88
|
+
input: {
|
|
89
|
+
name: 'narrow-child',
|
|
90
|
+
recipe: recipePath,
|
|
91
|
+
dataDir,
|
|
92
|
+
subscription: narrowSubscription,
|
|
93
|
+
},
|
|
94
|
+
});
|
|
95
|
+
expect((result as { success?: boolean }).success).toBe(true);
|
|
96
|
+
|
|
97
|
+
const logPath = join(dataDir, 'headless.log');
|
|
98
|
+
await waitFor(() => existsSync(logPath), 15_000, 'headless.log appears');
|
|
99
|
+
// Wait until the child has actually applied a subscribe. The headless
|
|
100
|
+
// logs `subscription set: ...` on every subscribe; the most recent line
|
|
101
|
+
// is what's currently active.
|
|
102
|
+
await waitFor(
|
|
103
|
+
() => existsSync(logPath) && /subscription set: .*inference:tool_calls_yielded/.test(readFileSync(logPath, 'utf-8')),
|
|
104
|
+
15_000,
|
|
105
|
+
'subscribe with reducer events applied',
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
const log = readFileSync(logPath, 'utf-8');
|
|
109
|
+
// Pull the most recent `subscription set:` line — that's what's effective now.
|
|
110
|
+
const subLines = log.split('\n').filter(l => l.includes('subscription set:'));
|
|
111
|
+
expect(subLines.length).toBeGreaterThan(0);
|
|
112
|
+
const lastSet = subLines[subLines.length - 1]!;
|
|
113
|
+
// The recipe-specified events are still there:
|
|
114
|
+
expect(lastSet).toContain('lifecycle');
|
|
115
|
+
expect(lastSet).toContain('inference:speech');
|
|
116
|
+
// The reducer-required events that the recipe omitted have been forced in:
|
|
117
|
+
expect(lastSet).toContain('inference:tool_calls_yielded');
|
|
118
|
+
expect(lastSet).toContain('inference:started');
|
|
119
|
+
expect(lastSet).toContain('inference:tokens');
|
|
120
|
+
expect(lastSet).toContain('inference:usage');
|
|
121
|
+
expect(lastSet).toContain('tool:started');
|
|
122
|
+
});
|
|
123
|
+
});
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Verifies FleetModule's reducer-required event union.
|
|
3
|
+
*
|
|
4
|
+
* Recipe authors can specify a narrow per-child subscription (to keep the
|
|
5
|
+
* wire lean), but the unified-tree rendering needs a specific set of events
|
|
6
|
+
* to avoid drifting. The framework forces those into every subscribe sent
|
|
7
|
+
* to a child — recipes can extend the set, never narrow it below rendering's
|
|
8
|
+
* floor.
|
|
9
|
+
*/
|
|
10
|
+
import { describe, test, expect } from 'bun:test';
|
|
11
|
+
import { unionWithReducerRequired } from '../src/modules/fleet-module.js';
|
|
12
|
+
import { REDUCER_REQUIRED_EVENTS } from '../src/state/agent-tree-reducer.js';
|
|
13
|
+
|
|
14
|
+
describe('unionWithReducerRequired', () => {
|
|
15
|
+
test('adds all reducer-required events when none are present', () => {
|
|
16
|
+
const result = new Set(unionWithReducerRequired(['lifecycle']));
|
|
17
|
+
expect(result.has('lifecycle')).toBe(true);
|
|
18
|
+
for (const required of REDUCER_REQUIRED_EVENTS) {
|
|
19
|
+
expect(result.has(required)).toBe(true);
|
|
20
|
+
}
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test('leaves subscription unchanged when * is already present', () => {
|
|
24
|
+
const result = unionWithReducerRequired(['*']);
|
|
25
|
+
expect(result).toEqual(['*']);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test('does not duplicate events already present', () => {
|
|
29
|
+
const subscription = ['lifecycle', 'inference:tool_calls_yielded', 'inference:completed'];
|
|
30
|
+
const result = unionWithReducerRequired(subscription);
|
|
31
|
+
const counts = new Map<string, number>();
|
|
32
|
+
for (const e of result) counts.set(e, (counts.get(e) ?? 0) + 1);
|
|
33
|
+
for (const [event, count] of counts) {
|
|
34
|
+
expect(count).toBe(1);
|
|
35
|
+
}
|
|
36
|
+
expect(result).toContain('lifecycle');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test('honours prefix-glob coverage (tool:* covers tool:started, tool:completed, tool:failed)', () => {
|
|
40
|
+
const result = unionWithReducerRequired(['lifecycle', 'tool:*']);
|
|
41
|
+
expect(result).not.toContain('tool:started');
|
|
42
|
+
expect(result).not.toContain('tool:completed');
|
|
43
|
+
expect(result).not.toContain('tool:failed');
|
|
44
|
+
// But inference:* events still need to be added
|
|
45
|
+
expect(result).toContain('inference:tool_calls_yielded');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('honours inference:* glob coverage', () => {
|
|
49
|
+
const result = unionWithReducerRequired(['inference:*', 'lifecycle']);
|
|
50
|
+
expect(result).not.toContain('inference:tool_calls_yielded');
|
|
51
|
+
expect(result).not.toContain('inference:tokens');
|
|
52
|
+
// tool:* still needed
|
|
53
|
+
expect(result).toContain('tool:started');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('the postmortem-broken triumvirate subscription gets the missing events back', () => {
|
|
57
|
+
// Exact subscription from triumvirate.json that caused the rendering gap.
|
|
58
|
+
const recipeSubscription = [
|
|
59
|
+
'lifecycle',
|
|
60
|
+
'inference:completed',
|
|
61
|
+
'inference:speech',
|
|
62
|
+
'tool:completed',
|
|
63
|
+
'tool:failed',
|
|
64
|
+
'inference:failed',
|
|
65
|
+
];
|
|
66
|
+
const result = new Set(unionWithReducerRequired(recipeSubscription));
|
|
67
|
+
// Recipe-specified events still present:
|
|
68
|
+
expect(result.has('lifecycle')).toBe(true);
|
|
69
|
+
expect(result.has('inference:speech')).toBe(true);
|
|
70
|
+
// Reducer-required events that were missing get added:
|
|
71
|
+
expect(result.has('inference:tool_calls_yielded')).toBe(true);
|
|
72
|
+
expect(result.has('inference:started')).toBe(true);
|
|
73
|
+
expect(result.has('inference:tokens')).toBe(true);
|
|
74
|
+
expect(result.has('inference:usage')).toBe(true);
|
|
75
|
+
expect(result.has('tool:started')).toBe(true);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('returns a fresh array (caller can mutate without affecting input)', () => {
|
|
79
|
+
const input = ['lifecycle'];
|
|
80
|
+
const result = unionWithReducerRequired(input);
|
|
81
|
+
expect(result).not.toBe(input);
|
|
82
|
+
result.push('extra');
|
|
83
|
+
expect(input).toEqual(['lifecycle']);
|
|
84
|
+
});
|
|
85
|
+
});
|