@ronkovic/aad 0.3.0 → 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.
@@ -50,6 +50,9 @@ export class WorktreeManager {
50
50
  // Ensure worktree base directory exists
51
51
  await this.fsOps.mkdir(this.worktreeBase, { recursive: true });
52
52
 
53
+ // Clean up stale worktree/branch if they already exist
54
+ await this.cleanupStaleWorktree(worktreePath, branch);
55
+
53
56
  // Create worktree with new branch
54
57
  await this.gitOps.gitExec(
55
58
  ["worktree", "add", "-b", branch, worktreePath, "HEAD"],
@@ -67,6 +70,46 @@ export class WorktreeManager {
67
70
  }
68
71
  }
69
72
 
73
+ /**
74
+ * Clean up stale worktree path and branch before creating a new one.
75
+ * Handles cases where a previous run left behind artifacts.
76
+ */
77
+ private async cleanupStaleWorktree(worktreePath: string, branch: string): Promise<void> {
78
+ // 1. Remove existing worktree directory if it exists
79
+ try {
80
+ await this.gitOps.gitExec(
81
+ ["worktree", "remove", worktreePath, "--force"],
82
+ { cwd: this.repoRoot, logger: this.logger }
83
+ );
84
+ this.logger?.info({ worktreePath }, "Removed stale worktree");
85
+ } catch {
86
+ // Worktree doesn't exist or already removed — try direct directory removal
87
+ try {
88
+ await this.fsOps.rm(worktreePath, { recursive: true, force: true });
89
+ } catch {
90
+ // Directory doesn't exist, nothing to do
91
+ }
92
+ }
93
+
94
+ // 2. Prune worktree references
95
+ try {
96
+ await this.gitOps.gitExec(["worktree", "prune"], { cwd: this.repoRoot, logger: this.logger });
97
+ } catch {
98
+ // Non-critical
99
+ }
100
+
101
+ // 3. Delete stale branch if it exists
102
+ try {
103
+ await this.gitOps.gitExec(
104
+ ["branch", "-D", branch],
105
+ { cwd: this.repoRoot, logger: this.logger }
106
+ );
107
+ this.logger?.info({ branch }, "Deleted stale branch");
108
+ } catch {
109
+ // Branch doesn't exist, nothing to do
110
+ }
111
+ }
112
+
70
113
  /**
71
114
  * Create worktree for parent branch (used for merging)
72
115
  */
@@ -197,3 +240,73 @@ export class WorktreeManager {
197
240
  );
198
241
  }
199
242
  }
