@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.
- package/README.md +16 -0
- package/package.json +2 -2
- 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
|
@@ -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).
|
|
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.
|
|
79
|
-
|
|
80
|
-
|
|
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: [
|
|
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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
-
|
|
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
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
1.
|
|
187
|
-
2.
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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
|
|
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
|
+
});
|