@ronkovic/aad 0.3.1 → 0.3.2

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
@@ -91,6 +91,21 @@ bun install
91
91
  bun run dev
92
92
  ```
93
93
 
94
+ ## システム要件
95
+
96
+ | | RAM | 推奨ワーカー数 |
97
+ |---|---|---|
98
+ | **最小** | 8GB | 1(シングルワーカー) |
99
+ | **推奨** | 16GB | 2(並列ワーカー) |
100
+
101
+ **メモリ安全機能:**
102
+ - **自動メモリゲート** — 空きメモリが閾値を下回ると新規ワーカーの起動を抑制
103
+ - **シーケンシャルフォールバック** — メモリ不足時に並列実行から逐次実行へ自動切替
104
+ - **グレースフルシャットダウン** — メモリ圧迫時に安全にプロセスを終了
105
+ - **ランタイム監視** — 実行中のメモリ使用量をリアルタイムで監視
106
+
107
+ > **macOS:** メモリ監視は `vm_stat` ベースで実装されています。Linux環境では `/proc/meminfo` を使用します。
108
+
94
109
  ## クイックスタート
95
110
 
96
111
  ### 1. 要件ファイルの作成
@@ -303,6 +318,7 @@ aad cleanup --run <run_id> --force
303
318
  ## リンク
304
319
 
305
320
  - [Architecture Documentation](docs/architecture.md)
321
+ - [Case Studies](docs/case-studies/) - Real-world execution examples
306
322
  - [Rewrite Plan (Internal)](docs/rewrite-plan/README.md)
307
323
  - [Project Guidelines](CLAUDE.md)
308
324
  - [Issue Tracker](https://github.com/ronkovic/aad/issues)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ronkovic/aad",
3
- "version": "0.3.1",
3
+ "version": "0.3.2",
4
4
  "description": "Autonomous Agent Development Orchestrator - Multi-agent TDD pipeline powered by Claude",
5
5
  "module": "src/main.ts",
6
6
  "type": "module",
package/src/main.ts CHANGED
@@ -27,7 +27,7 @@ program
27
27
  .option("--persist <mode>", "Persistence mode: memory or fs", "memory")
28
28
  .option("--debug", "Enable debug logging", false)
29
29
  .option("--no-dashboard", "Disable dashboard server")
30
- .option("--provider <type>", "Default provider: cli or sdk", "cli");
30
+ .option("--provider <type>", "Default provider: cli or sdk", "sdk");
31
31
 
32
32
  // App factory with global options
33
33
  const getApp = (): App => {
@@ -15,6 +15,7 @@ export interface ClaudeRequest {
15
15
  maxRetries?: number;
16
16
  allowedTools?: string[];
17
17
  outputFormat?: "text" | "json";
18
+ jsonSchema?: Record<string, unknown>; // JSON schema for structured output (when outputFormat="json")
18
19
  permissionMode?: "acceptEdits" | "bypassPermissions" | "default" | "delegate" | "dontAsk" | "plan";
19
20
  cwd?: string;
20
21
  appendSystemPrompt?: string;
@@ -2,6 +2,7 @@ import type { ClaudeProvider, ClaudeRequest, ClaudeResponse } from "./claude-pro
2
2
  import type { Config } from "../../shared/config";
3
3
  import type pino from "pino";
4
4
  import { ClaudeProviderError } from "../../shared/errors";
5
+ import { createMemoryMonitor } from "../../shared/memory-monitor";
5
6
  import {
6
7
  query,
7
8
  type Options,
@@ -26,10 +27,32 @@ export class ClaudeSdkAdapter implements ClaudeProvider {
26
27
  const startTime = Date.now();
27
28
  const childLogger = this.logger.child({ adapter: "sdk", prompt: request.prompt.slice(0, 50) });
28
29
 
30
+ const memMonitor = createMemoryMonitor({
31
+ logger: childLogger,
32
+ checkIntervalMs: 2000,
33
+ criticalFreeGB: 0.8,
34
+ });
35
+
29
36
  try {
30
37
  childLogger.info("Starting SDK query");
31
38
 
32
39
  const options = this.buildOptions(request);
40
+
41
+ // Memory monitor: abort query if memory becomes critical
42
+ const memoryAbortController = new AbortController();
43
+ memMonitor.onCritical(() => {
44
+ childLogger.error("Critical memory: aborting SDK query");
45
+ memoryAbortController.abort();
46
+ });
47
+
48
+ // Chain abort controllers if request already has a timeout
49
+ if (options.abortController) {
50
+ const originalController = options.abortController;
51
+ originalController.signal.addEventListener("abort", () => memoryAbortController.abort());
52
+ }
53
+ options.abortController = memoryAbortController;
54
+
55
+ memMonitor.start();
33
56
  const q = query({ prompt: request.prompt, options });
34
57
 
35
58
  // AsyncGeneratorを消費してClaudeResponseにマッピング
@@ -48,10 +71,21 @@ export class ClaudeSdkAdapter implements ClaudeProvider {
48
71
  const textBlocks = assistantMsg.message.content.filter(
49
72
  (block: { type: string }) => block.type === "text"
50
73
  );
74
+
75
+ childLogger.debug({
76
+ contentLength: assistantMsg.message.content.length,
77
+ textBlockCount: textBlocks.length,
78
+ blockTypes: assistantMsg.message.content.map((b: { type: string }) => b.type)
79
+ }, "Assistant message content");
80
+
51
81
  if (textBlocks.length > 0) {
52
82
  lastAssistantMessage = textBlocks
53
83
  .map((block: { text?: string }) => block.text ?? "")
54
84
  .join("\n");
85
+ childLogger.debug({
86
+ textLength: lastAssistantMessage.length,
87
+ textPreview: lastAssistantMessage.slice(0, 300)
88
+ }, "Extracted text from assistant message");
55
89
  }
56
90
  actualModel = assistantMsg.message.model ?? actualModel;
57
91
 
@@ -64,8 +98,22 @@ export class ClaudeSdkAdapter implements ClaudeProvider {
64
98
  resultMessage = message as SDKResultMessage;
65
99
  if (resultMessage.subtype === "success") {
66
100
  const successMsg = resultMessage as SDKResultSuccess;
67
- lastAssistantMessage = successMsg.result;
101
+ childLogger.debug({
102
+ sdkResultLength: successMsg.result?.length ?? 0,
103
+ sdkResultPreview: successMsg.result?.slice(0, 200) ?? "",
104
+ hasResult: !!successMsg.result,
105
+ willOverwrite: !!lastAssistantMessage
106
+ }, "SDK result message received");
107
+
108
+ // Result message contains the final output; use it if available
109
+ if (successMsg.result) {
110
+ lastAssistantMessage = successMsg.result;
111
+ }
68
112
  exitCode = 0;
113
+ childLogger.debug({
114
+ finalLength: lastAssistantMessage.length,
115
+ finalPreview: lastAssistantMessage.slice(0, 200)
116
+ }, "Result success");
69
117
  } else {
70
118
  const errorMsg = resultMessage as SDKResultError;
71
119
  error = errorMsg.errors.join("; ");
@@ -74,6 +122,7 @@ export class ClaudeSdkAdapter implements ClaudeProvider {
74
122
  }
75
123
  }
76
124
 
125
+ memMonitor.stop();
77
126
  const duration = Date.now() - startTime;
78
127
 
79
128
  if (exitCode !== 0) {
@@ -85,14 +134,22 @@ export class ClaudeSdkAdapter implements ClaudeProvider {
85
134
 
86
135
  childLogger.info({ duration, model: actualModel }, "SDK query completed");
87
136
 
88
- return {
137
+ const response: ClaudeResponse = {
89
138
  result: lastAssistantMessage,
90
139
  exitCode,
91
140
  model: actualModel,
92
141
  effortLevel: request.effortLevel ?? "medium",
93
142
  duration,
94
143
  };
144
+
145
+ childLogger.debug({
146
+ responseLength: response.result.length,
147
+ responsePreview: response.result.slice(0, 300)
148
+ }, "Returning response");
149
+
150
+ return response;
95
151
  } catch (err) {
152
+ memMonitor.stop();
96
153
  const duration = Date.now() - startTime;
97
154
  childLogger.error({ err, duration }, "SDK query error");
98
155
 
@@ -121,10 +178,10 @@ export class ClaudeSdkAdapter implements ClaudeProvider {
121
178
  // Output Format
122
179
  if (request.outputFormat === "json") {
123
180
  // JSON出力を要求する場合、schema指定が必要
124
- // ここでは汎用的なschemaを使用
181
+ // jsonSchemaが指定されていればそれを使用、なければ汎用的なschemaを使用
125
182
  options.outputFormat = {
126
183
  type: "json_schema",
127
- schema: {
184
+ schema: request.jsonSchema ?? {
128
185
  type: "object",
129
186
  properties: {
130
187
  result: { type: "string" },
@@ -39,9 +39,13 @@ describe("runPipeline", () => {
39
39
  logger: {
40
40
  info: mock(() => {}),
41
41
  error: mock(() => {}),
42
+ warn: mock(() => {}),
43
+ debug: mock(() => {}),
42
44
  child: mock(() => ({
43
45
  info: mock(() => {}),
44
46
  error: mock(() => {}),
47
+ warn: mock(() => {}),
48
+ debug: mock(() => {}),
45
49
  })),
46
50
  } as any,
47
51
  stores: {
@@ -9,8 +9,10 @@ import type { App } from "../app";
9
9
  import { formatProgress, formatTaskTable, createSpinner } from "../output";
10
10
  import { createRunId, createWorkerId } from "../../../shared/types";
11
11
  import type { WorkspaceInfo } from "../../../shared/types";
12
- import { getCurrentBranch } from "../../git-workspace";
12
+ import { getCurrentBranch, cleanupOrphanedFromPreviousRuns } from "../../git-workspace";
13
13
  import { executeTddPipeline } from "../../task-execution";
14
+ import { checkMemoryAndWarn, waitForMemory } from "../../../shared/memory-check";
15
+ import { installShutdownHandler, isShuttingDown } from "../../../shared/shutdown-handler";
14
16
  import {
15
17
  detectProjectType,
16
18
  detectPackageManager,
@@ -29,14 +31,15 @@ export function createRunCommand(getApp: () => App): Command {
29
31
  .option("--persist <mode>", "Persistence mode: memory or fs", "memory")
30
32
  .option("--debug", "Enable debug logging")
31
33
  .option("--no-dashboard", "Disable dashboard server")
32
- .option("--provider <type>", "Default provider: cli or sdk", "cli")
34
+ .option("--provider <type>", "Default provider: cli or sdk", "sdk")
33
35
  .option("--repos <paths>", "Comma-separated repository paths for multi-repo mode")
34
36
  .option("--strategy <type>", "Multi-repo strategy: independent or coordinated", "independent")
35
37
  .option("--plugins <paths>", "Comma-separated plugin paths to load")
36
- .action(async (requirementsPath: string) => {
38
+ .option("--keep-worktrees", "Keep worktrees and branches after completion (default: auto-cleanup)", false)
39
+ .action(async (requirementsPath: string, cmdOptions: { keepWorktrees?: boolean }) => {
37
40
  try {
38
41
  const app = getApp();
39
- await runPipeline(app, requirementsPath);
42
+ await runPipeline(app, requirementsPath, cmdOptions.keepWorktrees ?? false);
40
43
  } catch (error) {
41
44
  const message = error instanceof Error ? error.message : String(error);
42
45
  console.error("Run pipeline failed:", message);
@@ -50,8 +53,8 @@ export function createRunCommand(getApp: () => App): Command {
50
53
  /**
51
54
  * メインパイプライン実行
52
55
  */
53
- export async function runPipeline(app: App, requirementsPath: string): Promise<void> {
54
- const { config, logger, eventBus, planningService, dispatcher, processManager, worktreeManager, providerRegistry, stores } = app;
56
+ 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;
55
58
 
56
59
  // Validate requirements file exists
57
60
  const reqFile = Bun.file(requirementsPath);
@@ -61,6 +64,19 @@ export async function runPipeline(app: App, requirementsPath: string): Promise<v
61
64
 
62
65
  logger.info({ requirementsPath }, "Starting AAD pipeline");
63
66
 
67
+ // 0. Memory check
68
+ await checkMemoryAndWarn(logger);
69
+
70
+ // 0.5. Cleanup orphaned worktrees/branches from previous runs
71
+ try {
72
+ await cleanupOrphanedFromPreviousRuns(worktreeManager, logger);
73
+ } catch (error) {
74
+ logger.warn({ error }, "Orphaned worktree cleanup failed (non-critical)");
75
+ }
76
+
77
+ // Semaphore for serializing tasks under memory pressure
78
+ let memoryGateLock: Promise<void> = Promise.resolve();
79
+
64
80
  // 1. RunId生成 + 現在ブランチ取得
65
81
  const runId = createRunId(randomUUID());
66
82
  const parentBranch = await getCurrentBranch(process.cwd());
@@ -134,16 +150,60 @@ export async function runPipeline(app: App, requirementsPath: string): Promise<v
134
150
  });
135
151
  }
136
152
 
153
+ // 4.5. Install graceful shutdown handler
154
+ installShutdownHandler({
155
+ runId,
156
+ stores: { runStore: stores.runStore, taskStore: stores.taskStore },
157
+ logger,
158
+ });
159
+
137
160
  // 5. EventBus: task:dispatched → TDDパイプライン起動
138
161
  eventBus.on<Extract<import("../../../shared/events").AADEvent, { type: "task:dispatched" }>>(
139
162
  "task:dispatched",
140
163
  (event) => {
141
164
  const taskId = event.taskId;
142
165
  const workerId = event.workerId;
166
+
167
+ if (isShuttingDown()) {
168
+ logger.warn({ taskId }, "Shutdown in progress, skipping dispatch");
169
+ return;
170
+ }
171
+
143
172
  logger.info({ taskId, workerId }, "Task dispatched, starting TDD pipeline");
144
173
 
145
174
  void (async () => {
146
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() {
147
207
  const task = await stores.taskStore.get(taskId);
148
208
  if (!task) {
149
209
  throw new Error(`Task not found: ${taskId}`);
@@ -181,6 +241,8 @@ export async function runPipeline(app: App, requirementsPath: string): Promise<v
181
241
 
182
242
  // 結果をEventBusに通知
183
243
  eventBus.emit({ type: "task:completed", taskId, result });
244
+ } // end runTask
245
+
184
246
  } catch (error) {
185
247
  logger.error({ taskId, workerId, error }, "TDD pipeline failed");
186
248
  eventBus.emit({ type: "task:failed", taskId, error: String(error) });
@@ -233,6 +295,50 @@ export async function runPipeline(app: App, requirementsPath: string): Promise<v
233
295
  logger.info({ runId }, "Pipeline completed successfully");
234
296
  console.log("✅ Pipeline completed successfully!");
235
297
  }
298
+
299
+ // 9. Auto-cleanup worktrees and branches (unless --keep-worktrees)
300
+ if (!keepWorktrees) {
301
+ console.log("\n🧹 Cleaning up worktrees and branches...");
302
+ logger.info({ runId }, "Auto-cleanup started");
303
+
304
+ try {
305
+ // List all worktrees
306
+ const worktrees = await worktreeManager.listWorktrees();
307
+ const worktreeBase = `${process.cwd()}/.aad/worktrees`;
308
+ const runWorktrees = worktrees.filter(
309
+ (wt) => wt.path.startsWith(worktreeBase)
310
+ && wt.branch.includes(runId) // Check branch name for runId
311
+ && !wt.path.includes(`parent-${runId}`) // Keep parent worktree (contains merge results)
312
+ );
313
+
314
+ // Remove worktrees for this run
315
+ let removed = 0;
316
+ for (const worktree of runWorktrees) {
317
+ try {
318
+ await worktreeManager.removeWorktree(worktree.path, false);
319
+ removed++;
320
+ logger.debug({ path: worktree.path }, "Worktree removed");
321
+ } catch (error) {
322
+ logger.error({ path: worktree.path, error }, "Failed to remove worktree");
323
+ }
324
+ }
325
+
326
+ // Prune orphaned worktrees
327
+ await worktreeManager.pruneWorktrees();
328
+
329
+ // Cleanup orphaned branches for this run (force delete merged branches)
330
+ const deletedBranches = await branchManager.cleanupOrphanBranches(runId, true);
331
+
332
+ console.log(` Removed ${removed} worktree(s)`);
333
+ console.log(` Deleted ${deletedBranches.length} branch(es)`);
334
+ logger.info({ runId, removedWorktrees: removed, deletedBranches: deletedBranches.length }, "Auto-cleanup completed");
335
+ } catch (error) {
336
+ logger.error({ runId, error }, "Auto-cleanup failed");
337
+ console.error(" ⚠️ Cleanup failed (non-critical)");
338
+ }
339
+ } else {
340
+ logger.info({ runId }, "Auto-cleanup skipped (--keep-worktrees)");
341
+ }
236
342
  }
237
343
 
238
344
  /**
@@ -0,0 +1,103 @@
1
+ import { describe, test, expect, beforeEach, mock } from "bun:test";
2
+ import { WorktreeManager, cleanupOrphanedFromPreviousRuns } from "../worktree-manager";
3
+ import { pino } from "pino";
4
+ import type { GitOps } from "../git-exec";
5
+ import type { WorktreeManagerFsOps } from "../worktree-manager";
6
+
7
+ describe("cleanupOrphanedFromPreviousRuns", () => {
8
+ let worktreeManager: WorktreeManager;
9
+ let mockGitOps: GitOps;
10
+ let mockFsOps: WorktreeManagerFsOps;
11
+ const logger = pino({ level: "silent" });
12
+
13
+ beforeEach(() => {
14
+ mockGitOps = {
15
+ gitExec: mock(async (args: string[]) => {
16
+ if (args[0] === "worktree" && args[1] === "list") {
17
+ return {
18
+ stdout: [
19
+ "worktree /repo",
20
+ "HEAD abc123",
21
+ "branch refs/heads/main",
22
+ "",
23
+ "worktree /repo/.aad/worktrees/task-001",
24
+ "HEAD def456",
25
+ "branch refs/heads/aad/run1/task-001",
26
+ "",
27
+ "worktree /repo/.aad/worktrees/task-002",
28
+ "HEAD ghi789",
29
+ "branch refs/heads/aad/run1/task-002",
30
+ "",
31
+ ].join("\n"),
32
+ stderr: "",
33
+ exitCode: 0,
34
+ };
35
+ }
36
+ if (args[0] === "branch" && args[1] === "--list") {
37
+ return {
38
+ stdout: " aad/run1/task-001\n aad/run1/task-002\n",
39
+ stderr: "",
40
+ exitCode: 0,
41
+ };
42
+ }
43
+ return { stdout: "", stderr: "", exitCode: 0 };
44
+ }),
45
+ branchExists: mock(async () => false),
46
+ };
47
+ mockFsOps = {
48
+ mkdir: mock(async () => undefined) as any,
49
+ rm: mock(async () => {}) as any,
50
+ };
51
+
52
+ worktreeManager = new WorktreeManager({
53
+ repoRoot: "/repo",
54
+ worktreeBase: "/repo/.aad/worktrees",
55
+ logger,
56
+ gitOps: mockGitOps,
57
+ fsOps: mockFsOps,
58
+ });
59
+ });
60
+
61
+ test("removes orphaned worktrees and branches", async () => {
62
+ const result = await cleanupOrphanedFromPreviousRuns(worktreeManager, logger);
63
+
64
+ expect(result.removedWorktrees).toBe(2);
65
+ expect(result.deletedBranches).toBe(2);
66
+ });
67
+
68
+ test("handles empty worktree list gracefully", async () => {
69
+ mockGitOps.gitExec = mock(async (args: string[]) => {
70
+ if (args[0] === "worktree" && args[1] === "list") {
71
+ return { stdout: "worktree /repo\nHEAD abc\nbranch refs/heads/main\n\n", stderr: "", exitCode: 0 };
72
+ }
73
+ if (args[0] === "branch" && args[1] === "--list") {
74
+ return { stdout: "", stderr: "", exitCode: 0 };
75
+ }
76
+ return { stdout: "", stderr: "", exitCode: 0 };
77
+ });
78
+
79
+ const result = await cleanupOrphanedFromPreviousRuns(worktreeManager, logger);
80
+
81
+ expect(result.removedWorktrees).toBe(0);
82
+ expect(result.deletedBranches).toBe(0);
83
+ });
84
+
85
+ test("continues if individual worktree removal fails", async () => {
86
+ let removeCallCount = 0;
87
+ const originalGitExec = mockGitOps.gitExec;
88
+ mockGitOps.gitExec = mock(async (args: string[], opts?: any) => {
89
+ if (args[0] === "worktree" && args[1] === "remove") {
90
+ removeCallCount++;
91
+ if (removeCallCount === 1) throw new Error("removal failed");
92
+ return { stdout: "", stderr: "", exitCode: 0 };
93
+ }
94
+ return (originalGitExec as any)(args, opts);
95
+ });
96
+
97
+ const result = await cleanupOrphanedFromPreviousRuns(worktreeManager, logger);
98
+
99
+ // First worktree fails, second succeeds
100
+ expect(result.removedWorktrees).toBe(1);
101
+ expect(result.deletedBranches).toBe(2);
102
+ });
103
+ });
@@ -244,4 +244,87 @@ HEAD 789ghi
244
244
  );
245
245
  });
246
246
  });
247
+
248
+ describe("cleanupStaleWorktree (via createTaskWorktree)", () => {
249
+ test("removes stale worktree at target path before creating new one", async () => {
250
+ const taskId = createTaskId("task-010");
251
+ const branch = "task/010-stale";
252
+ const calls: string[][] = [];
253
+
254
+ mockGitOps.gitExec = mock(async (args: string[]) => {
255
+ calls.push(args);
256
+ return { stdout: "", stderr: "", exitCode: 0 };
257
+ });
258
+
259
+ await worktreeManager.createTaskWorktree(taskId, branch);
260
+
261
+ // Should call worktree remove before worktree add
262
+ const removeIdx = calls.findIndex(c => c[0] === "worktree" && c[1] === "remove");
263
+ const addIdx = calls.findIndex(c => c[0] === "worktree" && c[1] === "add");
264
+ expect(removeIdx).toBeGreaterThanOrEqual(0);
265
+ expect(addIdx).toBeGreaterThan(removeIdx);
266
+ expect(calls[removeIdx]).toEqual(["worktree", "remove", "/test/worktrees/task-010", "--force"]);
267
+ });
268
+
269
+ test("deletes stale branch before creating new one", async () => {
270
+ const taskId = createTaskId("task-011");
271
+ const branch = "task/011-stale-branch";
272
+ const calls: string[][] = [];
273
+
274
+ mockGitOps.gitExec = mock(async (args: string[]) => {
275
+ calls.push(args);
276
+ return { stdout: "", stderr: "", exitCode: 0 };
277
+ });
278
+
279
+ await worktreeManager.createTaskWorktree(taskId, branch);
280
+
281
+ const branchDeleteCall = calls.find(c => c[0] === "branch" && c[1] === "-D");
282
+ expect(branchDeleteCall).toBeDefined();
283
+ expect(branchDeleteCall![2]).toBe(branch);
284
+
285
+ // Branch delete should happen before worktree add
286
+ const branchIdx = calls.indexOf(branchDeleteCall!);
287
+ const addIdx = calls.findIndex(c => c[0] === "worktree" && c[1] === "add");
288
+ expect(branchIdx).toBeLessThan(addIdx);
289
+ });
290
+
291
+ test("handles gracefully when no stale worktree exists", async () => {
292
+ const taskId = createTaskId("task-012");
293
+ const branch = "task/012-clean";
294
+
295
+ // Simulate: worktree remove fails, rm succeeds, prune succeeds, branch -D fails, add succeeds
296
+ let _callCount = 0;
297
+ mockGitOps.gitExec = mock(async (args: string[]) => {
298
+ _callCount++;
299
+ if (args[1] === "remove") throw new Error("not a worktree");
300
+ if (args[0] === "branch" && args[1] === "-D") throw new Error("branch not found");
301
+ return { stdout: "", stderr: "", exitCode: 0 };
302
+ });
303
+
304
+ // Should not throw — cleanup errors are swallowed
305
+ const result = await worktreeManager.createTaskWorktree(taskId, branch);
306
+ expect(result).toBe("/test/worktrees/task-012");
307
+ });
308
+
309
+ test("calls git worktree prune during cleanup", async () => {
310
+ const taskId = createTaskId("task-013");
311
+ const branch = "task/013-prune";
312
+ const calls: string[][] = [];
313
+
314
+ mockGitOps.gitExec = mock(async (args: string[]) => {
315
+ calls.push(args);
316
+ return { stdout: "", stderr: "", exitCode: 0 };
317
+ });
318
+
319
+ await worktreeManager.createTaskWorktree(taskId, branch);
320
+
321
+ const pruneCall = calls.find(c => c[0] === "worktree" && c[1] === "prune");
322
+ expect(pruneCall).toBeDefined();
323
+
324
+ // Prune should happen before worktree add
325
+ const pruneIdx = calls.indexOf(pruneCall!);
326
+ const addIdx = calls.findIndex(c => c[0] === "worktree" && c[1] === "add");
327
+ expect(pruneIdx).toBeLessThan(addIdx);
328
+ });
329
+ });
247
330
  });
@@ -123,7 +123,7 @@ export class BranchManager {
123
123
 
124
124
  return result.stdout
125
125
  .split("\n")
126
- .map((line) => line.trim().replace(/^\* /, ""))
126
+ .map((line) => line.trim().replace(/^[*+]\s*/, ""))
127
127
  .filter((line) => line.length > 0);
128
128
  } catch (error) {
129
129
  throw new GitWorkspaceError("Failed to list branches", {
@@ -136,13 +136,13 @@ export class BranchManager {
136
136
  /**
137
137
  * Cleanup orphaned AAD branches (branches without worktrees)
138
138
  */
139
- async cleanupOrphanBranches(runId?: RunId): Promise<string[]> {
139
+ async cleanupOrphanBranches(runId?: RunId, force = false): Promise<string[]> {
140
140
  // Support both aad/* (slash) and aad-* (hyphen) patterns for backward compatibility
141
141
  const patterns = runId
142
142
  ? [`aad/${runId as string}/*`, `aad-*-${runId as string}*`]
143
143
  : ["aad/*", "aad-*"];
144
144
 
145
- this.logger?.info({ patterns }, "Cleaning up orphan branches");
145
+ this.logger?.info({ patterns, force }, "Cleaning up orphan branches");
146
146
 
147
147
  const deleted: string[] = [];
148
148
 
@@ -151,13 +151,13 @@ export class BranchManager {
151
151
 
152
152
  for (const branch of branches) {
153
153
  try {
154
- // Try to delete branch (non-force)
155
- await this.deleteBranch(branch, false);
154
+ // Delete branch (force if requested)
155
+ await this.deleteBranch(branch, force);
156
156
  deleted.push(branch);
157
157
  this.logger?.info({ branch }, "Deleted orphan branch");
158
- } catch (_error) {
159
- // Branch still has commits or is checked out, skip
160
- this.logger?.debug({ branch }, "Skipping branch (in use or has commits)");
158
+ } catch (error) {
159
+ // Branch is checked out in a worktree or deletion failed
160
+ this.logger?.warn({ branch, error }, "Failed to delete branch");
161
161
  }
162
162
  }
163
163
  }
@@ -1,7 +1,7 @@
1
1
  export { gitExec, isGitRepo, getCurrentBranch, branchExists, defaultGitOps } from "./git-exec";
2
2
  export type { GitExecOptions, GitExecResult, GitOps } from "./git-exec";
3
3
 
4
- export { WorktreeManager } from "./worktree-manager";
4
+ export { WorktreeManager, cleanupOrphanedFromPreviousRuns } from "./worktree-manager";
5
5
  export type { WorktreeManagerOptions, WorktreeManagerFsOps } from "./worktree-manager";
6
6
 
7
7
  export { BranchManager } from "./branch-manager";