243
+
244
+ /**
245
+ * Cleanup orphaned worktrees and branches from previous AAD runs.
246
+ * Called at startup before planning phase.
247
+ */
248
+ export async function cleanupOrphanedFromPreviousRuns(
249
+ worktreeManager: WorktreeManager,
250
+ logger: Logger,
251
+ ): Promise<{ removedWorktrees: number; deletedBranches: number }> {
252
+ logger.info("Cleaning up orphaned worktrees/branches from previous runs");
253
+
254
+ let removedWorktrees = 0;
255
+ let deletedBranches = 0;
256
+
257
+ // 1. Remove all worktrees under .aad/worktrees/
258
+ try {
259
+ const worktrees = await worktreeManager.listWorktrees();
260
+ const aadWorktrees = worktrees.filter((wt) => wt.path.includes("/.aad/worktrees/"));
261
+
262
+ for (const wt of aadWorktrees) {
263
+ try {
264
+ await worktreeManager.removeWorktree(wt.path, true);
265
+ removedWorktrees++;
266
+ logger.debug({ path: wt.path }, "Removed orphaned worktree");
267
+ } catch {
268
+ logger.debug({ path: wt.path }, "Failed to remove orphaned worktree (ignored)");
269
+ }
270
+ }
271
+ } catch (error) {
272
+ logger.debug({ error }, "Failed to list worktrees during cleanup");
273
+ }
274
+
275
+ // 2. Prune worktree references
276
+ try {
277
+ await worktreeManager.pruneWorktrees();
278
+ } catch {
279
+ // non-critical
280
+ }
281
+
282
+ // 3. Delete all branches matching aad/*
283
+ try {
284
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
285
+ const gitOps = (worktreeManager as any).gitOps as GitOps;
286
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
287
+ const repoRoot = (worktreeManager as any).repoRoot as string;
288
+ const result = await gitOps.gitExec(
289
+ ["branch", "--list", "aad/*"],
290
+ { cwd: repoRoot, logger },
291
+ );
292
+ const branches = result.stdout
293
+ .split("\n")
294
+ .map((b: string) => b.trim().replace(/^\* /, ""))
295
+ .filter((b: string) => b.length > 0);
296
+
297
+ for (const branch of branches) {
298
+ try {
299
+ await gitOps.gitExec(["branch", "-D", branch], { cwd: repoRoot, logger });
300
+ deletedBranches++;
301
+ logger.debug({ branch }, "Deleted orphaned branch");
302
+ } catch {
303
+ logger.debug({ branch }, "Failed to delete orphaned branch (ignored)");
304
+ }
305
+ }
306
+ } catch (error) {
307
+ logger.debug({ error }, "Failed to list branches during cleanup");
308
+ }
309
+
310
+ logger.info({ removedWorktrees, deletedBranches }, "Orphaned cleanup completed");
311
+ return { removedWorktrees, deletedBranches };
312
+ }
@@ -165,8 +165,8 @@ describe("PlanningService", () => {
165
165
  }
166
166
 
167
167
  expect(mockProvider.lastRequest).toBeDefined();
168
- expect(mockProvider.lastRequest?.prompt).toContain("splitterエージェントとして");
169
- expect(mockProvider.lastRequest?.prompt).not.toContain("サブエージェント");
168
+ expect(mockProvider.lastRequest?.prompt).toContain("task splitter agent");
169
+ expect(mockProvider.lastRequest?.prompt).toContain("CRITICAL");
170
170
  }, 15_000);
171
171
 
