fix(KB-144): exclude in-review worktrees from maxWorktrees limit
- Change activeWorktrees filter to only count in-progress tasks, not in-review with worktree - Update existing scheduler test to reflect in-progress-only counting - Add 3 new tests covering in-review tasks not blocking scheduling - Update maxWorktrees JSDoc to reflect new semantics - Add changeset for patch release
This commit is contained in:
5
.changeset/fix-in-review-worktree-counting.md
Normal file
5
.changeset/fix-in-review-worktree-counting.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@dustinbyrne/kb": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Fix scheduler to not count in-review worktrees against maxWorktrees limit. In-review tasks are idle (waiting to merge) and no longer block new tasks from starting.
|
||||||
@@ -175,11 +175,11 @@ describe("Scheduler dynamic settings reload", () => {
|
|||||||
it("reads maxWorktrees from store settings on each schedule() call", async () => {
|
it("reads maxWorktrees from store settings on each schedule() call", async () => {
|
||||||
const tasks = [
|
const tasks = [
|
||||||
makeTask({ id: "KB-001", column: "in-progress" }),
|
makeTask({ id: "KB-001", column: "in-progress" }),
|
||||||
makeTask({ id: "KB-002", column: "in-review", worktree: "/tmp/wt" }),
|
makeTask({ id: "KB-002", column: "in-progress" }),
|
||||||
makeTask({ id: "KB-003", column: "todo" }),
|
makeTask({ id: "KB-003", column: "todo" }),
|
||||||
];
|
];
|
||||||
const store = createMockStore(tasks);
|
const store = createMockStore(tasks);
|
||||||
// Start with maxWorktrees: 2 — no room (2 active worktrees)
|
// Start with maxWorktrees: 2 — no room (2 in-progress worktrees)
|
||||||
store.getSettings.mockResolvedValue({
|
store.getSettings.mockResolvedValue({
|
||||||
maxConcurrent: 4,
|
maxConcurrent: 4,
|
||||||
maxWorktrees: 2,
|
maxWorktrees: 2,
|
||||||
@@ -787,3 +787,85 @@ describe("Scheduler worktree limit logging", () => {
|
|||||||
logSpy.mockRestore();
|
logSpy.mockRestore();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("Scheduler in-review worktrees do not count against maxWorktrees", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function runSchedule(scheduler: Scheduler): Promise<void> {
|
||||||
|
(scheduler as any).running = true;
|
||||||
|
await scheduler.schedule();
|
||||||
|
}
|
||||||
|
|
||||||
|
it("in-review task with worktree does NOT count against maxWorktrees", async () => {
|
||||||
|
const tasks = [
|
||||||
|
makeTask({ id: "KB-001", column: "in-review", worktree: "/tmp/wt/kb-001" }),
|
||||||
|
makeTask({ id: "KB-002", column: "in-review", worktree: "/tmp/wt/kb-002" }),
|
||||||
|
makeTask({ id: "KB-003", column: "in-review", worktree: "/tmp/wt/kb-003" }),
|
||||||
|
makeTask({ id: "KB-004", column: "todo" }),
|
||||||
|
];
|
||||||
|
const store = createMockStore(tasks);
|
||||||
|
store.getSettings.mockResolvedValue({
|
||||||
|
maxConcurrent: 4,
|
||||||
|
maxWorktrees: 2,
|
||||||
|
pollIntervalMs: 15000,
|
||||||
|
groupOverlappingFiles: false,
|
||||||
|
autoMerge: false,
|
||||||
|
});
|
||||||
|
const scheduler = new Scheduler(store);
|
||||||
|
|
||||||
|
await runSchedule(scheduler);
|
||||||
|
|
||||||
|
// 3 in-review worktrees should NOT block KB-004 — only in-progress counts
|
||||||
|
expect(store.moveTask).toHaveBeenCalledWith("KB-004", "in-progress");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("in-review tasks with worktrees do NOT block scheduling even when many exist", async () => {
|
||||||
|
const tasks = [
|
||||||
|
makeTask({ id: "KB-001", column: "in-review", worktree: "/tmp/wt/kb-001" }),
|
||||||
|
makeTask({ id: "KB-002", column: "in-review", worktree: "/tmp/wt/kb-002" }),
|
||||||
|
makeTask({ id: "KB-003", column: "in-review", worktree: "/tmp/wt/kb-003" }),
|
||||||
|
makeTask({ id: "KB-004", column: "in-review", worktree: "/tmp/wt/kb-004" }),
|
||||||
|
makeTask({ id: "KB-005", column: "in-review", worktree: "/tmp/wt/kb-005" }),
|
||||||
|
makeTask({ id: "KB-006", column: "in-progress" }),
|
||||||
|
makeTask({ id: "KB-007", column: "todo" }),
|
||||||
|
];
|
||||||
|
const store = createMockStore(tasks);
|
||||||
|
store.getSettings.mockResolvedValue({
|
||||||
|
maxConcurrent: 4,
|
||||||
|
maxWorktrees: 2,
|
||||||
|
pollIntervalMs: 15000,
|
||||||
|
groupOverlappingFiles: false,
|
||||||
|
autoMerge: false,
|
||||||
|
});
|
||||||
|
const scheduler = new Scheduler(store);
|
||||||
|
|
||||||
|
await runSchedule(scheduler);
|
||||||
|
|
||||||
|
// 5 in-review + 1 in-progress = only 1 active worktree, room for KB-007
|
||||||
|
expect(store.moveTask).toHaveBeenCalledWith("KB-007", "in-progress");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maxWorktrees correctly limits only in-progress tasks", async () => {
|
||||||
|
const tasks = [
|
||||||
|
makeTask({ id: "KB-001", column: "in-progress" }),
|
||||||
|
makeTask({ id: "KB-002", column: "in-progress" }),
|
||||||
|
makeTask({ id: "KB-003", column: "todo" }),
|
||||||
|
];
|
||||||
|
const store = createMockStore(tasks);
|
||||||
|
store.getSettings.mockResolvedValue({
|
||||||
|
maxConcurrent: 4,
|
||||||
|
maxWorktrees: 2,
|
||||||
|
pollIntervalMs: 15000,
|
||||||
|
groupOverlappingFiles: false,
|
||||||
|
autoMerge: false,
|
||||||
|
});
|
||||||
|
const scheduler = new Scheduler(store);
|
||||||
|
|
||||||
|
await runSchedule(scheduler);
|
||||||
|
|
||||||
|
// 2 in-progress = 2 active worktrees, maxWorktrees: 2 — no room for KB-003
|
||||||
|
expect(store.moveTask).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ export function pathsOverlap(a: string[], b: string[]): boolean {
|
|||||||
export interface SchedulerOptions {
|
export interface SchedulerOptions {
|
||||||
/** Max concurrent in-progress tasks. Default: 2 */
|
/** Max concurrent in-progress tasks. Default: 2 */
|
||||||
maxConcurrent?: number;
|
maxConcurrent?: number;
|
||||||
/** Max total worktrees (in-progress + in-review with worktree). Default: 4 */
|
/** Max worktrees for active (in-progress) tasks. Default: 4 */
|
||||||
maxWorktrees?: number;
|
maxWorktrees?: number;
|
||||||
/** Milliseconds between scheduling polls. Default: 15000 */
|
/** Milliseconds between scheduling polls. Default: 15000 */
|
||||||
pollIntervalMs?: number;
|
pollIntervalMs?: number;
|
||||||
@@ -181,11 +181,11 @@ export class Scheduler {
|
|||||||
// Refresh the poll interval if the persisted setting has changed
|
// Refresh the poll interval if the persisted setting has changed
|
||||||
this.refreshPollInterval(settings.pollIntervalMs);
|
this.refreshPollInterval(settings.pollIntervalMs);
|
||||||
|
|
||||||
// Count all tasks with active worktrees (in-progress or in-review with worktree set)
|
// Count only in-progress tasks toward the worktree limit.
|
||||||
|
// In-review tasks with worktrees are idle (waiting to merge) and
|
||||||
|
// should not block new tasks from starting.
|
||||||
const activeWorktrees = tasks.filter(
|
const activeWorktrees = tasks.filter(
|
||||||
(t) =>
|
(t) => t.column === "in-progress",
|
||||||
t.column === "in-progress" ||
|
|
||||||
(t.column === "in-review" && t.worktree),
|
|
||||||
).length;
|
).length;
|
||||||
|
|
||||||
if (activeWorktrees >= maxWorktrees) {
|
if (activeWorktrees >= maxWorktrees) {
|
||||||
|
|||||||
Reference in New Issue
Block a user