@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.
Files changed (192) hide show
  1. package/dist/adapters/claude-code-adapter.d.ts +27 -0
  2. package/dist/adapters/claude-code-adapter.d.ts.map +1 -0
  3. package/dist/adapters/claude-code-adapter.js +111 -0
  4. package/dist/adapters/claude-code-adapter.js.map +1 -0
  5. package/dist/adapters/index.d.ts +9 -0
  6. package/dist/adapters/index.d.ts.map +1 -0
  7. package/dist/adapters/index.js +10 -0
  8. package/dist/adapters/index.js.map +1 -0
  9. package/dist/adapters/registry.d.ts +21 -0
  10. package/dist/adapters/registry.d.ts.map +1 -0
  11. package/dist/adapters/registry.js +44 -0
  12. package/dist/adapters/registry.js.map +1 -0
  13. package/dist/adapters/types.d.ts +93 -0
  14. package/dist/adapters/types.d.ts.map +1 -0
  15. package/dist/adapters/types.js +10 -0
  16. package/dist/adapters/types.js.map +1 -0
  17. package/dist/brain/brain.d.ts +12 -1
  18. package/dist/brain/brain.d.ts.map +1 -1
  19. package/dist/brain/brain.js +106 -44
  20. package/dist/brain/brain.js.map +1 -1
  21. package/dist/brain/intelligence.d.ts.map +1 -1
  22. package/dist/brain/intelligence.js +36 -30
  23. package/dist/brain/intelligence.js.map +1 -1
  24. package/dist/chat/agent-loop.js +1 -1
  25. package/dist/chat/agent-loop.js.map +1 -1
  26. package/dist/chat/notifications.d.ts.map +1 -1
  27. package/dist/chat/notifications.js +4 -0
  28. package/dist/chat/notifications.js.map +1 -1
  29. package/dist/control/intent-router.d.ts +1 -0
  30. package/dist/control/intent-router.d.ts.map +1 -1
  31. package/dist/control/intent-router.js +11 -5
  32. package/dist/control/intent-router.js.map +1 -1
  33. package/dist/curator/curator.d.ts +4 -0
  34. package/dist/curator/curator.d.ts.map +1 -1
  35. package/dist/curator/curator.js +141 -27
  36. package/dist/curator/curator.js.map +1 -1
  37. package/dist/index.d.ts +14 -1
  38. package/dist/index.d.ts.map +1 -1
  39. package/dist/index.js +12 -1
  40. package/dist/index.js.map +1 -1
  41. package/dist/llm/llm-client.d.ts.map +1 -1
  42. package/dist/llm/llm-client.js +1 -0
  43. package/dist/llm/llm-client.js.map +1 -1
  44. package/dist/packs/index.d.ts +3 -2
  45. package/dist/packs/index.d.ts.map +1 -1
  46. package/dist/packs/index.js +3 -2
  47. package/dist/packs/index.js.map +1 -1
  48. package/dist/packs/lockfile.d.ts +23 -1
  49. package/dist/packs/lockfile.d.ts.map +1 -1
  50. package/dist/packs/lockfile.js +50 -4
  51. package/dist/packs/lockfile.js.map +1 -1
  52. package/dist/packs/pack-installer.d.ts +10 -0
  53. package/dist/packs/pack-installer.d.ts.map +1 -1
  54. package/dist/packs/pack-installer.js +69 -2
  55. package/dist/packs/pack-installer.js.map +1 -1
  56. package/dist/packs/pack-lifecycle.d.ts +50 -0
  57. package/dist/packs/pack-lifecycle.d.ts.map +1 -0
  58. package/dist/packs/pack-lifecycle.js +91 -0
  59. package/dist/packs/pack-lifecycle.js.map +1 -0
  60. package/dist/packs/types.d.ts +64 -44
  61. package/dist/packs/types.d.ts.map +1 -1
  62. package/dist/packs/types.js +9 -0
  63. package/dist/packs/types.js.map +1 -1
  64. package/dist/persistence/sqlite-provider.d.ts +5 -1
  65. package/dist/persistence/sqlite-provider.d.ts.map +1 -1
  66. package/dist/persistence/sqlite-provider.js +22 -2
  67. package/dist/persistence/sqlite-provider.js.map +1 -1
  68. package/dist/planning/github-projection.d.ts +8 -8
  69. package/dist/planning/github-projection.d.ts.map +1 -1
  70. package/dist/planning/github-projection.js +42 -42
  71. package/dist/planning/github-projection.js.map +1 -1
  72. package/dist/plugins/types.d.ts +21 -21
  73. package/dist/queue/pipeline-runner.d.ts.map +1 -1
  74. package/dist/queue/pipeline-runner.js +4 -0
  75. package/dist/queue/pipeline-runner.js.map +1 -1
  76. package/dist/runtime/curator-extra-ops.d.ts.map +1 -1
  77. package/dist/runtime/curator-extra-ops.js +9 -1
  78. package/dist/runtime/curator-extra-ops.js.map +1 -1
  79. package/dist/runtime/facades/memory-facade.d.ts.map +1 -1
  80. package/dist/runtime/facades/memory-facade.js +169 -0
  81. package/dist/runtime/facades/memory-facade.js.map +1 -1
  82. package/dist/runtime/orchestrate-ops.d.ts.map +1 -1
  83. package/dist/runtime/orchestrate-ops.js +133 -4
  84. package/dist/runtime/orchestrate-ops.js.map +1 -1
  85. package/dist/runtime/runtime.d.ts.map +1 -1
  86. package/dist/runtime/runtime.js +128 -90
  87. package/dist/runtime/runtime.js.map +1 -1
  88. package/dist/runtime/session-briefing.d.ts.map +1 -1
  89. package/dist/runtime/session-briefing.js +44 -11
  90. package/dist/runtime/session-briefing.js.map +1 -1
  91. package/dist/runtime/shutdown-registry.d.ts +36 -0
  92. package/dist/runtime/shutdown-registry.d.ts.map +1 -0
  93. package/dist/runtime/shutdown-registry.js +74 -0
  94. package/dist/runtime/shutdown-registry.js.map +1 -0
  95. package/dist/runtime/types.d.ts +10 -1
  96. package/dist/runtime/types.d.ts.map +1 -1
  97. package/dist/subagent/concurrency-manager.d.ts +29 -0
  98. package/dist/subagent/concurrency-manager.d.ts.map +1 -0
  99. package/dist/subagent/concurrency-manager.js +73 -0
  100. package/dist/subagent/concurrency-manager.js.map +1 -0
  101. package/dist/subagent/dispatcher.d.ts +41 -0
  102. package/dist/subagent/dispatcher.d.ts.map +1 -0
  103. package/dist/subagent/dispatcher.js +259 -0
  104. package/dist/subagent/dispatcher.js.map +1 -0
  105. package/dist/subagent/index.d.ts +14 -0
  106. package/dist/subagent/index.d.ts.map +1 -0
  107. package/dist/subagent/index.js +15 -0
  108. package/dist/subagent/index.js.map +1 -0
  109. package/dist/subagent/orphan-reaper.d.ts +37 -0
  110. package/dist/subagent/orphan-reaper.d.ts.map +1 -0
  111. package/dist/subagent/orphan-reaper.js +71 -0
  112. package/dist/subagent/orphan-reaper.js.map +1 -0
  113. package/dist/subagent/result-aggregator.d.ts +7 -0
  114. package/dist/subagent/result-aggregator.d.ts.map +1 -0
  115. package/dist/subagent/result-aggregator.js +57 -0
  116. package/dist/subagent/result-aggregator.js.map +1 -0
  117. package/dist/subagent/task-checkout.d.ts +36 -0
  118. package/dist/subagent/task-checkout.d.ts.map +1 -0
  119. package/dist/subagent/task-checkout.js +52 -0
  120. package/dist/subagent/task-checkout.js.map +1 -0
  121. package/dist/subagent/types.d.ts +114 -0
  122. package/dist/subagent/types.d.ts.map +1 -0
  123. package/dist/subagent/types.js +9 -0
  124. package/dist/subagent/types.js.map +1 -0
  125. package/dist/subagent/workspace-resolver.d.ts +35 -0
  126. package/dist/subagent/workspace-resolver.d.ts.map +1 -0
  127. package/dist/subagent/workspace-resolver.js +99 -0
  128. package/dist/subagent/workspace-resolver.js.map +1 -0
  129. package/dist/transport/http-server.d.ts.map +1 -1
  130. package/dist/transport/http-server.js +49 -3
  131. package/dist/transport/http-server.js.map +1 -1
  132. package/dist/transport/ws-server.d.ts.map +1 -1
  133. package/dist/transport/ws-server.js +7 -0
  134. package/dist/transport/ws-server.js.map +1 -1
  135. package/dist/vault/linking.d.ts +3 -4
  136. package/dist/vault/linking.d.ts.map +1 -1
  137. package/dist/vault/linking.js +79 -32
  138. package/dist/vault/linking.js.map +1 -1
  139. package/dist/vault/vault-maintenance.d.ts.map +1 -1
  140. package/dist/vault/vault-maintenance.js +7 -14
  141. package/dist/vault/vault-maintenance.js.map +1 -1
  142. package/dist/vault/vault-memories.d.ts.map +1 -1
  143. package/dist/vault/vault-memories.js +19 -9
  144. package/dist/vault/vault-memories.js.map +1 -1
  145. package/dist/vault/vault-schema.d.ts +1 -0
  146. package/dist/vault/vault-schema.d.ts.map +1 -1
  147. package/dist/vault/vault-schema.js +20 -0
  148. package/dist/vault/vault-schema.js.map +1 -1
  149. package/dist/vault/vault.d.ts.map +1 -1
  150. package/dist/vault/vault.js +7 -3
  151. package/dist/vault/vault.js.map +1 -1
  152. package/package.json +8 -2
  153. package/src/__tests__/adapters/claude-code-adapter.test.ts +167 -0
  154. package/src/__tests__/adapters/registry.test.ts +100 -0
  155. package/src/__tests__/packs/pack-lifecycle.test.ts +379 -0
  156. package/src/__tests__/subagent/concurrency-manager.test.ts +132 -0
  157. package/src/__tests__/subagent/dispatcher.test.ts +195 -0
  158. package/src/__tests__/subagent/orphan-reaper.test.ts +141 -0
  159. package/src/__tests__/subagent/result-aggregator.test.ts +141 -0
  160. package/src/__tests__/subagent/task-checkout.test.ts +86 -0
  161. package/src/__tests__/subagent/workspace-resolver.test.ts +138 -0
  162. package/src/adapters/claude-code-adapter.ts +163 -0
  163. package/src/adapters/index.ts +22 -0
  164. package/src/adapters/registry.ts +53 -0
  165. package/src/adapters/types.ts +114 -0
  166. package/src/curator/curator.ts +1 -0
  167. package/src/index.ts +38 -1
  168. package/src/packs/index.ts +5 -1
  169. package/src/packs/lockfile.ts +70 -5
  170. package/src/packs/pack-installer.ts +78 -2
  171. package/src/packs/pack-lifecycle.ts +115 -0
  172. package/src/packs/pack-lockfile.test.ts +1 -1
  173. package/src/packs/pack-system.test.ts +1 -1
  174. package/src/packs/types.ts +40 -2
  175. package/src/persistence/sqlite-provider.ts +26 -2
  176. package/src/runtime/admin-setup-ops.test.ts +9 -4
  177. package/src/runtime/orchestrate-ops.ts +153 -1
  178. package/src/runtime/runtime.ts +15 -0
  179. package/src/runtime/session-briefing.test.ts +94 -2
  180. package/src/runtime/session-briefing.ts +48 -12
  181. package/src/runtime/types.ts +6 -0
  182. package/src/subagent/concurrency-manager.ts +89 -0
  183. package/src/subagent/dispatcher.ts +326 -0
  184. package/src/subagent/index.ts +28 -0
  185. package/src/subagent/orphan-reaper.ts +82 -0
  186. package/src/subagent/result-aggregator.ts +66 -0
  187. package/src/subagent/task-checkout.ts +60 -0
  188. package/src/subagent/types.ts +138 -0
  189. package/src/subagent/workspace-resolver.ts +117 -0
  190. package/src/vault/vault-scaling.test.ts +3 -2
  191. package/vitest.config.ts +2 -0
  192. package/src/hooks/index.ts +0 -6