172
172
  test("planTasks calls ClaudeProvider with teams mode prompt", async () => {
@@ -197,7 +197,7 @@ describe("PlanningService", () => {
197
197
  }
198
198
 
199
199
  expect(mockProvider.lastRequest).toBeDefined();
200
- expect(mockProvider.lastRequest?.prompt).toContain("サブエージェント");
200
+ expect(mockProvider.lastRequest?.prompt).toContain("task splitter agent");
201
201
  // Teams mode now uses subagents instead of prompt-only instructions
202
202
  expect(mockProvider.lastRequest?.subagents).toBeDefined();
203
203
  expect(mockProvider.lastRequest?.subagents).toHaveLength(3);
@@ -363,4 +363,157 @@ describe("PlanningService", () => {
363
363
 
364
364
  expect(failedEvent).toBeDefined();
365
365
  });
366
+
367
+ describe("file-write planning approach", () => {
368
+ test("reads task plan from temp file when it exists", async () => {
369
+ const config = createMockConfig();
370
+ const service = new PlanningService(mockProvider, eventBus, config, mockLogger, { fileChecker: mockFileChecker });
371
+
372
+ const taskPlanData = {
373
+ run_id: "test-run",
374
+ parent_branch: "feature/test-run",
375
+ tasks: [
376
+ {
377
+ task_id: "task-1",
378
+ title: "File-based task",
379
+ description: "Read from file",
380
+ files_to_modify: ["file1.ts"],
381
+ depends_on: [],
382
+ priority: 1,
383
+ },
384
+ ],
385
+ };
386
+
387
+ // Claude writes the file; the service reads it.
388
+ // We intercept the claude call to actually write the file at the path in the prompt.
389
+ mockProvider.call = async (request: ClaudeRequest) => {
390
+ // Extract the task_plan.json path from the prompt
391
+ const match = request.prompt.match(/\n\s+(\/[^\n]*task_plan\.json)/);
392
+ if (match?.[1]) {
393
+ const { writeFile } = await import("node:fs/promises");
394
+ await writeFile(match[1], JSON.stringify(taskPlanData));
395
+ }
396
+ return {
397
+ result: "I have written the task plan to the file.",
398
+ exitCode: 0,
399
+ model: "claude-sonnet-4-5" as const,
400
+ effortLevel: "medium" as const,
401
+ duration: 1000,
402
+ };
403
+ };
404
+
405
+ const result = await service.planTasks({
406
+ runId: createRunId("test-run"),
407
+ parentBranch: "feature/test-run",
408
+ requirementsPath: "/test/requirements.md",
409
+ targetDocsDir: "/test/docs",
410
+ });
411
+
412
+ expect(result.tasks).toHaveLength(1);
413
+ expect(result.tasks[0]!.title).toBe("File-based task");
414
+ });
415
+
416
+ test("falls back to stdout parsing when temp file does not exist", async () => {
417
+ const config = createMockConfig();
418
+ const service = new PlanningService(mockProvider, eventBus, config, mockLogger, { fileChecker: mockFileChecker });
419
+
420
+ // Claude does NOT write the file, but returns JSON in stdout
421
+ mockProvider.mockResponse = {
422
+ result: JSON.stringify({
423
+ run_id: "test-run",
424
+ parent_branch: "feature/test-run",
425
+ tasks: [
426
+ {
427
+ task_id: "task-1",
428
+ title: "Stdout task",
429
+ description: "Parsed from stdout",
430
+ files_to_modify: ["file1.ts"],
431
+ depends_on: [],
432
+ priority: 1,
433
+ },
434
+ ],
435
+ }),
436
+ exitCode: 0,
437
+ model: "claude-sonnet-4-5",
438
+ effortLevel: "medium" as const,
439
+ duration: 1000,
440
+ };
441
+
442
+ const result = await service.planTasks({
443
+ runId: createRunId("test-run"),
444
+ parentBranch: "feature/test-run",
445
+ requirementsPath: "/test/requirements.md",
446
+ targetDocsDir: "/test/docs",
447
+ });
448
+
449
+ expect(result.tasks).toHaveLength(1);
450
+ expect(result.tasks[0]!.title).toBe("Stdout task");
451
+ });
452
+
453
+ test("temp directory is cleaned up after planning (success)", async () => {
454
+ const config = createMockConfig();
455
+ const service = new PlanningService(mockProvider, eventBus, config, mockLogger, { fileChecker: mockFileChecker });
456
+ const { existsSync } = await import("node:fs");
457
+
458
+ let tempDirPath: string | null = null;
459
+
460
+ mockProvider.call = async (request: ClaudeRequest) => {
461
+ const match = request.prompt.match(/\n\s+(\/[^\n]*task_plan\.json)/);
462
+ if (match?.[1]) {
463
+ tempDirPath = match[1].replace("/task_plan.json", "");
464
+ const { writeFile } = await import("node:fs/promises");
465
+ await writeFile(match[1], JSON.stringify({
466
+ run_id: "test-run",
467
+ parent_branch: "feature/test-run",
468
+ tasks: [{
469
+ task_id: "task-1",
470
+ title: "Cleanup test",
471
+ description: "Test",
472
+ files_to_modify: ["f.ts"],
473
+ depends_on: [],
474
+ priority: 1,
475
+ }],
476
+ }));
477
+ }
478
+ return { result: "done", exitCode: 0, model: "claude-sonnet-4-5" as const, effortLevel: "medium" as const, duration: 1000 };
479
+ };
480
+
481
+ await service.planTasks({
482
+ runId: createRunId("test-run"),
483
+ parentBranch: "feature/test-run",
484
+ requirementsPath: "/test/requirements.md",
485
+ targetDocsDir: "/test/docs",
486
+ });
487
+
488
+ expect(tempDirPath).not.toBeNull();
489
+ // Temp directory should be cleaned up
490
+ expect(existsSync(tempDirPath!)).toBe(false);
491
+ });
492
+
493
+ test("temp directory is cleaned up after planning (failure)", async () => {
494
+ const config = createMockConfig();
495
+ const service = new PlanningService(mockProvider, eventBus, config, mockLogger, { fileChecker: mockFileChecker });
496
+ const { existsSync } = await import("node:fs");
497
+
498
+ let tempDirPath: string | null = null;
499
+
500
+ mockProvider.call = async (request: ClaudeRequest) => {
501
+ const match = request.prompt.match(/\n\s+(\/[^\n]*task_plan\.json)/);
502
+ if (match?.[1]) {
503
+ tempDirPath = match[1].replace("/task_plan.json", "");
504
+ }
505
+ return { result: "error", exitCode: 1, model: "claude-sonnet-4-5" as const, effortLevel: "medium" as const, duration: 1000 };
506
+ };
507
+
508
+ await expect(service.planTasks({
509
+ runId: createRunId("test-run"),
510
+ parentBranch: "feature/test-run",
511
+ requirementsPath: "/test/requirements.md",
512
+ targetDocsDir: "/test/docs",
513
+ })).rejects.toThrow();
514
+
515
+ expect(tempDirPath).not.toBeNull();
516
+ expect(existsSync(tempDirPath!)).toBe(false);
517
+ });
518
+ });
366
519
  });
@@ -10,6 +10,9 @@ import { PlanningError } from "../../shared/errors";
10
10
  import { parseTaskPlan, validateTaskPlan } from "../task-queue";
11
11
  import { validateFileConflicts, formatConflictErrors } from "./file-conflict-validator";
12
12
  import { analyzeProject, createBunFileChecker, type FileChecker } from "./project-detection";
13
+ import { join } from "node:path";
14
+ import { mkdtemp, readFile, rm } from "node:fs/promises";
15
+ import { tmpdir } from "node:os";
13
16
 
14
17
  export interface PlanTasksParams {
15
18
  runId: RunId;
@@ -45,25 +48,32 @@ export class PlanningService {
45
48
  requirementsPath,
46
49
  });
47
50
 
51
+ // Create temp directory for task_plan.json output
52
+ const tempDir = await mkdtemp(join(tmpdir(), `aad-plan-${runId}-`));
53
+ const taskPlanFilePath = join(tempDir, "task_plan.json");
54
+
48
55
  try {
49
56
  // 1. Project analysis (optional context for splitter)
50
57
  const projectAnalysis = await analyzeProject(projectRoot, this.fileChecker);
51
58
  this.logger.debug({ projectAnalysis }, "Project analysis completed");
52
59
 
53
- // 2. Build splitter prompt
60
+ // 2. Build splitter prompt (file-write based)
54
61
  const prompt = this.buildSplitterPrompt({
55
62
  runId,
56
63
  parentBranch,
57
64
  requirementsPath,
58
65
  targetDocsDir,
59
66
  projectAnalysis,
67
+ taskPlanFilePath,
60
68
  });
61
69
 
62
- // 3. Call Claude splitter
63
- this.logger.info("Calling Claude splitter agent");
70
+ // 3. Call Claude splitter — output goes to file, not stdout
71
+ this.logger.info({ taskPlanFilePath }, "Calling Claude splitter agent (file-write mode)");
64
72
  const subagents = this.config.teams.splitter ? this.buildSplitterSubagents() : undefined;
73
+
65
74
  const response = await this.claudeProvider.call({
66
75
  prompt,
76
+ allowedTools: ["Read", "Write", "Edit", "Glob", "Grep", "Bash"],
67
77
  ...(subagents ? { subagents } : {}),
68
78
  });
69
79
 
@@ -75,9 +85,31 @@ export class PlanningService {
75
85
  });
76
86
  }
