feat(FN-4021): split monolithic store tests into focused domain suites
FN-4021 refactors the core store test suite by splitting the monolithic `store.test.ts` (6,865 lines) into nine focused domain suites — activity, comments, create, movement, ops, parsing, resilience, scheduling, snapshots, update, and upsert — and introduces shared test helpers to support the split Fusion-Task-Id: FN-4021 Fusion-Task-Lineage: 359f2dca-f7f7-4735-bcbd-92d569d2bcd5
This commit is contained in:
@@ -422,6 +422,85 @@ describe("Scheduler", () => {
|
||||
expect(store.updateTask).toHaveBeenCalledWith("FN-3811", { blockedBy: null, status: null });
|
||||
});
|
||||
|
||||
it("FN-3908: unblocks queued multi-dependency task when moved blocker archives and remaining deps are satisfied", async () => {
|
||||
const dependent = createMockTask({
|
||||
id: "FN-3170",
|
||||
column: "todo",
|
||||
status: "queued",
|
||||
blockedBy: undefined,
|
||||
dependencies: ["FN-3168", "FN-3169"],
|
||||
});
|
||||
const blockerA = createMockTask({ id: "FN-3168", column: "archived" });
|
||||
const blockerB = createMockTask({ id: "FN-3169", column: "done" });
|
||||
const allTasks = [dependent, blockerA, blockerB];
|
||||
const store = createMockStore({
|
||||
listTasks: vi.fn(async (options?: { column?: string }) =>
|
||||
options?.column === "todo" ? [dependent] : allTasks,
|
||||
),
|
||||
getSettings: vi.fn().mockResolvedValue({ maxConcurrent: 2, maxWorktrees: 4 }),
|
||||
});
|
||||
|
||||
new Scheduler(store);
|
||||
const movedHandler = (store.on as any).mock.calls.find((call: any) => call[0] === "task:moved")?.[1];
|
||||
await movedHandler({ task: blockerA, from: "in-review", to: "archived" });
|
||||
|
||||
expect(store.updateTask).toHaveBeenCalledWith("FN-3170", { blockedBy: null, status: null });
|
||||
expect(store.logEntry).toHaveBeenCalledWith(
|
||||
"FN-3170",
|
||||
"Auto-unblocked: blocker FN-3168 reached archived",
|
||||
);
|
||||
});
|
||||
|
||||
it("FN-3908: repoints blockedBy when moved blocker is done but another dependency remains unresolved", async () => {
|
||||
const dependent = createMockTask({
|
||||
id: "FN-3170",
|
||||
column: "todo",
|
||||
status: "queued",
|
||||
blockedBy: "FN-3168",
|
||||
dependencies: ["FN-3168", "FN-3169"],
|
||||
});
|
||||
const blockerA = createMockTask({ id: "FN-3168", column: "done" });
|
||||
const blockerB = createMockTask({ id: "FN-3169", column: "in-progress" });
|
||||
const allTasks = [dependent, blockerA, blockerB];
|
||||
const store = createMockStore({
|
||||
listTasks: vi.fn(async (options?: { column?: string }) =>
|
||||
options?.column === "todo" ? [dependent] : allTasks,
|
||||
),
|
||||
getSettings: vi.fn().mockResolvedValue({ maxConcurrent: 2, maxWorktrees: 4 }),
|
||||
});
|
||||
|
||||
new Scheduler(store);
|
||||
const movedHandler = (store.on as any).mock.calls.find((call: any) => call[0] === "task:moved")?.[1];
|
||||
await movedHandler({ task: blockerA, from: "in-progress", to: "done" });
|
||||
|
||||
expect(store.updateTask).toHaveBeenCalledWith("FN-3170", { status: "queued", blockedBy: "FN-3169" });
|
||||
expect(store.updateTask).not.toHaveBeenCalledWith("FN-3170", { blockedBy: null, status: null });
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ globalPause: true, enginePaused: false },
|
||||
{ globalPause: false, enginePaused: true },
|
||||
])("FN-3908: skips event-driven dependency reconciliation when pauses are active", async (settings) => {
|
||||
const dependent = createMockTask({
|
||||
id: "FN-3170",
|
||||
column: "todo",
|
||||
status: "queued",
|
||||
blockedBy: undefined,
|
||||
dependencies: ["FN-3168"],
|
||||
});
|
||||
const blocker = createMockTask({ id: "FN-3168", column: "archived" });
|
||||
const store = createMockStore({
|
||||
listTasks: vi.fn().mockResolvedValue([dependent, blocker]),
|
||||
getSettings: vi.fn().mockResolvedValue({ maxConcurrent: 2, maxWorktrees: 4, ...settings }),
|
||||
});
|
||||
|
||||
new Scheduler(store);
|
||||
const movedHandler = (store.on as any).mock.calls.find((call: any) => call[0] === "task:moved")?.[1];
|
||||
await movedHandler({ task: blocker, from: "in-review", to: "archived" });
|
||||
|
||||
expect(store.updateTask).not.toHaveBeenCalledWith("FN-3170", { blockedBy: null, status: null });
|
||||
});
|
||||
|
||||
it("FN-3924: does not repoint cleared dependency blocker to unrelated overlap task", async () => {
|
||||
vi.mocked(existsSync).mockReturnValue(true);
|
||||
vi.mocked(readFile).mockResolvedValue("# Task\nDo something");
|
||||
@@ -764,6 +843,31 @@ describe("Scheduler", () => {
|
||||
// With 4 in-progress and maxWorktrees=4, no new tasks should start
|
||||
expect(store.moveTask).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("FN-3908: logs queued concurrency reason once per unchanged state", async () => {
|
||||
vi.mocked(existsSync).mockReturnValue(true);
|
||||
vi.mocked(readFile).mockResolvedValue("# Task\nDo something");
|
||||
|
||||
const tasks = [
|
||||
createMockTask({ id: "FN-001", column: "todo", dependencies: [] }),
|
||||
createMockTask({ id: "FN-002", column: "todo", dependencies: [] }),
|
||||
];
|
||||
|
||||
const store = createMockStore({
|
||||
listTasks: vi.fn().mockResolvedValue(tasks),
|
||||
getSettings: vi.fn().mockResolvedValue({ maxConcurrent: 1, maxWorktrees: 4 }),
|
||||
});
|
||||
|
||||
const scheduler = new Scheduler(store);
|
||||
(scheduler as any).running = true;
|
||||
await scheduler.schedule();
|
||||
await scheduler.schedule();
|
||||
|
||||
const concurrencyReasonCalls = (store.logEntry as ReturnType<typeof vi.fn>).mock.calls.filter(
|
||||
(call: unknown[]) => call[0] === "FN-002" && String(call[1]).includes("queued — concurrency limit reached"),
|
||||
);
|
||||
expect(concurrencyReasonCalls).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("priority-aware todo dispatch", () => {
|
||||
|
||||
@@ -4334,6 +4334,48 @@ describe("clearStaleBlockedBy", () => {
|
||||
expect(store.logEntry).toHaveBeenCalledTimes(1);
|
||||
manager.stop();
|
||||
});
|
||||
|
||||
it("FN-3908: clears stale queued status when all dependencies are already satisfied", async () => {
|
||||
const store = createRunningStore();
|
||||
const queuedTask = createTask("FN-3170", {
|
||||
status: "queued",
|
||||
blockedBy: null,
|
||||
dependencies: ["FN-3168", "FN-3169"],
|
||||
});
|
||||
const depA = createTask("FN-3168", { column: "archived" });
|
||||
const depB = createTask("FN-3169", { column: "done" });
|
||||
(store.listTasks as ReturnType<typeof vi.fn>).mockResolvedValue([queuedTask, depA, depB]);
|
||||
|
||||
const manager = new SelfHealingManager(store, { rootDir: "/tmp/test-project" });
|
||||
const recovered = await manager.clearStaleBlockedBy();
|
||||
|
||||
expect(recovered).toBe(1);
|
||||
expect(store.updateTask).toHaveBeenCalledWith("FN-3170", { blockedBy: null, status: null });
|
||||
expect(store.logEntry).toHaveBeenCalledWith(
|
||||
"FN-3170",
|
||||
"Auto-recovered: cleared stale queued status — all dependencies satisfied",
|
||||
);
|
||||
manager.stop();
|
||||
});
|
||||
|
||||
it("FN-3908: refreshes blockedBy to first unresolved dependency when stale blocker changed", async () => {
|
||||
const store = createRunningStore();
|
||||
const queuedTask = createTask("FN-3170", {
|
||||
status: "queued",
|
||||
blockedBy: "FN-3168",
|
||||
dependencies: ["FN-3168", "FN-3169"],
|
||||
});
|
||||
const depA = createTask("FN-3168", { column: "archived" });
|
||||
const depB = createTask("FN-3169", { column: "in-progress" });
|
||||
(store.listTasks as ReturnType<typeof vi.fn>).mockResolvedValue([queuedTask, depA, depB]);
|
||||
|
||||
const manager = new SelfHealingManager(store, { rootDir: "/tmp/test-project" });
|
||||
await manager.clearStaleBlockedBy();
|
||||
|
||||
expect(store.updateTask).toHaveBeenCalledWith("FN-3170", { blockedBy: "FN-3169", status: "queued" });
|
||||
expect(store.logEntry).toHaveBeenCalledWith("FN-3170", expect.stringContaining("refreshed stale blockedBy"));
|
||||
manager.stop();
|
||||
});
|
||||
});
|
||||
|
||||
describe("stale triage processing eviction before recovery", () => {
|
||||
|
||||
@@ -176,6 +176,8 @@ export class Scheduler {
|
||||
private wasNodeBlocked = new Set<string>();
|
||||
/** Tracks tasks blocked by missing project-node mapping to deduplicate block log entries. */
|
||||
private wasNodeDispatchValidationBlocked = new Set<string>();
|
||||
/** Tracks dispatch-queued reason signatures to avoid per-tick log spam. */
|
||||
private wasDispatchQueuedReasonLogged = new Set<string>();
|
||||
|
||||
/**
|
||||
* Async listener guard convention:
|
||||
@@ -460,9 +462,29 @@ export class Scheduler {
|
||||
this.failedTaskIds.clear();
|
||||
this.wasNodeBlocked.clear();
|
||||
this.wasNodeDispatchValidationBlocked.clear();
|
||||
this.wasDispatchQueuedReasonLogged.clear();
|
||||
schedulerLog.log("Stopped");
|
||||
}
|
||||
|
||||
private clearDispatchQueuedReasonMemo(taskId: string): void {
|
||||
for (const key of this.wasDispatchQueuedReasonLogged) {
|
||||
if (key.startsWith(`${taskId}:`)) {
|
||||
this.wasDispatchQueuedReasonLogged.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async logDispatchQueuedReason(taskId: string, reason: string): Promise<void> {
|
||||
const key = `${taskId}:${reason}`;
|
||||
if (this.wasDispatchQueuedReasonLogged.has(key)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.clearDispatchQueuedReasonMemo(taskId);
|
||||
this.wasDispatchQueuedReasonLogged.add(key);
|
||||
await this.store.logEntry(taskId, reason);
|
||||
}
|
||||
|
||||
/**
|
||||
* If `newIntervalMs` differs from the currently active timer, restart
|
||||
* the `setInterval` so the new cadence takes effect immediately.
|
||||
@@ -740,6 +762,7 @@ export class Scheduler {
|
||||
);
|
||||
if (!recovered) {
|
||||
await this.store.updateTask(task.id, { status: "queued" });
|
||||
await this.logDispatchQueuedReason(task.id, "queued — checkout lease recovery blocked dispatch");
|
||||
continue;
|
||||
}
|
||||
}
|
||||
@@ -755,6 +778,7 @@ export class Scheduler {
|
||||
status: "queued",
|
||||
blockedBy: unmetDeps[0],
|
||||
});
|
||||
await this.logDispatchQueuedReason(task.id, `queued — unmet dependencies: ${unmetDeps.join(", ")}`);
|
||||
this.options.onBlocked?.(task, unmetDeps);
|
||||
continue;
|
||||
}
|
||||
@@ -818,6 +842,7 @@ export class Scheduler {
|
||||
if (task.status !== "queued" || task.blockedBy !== targetBlockedBy) {
|
||||
await this.store.updateTask(task.id, { status: "queued", blockedBy: targetBlockedBy });
|
||||
}
|
||||
await this.logDispatchQueuedReason(task.id, `queued — file scope overlap with ${overlappingTaskId}`);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
@@ -825,6 +850,7 @@ export class Scheduler {
|
||||
|
||||
// Dependencies met — check concurrency
|
||||
if (started >= available) {
|
||||
await this.logDispatchQueuedReason(task.id, `queued — concurrency limit reached (${available} available)`);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -927,6 +953,7 @@ export class Scheduler {
|
||||
});
|
||||
this.wasNodeBlocked.delete(task.id);
|
||||
this.wasNodeDispatchValidationBlocked.delete(task.id);
|
||||
this.clearDispatchQueuedReasonMemo(task.id);
|
||||
await this.store.logEntry(task.id, `Node routing resolved: ${effectiveNode.nodeId ?? "local"} (source: ${effectiveNode.source})`);
|
||||
this.options.onSchedule?.(task);
|
||||
started++;
|
||||
|
||||
@@ -1199,52 +1199,95 @@ export class SelfHealingManager {
|
||||
const blockedTasks = todoTasks.filter(
|
||||
(task) => typeof task.blockedBy === "string" && task.blockedBy.trim().length > 0,
|
||||
);
|
||||
const queuedDependencyTasks = todoTasks.filter(
|
||||
(task) => task.status === "queued" && task.dependencies.length > 0,
|
||||
);
|
||||
|
||||
if (blockedTasks.length === 0) return 0;
|
||||
if (blockedTasks.length === 0 && queuedDependencyTasks.length === 0) return 0;
|
||||
|
||||
const allTasks = await this.store.listTasks({ slim: true, includeArchived: true });
|
||||
const taskById = new Map(allTasks.map((task) => [task.id, task]));
|
||||
|
||||
let recovered = 0;
|
||||
for (const task of blockedTasks) {
|
||||
const blockerId = task.blockedBy;
|
||||
if (!blockerId) continue;
|
||||
const blockedTaskIds = new Set(blockedTasks.map((task) => task.id));
|
||||
const queuedDependencyTaskIds = new Set(queuedDependencyTasks.map((task) => task.id));
|
||||
const candidates = new Map<string, typeof todoTasks[number]>();
|
||||
for (const task of blockedTasks) candidates.set(task.id, task);
|
||||
for (const task of queuedDependencyTasks) candidates.set(task.id, task);
|
||||
|
||||
const blocker = taskById.get(blockerId);
|
||||
let reason: string | null = null;
|
||||
for (const task of candidates.values()) {
|
||||
const blockerId = task.blockedBy;
|
||||
|
||||
const unresolvedDeps = task.dependencies.filter((depId) => {
|
||||
const dep = taskById.get(depId);
|
||||
return dep && dep.column !== "done" && dep.column !== "in-review" && dep.column !== "archived";
|
||||
});
|
||||
|
||||
if (!blocker) {
|
||||
reason = `blocker ${blockerId} missing`;
|
||||
} else if (blocker.column === "done") {
|
||||
reason = `blocker ${blockerId} is done`;
|
||||
} else if (blocker.column === "archived") {
|
||||
reason = `blocker ${blockerId} is archived`;
|
||||
} else if (blocker.column === "in-review" && blocker.paused) {
|
||||
reason = `blocker ${blockerId} in-review + paused`;
|
||||
} else if (
|
||||
blocker.column === "in-review" &&
|
||||
blocker.status === "failed" &&
|
||||
(blocker.mergeRetries ?? 0) >= MAX_AUTO_MERGE_RETRIES
|
||||
) {
|
||||
reason = `blocker ${blockerId} in-review + failed (mergeRetries ${blocker.mergeRetries ?? 0}/${MAX_AUTO_MERGE_RETRIES})`;
|
||||
} else if (task.dependencies.length > 0 && !unresolvedDeps.includes(blockerId)) {
|
||||
reason = `blocker ${blockerId} not among unresolved dependencies`;
|
||||
if (blockedTaskIds.has(task.id)) {
|
||||
if (!blockerId) continue;
|
||||
|
||||
const blocker = taskById.get(blockerId);
|
||||
let reason: string | null = null;
|
||||
|
||||
if (!blocker) {
|
||||
reason = `blocker ${blockerId} missing`;
|
||||
} else if (blocker.column === "done") {
|
||||
reason = `blocker ${blockerId} is done`;
|
||||
} else if (blocker.column === "archived") {
|
||||
reason = `blocker ${blockerId} is archived`;
|
||||
} else if (blocker.column === "in-review" && blocker.paused) {
|
||||
reason = `blocker ${blockerId} in-review + paused`;
|
||||
} else if (
|
||||
blocker.column === "in-review" &&
|
||||
blocker.status === "failed" &&
|
||||
(blocker.mergeRetries ?? 0) >= MAX_AUTO_MERGE_RETRIES
|
||||
) {
|
||||
reason = `blocker ${blockerId} in-review + failed (mergeRetries ${blocker.mergeRetries ?? 0}/${MAX_AUTO_MERGE_RETRIES})`;
|
||||
} else if (task.dependencies.length > 0 && !unresolvedDeps.includes(blockerId)) {
|
||||
reason = `blocker ${blockerId} not among unresolved dependencies`;
|
||||
}
|
||||
|
||||
if (reason) {
|
||||
try {
|
||||
if (unresolvedDeps.length > 0) {
|
||||
const nextBlocker = unresolvedDeps[0]!;
|
||||
await this.store.updateTask(task.id, { blockedBy: nextBlocker, status: "queued" });
|
||||
await this.store.logEntry(task.id, `Auto-recovered: refreshed stale blockedBy — ${reason}; now blocked by ${nextBlocker}`);
|
||||
} else {
|
||||
await this.store.updateTask(task.id, { blockedBy: null, status: null });
|
||||
await this.store.logEntry(task.id, `Auto-recovered: cleared stale blockedBy — ${reason}`);
|
||||
}
|
||||
recovered++;
|
||||
} catch (err: unknown) {
|
||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
log.error(`Failed to clear stale blockedBy for ${task.id}: ${errorMessage}`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (!reason) continue;
|
||||
if (unresolvedDeps.length === 0) {
|
||||
if (queuedDependencyTaskIds.has(task.id)) {
|
||||
try {
|
||||
await this.store.updateTask(task.id, { blockedBy: null, status: null });
|
||||
await this.store.logEntry(task.id, "Auto-recovered: cleared stale queued status — all dependencies satisfied");
|
||||
recovered++;
|
||||
} catch (err: unknown) {
|
||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
log.error(`Failed to clear stale queued status for ${task.id}: ${errorMessage}`);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.store.updateTask(task.id, { blockedBy: null, status: null });
|
||||
await this.store.logEntry(task.id, `Auto-recovered: cleared stale blockedBy — ${reason}`);
|
||||
recovered++;
|
||||
} catch (err: unknown) {
|
||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
log.error(`Failed to clear stale blockedBy for ${task.id}: ${errorMessage}`);
|
||||
const nextBlocker = unresolvedDeps[0] ?? null;
|
||||
if (nextBlocker && task.blockedBy !== nextBlocker) {
|
||||
try {
|
||||
await this.store.updateTask(task.id, { blockedBy: nextBlocker, status: "queued" });
|
||||
} catch (err: unknown) {
|
||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
log.error(`Failed to refresh blockedBy for ${task.id}: ${errorMessage}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user