@soleri/core 9.5.0 → 9.6.0
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/adapters/claude-code-adapter.d.ts +27 -0
- package/dist/adapters/claude-code-adapter.d.ts.map +1 -0
- package/dist/adapters/claude-code-adapter.js +111 -0
- package/dist/adapters/claude-code-adapter.js.map +1 -0
- package/dist/adapters/index.d.ts +9 -0
- package/dist/adapters/index.d.ts.map +1 -0
- package/dist/adapters/index.js +10 -0
- package/dist/adapters/index.js.map +1 -0
- package/dist/adapters/registry.d.ts +21 -0
- package/dist/adapters/registry.d.ts.map +1 -0
- package/dist/adapters/registry.js +44 -0
- package/dist/adapters/registry.js.map +1 -0
- package/dist/adapters/types.d.ts +93 -0
- package/dist/adapters/types.d.ts.map +1 -0
- package/dist/adapters/types.js +10 -0
- package/dist/adapters/types.js.map +1 -0
- package/dist/brain/brain.d.ts +12 -1
- package/dist/brain/brain.d.ts.map +1 -1
- package/dist/brain/brain.js +106 -44
- package/dist/brain/brain.js.map +1 -1
- package/dist/brain/intelligence.d.ts.map +1 -1
- package/dist/brain/intelligence.js +36 -30
- package/dist/brain/intelligence.js.map +1 -1
- package/dist/chat/agent-loop.js +1 -1
- package/dist/chat/agent-loop.js.map +1 -1
- package/dist/chat/notifications.d.ts.map +1 -1
- package/dist/chat/notifications.js +4 -0
- package/dist/chat/notifications.js.map +1 -1
- package/dist/control/intent-router.d.ts +1 -0
- package/dist/control/intent-router.d.ts.map +1 -1
- package/dist/control/intent-router.js +11 -5
- package/dist/control/intent-router.js.map +1 -1
- package/dist/curator/curator.d.ts +4 -0
- package/dist/curator/curator.d.ts.map +1 -1
- package/dist/curator/curator.js +141 -27
- package/dist/curator/curator.js.map +1 -1
- package/dist/index.d.ts +14 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +12 -1
- package/dist/index.js.map +1 -1
- package/dist/llm/llm-client.d.ts.map +1 -1
- package/dist/llm/llm-client.js +1 -0
- package/dist/llm/llm-client.js.map +1 -1
- package/dist/packs/index.d.ts +3 -2
- package/dist/packs/index.d.ts.map +1 -1
- package/dist/packs/index.js +3 -2
- package/dist/packs/index.js.map +1 -1
- package/dist/packs/lockfile.d.ts +23 -1
- package/dist/packs/lockfile.d.ts.map +1 -1
- package/dist/packs/lockfile.js +50 -4
- package/dist/packs/lockfile.js.map +1 -1
- package/dist/packs/pack-installer.d.ts +10 -0
- package/dist/packs/pack-installer.d.ts.map +1 -1
- package/dist/packs/pack-installer.js +69 -2
- package/dist/packs/pack-installer.js.map +1 -1
- package/dist/packs/pack-lifecycle.d.ts +50 -0
- package/dist/packs/pack-lifecycle.d.ts.map +1 -0
- package/dist/packs/pack-lifecycle.js +91 -0
- package/dist/packs/pack-lifecycle.js.map +1 -0
- package/dist/packs/types.d.ts +64 -44
- package/dist/packs/types.d.ts.map +1 -1
- package/dist/packs/types.js +9 -0
- package/dist/packs/types.js.map +1 -1
- package/dist/persistence/sqlite-provider.d.ts +5 -1
- package/dist/persistence/sqlite-provider.d.ts.map +1 -1
- package/dist/persistence/sqlite-provider.js +22 -2
- package/dist/persistence/sqlite-provider.js.map +1 -1
- package/dist/planning/github-projection.d.ts +8 -8
- package/dist/planning/github-projection.d.ts.map +1 -1
- package/dist/planning/github-projection.js +42 -42
- package/dist/planning/github-projection.js.map +1 -1
- package/dist/plugins/types.d.ts +21 -21
- package/dist/queue/pipeline-runner.d.ts.map +1 -1
- package/dist/queue/pipeline-runner.js +4 -0
- package/dist/queue/pipeline-runner.js.map +1 -1
- package/dist/runtime/curator-extra-ops.d.ts.map +1 -1
- package/dist/runtime/curator-extra-ops.js +9 -1
- package/dist/runtime/curator-extra-ops.js.map +1 -1
- package/dist/runtime/facades/memory-facade.d.ts.map +1 -1
- package/dist/runtime/facades/memory-facade.js +169 -0
- package/dist/runtime/facades/memory-facade.js.map +1 -1
- package/dist/runtime/orchestrate-ops.d.ts.map +1 -1
- package/dist/runtime/orchestrate-ops.js +133 -4
- package/dist/runtime/orchestrate-ops.js.map +1 -1
- package/dist/runtime/runtime.d.ts.map +1 -1
- package/dist/runtime/runtime.js +128 -90
- package/dist/runtime/runtime.js.map +1 -1
- package/dist/runtime/session-briefing.d.ts.map +1 -1
- package/dist/runtime/session-briefing.js +44 -11
- package/dist/runtime/session-briefing.js.map +1 -1
- package/dist/runtime/shutdown-registry.d.ts +36 -0
- package/dist/runtime/shutdown-registry.d.ts.map +1 -0
- package/dist/runtime/shutdown-registry.js +74 -0
- package/dist/runtime/shutdown-registry.js.map +1 -0
- package/dist/runtime/types.d.ts +10 -1
- package/dist/runtime/types.d.ts.map +1 -1
- package/dist/subagent/concurrency-manager.d.ts +29 -0
- package/dist/subagent/concurrency-manager.d.ts.map +1 -0
- package/dist/subagent/concurrency-manager.js +73 -0
- package/dist/subagent/concurrency-manager.js.map +1 -0
- package/dist/subagent/dispatcher.d.ts +41 -0
- package/dist/subagent/dispatcher.d.ts.map +1 -0
- package/dist/subagent/dispatcher.js +259 -0
- package/dist/subagent/dispatcher.js.map +1 -0
- package/dist/subagent/index.d.ts +14 -0
- package/dist/subagent/index.d.ts.map +1 -0
- package/dist/subagent/index.js +15 -0
- package/dist/subagent/index.js.map +1 -0
- package/dist/subagent/orphan-reaper.d.ts +37 -0
- package/dist/subagent/orphan-reaper.d.ts.map +1 -0
- package/dist/subagent/orphan-reaper.js +71 -0
- package/dist/subagent/orphan-reaper.js.map +1 -0
- package/dist/subagent/result-aggregator.d.ts +7 -0
- package/dist/subagent/result-aggregator.d.ts.map +1 -0
- package/dist/subagent/result-aggregator.js +57 -0
- package/dist/subagent/result-aggregator.js.map +1 -0
- package/dist/subagent/task-checkout.d.ts +36 -0
- package/dist/subagent/task-checkout.d.ts.map +1 -0
- package/dist/subagent/task-checkout.js +52 -0
- package/dist/subagent/task-checkout.js.map +1 -0
- package/dist/subagent/types.d.ts +114 -0
- package/dist/subagent/types.d.ts.map +1 -0
- package/dist/subagent/types.js +9 -0
- package/dist/subagent/types.js.map +1 -0
- package/dist/subagent/workspace-resolver.d.ts +35 -0
- package/dist/subagent/workspace-resolver.d.ts.map +1 -0
- package/dist/subagent/workspace-resolver.js +99 -0
- package/dist/subagent/workspace-resolver.js.map +1 -0
- package/dist/transport/http-server.d.ts.map +1 -1
- package/dist/transport/http-server.js +49 -3
- package/dist/transport/http-server.js.map +1 -1
- package/dist/transport/ws-server.d.ts.map +1 -1
- package/dist/transport/ws-server.js +7 -0
- package/dist/transport/ws-server.js.map +1 -1
- package/dist/vault/linking.d.ts +3 -4
- package/dist/vault/linking.d.ts.map +1 -1
- package/dist/vault/linking.js +79 -32
- package/dist/vault/linking.js.map +1 -1
- package/dist/vault/vault-maintenance.d.ts.map +1 -1
- package/dist/vault/vault-maintenance.js +7 -14
- package/dist/vault/vault-maintenance.js.map +1 -1
- package/dist/vault/vault-memories.d.ts.map +1 -1
- package/dist/vault/vault-memories.js +19 -9
- package/dist/vault/vault-memories.js.map +1 -1
- package/dist/vault/vault-schema.d.ts +1 -0
- package/dist/vault/vault-schema.d.ts.map +1 -1
- package/dist/vault/vault-schema.js +20 -0
- package/dist/vault/vault-schema.js.map +1 -1
- package/dist/vault/vault.d.ts.map +1 -1
- package/dist/vault/vault.js +7 -3
- package/dist/vault/vault.js.map +1 -1
- package/package.json +8 -2
- package/src/__tests__/adapters/claude-code-adapter.test.ts +167 -0
- package/src/__tests__/adapters/registry.test.ts +100 -0
- package/src/__tests__/packs/pack-lifecycle.test.ts +379 -0
- package/src/__tests__/subagent/concurrency-manager.test.ts +132 -0
- package/src/__tests__/subagent/dispatcher.test.ts +195 -0
- package/src/__tests__/subagent/orphan-reaper.test.ts +141 -0
- package/src/__tests__/subagent/result-aggregator.test.ts +141 -0
- package/src/__tests__/subagent/task-checkout.test.ts +86 -0
- package/src/__tests__/subagent/workspace-resolver.test.ts +138 -0
- package/src/adapters/claude-code-adapter.ts +163 -0
- package/src/adapters/index.ts +22 -0
- package/src/adapters/registry.ts +53 -0
- package/src/adapters/types.ts +114 -0
- package/src/curator/curator.ts +1 -0
- package/src/index.ts +38 -1
- package/src/packs/index.ts +5 -1
- package/src/packs/lockfile.ts +70 -5
- package/src/packs/pack-installer.ts +78 -2
- package/src/packs/pack-lifecycle.ts +115 -0
- package/src/packs/pack-lockfile.test.ts +1 -1
- package/src/packs/pack-system.test.ts +1 -1
- package/src/packs/types.ts +40 -2
- package/src/persistence/sqlite-provider.ts +26 -2
- package/src/runtime/admin-setup-ops.test.ts +9 -4
- package/src/runtime/orchestrate-ops.ts +153 -1
- package/src/runtime/runtime.ts +15 -0
- package/src/runtime/session-briefing.test.ts +94 -2
- package/src/runtime/session-briefing.ts +48 -12
- package/src/runtime/types.ts +6 -0
- package/src/subagent/concurrency-manager.ts +89 -0
- package/src/subagent/dispatcher.ts +326 -0
- package/src/subagent/index.ts +28 -0
- package/src/subagent/orphan-reaper.ts +82 -0
- package/src/subagent/result-aggregator.ts +66 -0
- package/src/subagent/task-checkout.ts +60 -0
- package/src/subagent/types.ts +138 -0
- package/src/subagent/workspace-resolver.ts +117 -0
- package/src/vault/vault-scaling.test.ts +3 -2
- package/vitest.config.ts +2 -0
- package/src/hooks/index.ts +0 -6
|
@@ -17,6 +17,14 @@ function makeRuntime(overrides?: {
|
|
|
17
17
|
toolsUsed: string[];
|
|
18
18
|
filesModified: string[];
|
|
19
19
|
}>;
|
|
20
|
+
memories?: Array<{
|
|
21
|
+
id?: string;
|
|
22
|
+
createdAt: number;
|
|
23
|
+
projectPath?: string;
|
|
24
|
+
summary?: string;
|
|
25
|
+
context?: string;
|
|
26
|
+
type?: string;
|
|
27
|
+
}>;
|
|
20
28
|
plans?: Array<{
|
|
21
29
|
id: string;
|
|
22
30
|
status: string;
|
|
@@ -45,6 +53,7 @@ function makeRuntime(overrides?: {
|
|
|
45
53
|
vault: {
|
|
46
54
|
stats: () => o.vaultStats ?? { totalEntries: 50, byType: { playbook: 5 } },
|
|
47
55
|
getRecent: (_n: number) => o.recentEntries ?? [],
|
|
56
|
+
listMemories: () => o.memories ?? [],
|
|
48
57
|
},
|
|
49
58
|
curator: {
|
|
50
59
|
healthAudit: () => ({
|
|
@@ -95,11 +104,34 @@ describe('session-briefing', () => {
|
|
|
95
104
|
expect(data.sections.find((s) => s.label === 'Welcome')).toBeUndefined();
|
|
96
105
|
});
|
|
97
106
|
|
|
98
|
-
it('includes Last session
|
|
107
|
+
it('includes Last session from cross-project memories when fresh', async () => {
|
|
108
|
+
const runtime = makeRuntime({
|
|
109
|
+
memories: [
|
|
110
|
+
{
|
|
111
|
+
id: 'mem-1',
|
|
112
|
+
createdAt: Date.now() - 600_000, // 10 min ago (ms)
|
|
113
|
+
projectPath: '/Users/me/projects/other-app',
|
|
114
|
+
summary: 'Fixed KPI card layout in the dashboard',
|
|
115
|
+
type: 'session',
|
|
116
|
+
},
|
|
117
|
+
],
|
|
118
|
+
});
|
|
119
|
+
const ops = captureOps(createSessionBriefingOps(runtime));
|
|
120
|
+
const res = await executeOp(ops, 'session_briefing', {});
|
|
121
|
+
|
|
122
|
+
const data = res.data as { sections: Array<{ label: string; content: string }> };
|
|
123
|
+
const session = data.sections.find((s) => s.label === 'Last session');
|
|
124
|
+
expect(session).toBeDefined();
|
|
125
|
+
expect(session!.content).toContain('other-app');
|
|
126
|
+
expect(session!.content).toContain('Fixed KPI card layout');
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('falls back to brain sessions when no fresh memories exist', async () => {
|
|
99
130
|
const runtime = makeRuntime({
|
|
131
|
+
memories: [], // no memories
|
|
100
132
|
sessions: [
|
|
101
133
|
{
|
|
102
|
-
endedAt: new Date(Date.now() -
|
|
134
|
+
endedAt: new Date(Date.now() - 3600_000).toISOString(), // 1h ago
|
|
103
135
|
domain: 'frontend',
|
|
104
136
|
context: 'Refactored button component',
|
|
105
137
|
toolsUsed: ['vault_search', 'brain_recommend'],
|
|
@@ -117,6 +149,63 @@ describe('session-briefing', () => {
|
|
|
117
149
|
expect(session!.content).toContain('Refactored button component');
|
|
118
150
|
});
|
|
119
151
|
|
|
152
|
+
it('skips Last session when all sessions are stale', async () => {
|
|
153
|
+
const staleTs = Date.now() - 72 * 3600_000; // 72h ago — beyond default 48h
|
|
154
|
+
const runtime = makeRuntime({
|
|
155
|
+
memories: [
|
|
156
|
+
{
|
|
157
|
+
id: 'mem-old',
|
|
158
|
+
createdAt: staleTs,
|
|
159
|
+
projectPath: '/old-project',
|
|
160
|
+
summary: 'Ancient session',
|
|
161
|
+
type: 'session',
|
|
162
|
+
},
|
|
163
|
+
],
|
|
164
|
+
sessions: [
|
|
165
|
+
{
|
|
166
|
+
endedAt: new Date(staleTs).toISOString(),
|
|
167
|
+
domain: 'old',
|
|
168
|
+
context: 'Ancient brain session',
|
|
169
|
+
toolsUsed: [],
|
|
170
|
+
filesModified: [],
|
|
171
|
+
},
|
|
172
|
+
],
|
|
173
|
+
});
|
|
174
|
+
const ops = captureOps(createSessionBriefingOps(runtime));
|
|
175
|
+
const res = await executeOp(ops, 'session_briefing', {});
|
|
176
|
+
|
|
177
|
+
const data = res.data as { sections: Array<{ label: string; content: string }> };
|
|
178
|
+
const session = data.sections.find((s) => s.label === 'Last session');
|
|
179
|
+
expect(session).toBeUndefined();
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('respects custom recencyHours parameter', async () => {
|
|
183
|
+
const runtime = makeRuntime({
|
|
184
|
+
memories: [
|
|
185
|
+
{
|
|
186
|
+
id: 'mem-3h',
|
|
187
|
+
createdAt: Date.now() - 3 * 3600_000, // 3h ago
|
|
188
|
+
projectPath: '/recent-project',
|
|
189
|
+
summary: 'Recent work',
|
|
190
|
+
type: 'session',
|
|
191
|
+
},
|
|
192
|
+
],
|
|
193
|
+
});
|
|
194
|
+
const ops = captureOps(createSessionBriefingOps(runtime));
|
|
195
|
+
|
|
196
|
+
// With 1h window — should skip
|
|
197
|
+
const narrow = await executeOp(ops, 'session_briefing', { recencyHours: 1 });
|
|
198
|
+
const narrowData = narrow.data as { sections: Array<{ label: string }> };
|
|
199
|
+
expect(narrowData.sections.find((s) => s.label === 'Last session')).toBeUndefined();
|
|
200
|
+
|
|
201
|
+
// With 4h window — should include
|
|
202
|
+
const wide = await executeOp(ops, 'session_briefing', { recencyHours: 4 });
|
|
203
|
+
const wideData = wide.data as { sections: Array<{ label: string; content: string }> };
|
|
204
|
+
const session = wideData.sections.find((s) => s.label === 'Last session');
|
|
205
|
+
expect(session).toBeDefined();
|
|
206
|
+
expect(session!.content).toContain('Recent work');
|
|
207
|
+
});
|
|
208
|
+
|
|
120
209
|
it('includes Active plans section', async () => {
|
|
121
210
|
const runtime = makeRuntime({
|
|
122
211
|
plans: [
|
|
@@ -234,6 +323,9 @@ describe('session-briefing', () => {
|
|
|
234
323
|
getRecent: () => {
|
|
235
324
|
throw new Error('no vault');
|
|
236
325
|
},
|
|
326
|
+
listMemories: () => {
|
|
327
|
+
throw new Error('no vault');
|
|
328
|
+
},
|
|
237
329
|
},
|
|
238
330
|
curator: {
|
|
239
331
|
healthAudit: () => {
|
|
@@ -57,22 +57,58 @@ export function createSessionBriefingOps(runtime: AgentRuntime): OpDefinition[]
|
|
|
57
57
|
// Vault stats unavailable — skip
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
-
// 1. Last session
|
|
60
|
+
// 1. Last session — cross-project, with staleness threshold
|
|
61
61
|
try {
|
|
62
|
-
const
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
const
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
62
|
+
const recencyMs = (params.recencyHours as number) * 3600_000;
|
|
63
|
+
const cutoff = Date.now() - recencyMs;
|
|
64
|
+
|
|
65
|
+
// Try cross-project memories first (most recent work, any project)
|
|
66
|
+
const recentMemories = vault.listMemories({ type: 'session', limit: 3 });
|
|
67
|
+
const freshMemory = recentMemories.find((m) => {
|
|
68
|
+
const ts = m.createdAt > 1e12 ? m.createdAt : m.createdAt * 1000;
|
|
69
|
+
return ts > cutoff;
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
if (freshMemory) {
|
|
73
|
+
const ts =
|
|
74
|
+
freshMemory.createdAt > 1e12 ? freshMemory.createdAt : freshMemory.createdAt * 1000;
|
|
75
|
+
const ago = formatTimeAgo(ts);
|
|
76
|
+
const project = freshMemory.projectPath
|
|
77
|
+
? ` [${freshMemory.projectPath.split('/').pop()}]`
|
|
78
|
+
: '';
|
|
79
|
+
const summary = freshMemory.summary
|
|
80
|
+
? `: ${freshMemory.summary.slice(0, 100)}`
|
|
81
|
+
: freshMemory.context
|
|
82
|
+
? `: ${freshMemory.context.slice(0, 100)}`
|
|
83
|
+
: '';
|
|
84
|
+
dataPoints += recentMemories.length;
|
|
72
85
|
sections.push({
|
|
73
86
|
label: 'Last session',
|
|
74
|
-
content: `(${ago})${
|
|
87
|
+
content: `(${ago})${project}${summary}`,
|
|
75
88
|
});
|
|
89
|
+
} else {
|
|
90
|
+
// Fall back to brain sessions (same-project only) if no fresh memory
|
|
91
|
+
const sessions = brainIntelligence.listSessions({ limit: 1, active: false });
|
|
92
|
+
dataPoints += sessions.length;
|
|
93
|
+
if (sessions.length > 0) {
|
|
94
|
+
const last = sessions[0];
|
|
95
|
+
const endTs = last.endedAt ? new Date(last.endedAt).getTime() : 0;
|
|
96
|
+
if (endTs > cutoff) {
|
|
97
|
+
const ago = formatTimeAgo(endTs);
|
|
98
|
+
const domain = last.domain ? ` [${last.domain}]` : '';
|
|
99
|
+
const context = last.context ? `: ${last.context.slice(0, 80)}` : '';
|
|
100
|
+
const tools =
|
|
101
|
+
last.toolsUsed.length > 0 ? `, used ${last.toolsUsed.length} tools` : '';
|
|
102
|
+
const files =
|
|
103
|
+
last.filesModified.length > 0
|
|
104
|
+
? `, modified ${last.filesModified.length} files`
|
|
105
|
+
: '';
|
|
106
|
+
sections.push({
|
|
107
|
+
label: 'Last session',
|
|
108
|
+
content: `(${ago})${domain}${context}${tools}${files}`,
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
}
|
|
76
112
|
}
|
|
77
113
|
} catch {
|
|
78
114
|
// Session data unavailable — skip
|
package/src/runtime/types.ts
CHANGED
|
@@ -37,6 +37,8 @@ import type { OperatorProfileStore } from '../operator/operator-profile.js';
|
|
|
37
37
|
import type { OperatorContextStore } from '../operator/operator-context-store.js';
|
|
38
38
|
import type { ContextHealthMonitor } from './context-health.js';
|
|
39
39
|
import type { ShutdownRegistry } from './shutdown-registry.js';
|
|
40
|
+
import type { RuntimeAdapterRegistry } from '../adapters/registry.js';
|
|
41
|
+
import type { SubagentDispatcher } from '../subagent/dispatcher.js';
|
|
40
42
|
|
|
41
43
|
/**
|
|
42
44
|
* Configuration for creating an agent runtime.
|
|
@@ -129,6 +131,10 @@ export interface AgentRuntime {
|
|
|
129
131
|
persona: import('../persona/types.js').PersonaConfig;
|
|
130
132
|
/** Generated persona system instructions for LLM context. */
|
|
131
133
|
personaInstructions: import('../persona/types.js').PersonaSystemInstructions;
|
|
134
|
+
/** Runtime adapter registry — dispatch work to different AI CLIs. */
|
|
135
|
+
adapterRegistry: RuntimeAdapterRegistry;
|
|
136
|
+
/** Subagent dispatcher — spawn and manage child agent processes. */
|
|
137
|
+
subagentDispatcher: SubagentDispatcher;
|
|
132
138
|
/** Context health monitor — tracks tool call volume and context window fill. */
|
|
133
139
|
contextHealth: ContextHealthMonitor;
|
|
134
140
|
/** Shutdown registry — centralized cleanup for timers, watchers, child processes. */
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-agent-type semaphore for controlling concurrent subagent runs.
|
|
3
|
+
*
|
|
4
|
+
* Pure promise-based — zero external deps. FIFO ordering guarantees
|
|
5
|
+
* the first waiter is the first to acquire a slot when one frees up.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
interface TypeState {
|
|
9
|
+
active: number;
|
|
10
|
+
waiters: Array<() => void>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const DEFAULT_MAX_CONCURRENT = 3;
|
|
14
|
+
|
|
15
|
+
export class ConcurrencyManager {
|
|
16
|
+
private state: Map<string, TypeState> = new Map();
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Acquire a concurrency slot for the given adapter type.
|
|
20
|
+
* Resolves immediately if a slot is available, otherwise queues
|
|
21
|
+
* and resolves when a slot frees up (FIFO).
|
|
22
|
+
*/
|
|
23
|
+
async acquire(type: string, maxConcurrent: number = DEFAULT_MAX_CONCURRENT): Promise<void> {
|
|
24
|
+
const entry = this.getOrCreate(type);
|
|
25
|
+
|
|
26
|
+
if (entry.active < maxConcurrent) {
|
|
27
|
+
entry.active++;
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// At capacity — park until a slot opens.
|
|
32
|
+
return new Promise<void>((resolve) => {
|
|
33
|
+
entry.waiters.push(() => {
|
|
34
|
+
entry.active++;
|
|
35
|
+
resolve();
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Release a concurrency slot for the given adapter type.
|
|
42
|
+
* If waiters exist, the first one (FIFO) is unblocked.
|
|
43
|
+
* No-op if the type is not tracked.
|
|
44
|
+
*/
|
|
45
|
+
release(type: string): void {
|
|
46
|
+
const entry = this.state.get(type);
|
|
47
|
+
if (!entry) return;
|
|
48
|
+
|
|
49
|
+
entry.active = Math.max(0, entry.active - 1);
|
|
50
|
+
|
|
51
|
+
if (entry.waiters.length > 0) {
|
|
52
|
+
const next = entry.waiters.shift()!;
|
|
53
|
+
next();
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Return the number of active slots for a type (0 if untracked). */
|
|
58
|
+
getActive(type: string): number {
|
|
59
|
+
return this.state.get(type)?.active ?? 0;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Return the number of waiters queued for a type (0 if untracked). */
|
|
63
|
+
getWaiting(type: string): number {
|
|
64
|
+
return this.state.get(type)?.waiters.length ?? 0;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Clear all state, resolving any pending waiters immediately. */
|
|
68
|
+
reset(): void {
|
|
69
|
+
for (const entry of this.state.values()) {
|
|
70
|
+
for (const waiter of entry.waiters) {
|
|
71
|
+
waiter();
|
|
72
|
+
}
|
|
73
|
+
entry.waiters.length = 0;
|
|
74
|
+
entry.active = 0;
|
|
75
|
+
}
|
|
76
|
+
this.state.clear();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ── internal ──────────────────────────────────────────────────────
|
|
80
|
+
|
|
81
|
+
private getOrCreate(type: string): TypeState {
|
|
82
|
+
let entry = this.state.get(type);
|
|
83
|
+
if (!entry) {
|
|
84
|
+
entry = { active: 0, waiters: [] };
|
|
85
|
+
this.state.set(type, entry);
|
|
86
|
+
}
|
|
87
|
+
return entry;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SubagentDispatcher — the core orchestrator for subagent execution.
|
|
3
|
+
*
|
|
4
|
+
* Composes: TaskCheckout, WorkspaceResolver, ConcurrencyManager,
|
|
5
|
+
* OrphanReaper, and RuntimeAdapterRegistry to dispatch tasks to
|
|
6
|
+
* child agent processes.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { RuntimeAdapterRegistry } from '../adapters/registry.js';
|
|
10
|
+
import type {
|
|
11
|
+
SubagentTask,
|
|
12
|
+
SubagentResult,
|
|
13
|
+
SubagentStatus,
|
|
14
|
+
DispatchOptions,
|
|
15
|
+
AggregatedResult,
|
|
16
|
+
} from './types.js';
|
|
17
|
+
import { TaskCheckout } from './task-checkout.js';
|
|
18
|
+
import { WorkspaceResolver } from './workspace-resolver.js';
|
|
19
|
+
import { ConcurrencyManager } from './concurrency-manager.js';
|
|
20
|
+
import { OrphanReaper } from './orphan-reaper.js';
|
|
21
|
+
import { aggregate } from './result-aggregator.js';
|
|
22
|
+
|
|
23
|
+
const DEFAULT_TIMEOUT = 300_000; // 5 minutes
|
|
24
|
+
const DEFAULT_MAX_CONCURRENT = 3;
|
|
25
|
+
|
|
26
|
+
export interface SubagentDispatcherConfig {
|
|
27
|
+
/** RuntimeAdapterRegistry for looking up adapters by type */
|
|
28
|
+
adapterRegistry: RuntimeAdapterRegistry;
|
|
29
|
+
/** Base directory for git worktree isolation */
|
|
30
|
+
baseDir?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export class SubagentDispatcher {
|
|
34
|
+
private readonly checkout = new TaskCheckout();
|
|
35
|
+
private readonly workspace: WorkspaceResolver;
|
|
36
|
+
private readonly concurrency = new ConcurrencyManager();
|
|
37
|
+
private readonly reaper: OrphanReaper;
|
|
38
|
+
private readonly adapterRegistry: RuntimeAdapterRegistry;
|
|
39
|
+
|
|
40
|
+
constructor(config: SubagentDispatcherConfig) {
|
|
41
|
+
this.adapterRegistry = config.adapterRegistry;
|
|
42
|
+
this.workspace = new WorkspaceResolver(config.baseDir ?? process.cwd());
|
|
43
|
+
this.reaper = new OrphanReaper((taskId) => {
|
|
44
|
+
// On orphan: release the task claim and clean up workspace
|
|
45
|
+
this.checkout.release(taskId);
|
|
46
|
+
this.workspace.cleanup(taskId);
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Dispatch one or more tasks to subagents.
|
|
52
|
+
*
|
|
53
|
+
* Tasks run in parallel by default (controlled by options.parallel).
|
|
54
|
+
* Each task goes through: claim → resolve workspace → acquire slot →
|
|
55
|
+
* execute via adapter → collect result.
|
|
56
|
+
*/
|
|
57
|
+
async dispatch(tasks: SubagentTask[], options: DispatchOptions = {}): Promise<AggregatedResult> {
|
|
58
|
+
const {
|
|
59
|
+
parallel = true,
|
|
60
|
+
maxConcurrent = DEFAULT_MAX_CONCURRENT,
|
|
61
|
+
worktreeIsolation = false,
|
|
62
|
+
timeout = DEFAULT_TIMEOUT,
|
|
63
|
+
onTaskUpdate,
|
|
64
|
+
} = options;
|
|
65
|
+
|
|
66
|
+
if (tasks.length === 0) {
|
|
67
|
+
return aggregate([]);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Resolve dependency order
|
|
71
|
+
const ordered = this.resolveDependencies(tasks);
|
|
72
|
+
|
|
73
|
+
if (parallel) {
|
|
74
|
+
// Run independent tasks in parallel, respecting dependencies
|
|
75
|
+
const results = await this.dispatchParallel(ordered, {
|
|
76
|
+
maxConcurrent,
|
|
77
|
+
worktreeIsolation,
|
|
78
|
+
timeout,
|
|
79
|
+
onTaskUpdate,
|
|
80
|
+
});
|
|
81
|
+
return aggregate(results);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Sequential dispatch — await in loop is intentional (tasks must run one at a time)
|
|
85
|
+
const results: SubagentResult[] = [];
|
|
86
|
+
for (const task of ordered) {
|
|
87
|
+
// eslint-disable-line no-await-in-loop
|
|
88
|
+
onTaskUpdate?.(task.taskId, 'running');
|
|
89
|
+
const result = await this.executeTask(task, worktreeIsolation, timeout);
|
|
90
|
+
results.push(result);
|
|
91
|
+
onTaskUpdate?.(task.taskId, result.status);
|
|
92
|
+
|
|
93
|
+
// Stop on failure in sequential mode
|
|
94
|
+
if (result.exitCode !== 0) break;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return aggregate(results);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Clean up all resources (worktrees, claims, concurrency) */
|
|
101
|
+
cleanup(): void {
|
|
102
|
+
this.workspace.cleanupAll();
|
|
103
|
+
this.checkout.releaseAll();
|
|
104
|
+
this.concurrency.reset();
|
|
105
|
+
this.reaper.clear();
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Run orphan detection and cleanup */
|
|
109
|
+
reapOrphans(): SubagentResult[] {
|
|
110
|
+
const orphaned = this.reaper.reap();
|
|
111
|
+
return orphaned.map((p) => ({
|
|
112
|
+
taskId: p.taskId,
|
|
113
|
+
status: 'orphaned' as const,
|
|
114
|
+
exitCode: 1,
|
|
115
|
+
error: `Process ${p.pid} died unexpectedly`,
|
|
116
|
+
durationMs: Date.now() - p.registeredAt,
|
|
117
|
+
pid: p.pid,
|
|
118
|
+
}));
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ── Internal ──────────────────────────────────────────────────────
|
|
122
|
+
|
|
123
|
+
private async dispatchParallel(
|
|
124
|
+
tasks: SubagentTask[],
|
|
125
|
+
opts: {
|
|
126
|
+
maxConcurrent: number;
|
|
127
|
+
worktreeIsolation: boolean;
|
|
128
|
+
timeout: number;
|
|
129
|
+
onTaskUpdate?: (taskId: string, status: SubagentStatus) => void;
|
|
130
|
+
},
|
|
131
|
+
): Promise<SubagentResult[]> {
|
|
132
|
+
const results = new Map<string, SubagentResult>();
|
|
133
|
+
const pending = new Map<string, SubagentTask>();
|
|
134
|
+
const completed = new Set<string>();
|
|
135
|
+
|
|
136
|
+
// Initialize all tasks as pending
|
|
137
|
+
for (const task of tasks) {
|
|
138
|
+
pending.set(task.taskId, task);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Process in waves until all done
|
|
142
|
+
while (pending.size > 0) {
|
|
143
|
+
// Find tasks whose dependencies are all completed
|
|
144
|
+
const ready: SubagentTask[] = [];
|
|
145
|
+
for (const [_id, task] of pending) {
|
|
146
|
+
const deps = task.dependencies ?? [];
|
|
147
|
+
if (deps.every((d) => completed.has(d))) {
|
|
148
|
+
ready.push(task);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (ready.length === 0 && pending.size > 0) {
|
|
153
|
+
// Deadlock — remaining tasks have unmet dependencies
|
|
154
|
+
for (const [deadId, task] of pending) {
|
|
155
|
+
results.set(deadId, {
|
|
156
|
+
taskId: deadId,
|
|
157
|
+
status: 'failed',
|
|
158
|
+
exitCode: 1,
|
|
159
|
+
error: `Unresolvable dependencies: ${(task.dependencies ?? []).filter((d) => !completed.has(d)).join(', ')}`,
|
|
160
|
+
durationMs: 0,
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
break;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Dispatch ready tasks in parallel with concurrency control
|
|
167
|
+
// eslint-disable-next-line no-await-in-loop -- waves must complete before next wave
|
|
168
|
+
const waveResults = await Promise.allSettled(
|
|
169
|
+
ready.map(async (task) => {
|
|
170
|
+
opts.onTaskUpdate?.(task.taskId, 'running');
|
|
171
|
+
await this.concurrency.acquire(task.runtime ?? 'default', opts.maxConcurrent);
|
|
172
|
+
try {
|
|
173
|
+
return await this.executeTask(
|
|
174
|
+
task,
|
|
175
|
+
opts.worktreeIsolation,
|
|
176
|
+
task.timeout ?? opts.timeout,
|
|
177
|
+
);
|
|
178
|
+
} finally {
|
|
179
|
+
this.concurrency.release(task.runtime ?? 'default');
|
|
180
|
+
}
|
|
181
|
+
}),
|
|
182
|
+
);
|
|
183
|
+
|
|
184
|
+
// Collect results
|
|
185
|
+
for (let i = 0; i < ready.length; i++) {
|
|
186
|
+
const task = ready[i];
|
|
187
|
+
const settled = waveResults[i];
|
|
188
|
+
const result: SubagentResult =
|
|
189
|
+
settled.status === 'fulfilled'
|
|
190
|
+
? settled.value
|
|
191
|
+
: {
|
|
192
|
+
taskId: task.taskId,
|
|
193
|
+
status: 'failed',
|
|
194
|
+
exitCode: 1,
|
|
195
|
+
error: settled.reason?.message ?? 'Unknown error',
|
|
196
|
+
durationMs: 0,
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
results.set(task.taskId, result);
|
|
200
|
+
completed.add(task.taskId);
|
|
201
|
+
pending.delete(task.taskId);
|
|
202
|
+
opts.onTaskUpdate?.(task.taskId, result.status);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Return in original task order
|
|
207
|
+
return tasks.map((t) => results.get(t.taskId)!);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
private async executeTask(
|
|
211
|
+
task: SubagentTask,
|
|
212
|
+
worktreeIsolation: boolean,
|
|
213
|
+
timeout: number,
|
|
214
|
+
): Promise<SubagentResult> {
|
|
215
|
+
const startTime = Date.now();
|
|
216
|
+
|
|
217
|
+
// 1. Claim the task
|
|
218
|
+
const claimed = this.checkout.claim(task.taskId, 'dispatcher');
|
|
219
|
+
if (!claimed) {
|
|
220
|
+
return {
|
|
221
|
+
taskId: task.taskId,
|
|
222
|
+
status: 'failed',
|
|
223
|
+
exitCode: 1,
|
|
224
|
+
error: 'Task already claimed by another process',
|
|
225
|
+
durationMs: Date.now() - startTime,
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// 2. Resolve workspace
|
|
230
|
+
const workspace = this.workspace.resolve(task.taskId, task.workspace, worktreeIsolation);
|
|
231
|
+
|
|
232
|
+
// 3. Get adapter
|
|
233
|
+
const adapterType = task.runtime ?? this.getDefaultAdapterType();
|
|
234
|
+
let adapter;
|
|
235
|
+
try {
|
|
236
|
+
adapter = this.adapterRegistry.get(adapterType);
|
|
237
|
+
} catch {
|
|
238
|
+
this.checkout.release(task.taskId);
|
|
239
|
+
return {
|
|
240
|
+
taskId: task.taskId,
|
|
241
|
+
status: 'failed',
|
|
242
|
+
exitCode: 1,
|
|
243
|
+
error: `Adapter '${adapterType}' not found in registry`,
|
|
244
|
+
durationMs: Date.now() - startTime,
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// 4. Execute with timeout
|
|
249
|
+
try {
|
|
250
|
+
const resultPromise = adapter.execute({
|
|
251
|
+
runId: `subagent-${task.taskId}-${Date.now()}`,
|
|
252
|
+
prompt: task.prompt,
|
|
253
|
+
workspace,
|
|
254
|
+
config: { ...task.config, timeout },
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
const timeoutPromise = new Promise<never>((_, reject) => {
|
|
258
|
+
setTimeout(() => reject(new Error('Task timed out')), timeout);
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
const adapterResult = await Promise.race([resultPromise, timeoutPromise]);
|
|
262
|
+
|
|
263
|
+
return {
|
|
264
|
+
taskId: task.taskId,
|
|
265
|
+
status: adapterResult.exitCode === 0 ? 'completed' : 'failed',
|
|
266
|
+
exitCode: adapterResult.exitCode,
|
|
267
|
+
summary: adapterResult.summary,
|
|
268
|
+
usage: adapterResult.usage,
|
|
269
|
+
sessionState: adapterResult.sessionState,
|
|
270
|
+
durationMs: Date.now() - startTime,
|
|
271
|
+
};
|
|
272
|
+
} catch (err) {
|
|
273
|
+
return {
|
|
274
|
+
taskId: task.taskId,
|
|
275
|
+
status: 'failed',
|
|
276
|
+
exitCode: 1,
|
|
277
|
+
error: err instanceof Error ? err.message : String(err),
|
|
278
|
+
durationMs: Date.now() - startTime,
|
|
279
|
+
};
|
|
280
|
+
} finally {
|
|
281
|
+
// Cleanup
|
|
282
|
+
this.checkout.release(task.taskId);
|
|
283
|
+
if (worktreeIsolation) {
|
|
284
|
+
this.workspace.cleanup(task.taskId);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** Topological sort by dependencies (stable — preserves input order for equal deps) */
|
|
290
|
+
private resolveDependencies(tasks: SubagentTask[]): SubagentTask[] {
|
|
291
|
+
const taskMap = new Map(tasks.map((t) => [t.taskId, t]));
|
|
292
|
+
const sorted: SubagentTask[] = [];
|
|
293
|
+
const visited = new Set<string>();
|
|
294
|
+
const visiting = new Set<string>();
|
|
295
|
+
|
|
296
|
+
const visit = (id: string) => {
|
|
297
|
+
if (visited.has(id)) return;
|
|
298
|
+
if (visiting.has(id)) return; // cycle — skip
|
|
299
|
+
visiting.add(id);
|
|
300
|
+
|
|
301
|
+
const task = taskMap.get(id);
|
|
302
|
+
if (task) {
|
|
303
|
+
for (const dep of task.dependencies ?? []) {
|
|
304
|
+
visit(dep);
|
|
305
|
+
}
|
|
306
|
+
visited.add(id);
|
|
307
|
+
visiting.delete(id);
|
|
308
|
+
sorted.push(task);
|
|
309
|
+
}
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
for (const task of tasks) {
|
|
313
|
+
visit(task.taskId);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
return sorted;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
private getDefaultAdapterType(): string {
|
|
320
|
+
try {
|
|
321
|
+
return this.adapterRegistry.getDefault().type;
|
|
322
|
+
} catch {
|
|
323
|
+
return 'claude-code';
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Subagent runtime engine — spawn, manage, and aggregate results
|
|
3
|
+
* from child agent processes.
|
|
4
|
+
*
|
|
5
|
+
* @module subagent
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
// Types
|
|
9
|
+
export type {
|
|
10
|
+
SubagentTask,
|
|
11
|
+
SubagentStatus,
|
|
12
|
+
SubagentResult,
|
|
13
|
+
DispatchOptions,
|
|
14
|
+
AggregatedResult,
|
|
15
|
+
ClaimInfo,
|
|
16
|
+
WorktreeInfo,
|
|
17
|
+
TrackedProcess,
|
|
18
|
+
} from './types.js';
|
|
19
|
+
|
|
20
|
+
// Components
|
|
21
|
+
export { TaskCheckout } from './task-checkout.js';
|
|
22
|
+
export { WorkspaceResolver } from './workspace-resolver.js';
|
|
23
|
+
export { ConcurrencyManager } from './concurrency-manager.js';
|
|
24
|
+
export { OrphanReaper } from './orphan-reaper.js';
|
|
25
|
+
export { aggregate as aggregateResults } from './result-aggregator.js';
|
|
26
|
+
|
|
27
|
+
// Dispatcher
|
|
28
|
+
export { SubagentDispatcher } from './dispatcher.js';
|