77
87
 
78
- // 4. Parse task plan from Claude output
79
- const taskPlanJsonStr = this.extractTaskPlanFromOutput(response.result);
80
- const taskPlanJson = JSON.parse(taskPlanJsonStr);
88
+ // 4. Read task plan from file (not from stdout)
89
+ let taskPlanJsonStr: string;
90
+ try {
91
+ taskPlanJsonStr = await readFile(taskPlanFilePath, "utf-8");
92
+ this.logger.info({ fileLength: taskPlanJsonStr.length }, "Read task_plan.json from file");
93
+ } catch (fileError) {
94
+ // Fallback: try extracting JSON from stdout (backward compatibility)
95
+ this.logger.warn({ fileError }, "task_plan.json file not found, falling back to stdout extraction");
96
+ taskPlanJsonStr = this.extractTaskPlanFromOutput(response.result);
97
+ }
98
+
99
+ this.logger.debug({ jsonLength: taskPlanJsonStr.length, jsonPreview: taskPlanJsonStr.slice(0, 500) }, "Extracted JSON");
100
+
101
+ let taskPlanJson;
102
+ try {
103
+ taskPlanJson = JSON.parse(taskPlanJsonStr);
104
+ } catch (parseError) {
105
+ const errorMsg = parseError instanceof Error ? parseError.message : String(parseError);
106
+ this.logger.error({ parseError: errorMsg, jsonLength: taskPlanJsonStr.length, jsonSample: taskPlanJsonStr.slice(0, 1000) }, "JSON parse failed");
107
+ throw new PlanningError(`Failed to parse task plan JSON: ${errorMsg}`, {
108
+ runId,
109
+ rawOutput: taskPlanJsonStr.slice(0, 2000),
110
+ });
111
+ }
112
+
81
113
  const taskPlan = parseTaskPlan(taskPlanJson);
