@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 +16 -0
- package/package.json +1 -1
- package/src/main.ts +1 -1
- package/src/modules/claude-provider/claude-provider.port.ts +1 -0
- package/src/modules/claude-provider/claude-sdk.adapter.ts +61 -4
- package/src/modules/cli/__tests__/run.test.ts +4 -0
- package/src/modules/cli/commands/run.ts +112 -6
- package/src/modules/git-workspace/__tests__/worktree-cleanup.test.ts +103 -0
- package/src/modules/git-workspace/__tests__/worktree-manager.test.ts +83 -0
- package/src/modules/git-workspace/branch-manager.ts +8 -8
- package/src/modules/git-workspace/index.ts +1 -1
- package/src/modules/git-workspace/worktree-manager.ts +113 -0
- package/src/modules/planning/__tests__/planning-service.test.ts +156 -3
- package/src/modules/planning/planning.service.ts +103 -51
- package/src/shared/__tests__/memory-check.test.ts +91 -0
- package/src/shared/__tests__/memory-monitor.test.ts +120 -0
- package/src/shared/__tests__/shutdown-handler.test.ts +138 -0
- package/src/shared/__tests__/utils.test.ts +294 -0
- package/src/shared/errors.ts +7 -0
- package/src/shared/memory-check.ts +155 -0
- package/src/shared/memory-monitor.ts +69 -0
- package/src/shared/shutdown-handler.ts +115 -0
- package/src/shared/utils.ts +49 -0
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
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", "
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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", "
|
|
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
|
-
.
|
|
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
|
-
//
|
|
155
|
-
await this.deleteBranch(branch,
|
|
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 (
|
|
159
|
-
// Branch
|
|
160
|
-
this.logger?.
|
|
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";
|