@@ -0,0 +1,132 @@
1
+ import { describe, it, expect, beforeEach } from 'vitest';
2
+ import { ConcurrencyManager } from '../../subagent/concurrency-manager.js';
3
+
4
+ describe('ConcurrencyManager', () => {
5
+ let cm: ConcurrencyManager;
6
+
7
+ beforeEach(() => {
8
+ cm = new ConcurrencyManager();
9
+ });
10
+
11
+ it('acquire() resolves immediately when under the limit', async () => {
12
+ await cm.acquire('test', 3);
13
+ expect(cm.getActive('test')).toBe(1);
14
+ });
15
+
16
+ it('acquire() queues when at the limit', async () => {
17
+ // Fill all 2 slots
18
+ await cm.acquire('test', 2);
19
+ await cm.acquire('test', 2);
20
+ expect(cm.getActive('test')).toBe(2);
21
+
22
+ // Third acquire should queue
23
+ let resolved = false;
24
+ const pending = cm.acquire('test', 2).then(() => {
25
+ resolved = true;
26
+ });
27
+
28
+ // Give microtask queue a tick
29
+ await Promise.resolve();
30
+ expect(resolved).toBe(false);
31
+ expect(cm.getWaiting('test')).toBe(1);
32
+
33
+ // Release one slot to unblock
34
+ cm.release('test');
35
+ await pending;
36
+ expect(resolved).toBe(true);
37
+ });
38
+
39
+ it('release() unblocks the next waiter in FIFO order', async () => {
40
+ await cm.acquire('test', 1);
41
+
42
+ const order: number[] = [];
43
+ const p1 = cm.acquire('test', 1).then(() => order.push(1));
44
+ const p2 = cm.acquire('test', 1).then(() => order.push(2));
45
+
46
+ expect(cm.getWaiting('test')).toBe(2);
47
+
48
+ cm.release('test');
49
+ await p1;
50
+ cm.release('test');
51
+ await p2;
52
+
53
+ expect(order).toEqual([1, 2]);
54
+ });
55
+
56
+ it('getActive() returns correct count', async () => {
57
+ expect(cm.getActive('test')).toBe(0);
58
+ await cm.acquire('test', 5);
59
+ await cm.acquire('test', 5);
60
+ expect(cm.getActive('test')).toBe(2);
61
+ });
62
+
63
+ it('getWaiting() returns correct count', async () => {
64
+ await cm.acquire('test', 1);
65
+ expect(cm.getWaiting('test')).toBe(0);
66
+
67
+ // These will queue
68
+ cm.acquire('test', 1);
69
+ cm.acquire('test', 1);
70
+ await Promise.resolve();
71
+ expect(cm.getWaiting('test')).toBe(2);
72
+ });
73
+
74
+ it('reset() resolves all waiters and clears state', async () => {
75
+ await cm.acquire('test', 1);
76
+
77
+ let waiterResolved = false;
78
+ const pending = cm.acquire('test', 1).then(() => {
79
+ waiterResolved = true;
80
+ });
81
+
82
+ cm.reset();
83
+ await pending;
84
+ expect(waiterResolved).toBe(true);
85
+ expect(cm.getActive('test')).toBe(0);
86
+ expect(cm.getWaiting('test')).toBe(0);
87
+ });
88
+
89
+ it('multiple types are independent', async () => {
90
+ await cm.acquire('typeA', 1);
91
+ await cm.acquire('typeB', 1);
92
+
93
+ expect(cm.getActive('typeA')).toBe(1);
94
+ expect(cm.getActive('typeB')).toBe(1);
95
+
96
+ // typeA is at limit=1, but typeB should still be acquirable
97
+ let typeBResolved = false;
98
+ const pending = cm.acquire('typeB', 2).then(() => {
99
+ typeBResolved = true;
100
+ });
101
+ await pending;
102
+ expect(typeBResolved).toBe(true);
103
+ expect(cm.getActive('typeB')).toBe(2);
104
+ });
105
+
106
+ it('default maxConcurrent is 3', async () => {
107
+ // Acquire 3 without specifying max (uses default of 3)
108
+ await cm.acquire('test');
109
+ await cm.acquire('test');
110
+ await cm.acquire('test');
111
+ expect(cm.getActive('test')).toBe(3);
112
+
113
+ // Fourth should queue
114
+ let queued = false;
115
+ cm.acquire('test').then(() => {
116
+ queued = true;
117
+ });
118
+ await Promise.resolve();
119
+ expect(queued).toBe(false);
120
+ expect(cm.getWaiting('test')).toBe(1);
121
+ });
122
+
123
+ it('release() is a no-op for untracked types', () => {
124
+ // Should not throw
125
+ cm.release('nonexistent');
126
+ expect(cm.getActive('nonexistent')).toBe(0);
127
+ });
128
+
129
+ it('getActive() returns 0 for untracked types', () => {
130
+ expect(cm.getActive('unknown')).toBe(0);
131
+ });
132
+ });
@@ -0,0 +1,195 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { SubagentDispatcher } from '../../subagent/dispatcher.js';
3
+ import { RuntimeAdapterRegistry } from '../../adapters/registry.js';
4
+ import type { RuntimeAdapter } from '../../adapters/types.js';
5
+ import type { SubagentTask } from '../../subagent/types.js';
6
+
7
+ function createMockRegistry() {
8
+ const registry = new RuntimeAdapterRegistry();
9
+ const mockAdapter: RuntimeAdapter = {
10
+ type: 'mock',
11
+ execute: vi.fn().mockResolvedValue({ exitCode: 0, summary: 'done' }),
12
+ testEnvironment: vi.fn().mockResolvedValue({ available: true }),
13
+ };
14
+ registry.register('mock', mockAdapter);
15
+ registry.setDefault('mock');
16
+ return { registry, mockAdapter };
17
+ }
18
+
19
+ function makeTask(overrides: Partial<SubagentTask> & { taskId: string }): SubagentTask {
20
+ return {
21
+ prompt: 'do something',
22
+ workspace: '/tmp/test',
23
+ ...overrides,
24
+ };
25
+ }
26
+
27
+ describe('SubagentDispatcher', () => {
28
+ let dispatcher: SubagentDispatcher;
29
+ let mockAdapter: RuntimeAdapter;
30
+
31
+ beforeEach(() => {
32
+ const { registry, mockAdapter: adapter } = createMockRegistry();
33
+ mockAdapter = adapter;
34
+ dispatcher = new SubagentDispatcher({ adapterRegistry: registry });
35
+ });
36
+
37
+ it('constructor accepts config with adapterRegistry', () => {
38
+ const { registry } = createMockRegistry();
39
+ const d = new SubagentDispatcher({ adapterRegistry: registry });
40
+ expect(d).toBeDefined();
41
+ });
42
+
43
+ it('dispatch() with empty tasks returns aggregate with 0 tasks', async () => {
44
+ const result = await dispatcher.dispatch([]);
45
+ expect(result.totalTasks).toBe(0);
46
+ expect(result.status).toBe('all-passed');
47
+ expect(result.results).toEqual([]);
48
+ });
49
+
50
+ it('dispatch() calls adapter.execute() for each task', async () => {
51
+ const tasks = [makeTask({ taskId: 'a' }), makeTask({ taskId: 'b' })];
52
+
53
+ await dispatcher.dispatch(tasks, { parallel: false });
54
+ expect(mockAdapter.execute).toHaveBeenCalledTimes(2);
55
+ });
56
+
57
+ it('dispatch() respects parallel=false option (sequential)', async () => {
58
+ const callOrder: string[] = [];
59
+ (mockAdapter.execute as ReturnType<typeof vi.fn>).mockImplementation(async (ctx) => {
60
+ callOrder.push(ctx.runId);
61
+ return { exitCode: 0, summary: 'ok' };
62
+ });
63
+
64
+ const tasks = [makeTask({ taskId: 'first' }), makeTask({ taskId: 'second' })];
65
+
66
+ await dispatcher.dispatch(tasks, { parallel: false });
67
+ expect(callOrder).toHaveLength(2);
68
+ // In sequential mode, first task should complete before second starts
69
+ expect(callOrder[0]).toContain('first');
70
+ expect(callOrder[1]).toContain('second');
71
+ });
72
+
73
+ it('dispatch() handles adapter failure gracefully', async () => {
74
+ (mockAdapter.execute as ReturnType<typeof vi.fn>).mockRejectedValueOnce(
75
+ new Error('Adapter crashed'),
76
+ );
77
+
78
+ const tasks = [makeTask({ taskId: 'fail-task' })];
79
+ const result = await dispatcher.dispatch(tasks);
80
+
81
+ expect(result.totalTasks).toBe(1);
82
+ expect(result.failed).toBe(1);
83
+ expect(result.results[0].error).toContain('Adapter crashed');
84
+ });
85
+
86
+ it('dispatch() claims and releases tasks', async () => {
87
+ const tasks = [makeTask({ taskId: 'claim-test' })];
88
+ await dispatcher.dispatch(tasks);
89
+
90
+ // After dispatch, the task should be released (available again)
91
+ // We verify by dispatching the same task again successfully
92
+ const result = await dispatcher.dispatch(tasks);
93
+ expect(result.completed).toBe(1);
94
+ });
95
+
96
+ it('dispatch() uses concurrency control in parallel mode', async () => {
97
+ let concurrentCount = 0;
98
+ let maxConcurrent = 0;
99
+
100
+ (mockAdapter.execute as ReturnType<typeof vi.fn>).mockImplementation(async () => {
101
+ concurrentCount++;
102
+ maxConcurrent = Math.max(maxConcurrent, concurrentCount);
103
+ await new Promise((resolve) => setTimeout(resolve, 50));
104
+ concurrentCount--;
105
+ return { exitCode: 0, summary: 'ok' };
106
+ });
107
+
108
+ const tasks = [
109
+ makeTask({ taskId: 'c1' }),
110
+ makeTask({ taskId: 'c2' }),
111
+ makeTask({ taskId: 'c3' }),
112
+ makeTask({ taskId: 'c4' }),
113
+ makeTask({ taskId: 'c5' }),
114
+ ];
115
+
116
+ await dispatcher.dispatch(tasks, { parallel: true, maxConcurrent: 2 });
117
+ // Max concurrent should not exceed 2
118
+ expect(maxConcurrent).toBeLessThanOrEqual(2);
119
+ });
120
+
121
+ it('dispatch() resolves dependencies correctly (task B depends on task A)', async () => {
122
+ const callOrder: string[] = [];
123
+ (mockAdapter.execute as ReturnType<typeof vi.fn>).mockImplementation(async (ctx) => {
124
+ callOrder.push(ctx.runId);
125
+ return { exitCode: 0, summary: 'ok' };
126
+ });
127
+
128
+ const tasks = [makeTask({ taskId: 'B', dependencies: ['A'] }), makeTask({ taskId: 'A' })];
129
+
130
+ await dispatcher.dispatch(tasks, { parallel: true });
131
+ // A should execute before B
132
+ const aIndex = callOrder.findIndex((r) => r.includes('-A-'));
133
+ const bIndex = callOrder.findIndex((r) => r.includes('-B-'));
134
+ expect(aIndex).toBeLessThan(bIndex);
135
+ });
136
+
137
+ it('dispatch() handles deadlocked dependencies', async () => {
138
+ const tasks = [
139
+ makeTask({ taskId: 'X', dependencies: ['Y'] }),
140
+ makeTask({ taskId: 'Y', dependencies: ['X'] }),
141
+ ];
142
+
143
+ const result = await dispatcher.dispatch(tasks, { parallel: true });
144
+ // Both tasks should fail due to unresolvable dependencies
145
+ expect(result.failed).toBe(2);
146
+ expect(result.results.every((r) => r.error?.includes('Unresolvable dependencies'))).toBe(true);
147
+ });
148
+
149
+ it('cleanup() clears all state', async () => {
150
+ const tasks = [makeTask({ taskId: 'cleanup-test' })];
151
+ await dispatcher.dispatch(tasks);
152
+
153
+ // cleanup should not throw
154
+ dispatcher.cleanup();
155
+ });
156
+
157
+ it('reapOrphans() returns orphaned results when processes are dead', () => {
158
+ // reapOrphans delegates to the internal OrphanReaper
159
+ // Without registering processes, it should return empty
160
+ const orphaned = dispatcher.reapOrphans();
161
+ expect(orphaned).toEqual([]);
162
+ });
163
+
164
+ it('dispatch() stops on first failure in sequential mode', async () => {
165
+ let callCount = 0;
166
+ (mockAdapter.execute as ReturnType<typeof vi.fn>).mockImplementation(async () => {
167
+ callCount++;
168
+ if (callCount === 1) {
169
+ return { exitCode: 1, summary: 'failed' };
170
+ }
171
+ return { exitCode: 0, summary: 'ok' };
172
+ });
173
+
174
+ const tasks = [makeTask({ taskId: 'first' }), makeTask({ taskId: 'second' })];
175
+
176
+ const result = await dispatcher.dispatch(tasks, { parallel: false });
177
+ // Only the first task should have been executed
178
+ expect(callCount).toBe(1);
179
+ expect(result.results).toHaveLength(1);
180
+ expect(result.failed).toBe(1);
181
+ });
182
+
183
+ it('dispatch() invokes onTaskUpdate callback', async () => {
184
+ const updates: Array<[string, string]> = [];
185
+
186
+ const tasks = [makeTask({ taskId: 'cb-test' })];
187
+ await dispatcher.dispatch(tasks, {
188
+ parallel: false,
189
+ onTaskUpdate: (taskId, status) => updates.push([taskId, status]),
190
+ });
191
+
192
+ expect(updates.length).toBeGreaterThanOrEqual(1);
193
+ expect(updates[0][0]).toBe('cb-test');
194
+ });
195
+ });
@@ -0,0 +1,141 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ import { OrphanReaper } from '../../subagent/orphan-reaper.js';
3
+
4
+ describe('OrphanReaper', () => {
5
+ let killSpy: ReturnType<typeof vi.spyOn>;
6
+
7
+ beforeEach(() => {
8
+ killSpy = vi.spyOn(process, 'kill');
9
+ });
10
+
11
+ afterEach(() => {
12
+ killSpy.mockRestore();
13
+ });
14
+
15
+ it('register() tracks a process', () => {
16
+ const reaper = new OrphanReaper();
17
+ reaper.register(1234, 'task-1');
18
+ expect(reaper.isTracked(1234)).toBe(true);
19
+ expect(reaper.listTracked()).toHaveLength(1);
20
+ expect(reaper.listTracked()[0].taskId).toBe('task-1');
21
+ });
22
+
23
+ it('unregister() stops tracking a process', () => {
24
+ const reaper = new OrphanReaper();
25
+ reaper.register(1234, 'task-1');
26
+ reaper.unregister(1234);
27
+ expect(reaper.isTracked(1234)).toBe(false);
28
+ expect(reaper.listTracked()).toHaveLength(0);
29
+ });
30
+
31
+ it('reap() returns empty when all processes are alive', () => {
32
+ // process.kill(pid, 0) succeeds = process alive
33
+ killSpy.mockImplementation(() => true);
34
+
35
+ const reaper = new OrphanReaper();
36
+ reaper.register(1234, 'task-1');
37
+ reaper.register(5678, 'task-2');
38
+
39
+ const reaped = reaper.reap();
40
+ expect(reaped).toHaveLength(0);
41
+ expect(reaper.listTracked()).toHaveLength(2);
42
+ });
43
+
44
+ it('reap() detects dead processes via ESRCH', () => {
45
+ killSpy.mockImplementation((_pid: number, _signal?: number) => {
46
+ const err = new Error('No such process') as NodeJS.ErrnoException;
47
+ err.code = 'ESRCH';
48
+ throw err;
49
+ });
50
+
51
+ const reaper = new OrphanReaper();
52
+ reaper.register(1234, 'task-1');
53
+
54
+ const reaped = reaper.reap();
55
+ expect(reaped).toHaveLength(1);
56
+ expect(reaped[0].taskId).toBe('task-1');
57
+ expect(reaped[0].pid).toBe(1234);
58
+ // Dead process should be removed from tracking
59
+ expect(reaper.isTracked(1234)).toBe(false);
60
+ });
61
+
62
+ it('reap() calls onOrphan callback for dead processes', () => {
63
+ killSpy.mockImplementation(() => {
64
+ const err = new Error('No such process') as NodeJS.ErrnoException;
65
+ err.code = 'ESRCH';
66
+ throw err;
67
+ });
68
+
69
+ const onOrphan = vi.fn();
70
+ const reaper = new OrphanReaper(onOrphan);
71
+ reaper.register(1234, 'task-1');
72
+
73
+ reaper.reap();
74
+ expect(onOrphan).toHaveBeenCalledWith('task-1', 1234);
75
+ });
76
+
77
+ it('reap() treats EPERM as alive (process exists but no permission)', () => {
78
+ killSpy.mockImplementation(() => {
79
+ const err = new Error('Operation not permitted') as NodeJS.ErrnoException;
80
+ err.code = 'EPERM';
81
+ throw err;
82
+ });
83
+
84
+ const reaper = new OrphanReaper();
85
+ reaper.register(1234, 'task-1');
86
+
87
+ const reaped = reaper.reap();
88
+ expect(reaped).toHaveLength(0);
89
+ expect(reaper.isTracked(1234)).toBe(true);
90
+ });
91
+
92
+ it('listTracked() returns all tracked processes', () => {
93
+ const reaper = new OrphanReaper();
94
+ reaper.register(100, 'task-a');
95
+ reaper.register(200, 'task-b');
96
+ reaper.register(300, 'task-c');
97
+
98
+ const tracked = reaper.listTracked();
99
+ expect(tracked).toHaveLength(3);
100
+ expect(tracked.map((t) => t.pid).sort()).toEqual([100, 200, 300]);
101
+ });
102
+
103
+ it('isTracked() returns correct boolean', () => {
104
+ const reaper = new OrphanReaper();
105
+ expect(reaper.isTracked(999)).toBe(false);
106
+ reaper.register(999, 'task-x');
107
+ expect(reaper.isTracked(999)).toBe(true);
108
+ });
109
+
110
+ it('clear() removes all tracked processes', () => {
111
+ const reaper = new OrphanReaper();
112
+ reaper.register(100, 'task-a');
113
+ reaper.register(200, 'task-b');
114
+
115
+ reaper.clear();
116
+ expect(reaper.listTracked()).toHaveLength(0);
117
+ expect(reaper.isTracked(100)).toBe(false);
118
+ expect(reaper.isTracked(200)).toBe(false);
119
+ });
120
+
121
+ it('reap() handles mixed alive and dead processes', () => {
122
+ killSpy.mockImplementation((pid: number, _signal?: number) => {
123
+ if (pid === 1234) {
124
+ return true; // alive
125
+ }
126
+ const err = new Error('No such process') as NodeJS.ErrnoException;
127
+ err.code = 'ESRCH';
128
+ throw err;
129
+ });
130
+
131
+ const reaper = new OrphanReaper();
132
+ reaper.register(1234, 'task-alive');
133
+ reaper.register(5678, 'task-dead');
134
+
135
+ const reaped = reaper.reap();
136
+ expect(reaped).toHaveLength(1);
137
+ expect(reaped[0].taskId).toBe('task-dead');
138
+ expect(reaper.isTracked(1234)).toBe(true);
139
+ expect(reaper.isTracked(5678)).toBe(false);
140
+ });
141
+ });
@@ -0,0 +1,141 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { aggregate } from '../../subagent/result-aggregator.js';
3
+ import type { SubagentResult } from '../../subagent/types.js';
4
+
5
+ function makeResult(overrides: Partial<SubagentResult> & { taskId: string }): SubagentResult {
6
+ return {
7
+ status: overrides.exitCode === 0 ? 'completed' : 'failed',
8
+ exitCode: 0,
9
+ durationMs: 100,
10
+ ...overrides,
11
+ };
12
+ }
13
+
14
+ describe('aggregate()', () => {
15
+ it('returns all-passed for all exitCode 0', () => {
16
+ const results: SubagentResult[] = [
17
+ makeResult({ taskId: 'a', exitCode: 0 }),
18
+ makeResult({ taskId: 'b', exitCode: 0 }),
19
+ ];
20
+ const agg = aggregate(results);
21
+ expect(agg.status).toBe('all-passed');
22
+ expect(agg.completed).toBe(2);
23
+ expect(agg.failed).toBe(0);
24
+ });
25
+
26
+ it('returns all-failed for all non-zero exitCodes', () => {
27
+ const results: SubagentResult[] = [
28
+ makeResult({ taskId: 'a', exitCode: 1, status: 'failed' }),
29
+ makeResult({ taskId: 'b', exitCode: 1, status: 'failed' }),
30
+ ];
31
+ const agg = aggregate(results);
32
+ expect(agg.status).toBe('all-failed');
33
+ expect(agg.completed).toBe(0);
34
+ expect(agg.failed).toBe(2);
35
+ });
36
+
37
+ it('returns partial for mixed results', () => {
38
+ const results: SubagentResult[] = [
39
+ makeResult({ taskId: 'a', exitCode: 0 }),
40
+ makeResult({ taskId: 'b', exitCode: 1, status: 'failed' }),
41
+ ];
42
+ const agg = aggregate(results);
43
+ expect(agg.status).toBe('partial');
44
+ expect(agg.completed).toBe(1);
45
+ expect(agg.failed).toBe(1);
46
+ });
47
+
48
+ it('sums token usage across results', () => {
49
+ const results: SubagentResult[] = [
50
+ makeResult({
51
+ taskId: 'a',
52
+ exitCode: 0,
53
+ usage: { inputTokens: 100, outputTokens: 50, totalTokens: 150 },
54
+ }),
55
+ makeResult({
56
+ taskId: 'b',
57
+ exitCode: 0,
58
+ usage: { inputTokens: 200, outputTokens: 75, totalTokens: 275 },
59
+ }),
60
+ ];
61
+ const agg = aggregate(results);
62
+ expect(agg.totalUsage.inputTokens).toBe(300);
63
+ expect(agg.totalUsage.outputTokens).toBe(125);
64
+ expect(agg.totalUsage.totalTokens).toBe(425);
65
+ });
66
+
67
+ it('handles results with no usage field', () => {
68
+ const results: SubagentResult[] = [
69
+ makeResult({ taskId: 'a', exitCode: 0 }),
70
+ makeResult({
71
+ taskId: 'b',
72
+ exitCode: 0,
73
+ usage: { inputTokens: 10, outputTokens: 5, totalTokens: 15 },
74
+ }),
75
+ ];
76
+ const agg = aggregate(results);
77
+ expect(agg.totalUsage.inputTokens).toBe(10);
78
+ expect(agg.totalUsage.outputTokens).toBe(5);
79
+ expect(agg.totalUsage.totalTokens).toBe(15);
80
+ });
81
+
82
+ it('deduplicates filesChanged', () => {
83
+ const results: SubagentResult[] = [
84
+ makeResult({ taskId: 'a', exitCode: 0, filesChanged: ['file1.ts', 'file2.ts'] }),
85
+ makeResult({ taskId: 'b', exitCode: 0, filesChanged: ['file2.ts', 'file3.ts'] }),
86
+ ];
87
+ const agg = aggregate(results);
88
+ expect(agg.filesChanged.sort()).toEqual(['file1.ts', 'file2.ts', 'file3.ts']);
89
+ });
90
+
91
+ it('handles empty results array', () => {
92
+ const agg = aggregate([]);
93
+ expect(agg.status).toBe('all-passed');
94
+ expect(agg.totalTasks).toBe(0);
95
+ expect(agg.completed).toBe(0);
96
+ expect(agg.failed).toBe(0);
97
+ expect(agg.filesChanged).toEqual([]);
98
+ expect(agg.combinedSummary).toBe('');
99
+ expect(agg.durationMs).toBe(0);
100
+ expect(agg.results).toEqual([]);
101
+ });
102
+
103
+ it('builds combinedSummary with taskId prefixes', () => {
104
+ const results: SubagentResult[] = [
105
+ makeResult({ taskId: 'task-1', exitCode: 0, summary: 'Fixed the bug' }),
106
+ makeResult({ taskId: 'task-2', exitCode: 0, summary: 'Added tests' }),
107
+ ];
108
+ const agg = aggregate(results);
109
+ expect(agg.combinedSummary).toContain('[task-1] Fixed the bug');
110
+ expect(agg.combinedSummary).toContain('[task-2] Added tests');
111
+ });
112
+
113
+ it('skips tasks without summary in combinedSummary', () => {
114
+ const results: SubagentResult[] = [
115
+ makeResult({ taskId: 'task-1', exitCode: 0, summary: 'Done' }),
116
+ makeResult({ taskId: 'task-2', exitCode: 0 }),
117
+ ];
118
+ const agg = aggregate(results);
119
+ expect(agg.combinedSummary).toBe('[task-1] Done');
120
+ });
121
+
122
+ it('uses max duration for durationMs', () => {
123
+ const results: SubagentResult[] = [
124
+ makeResult({ taskId: 'a', exitCode: 0, durationMs: 100 }),
125
+ makeResult({ taskId: 'b', exitCode: 0, durationMs: 500 }),
126
+ makeResult({ taskId: 'c', exitCode: 0, durationMs: 250 }),
127
+ ];
128
+ const agg = aggregate(results);
129
+ expect(agg.durationMs).toBe(500);
130
+ });
131
+
132
+ it('totalTasks matches the input count', () => {
133
+ const results: SubagentResult[] = [
134
+ makeResult({ taskId: 'a', exitCode: 0 }),
135
+ makeResult({ taskId: 'b', exitCode: 1, status: 'failed' }),
136
+ makeResult({ taskId: 'c', exitCode: 0 }),
137
+ ];
138
+ const agg = aggregate(results);
139
+ expect(agg.totalTasks).toBe(3);
140
+ });
141
+ });
@@ -0,0 +1,86 @@
1
+ import { describe, it, expect, beforeEach } from 'vitest';
2
+ import { TaskCheckout } from '../../subagent/task-checkout.js';
3
+
4
+ describe('TaskCheckout', () => {
5
+ let checkout: TaskCheckout;
6
+
7
+ beforeEach(() => {
8
+ checkout = new TaskCheckout();
9
+ });
10
+
11
+ it('claim() returns true for an unclaimed task', () => {
12
+ expect(checkout.claim('task-1', 'agent-a')).toBe(true);
13
+ });
14
+
15
+ it('claim() returns false when a different claimer tries the same task', () => {
16
+ checkout.claim('task-1', 'agent-a');
17
+ expect(checkout.claim('task-1', 'agent-b')).toBe(false);
18
+ });
19
+
20
+ it('claim() returns true for the same claimer (idempotent)', () => {
21
+ checkout.claim('task-1', 'agent-a');
22
+ expect(checkout.claim('task-1', 'agent-a')).toBe(true);
23
+ });
24
+
25
+ it('claim() stores claimerId and taskId in ClaimInfo', () => {
26
+ checkout.claim('task-1', 'agent-a');
27
+ const info = checkout.getClaimer('task-1');
28
+ expect(info).toBeDefined();
29
+ expect(info!.taskId).toBe('task-1');
30
+ expect(info!.claimerId).toBe('agent-a');
31
+ expect(info!.claimedAt).toBeGreaterThan(0);
32
+ });
33
+
34
+ it('release() returns true for a claimed task', () => {
35
+ checkout.claim('task-1', 'agent-a');
36
+ expect(checkout.release('task-1')).toBe(true);
37
+ });
38
+
39
+ it('release() returns false for an unclaimed task', () => {
40
+ expect(checkout.release('task-1')).toBe(false);
41
+ });
42
+
43
+ it('getClaimer() returns ClaimInfo for a claimed task', () => {
44
+ checkout.claim('task-1', 'agent-a');
45
+ const info = checkout.getClaimer('task-1');
46
+ expect(info).toBeDefined();
47
+ expect(info!.claimerId).toBe('agent-a');
48
+ });
49
+
50
+ it('getClaimer() returns undefined for an unclaimed task', () => {
51
+ expect(checkout.getClaimer('task-1')).toBeUndefined();
52
+ });
53
+
54
+ it('isAvailable() returns true for an unclaimed task', () => {
55
+ expect(checkout.isAvailable('task-1')).toBe(true);
56
+ });
57
+
58
+ it('isAvailable() returns false for a claimed task', () => {
59
+ checkout.claim('task-1', 'agent-a');
60
+ expect(checkout.isAvailable('task-1')).toBe(false);
61
+ });
62
+
63
+ it('listClaimed() returns all active claims', () => {
64
+ checkout.claim('task-1', 'agent-a');
65
+ checkout.claim('task-2', 'agent-b');
66
+ const claims = checkout.listClaimed();
67
+ expect(claims).toHaveLength(2);
68
+ expect(claims.map((c) => c.taskId).sort()).toEqual(['task-1', 'task-2']);
69
+ });
70
+
71
+ it('releaseAll() clears all claims', () => {
72
+ checkout.claim('task-1', 'agent-a');
73
+ checkout.claim('task-2', 'agent-b');
74
+ checkout.releaseAll();
75
+ expect(checkout.listClaimed()).toHaveLength(0);
76
+ expect(checkout.isAvailable('task-1')).toBe(true);
77
+ expect(checkout.isAvailable('task-2')).toBe(true);
78
+ });
79
+
80
+ it('release() makes the task available for a new claimer', () => {
81
+ checkout.claim('task-1', 'agent-a');
82
+ checkout.release('task-1');
83
+ expect(checkout.claim('task-1', 'agent-b')).toBe(true);
84
+ expect(checkout.getClaimer('task-1')!.claimerId).toBe('agent-b');
85
+ });
86
+ });