82
114
 
83
115
  this.logger.debug({ taskCount: taskPlan.tasks.length }, "Task plan parsed");
@@ -118,6 +150,9 @@ export class PlanningService {
118
150
  error: errorMessage,
119
151
  });
120
152
  throw error;
153
+ } finally {
154
+ // Cleanup temp directory
155
+ await rm(tempDir, { recursive: true, force: true }).catch(() => {});
121
156
  }
122
157
  }
123
158
 
@@ -130,8 +165,9 @@ export class PlanningService {
130
165
  requirementsPath: string;
131
166
  targetDocsDir: string;
132
167
  projectAnalysis: unknown;
168
+ taskPlanFilePath: string;
133
169
  }): string {
134
- const { runId, parentBranch, requirementsPath } = params;
170
+ const { runId, parentBranch, requirementsPath, taskPlanFilePath } = params;
135
171
 
136
172
  const taskPlanFormat = JSON.stringify(
137
173
  {
@@ -140,10 +176,10 @@ export class PlanningService {
140
176
  tasks: [
141
177
  {
142
178
  task_id: "task-001",
143
- title: "タスクのタイトル",
144
- description: "詳細な説明",
179
+ title: "Task title",
180
+ description: "Detailed description",
145
181
  files_to_modify: ["file1.js", "file2.js"],
146
- depends_on: ["task-000"],
182
+ depends_on: [],
147
183
  priority: 1,
148
184
  },
149
185
  ],
@@ -152,48 +188,33 @@ export class PlanningService {
152
188
  2
153
189
  );
154
190
 
155
- if (this.config.teams.splitter) {
156
- // Teams mode: subagents handle analysis, main agent integrates
157
- return `splitterエージェントとして、サブエージェントの分析結果を統合し、要件をタスクに分割してください。
158
-
159
- サブエージェント(codebase-analyzer, requirement-analyzer, dependency-mapper)が自動的に並列分析を行います。
160
- それぞれの結果を統合して、最終的なタスクプランを作成してください。
191
+ // File-write based approach: Claude writes task_plan.json to a specific path.
192
+ // This avoids JSON parsing issues from stdout where conversational text gets mixed in.
193
+ // Same pattern as the proven shell script version (.aad/scripts/run-parallel.sh).
194
+ const basePrompt = `You are a task splitter agent. Analyze the requirements and split them into tasks.
161
195
 
162
- 要件: ${requirementsPath}
163
- Run ID: ${runId}
164
- 親ブランチ: ${parentBranch}
165
-
166
- 以下の手順を実行してください:
167
- 1. サブエージェントの分析結果を統合する
168
- 2. タスクに分割し、以下のJSON形式で標準出力に出力してください(ファイル生成ではなく、コンソールに直接出力):
169
- ${taskPlanFormat}
170
- 3. 各タスクの files_to_modify を明確にする
171
- 4. 依存関係 (depends_on) を設定する
172
- ⚠️ 重要: 同じファイルを変更する複数のタスクがある場合、必ず後のタスクを前のタスクに依存させてください
173
- これにより、ファイル衝突を防ぎます
174
- 5. priority を設定する
175
-
176
- ⚠️ 重要: 最終的な出力は、上記のJSON形式のみを標準出力に出力してください。説明文やコードブロック記法(\`\`\`)は不要です。`;
177
- } else {
178
- // Solo mode: traditional prompt
179
- return `splitterエージェントとして、以下の要件を分割してください:
180
-
181
- 要件: ${requirementsPath}
196
+ Requirements file: ${requirementsPath}
182
197
  Run ID: ${runId}
183
- 親ブランチ: ${parentBranch}
184
-
185
- 以下の手順を実行してください:
186
- 1. 要件ファイル/ディレクトリを読み込む(ディレクトリの場合は配下のファイルを全て読む)
187
- 2. タスクに分割し、以下のJSON形式で標準出力に出力してください(ファイル生成ではなく、コンソールに直接出力):
188
- ${taskPlanFormat}
189
- 3. 各タスクの files_to_modify を明確にする
190
- 4. 依存関係 (depends_on) を設定する
191
- ⚠️ 重要: 同じファイルを変更する複数のタスクがある場合、必ず後のタスクを前のタスクに依存させてください
192
- これにより、ファイル衝突を防ぎます
193
- 5. priority を設定する
194
-
195
- ⚠️ 重要: 最終的な出力は、上記のJSON形式のみを標準出力に出力してください。説明文やコードブロック記法(\`\`\`)は不要です。`;
196
- }
198
+ Parent branch: ${parentBranch}
199
+
200
+ Instructions:
201
+ 1. Read the requirements file/directory
202
+ 2. Split into independent tasks with clear file assignments
203
+ 3. Set depends_on for tasks that modify the same files (later task depends on earlier)
204
+ 4. Set priority (1 = highest)
205
+ 5. CRITICAL: Write the result as a JSON file to the following path:
206
+ ${taskPlanFilePath}
207
+
208
+ The JSON file MUST follow this exact format:
209
+ ${taskPlanFormat}
210
+
211
+ IMPORTANT:
212
+ - Use the Write tool to write the JSON to ${taskPlanFilePath}
213
+ - The file must contain ONLY valid JSON (no markdown, no explanation)
214
+ - Use "task_id" (not "id") for task identifiers
215
+ - After writing the file, confirm that you have written it`;
216
+
217
+ return basePrompt;
197
218
  }
198
219
 
199
220
  /**
@@ -241,7 +262,7 @@ Run ID: ${runId}
241
262
  JSON.parse(output);
242
263
  return output;
243
264
  } catch {
244
- // Not valid JSON, try to extract from markdown code block
265
+ // Not valid JSON, try extraction strategies
245
266
  }
246
267
 
247
268
  // Try to extract from ```json ... ``` block
@@ -256,6 +277,37 @@ Run ID: ${runId}
256
277
  return codeBlockMatch[1];
257
278
  }
258
279
 
280
+ // Try to find JSON object starting with { and ending with }
281
+ const jsonObjectMatch = output.match(/\{[\s\S]*"tasks"\s*:\s*\[[\s\S]*\]\s*\}/);
282
+ if (jsonObjectMatch) {
283
+ try {
284
+ JSON.parse(jsonObjectMatch[0]);
285
+ return jsonObjectMatch[0];
286
+ } catch {
287
+ // Not valid JSON, continue
288
+ }
289
+ }
290
+
291
+ // Try to find the last JSON-like block (Claude often adds explanation before JSON)
292
+ const lastBraceIndex = output.lastIndexOf("}");
293
+ if (lastBraceIndex !== -1) {
294
+ // Find the matching opening brace
295
+ let depth = 0;
296
+ for (let i = lastBraceIndex; i >= 0; i--) {
297
+ if (output[i] === "}") depth++;
298
+ if (output[i] === "{") depth--;
299
+ if (depth === 0) {
300
+ const candidate = output.slice(i, lastBraceIndex + 1);
301
+ try {
302
+ JSON.parse(candidate);
303
+ return candidate;
304
+ } catch {
305
+ break;
306
+ }
307
+ }
308
+ }
309
+ }
310
+
259
311
  // Return as-is and let parseTaskPlan handle validation
260
312
  return output;
261
313
  }
@@ -0,0 +1,91 @@
1
+ import { describe, test, expect, mock } from "bun:test";
2
+ import { waitForMemory, type MemoryStatus } from "../memory-check";
3
+ import { MemoryError } from "../errors";
4
+ import { pino } from "pino";
5
+
6
+ const logger = pino({ level: "silent" });
7
+
8
+ function mockStatus(freeGB: number): MemoryStatus {
9
+ return {
10
+ totalGB: 16,
11
+ usedGB: 16 - freeGB,
12
+ freeGB,
13
+ usedPercent: Math.round(((16 - freeGB) / 16) * 100),
14
+ isLowMemory: freeGB < 2.0,
15
+ };
16
+ }
17
+
18
+ describe("waitForMemory", () => {
19
+ test("returns immediately when memory is sufficient", async () => {
20
+ const getStatus = mock(async () => mockStatus(8.0));
21
+ const sleep = mock(async () => {});
22
+
23
+ const result = await waitForMemory(logger, {}, getStatus, sleep);
24
+
25
+ expect(result.shouldReduceWorkers).toBe(false);
26
+ expect(result.freeGB).toBe(8.0);
27
+ expect(sleep).not.toHaveBeenCalled();
28
+ });
29
+
30
+ test("returns shouldReduceWorkers=true when freeGB < 3.0", async () => {
31
+ const getStatus = mock(async () => mockStatus(2.5));
32
+ const sleep = mock(async () => {});
33
+
34
+ const result = await waitForMemory(logger, {}, getStatus, sleep);
35
+
36
+ expect(result.shouldReduceWorkers).toBe(true);
37
+ expect(result.freeGB).toBe(2.5);
38
+ });
39
+
40
+ test("waits and retries when freeGB < 1.5", async () => {
41
+ let callCount = 0;
42
+ const getStatus = mock(async () => {
43
+ callCount++;
44
+ // First 2 calls: low memory, then recover
45
+ return mockStatus(callCount <= 2 ? 1.0 : 4.0);
46
+ });
47
+ const sleep = mock(async () => {});
48
+
49
+ const result = await waitForMemory(logger, { retryIntervalMs: 100 }, getStatus, sleep);
50
+
51
+ expect(sleep).toHaveBeenCalledTimes(2);
52
+ expect(result.shouldReduceWorkers).toBe(false);
53
+ expect(result.freeGB).toBe(4.0);
54
+ });
55
+
56
+ test("throws MemoryError when memory stays below 1.0GB after all retries", async () => {
57
+ const getStatus = mock(async () => mockStatus(0.5));
58
+ const sleep = mock(async () => {});
59
+
60
+ await expect(
61
+ waitForMemory(logger, { maxRetries: 3, retryIntervalMs: 10 }, getStatus, sleep),
62
+ ).rejects.toThrow(MemoryError);
63
+
64
+ expect(sleep).toHaveBeenCalledTimes(3);
65
+ });
66
+
67
+ test("does not throw when memory is between 1.0 and 1.5 after retries", async () => {
68
+ let callCount = 0;
69
+ const getStatus = mock(async () => {
70
+ callCount++;
71
+ return mockStatus(callCount <= 5 ? 1.2 : 1.2); // stays at 1.2 - above 1.0 threshold
72
+ });
73
+ const sleep = mock(async () => {});
74
+
75
+ const result = await waitForMemory(logger, { maxRetries: 5, retryIntervalMs: 10 }, getStatus, sleep);
76
+
77
+ // 1.2 < 3.0 so shouldReduceWorkers
78
+ expect(result.shouldReduceWorkers).toBe(true);
79
+ expect(sleep).toHaveBeenCalledTimes(5);
80
+ });
81
+
82
+ test("uses custom minFreeGB option", async () => {
83
+ const getStatus = mock(async () => mockStatus(2.0));
84
+ const sleep = mock(async () => {});
85
+
86
+ const result = await waitForMemory(logger, { minFreeGB: 1.0 }, getStatus, sleep);
87
+
88
+ expect(sleep).not.toHaveBeenCalled();
89
+ expect(result.shouldReduceWorkers).toBe(true);
90
+ });
91
+ });