@ronkovic/aad 0.3.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 (195) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +312 -0
  3. package/bin/aad.js +2 -0
  4. package/package.json +78 -0
  5. package/src/__tests__/e2e/pipeline-e2e.test.ts +279 -0
  6. package/src/__tests__/e2e/resume-e2e.test.ts +200 -0
  7. package/src/__tests__/integration/cli-smoke.test.ts +175 -0
  8. package/src/__tests__/integration/pipeline.test.ts +346 -0
  9. package/src/bun-imports.d.ts +14 -0
  10. package/src/main.ts +52 -0
  11. package/src/modules/claude-provider/__tests__/claude-cli.adapter.test.ts +277 -0
  12. package/src/modules/claude-provider/__tests__/claude-sdk-real-env.test.ts +127 -0
  13. package/src/modules/claude-provider/__tests__/claude-sdk.adapter.test.ts +347 -0
  14. package/src/modules/claude-provider/__tests__/effort-strategy.test.ts +212 -0
  15. package/src/modules/claude-provider/__tests__/provider-registry.test.ts +251 -0
  16. package/src/modules/claude-provider/__tests__/retry.test.ts +201 -0
  17. package/src/modules/claude-provider/claude-cli.adapter.ts +156 -0
  18. package/src/modules/claude-provider/claude-provider.port.ts +35 -0
  19. package/src/modules/claude-provider/claude-sdk.adapter.ts +217 -0
  20. package/src/modules/claude-provider/effort-strategy.ts +94 -0
  21. package/src/modules/claude-provider/index.ts +32 -0
  22. package/src/modules/claude-provider/provider-registry.ts +92 -0
  23. package/src/modules/claude-provider/retry.ts +81 -0
  24. package/src/modules/cli/__tests__/app.test.ts +160 -0
  25. package/src/modules/cli/__tests__/cleanup.test.ts +111 -0
  26. package/src/modules/cli/__tests__/commands.test.ts +186 -0
  27. package/src/modules/cli/__tests__/output.test.ts +329 -0
  28. package/src/modules/cli/__tests__/resume.test.ts +324 -0
  29. package/src/modules/cli/__tests__/run.test.ts +168 -0
  30. package/src/modules/cli/__tests__/shutdown.test.ts +168 -0
  31. package/src/modules/cli/__tests__/status.test.ts +144 -0
  32. package/src/modules/cli/app.ts +241 -0
  33. package/src/modules/cli/commands/cleanup.ts +120 -0
  34. package/src/modules/cli/commands/resume.ts +156 -0
  35. package/src/modules/cli/commands/run.ts +322 -0
  36. package/src/modules/cli/commands/status.ts +101 -0
  37. package/src/modules/cli/index.ts +29 -0
  38. package/src/modules/cli/output.ts +256 -0
  39. package/src/modules/cli/shutdown.ts +122 -0
  40. package/src/modules/dashboard/__tests__/api-routes.test.ts +204 -0
  41. package/src/modules/dashboard/__tests__/file-watcher.test.ts +34 -0
  42. package/src/modules/dashboard/__tests__/server.test.ts +120 -0
  43. package/src/modules/dashboard/__tests__/sse-broadcaster.test.ts +163 -0
  44. package/src/modules/dashboard/__tests__/sse-routes.test.ts +58 -0
  45. package/src/modules/dashboard/__tests__/state-aggregator.test.ts +330 -0
  46. package/src/modules/dashboard/index.ts +8 -0
  47. package/src/modules/dashboard/routes/api.ts +84 -0
  48. package/src/modules/dashboard/routes/sse.ts +37 -0
  49. package/src/modules/dashboard/server.ts +111 -0
  50. package/src/modules/dashboard/services/file-watcher.ts +36 -0
  51. package/src/modules/dashboard/services/sse-broadcaster.ts +81 -0
  52. package/src/modules/dashboard/services/state-aggregator.ts +132 -0
  53. package/src/modules/dashboard/ui/dashboard.html +405 -0
  54. package/src/modules/git-workspace/__tests__/branch-manager.test.ts +335 -0
  55. package/src/modules/git-workspace/__tests__/git-exec.test.ts +91 -0
  56. package/src/modules/git-workspace/__tests__/memory-sync.test.ts +273 -0
  57. package/src/modules/git-workspace/__tests__/merge-service.test.ts +286 -0
  58. package/src/modules/git-workspace/__tests__/settings-merge.test.ts +163 -0
  59. package/src/modules/git-workspace/__tests__/worktree-manager.test.ts +247 -0
  60. package/src/modules/git-workspace/branch-manager.ts +191 -0
  61. package/src/modules/git-workspace/git-exec.ts +124 -0
  62. package/src/modules/git-workspace/index.ts +17 -0
  63. package/src/modules/git-workspace/memory-sync.ts +89 -0
  64. package/src/modules/git-workspace/merge-service.ts +156 -0
  65. package/src/modules/git-workspace/settings-merge.ts +95 -0
  66. package/src/modules/git-workspace/worktree-manager.ts +199 -0
  67. package/src/modules/logging/__tests__/log-store.test.ts +242 -0
  68. package/src/modules/logging/__tests__/logger.test.ts +81 -0
  69. package/src/modules/logging/__tests__/sse-transport.test.ts +93 -0
  70. package/src/modules/logging/index.ts +7 -0
  71. package/src/modules/logging/log-store.ts +80 -0
  72. package/src/modules/logging/logger.ts +55 -0
  73. package/src/modules/logging/transports/sse-transport.ts +28 -0
  74. package/src/modules/multi-repo/__tests__/multi-repo-planner.test.ts +93 -0
  75. package/src/modules/multi-repo/__tests__/repo-context.test.ts +79 -0
  76. package/src/modules/multi-repo/index.ts +12 -0
  77. package/src/modules/multi-repo/multi-repo-planner.ts +112 -0
  78. package/src/modules/multi-repo/repo-context.ts +71 -0
  79. package/src/modules/persistence/__tests__/.tmp-stores-test-81991/progress.json +10 -0
  80. package/src/modules/persistence/__tests__/.tmp-stores-test-81991/queue/completed/task-getall-2.json +10 -0
  81. package/src/modules/persistence/__tests__/.tmp-stores-test-81991/queue/pending/task-1.json +13 -0
  82. package/src/modules/persistence/__tests__/.tmp-stores-test-81991/queue/pending/task-getall-1.json +10 -0
  83. package/src/modules/persistence/__tests__/.tmp-stores-test-81991/queue/pending/task-status-change.json +10 -0
  84. package/src/modules/persistence/__tests__/.tmp-stores-test-81991/workers/worker-1.json +5 -0
  85. package/src/modules/persistence/__tests__/.tmp-stores-test-81991/workers/worker-2.json +5 -0
  86. package/src/modules/persistence/__tests__/.tmp-stores-test-82469/progress.json +10 -0
  87. package/src/modules/persistence/__tests__/.tmp-stores-test-82469/queue/completed/task-getall-2.json +10 -0
  88. package/src/modules/persistence/__tests__/.tmp-stores-test-82469/queue/pending/task-1.json +13 -0
  89. package/src/modules/persistence/__tests__/.tmp-stores-test-82469/queue/pending/task-getall-1.json +10 -0
  90. package/src/modules/persistence/__tests__/.tmp-stores-test-82469/queue/pending/task-status-change.json +10 -0
  91. package/src/modules/persistence/__tests__/.tmp-stores-test-82469/workers/worker-1.json +5 -0
  92. package/src/modules/persistence/__tests__/.tmp-stores-test-82469/workers/worker-2.json +5 -0
  93. package/src/modules/persistence/__tests__/.tmp-stores-test-85301/progress.json +10 -0
  94. package/src/modules/persistence/__tests__/.tmp-stores-test-85301/queue/completed/task-getall-2.json +10 -0
  95. package/src/modules/persistence/__tests__/.tmp-stores-test-85301/queue/pending/task-1.json +13 -0
  96. package/src/modules/persistence/__tests__/.tmp-stores-test-85301/queue/pending/task-getall-1.json +10 -0
  97. package/src/modules/persistence/__tests__/.tmp-stores-test-85301/queue/pending/task-status-change.json +10 -0
  98. package/src/modules/persistence/__tests__/.tmp-stores-test-85301/workers/worker-1.json +5 -0
  99. package/src/modules/persistence/__tests__/.tmp-stores-test-85301/workers/worker-2.json +5 -0
  100. package/src/modules/persistence/__tests__/.tmp-stores-test-85759/progress.json +10 -0
  101. package/src/modules/persistence/__tests__/.tmp-stores-test-85759/queue/completed/task-getall-2.json +10 -0
  102. package/src/modules/persistence/__tests__/.tmp-stores-test-85759/queue/pending/task-1.json +13 -0
  103. package/src/modules/persistence/__tests__/.tmp-stores-test-85759/queue/pending/task-getall-1.json +10 -0
  104. package/src/modules/persistence/__tests__/.tmp-stores-test-85759/queue/pending/task-status-change.json +10 -0
  105. package/src/modules/persistence/__tests__/.tmp-stores-test-85759/workers/worker-1.json +5 -0
  106. package/src/modules/persistence/__tests__/.tmp-stores-test-85759/workers/worker-2.json +5 -0
  107. package/src/modules/persistence/__tests__/.tmp-stores-test-86184/progress.json +10 -0
  108. package/src/modules/persistence/__tests__/.tmp-stores-test-86184/queue/completed/task-getall-2.json +10 -0
  109. package/src/modules/persistence/__tests__/.tmp-stores-test-86184/queue/pending/task-1.json +13 -0
  110. package/src/modules/persistence/__tests__/.tmp-stores-test-86184/queue/pending/task-getall-1.json +10 -0
  111. package/src/modules/persistence/__tests__/.tmp-stores-test-86184/queue/pending/task-status-change.json +10 -0
  112. package/src/modules/persistence/__tests__/.tmp-stores-test-86184/workers/worker-1.json +5 -0
  113. package/src/modules/persistence/__tests__/.tmp-stores-test-86184/workers/worker-2.json +5 -0
  114. package/src/modules/persistence/__tests__/.tmp-stores-test-88026/progress.json +10 -0
  115. package/src/modules/persistence/__tests__/.tmp-stores-test-88026/queue/completed/task-getall-2.json +10 -0
  116. package/src/modules/persistence/__tests__/.tmp-stores-test-88026/queue/pending/task-1.json +13 -0
  117. package/src/modules/persistence/__tests__/.tmp-stores-test-88026/queue/pending/task-getall-1.json +10 -0
  118. package/src/modules/persistence/__tests__/.tmp-stores-test-88026/queue/pending/task-status-change.json +10 -0
  119. package/src/modules/persistence/__tests__/.tmp-stores-test-88026/workers/worker-1.json +5 -0
  120. package/src/modules/persistence/__tests__/.tmp-stores-test-88026/workers/worker-2.json +5 -0
  121. package/src/modules/persistence/__tests__/.tmp-stores-test-89475/progress.json +10 -0
  122. package/src/modules/persistence/__tests__/.tmp-stores-test-89475/queue/completed/task-getall-2.json +10 -0
  123. package/src/modules/persistence/__tests__/.tmp-stores-test-89475/queue/pending/task-1.json +13 -0
  124. package/src/modules/persistence/__tests__/.tmp-stores-test-89475/queue/pending/task-getall-1.json +10 -0
  125. package/src/modules/persistence/__tests__/.tmp-stores-test-89475/queue/pending/task-status-change.json +10 -0
  126. package/src/modules/persistence/__tests__/.tmp-stores-test-89475/workers/worker-1.json +5 -0
  127. package/src/modules/persistence/__tests__/.tmp-stores-test-89475/workers/worker-2.json +5 -0
  128. package/src/modules/persistence/__tests__/.tmp-stores-test-89924/progress.json +10 -0
  129. package/src/modules/persistence/__tests__/.tmp-stores-test-89924/queue/completed/task-getall-2.json +10 -0
  130. package/src/modules/persistence/__tests__/.tmp-stores-test-89924/queue/pending/task-1.json +13 -0
  131. package/src/modules/persistence/__tests__/.tmp-stores-test-89924/queue/pending/task-getall-1.json +10 -0
  132. package/src/modules/persistence/__tests__/.tmp-stores-test-89924/queue/pending/task-status-change.json +10 -0
  133. package/src/modules/persistence/__tests__/.tmp-stores-test-89924/workers/worker-1.json +5 -0
  134. package/src/modules/persistence/__tests__/.tmp-stores-test-89924/workers/worker-2.json +5 -0
  135. package/src/modules/persistence/__tests__/file-lock.test.ts +141 -0
  136. package/src/modules/persistence/__tests__/index.test.ts +38 -0
  137. package/src/modules/persistence/__tests__/stores.test.ts +594 -0
  138. package/src/modules/persistence/file-lock.ts +158 -0
  139. package/src/modules/persistence/fs-run-store.ts +73 -0
  140. package/src/modules/persistence/fs-task-store.ts +152 -0
  141. package/src/modules/persistence/fs-worker-store.ts +116 -0
  142. package/src/modules/persistence/in-memory-stores.ts +98 -0
  143. package/src/modules/persistence/index.ts +60 -0
  144. package/src/modules/persistence/stores.port.ts +60 -0
  145. package/src/modules/planning/__tests__/file-conflict-validator.test.ts +256 -0
  146. package/src/modules/planning/__tests__/planning-service.test.ts +366 -0
  147. package/src/modules/planning/__tests__/project-detection.test.ts +707 -0
  148. package/src/modules/planning/file-conflict-validator.ts +135 -0
  149. package/src/modules/planning/index.ts +40 -0
  150. package/src/modules/planning/planning.service.ts +262 -0
  151. package/src/modules/planning/project-detection.ts +525 -0
  152. package/src/modules/plugin/__tests__/plugin-loader.test.ts +83 -0
  153. package/src/modules/plugin/__tests__/plugin-manager.test.ts +187 -0
  154. package/src/modules/plugin/index.ts +3 -0
  155. package/src/modules/plugin/plugin-loader.ts +46 -0
  156. package/src/modules/plugin/plugin-manager.ts +90 -0
  157. package/src/modules/plugin/plugin.types.ts +37 -0
  158. package/src/modules/process-manager/__tests__/process-manager.test.ts +210 -0
  159. package/src/modules/process-manager/__tests__/worker.test.ts +89 -0
  160. package/src/modules/process-manager/index.ts +5 -0
  161. package/src/modules/process-manager/process-manager.ts +193 -0
  162. package/src/modules/process-manager/worker.ts +106 -0
  163. package/src/modules/task-execution/__tests__/default-spawner.test.ts +154 -0
  164. package/src/modules/task-execution/__tests__/executor.test.ts +760 -0
  165. package/src/modules/task-execution/__tests__/implementer-green.test.ts +286 -0
  166. package/src/modules/task-execution/__tests__/merge-phase.test.ts +368 -0
  167. package/src/modules/task-execution/__tests__/reviewer.test.ts +302 -0
  168. package/src/modules/task-execution/__tests__/tester-red.test.ts +281 -0
  169. package/src/modules/task-execution/__tests__/tester-verify.test.ts +313 -0
  170. package/src/modules/task-execution/executor.ts +303 -0
  171. package/src/modules/task-execution/index.ts +45 -0
  172. package/src/modules/task-execution/phases/default-spawner.ts +49 -0
  173. package/src/modules/task-execution/phases/implementer-green.ts +100 -0
  174. package/src/modules/task-execution/phases/merge.ts +122 -0
  175. package/src/modules/task-execution/phases/reviewer.ts +160 -0
  176. package/src/modules/task-execution/phases/tester-red.ts +100 -0
  177. package/src/modules/task-execution/phases/tester-verify.ts +120 -0
  178. package/src/modules/task-queue/__tests__/dependency-resolver.test.ts +456 -0
  179. package/src/modules/task-queue/__tests__/dispatcher.test.ts +824 -0
  180. package/src/modules/task-queue/__tests__/task-plan.test.ts +122 -0
  181. package/src/modules/task-queue/__tests__/task.test.ts +130 -0
  182. package/src/modules/task-queue/dependency-resolver.ts +171 -0
  183. package/src/modules/task-queue/dispatcher.ts +372 -0
  184. package/src/modules/task-queue/index.ts +16 -0
  185. package/src/modules/task-queue/task-plan.ts +40 -0
  186. package/src/modules/task-queue/task.ts +67 -0
  187. package/src/shared/__tests__/config.test.ts +204 -0
  188. package/src/shared/__tests__/errors.test.ts +285 -0
  189. package/src/shared/__tests__/events.test.ts +496 -0
  190. package/src/shared/__tests__/types.test.ts +360 -0
  191. package/src/shared/config.ts +133 -0
  192. package/src/shared/errors.ts +128 -0
  193. package/src/shared/events.ts +171 -0
  194. package/src/shared/types.ts +143 -0
  195. package/tsconfig.json +30 -0
