@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,372 @@
1
+ import type { Logger } from "pino";
2
+ import type { TaskId, WorkerId, TaskPlan, Task, ProgressState } from "@aad/shared/types";
3
+ import type { EventBus } from "@aad/shared/events";
4
+ import { TaskQueueError } from "@aad/shared/errors";
5
+ import { getReadyTasks } from "./dependency-resolver";
6
+
7
+ // Import from persistence module
8
+ export interface TaskStore {
9
+ get(taskId: TaskId): Promise<Task | null>;
10
+ getAll(): Promise<Task[]>;
11
+ getByStatus(status: import("@aad/shared/types").TaskStatus): Promise<Task[]>;
12
+ save(task: Task): Promise<void>;
13
+ delete(taskId: TaskId): Promise<void>;
14
+ }
15
+
16
+ export interface WorkerStore {
17
+ get(workerId: WorkerId): Promise<import("@aad/shared/types").Worker | null>;
18
+ getAll(): Promise<Array<import("@aad/shared/types").Worker>>;
19
+ getIdle(): Promise<Array<import("@aad/shared/types").Worker>>;
20
+ save(worker: import("@aad/shared/types").Worker): Promise<void>;
21
+ delete(workerId: WorkerId): Promise<void>;
22
+ }
23
+
24
+ export interface RunStore {
25
+ get(runId: import("@aad/shared/types").RunId): Promise<import("@aad/shared/types").RunState | null>;
26
+ save(state: import("@aad/shared/types").RunState): Promise<void>;
27
+ }
28
+
29
+ export interface DispatcherConfig {
30
+ maxRetries?: number;
31
+ staleTaskCheckInterval?: number;
32
+ staleTaskThreshold?: number;
33
+ }
34
+
35
+ export interface DispatcherDeps {
36
+ taskStore: TaskStore;
37
+ workerStore: WorkerStore;
38
+ runStore: RunStore;
39
+ eventBus: EventBus;
40
+ config: DispatcherConfig;
41
+ logger: Logger;
42
+ }
43
+
44
+ /**
45
+ * Event-driven task dispatcher
46
+ * Manages task lifecycle and dependency resolution
47
+ */
48
+ export class Dispatcher {
49
+ private deps: DispatcherDeps;
50
+ private staleCheckInterval?: Timer;
51
+ private taskMap: Map<string, Task> = new Map();
52
+ private initialized = false;
53
+ private runId?: import("@aad/shared/types").RunId;
54
+
55
+ constructor(deps: DispatcherDeps) {
56
+ this.deps = deps;
57
+ }
58
+
59
+ async initialize(taskPlan: TaskPlan): Promise<void> {
60
+ if (this.initialized) {
61
+ throw new TaskQueueError("Dispatcher already initialized");
62
+ }
63
+
64
+ // Store runId for later use
65
+ this.runId = taskPlan.runId;
66
+
67
+ // Save all tasks to store
68
+ for (const task of taskPlan.tasks) {
69
+ await this.deps.taskStore.save(task);
70
+ this.taskMap.set(task.taskId as string, task);
71
+ }
72
+
73
+ // Save run state
74
+ await this.deps.runStore.save({
75
+ runId: taskPlan.runId,
76
+ parentBranch: taskPlan.parentBranch,
77
+ totalTasks: taskPlan.tasks.length,
78
+ pending: taskPlan.tasks.length,
79
+ running: 0,
80
+ completed: 0,
81
+ failed: 0,
82
+ startTime: new Date().toISOString(),
83
+ });
84
+
85
+ this.initialized = true;
86
+
87
+ this.deps.eventBus.emit({
88
+ type: "queue:initialized",
89
+ totalTasks: taskPlan.tasks.length,
90
+ });
91
+
92
+ this.deps.logger.info(
93
+ { runId: taskPlan.runId, totalTasks: taskPlan.tasks.length },
94
+ "Dispatcher initialized"
95
+ );
96
+ }
97
+
98
+ start(): void {
99
+ if (!this.initialized) {
100
+ throw new TaskQueueError("Dispatcher not initialized");
101
+ }
102
+
103
+ // Listen to worker:idle events
104
+ this.deps.eventBus.on("worker:idle", (event) => {
105
+ if (event.type === "worker:idle") {
106
+ void this.handleWorkerIdle(event.workerId);
107
+ }
108
+ });
109
+
110
+ // Listen to task:completed events
111
+ this.deps.eventBus.on("task:completed", (event) => {
112
+ if (event.type === "task:completed") {
113
+ void this.handleTaskCompleted(event.taskId);
114
+ }
115
+ });
116
+
117
+ // Listen to task:failed events
118
+ this.deps.eventBus.on("task:failed", (event) => {
119
+ if (event.type === "task:failed") {
120
+ void this.handleTaskFailed(event.taskId, event.error);
121
+ }
122
+ });
123
+
124
+ // Start stale task detection
125
+ this.startStaleDetection();
126
+
127
+ this.deps.logger.info("Dispatcher started");
128
+
129
+ // Trigger initial dispatch (CRITICAL FIX #2)
130
+ void this.dispatchNext();
131
+ }
132
+
133
+ stop(): void {
134
+ if (this.staleCheckInterval) {
135
+ clearInterval(this.staleCheckInterval);
136
+ this.staleCheckInterval = undefined;
137
+ }
138
+
139
+ this.deps.logger.info("Dispatcher stopped");
140
+ }
141
+
142
+ async dispatchNext(): Promise<boolean> {
143
+ // Get ready tasks
144
+ const taskMapReadonly = new Map(
145
+ Array.from(this.taskMap.entries()).map(([k, v]) => [k as unknown as TaskId, v])
146
+ ) as ReadonlyMap<TaskId, Task>;
147
+ const readyTasks = getReadyTasks(taskMapReadonly);
148
+
149
+ if (readyTasks.length === 0) {
150
+ const pendingTasks = Array.from(this.taskMap.values()).filter(
151
+ (t) => t.status === "pending"
152
+ );
153
+
154
+ if (pendingTasks.length === 0) {
155
+ this.deps.eventBus.emit({ type: "queue:empty" });
156
+ return false;
157
+ }
158
+
159
+ // Tasks are waiting for dependencies
160
+ this.deps.logger.debug("No ready tasks, waiting for dependencies");
161
+ return false;
162
+ }
163
+
164
+ // Get idle workers
165
+ const idleWorkers = await this.deps.workerStore.getIdle();
166
+
167
+ if (idleWorkers.length === 0) {
168
+ this.deps.logger.debug("No idle workers available");
169
+ return false;
170
+ }
171
+
172
+ // Dispatch multiple tasks to available workers (FIX #2)
173
+ let dispatched = 0;
174
+ for (let i = 0; i < Math.min(readyTasks.length, idleWorkers.length); i++) {
175
+ const task = readyTasks[i];
176
+ const worker = idleWorkers[i];
177
+
178
+ if (!task || !worker) {
179
+ break;
180
+ }
181
+
182
+ // Update task status
183
+ task.status = "running";
184
+ task.workerId = worker.workerId;
185
+ task.startTime = new Date().toISOString();
186
+ await this.deps.taskStore.save(task);
187
+ this.taskMap.set(task.taskId as string, task);
188
+
189
+ // Emit dispatch event
190
+ this.deps.eventBus.emit({
191
+ type: "task:dispatched",
192
+ taskId: task.taskId,
193
+ workerId: worker.workerId,
194
+ });
195
+
196
+ this.deps.logger.info(
197
+ { taskId: task.taskId, workerId: worker.workerId },
198
+ "Task dispatched"
199
+ );
200
+
201
+ dispatched++;
202
+ }
203
+
204
+ this.updateProgress();
205
+
206
+ return dispatched > 0;
207
+ }
208
+
209
+ async handleTaskCompleted(taskId: TaskId): Promise<void> {
210
+ const task = this.taskMap.get(taskId as string);
211
+ if (!task) {
212
+ this.deps.logger.warn({ taskId }, "Task not found for completion");
213
+ return;
214
+ }
215
+
216
+ task.status = "completed";
217
+ task.endTime = new Date().toISOString();
218
+ await this.deps.taskStore.save(task);
219
+ this.taskMap.set(taskId as string, task);
220
+
221
+ this.updateProgress();
222
+
223
+ this.deps.logger.info({ taskId }, "Task completed");
224
+
225
+ // Try to dispatch next tasks
226
+ await this.dispatchNext();
227
+ }
228
+
229
+ async handleTaskFailed(taskId: TaskId, error: string): Promise<void> {
230
+ const task = this.taskMap.get(taskId as string);
231
+ if (!task) {
232
+ this.deps.logger.warn({ taskId }, "Task not found for failure");
233
+ return;
234
+ }
235
+
236
+ const maxRetries = this.deps.config.maxRetries ?? 2;
237
+
238
+ if (task.retryCount < maxRetries) {
239
+ // Retry
240
+ task.status = "pending";
241
+ task.retryCount += 1;
242
+ task.workerId = undefined;
243
+ task.failureReason = error;
244
+ await this.deps.taskStore.save(task);
245
+ this.taskMap.set(taskId as string, task);
246
+
247
+ this.deps.eventBus.emit({
248
+ type: "task:retry",
249
+ taskId,
250
+ retryCount: task.retryCount,
251
+ });
252
+
253
+ this.deps.logger.warn(
254
+ { taskId, retryCount: task.retryCount, error },
255
+ "Task failed, retrying"
256
+ );
257
+
258
+ // Try to dispatch
259
+ await this.dispatchNext();
260
+ } else {
261
+ // Failed permanently
262
+ task.status = "failed";
263
+ task.endTime = new Date().toISOString();
264
+ task.failureReason = error;
265
+ await this.deps.taskStore.save(task);
266
+ this.taskMap.set(taskId as string, task);
267
+
268
+ this.updateProgress();
269
+
270
+ this.deps.logger.error({ taskId, error }, "Task failed permanently");
271
+
272
+ // Cascade fail dependent tasks
273
+ await this.cascadeFailDependents(taskId);
274
+ }
275
+ }
276
+
277
+ getProgress(): ProgressState {
278
+ const tasks = Array.from(this.taskMap.values());
279
+ return {
280
+ total: tasks.length,
281
+ pending: tasks.filter((t) => t.status === "pending").length,
282
+ running: tasks.filter((t) => t.status === "running").length,
283
+ completed: tasks.filter((t) => t.status === "completed").length,
284
+ failed: tasks.filter((t) => t.status === "failed").length,
285
+ };
286
+ }
287
+
288
+ private async handleWorkerIdle(workerId: WorkerId): Promise<void> {
289
+ this.deps.logger.debug({ workerId: workerId as string }, "Worker idle, attempting dispatch");
290
+ await this.dispatchNext();
291
+ }
292
+
293
+ private async cascadeFailDependents(failedTaskId: TaskId): Promise<void> {
294
+ // Find all tasks that depend on the failed task
295
+ for (const task of this.taskMap.values()) {
296
+ if (task.dependsOn.includes(failedTaskId) && task.status === "pending") {
297
+ task.status = "failed";
298
+ task.failureReason = `Dependency ${failedTaskId as string} failed`;
299
+ task.endTime = new Date().toISOString();
300
+ await this.deps.taskStore.save(task);
301
+ this.taskMap.set(task.taskId as string, task);
302
+
303
+ this.deps.logger.warn(
304
+ { taskId: task.taskId, failedDep: failedTaskId },
305
+ "Task failed due to dependency failure"
306
+ );
307
+
308
+ // Recursively fail dependents
309
+ await this.cascadeFailDependents(task.taskId);
310
+ }
311
+ }
312
+
313
+ this.updateProgress();
314
+ }
315
+
316
+ private updateProgress(): void {
317
+ const progress = this.getProgress();
318
+
319
+ this.deps.eventBus.emit({
320
+ type: "progress:updated",
321
+ state: progress,
322
+ });
323
+
324
+ // Emit run:completed if all tasks are done (FIX #1)
325
+ if (progress.pending === 0 && progress.running === 0) {
326
+ if (this.runId) {
327
+ this.deps.eventBus.emit({
328
+ type: "run:completed",
329
+ runId: this.runId,
330
+ });
331
+
332
+ this.deps.logger.info(
333
+ { runId: this.runId, completed: progress.completed, failed: progress.failed },
334
+ "Run completed"
335
+ );
336
+ }
337
+ }
338
+ }
339
+
340
+ private startStaleDetection(): void {
341
+ const interval = this.deps.config.staleTaskCheckInterval ?? 60000; // 60s default
342
+
343
+ this.staleCheckInterval = setInterval(() => {
344
+ this.detectStaleTasks();
345
+ }, interval);
346
+ }
347
+
348
+ private detectStaleTasks(): void {
349
+ const staleThreshold = this.deps.config.staleTaskThreshold ?? 300000; // 5 minutes
350
+ const now = Date.now();
351
+
352
+ for (const task of this.taskMap.values()) {
353
+ if (task.status === "running" && task.startTime) {
354
+ const startTime = new Date(task.startTime).getTime();
355
+ const elapsed = now - startTime;
356
+
357
+ if (elapsed > staleThreshold) {
358
+ this.deps.eventBus.emit({
359
+ type: "task:stale",
360
+ taskId: task.taskId,
361
+ elapsed,
362
+ });
363
+
364
+ this.deps.logger.warn(
365
+ { taskId: task.taskId, elapsed },
366
+ "Stale task detected"
367
+ );
368
+ }
369
+ }
370
+ }
371
+ }
372
+ }
@@ -0,0 +1,16 @@
1
+ export { parseTask, serializeTask, TaskJsonSchema } from "./task";
2
+ export type { TaskJson } from "./task";
3
+
4
+ export { parseTaskPlan, loadTaskPlan } from "./task-plan";
5
+ export type { TaskPlanJson } from "./task-plan";
6
+
7
+ export {
8
+ checkDependencies,
9
+ detectCircularDependencies,
10
+ getReadyTasks,
11
+ validateTaskPlan,
12
+ } from "./dependency-resolver";
13
+ export type { DependencyStatus, DependencyResult } from "./dependency-resolver";
14
+
15
+ export { Dispatcher } from "./dispatcher";
16
+ export type { DispatcherDeps, TaskStore, WorkerStore, RunStore } from "./dispatcher";
@@ -0,0 +1,40 @@
1
+ import { z } from "zod";
2
+ import { createRunId, type TaskPlan } from "@aad/shared/types";
3
+ import { parseTask, TaskJsonSchema } from "./task";
4
+
5
+ /**
6
+ * TaskPlan JSON schema (snake_case from Bash scripts)
7
+ */
8
+ const TaskPlanJsonSchema = z.object({
9
+ run_id: z.string().transform((id) => createRunId(id)),
10
+ parent_branch: z.string(),
11
+ title: z.string().optional(),
12
+ description: z.string().optional(),
13
+ tasks: z.array(TaskJsonSchema),
14
+ });
15
+
16
+ export type TaskPlanJson = z.input<typeof TaskPlanJsonSchema>;
17
+
18
+ /**
19
+ * Parse task_plan.json to TaskPlan domain model
20
+ */
21
+ export function parseTaskPlan(json: unknown): TaskPlan {
22
+ const parsed = TaskPlanJsonSchema.parse(json);
23
+
24
+ return {
25
+ runId: parsed.run_id,
26
+ parentBranch: parsed.parent_branch,
27
+ title: parsed.title,
28
+ description: parsed.description,
29
+ tasks: parsed.tasks.map((taskJson) => parseTask(taskJson)),
30
+ };
31
+ }
32
+
33
+ /**
34
+ * Load and parse task_plan.json from file
35
+ */
36
+ export async function loadTaskPlan(filePath: string): Promise<TaskPlan> {
37
+ const content = await Bun.file(filePath).text();
38
+ const json = JSON.parse(content);
39
+ return parseTaskPlan(json);
40
+ }
@@ -0,0 +1,67 @@
1
+ import { z } from "zod";
2
+ import { createTaskId, createWorkerId, type Task, type TaskStatus } from "@aad/shared/types";
3
+
4
+ /**
5
+ * Task schema for JSON deserialization
6
+ * Handles snake_case to camelCase conversion
7
+ */
8
+ const TaskStatusSchema = z.enum(["pending", "running", "completed", "failed"]);
9
+
10
+ export const TaskJsonSchema = z.object({
11
+ task_id: z.string().transform((id) => createTaskId(id)),
12
+ title: z.string(),
13
+ description: z.string(),
14
+ files_to_modify: z.array(z.string()),
15
+ depends_on: z.array(z.string()).transform((ids) => ids.map((id) => createTaskId(id))),
16
+ priority: z.number(),
17
+ status: TaskStatusSchema.optional().default("pending"),
18
+ worker_id: z.string().optional(),
19
+ start_time: z.string().optional(),
20
+ end_time: z.string().optional(),
21
+ retry_count: z.number().optional().default(0),
22
+ failure_reason: z.string().optional(),
23
+ });
24
+
25
+ export type TaskJson = z.input<typeof TaskJsonSchema>;
26
+
27
+ /**
28
+ * Parse task from snake_case JSON to Task domain model
29
+ */
30
+ export function parseTask(json: unknown): Task {
31
+ const parsed = TaskJsonSchema.parse(json);
32
+
33
+ return {
34
+ taskId: parsed.task_id,
35
+ title: parsed.title,
36
+ description: parsed.description,
37
+ filesToModify: parsed.files_to_modify,
38
+ dependsOn: parsed.depends_on,
39
+ priority: parsed.priority,
40
+ status: parsed.status as TaskStatus,
41
+ workerId: parsed.worker_id ? createWorkerId(parsed.worker_id) : undefined,
42
+ startTime: parsed.start_time,
43
+ endTime: parsed.end_time,
44
+ retryCount: parsed.retry_count,
45
+ failureReason: parsed.failure_reason,
46
+ };
47
+ }
48
+
49
+ /**
50
+ * Serialize Task to snake_case JSON
51
+ */
52
+ export function serializeTask(task: Task): Record<string, unknown> {
53
+ return {
54
+ task_id: task.taskId,
55
+ title: task.title,
56
+ description: task.description,
57
+ files_to_modify: task.filesToModify,
58
+ depends_on: task.dependsOn,
59
+ priority: task.priority,
60
+ status: task.status,
61
+ worker_id: task.workerId,
62
+ start_time: task.startTime,
63
+ end_time: task.endTime,
64
+ retry_count: task.retryCount,
65
+ failure_reason: task.failureReason,
66
+ };
67
+ }
@@ -0,0 +1,204 @@
1
+ import { describe, test, expect } from "bun:test";
2
+ import { loadConfig } from "../config";
3
+ import { ConfigError } from "../errors";
4
+
5
+ describe("loadConfig", () => {
6
+ test("loads config with default values when no env vars set", () => {
7
+ const config = loadConfig({});
8
+
9
+ expect(config.workers.num).toBe(2);
10
+ expect(config.workers.max).toBe(8);
11
+ expect(config.models.default).toBeUndefined();
12
+ expect(config.models.splitter).toBeUndefined();
13
+ expect(config.models.tester).toBeUndefined();
14
+ expect(config.models.implementer).toBeUndefined();
15
+ expect(config.models.reviewer).toBeUndefined();
16
+ expect(config.models.mergeResolver).toBeUndefined();
17
+ expect(config.models.branchName).toBeUndefined();
18
+ expect(config.timeouts.claude).toBe(1200);
19
+ expect(config.timeouts.test).toBe(600);
20
+ expect(config.timeouts.staleTask).toBe(5400);
21
+ expect(config.retry.maxRetries).toBe(2);
22
+ expect(config.debug).toBe(false);
23
+ expect(config.adaptiveEffort).toBe(false);
24
+ expect(config.teams.splitter).toBe(false);
25
+ expect(config.teams.reviewer).toBe(false);
26
+ expect(config.memorySync).toBe(true);
27
+ expect(config.dashboard.enabled).toBe(true);
28
+ expect(config.dashboard.port).toBe(7333);
29
+ expect(config.dashboard.host).toBe("localhost");
30
+ });
31
+
32
+ test("loads config from env vars", () => {
33
+ const config = loadConfig({
34
+ AAD_NUM_WORKERS: "4",
35
+ AAD_MAX_WORKERS: "16",
36
+ AAD_MODEL: "opus",
37
+ AAD_MODEL_SPLITTER: "sonnet",
38
+ AAD_MODEL_TESTER: "sonnet",
39
+ AAD_MODEL_IMPLEMENTER: "sonnet",
40
+ AAD_MODEL_REVIEWER: "opus",
41
+ AAD_MODEL_MERGE: "haiku",
42
+ AAD_MODEL_BRANCH_NAME: "haiku",
43
+ CLAUDE_TIMEOUT: "1800",
44
+ TEST_TIMEOUT: "900",
45
+ AAD_STALE_TASK_TIMEOUT: "7200",
46
+ MAX_PHASE_RETRIES: "3",
47
+ DEBUG: "1",
48
+ AAD_ADAPTIVE_EFFORT: "1",
49
+ AAD_USE_TEAMS_FOR_SPLITTER: "1",
50
+ AAD_USE_TEAMS_FOR_REVIEW: "1",
51
+ AAD_MEMORY_SYNC_ENABLED: "0",
52
+ });
53
+
54
+ expect(config.workers.num).toBe(4);
55
+ expect(config.workers.max).toBe(16);
56
+ expect(config.models.default).toBe("opus");
57
+ expect(config.models.splitter).toBe("sonnet");
58
+ expect(config.models.tester).toBe("sonnet");
59
+ expect(config.models.implementer).toBe("sonnet");
60
+ expect(config.models.reviewer).toBe("opus");
61
+ expect(config.models.mergeResolver).toBe("haiku");
62
+ expect(config.models.branchName).toBe("haiku");
63
+ expect(config.timeouts.claude).toBe(1800);
64
+ expect(config.timeouts.test).toBe(900);
65
+ expect(config.timeouts.staleTask).toBe(7200);
66
+ expect(config.retry.maxRetries).toBe(3);
67
+ expect(config.debug).toBe(true);
68
+ expect(config.adaptiveEffort).toBe(true);
69
+ expect(config.teams.splitter).toBe(true);
70
+ expect(config.teams.reviewer).toBe(true);
71
+ expect(config.memorySync).toBe(false);
72
+ });
73
+
74
+ test("throws ConfigError when AAD_NUM_WORKERS exceeds AAD_MAX_WORKERS", () => {
75
+ expect(() => {
76
+ loadConfig({
77
+ AAD_NUM_WORKERS: "10",
78
+ AAD_MAX_WORKERS: "8",
79
+ });
80
+ }).toThrow(ConfigError);
81
+ });
82
+
83
+ test("throws ConfigError for invalid AAD_NUM_WORKERS", () => {
84
+ expect(() => {
85
+ loadConfig({
86
+ AAD_NUM_WORKERS: "invalid",
87
+ });
88
+ }).toThrow(ConfigError);
89
+ });
90
+
91
+ test("throws ConfigError for negative AAD_NUM_WORKERS", () => {
92
+ expect(() => {
93
+ loadConfig({
94
+ AAD_NUM_WORKERS: "-1",
95
+ });
96
+ }).toThrow(ConfigError);
97
+ });
98
+
99
+ test("throws ConfigError for invalid CLAUDE_TIMEOUT", () => {
100
+ expect(() => {
101
+ loadConfig({
102
+ CLAUDE_TIMEOUT: "not-a-number",
103
+ });
104
+ }).toThrow(ConfigError);
105
+ });
106
+
107
+ test("uses process.env when no env parameter provided", () => {
108
+ const originalNumWorkers = process.env.AAD_NUM_WORKERS;
109
+ const originalMaxWorkers = process.env.AAD_MAX_WORKERS;
110
+ process.env.AAD_NUM_WORKERS = "3";
111
+ process.env.AAD_MAX_WORKERS = "8";
112
+
113
+ const config = loadConfig();
114
+ expect(config.workers.num).toBe(3);
115
+
116
+ if (originalNumWorkers !== undefined) {
117
+ process.env.AAD_NUM_WORKERS = originalNumWorkers;
118
+ } else {
119
+ delete process.env.AAD_NUM_WORKERS;
120
+ }
121
+ if (originalMaxWorkers !== undefined) {
122
+ process.env.AAD_MAX_WORKERS = originalMaxWorkers;
123
+ } else {
124
+ delete process.env.AAD_MAX_WORKERS;
125
+ }
126
+ });
127
+
128
+ test("accepts valid model names", () => {
129
+ const config = loadConfig({
130
+ AAD_MODEL: "claude-sonnet-4-5-20250929",
131
+ AAD_MODEL_SPLITTER: "claude-opus-4-6",
132
+ AAD_MODEL_MERGE: "claude-haiku-4-5-20251001",
133
+ });
134
+
135
+ expect(config.models.default).toBe("claude-sonnet-4-5-20250929");
136
+ expect(config.models.splitter).toBe("claude-opus-4-6");
137
+ expect(config.models.mergeResolver).toBe("claude-haiku-4-5-20251001");
138
+ });
139
+
140
+ test("accepts short model names", () => {
141
+ const config = loadConfig({
142
+ AAD_MODEL: "sonnet",
143
+ AAD_MODEL_SPLITTER: "opus",
144
+ AAD_MODEL_MERGE: "haiku",
145
+ });
146
+
147
+ expect(config.models.default).toBe("sonnet");
148
+ expect(config.models.splitter).toBe("opus");
149
+ expect(config.models.mergeResolver).toBe("haiku");
150
+ });
151
+
152
+ test("loads dashboard config from env vars", () => {
153
+ const config = loadConfig({
154
+ AAD_DASHBOARD_PORT: "4444",
155
+ AAD_DASHBOARD_ENABLED: "0",
156
+ AAD_DASHBOARD_HOST: "0.0.0.0",
157
+ });
158
+
159
+ expect(config.dashboard.port).toBe(4444);
160
+ expect(config.dashboard.enabled).toBe(false);
161
+ expect(config.dashboard.host).toBe("0.0.0.0");
162
+ });
163
+
164
+ test("uses dashboard defaults when env vars not set", () => {
165
+ const config = loadConfig({});
166
+
167
+ expect(config.dashboard.enabled).toBe(true);
168
+ expect(config.dashboard.port).toBe(7333);
169
+ expect(config.dashboard.host).toBe("localhost");
170
+ });
171
+
172
+ test("loads repos from AAD_REPOS env var", () => {
173
+ const config = loadConfig({ AAD_REPOS: "/tmp/repo-a, /tmp/repo-b" });
174
+
175
+ expect(config.repos).toHaveLength(2);
176
+ expect(config.repos![0]!.path).toBe("/tmp/repo-a");
177
+ expect(config.repos![1]!.path).toBe("/tmp/repo-b");
178
+ });
179
+
180
+ test("repos is undefined when AAD_REPOS not set", () => {
181
+ const config = loadConfig({});
182
+ expect(config.repos).toBeUndefined();
183
+ });
184
+
185
+ test("loads multiRepoStrategy from env", () => {
186
+ const config = loadConfig({ AAD_MULTI_REPO_STRATEGY: "coordinated" });
187
+ expect(config.multiRepoStrategy).toBe("coordinated");
188
+ });
189
+
190
+ test("multiRepoStrategy is undefined when not set", () => {
191
+ const config = loadConfig({});
192
+ expect(config.multiRepoStrategy).toBeUndefined();
193
+ });
194
+
195
+ test("loads plugins from AAD_PLUGINS env var", () => {
196
+ const config = loadConfig({ AAD_PLUGINS: "./my-plugin.ts, /abs/plugin.ts" });
197
+ expect(config.plugins).toEqual(["./my-plugin.ts", "/abs/plugin.ts"]);
198
+ });
199
+
200
+ test("plugins is undefined when AAD_PLUGINS not set", () => {
201
+ const config = loadConfig({});
202
+ expect(config.plugins).toBeUndefined();
203
+ });
204
+ });