Skip to content

Commit f59aeac

Browse files
recuu-pfegclaude
andcommitted
feat: task auto-split (8SP+) + dependency-aware assignment
- task-splitter.ts: auto-split large tasks via LLM (Haiku), create subtasks via API - task-dependency.ts: keyword + file-path based dependency detection, topological sort - run-loop.ts: pre-process split + dependency sort before dispatch - Tasks with unresolved deps are skipped and re-evaluated on next poll - 34 new tests (13 splitter + 21 dependency), 145 total pass Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent e2f80ea commit f59aeac

5 files changed

Lines changed: 882 additions & 86 deletions

File tree

Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
1+
import { describe, it, expect } from "vitest";
2+
import {
3+
extractPaths,
4+
pathsOverlap,
5+
detectDependencies,
6+
sortByDependency,
7+
type Dependency,
8+
} from "../task-dependency.js";
9+
import type { Task } from "../api-client.js";
10+
11+
function makeTask(overrides: Partial<Task> = {}): Task {
12+
return {
13+
id: `task-${Math.random().toString(36).slice(2, 8)}`,
14+
title: "Test task",
15+
description: "",
16+
status: "in_progress",
17+
priority: "p2",
18+
owner: "builder",
19+
...overrides,
20+
} as Task;
21+
}
22+
23+
// ---------------------------------------------------------------------------
24+
// extractPaths
25+
// ---------------------------------------------------------------------------
26+
27+
describe("extractPaths", () => {
28+
it("extracts TypeScript file paths", () => {
29+
const paths = extractPaths("Modify src/api-client.ts and src/commands/run-loop.ts");
30+
expect(paths).toContain("src/api-client.ts");
31+
expect(paths).toContain("src/commands/run-loop.ts");
32+
});
33+
34+
it("extracts paths in backticks", () => {
35+
const paths = extractPaths("Edit `src/utils/parse-labels.ts`");
36+
expect(paths).toContain("src/utils/parse-labels.ts");
37+
});
38+
39+
it("extracts directory paths", () => {
40+
const paths = extractPaths("Changes in src/commands/ and handlers/git-merge");
41+
expect(paths).toContain("src/commands/");
42+
expect(paths).toContain("handlers/git-merge");
43+
});
44+
45+
it("ignores non-file strings", () => {
46+
const paths = extractPaths("This is just a plain text description with no file paths");
47+
expect(paths).toHaveLength(0);
48+
});
49+
50+
it("returns empty for empty input", () => {
51+
expect(extractPaths("")).toHaveLength(0);
52+
expect(extractPaths(null as unknown as string)).toHaveLength(0);
53+
});
54+
});
55+
56+
// ---------------------------------------------------------------------------
57+
// pathsOverlap
58+
// ---------------------------------------------------------------------------
59+
60+
describe("pathsOverlap", () => {
61+
it("detects same file overlap", () => {
62+
const result = pathsOverlap(["src/api-client.ts"], ["src/api-client.ts"]);
63+
expect(result).toContain("same file");
64+
});
65+
66+
it("detects same directory overlap", () => {
67+
const result = pathsOverlap(["src/commands/run-loop.ts"], ["src/commands/setup.ts"]);
68+
expect(result).toContain("same directory");
69+
});
70+
71+
it("returns null for no overlap", () => {
72+
const result = pathsOverlap(["src/api-client.ts"], ["src/commands/run-loop.ts"]);
73+
expect(result).toBeNull();
74+
});
75+
76+
it("returns null for empty arrays", () => {
77+
expect(pathsOverlap([], [])).toBeNull();
78+
expect(pathsOverlap(["src/a.ts"], [])).toBeNull();
79+
});
80+
});
81+
82+
// ---------------------------------------------------------------------------
83+
// detectDependencies
84+
// ---------------------------------------------------------------------------
85+
86+
describe("detectDependencies", () => {
87+
it("detects explicit Japanese dependency keywords", () => {
88+
const taskA = makeTask({ id: "a", title: "API client refactor", description: "Refactor api-client.ts" });
89+
const taskB = makeTask({ id: "b", title: "Update tests", description: "API client refactorの後にテストを更新" });
90+
91+
const deps = detectDependencies([taskA, taskB]);
92+
expect(deps.length).toBeGreaterThanOrEqual(1);
93+
const dep = deps.find((d) => d.to === "b" && d.from === "a");
94+
expect(dep).toBeDefined();
95+
expect(dep!.type).toBe("explicit");
96+
});
97+
98+
it("detects explicit English dependency keywords", () => {
99+
const taskA = makeTask({ id: "a", title: "Setup database schema", description: "Create tables" });
100+
const taskB = makeTask({ id: "b", title: "API endpoints", description: "depends on Setup database schema" });
101+
102+
const deps = detectDependencies([taskA, taskB]);
103+
const dep = deps.find((d) => d.to === "b" && d.from === "a");
104+
expect(dep).toBeDefined();
105+
expect(dep!.type).toBe("explicit");
106+
});
107+
108+
it("detects file-based conflicts", () => {
109+
const taskA = makeTask({ id: "a", title: "Fix api client", description: "Modify src/api-client.ts" });
110+
const taskB = makeTask({ id: "b", title: "Add retry", description: "Add retry to src/api-client.ts" });
111+
112+
const deps = detectDependencies([taskA, taskB]);
113+
const dep = deps.find((d) => d.type === "file_conflict");
114+
expect(dep).toBeDefined();
115+
expect(dep!.reason).toContain("api-client.ts");
116+
});
117+
118+
it("higher priority task is the dependency source for file conflicts", () => {
119+
const taskA = makeTask({ id: "a", title: "Fix api client", description: "Modify src/api-client.ts", priority: "p1" });
120+
const taskB = makeTask({ id: "b", title: "Add retry", description: "Add retry to src/api-client.ts", priority: "p3" });
121+
122+
const deps = detectDependencies([taskA, taskB]);
123+
const dep = deps.find((d) => d.type === "file_conflict");
124+
expect(dep).toBeDefined();
125+
expect(dep!.from).toBe("a"); // p1 goes first
126+
expect(dep!.to).toBe("b");
127+
});
128+
129+
it("returns empty for independent tasks", () => {
130+
const taskA = makeTask({ id: "a", title: "Build frontend", description: "Create React components" });
131+
const taskB = makeTask({ id: "b", title: "Write API docs", description: "Document REST endpoints" });
132+
133+
const deps = detectDependencies([taskA, taskB]);
134+
expect(deps).toHaveLength(0);
135+
});
136+
});
137+
138+
// ---------------------------------------------------------------------------
139+
// sortByDependency
140+
// ---------------------------------------------------------------------------
141+
142+
describe("sortByDependency", () => {
143+
it("puts dependency-free tasks first", () => {
144+
const taskA = makeTask({ id: "a", title: "Independent task", priority: "p2" });
145+
const taskB = makeTask({ id: "b", title: "Dependent task", priority: "p2" });
146+
147+
const deps: Dependency[] = [{ from: "a", to: "b", reason: "test", type: "explicit" }];
148+
const result = sortByDependency([taskB, taskA], deps);
149+
150+
expect(result[0].id).toBe("a");
151+
expect(result[1].id).toBe("b");
152+
expect(result[0].parallelReady).toBe(true);
153+
expect(result[1].parallelReady).toBe(false);
154+
expect(result[1].dependsOn).toEqual(["a"]);
155+
});
156+
157+
it("marks all tasks as parallelReady when no deps", () => {
158+
const taskA = makeTask({ id: "a", title: "Task A", priority: "p1" });
159+
const taskB = makeTask({ id: "b", title: "Task B", priority: "p2" });
160+
161+
const result = sortByDependency([taskA, taskB], []);
162+
expect(result.every((t) => t.parallelReady)).toBe(true);
163+
});
164+
165+
it("resolves completed dependencies", () => {
166+
const taskA = makeTask({ id: "a", title: "Task A" });
167+
const taskB = makeTask({ id: "b", title: "Task B" });
168+
169+
const deps: Dependency[] = [{ from: "a", to: "b", reason: "test", type: "explicit" }];
170+
const completed = new Set(["a"]);
171+
const result = sortByDependency([taskA, taskB], deps, completed);
172+
173+
// taskB should now be parallelReady since taskA is completed
174+
const taskBResult = result.find((t) => t.id === "b")!;
175+
expect(taskBResult.parallelReady).toBe(true);
176+
expect(taskBResult.dependsOn).toHaveLength(0);
177+
});
178+
179+
it("handles circular dependencies gracefully", () => {
180+
const taskA = makeTask({ id: "a", title: "Task A" });
181+
const taskB = makeTask({ id: "b", title: "Task B" });
182+
183+
const deps: Dependency[] = [
184+
{ from: "a", to: "b", reason: "test", type: "explicit" },
185+
{ from: "b", to: "a", reason: "test", type: "explicit" },
186+
];
187+
188+
// Should not throw, should include both tasks
189+
const result = sortByDependency([taskA, taskB], deps);
190+
expect(result).toHaveLength(2);
191+
expect(result.map((t) => t.id)).toContain("a");
192+
expect(result.map((t) => t.id)).toContain("b");
193+
});
194+
195+
it("sorts by priority within same dependency level", () => {
196+
const taskA = makeTask({ id: "a", title: "Low priority", priority: "p3" });
197+
const taskB = makeTask({ id: "b", title: "High priority", priority: "p1" });
198+
const taskC = makeTask({ id: "c", title: "Medium priority", priority: "p2" });
199+
200+
const result = sortByDependency([taskA, taskB, taskC], []);
201+
expect(result[0].id).toBe("b"); // p1
202+
expect(result[1].id).toBe("c"); // p2
203+
expect(result[2].id).toBe("a"); // p3
204+
});
205+
206+
it("handles chain dependencies (A -> B -> C)", () => {
207+
const taskA = makeTask({ id: "a", title: "Step 1" });
208+
const taskB = makeTask({ id: "b", title: "Step 2" });
209+
const taskC = makeTask({ id: "c", title: "Step 3" });
210+
211+
const deps: Dependency[] = [
212+
{ from: "a", to: "b", reason: "chain", type: "explicit" },
213+
{ from: "b", to: "c", reason: "chain", type: "explicit" },
214+
];
215+
216+
const result = sortByDependency([taskC, taskA, taskB], deps);
217+
const ids = result.map((t) => t.id);
218+
expect(ids.indexOf("a")).toBeLessThan(ids.indexOf("b"));
219+
expect(ids.indexOf("b")).toBeLessThan(ids.indexOf("c"));
220+
221+
expect(result.find((t) => t.id === "a")!.parallelReady).toBe(true);
222+
expect(result.find((t) => t.id === "b")!.parallelReady).toBe(false);
223+
expect(result.find((t) => t.id === "c")!.parallelReady).toBe(false);
224+
});
225+
226+
it("ignores deps referencing tasks not in the list", () => {
227+
const taskA = makeTask({ id: "a", title: "Task A" });
228+
const deps: Dependency[] = [{ from: "nonexistent", to: "a", reason: "test", type: "explicit" }];
229+
230+
const result = sortByDependency([taskA], deps);
231+
expect(result[0].parallelReady).toBe(true);
232+
});
233+
});
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
import { describe, it, expect, vi, beforeEach } from "vitest";
2+
import { shouldSplit, autoSplitTasks, type SubtaskDef } from "../task-splitter.js";
3+
import type { Task } from "../api-client.js";
4+
5+
function makeTask(overrides: Partial<Task> = {}): Task {
6+
return {
7+
id: "task-001",
8+
title: "Implement feature X",
9+
description: "Build the feature X with tests",
10+
status: "todo",
11+
priority: "p2",
12+
story_points: 8,
13+
owner: "builder",
14+
type: "feature",
15+
...overrides,
16+
} as Task;
17+
}
18+
19+
describe("shouldSplit", () => {
20+
it("returns true for task with SP >= threshold", () => {
21+
expect(shouldSplit(makeTask({ story_points: 8 }), 8)).toBe(true);
22+
expect(shouldSplit(makeTask({ story_points: 13 }), 8)).toBe(true);
23+
});
24+
25+
it("returns false for task with SP < threshold", () => {
26+
expect(shouldSplit(makeTask({ story_points: 5 }), 8)).toBe(false);
27+
expect(shouldSplit(makeTask({ story_points: 7 }), 8)).toBe(false);
28+
});
29+
30+
it("returns false for null/undefined SP", () => {
31+
expect(shouldSplit(makeTask({ story_points: null }), 8)).toBe(false);
32+
expect(shouldSplit(makeTask({ story_points: undefined }), 8)).toBe(false);
33+
});
34+
35+
it("returns false for task with auto_split:false label", () => {
36+
expect(shouldSplit(makeTask({ labels: ["auto_split:false"] }), 8)).toBe(false);
37+
});
38+
39+
it("returns false for task with no_split label", () => {
40+
expect(shouldSplit(makeTask({ labels: '["no_split"]' }), 8)).toBe(false);
41+
});
42+
43+
it("returns false for already-blocked tasks", () => {
44+
expect(shouldSplit(makeTask({ status: "blocked" as Task["status"] }), 8)).toBe(false);
45+
});
46+
47+
it("returns false for sub-tasks (has parent_task)", () => {
48+
expect(shouldSplit(makeTask({ parent_task: "parent-001" }), 8)).toBe(false);
49+
});
50+
51+
it("returns false for done tasks", () => {
52+
expect(shouldSplit(makeTask({ status: "done" as Task["status"] }), 8)).toBe(false);
53+
});
54+
});
55+
56+
describe("autoSplitTasks", () => {
57+
let fetchMock: ReturnType<typeof vi.fn>;
58+
59+
beforeEach(() => {
60+
fetchMock = vi.fn().mockResolvedValue({ ok: true, json: () => Promise.resolve({}) });
61+
vi.stubGlobal("fetch", fetchMock);
62+
});
63+
64+
const mockSubtasks: SubtaskDef[] = [
65+
{ title: "Subtask 1", description: "First part", owner: "builder", type: "feature", priority: "p2", story_points: 3 },
66+
{ title: "Subtask 2", description: "Second part", owner: "builder", type: "feature", priority: "p2", story_points: 3 },
67+
];
68+
69+
it("splits tasks and creates subtasks via API", async () => {
70+
const task = makeTask({ story_points: 10 });
71+
const splitFn = vi.fn().mockResolvedValue(mockSubtasks);
72+
73+
const results = await autoSplitTasks([task], 5, {
74+
minSp: 8,
75+
apiUrl: "http://localhost:8787",
76+
apiKey: "test-key",
77+
}, splitFn);
78+
79+
expect(results).toHaveLength(1);
80+
expect(results[0].skipped).toBe(false);
81+
expect(results[0].subtasks).toHaveLength(2);
82+
expect(splitFn).toHaveBeenCalledOnce();
83+
84+
// 2 subtask creates + 1 parent status update = 3 fetch calls
85+
expect(fetchMock).toHaveBeenCalledTimes(3);
86+
87+
// Verify subtask creation calls
88+
const createCalls = fetchMock.mock.calls.filter(
89+
(call: unknown[]) => (call[1] as RequestInit).method === "POST"
90+
);
91+
expect(createCalls).toHaveLength(2);
92+
const body1 = JSON.parse((createCalls[0][1] as RequestInit).body as string);
93+
expect(body1.parent_task).toBe("task-001");
94+
expect(body1.sprint).toBe(5);
95+
expect(body1.status).toBe("todo");
96+
97+
// Verify parent blocked
98+
const patchCall = fetchMock.mock.calls.find(
99+
(call: unknown[]) => (call[1] as RequestInit).method === "PATCH"
100+
);
101+
expect(patchCall).toBeDefined();
102+
expect(JSON.parse((patchCall![1] as RequestInit).body as string)).toEqual({ status: "blocked" });
103+
});
104+
105+
it("skips tasks below SP threshold", async () => {
106+
const task = makeTask({ story_points: 5 });
107+
const splitFn = vi.fn().mockResolvedValue(mockSubtasks);
108+
109+
const results = await autoSplitTasks([task], 5, {
110+
minSp: 8,
111+
apiUrl: "http://localhost:8787",
112+
apiKey: "test-key",
113+
}, splitFn);
114+
115+
expect(results).toHaveLength(0);
116+
expect(splitFn).not.toHaveBeenCalled();
117+
});
118+
119+
it("handles LLM returning empty subtasks", async () => {
120+
const task = makeTask({ story_points: 10 });
121+
const splitFn = vi.fn().mockResolvedValue([]);
122+
123+
const results = await autoSplitTasks([task], 5, {
124+
minSp: 8,
125+
apiUrl: "http://localhost:8787",
126+
apiKey: "test-key",
127+
}, splitFn);
128+
129+
expect(results).toHaveLength(1);
130+
expect(results[0].skipped).toBe(true);
131+
expect(results[0].reason).toContain("no valid subtasks");
132+
expect(fetchMock).not.toHaveBeenCalled();
133+
});
134+
135+
it("handles LLM errors gracefully", async () => {
136+
const task = makeTask({ story_points: 10 });
137+
const splitFn = vi.fn().mockRejectedValue(new Error("LLM timeout"));
138+
139+
const results = await autoSplitTasks([task], 5, {
140+
minSp: 8,
141+
apiUrl: "http://localhost:8787",
142+
apiKey: "test-key",
143+
}, splitFn);
144+
145+
expect(results).toHaveLength(1);
146+
expect(results[0].skipped).toBe(true);
147+
expect(results[0].reason).toContain("LLM timeout");
148+
});
149+
150+
it("skips tasks with auto_split:false label", async () => {
151+
const task = makeTask({ story_points: 10, labels: ["auto_split:false"] });
152+
const splitFn = vi.fn().mockResolvedValue(mockSubtasks);
153+
154+
const results = await autoSplitTasks([task], 5, {
155+
minSp: 8,
156+
apiUrl: "http://localhost:8787",
157+
apiKey: "test-key",
158+
}, splitFn);
159+
160+
expect(results).toHaveLength(0);
161+
expect(splitFn).not.toHaveBeenCalled();
162+
});
163+
});

0 commit comments

Comments
 (0)