@@ -0,0 +1,824 @@
1
+ import { describe, test, expect, beforeEach, afterEach } from "bun:test";
2
+ import { createTaskId, createWorkerId, createRunId, type TaskPlan, type Worker } from "@aad/shared/types";
3
+ import { EventBus } from "@aad/shared/events";
4
+ import { createStores } from "../../persistence";
5
+ import { Dispatcher } from "../dispatcher";
6
+ import { pino } from "pino";
7
+
8
+ describe("Dispatcher", () => {
9
+ let dispatcher: Dispatcher;
10
+ let eventBus: EventBus;
11
+
12
+ beforeEach(() => {
13
+ const stores = createStores("memory");
14
+ eventBus = new EventBus();
15
+ const logger = pino({ level: "silent" });
16
+
17
+ dispatcher = new Dispatcher({
18
+ taskStore: stores.taskStore,
19
+ workerStore: stores.workerStore,
20
+ runStore: stores.runStore,
21
+ eventBus,
22
+ config: {
23
+ maxRetries: 2,
24
+ staleTaskCheckInterval: 60000,
25
+ staleTaskThreshold: 300000,
26
+ },
27
+ logger,
28
+ });
29
+ });
30
+
31
+ afterEach(() => {
32
+ dispatcher.stop();
33
+ });
34
+
35
+ describe("initialize", () => {
36
+ test("initializes with task plan", async () => {
37
+ const taskPlan: TaskPlan = {
38
+ runId: createRunId("run-123"),
39
+ parentBranch: "main",
40
+ tasks: [
41
+ {
42
+ taskId: createTaskId("task-1"),
43
+ title: "Task 1",
44
+ description: "Task",
45
+ filesToModify: [],
46
+ dependsOn: [],
47
+ priority: 1,
48
+ status: "pending",
49
+ retryCount: 0,
50
+ },
51
+ ],
52
+ };
53
+
54
+ let eventEmitted = false;
55
+ eventBus.on("queue:initialized", () => {
56
+ eventEmitted = true;
57
+ });
58
+
59
+ await dispatcher.initialize(taskPlan);
60
+
61
+ expect(eventEmitted).toBe(true);
62
+
63
+ const progress = dispatcher.getProgress();
64
+ expect(progress.total).toBe(1);
65
+ expect(progress.pending).toBe(1);
66
+ });
67
+
68
+ test("throws if already initialized", async () => {
69
+ const taskPlan: TaskPlan = {
70
+ runId: createRunId("run-123"),
71
+ parentBranch: "main",
72
+ tasks: [],
73
+ };
74
+
75
+ await dispatcher.initialize(taskPlan);
76
+
77
+ await expect(dispatcher.initialize(taskPlan)).rejects.toThrow("already initialized");
78
+ });
79
+ });
80
+
81
+ describe("dispatchNext", () => {
82
+ test("dispatches ready task to idle worker", async () => {
83
+ const taskPlan: TaskPlan = {
84
+ runId: createRunId("run-123"),
85
+ parentBranch: "main",
86
+ tasks: [
87
+ {
88
+ taskId: createTaskId("task-1"),
89
+ title: "Task 1",
90
+ description: "Task",
91
+ filesToModify: [],
92
+ dependsOn: [],
93
+ priority: 1,
94
+ status: "pending",
95
+ retryCount: 0,
96
+ },
97
+ ],
98
+ };
99
+
100
+ await dispatcher.initialize(taskPlan);
101
+
102
+ // Add idle worker
103
+ const worker: Worker = {
104
+ workerId: createWorkerId("worker-1"),
105
+ status: "idle",
106
+ currentTask: null,
107
+ };
108
+ await dispatcher["deps"].workerStore.save(worker);
109
+
110
+ let dispatchedTaskId: string | undefined;
111
+ eventBus.on("task:dispatched", (event) => {
112
+ if (event.type === "task:dispatched") {
113
+ dispatchedTaskId = event.taskId as string;
114
+ }
115
+ });
116
+
117
+ const dispatched = await dispatcher.dispatchNext();
118
+
119
+ expect(dispatched).toBe(true);
120
+ expect(dispatchedTaskId).toBe("task-1");
121
+ });
122
+
123
+ test("returns false when no idle workers", async () => {
124
+ const taskPlan: TaskPlan = {
125
+ runId: createRunId("run-123"),
126
+ parentBranch: "main",
127
+ tasks: [
128
+ {
129
+ taskId: createTaskId("task-1"),
130
+ title: "Task 1",
131
+ description: "Task",
132
+ filesToModify: [],
133
+ dependsOn: [],
134
+ priority: 1,
135
+ status: "pending",
136
+ retryCount: 0,
137
+ },
138
+ ],
139
+ };
140
+
141
+ await dispatcher.initialize(taskPlan);
142
+
143
+ const dispatched = await dispatcher.dispatchNext();
144
+
145
+ expect(dispatched).toBe(false);
146
+ });
147
+
148
+ test("returns false when no ready tasks", async () => {
149
+ const taskPlan: TaskPlan = {
150
+ runId: createRunId("run-123"),
151
+ parentBranch: "main",
152
+ tasks: [
153
+ {
154
+ taskId: createTaskId("task-1"),
155
+ title: "Task 1",
156
+ description: "Task",
157
+ filesToModify: [],
158
+ dependsOn: [],
159
+ priority: 1,
160
+ status: "running",
161
+ retryCount: 0,
162
+ },
163
+ ],
164
+ };
165
+
166
+ await dispatcher.initialize(taskPlan);
167
+
168
+ const worker: Worker = {
169
+ workerId: createWorkerId("worker-1"),
170
+ status: "idle",
171
+ currentTask: null,
172
+ };
173
+ await dispatcher["deps"].workerStore.save(worker);
174
+
175
+ const dispatched = await dispatcher.dispatchNext();
176
+
177
+ expect(dispatched).toBe(false);
178
+ });
179
+
180
+ test("dispatches highest priority task first", async () => {
181
+ const taskPlan: TaskPlan = {
182
+ runId: createRunId("run-123"),
183
+ parentBranch: "main",
184
+ tasks: [
185
+ {
186
+ taskId: createTaskId("task-1"),
187
+ title: "Task 1",
188
+ description: "Task",
189
+ filesToModify: [],
190
+ dependsOn: [],
191
+ priority: 1,
192
+ status: "pending",
193
+ retryCount: 0,
194
+ },
195
+ {
196
+ taskId: createTaskId("task-2"),
197
+ title: "Task 2",
198
+ description: "Task",
199
+ filesToModify: [],
200
+ dependsOn: [],
201
+ priority: 3,
202
+ status: "pending",
203
+ retryCount: 0,
204
+ },
205
+ ],
206
+ };
207
+
208
+ await dispatcher.initialize(taskPlan);
209
+
210
+ const worker: Worker = {
211
+ workerId: createWorkerId("worker-1"),
212
+ status: "idle",
213
+ currentTask: null,
214
+ };
215
+ await dispatcher["deps"].workerStore.save(worker);
216
+
217
+ let dispatchedTaskId: string | undefined;
218
+ eventBus.on("task:dispatched", (event) => {
219
+ if (event.type === "task:dispatched") {
220
+ dispatchedTaskId = event.taskId as string;
221
+ }
222
+ });
223
+
224
+ await dispatcher.dispatchNext();
225
+
226
+ expect(dispatchedTaskId).toBe("task-2"); // Higher priority
227
+ });
228
+ });
229
+
230
+ describe("handleTaskCompleted", () => {
231
+ test("marks task as completed and updates progress", async () => {
232
+ const taskPlan: TaskPlan = {
233
+ runId: createRunId("run-123"),
234
+ parentBranch: "main",
235
+ tasks: [
236
+ {
237
+ taskId: createTaskId("task-1"),
238
+ title: "Task 1",
239
+ description: "Task",
240
+ filesToModify: [],
241
+ dependsOn: [],
242
+ priority: 1,
243
+ status: "running",
244
+ retryCount: 0,
245
+ },
246
+ ],
247
+ };
248
+
249
+ await dispatcher.initialize(taskPlan);
250
+
251
+ let progressUpdated = false;
252
+ eventBus.on("progress:updated", () => {
253
+ progressUpdated = true;
254
+ });
255
+
256
+ await dispatcher.handleTaskCompleted(createTaskId("task-1"));
257
+
258
+ expect(progressUpdated).toBe(true);
259
+
260
+ const progress = dispatcher.getProgress();
261
+ expect(progress.completed).toBe(1);
262
+ expect(progress.running).toBe(0);
263
+ });
264
+
265
+ test("emits run:completed when all tasks are completed", async () => {
266
+ const taskPlan: TaskPlan = {
267
+ runId: createRunId("run-123"),
268
+ parentBranch: "main",
269
+ tasks: [
270
+ {
271
+ taskId: createTaskId("task-1"),
272
+ title: "Task 1",
273
+ description: "Task",
274
+ filesToModify: [],
275
+ dependsOn: [],
276
+ priority: 1,
277
+ status: "running",
278
+ retryCount: 0,
279
+ },
280
+ {
281
+ taskId: createTaskId("task-2"),
282
+ title: "Task 2",
283
+ description: "Task",
284
+ filesToModify: [],
285
+ dependsOn: [],
286
+ priority: 2,
287
+ status: "completed",
288
+ retryCount: 0,
289
+ },
290
+ ],
291
+ };
292
+
293
+ await dispatcher.initialize(taskPlan);
294
+
295
+ let runCompletedEmitted = false;
296
+ let capturedRunId: string | undefined;
297
+ eventBus.on("run:completed", (event) => {
298
+ if (event.type === "run:completed") {
299
+ runCompletedEmitted = true;
300
+ capturedRunId = event.runId as string;
301
+ }
302
+ });
303
+
304
+ await dispatcher.handleTaskCompleted(createTaskId("task-1"));
305
+
306
+ expect(runCompletedEmitted).toBe(true);
307
+ expect(capturedRunId).toBe("run-123");
308
+ });
309
+
310
+ test("emits run:completed when some tasks failed and rest completed", async () => {
311
+ const taskPlan: TaskPlan = {
312
+ runId: createRunId("run-456"),
313
+ parentBranch: "main",
314
+ tasks: [
315
+ {
316
+ taskId: createTaskId("task-1"),
317
+ title: "Task 1",
318
+ description: "Task",
319
+ filesToModify: [],
320
+ dependsOn: [],
321
+ priority: 1,
322
+ status: "running",
323
+ retryCount: 0,
324
+ },
325
+ {
326
+ taskId: createTaskId("task-2"),
327
+ title: "Task 2",
328
+ description: "Task",
329
+ filesToModify: [],
330
+ dependsOn: [],
331
+ priority: 2,
332
+ status: "running",
333
+ retryCount: 2, // Will fail permanently
334
+ },
335
+ ],
336
+ };
337
+
338
+ await dispatcher.initialize(taskPlan);
339
+
340
+ let runCompletedEmitted = false;
341
+ eventBus.on("run:completed", (event) => {
342
+ if (event.type === "run:completed") {
343
+ runCompletedEmitted = true;
344
+ }
345
+ });
346
+
347
+ // First task fails permanently
348
+ await dispatcher.handleTaskFailed(createTaskId("task-2"), "Permanent failure");
349
+
350
+ expect(runCompletedEmitted).toBe(false); // Not yet
351
+
352
+ // Second task completes
353
+ await dispatcher.handleTaskCompleted(createTaskId("task-1"));
354
+
355
+ expect(runCompletedEmitted).toBe(true);
356
+ });
357
+
358
+ test("does not emit run:completed when task plan is empty", async () => {
359
+ const taskPlan: TaskPlan = {
360
+ runId: createRunId("run-empty"),
361
+ parentBranch: "main",
362
+ tasks: [],
363
+ };
364
+
365
+ await dispatcher.initialize(taskPlan);
366
+
367
+ let runCompletedEmitted = false;
368
+ eventBus.on("run:completed", () => {
369
+ runCompletedEmitted = true;
370
+ });
371
+
372
+ // Simulate progress check
373
+ const progress = dispatcher.getProgress();
374
+ expect(progress.total).toBe(0);
375
+
376
+ // No tasks to complete, so run:completed should not be emitted
377
+ expect(runCompletedEmitted).toBe(false);
378
+ });
379
+ });
380
+
381
+ describe("handleTaskFailed", () => {
382
+ test("retries failed task if under max retries", async () => {
383
+ const taskPlan: TaskPlan = {
384
+ runId: createRunId("run-123"),
385
+ parentBranch: "main",
386
+ tasks: [
387
+ {
388
+ taskId: createTaskId("task-1"),
389
+ title: "Task 1",
390
+ description: "Task",
391
+ filesToModify: [],
392
+ dependsOn: [],
393
+ priority: 1,
394
+ status: "running",
395
+ retryCount: 0,
396
+ },
397
+ ],
398
+ };
399
+
400
+ await dispatcher.initialize(taskPlan);
401
+
402
+ let retryEventEmitted = false;
403
+ eventBus.on("task:retry", () => {
404
+ retryEventEmitted = true;
405
+ });
406
+
407
+ await dispatcher.handleTaskFailed(createTaskId("task-1"), "Test error");
408
+
409
+ expect(retryEventEmitted).toBe(true);
410
+
411
+ const progress = dispatcher.getProgress();
412
+ expect(progress.pending).toBe(1); // Back to pending
413
+ expect(progress.failed).toBe(0);
414
+ });
415
+
416
+ test("marks task as permanently failed after max retries", async () => {
417
+ const taskPlan: TaskPlan = {
418
+ runId: createRunId("run-123"),
419
+ parentBranch: "main",
420
+ tasks: [
421
+ {
422
+ taskId: createTaskId("task-1"),
423
+ title: "Task 1",
424
+ description: "Task",
425
+ filesToModify: [],
426
+ dependsOn: [],
427
+ priority: 1,
428
+ status: "running",
429
+ retryCount: 2, // At max retries
430
+ },
431
+ ],
432
+ };
433
+
434
+ await dispatcher.initialize(taskPlan);
435
+
436
+ await dispatcher.handleTaskFailed(createTaskId("task-1"), "Test error");
437
+
438
+ const progress = dispatcher.getProgress();
439
+ expect(progress.failed).toBe(1);
440
+ expect(progress.pending).toBe(0);
441
+ });
442
+ });
443
+
444
+ describe("getProgress", () => {
445
+ test("returns accurate progress state", async () => {
446
+ const taskPlan: TaskPlan = {
447
+ runId: createRunId("run-123"),
448
+ parentBranch: "main",
449
+ tasks: [
450
+ {
451
+ taskId: createTaskId("task-1"),
452
+ title: "Task 1",
453
+ description: "Task",
454
+ filesToModify: [],
455
+ dependsOn: [],
456
+ priority: 1,
457
+ status: "pending",
458
+ retryCount: 0,
459
+ },
460
+ {
461
+ taskId: createTaskId("task-2"),
462
+ title: "Task 2",
463
+ description: "Task",
464
+ filesToModify: [],
465
+ dependsOn: [],
466
+ priority: 2,
467
+ status: "running",
468
+ retryCount: 0,
469
+ },
470
+ {
471
+ taskId: createTaskId("task-3"),
472
+ title: "Task 3",
473
+ description: "Task",
474
+ filesToModify: [],
475
+ dependsOn: [],
476
+ priority: 3,
477
+ status: "completed",
478
+ retryCount: 0,
479
+ },
480
+ ],
481
+ };
482
+
483
+ await dispatcher.initialize(taskPlan);
484
+
485
+ const progress = dispatcher.getProgress();
486
+
487
+ expect(progress.total).toBe(3);
488
+ expect(progress.pending).toBe(1);
489
+ expect(progress.running).toBe(1);
490
+ expect(progress.completed).toBe(1);
491
+ expect(progress.failed).toBe(0);
492
+ });
493
+ });
494
+
495
+ describe("start", () => {
496
+ test("triggers initial dispatchNext on start", async () => {
497
+ const taskPlan: TaskPlan = {
498
+ runId: createRunId("run-123"),
499
+ parentBranch: "main",
500
+ tasks: [
501
+ {
502
+ taskId: createTaskId("task-1"),
503
+ title: "Task 1",
504
+ description: "Task",
505
+ filesToModify: [],
506
+ dependsOn: [],
507
+ priority: 1,
508
+ status: "pending",
509
+ retryCount: 0,
510
+ },
511
+ ],
512
+ };
513
+
514
+ await dispatcher.initialize(taskPlan);
515
+
516
+ const worker: Worker = {
517
+ workerId: createWorkerId("worker-1"),
518
+ status: "idle",
519
+ currentTask: null,
520
+ };
521
+ await dispatcher["deps"].workerStore.save(worker);
522
+
523
+ let dispatchedTaskId: string | undefined;
524
+ eventBus.on("task:dispatched", (event) => {
525
+ if (event.type === "task:dispatched") {
526
+ dispatchedTaskId = event.taskId as string;
527
+ }
528
+ });
529
+
530
+ dispatcher.start();
531
+
532
+ // Wait for microtask/setTimeout to resolve
533
+ await new Promise((resolve) => setTimeout(resolve, 50));
534
+
535
+ expect(dispatchedTaskId).toBe("task-1");
536
+ });
537
+
538
+ test("dispatches multiple tasks to multiple workers simultaneously", async () => {
539
+ const taskPlan: TaskPlan = {
540
+ runId: createRunId("run-123"),
541
+ parentBranch: "main",
542
+ tasks: [
543
+ {
544
+ taskId: createTaskId("task-1"),
545
+ title: "Task 1",
546
+ description: "Task",
547
+ filesToModify: [],
548
+ dependsOn: [],
549
+ priority: 1,
550
+ status: "pending",
551
+ retryCount: 0,
552
+ },
553
+ {
554
+ taskId: createTaskId("task-2"),
555
+ title: "Task 2",
556
+ description: "Task",
557
+ filesToModify: [],
558
+ dependsOn: [],
559
+ priority: 2,
560
+ status: "pending",
561
+ retryCount: 0,
562
+ },
563
+ {
564
+ taskId: createTaskId("task-3"),
565
+ title: "Task 3",
566
+ description: "Task",
567
+ filesToModify: [],
568
+ dependsOn: [],
569
+ priority: 3,
570
+ status: "pending",
571
+ retryCount: 0,
572
+ },
573
+ ],
574
+ };
575
+
576
+ await dispatcher.initialize(taskPlan);
577
+
578
+ const worker1: Worker = {
579
+ workerId: createWorkerId("worker-1"),
580
+ status: "idle",
581
+ currentTask: null,
582
+ };
583
+ const worker2: Worker = {
584
+ workerId: createWorkerId("worker-2"),
585
+ status: "idle",
586
+ currentTask: null,
587
+ };
588
+ await dispatcher["deps"].workerStore.save(worker1);
589
+ await dispatcher["deps"].workerStore.save(worker2);
590
+
591
+ const dispatchedTasks: string[] = [];
592
+ eventBus.on("task:dispatched", (event) => {
593
+ if (event.type === "task:dispatched") {
594
+ dispatchedTasks.push(event.taskId as string);
595
+ }
596
+ });
597
+
598
+ await dispatcher.dispatchNext();
599
+
600
+ expect(dispatchedTasks.length).toBe(2);
601
+ // Priority order: task-3 (p=3), task-2 (p=2)
602
+ expect(dispatchedTasks).toContain("task-3");
603
+ expect(dispatchedTasks).toContain("task-2");
604
+ });
605
+
606
+ test("dispatches only available tasks when workers exceed tasks", async () => {
607
+ const taskPlan: TaskPlan = {
608
+ runId: createRunId("run-123"),
609
+ parentBranch: "main",
610
+ tasks: [
611
+ {
612
+ taskId: createTaskId("task-1"),
613
+ title: "Task 1",
614
+ description: "Task",
615
+ filesToModify: [],
616
+ dependsOn: [],
617
+ priority: 1,
618
+ status: "pending",
619
+ retryCount: 0,
620
+ },
621
+ ],
622
+ };
623
+
624
+ await dispatcher.initialize(taskPlan);
625
+
626
+ const worker1: Worker = {
627
+ workerId: createWorkerId("worker-1"),
628
+ status: "idle",
629
+ currentTask: null,
630
+ };
631
+ const worker2: Worker = {
632
+ workerId: createWorkerId("worker-2"),
633
+ status: "idle",
634
+ currentTask: null,
635
+ };
636
+ const worker3: Worker = {
637
+ workerId: createWorkerId("worker-3"),
638
+ status: "idle",
639
+ currentTask: null,
640
+ };
641
+ await dispatcher["deps"].workerStore.save(worker1);
642
+ await dispatcher["deps"].workerStore.save(worker2);
643
+ await dispatcher["deps"].workerStore.save(worker3);
644
+
645
+ const dispatchedTasks: string[] = [];
646
+ eventBus.on("task:dispatched", (event) => {
647
+ if (event.type === "task:dispatched") {
648
+ dispatchedTasks.push(event.taskId as string);
649
+ }
650
+ });
651
+
652
+ await dispatcher.dispatchNext();
653
+
654
+ expect(dispatchedTasks.length).toBe(1);
655
+ expect(dispatchedTasks[0]).toBe("task-1");
656
+ });
657
+
658
+ test("throws if start() called before initialize", () => {
659
+ expect(() => dispatcher.start()).toThrow("not initialized");
660
+ });
661
+
662
+ test("stop clears stale check interval", async () => {
663
+ const taskPlan: TaskPlan = {
664
+ runId: createRunId("run-stop"),
665
+ parentBranch: "main",
666
+ tasks: [],
667
+ };
668
+
669
+ await dispatcher.initialize(taskPlan);
670
+ dispatcher.start();
671
+ dispatcher.stop();
672
+ // Should not throw when called again
673
+ dispatcher.stop();
674
+ });
675
+ });
676
+
677
+ describe("edge cases", () => {
678
+ test("handleTaskCompleted ignores unknown task", async () => {
679
+ const taskPlan: TaskPlan = {
680
+ runId: createRunId("run-unknown"),
681
+ parentBranch: "main",
682
+ tasks: [],
683
+ };
684
+
685
+ await dispatcher.initialize(taskPlan);
686
+
687
+ // Should not throw
688
+ await dispatcher.handleTaskCompleted(createTaskId("nonexistent"));
689
+ });
690
+
691
+ test("handleTaskFailed ignores unknown task", async () => {
692
+ const taskPlan: TaskPlan = {
693
+ runId: createRunId("run-unknown2"),
694
+ parentBranch: "main",
695
+ tasks: [],
696
+ };
697
+
698
+ await dispatcher.initialize(taskPlan);
699
+
700
+ // Should not throw
701
+ await dispatcher.handleTaskFailed(createTaskId("nonexistent"), "error");
702
+ });
703
+
704
+ test("emits queue:empty when all tasks are non-pending and no ready tasks", async () => {
705
+ const taskPlan: TaskPlan = {
706
+ runId: createRunId("run-empty-q"),
707
+ parentBranch: "main",
708
+ tasks: [
709
+ {
710
+ taskId: createTaskId("task-done"),
711
+ title: "Done",
712
+ description: "Task",
713
+ filesToModify: [],
714
+ dependsOn: [],
715
+ priority: 1,
716
+ status: "completed",
717
+ retryCount: 0,
718
+ },
719
+ ],
720
+ };
721
+
722
+ await dispatcher.initialize(taskPlan);
723
+
724
+ let queueEmptyEmitted = false;
725
+ eventBus.on("queue:empty", () => {
726
+ queueEmptyEmitted = true;
727
+ });
728
+
729
+ const worker: Worker = {
730
+ workerId: createWorkerId("worker-1"),
731
+ status: "idle",
732
+ currentTask: null,
733
+ };
734
+ await dispatcher["deps"].workerStore.save(worker);
735
+
736
+ const dispatched = await dispatcher.dispatchNext();
737
+
738
+ expect(dispatched).toBe(false);
739
+ expect(queueEmptyEmitted).toBe(true);
740
+ });
741
+
742
+ test("cascadeFailDependents fails dependent tasks recursively", async () => {
743
+ const taskPlan: TaskPlan = {
744
+ runId: createRunId("run-cascade"),
745
+ parentBranch: "main",
746
+ tasks: [
747
+ {
748
+ taskId: createTaskId("task-root"),
749
+ title: "Root",
750
+ description: "Task",
751
+ filesToModify: [],
752
+ dependsOn: [],
753
+ priority: 1,
754
+ status: "running",
755
+ retryCount: 2, // Will fail permanently
756
+ },
757
+ {
758
+ taskId: createTaskId("task-child"),
759
+ title: "Child",
760
+ description: "Task",
761
+ filesToModify: [],
762
+ dependsOn: [createTaskId("task-root")],
763
+ priority: 2,
764
+ status: "pending",
765
+ retryCount: 0,
766
+ },
767
+ {
768
+ taskId: createTaskId("task-grandchild"),
769
+ title: "Grandchild",
770
+ description: "Task",
771
+ filesToModify: [],
772
+ dependsOn: [createTaskId("task-child")],
773
+ priority: 3,
774
+ status: "pending",
775
+ retryCount: 0,
776
+ },
777
+ ],
778
+ };
779
+
780
+ await dispatcher.initialize(taskPlan);
781
+
782
+ await dispatcher.handleTaskFailed(createTaskId("task-root"), "Root failed");
783
+
784
+ const progress = dispatcher.getProgress();
785
+ expect(progress.failed).toBe(3); // root + child + grandchild
786
+ });
787
+
788
+ test("stale task detection emits task:stale event", async () => {
789
+ const pastTime = new Date(Date.now() - 600000).toISOString(); // 10 minutes ago
790
+
791
+ const taskPlan: TaskPlan = {
792
+ runId: createRunId("run-stale"),
793
+ parentBranch: "main",
794
+ tasks: [
795
+ {
796
+ taskId: createTaskId("task-stale"),
797
+ title: "Stale",
798
+ description: "Task",
799
+ filesToModify: [],
800
+ dependsOn: [],
801
+ priority: 1,
802
+ status: "running",
803
+ startTime: pastTime,
804
+ retryCount: 0,
805
+ },
806
+ ],
807
+ };
808
+
809
+ await dispatcher.initialize(taskPlan);
810
+
811
+ let staleEmitted = false;
812
+ eventBus.on("task:stale", (event) => {
813
+ if (event.type === "task:stale") {
814
+ staleEmitted = true;
815
+ }
816
+ });
817
+
818
+ // Manually call detectStaleTasks
819
+ dispatcher["detectStaleTasks"]();
820
+
821
+ expect(staleEmitted).toBe(true);
822
+ });
823
+ });
824
+ });