@ronkovic/aad 0.3.2 → 0.3.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -63,13 +63,13 @@ cli (エントリポイント)
63
63
  ### インストール不要で実行 (推奨)
64
64
 
65
65
  ```bash
66
- bunx aad run requirements.md
66
+ bunx @ronkovic/aad run requirements.md
67
67
  ```
68
68
 
69
69
  ### グローバルインストール
70
70
 
71
71
  ```bash
72
- bun install -g aad
72
+ bun install -g @ronkovic/aad
73
73
  aad run requirements.md
74
74
  ```
75
75
 
@@ -151,14 +151,15 @@ aad resume <run_id>
151
151
  aad cleanup
152
152
  ```
153
153
 
154
- ### 4. Webダッシュボード (オプション)
154
+ ### 4. Webダッシュボード
155
155
 
156
+ ダッシュボードはデフォルトで有効です。`http://localhost:7333` でリアルタイムの進捗・ログ・依存グラフを確認できます。
157
+
158
+ 無効にする場合:
156
159
  ```bash
157
- aad run requirements.md --workers 4 --dashboard
160
+ aad run requirements.md --workers 4 --no-dashboard
158
161
  ```
159
162
 
160
- ブラウザで `http://localhost:7333` を開くと、リアルタイムで進捗・ログ・依存グラフを確認できます。
161
-
162
163
  ## 開発ガイド
163
164
 
164
165
  ### 環境セットアップ
@@ -264,16 +265,14 @@ AADはClaudeとの通信に2つのadapterを提供:
264
265
 
265
266
  サービスごとに使い分け可能:
266
267
 
267
- ```typescript
268
- const providerConfig = {
269
- default: 'cli',
270
- overrides: {
271
- splitter: 'sdk', // 構造化JSON出力
272
- reviewer: 'sdk', // 構造化レビュー結果
273
- }
274
- };
268
+ デフォルトは `sdk` です。CLIに変更する場合:
269
+
270
+ ```bash
271
+ aad run requirements.md --provider cli
275
272
  ```
276
273
 
274
+ サービスごとに使い分けも可能です(設定ファイルで指定)。
275
+
277
276
  ## トラブルシューティング
278
277
 
279
278
  ### ログの確認
@@ -286,7 +285,11 @@ cat .aad/docs/<run_id>/logs/structured.jsonl | bunx pino-pretty
286
285
  ### Worktreeが残っている
287
286
 
288
287
  ```bash
289
- aad cleanup --all
288
+ # 全AAD worktreeをクリーンアップ
289
+ aad cleanup
290
+
291
+ # 特定のrunのみ
292
+ aad cleanup <run_id>
290
293
  ```
291
294
 
292
295
  ### タスクがスタックした
@@ -296,7 +299,7 @@ aad cleanup --all
296
299
  aad status <run_id>
297
300
 
298
301
  # 強制クリーンアップ
299
- aad cleanup --run <run_id> --force
302
+ aad cleanup <run_id> --force
300
303
  ```
301
304
 
302
305
  ## ライセンス
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ronkovic/aad",
3
- "version": "0.3.2",
3
+ "version": "0.3.4",
4
4
  "description": "Autonomous Agent Development Orchestrator - Multi-agent TDD pipeline powered by Claude",
5
5
  "module": "src/main.ts",
6
6
  "type": "module",
