feat(FN-3507): block dispatch when project lacks node mapping

Adds validation to block task dispatch when no project-node mapping exists (FN-3507), including a read helper in CentralCore and a new `node-dispatch-validation` module integrated into the scheduler and in-process runtime, with test coverage across routing and validation scenarios.

Fusion-Task-Id: FN-3507
This commit is contained in:
Fusion
2026-05-07 21:23:26 -07:00
committed by gsxdsm
parent 695cb0bca5
commit 11bf7084e1
9 changed files with 261 additions and 1 deletions

View File

@@ -19,6 +19,7 @@ import { reconcileMissionFeatureState } from "./mission-feature-sync.js";
import { evaluateSpecStaleness, getPromptPath } from "./spec-staleness.js";
import { resolveEffectiveNode } from "./effective-node.js";
import { applyUnavailableNodePolicy } from "./node-routing-policy.js";
import type { NodeDispatchValidationResult } from "./node-dispatch-validation.js";
/**
* Check whether two sets of file scope paths overlap.
@@ -135,6 +136,8 @@ export interface SchedulerOptions {
* Reserved for FN-2722-C (unavailable node policy enforcement).
* Accepted here so the option can be wired at construction time. */
nodeHealthMonitor?: import("./node-health-monitor.js").NodeHealthMonitor;
/** Optional dispatch validator used to block dispatch on configuration issues before health policy checks. */
validateNodeDispatch?: (nodeId: string) => Promise<NodeDispatchValidationResult>;
}
/**
@@ -168,6 +171,8 @@ export class Scheduler {
private failedTaskIds = new Set<string>();
/** Tracks tasks blocked by unavailable-node policy to deduplicate block log entries. */
private wasNodeBlocked = new Set<string>();
/** Tracks tasks blocked by missing project-node mapping to deduplicate block log entries. */
private wasNodeDispatchValidationBlocked = new Set<string>();
/**
* Async listener guard convention:
@@ -401,6 +406,7 @@ export class Scheduler {
}
this.failedTaskIds.clear();
this.wasNodeBlocked.clear();
this.wasNodeDispatchValidationBlocked.clear();
schedulerLog.log("Stopped");
}
@@ -769,6 +775,21 @@ export class Scheduler {
let effectiveNode = resolveEffectiveNode(freshTask, settings);
schedulerLog.log(`Task ${task.id} routed to node=${effectiveNode.nodeId ?? "local"} (source=${effectiveNode.source})`);
// Enforce dispatch configuration validation before node-health fallback logic.
if (effectiveNode.nodeId !== undefined && this.options.validateNodeDispatch) {
const validation = await this.options.validateNodeDispatch(effectiveNode.nodeId);
if (!validation.allowed) {
if (!this.wasNodeDispatchValidationBlocked.has(task.id)) {
this.wasNodeDispatchValidationBlocked.add(task.id);
schedulerLog.log(`Task ${task.id} dispatch blocked — ${validation.reason}`);
await this.store.logEntry(task.id, validation.reason);
}
continue;
}
this.wasNodeDispatchValidationBlocked.delete(task.id);
}
// Enforce unavailable-node policy
if (effectiveNode.nodeId !== undefined && this.options.nodeHealthMonitor) {
const nodeHealth = this.options.nodeHealthMonitor.getNodeHealth(effectiveNode.nodeId);
@@ -817,6 +838,7 @@ export class Scheduler {
this.planWorktreePath(task, settings.worktreeNaming, reservedNames),
});
this.wasNodeBlocked.delete(task.id);
this.wasNodeDispatchValidationBlocked.delete(task.id);
await this.store.logEntry(task.id, `Node routing resolved: ${effectiveNode.nodeId ?? "local"} (source: ${effectiveNode.source})`);
this.options.onSchedule?.(task);
started++;