@ronkovic/aad 0.3.6 → 0.3.7
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/package.json +1 -1
- package/src/__tests__/e2e/pipeline-e2e.test.ts +6 -0
- package/src/modules/cli/commands/resume.ts +1 -1
- package/src/modules/cli/commands/run.ts +3 -3
- package/src/modules/cli/commands/task-dispatch-handler.ts +3 -3
- package/src/modules/git-workspace/__tests__/worktree-cleanup.test.ts +9 -5
- package/src/modules/git-workspace/__tests__/worktree-manager.test.ts +3 -3
- package/src/modules/git-workspace/branch-manager.ts +10 -3
- package/src/modules/git-workspace/worktree-manager.ts +28 -20
- package/src/modules/planning/__tests__/feature-name-generator.test.ts +60 -12
- package/src/modules/planning/feature-name-generator.ts +48 -16
- package/src/modules/task-queue/dispatcher.ts +6 -4
package/package.json
CHANGED
|
@@ -270,6 +270,12 @@ describe("E2E Pipeline", () => {
|
|
|
270
270
|
result: { taskId: createTaskId("task-1"), status: "completed", duration: 50 },
|
|
271
271
|
});
|
|
272
272
|
|
|
273
|
+
// Emit worker:idle to trigger dispatch (mirrors real task-dispatch-handler behavior)
|
|
274
|
+
eventBus.emit({
|
|
275
|
+
type: "worker:idle",
|
|
276
|
+
workerId: createWorkerId("w-1"),
|
|
277
|
+
});
|
|
278
|
+
|
|
273
279
|
await waitFor(() => dispatchedTasks.length >= 2, 3000);
|
|
274
280
|
|
|
275
281
|
expect(dispatchedTasks[1]).toBe(createTaskId("task-2"));
|
|
@@ -132,7 +132,7 @@ export async function resumeRun(app: App, runIdStr: string): Promise<void> {
|
|
|
132
132
|
const branchResult = await import("../../git-workspace").then(
|
|
133
133
|
(m) => m.getCurrentBranch(parentWorktreePath),
|
|
134
134
|
);
|
|
135
|
-
if (branchResult
|
|
135
|
+
if (branchResult && /^(feat|fix|docs|style|refactor|perf|test|chore)\//.test(branchResult)) {
|
|
136
136
|
featureBranchName = branchResult;
|
|
137
137
|
}
|
|
138
138
|
} catch {
|
|
@@ -149,12 +149,12 @@ export async function runPipeline(app: App, requirementsPath: string, keepWorktr
|
|
|
149
149
|
let parentWorktreePath: string | undefined;
|
|
150
150
|
let featureBranchName: string | undefined;
|
|
151
151
|
try {
|
|
152
|
-
const featureName = await generateFeatureName(
|
|
152
|
+
const { prefix, name: featureName } = await generateFeatureName(
|
|
153
153
|
requirementsPath,
|
|
154
154
|
providerRegistry.getProvider("implementer"),
|
|
155
155
|
logger,
|
|
156
156
|
);
|
|
157
|
-
featureBranchName =
|
|
157
|
+
featureBranchName = `${prefix}/${featureName}/parent`;
|
|
158
158
|
const result = await worktreeManager.createParentWorktree(runId, parentBranch, featureBranchName);
|
|
159
159
|
parentWorktreePath = result.worktreePath;
|
|
160
160
|
featureBranchName = result.branch;
|
|
@@ -256,7 +256,7 @@ export async function runPipeline(app: App, requirementsPath: string, keepWorktr
|
|
|
256
256
|
if (featureBranchName) {
|
|
257
257
|
const featurePrefix = featureBranchName.replace(/\/parent$/, "");
|
|
258
258
|
const featureBranches = await branchManager.cleanupOrphanBranches(
|
|
259
|
-
createRunId(featurePrefix.replace(
|
|
259
|
+
createRunId(featurePrefix.replace(/^[^/]+\//, "")),
|
|
260
260
|
true,
|
|
261
261
|
);
|
|
262
262
|
deletedBranches = [...deletedBranches, ...featureBranches];
|
|
@@ -28,7 +28,7 @@ export interface TaskDispatchContext {
|
|
|
28
28
|
/** Path to the parent worktree where task branches are merged into.
|
|
29
29
|
* If not provided, falls back to process.cwd() (repo root). */
|
|
30
30
|
parentWorktreePath?: string;
|
|
31
|
-
/** Feature branch name (e.g.
|
|
31
|
+
/** Feature branch name (e.g. feat/auth-feature/parent).
|
|
32
32
|
* Task branches are created from this branch. */
|
|
33
33
|
featureBranchName?: string;
|
|
34
34
|
}
|
|
@@ -61,10 +61,10 @@ export function registerTaskDispatchHandler(ctx: TaskDispatchContext): void {
|
|
|
61
61
|
}
|
|
62
62
|
|
|
63
63
|
// Derive task branch name from feature branch or runId
|
|
64
|
-
// e.g.
|
|
64
|
+
// e.g. feat/auth-feature/task-001 (from feat/auth-feature/parent)
|
|
65
65
|
const branchPrefix = featureBranchName
|
|
66
66
|
? featureBranchName.replace(/\/parent$/, "")
|
|
67
|
-
: `
|
|
67
|
+
: `feat/${runId}`;
|
|
68
68
|
const branchName = `${branchPrefix}/${taskId}`;
|
|
69
69
|
const worktreePath = await worktreeManager.createTaskWorktree(taskId, branchName, featureBranchName);
|
|
70
70
|
logger.info({ taskId, worktreePath, branchName }, "Worktree created");
|
|
@@ -34,11 +34,15 @@ describe("cleanupOrphanedFromPreviousRuns", () => {
|
|
|
34
34
|
};
|
|
35
35
|
}
|
|
36
36
|
if (args[0] === "branch" && args[1] === "--list") {
|
|
37
|
-
return
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
37
|
+
// Only return branches for matching prefix pattern
|
|
38
|
+
if (args[2] === "aad/*") {
|
|
39
|
+
return {
|
|
40
|
+
stdout: " aad/run1/task-001\n aad/run1/task-002\n",
|
|
41
|
+
stderr: "",
|
|
42
|
+
exitCode: 0,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
return { stdout: "", stderr: "", exitCode: 0 };
|
|
42
46
|
}
|
|
43
47
|
return { stdout: "", stderr: "", exitCode: 0 };
|
|
44
48
|
}),
|
|
@@ -74,7 +74,7 @@ describe("WorktreeManager", () => {
|
|
|
74
74
|
test("creates worktree with feature branch", async () => {
|
|
75
75
|
const runId = createRunId("run-001");
|
|
76
76
|
const parentBranch = "main";
|
|
77
|
-
const featureBranch = "
|
|
77
|
+
const featureBranch = "feat/auth-feature/parent";
|
|
78
78
|
|
|
79
79
|
const result = await worktreeManager.createParentWorktree(runId, parentBranch, featureBranch);
|
|
80
80
|
|
|
@@ -92,9 +92,9 @@ describe("WorktreeManager", () => {
|
|
|
92
92
|
|
|
93
93
|
const result = await worktreeManager.createParentWorktree(runId, parentBranch);
|
|
94
94
|
|
|
95
|
-
expect(result.branch).toBe("
|
|
95
|
+
expect(result.branch).toBe("feat/run-001/parent");
|
|
96
96
|
expect(mockGitOps.gitExec).toHaveBeenCalledWith(
|
|
97
|
-
["worktree", "add", "-b", "
|
|
97
|
+
["worktree", "add", "-b", "feat/run-001/parent", "/test/worktrees/parent-run-001", parentBranch],
|
|
98
98
|
expect.objectContaining({ cwd: repoRoot })
|
|
99
99
|
);
|
|
100
100
|
});
|
|
@@ -137,10 +137,17 @@ export class BranchManager {
|
|
|
137
137
|
* Cleanup orphaned AAD branches (branches without worktrees)
|
|
138
138
|
*/
|
|
139
139
|
async cleanupOrphanBranches(runId?: RunId, force = false): Promise<string[]> {
|
|
140
|
-
// Support
|
|
140
|
+
// Support aad/*, conventional commit prefixes (feat/*, fix/*, etc.), and legacy aad-* patterns
|
|
141
|
+
const conventionalPrefixes = ["aad", "feat", "fix", "docs", "style", "refactor", "perf", "test", "chore"];
|
|
141
142
|
const patterns = runId
|
|
142
|
-
? [
|
|
143
|
-
|
|
143
|
+
? [
|
|
144
|
+
...conventionalPrefixes.map((p) => `${p}/${runId as string}/*`),
|
|
145
|
+
`aad-*-${runId as string}*`,
|
|
146
|
+
]
|
|
147
|
+
: [
|
|
148
|
+
...conventionalPrefixes.map((p) => `${p}/*`),
|
|
149
|
+
"aad-*",
|
|
150
|
+
];
|
|
144
151
|
|
|
145
152
|
this.logger?.info({ patterns, force }, "Cleaning up orphan branches");
|
|
146
153
|
|
|
@@ -76,11 +76,11 @@ export class WorktreeManager {
|
|
|
76
76
|
* Handles cases where a previous run left behind artifacts.
|
|
77
77
|
*/
|
|
78
78
|
private async cleanupStaleWorktree(worktreePath: string, branch: string): Promise<void> {
|
|
79
|
-
// 1. Remove existing worktree directory if it exists
|
|
79
|
+
// 1. Remove existing worktree directory if it exists (no logger to suppress error-level output)
|
|
80
80
|
try {
|
|
81
81
|
await this.gitOps.gitExec(
|
|
82
82
|
["worktree", "remove", worktreePath, "--force"],
|
|
83
|
-
{ cwd: this.repoRoot
|
|
83
|
+
{ cwd: this.repoRoot }
|
|
84
84
|
);
|
|
85
85
|
this.logger?.info({ worktreePath }, "Removed stale worktree");
|
|
86
86
|
} catch {
|
|
@@ -99,11 +99,11 @@ export class WorktreeManager {
|
|
|
99
99
|
// Non-critical
|
|
100
100
|
}
|
|
101
101
|
|
|
102
|
-
// 3. Delete stale branch if it exists
|
|
102
|
+
// 3. Delete stale branch if it exists (no logger to suppress error-level git output)
|
|
103
103
|
try {
|
|
104
104
|
await this.gitOps.gitExec(
|
|
105
105
|
["branch", "-D", branch],
|
|
106
|
-
{ cwd: this.repoRoot
|
|
106
|
+
{ cwd: this.repoRoot }
|
|
107
107
|
);
|
|
108
108
|
this.logger?.info({ branch }, "Deleted stale branch");
|
|
109
109
|
} catch {
|
|
@@ -120,7 +120,7 @@ export class WorktreeManager {
|
|
|
120
120
|
featureBranch?: string,
|
|
121
121
|
): Promise<{ worktreePath: string; branch: string }> {
|
|
122
122
|
const worktreePath = join(this.worktreeBase, `parent-${runId as string}`);
|
|
123
|
-
const branch = featureBranch ?? `
|
|
123
|
+
const branch = featureBranch ?? `feat/${runId as string}/parent`;
|
|
124
124
|
|
|
125
125
|
this.logger?.info({ runId, parentBranch, branch, worktreePath }, "Creating parent worktree");
|
|
126
126
|
|
|
@@ -128,7 +128,7 @@ export class WorktreeManager {
|
|
|
128
128
|
await this.fsOps.mkdir(this.worktreeBase, { recursive: true });
|
|
129
129
|
|
|
130
130
|
// Create worktree with a new branch based on parentBranch
|
|
131
|
-
// e.g. git worktree add -b
|
|
131
|
+
// e.g. git worktree add -b feat/auth-feature/parent <path> main
|
|
132
132
|
await this.gitOps.gitExec(
|
|
133
133
|
["worktree", "add", "-b", branch, worktreePath, parentBranch],
|
|
134
134
|
{ cwd: this.repoRoot, logger: this.logger }
|
|
@@ -287,28 +287,36 @@ export async function cleanupOrphanedFromPreviousRuns(
|
|
|
287
287
|
// non-critical
|
|
288
288
|
}
|
|
289
289
|
|
|
290
|
-
// 3. Delete all branches matching aad
|
|
290
|
+
// 3. Delete all branches matching aad/*, feat/*, fix/*, etc.
|
|
291
|
+
const branchPrefixes = ["aad", "feat", "fix", "docs", "style", "refactor", "perf", "test", "chore"];
|
|
291
292
|
try {
|
|
292
293
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
293
294
|
const gitOps = (worktreeManager as any).gitOps as GitOps;
|
|
294
295
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
295
296
|
const repoRoot = (worktreeManager as any).repoRoot as string;
|
|
296
|
-
const result = await gitOps.gitExec(
|
|
297
|
-
["branch", "--list", "aad/*"],
|
|
298
|
-
{ cwd: repoRoot, logger },
|
|
299
|
-
);
|
|
300
|
-
const branches = result.stdout
|
|
301
|
-
.split("\n")
|
|
302
|
-
.map((b: string) => b.trim().replace(/^\* /, ""))
|
|
303
|
-
.filter((b: string) => b.length > 0);
|
|
304
297
|
|
|
305
|
-
for (const
|
|
298
|
+
for (const prefix of branchPrefixes) {
|
|
306
299
|
try {
|
|
307
|
-
await gitOps.gitExec(
|
|
308
|
-
|
|
309
|
-
|
|
300
|
+
const result = await gitOps.gitExec(
|
|
301
|
+
["branch", "--list", `${prefix}/*`],
|
|
302
|
+
{ cwd: repoRoot },
|
|
303
|
+
);
|
|
304
|
+
const branches = result.stdout
|
|
305
|
+
.split("\n")
|
|
306
|
+
.map((b: string) => b.trim().replace(/^[*+]\s*/, ""))
|
|
307
|
+
.filter((b: string) => b.length > 0);
|
|
308
|
+
|
|
309
|
+
for (const branch of branches) {
|
|
310
|
+
try {
|
|
311
|
+
await gitOps.gitExec(["branch", "-D", branch], { cwd: repoRoot });
|
|
312
|
+
deletedBranches++;
|
|
313
|
+
logger.debug({ branch }, "Deleted orphaned branch");
|
|
314
|
+
} catch {
|
|
315
|
+
logger.debug({ branch }, "Failed to delete orphaned branch (ignored)");
|
|
316
|
+
}
|
|
317
|
+
}
|
|
310
318
|
} catch {
|
|
311
|
-
|
|
319
|
+
// Pattern didn't match any branches
|
|
312
320
|
}
|
|
313
321
|
}
|
|
314
322
|
} catch (error) {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect } from "bun:test";
|
|
2
|
-
import { sanitizeBranchName, generateFeatureName } from "../feature-name-generator";
|
|
2
|
+
import { sanitizeBranchName, parsePrefix, generateFeatureName } from "../feature-name-generator";
|
|
3
3
|
import type { ClaudeProvider } from "../../claude-provider";
|
|
4
4
|
|
|
5
5
|
describe("Feature Name Generator", () => {
|
|
@@ -20,8 +20,13 @@ describe("Feature Name Generator", () => {
|
|
|
20
20
|
expect(sanitizeBranchName("-hello-")).toBe("hello");
|
|
21
21
|
});
|
|
22
22
|
|
|
23
|
-
it("truncates to
|
|
24
|
-
|
|
23
|
+
it("truncates to 30 characters by default", () => {
|
|
24
|
+
const result = sanitizeBranchName("this-is-a-very-long-branch-name-that-exceeds-limit");
|
|
25
|
+
expect(result.length).toBeLessThanOrEqual(30);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("accepts custom maxLength", () => {
|
|
29
|
+
expect(sanitizeBranchName("abcdefghij", 5)).toBe("abcde");
|
|
25
30
|
});
|
|
26
31
|
|
|
27
32
|
it("handles empty string", () => {
|
|
@@ -33,11 +38,51 @@ describe("Feature Name Generator", () => {
|
|
|
33
38
|
});
|
|
34
39
|
});
|
|
35
40
|
|
|
41
|
+
describe("parsePrefix", () => {
|
|
42
|
+
it("parses valid prefixes", () => {
|
|
43
|
+
expect(parsePrefix("feat")).toBe("feat");
|
|
44
|
+
expect(parsePrefix("fix")).toBe("fix");
|
|
45
|
+
expect(parsePrefix("docs")).toBe("docs");
|
|
46
|
+
expect(parsePrefix("refactor")).toBe("refactor");
|
|
47
|
+
expect(parsePrefix("chore")).toBe("chore");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("handles trailing colon", () => {
|
|
51
|
+
expect(parsePrefix("feat:")).toBe("feat");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("handles case insensitivity", () => {
|
|
55
|
+
expect(parsePrefix("FEAT")).toBe("feat");
|
|
56
|
+
expect(parsePrefix("Fix")).toBe("fix");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("defaults to feat for unknown", () => {
|
|
60
|
+
expect(parsePrefix("unknown")).toBe("feat");
|
|
61
|
+
expect(parsePrefix("")).toBe("feat");
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
36
65
|
describe("generateFeatureName", () => {
|
|
37
|
-
it("
|
|
66
|
+
it("parses prefix and name from two-line response", async () => {
|
|
67
|
+
const mockProvider: ClaudeProvider = {
|
|
68
|
+
call: async () => ({
|
|
69
|
+
result: "fix\nauth-login-bug\n",
|
|
70
|
+
exitCode: 0,
|
|
71
|
+
model: "claude-haiku-4-5",
|
|
72
|
+
effortLevel: "low" as const,
|
|
73
|
+
duration: 100,
|
|
74
|
+
}),
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const result = await generateFeatureName("/dev/null", mockProvider);
|
|
78
|
+
expect(result.prefix).toBe("fix");
|
|
79
|
+
expect(result.name).toBe("auth-login-bug");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("defaults prefix to feat when single line", async () => {
|
|
38
83
|
const mockProvider: ClaudeProvider = {
|
|
39
84
|
call: async () => ({
|
|
40
|
-
result: "
|
|
85
|
+
result: "auth-login\n",
|
|
41
86
|
exitCode: 0,
|
|
42
87
|
model: "claude-haiku-4-5",
|
|
43
88
|
effortLevel: "low" as const,
|
|
@@ -45,8 +90,9 @@ describe("Feature Name Generator", () => {
|
|
|
45
90
|
}),
|
|
46
91
|
};
|
|
47
92
|
|
|
48
|
-
const
|
|
49
|
-
expect(
|
|
93
|
+
const result = await generateFeatureName("/dev/null", mockProvider);
|
|
94
|
+
expect(result.prefix).toBe("feat");
|
|
95
|
+
expect(result.name).toBe("auth-login");
|
|
50
96
|
});
|
|
51
97
|
|
|
52
98
|
it("falls back to filename on provider error", async () => {
|
|
@@ -54,14 +100,15 @@ describe("Feature Name Generator", () => {
|
|
|
54
100
|
call: async () => { throw new Error("API error"); },
|
|
55
101
|
};
|
|
56
102
|
|
|
57
|
-
const
|
|
58
|
-
expect(
|
|
103
|
+
const result = await generateFeatureName("/some/path/auth-feature.md", mockProvider);
|
|
104
|
+
expect(result.prefix).toBe("feat");
|
|
105
|
+
expect(result.name).toBe("auth-feature");
|
|
59
106
|
});
|
|
60
107
|
|
|
61
108
|
it("falls back to filename when result too short", async () => {
|
|
62
109
|
const mockProvider: ClaudeProvider = {
|
|
63
110
|
call: async () => ({
|
|
64
|
-
result: "
|
|
111
|
+
result: "feat\nab",
|
|
65
112
|
exitCode: 0,
|
|
66
113
|
model: "claude-haiku-4-5",
|
|
67
114
|
effortLevel: "low" as const,
|
|
@@ -69,8 +116,9 @@ describe("Feature Name Generator", () => {
|
|
|
69
116
|
}),
|
|
70
117
|
};
|
|
71
118
|
|
|
72
|
-
const
|
|
73
|
-
expect(
|
|
119
|
+
const result = await generateFeatureName("/path/user-signup.md", mockProvider);
|
|
120
|
+
expect(result.prefix).toBe("feat");
|
|
121
|
+
expect(result.name).toBe("user-signup");
|
|
74
122
|
});
|
|
75
123
|
});
|
|
76
124
|
});
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Feature Name Generator
|
|
3
|
-
* Generates a
|
|
4
|
-
* Used for branch naming:
|
|
3
|
+
* Generates a git branch prefix and feature name from a requirements document.
|
|
4
|
+
* Used for branch naming: {prefix}/{featureName}/parent, {prefix}/{featureName}/task-001
|
|
5
|
+
*
|
|
6
|
+
* Prefix follows conventional commit types:
|
|
7
|
+
* feat, fix, docs, style, refactor, perf, test, chore
|
|
5
8
|
*
|
|
6
9
|
* Ported from .aad/scripts/run-parallel.sh extract_requirement_title()
|
|
7
10
|
*/
|
|
@@ -10,12 +13,24 @@ import type pino from "pino";
|
|
|
10
13
|
import { readFile } from "node:fs/promises";
|
|
11
14
|
import { basename } from "node:path";
|
|
12
15
|
|
|
13
|
-
const
|
|
16
|
+
const VALID_PREFIXES = [
|
|
17
|
+
"feat",
|
|
18
|
+
"fix",
|
|
19
|
+
"docs",
|
|
20
|
+
"style",
|
|
21
|
+
"refactor",
|
|
22
|
+
"perf",
|
|
23
|
+
"test",
|
|
24
|
+
"chore",
|
|
25
|
+
] as const;
|
|
26
|
+
|
|
27
|
+
export type BranchPrefix = (typeof VALID_PREFIXES)[number];
|
|
28
|
+
|
|
29
|
+
const FEATURE_NAME_PROMPT = `You are a git branch name generator. Given the requirements below, output EXACTLY two lines:
|
|
30
|
+
Line 1: The conventional commit type (one of: feat, fix, docs, style, refactor, perf, test, chore)
|
|
31
|
+
Line 2: A short English identifier for the branch name (lowercase alphanumeric and hyphens only, 30 chars max)
|
|
14
32
|
|
|
15
|
-
|
|
16
|
-
- Lowercase alphanumeric and hyphens only
|
|
17
|
-
- 20 characters max
|
|
18
|
-
- No explanation, no decoration, just the identifier
|
|
33
|
+
No explanation, no decoration, just the two lines.
|
|
19
34
|
|
|
20
35
|
Requirements:
|
|
21
36
|
`;
|
|
@@ -23,24 +38,40 @@ Requirements:
|
|
|
23
38
|
/**
|
|
24
39
|
* Sanitize a string to be a valid git branch name component
|
|
25
40
|
*/
|
|
26
|
-
export function sanitizeBranchName(name: string): string {
|
|
41
|
+
export function sanitizeBranchName(name: string, maxLength = 30): string {
|
|
27
42
|
return name
|
|
28
43
|
.toLowerCase()
|
|
29
44
|
.replace(/[^a-z0-9-]/g, "-")
|
|
30
45
|
.replace(/-+/g, "-")
|
|
31
46
|
.replace(/^-|-$/g, "")
|
|
32
|
-
.slice(0,
|
|
47
|
+
.slice(0, maxLength);
|
|
33
48
|
}
|
|
34
49
|
|
|
35
50
|
/**
|
|
36
|
-
*
|
|
51
|
+
* Parse prefix from Claude response, defaulting to "feat"
|
|
52
|
+
*/
|
|
53
|
+
export function parsePrefix(line: string): BranchPrefix {
|
|
54
|
+
const trimmed = line.trim().toLowerCase().replace(/:$/, "");
|
|
55
|
+
if (VALID_PREFIXES.includes(trimmed as BranchPrefix)) {
|
|
56
|
+
return trimmed as BranchPrefix;
|
|
57
|
+
}
|
|
58
|
+
return "feat";
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface FeatureNameResult {
|
|
62
|
+
prefix: BranchPrefix;
|
|
63
|
+
name: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Generate a feature name and prefix from requirements file using Claude
|
|
37
68
|
* Falls back to filename-based name on failure
|
|
38
69
|
*/
|
|
39
70
|
export async function generateFeatureName(
|
|
40
71
|
requirementsPath: string,
|
|
41
72
|
provider: ClaudeProvider,
|
|
42
73
|
logger?: pino.Logger,
|
|
43
|
-
): Promise<
|
|
74
|
+
): Promise<FeatureNameResult> {
|
|
44
75
|
try {
|
|
45
76
|
// Read first 20 lines of requirements
|
|
46
77
|
const fullContent = await readFile(requirementsPath, "utf-8");
|
|
@@ -51,13 +82,14 @@ export async function generateFeatureName(
|
|
|
51
82
|
model: "claude-haiku-4-5",
|
|
52
83
|
});
|
|
53
84
|
|
|
54
|
-
const
|
|
55
|
-
|
|
85
|
+
const lines = result.result.trim().split("\n").filter(Boolean);
|
|
86
|
+
const prefix = parsePrefix(lines[0] ?? "");
|
|
87
|
+
const rawName = (lines[1] ?? lines[0] ?? "").trim();
|
|
56
88
|
const sanitized = sanitizeBranchName(rawName);
|
|
57
89
|
|
|
58
90
|
if (sanitized.length >= 3) {
|
|
59
|
-
logger?.info({ featureName: sanitized, raw: rawName }, "Feature name generated");
|
|
60
|
-
return sanitized;
|
|
91
|
+
logger?.info({ prefix, featureName: sanitized, raw: rawName }, "Feature name generated");
|
|
92
|
+
return { prefix, name: sanitized };
|
|
61
93
|
}
|
|
62
94
|
} catch (error) {
|
|
63
95
|
logger?.warn({ error }, "Feature name generation failed, using fallback");
|
|
@@ -67,5 +99,5 @@ export async function generateFeatureName(
|
|
|
67
99
|
const fallback = sanitizeBranchName(
|
|
68
100
|
basename(requirementsPath).replace(/\.[^.]*$/, ""),
|
|
69
101
|
);
|
|
70
|
-
return fallback || "default";
|
|
102
|
+
return { prefix: "feat", name: fallback || "default" };
|
|
71
103
|
}
|
|
@@ -226,8 +226,11 @@ export class Dispatcher {
|
|
|
226
226
|
|
|
227
227
|
this.deps.logger.info({ taskId }, "Task completed");
|
|
228
228
|
|
|
229
|
-
//
|
|
230
|
-
|
|
229
|
+
// Note: Do NOT call dispatchNext() here.
|
|
230
|
+
// The worker:idle event (emitted by task-dispatch-handler after completeTask)
|
|
231
|
+
// will trigger handleWorkerIdle → dispatchNext(). Calling it here too causes
|
|
232
|
+
// a race condition where two dispatchNext() calls run concurrently without a mutex,
|
|
233
|
+
// potentially dispatching multiple tasks to the same worker.
|
|
231
234
|
}
|
|
232
235
|
|
|
233
236
|
async handleTaskFailed(taskId: TaskId, error: string): Promise<void> {
|
|
@@ -259,8 +262,7 @@ export class Dispatcher {
|
|
|
259
262
|
"Task failed, retrying"
|
|
260
263
|
);
|
|
261
264
|
|
|
262
|
-
//
|
|
263
|
-
await this.dispatchNext();
|
|
265
|
+
// Dispatch deferred to worker:idle event (avoids race condition)
|
|
264
266
|
} else {
|
|
265
267
|
// Failed permanently
|
|
266
268
|
task.status = "failed";
|