feat(FN-5430): gate task update invalidation in scheduler
Scheduler now gates task update invalidation, preventing spurious invalidations when engine lifecycle changes (soft-delete, lease recovery) touch task metadata, with coverage via new scheduler invalidation tests and a small dashboard server test adjustment. Fusion-Task-Id: FN-5430
This commit is contained in:
committed by
gsxdsm
parent
5c15031416
commit
59bebf0806
@@ -98,6 +98,13 @@ function createMockStore(overrides: Partial<TaskStore> = {}): TaskStore {
|
|||||||
isRunning: false,
|
isRunning: false,
|
||||||
lastCheckedAt: null,
|
lastCheckedAt: null,
|
||||||
}),
|
}),
|
||||||
|
refreshDatabaseHealth: vi.fn().mockReturnValue({
|
||||||
|
healthy: true,
|
||||||
|
corruptionDetected: false,
|
||||||
|
corruptionErrors: [],
|
||||||
|
isRunning: false,
|
||||||
|
lastCheckedAt: null,
|
||||||
|
}),
|
||||||
getTaskIdIntegrityReport: vi.fn().mockReturnValue({
|
getTaskIdIntegrityReport: vi.fn().mockReturnValue({
|
||||||
status: "ok",
|
status: "ok",
|
||||||
checkedAt: "2026-05-12T00:00:00.000Z",
|
checkedAt: "2026-05-12T00:00:00.000Z",
|
||||||
@@ -506,7 +513,7 @@ describe("createServer health and headless mode", () => {
|
|||||||
|
|
||||||
it("reports degraded status from /api/health/refresh when corruption is detected", async () => {
|
it("reports degraded status from /api/health/refresh when corruption is detected", async () => {
|
||||||
const store = createMockStore({
|
const store = createMockStore({
|
||||||
getDatabaseHealth: vi.fn().mockReturnValue({
|
refreshDatabaseHealth: vi.fn().mockReturnValue({
|
||||||
healthy: false,
|
healthy: false,
|
||||||
corruptionDetected: true,
|
corruptionDetected: true,
|
||||||
corruptionErrors: ["bad row"],
|
corruptionErrors: ["bad row"],
|
||||||
|
|||||||
@@ -27,14 +27,30 @@ function createStore() {
|
|||||||
return { store, emit };
|
return { store, emit };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createTask(overrides: Record<string, unknown> = {}) {
|
||||||
|
return {
|
||||||
|
id: "FN-1",
|
||||||
|
column: "todo",
|
||||||
|
paused: false,
|
||||||
|
assignedAgentId: null,
|
||||||
|
checkedOutBy: null,
|
||||||
|
deletedAt: null,
|
||||||
|
dependencies: [],
|
||||||
|
columnMovedAt: "2026-01-01T00:00:00.000Z",
|
||||||
|
createdAt: "2026-01-01T00:00:00.000Z",
|
||||||
|
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
describe("Scheduler auto-claim snapshot invalidation", () => {
|
describe("Scheduler auto-claim snapshot invalidation", () => {
|
||||||
it("invalidates on task:created, task:updated, and task:deleted", () => {
|
it("invalidates on task:created, first-seen task:updated, and task:deleted", () => {
|
||||||
const invalidate = vi.fn();
|
const invalidate = vi.fn();
|
||||||
const { store, emit } = createStore();
|
const { store, emit } = createStore();
|
||||||
new Scheduler(store, { snapshotManager: { invalidate } as any });
|
new Scheduler(store, { snapshotManager: { invalidate } as any });
|
||||||
|
|
||||||
emit("task:created", { task: { id: "FN-1" } });
|
emit("task:created", createTask({ id: "FN-1" }));
|
||||||
emit("task:updated", { id: "FN-1" });
|
emit("task:updated", createTask({ id: "FN-99" }));
|
||||||
emit("task:deleted", { id: "FN-1" });
|
emit("task:deleted", { id: "FN-1" });
|
||||||
|
|
||||||
expect(invalidate).toHaveBeenCalledWith("task:created");
|
expect(invalidate).toHaveBeenCalledWith("task:created");
|
||||||
@@ -42,6 +58,86 @@ describe("Scheduler auto-claim snapshot invalidation", () => {
|
|||||||
expect(invalidate).toHaveBeenCalledWith("task:deleted");
|
expect(invalidate).toHaveBeenCalledWith("task:deleted");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("does not invalidate for candidacy-neutral task:updated events", () => {
|
||||||
|
const invalidate = vi.fn();
|
||||||
|
const { store, emit } = createStore();
|
||||||
|
new Scheduler(store, { snapshotManager: { invalidate } as any });
|
||||||
|
|
||||||
|
emit("task:created", createTask());
|
||||||
|
for (let i = 1; i <= 5; i += 1) {
|
||||||
|
emit("task:updated", createTask({ updatedAt: `2026-01-01T00:00:0${i}.000Z`, title: `v${i}` }));
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(invalidate).toHaveBeenCalledTimes(1);
|
||||||
|
expect(invalidate).toHaveBeenNthCalledWith(1, "task:created");
|
||||||
|
expect(invalidate).not.toHaveBeenCalledWith("task:updated");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("invalidates once per candidacy-changing task:updated mutation", () => {
|
||||||
|
const invalidate = vi.fn();
|
||||||
|
const { store, emit } = createStore();
|
||||||
|
new Scheduler(store, { snapshotManager: { invalidate } as any });
|
||||||
|
|
||||||
|
emit("task:created", createTask());
|
||||||
|
|
||||||
|
emit("task:updated", createTask({ paused: true }));
|
||||||
|
expect(invalidate).toHaveBeenCalledTimes(2);
|
||||||
|
expect(invalidate).toHaveBeenLastCalledWith("task:updated");
|
||||||
|
|
||||||
|
emit("task:updated", createTask({ paused: true, updatedAt: "2026-01-01T00:00:02.000Z" }));
|
||||||
|
expect(invalidate).toHaveBeenCalledTimes(2);
|
||||||
|
|
||||||
|
emit("task:updated", createTask({ paused: false, updatedAt: "2026-01-01T00:00:03.000Z" }));
|
||||||
|
expect(invalidate).toHaveBeenCalledTimes(3);
|
||||||
|
expect(invalidate).toHaveBeenLastCalledWith("task:updated");
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["column", { column: "in-progress" }],
|
||||||
|
["paused", { paused: true }],
|
||||||
|
["assignedAgentId", { assignedAgentId: "agent-1" }],
|
||||||
|
["checkedOutBy", { checkedOutBy: "agent-2" }],
|
||||||
|
["deletedAt", { deletedAt: "2026-01-02T00:00:00.000Z" }],
|
||||||
|
["dependencies", { dependencies: ["FN-2"] }],
|
||||||
|
["columnMovedAt", { columnMovedAt: "2026-01-03T00:00:00.000Z" }],
|
||||||
|
])("invalidates when %s changes", (_field, mutation) => {
|
||||||
|
const invalidate = vi.fn();
|
||||||
|
const { store, emit } = createStore();
|
||||||
|
new Scheduler(store, { snapshotManager: { invalidate } as any });
|
||||||
|
|
||||||
|
emit("task:created", createTask());
|
||||||
|
emit("task:updated", createTask(mutation));
|
||||||
|
|
||||||
|
expect(invalidate).toHaveBeenCalledTimes(2);
|
||||||
|
expect(invalidate).toHaveBeenNthCalledWith(1, "task:created");
|
||||||
|
expect(invalidate).toHaveBeenNthCalledWith(2, "task:updated");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("invalidates on first-sighting task:updated with no stored fingerprint", () => {
|
||||||
|
const invalidate = vi.fn();
|
||||||
|
const { store, emit } = createStore();
|
||||||
|
new Scheduler(store, { snapshotManager: { invalidate } as any });
|
||||||
|
|
||||||
|
emit("task:updated", createTask({ id: "FN-99" }));
|
||||||
|
|
||||||
|
expect(invalidate).toHaveBeenCalledTimes(1);
|
||||||
|
expect(invalidate).toHaveBeenCalledWith("task:updated");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears task fingerprint on task:deleted", () => {
|
||||||
|
const invalidate = vi.fn();
|
||||||
|
const { store, emit } = createStore();
|
||||||
|
new Scheduler(store, { snapshotManager: { invalidate } as any });
|
||||||
|
|
||||||
|
emit("task:created", createTask({ id: "FN-1" }));
|
||||||
|
emit("task:deleted", { id: "FN-1" });
|
||||||
|
emit("task:updated", createTask({ id: "FN-1" }));
|
||||||
|
|
||||||
|
expect(invalidate).toHaveBeenNthCalledWith(1, "task:created");
|
||||||
|
expect(invalidate).toHaveBeenNthCalledWith(2, "task:deleted");
|
||||||
|
expect(invalidate).toHaveBeenNthCalledWith(3, "task:updated");
|
||||||
|
});
|
||||||
|
|
||||||
it("clears scheduler bookkeeping for deleted tasks", () => {
|
it("clears scheduler bookkeeping for deleted tasks", () => {
|
||||||
const { store, emit } = createStore();
|
const { store, emit } = createStore();
|
||||||
const scheduler = new Scheduler(store, {});
|
const scheduler = new Scheduler(store, {});
|
||||||
@@ -70,9 +166,9 @@ describe("Scheduler auto-claim snapshot invalidation", () => {
|
|||||||
const { store, emit } = createStore();
|
const { store, emit } = createStore();
|
||||||
new Scheduler(store, { snapshotManager: { invalidate } as any });
|
new Scheduler(store, { snapshotManager: { invalidate } as any });
|
||||||
|
|
||||||
emit("task:moved", { task: { id: "FN-1" }, from: "todo", to: "in-progress" });
|
emit("task:moved", { task: createTask({ id: "FN-1" }), from: "todo", to: "in-progress" });
|
||||||
emit("task:moved", { task: { id: "FN-2" }, from: "in-progress", to: "todo" });
|
emit("task:moved", { task: createTask({ id: "FN-2" }), from: "in-progress", to: "todo" });
|
||||||
emit("task:moved", { task: { id: "FN-3" }, from: "in-review", to: "done" });
|
emit("task:moved", { task: createTask({ id: "FN-3", column: "done" }), from: "in-review", to: "done" });
|
||||||
|
|
||||||
expect(invalidate).toHaveBeenCalledWith("task:moved:todo->in-progress");
|
expect(invalidate).toHaveBeenCalledWith("task:moved:todo->in-progress");
|
||||||
expect(invalidate).toHaveBeenCalledWith("task:moved:in-progress->todo");
|
expect(invalidate).toHaveBeenCalledWith("task:moved:in-progress->todo");
|
||||||
|
|||||||
@@ -87,6 +87,20 @@ function isIgnoredOverlapPath(path: string, ignorePath: string): boolean {
|
|||||||
return normalizedPath === normalizedIgnore || normalizedPath.startsWith(`${normalizedIgnore}/`);
|
return normalizedPath === normalizedIgnore || normalizedPath.startsWith(`${normalizedIgnore}/`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function computeAutoClaimFingerprint(task: Task): string {
|
||||||
|
const dependencies = [...(task.dependencies ?? [])].sort().join(",");
|
||||||
|
const sortAt = task.columnMovedAt ?? task.createdAt;
|
||||||
|
return [
|
||||||
|
task.column,
|
||||||
|
task.paused === true ? "1" : "0",
|
||||||
|
task.assignedAgentId ?? "",
|
||||||
|
task.checkedOutBy ?? "",
|
||||||
|
task.deletedAt ?? "",
|
||||||
|
dependencies,
|
||||||
|
sortAt,
|
||||||
|
].join("|");
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Remove scope entries that match configured overlap-ignore paths.
|
* Remove scope entries that match configured overlap-ignore paths.
|
||||||
* Used by scheduler overlap gating so shared safe paths (docs/generated/etc.)
|
* Used by scheduler overlap gating so shared safe paths (docs/generated/etc.)
|
||||||
@@ -320,6 +334,8 @@ export class Scheduler {
|
|||||||
private wasPermanentAgentUnavailable = new Set<string>();
|
private wasPermanentAgentUnavailable = new Set<string>();
|
||||||
/** Tracks dispatch-queued reason signatures to avoid per-tick log spam. */
|
/** Tracks dispatch-queued reason signatures to avoid per-tick log spam. */
|
||||||
private wasDispatchQueuedReasonLogged = new Set<string>();
|
private wasDispatchQueuedReasonLogged = new Set<string>();
|
||||||
|
/** Tracks per-task candidacy fingerprints for task:updated auto-claim invalidation gating. */
|
||||||
|
private lastAutoClaimFingerprint = new Map<string, string>();
|
||||||
private readonly staleTaskReporter: StaleTaskReporter;
|
private readonly staleTaskReporter: StaleTaskReporter;
|
||||||
private readonly backlogPressureReporter: BacklogPressureReporter;
|
private readonly backlogPressureReporter: BacklogPressureReporter;
|
||||||
private lastStaleTaskReportAt = 0;
|
private lastStaleTaskReportAt = 0;
|
||||||
@@ -348,7 +364,8 @@ export class Scheduler {
|
|||||||
* pass immediately instead of waiting for the next poll interval.
|
* pass immediately instead of waiting for the next poll interval.
|
||||||
* This reduces latency from up to 15 seconds to near-instant.
|
* This reduces latency from up to 15 seconds to near-instant.
|
||||||
*/
|
*/
|
||||||
this.store.on("task:created", () => {
|
this.store.on("task:created", (task) => {
|
||||||
|
this.lastAutoClaimFingerprint.set(task.id, computeAutoClaimFingerprint(task));
|
||||||
this.options.snapshotManager?.invalidate("task:created");
|
this.options.snapshotManager?.invalidate("task:created");
|
||||||
schedulerLog.log("Task created — triggering scheduling");
|
schedulerLog.log("Task created — triggering scheduling");
|
||||||
this.schedule();
|
this.schedule();
|
||||||
@@ -389,6 +406,7 @@ export class Scheduler {
|
|||||||
* update feature status and potentially activate next pending slice.
|
* update feature status and potentially activate next pending slice.
|
||||||
*/
|
*/
|
||||||
this.store.on("task:moved", async ({ task, from, to }) => {
|
this.store.on("task:moved", async ({ task, from, to }) => {
|
||||||
|
this.lastAutoClaimFingerprint.set(task.id, computeAutoClaimFingerprint(task));
|
||||||
if (from === "todo" || to === "todo") {
|
if (from === "todo" || to === "todo") {
|
||||||
this.options.snapshotManager?.invalidate(`task:moved:${from}->${to}`);
|
this.options.snapshotManager?.invalidate(`task:moved:${from}->${to}`);
|
||||||
}
|
}
|
||||||
@@ -502,7 +520,12 @@ export class Scheduler {
|
|||||||
* Also detects task-level unpause transitions and triggers immediate scheduling.
|
* Also detects task-level unpause transitions and triggers immediate scheduling.
|
||||||
*/
|
*/
|
||||||
this.store.on("task:updated", (task) => {
|
this.store.on("task:updated", (task) => {
|
||||||
this.options.snapshotManager?.invalidate("task:updated");
|
const nextFingerprint = computeAutoClaimFingerprint(task);
|
||||||
|
const previousFingerprint = this.lastAutoClaimFingerprint.get(task.id);
|
||||||
|
if (!previousFingerprint || previousFingerprint !== nextFingerprint) {
|
||||||
|
this.lastAutoClaimFingerprint.set(task.id, nextFingerprint);
|
||||||
|
this.options.snapshotManager?.invalidate("task:updated");
|
||||||
|
}
|
||||||
// Track mission failure signals before moveTask clears failure metadata.
|
// Track mission failure signals before moveTask clears failure metadata.
|
||||||
if (task.sliceId && task.column === "in-progress" && task.status === "failed") {
|
if (task.sliceId && task.column === "in-progress" && task.status === "failed") {
|
||||||
this.failedTaskIds.add(task.id);
|
this.failedTaskIds.add(task.id);
|
||||||
@@ -543,6 +566,7 @@ export class Scheduler {
|
|||||||
});
|
});
|
||||||
|
|
||||||
this.store.on("task:deleted", (task) => {
|
this.store.on("task:deleted", (task) => {
|
||||||
|
this.lastAutoClaimFingerprint.delete(task.id);
|
||||||
this.options.snapshotManager?.invalidate("task:deleted");
|
this.options.snapshotManager?.invalidate("task:deleted");
|
||||||
this.pausedTaskIds.delete(task.id);
|
this.pausedTaskIds.delete(task.id);
|
||||||
this.failedTaskIds.delete(task.id);
|
this.failedTaskIds.delete(task.id);
|
||||||
|
|||||||
Reference in New Issue
Block a user