Hidden dot paths no longer create file-overlap blockers unless operators opt back into legacy behavior. - Add the ignoreHiddenOverlapPaths project setting, schema default, API sanitization, and Settings UI toggle. - Filter hidden dot segments from scheduler, gridlock, and self-healing overlap checks by default while preserving explicit ignore paths. - Cover default and opt-out behavior across core parity, dashboard settings routes, scheduler, gridlock, and self-healing tests. - Document the setting and add a patch changeset for the published CLI package. Files changed: .changeset/fn-6962-hidden-overlap-paths.md | 7 ++ docs/settings-reference.md | 1 + .../core/src/__tests__/settings-parity.test.ts | 7 ++ packages/core/src/settings-schema.ts | 1 + packages/core/src/types.ts | 7 ++ .../dashboard/app/components/SettingsModal.tsx | 4 +- .../components/__tests__/SettingsModal.test.tsx | 69 ++++++++++++++ .../settings/sections/SchedulingSection.tsx | 10 ++ .../src/__tests__/routes-settings.test.ts | 30 ++++++ packages/dashboard/src/routes.ts | 9 ++ .../src/routes/register-settings-memory-routes.ts | 6 +- .../engine/src/__tests__/gridlock-detector.test.ts | 32 +++++++ .../__tests__/scheduler-overlap-starvation.test.ts | 102 ++++++++++++++++++++- packages/engine/src/__tests__/self-healing.test.ts | 24 +++++ packages/engine/src/gridlock-detector.ts | 5 +- packages/engine/src/scheduler.ts | 40 +++++--- packages/engine/src/self-healing.ts | 2 +- packages/i18n/locales/en/app.json | 2 + packages/i18n/locales/es/app.json | 11 ++- packages/i18n/locales/fr/app.json | 11 ++- packages/i18n/locales/ko/app.json | 11 ++- packages/i18n/locales/zh-CN/app.json | 11 ++- packages/i18n/locales/zh-TW/app.json | 11 ++- packages/i18n/src/resources.d.ts | 2 + 24 files changed, 379 insertions(+), 36 deletions(-) Fusion-Task-Id: FN-6962 Fusion-Task-Lineage: c79120d3-fd9e-439d-b2cf-439f9e2e6952
167 lines
5.4 KiB
TypeScript
167 lines
5.4 KiB
TypeScript
import type { MissionStore, Task, TaskStore } from "@fusion/core";
|
|
import { createLogger } from "./logger.js";
|
|
import { filterPathsByIgnoreList, pathsOverlap } from "./scheduler.js";
|
|
|
|
const gridlockLog = createLogger("gridlock-detector");
|
|
|
|
export interface GridlockEvent {
|
|
blockedTaskCount: number;
|
|
reasons: Record<string, "dependency" | "overlap">;
|
|
blockedTaskIds: string[];
|
|
blockingTaskIds: string[];
|
|
}
|
|
|
|
export interface GridlockDetectorOptions {
|
|
pollIntervalMs?: number;
|
|
missionStore?: MissionStore;
|
|
onGridlock?: (event: GridlockEvent) => void;
|
|
onGridlockCleared?: () => void;
|
|
}
|
|
|
|
export class GridlockDetector {
|
|
private interval: ReturnType<typeof setInterval> | null = null;
|
|
private readonly pollIntervalMs: number;
|
|
private readonly missionStore?: MissionStore;
|
|
private readonly onGridlock?: (event: GridlockEvent) => void;
|
|
private readonly onGridlockCleared?: () => void;
|
|
private lastGridlockKey: string | null = null;
|
|
|
|
constructor(
|
|
private readonly store: TaskStore,
|
|
options: GridlockDetectorOptions = {},
|
|
) {
|
|
this.pollIntervalMs = options.pollIntervalMs ?? 30_000;
|
|
this.missionStore = options.missionStore;
|
|
this.onGridlock = options.onGridlock;
|
|
this.onGridlockCleared = options.onGridlockCleared;
|
|
}
|
|
|
|
start(): void {
|
|
if (this.interval) return;
|
|
this.interval = setInterval(() => {
|
|
this.detectGridlock().catch((error) => {
|
|
gridlockLog.error("Failed gridlock detection cycle:", error);
|
|
});
|
|
}, this.pollIntervalMs);
|
|
gridlockLog.log(`Started (poll interval: ${this.pollIntervalMs}ms)`);
|
|
}
|
|
|
|
stop(): void {
|
|
if (!this.interval) return;
|
|
clearInterval(this.interval);
|
|
this.interval = null;
|
|
gridlockLog.log("Stopped");
|
|
}
|
|
|
|
async detectGridlock(): Promise<GridlockEvent | null> {
|
|
const [tasks, settings] = await Promise.all([
|
|
this.store.listTasks({ slim: true, includeArchived: false }),
|
|
this.store.getSettings(),
|
|
]);
|
|
|
|
const now = Date.now();
|
|
const schedulable = tasks.filter((task) => {
|
|
if (task.column !== "todo" || task.paused) return false;
|
|
if (task.nextRecoveryAt && new Date(task.nextRecoveryAt).getTime() > now) return false;
|
|
if (this.isMissionBlocked(task)) return false;
|
|
return true;
|
|
});
|
|
|
|
if (schedulable.length === 0) {
|
|
this.clearGridlockState();
|
|
return null;
|
|
}
|
|
|
|
const active = tasks.filter((task) => task.column === "in-progress" || (task.column === "in-review" && Boolean(task.worktree)));
|
|
if (active.length === 0) {
|
|
this.clearGridlockState();
|
|
return null;
|
|
}
|
|
|
|
const overlapIgnorePaths = settings.overlapIgnorePaths ?? [];
|
|
const filterOptions = { ignoreHiddenOverlapPaths: settings.ignoreHiddenOverlapPaths };
|
|
const activeScopes = new Map<string, string[]>();
|
|
if (settings.groupOverlappingFiles) {
|
|
for (const task of active) {
|
|
const scope = filterPathsByIgnoreList(await this.store.parseFileScopeFromPrompt(task.id), overlapIgnorePaths, filterOptions);
|
|
if (scope.length > 0) {
|
|
activeScopes.set(task.id, scope);
|
|
}
|
|
}
|
|
}
|
|
|
|
const reasons: Record<string, "dependency" | "overlap"> = {};
|
|
const blockingTaskIds = new Set<string>();
|
|
|
|
for (const task of schedulable) {
|
|
const unmetDeps = task.dependencies.filter((depId) => {
|
|
const dep = tasks.find((candidate) => candidate.id === depId);
|
|
return dep && dep.column !== "done" && dep.column !== "in-review" && dep.column !== "archived";
|
|
});
|
|
|
|
if (unmetDeps.length > 0) {
|
|
reasons[task.id] = "dependency";
|
|
for (const depId of unmetDeps) blockingTaskIds.add(depId);
|
|
continue;
|
|
}
|
|
|
|
if (!settings.groupOverlappingFiles) continue;
|
|
|
|
const taskScope = filterPathsByIgnoreList(await this.store.parseFileScopeFromPrompt(task.id), overlapIgnorePaths, filterOptions);
|
|
if (taskScope.length === 0) continue;
|
|
|
|
for (const [activeId, activeScope] of activeScopes) {
|
|
if (pathsOverlap(taskScope, activeScope)) {
|
|
reasons[task.id] = "overlap";
|
|
blockingTaskIds.add(activeId);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
const blockedTaskIds = Object.keys(reasons).sort();
|
|
if (blockedTaskIds.length !== schedulable.length) {
|
|
this.clearGridlockState();
|
|
return null;
|
|
}
|
|
|
|
const gridlockKey = blockedTaskIds.join(",");
|
|
const event: GridlockEvent = {
|
|
blockedTaskCount: blockedTaskIds.length,
|
|
reasons,
|
|
blockedTaskIds,
|
|
blockingTaskIds: Array.from(blockingTaskIds).sort(),
|
|
};
|
|
|
|
if (this.lastGridlockKey !== gridlockKey) {
|
|
this.lastGridlockKey = gridlockKey;
|
|
gridlockLog.warn(`Gridlock detected: blocked=${event.blockedTaskIds.join(",")}; blocking=${event.blockingTaskIds.join(",")}`);
|
|
this.onGridlock?.(event);
|
|
}
|
|
|
|
return event;
|
|
}
|
|
|
|
private clearGridlockState(): void {
|
|
if (this.lastGridlockKey !== null) {
|
|
this.lastGridlockKey = null;
|
|
this.onGridlockCleared?.();
|
|
}
|
|
}
|
|
|
|
private isMissionBlocked(task: Task): boolean {
|
|
if (!this.missionStore || !task.sliceId) return false;
|
|
try {
|
|
const slice = this.missionStore.getSlice(task.sliceId);
|
|
if (!slice) return false;
|
|
const milestone = this.missionStore.getMilestone(slice.milestoneId);
|
|
if (!milestone) return false;
|
|
const mission = this.missionStore.getMission(milestone.missionId);
|
|
return mission?.status === "blocked";
|
|
} catch (error) {
|
|
gridlockLog.warn(`Mission lookup failed for ${task.id}:`, error);
|
|
return false;
|
|
}
|
|
}
|
|
}
|