@@ -46,12 +46,20 @@ describe("Resume Command", () => {
46
46
  stores: {
47
47
  runStore: {
48
48
  get: mock(async () => mockRunState),
49
+ save: mock(async () => {}),
49
50
  } as any,
50
51
  taskStore: {
51
52
  list: mock(async () => [mockTask]),
52
53
  getAll: mock(async () => [mockTask]),
53
54
  save: mock(async () => {}),
54
55
  } as any,
56
+ workerStore: {
57
+ save: mock(async () => {}),
58
+ getAll: mock(async () => []),
59
+ getIdle: mock(async () => []),
60
+ get: mock(async () => null),
61
+ delete: mock(async () => {}),
62
+ } as any,
55
63
  } as any,
56
64
  dispatcher: {
57
65
  initialize: mock(async () => {}),
@@ -83,7 +83,7 @@ describe("resumeRun", () => {
83
83
  getAll: mock(async () => mockTasks),
84
84
  save: mock(async () => {}),
85
85
  } as any,
86
- workerStore: {} as any,
86
+ workerStore: { save: async () => {}, getAll: async () => [], getIdle: async () => [], get: async () => null, delete: async () => {} } as any,
87
87
  },
88
88
  dispatcher: {
89
89
  initialize: mock(async () => {}),
@@ -117,12 +117,15 @@ describe("runPipeline", () => {
117
117
  });
118
118
 
119
119
  test("runs pipeline with valid requirements", async () => {
120
- // Mock dispatcher.start to emit run:completed immediately
120
+ // Mock dispatcher.start to emit run:completed with the actual runId
121
121
  mockApp.dispatcher.start = mock(async () => {
122
- // Emit run:completed after a short delay to allow setup
123
122
  setTimeout(() => {
124
- // Use any runId that matches the pattern
125
- eventBus.emit({ type: "run:completed", runId: createRunId("any-run-id") });
123
+ // Capture runId from the queue:initialized event or use wildcard listener
124
+ // The run:completed handler checks runId, so we need to match it.
125
+ // Listen for queue:initialized to get the actual runId from dispatcher.initialize call.
126
+ const initCall = (mockApp.dispatcher.initialize as any).mock?.calls?.[0]?.[0];
127
+ const actualRunId = initCall?.runId ?? createRunId("fallback-run-id");
128
+ eventBus.emit({ type: "run:completed", runId: actualRunId });
126
129
  }, 50);
127
130
  });
128
131
 
@@ -86,13 +86,13 @@ export function createApp(options: AppOptions = {}): App {
86
86
 
87
87
  // 4. LogStore + SSE Transport初期化
88
88
  const logStore = new LogStore();
89
- const sseTransport = createSSETransport({ logStore, eventBus });
90
- void sseTransport;
89
+ // SSE transport registers EventBus listeners internally; no direct reference needed
90
+ createSSETransport({ logStore, eventBus });
91
91
 
92
92
  // 5. ProviderRegistry初期化
93
93
  const providerRegistry = createProviderRegistry(
94
94
  {
95
- default: options.providerDefault ?? "cli",
95
+ default: options.providerDefault ?? "sdk",
96
96
  overrides: options.providerOverrides,
97
97
  },
98
98
  config,
@@ -128,6 +128,7 @@ export function createApp(options: AppOptions = {}): App {
128
128
  staleTaskThreshold: config.timeouts.staleTask * 1000, // Convert seconds to milliseconds
129
129
  },
130
130
  logger: logger.child({ service: "task-queue" }),
131
+ getIdleWorkerIds: () => processManager.getIdleWorkers(),
131
132
  });
132
133
  logger.debug("Dispatcher initialized");
133
134
 
@@ -5,7 +5,9 @@
5
5
 
6
6
  import { Command } from "commander";
7
7
  import type { App } from "../app";
8
- import { createRunId } from "../../../shared/types";
8
+ import { createRunId, createWorkerId } from "../../../shared/types";
9
+ import { registerTaskDispatchHandler } from "./task-dispatch-handler";
10
+ import { installShutdownHandler } from "../../../shared/shutdown-handler";
9
11
 
10
12
  export function createResumeCommand(getApp: () => App): Command {
11
13
  const command = new Command("resume")
@@ -45,9 +47,10 @@ export async function resumeRun(app: App, runIdStr: string): Promise<void> {
45
47
 
46
48
  // 2. タスク一覧を読み込み
47
49
  const allTasks = await stores.taskStore.getAll();
48
- const runTasks = allTasks.filter((task) => {
49
- return task.taskId.includes(runIdStr);
50
- });
50
+ // In-memory store contains only one run's tasks (no cross-run contamination).
51
+ // For fs persistence, tasks are scoped to the run directory.
52
+ // Use all tasks — the dispatcher was initialized with this run's task plan.
53
+ const runTasks = allTasks;
51
54
 
52
55
  logger.info({ taskCount: runTasks.length }, "Tasks loaded");
53
56
 
@@ -104,9 +107,23 @@ export async function resumeRun(app: App, runIdStr: string): Promise<void> {
104
107
  await dispatcher.initialize(taskPlan);
105
108
  await processManager.initializePool(app.config.workers.num);
106
109
 
110
+ // Worker state初期化 (ProcessManagerと同じ1-indexedに合わせる)
111
+ for (let i = 0; i < app.config.workers.num; i++) {
112
+ const workerId = createWorkerId(`worker-${i + 1}`);
113
+ await stores.workerStore.save({ workerId, status: "idle", currentTask: null });
114
+ }
115
+
107
116
  logger.info("Dispatcher and ProcessManager re-initialized");
108
117
  console.log("✅ Dispatcher and ProcessManager re-initialized\n");
109
118
 
119
+ // 5.5. Install shutdown handler + task dispatch handler
120
+ installShutdownHandler({
121
+ runId,
122
+ stores: { runStore: stores.runStore, taskStore: stores.taskStore },
123
+ logger,
124
+ });
125
+ registerTaskDispatchHandler({ app, runId, parentBranch: runState.parentBranch });
126
+
110
127
  // 6. Dispatcher.start()
111
128
  dispatcher.start();
112
129
  logger.info({ runId }, "Run resumed");
@@ -8,20 +8,10 @@ import { randomUUID } from "node:crypto";
8
8
  import type { App } from "../app";
9
9
  import { formatProgress, formatTaskTable, createSpinner } from "../output";
10
10
  import { createRunId, createWorkerId } from "../../../shared/types";
11
- import type { WorkspaceInfo } from "../../../shared/types";
12
11
  import { getCurrentBranch, cleanupOrphanedFromPreviousRuns } from "../../git-workspace";
13
- import { executeTddPipeline } from "../../task-execution";
14
- import { checkMemoryAndWarn, waitForMemory } from "../../../shared/memory-check";
15
- import { installShutdownHandler, isShuttingDown } from "../../../shared/shutdown-handler";
16
- import {
17
- detectProjectType,
18
- detectPackageManager,
19
- detectTestFramework,
20
- detectFramework,
21
- detectOrm,
22
- detectArchitecturePattern,
23
- createBunFileChecker,
24
- } from "../../planning";
12
+ import { checkMemoryAndWarn } from "../../../shared/memory-check";
13
+ import { installShutdownHandler } from "../../../shared/shutdown-handler";
14
+ import { registerTaskDispatchHandler } from "./task-dispatch-handler";
25
15
 
26
16
  export function createRunCommand(getApp: () => App): Command {
27
17
  const command = new Command("run")
@@ -54,7 +44,7 @@ export function createRunCommand(getApp: () => App): Command {
54
44
  * メインパイプライン実行
55
45
  */
56
46
  export async function runPipeline(app: App, requirementsPath: string, keepWorktrees = false): Promise<void> {
57
- const { config, logger, eventBus, planningService, dispatcher, processManager, worktreeManager, providerRegistry, stores, branchManager } = app;
47
+ const { config, logger, eventBus, planningService, dispatcher, processManager, worktreeManager, stores, branchManager } = app;
58
48
 
59
49
  // Validate requirements file exists
60
50
  const reqFile = Bun.file(requirementsPath);
@@ -74,9 +64,6 @@ export async function runPipeline(app: App, requirementsPath: string, keepWorktr
74
64
  logger.warn({ error }, "Orphaned worktree cleanup failed (non-critical)");
75
65
  }
76
66
 
77
- // Semaphore for serializing tasks under memory pressure
78
- let memoryGateLock: Promise<void> = Promise.resolve();
79
-
80
67
  // 1. RunId生成 + 現在ブランチ取得
81
68
  const runId = createRunId(randomUUID());
82
69
  const parentBranch = await getCurrentBranch(process.cwd());
@@ -140,9 +127,9 @@ export async function runPipeline(app: App, requirementsPath: string, keepWorktr
140
127
  startTime: new Date().toISOString(),
141
128
  });
142
129
 
143
- // Worker state初期化
130
+ // Worker state初期化 (ProcessManagerと同じ1-indexedに合わせる)
144
131
  for (let i = 0; i < app.config.workers.num; i++) {
145
- const workerId = createWorkerId(`worker-${i}`);
132
+ const workerId = createWorkerId(`worker-${i + 1}`);
146
133
  await stores.workerStore.save({
147
134
  workerId,
148
135
  status: "idle",
@@ -157,99 +144,8 @@ export async function runPipeline(app: App, requirementsPath: string, keepWorktr
157
144
  logger,
158
145
  });
159
146
 
160
- // 5. EventBus: task:dispatched TDDパイプライン起動
161
- eventBus.on<Extract<import("../../../shared/events").AADEvent, { type: "task:dispatched" }>>(
162
- "task:dispatched",
163
- (event) => {
164
- const taskId = event.taskId;
165
- const workerId = event.workerId;
166
-
167
- if (isShuttingDown()) {
168
- logger.warn({ taskId }, "Shutdown in progress, skipping dispatch");
169
- return;
170
- }
171
-
172
- logger.info({ taskId, workerId }, "Task dispatched, starting TDD pipeline");
173
-
174
- void (async () => {
175
- try {
176
- // Memory gate: wait for sufficient memory before dispatch
177
- try {
178
- const memResult = await waitForMemory(logger);
179
- if (memResult.shouldReduceWorkers && config.workers.num > 1) {
180
- logger.warn(
181
- { freeGB: memResult.freeGB, workers: config.workers.num },
182
- "Low memory: serializing task execution",
183
- );
184
- // Serialize by waiting for previous task's lock
185
- const previousLock = memoryGateLock;
186
- let releaseLock: () => void;
187
- memoryGateLock = new Promise<void>((r) => { releaseLock = r; });
188
- await previousLock;
189
- // releaseLock will be called at the end of this task
190
- try {
191
- await runTask();
192
- } finally {
193
- releaseLock!();
194
- }
195
- return;
196
- }
197
- } catch (memError) {
198
- // MemoryError → abort task gracefully
199
- logger.error({ taskId, error: memError }, "Memory gate failed");
200
- eventBus.emit({ type: "task:failed", taskId, error: String(memError) });
201
- return;
202
- }
203
-
204
- await runTask();
205
-
206
- async function runTask() {
207
- const task = await stores.taskStore.get(taskId);
208
- if (!task) {
209
- throw new Error(`Task not found: ${taskId}`);
210
- }
211
-
212
- // Worktree作成
213
- const branchName = `aad/${runId}/${taskId}`;
214
- const worktreePath = await worktreeManager.createTaskWorktree(taskId, branchName);
215
- logger.info({ taskId, worktreePath, branchName }, "Worktree created");
216
-
217
- // Workspace情報 (project detectionから自動検出)
218
- const workspace = await detectWorkspace(worktreePath, logger);
219
-
220
- // Run before:execution hook
221
- await app.pluginManager.runHook("before:execution", { taskId, task, worktreePath });
222
-
223
- // TDDパイプライン実行
224
- const provider = providerRegistry.getProvider("implementer");
225
- const result = await executeTddPipeline(
226
- task,
227
- workspace,
228
- branchName,
229
- parentBranch,
230
- process.cwd(),
231
- runId,
232
- app.config,
233
- provider,
234
- app.mergeService,
235
- eventBus,
236
- undefined // testSpawner: use default
237
- );
238
-
239
- // Run after:execution hook
240
- await app.pluginManager.runHook("after:execution", { taskId, result });
241
-
242
- // 結果をEventBusに通知
243
- eventBus.emit({ type: "task:completed", taskId, result });
244
- } // end runTask
245
-
246
- } catch (error) {
247
- logger.error({ taskId, workerId, error }, "TDD pipeline failed");
248
- eventBus.emit({ type: "task:failed", taskId, error: String(error) });
249
- }
250
- })();
251
- }
252
- );
147
+ // 5. Register task dispatch handler (shared with resume)
148
+ registerTaskDispatchHandler({ app, runId, parentBranch });
253
149
 
254
150
  // 6. 進捗表示
255
151
  const progressSpinner = createSpinner("Executing tasks...");
@@ -269,16 +165,23 @@ export async function runPipeline(app: App, requirementsPath: string, keepWorktr
269
165
  // 7. Dispatcher.start() (イベントドリブン)
270
166
  void dispatcher.start();
271
167
 
272
- // 8. run:completed待機 → 結果表示
273
- await new Promise<void>((resolve) => {
168
+ // 8. run:completed待機 → 結果表示 (with timeout)
169
+ const pipelineTimeoutMs = (config.timeouts.staleTask || 5400) * taskPlan.tasks.length * 1000;
170
+ await new Promise<void>((resolve, reject) => {
274
171
  const handler: import("../../../shared/events").EventListener<
275
172
  Extract<import("../../../shared/events").AADEvent, { type: "run:completed" }>
276
- > = () => {
277
- // runIdの一致をチェック (テスト時はany runIdでも許可)
173
+ > = (event) => {
174
+ if (event.runId !== runId) return;
175
+ clearTimeout(timer);
278
176
  progressSpinner.stop();
279
177
  eventBus.off("run:completed", handler);
280
178
  resolve();
281
179
  };
180
+ const timer = setTimeout(() => {
181
+ progressSpinner.stop();
182
+ eventBus.off("run:completed", handler);
183
+ reject(new Error(`Pipeline timed out after ${Math.round(pipelineTimeoutMs / 1000)}s`));
184
+ }, pipelineTimeoutMs);
282
185
  eventBus.on("run:completed", handler);
283
186
  });
284
187
 
@@ -341,88 +244,3 @@ export async function runPipeline(app: App, requirementsPath: string, keepWorktr
341
244
  }
342
245
  }
343
246
 
344
- /**
345
- * Detect workspace information from worktree path
346
- */
347
- async function detectWorkspace(
348
- worktreePath: string,
349
- logger: import("pino").Logger
350
- ): Promise<WorkspaceInfo> {
351
- try {
352
- const fileChecker = createBunFileChecker();
353
- const projectType = await detectProjectType(worktreePath, fileChecker);
354
- const packageManager = await detectPackageManager(worktreePath, fileChecker);
355
- const testFramework = await detectTestFramework(worktreePath, projectType, fileChecker);
356
- const framework = await detectFramework(worktreePath, projectType, fileChecker);
357
- const orm = await detectOrm(worktreePath, projectType, fileChecker);
358
- const architecture = await detectArchitecturePattern(worktreePath, fileChecker);
359
-
360
- // Map planning module types → shared types
361
- const workspace: WorkspaceInfo = {
362
- path: worktreePath,
363
- language: mapProjectTypeToLanguage(projectType),
364
- packageManager,
365
- framework: framework !== "unknown" ? framework : "none",
366
- testFramework: mapTestFramework(testFramework),
367
- orm: orm !== "unknown" ? orm : undefined,
368
- architecturePattern: architecture !== "custom" && architecture !== "unknown" ? architecture : undefined,
369
- };
370
-
371
- logger.debug({ workspace }, "Workspace detected");
372
- return workspace;
373
- } catch (error) {
374
- // Fallback to defaults on detection failure
375
- logger.warn({ error, worktreePath }, "Project detection failed, using fallback");
376
- return {
377
- path: worktreePath,
378
- language: "typescript",
379
- packageManager: "bun",
380
- framework: "none",
381
- testFramework: "bun-test",
382
- };
383
- }
384
- }
385
-
386
- /**
387
- * Map ProjectType → language string
388
- */
389
- function mapProjectTypeToLanguage(projectType: import("../../planning").ProjectType): string {
390
- const mapping: Record<import("../../planning").ProjectType, string> = {
391
- go: "go",
392
- "go-workspace": "go",
393
- rust: "rust",
394
- python: "python",
395
- nodejs: "typescript",
396
- nextjs: "typescript",
397
- express: "typescript",
398
- react: "typescript",
399
- terraform: "hcl",
400
- unknown: "typescript",
401
- };
402
- return mapping[projectType];
403
- }
404
-
405
- /**
406
- * Map planning TestFramework → shared TestFramework
407
- */
408
- function mapTestFramework(tf: import("../../planning").TestFramework): import("../../../shared/types").TestFramework {
409
- // "bun:test" → "bun-test"
410
- if (tf === "bun:test") return "bun-test";
411
- // "unittest" は shared types にないので "pytest" にフォールバック
412
- if (tf === "unittest") return "pytest";
413
- // "terraform-validate" は shared types にないので "unknown" にフォールバック
414
- if (tf === "terraform-validate") return "unknown";
415
-
416
- // その他は1:1マッピング ("pytest", "vitest", "jest", "mocha", "go-test", "cargo-test", "unknown")
417
- const mapping: Partial<Record<import("../../planning").TestFramework, import("../../../shared/types").TestFramework>> = {
418
- pytest: "pytest",
419
- vitest: "vitest",
420
- jest: "jest",
421
- mocha: "mocha",
422
- "go-test": "go-test",
423
- "cargo-test": "cargo",
424
- unknown: "unknown",
425
- };
426
-
427
- return mapping[tf] ?? "unknown";
428
- }
@@ -0,0 +1,206 @@
1
+ /**
2
+ * Task Dispatch Handler
3
+ * Shared logic for registering task:dispatched event handler.
4
+ * Used by both `run` and `resume` commands.
5
+ */
6
+
7
+ import type { App } from "../app";
8
+ import type { TaskId, WorkerId, WorkspaceInfo } from "../../../shared/types";
9
+ import type { RunId } from "../../../shared/types";
10
+ import type { AADEvent } from "../../../shared/events";
11
+ import { executeTddPipeline } from "../../task-execution";
12
+ import { waitForMemory } from "../../../shared/memory-check";
13
+ import { isShuttingDown } from "../../../shared/shutdown-handler";
14
+ import {
15
+ detectProjectType,
16
+ detectPackageManager,
17
+ detectTestFramework,
18
+ detectFramework,
19
+ detectOrm,
20
+ detectArchitecturePattern,
21
+ createBunFileChecker,
22
+ } from "../../planning";
23
+
24
+ export interface TaskDispatchContext {
25
+ app: App;
26
+ runId: RunId;
27
+ parentBranch: string;
28
+ }
29
+
30
+ /**
31
+ * Register task:dispatched event handler on the EventBus.
32
+ * Handles memory gate, worker state, worktree creation, and TDD pipeline execution.
33
+ */
34
+ export function registerTaskDispatchHandler(ctx: TaskDispatchContext): void {
35
+ const { app, runId, parentBranch } = ctx;
36
+ const { eventBus, logger, config, processManager, worktreeManager, providerRegistry, stores } = app;
37
+
38
+ let memoryGateLock: Promise<void> = Promise.resolve();
39
+
40
+ async function executeTask(taskId: TaskId, workerId: WorkerId): Promise<void> {
41
+ try {
42
+ await stores.workerStore.save({ workerId, status: "busy", currentTask: taskId });
43
+ } catch (storeError) {
44
+ logger.warn({ workerId, error: storeError }, "Worker store update failed");
45
+ }
46
+
47
+ let releaseMemoryGate: (() => void) | null = null;
48
+ try {
49
+ releaseMemoryGate = await acquireMemoryGate();
50
+
51
+ const task = await stores.taskStore.get(taskId);
52
+ if (!task) {
53
+ throw new Error(`Task not found: ${taskId}`);
54
+ }
55
+
56
+ const branchName = `aad/${runId}/${taskId}`;
57
+ const worktreePath = await worktreeManager.createTaskWorktree(taskId, branchName);
58
+ logger.info({ taskId, worktreePath, branchName }, "Worktree created");
59
+
60
+ const workspace = await detectWorkspace(worktreePath, logger);
61
+
62
+ await app.pluginManager.runHook("before:execution", { taskId, task, worktreePath });
63
+
64
+ const provider = providerRegistry.getProvider("implementer");
65
+ const result = await executeTddPipeline(
66
+ task, workspace, branchName, parentBranch, process.cwd(),
67
+ runId, app.config, provider, app.mergeService, eventBus,
68
+ );
69
+
70
+ await app.pluginManager.runHook("after:execution", { taskId, result });
71
+ eventBus.emit({ type: "task:completed", taskId, result });
72
+ } catch (error) {
73
+ logger.error({ taskId, workerId, error }, "TDD pipeline failed");
74
+ eventBus.emit({ type: "task:failed", taskId, error: String(error) });
75
+ } finally {
76
+ releaseMemoryGate?.();
77
+ try {
78
+ processManager.completeTask(workerId);
79
+ await stores.workerStore.save({ workerId, status: "idle", currentTask: null });
80
+ } catch (stateError) {
81
+ logger.warn({ workerId, taskId, error: stateError }, "Worker state recovery failed");
82
+ }
83
+ }
84
+ }
85
+
86
+ async function acquireMemoryGate(): Promise<(() => void) | null> {
87
+ const memResult = await waitForMemory(logger);
88
+ if (memResult.shouldReduceWorkers && config.workers.num > 1) {
89
+ logger.warn({ freeGB: memResult.freeGB }, "Low memory: serializing task execution");
90
+ const previousLock = memoryGateLock;
91
+ let releaseLock: () => void;
92
+ memoryGateLock = new Promise<void>((r) => { releaseLock = r; });
93
+ await previousLock;
94
+ return () => releaseLock!();
95
+ }
96
+ return null;
97
+ }
98
+
99
+ eventBus.on<Extract<AADEvent, { type: "task:dispatched" }>>(
100
+ "task:dispatched",
101
+ (event) => {
102
+ const taskId = event.taskId;
103
+ const workerId = event.workerId;
104
+
105
+ if (isShuttingDown()) {
106
+ logger.warn({ taskId }, "Shutdown in progress, skipping dispatch");
107
+ return;
108
+ }
109
+
110
+ logger.info({ taskId, workerId }, "Task dispatched, starting TDD pipeline");
111
+
112
+ try {
113
+ processManager.assignTask(workerId, taskId);
114
+ } catch (stateError) {
115
+ logger.warn({ workerId, taskId, error: stateError }, "Worker state transition failed");
116
+ }
117
+
118
+ void executeTask(taskId, workerId);
119
+ }
120
+ );
121
+ }
122
+
123
+ /**
124
+ * Detect workspace information from worktree path
125
+ */
126
+ async function detectWorkspace(
127
+ worktreePath: string,
128
+ logger: import("pino").Logger
129
+ ): Promise<WorkspaceInfo> {
130
+ try {
131
+ const fileChecker = createBunFileChecker();
132
+ const projectType = await detectProjectType(worktreePath, fileChecker);
133
+ const packageManager = await detectPackageManager(worktreePath, fileChecker);
134
+ const testFramework = await detectTestFramework(worktreePath, projectType, fileChecker);
135
+ const framework = await detectFramework(worktreePath, projectType, fileChecker);
136
+ const orm = await detectOrm(worktreePath, projectType, fileChecker);
137
+ const architecture = await detectArchitecturePattern(worktreePath, fileChecker);
138
+
139
+ const language = await detectLanguage(worktreePath, projectType);
140
+
141
+ const workspace: WorkspaceInfo = {
142
+ path: worktreePath,
143
+ language,
144
+ packageManager,
145
+ framework: framework !== "unknown" ? framework : "none",
146
+ testFramework: mapTestFramework(testFramework),
147
+ orm: orm !== "unknown" ? orm : undefined,
148
+ architecturePattern: architecture !== "custom" && architecture !== "unknown" ? architecture : undefined,
149
+ };
150
+
151
+ logger.debug({ workspace }, "Workspace detected");
152
+ return workspace;
153
+ } catch (error) {
154
+ logger.warn({ error, worktreePath }, "Project detection failed, using fallback");
155
+ return {
156
+ path: worktreePath,
157
+ language: "typescript",
158
+ packageManager: "bun",
159
+ framework: "none",
160
+ testFramework: "bun-test",
161
+ };
162
+ }
163
+ }
164
+
165
+ async function detectLanguage(worktreePath: string, projectType: import("../../planning").ProjectType): Promise<string> {
166
+ const nodeTypes = ["nodejs", "nextjs", "express", "react"];
167
+ if (nodeTypes.includes(projectType)) {
168
+ const tsConfigExists = await Bun.file(`${worktreePath}/tsconfig.json`).exists();
169
+ return tsConfigExists ? "typescript" : "javascript";
170
+ }
171
+ return mapProjectTypeToLanguage(projectType);
172
+ }
173
+
174
+ function mapProjectTypeToLanguage(projectType: import("../../planning").ProjectType): string {
175
+ const mapping: Record<import("../../planning").ProjectType, string> = {
176
+ go: "go",
177
+ "go-workspace": "go",
178
+ rust: "rust",
179
+ python: "python",
180
+ nodejs: "javascript",
181
+ nextjs: "typescript",
182
+ express: "javascript",
183
+ react: "javascript",
184
+ terraform: "hcl",
185
+ unknown: "typescript",
186
+ };
187
+ return mapping[projectType];
188
+ }
189
+
190
+ function mapTestFramework(tf: import("../../planning").TestFramework): import("../../../shared/types").TestFramework {
191
+ if (tf === "bun:test") return "bun-test";
192
+ if (tf === "unittest") return "pytest";
193
+ if (tf === "terraform-validate") return "unknown";
194
+
195
+ const mapping: Partial<Record<import("../../planning").TestFramework, import("../../../shared/types").TestFramework>> = {
196
+ pytest: "pytest",
197
+ vitest: "vitest",
198
+ jest: "jest",
199
+ mocha: "mocha",
200
+ "go-test": "go-test",
201
+ "cargo-test": "cargo",
202
+ unknown: "unknown",
203
+ };
204
+
205
+ return mapping[tf] ?? "unknown";
206
+ }
@@ -1,6 +1,9 @@
1
1
  /**
2
- * Shutdown Manager
2
+ * Shutdown Manager (Public API)
3
3
  * LIFO順でshutdownハンドラを実行し、二重shutdown防止、シグナルハンドリングを提供
4
+ *
5
+ * Note: Currently unused internally — run/resume use shared/shutdown-handler.ts instead.
6
+ * Retained as public API for plugin/external integrations.
4
7
  */
5
8
 
6
9
  import type { Logger } from "pino";
@@ -67,14 +67,10 @@ export class MergeService {
67
67
  const conflicts = await this.detectConflicts(parentWorktree);
68
68
 
69
69
  if (conflicts.length > 0) {
70
- // Abort merge
71
- await gitExec(
72
- ["merge", "--abort"],
73
- { cwd: parentWorktree, logger: this.logger }
74
- );
75
-
76
- this.logger?.warn({ taskId, conflicts }, "Merge conflict detected");
70
+ this.logger?.warn({ taskId, conflicts }, "Merge conflict detected (merge left in progress for resolution)");
77
71
 
72
+ // Do NOT abort — leave the merge in progress so the resolver can
73
+ // edit conflicted files, `git add` them, and then commit.
78
74
  return {
79
75
  success: false,
80
76
  conflicts,
@@ -152,7 +152,9 @@ export class PlanningService {
152
152
  throw error;
153
153
  } finally {
154
154
  // Cleanup temp directory
155
- await rm(tempDir, { recursive: true, force: true }).catch(() => {});
155
+ await rm(tempDir, { recursive: true, force: true }).catch((cleanupErr) => {
156
+ this.logger.warn({ tempDir, error: cleanupErr }, "Failed to cleanup planning temp directory");
157
+ });
156
158
  }
157
159
  }
158
160
 
@@ -29,7 +29,7 @@ export class ProcessManager {
29
29
  /**
30
30
  * Initialize worker pool
31
31
  */
32
- initializePool(numWorkers: number): void {
32
+ async initializePool(numWorkers: number): Promise<void> {
33
33
  if (this.initialized) {
34
34
  throw new ProcessManagerError("ProcessManager already initialized");
35
35
  }
@@ -11,6 +11,7 @@ import type { EventBus } from "@aad/shared/events";
11
11
  import type { ProcessSpawner } from "./phases/tester-verify";
12
12
 
13
13
  import { estimateTaskComplexity, getAdaptiveEffortLevel } from "@aad/claude-provider";
14
+ import { gitExec } from "@aad/git-workspace";
14
15
  import { runTesterRed } from "./phases/tester-red";
15
16
  import { runImplementerGreen } from "./phases/implementer-green";
16
17
  import { runTests } from "./phases/tester-verify";
@@ -136,8 +137,11 @@ export async function executeTddPipeline(
136
137
  // ===== Commit generated code =====
137
138
  // Commit changes after Green phase so they can be merged later
138
139
  try {
139
- await Bun.$`git -C ${workspace.path} add -A`.quiet();
140
- await Bun.$`git -C ${workspace.path} commit -m ${"feat: Implement " + task.title}`.quiet();
140
+ await gitExec(["add", "-A"], { cwd: workspace.path });
141
+ await gitExec(
142
+ ["commit", "--no-gpg-sign", "-m", `feat: Implement ${task.title}`],
143
+ { cwd: workspace.path }
144
+ );
141
145
  } catch (_error) {
142
146
  // If commit fails (e.g., no changes), log but don't fail the pipeline
143
147
  // This can happen if Claude didn't generate any new files
@@ -256,11 +260,10 @@ export async function executeTddPipeline(
256
260
  }
257
261
 
258
262
  if (mergeResult.hadConflict) {
259
- // Emit conflict event for monitoring
260
263
  eventBus.emit({
261
264
  type: "execution:merge:conflict",
262
265
  taskId: task.taskId,
263
- conflictedFiles: [], // MergeService would provide this
266
+ conflictedFiles: mergeResult.output ? [mergeResult.output] : [],
264
267
  });
265
268
  }
266
269
 
@@ -1,18 +1,18 @@
1
1
  // Main executor
2
2
  export { executeTddPipeline } from "./executor";
3
3
 
4
+ // Shared PhaseResult type (moved from individual phases)
5
+ export type { PhaseResult } from "@aad/shared/types";
6
+
4
7
  // Phase functions
5
8
  export { runTesterRed, buildRedPhasePrompt } from "./phases/tester-red";
6
- export type { PhaseResult as TesterRedResult, TesterRedOptions } from "./phases/tester-red";
9
+ export type { TesterRedOptions } from "./phases/tester-red";
7
10
 
8
11
  export {
9
12
  runImplementerGreen,
10
13
  buildGreenPhasePrompt,
11
14
  } from "./phases/implementer-green";
12
- export type {
13
- PhaseResult as ImplementerGreenResult,
14
- ImplementerGreenOptions,
15
- } from "./phases/implementer-green";
15
+ export type { ImplementerGreenOptions } from "./phases/implementer-green";
16
16
 
17
17
  export { runTests, buildTestCommand } from "./phases/tester-verify";
18
18
  export type {
@@ -27,7 +27,6 @@ export {
27
27
  buildTeamsReviewPrompt,
28
28
  } from "./phases/reviewer";
29
29
  export type {
30
- PhaseResult as ReviewerResult,
31
30
  ReviewerOptions,
32
31
  ReviewConfig,
33
32
  } from "./phases/reviewer";
@@ -1,12 +1,6 @@
1
- import type { Task, WorkspaceInfo, EffortLevel } from "@aad/shared/types";
1
+ import type { Task, WorkspaceInfo, EffortLevel, PhaseResult } from "@aad/shared/types";
2
2
  import type { ClaudeProvider } from "@aad/claude-provider";
3
3
 
4
- export interface PhaseResult {
5
- success: boolean;
6
- output: string;
7
- duration?: number;
8
- }
9
-
10
4
  export interface ImplementerGreenOptions {
11
5
  effortLevel?: EffortLevel;
12
6
  model?: string;
@@ -1,6 +1,7 @@
1
1
  import type { Task, WorkspaceInfo } from "@aad/shared/types";
2
2
  import type { ClaudeProvider } from "@aad/claude-provider";
3
3
  import type { MergeService } from "@aad/git-workspace";
4
+ import { gitExec } from "@aad/git-workspace";
4
5
 
5
6
  export interface MergePhaseResult {
6
7
  success: boolean;
@@ -105,6 +106,29 @@ export async function runMergePhase(
105
106
  const duration = Date.now() - startTime;
106
107
 
107
108
  if (response.exitCode === 0) {
109
+ // Finalize the merge commit after conflict resolution
110
+ try {
111
+ const statusResult = await gitExec(["diff", "--name-only", "--diff-filter=U"], { cwd: parentWorktree });
112
+ if (statusResult.stdout.trim() === "") {
113
+ // All conflicts resolved — complete the merge commit
114
+ await gitExec(
115
+ ["commit", "--no-gpg-sign", "--no-edit"],
116
+ { cwd: parentWorktree }
117
+ );
118
+ } else {
119
+ // Still unresolved — abort and report failure
120
+ await gitExec(["merge", "--abort"], { cwd: parentWorktree });
121
+ return {
122
+ success: false,
123
+ output: `Unresolved conflicts remain: ${statusResult.stdout.trim()}`,
124
+ hadConflict: true,
125
+ duration,
126
+ };
127
+ }
128
+ } catch (_commitError) {
129
+ // Commit may already be done by Claude via Bash tool — not fatal
130
+ }
131
+
108
132
  return {
109
133
  success: true,
110
134
  output: response.result,
@@ -1,12 +1,6 @@
1
- import type { Task, WorkspaceInfo, RunId } from "@aad/shared/types";
1
+ import type { Task, WorkspaceInfo, RunId, PhaseResult } from "@aad/shared/types";
2
2
  import type { ClaudeProvider, SubagentConfig } from "@aad/claude-provider";
3
3
 
4
- export interface PhaseResult {
5
- success: boolean;
6
- output: string;
7
- duration?: number;
8
- }
9
-
10
4
  export interface ReviewerOptions {
11
5
  model?: string;
12
6
  timeout?: number;
@@ -1,12 +1,6 @@
1
- import type { Task, WorkspaceInfo, EffortLevel } from "@aad/shared/types";
1
+ import type { Task, WorkspaceInfo, EffortLevel, PhaseResult } from "@aad/shared/types";
2
2
  import type { ClaudeProvider } from "@aad/claude-provider";
3
3
 
4
- export interface PhaseResult {
5
- success: boolean;
6
- output: string;
7
- duration?: number;
8
- }
9
-
10
4
  export interface TesterRedOptions {
11
5
  effortLevel?: EffortLevel;
12
6
  model?: string;
@@ -39,6 +39,8 @@ export interface DispatcherDeps {
39
39
  eventBus: EventBus;
40
40
  config: DispatcherConfig;
41
41
  logger: Logger;
42
+ /** Optional synchronous idle worker provider (e.g. ProcessManager) */
43
+ getIdleWorkerIds?: () => WorkerId[];
42
44
  }
43
45
 
44
46
  /**
@@ -161,8 +163,10 @@ export class Dispatcher {
161
163
  return false;
162
164
  }
163
165
 
164
- // Get idle workers
165
- const idleWorkers = await this.deps.workerStore.getIdle();
166
+ // Get idle workers (prefer synchronous source if available)
167
+ const idleWorkers = this.deps.getIdleWorkerIds
168
+ ? this.deps.getIdleWorkerIds().map(id => ({ workerId: id, status: "idle" as const, currentTask: null }))
169
+ : await this.deps.workerStore.getIdle();
166
170
 
167
171
  if (idleWorkers.length === 0) {
168
172
  this.deps.logger.debug("No idle workers available");
@@ -158,6 +158,18 @@ export class EventBus {
158
158
  this.emitter.emit("*", event);
159
159
  }
160
160
 
161
+ /**
162
+ * Get current listener count for monitoring
163
+ */
164
+ getListenerCount(type?: EventType): number {
165
+ if (type) {
166
+ return this.emitter.listenerCount(type);
167
+ }
168
+ return this.emitter.eventNames().reduce(
169
+ (sum, name) => sum + this.emitter.listenerCount(name), 0
170
+ );
171
+ }
172
+
161
173
  off<T extends AADEvent>(
162
174
  type: T["type"] | "*",
163
175
  listener: EventListener<T>
@@ -17,16 +17,30 @@ export interface MemoryStatus {
17
17
 
18
18
  /**
19
19
  * Get current system memory status (macOS only)
20
+ *
21
+ * Uses vm_stat but counts inactive + free + speculative as "available" memory.
22
+ * macOS aggressively caches files in inactive pages which are immediately
23
+ * reclaimable — counting only "free" pages dramatically underreports
24
+ * available memory (e.g. 0.2GB "free" when 4GB+ is actually available).
25
+ *
26
+ * Environment variable AAD_SKIP_MEMORY_CHECK=1 disables all memory checks.
20
27
  */
21
28
  export async function getMemoryStatus(): Promise<MemoryStatus> {
22
- const { stdout } = await Bun.spawn(["vm_stat"], {
23
- stdout: "pipe",
24
- }).exited.then(async (code) => {
25
- if (code !== 0) throw new Error("vm_stat failed");
29
+ // Allow disabling memory checks entirely
30
+ if (process.env.AAD_SKIP_MEMORY_CHECK === "1") {
26
31
  return {
27
- stdout: await new Response(Bun.spawn(["vm_stat"], { stdout: "pipe" }).stdout).text(),
32
+ totalGB: 16,
33
+ usedGB: 0,
34
+ freeGB: 16,
35
+ usedPercent: 0,
36
+ isLowMemory: false,
28
37
  };
29
- });
38
+ }
39
+
40
+ const proc = Bun.spawn(["vm_stat"], { stdout: "pipe" });
41
+ const stdout = await new Response(proc.stdout).text();
42
+ const code = await proc.exited;
43
+ if (code !== 0) throw new Error("vm_stat failed");
30
44
 
31
45
  // Parse vm_stat output
32
46
  const pageSizeMatch = stdout.match(/page size of (\d+) bytes/);
@@ -35,6 +49,7 @@ export async function getMemoryStatus(): Promise<MemoryStatus> {
35
49
  const inactiveMatch = stdout.match(/Pages inactive:\s+(\d+)/);
36
50
  const speculativeMatch = stdout.match(/Pages speculative:\s+(\d+)/);
37
51
  const wiredMatch = stdout.match(/Pages wired down:\s+(\d+)/);
52
+ const purgableMatch = stdout.match(/Pages purgeable:\s+(\d+)/);
38
53
 
39
54
  if (!pageSizeMatch || !freeMatch || !activeMatch || !inactiveMatch || !wiredMatch) {
40
55
  throw new Error("Failed to parse vm_stat output");
@@ -46,30 +61,33 @@ export async function getMemoryStatus(): Promise<MemoryStatus> {
46
61
  const inactivePages = parseInt(inactiveMatch[1]!, 10);
47
62
  const speculativePages = parseInt(speculativeMatch?.[1] ?? "0", 10);
48
63
  const wiredPages = parseInt(wiredMatch[1]!, 10);
64
+ const purgablePages = parseInt(purgableMatch?.[1] ?? "0", 10);
49
65
 
50
66
  // Calculate memory in GB
51
67
  const bytesToGB = (bytes: number) => bytes / (1024 * 1024 * 1024);
52
68
  const pagesToGB = (pages: number) => bytesToGB(pages * pageSize);
53
69
 
54
- const freeGB = pagesToGB(freePages + speculativePages);
55
- const usedGB = pagesToGB(activePages + inactivePages + wiredPages);
56
- const totalGB = freeGB + usedGB;
70
+ // Available = free + inactive + speculative + purgeable
71
+ // macOS reclaims inactive/purgeable pages on demand — they are effectively free
72
+ const availableGB = pagesToGB(freePages + inactivePages + speculativePages + purgablePages);
73
+ const usedGB = pagesToGB(activePages + wiredPages);
74
+ const totalGB = availableGB + usedGB;
57
75
  const usedPercent = (usedGB / totalGB) * 100;
58
76
 
59
- // Determine if memory is low
60
- const isLowMemory = freeGB < 2.0; // Less than 2GB free
77
+ // Determine if memory is low (using available, not just free)
78
+ const isLowMemory = availableGB < 2.0;
61
79
  let recommendedAction: string | undefined;
62
80
 
63
- if (freeGB < 1.5) {
64
- recommendedAction = "Critical: Free memory < 1.5GB. Close heavy applications (Chrome, Docker) before running AAD.";
65
- } else if (freeGB < 2.5) {
66
- recommendedAction = "Warning: Free memory < 2.5GB. Consider closing unnecessary applications.";
81
+ if (availableGB < 1.5) {
82
+ recommendedAction = "Critical: Available memory < 1.5GB. Close heavy applications (Chrome, Docker) before running AAD.";
83
+ } else if (availableGB < 2.5) {
84
+ recommendedAction = "Warning: Available memory < 2.5GB. Consider closing unnecessary applications.";
67
85
  }
68
86
 
69
87
  return {
70
88
  totalGB: Math.round(totalGB * 10) / 10,
71
89
  usedGB: Math.round(usedGB * 10) / 10,
72
- freeGB: Math.round(freeGB * 10) / 10,
90
+ freeGB: Math.round(availableGB * 10) / 10,
73
91
  usedPercent: Math.round(usedPercent),
74
92
  isLowMemory,
75
93
  recommendedAction,
@@ -46,7 +46,11 @@ export function _resetShutdownState(): void {
46
46
  * Install signal handlers for graceful shutdown with state persistence.
47
47
  */
48
48
  export function installShutdownHandler(options: ShutdownHandlerOptions): void {
49
- if (_installed) return;
49
+ // Allow re-installation with new options (e.g. resume after run)
50
+ if (_installed && _cleanup) {
51
+ _cleanup();
52
+ _cleanup = null;
53
+ }
50
54
  _installed = true;
51
55
 
52
56
  const { runId, stores, logger, exitFn = (code: number) => process.exit(code) } = options;
@@ -141,3 +141,12 @@ export interface WorkspaceInfo {
141
141
  orm?: string;
142
142
  architecturePattern?: string;
143
143
  }
144
+
145
+ /**
146
+ * Common result type for TDD pipeline phases (Red/Green/Review)
147
+ */
148
+ export interface PhaseResult {
149
+ success: boolean;
150
+ output: string;
151
+ duration?: number;
152
+ }