FN-8857: invalidate stale task dispatch routes

Clear persisted effective routes whenever an eligible task node override changes.

- Add route invalidation logic that preserves checkout-bound and explicit replacement routes.
- Cover in-memory and PostgreSQL task-update scenarios, including partial snapshot replacements.
- Document the dispatch snapshot invalidation behavior and add a patch changeset.

Files changed:
 .changeset/fn-8857-stale-effective-route.md        |   7 +
 docs/architecture.md                               |   6 +-
 docs/task-management.md                            |   4 +-
 .../__tests__/effective-route-invalidation.test.ts | 101 ++++++++++++++
 .../postgres/task-node-override.pg.test.ts         |  63 +++++++++
 ...ask-update-effective-route-invalidation.test.ts | 148 +++++++++++++++++++++
 packages/core/src/index.ts                         |   5 +
 .../core/src/mesh/effective-route-invalidation.ts  |  58 ++++++++
 packages/core/src/task-store/task-update.ts        |  40 ++++++
 9 files changed, 428 insertions(+), 4 deletions(-)

Fusion-Task-Id: FN-8857

Fusion-Task-Lineage: ab6c1a40-70b4-4501-ab34-771de288da10

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-08-08 23:05:29 -07:00
parent 53416f6535
commit 65bf3097ff
9 changed files with 428 additions and 4 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Clear stale task dispatch routes when a node override changes.
category: fix
dev: shouldInvalidateEffectiveRoute at the updateTaskUnlockedImpl seam uses load-time checkout capture and clears only unsupplied fields of a replacement route.

View File

@@ -1886,11 +1886,11 @@ Behavior summary:
### Active-task node-override guard ### Active-task node-override guard
`packages/core/src/node-override-guard.ts` enforces immutable routing overrides for active tasks: `packages/core/src/mesh/node-override-guard.ts` enforces immutable routing overrides for active tasks:
- `validateNodeOverrideChange()` blocks node override updates while task column is `in-progress` - `validateNodeOverrideChange()` blocks node override updates while task column is `in-progress`
- returns reason `task-in-progress` - returns reason `task-in-progress`
`TaskStore.updateTask()` applies this guard before persisting `nodeId` changes. `TaskStore.updateTask()` applies this guard before persisting `nodeId` changes. For a task that was not checked out when read, changing `nodeId` also invalidates the dispatch-time `effectiveNodeId` / `effectiveNodeSource` snapshot unless that update supplies a replacement. Replacement is honored per field: a partial payload keeps its supplied field and clears its unsupplied companion, so the two-column snapshot cannot be half-stale. Checkout eligibility is captured when the task is read; a combined agent reassignment that clears checkout state later in the update does not make an already checked-out task eligible for invalidation.
### Task commit-association API (`GET /api/tasks/:id/commit-associations`) ### Task commit-association API (`GET /api/tasks/:id/commit-associations`)
@@ -1961,7 +1961,7 @@ Task create/update now preserves both branch fields end-to-end:
### Routing activity visibility ### Routing activity visibility
Routing decisions are visible in task activity/log entries and in task metadata (`effectiveNodeId`, `effectiveNodeSource`), and surfaced in dashboard routing UI + `fn task show` output. Routing decisions are visible in task activity/log entries and in task metadata (`effectiveNodeId`, `effectiveNodeSource`), and surfaced in dashboard routing UI + `fn task show` output. A non-checked-out node override change records an effective-route invalidation entry with the prior node and cleared snapshot fields; scheduler dispatch records the refreshed pair.
See also: See also:
- [Settings Reference → Node Routing settings](./settings-reference.md#node-routing-settings-project-scope) - [Settings Reference → Node Routing settings](./settings-reference.md#node-routing-settings-project-scope)

View File

@@ -829,10 +829,12 @@ Tasks execute on an effective node selected by routing precedence:
2. **Project default node** (`defaultNodeId` in project settings) 2. **Project default node** (`defaultNodeId` in project settings)
3. **Local execution** (no node configured) 3. **Local execution** (no node configured)
At dispatch time, scheduler routing is persisted on the task as: At dispatch time, scheduler routing is persisted on the task as the snapshot pair:
- `effectiveNodeId` - `effectiveNodeId`
- `effectiveNodeSource` (`task-override`, `project-default`, or `local`) - `effectiveNodeSource` (`task-override`, `project-default`, or `local`)
When a non-checked-out task's `nodeId` override changes, this persisted pair is invalidated unless the update explicitly replaces its fields. The next dispatch resolves routing again and writes a fresh snapshot pair.
### Create-time routing semantics ### Create-time routing semantics
Cluster-aware task creation separates two routing decisions: Cluster-aware task creation separates two routing decisions:

View File

@@ -0,0 +1,101 @@
import {describe, expect, it} from "vitest";
import {shouldInvalidateEffectiveRoute} from "../mesh/effective-route-invalidation.js";
const route = {
currentNodeId: "node-old",
nextNodeId: "node-new",
currentEffectiveNodeId: "node-old",
currentEffectiveNodeSource: "task-override",
checkedOutOnRead: false,
checkoutBeingSet: false,
explicitEffectiveNodeIdSupplied: false,
explicitEffectiveNodeSourceSupplied: false,
};
const noInvalidation = {invalidateNodeId: false, invalidateNodeSource: false};
const bothInvalidated = {
invalidateNodeId: true,
invalidateNodeSource: true,
reason: "node-override-changed",
};
describe("shouldInvalidateEffectiveRoute", () => {
it("invalidates both fields after a non-checked-out override change", () => {
expect(shouldInvalidateEffectiveRoute(route)).toEqual(bothInvalidated);
});
it("does nothing for unchanged or omitted overrides, including a partial route payload", () => {
expect(shouldInvalidateEffectiveRoute({...route, nextNodeId: "node-old"})).toEqual(noInvalidation);
expect(shouldInvalidateEffectiveRoute({...route, nextNodeId: undefined})).toEqual(noInvalidation);
expect(shouldInvalidateEffectiveRoute({
...route,
nextNodeId: undefined,
explicitEffectiveNodeSourceSupplied: true,
})).toEqual(noInvalidation);
});
it("normalizes clear, empty, and whitespace overrides like resolveEffectiveNode", () => {
expect(shouldInvalidateEffectiveRoute({...route, nextNodeId: null})).toEqual(bothInvalidated);
expect(shouldInvalidateEffectiveRoute({...route, nextNodeId: ""})).toEqual(bothInvalidated);
expect(shouldInvalidateEffectiveRoute({...route, nextNodeId: " "})).toEqual(bothInvalidated);
expect(shouldInvalidateEffectiveRoute({
...route,
currentNodeId: undefined,
nextNodeId: " ",
})).toEqual(noInvalidation);
});
it("does not invalidate when no snapshot exists or checkout ownership applies", () => {
expect(shouldInvalidateEffectiveRoute({
...route,
currentEffectiveNodeId: undefined,
currentEffectiveNodeSource: undefined,
})).toEqual(noInvalidation);
expect(shouldInvalidateEffectiveRoute({...route, checkedOutOnRead: true})).toEqual(noInvalidation);
expect(shouldInvalidateEffectiveRoute({...route, checkoutBeingSet: true})).toEqual(noInvalidation);
});
it("invalidates project-default and local snapshots identically", () => {
for (const currentEffectiveNodeSource of ["project-default", "local"]) {
expect(shouldInvalidateEffectiveRoute({...route, currentEffectiveNodeSource})).toEqual(bothInvalidated);
}
});
it("honors complete explicit replacements", () => {
expect(shouldInvalidateEffectiveRoute({
...route,
explicitEffectiveNodeIdSupplied: true,
explicitEffectiveNodeSourceSupplied: true,
})).toEqual(noInvalidation);
});
it("invalidates only the unsupplied half of partial replacements", () => {
expect(shouldInvalidateEffectiveRoute({
...route,
explicitEffectiveNodeIdSupplied: true,
})).toEqual({
invalidateNodeId: false,
invalidateNodeSource: true,
reason: "node-override-changed",
});
expect(shouldInvalidateEffectiveRoute({
...route,
explicitEffectiveNodeSourceSupplied: true,
})).toEqual({
invalidateNodeId: true,
invalidateNodeSource: false,
reason: "node-override-changed",
});
});
it("treats an explicit null field clear as supplied", () => {
expect(shouldInvalidateEffectiveRoute({
...route,
explicitEffectiveNodeIdSupplied: true,
})).toEqual({
invalidateNodeId: false,
invalidateNodeSource: true,
reason: "node-override-changed",
});
});
});

View File

@@ -120,6 +120,69 @@ pgTest("task node override persistence (PostgreSQL)", () => {
expect((await store.getTask(third.id)).nodeId).toBeUndefined(); expect((await store.getTask(third.id)).nodeId).toBeUndefined();
}); });
/*
FNXC:NodeRouting 2026-08-09-05:08:
Durable #3365 coverage proves the JSON/SQL round trip cannot retain a dispatch snapshot after a
non-checked-out override change. The negative cases preserve lease-bound and explicit replacement
routes; source-only replacement clears the old id rather than storing a half-stale snapshot.
*/
describe("effective route invalidation", () => {
async function createWithSnapshot() {
const store = h.store();
const task = await store.createTask({description: "effective route repro", nodeId: "node-old"});
await store.updateTask(task.id, {
effectiveNodeId: "node-old",
effectiveNodeSource: "task-override",
});
return {store, task};
}
it("clears the persisted snapshot after changing a non-checked-out override", async () => {
const {store, task} = await createWithSnapshot();
await store.updateTask(task.id, {nodeId: "node-new"});
const fetched = await store.getTask(task.id);
expect(fetched.nodeId).toBe("node-new");
expect(fetched.effectiveNodeId).toBeUndefined();
expect(fetched.effectiveNodeSource).toBeUndefined();
});
it("preserves a snapshot for a task checked out when read", async () => {
const {store, task} = await createWithSnapshot();
await store.updateTask(task.id, {checkedOutBy: "agent-x"});
await store.updateTask(task.id, {nodeId: "node-new"});
const fetched = await store.getTask(task.id);
expect(fetched.effectiveNodeId).toBe("node-old");
expect(fetched.effectiveNodeSource).toBe("task-override");
});
it("preserves a route during combined reassignment after checkout is cleared mid-pass", async () => {
const {store, task} = await createWithSnapshot();
await store.updateTask(task.id, {assignedAgentId: "agent-x", checkedOutBy: "agent-x"});
await store.updateTask(task.id, {assignedAgentId: "agent-y", nodeId: "node-new"});
const fetched = await store.getTask(task.id);
expect(fetched.checkedOutBy).toBeUndefined();
expect(fetched.effectiveNodeId).toBe("node-old");
expect(fetched.effectiveNodeSource).toBe("task-override");
});
it("honors a complete replacement and clears only the stale id for source-only replacement", async () => {
const complete = await createWithSnapshot();
await complete.store.updateTask(complete.task.id, {
nodeId: "node-new", effectiveNodeId: "node-new", effectiveNodeSource: "task-override",
});
expect((await complete.store.getTask(complete.task.id)).effectiveNodeId).toBe("node-new");
const sourceOnly = await createWithSnapshot();
await sourceOnly.store.updateTask(sourceOnly.task.id, {nodeId: "node-new", effectiveNodeSource: "local"});
const fetched = await sourceOnly.store.getTask(sourceOnly.task.id);
expect(fetched.effectiveNodeId).toBeUndefined();
expect(fetched.effectiveNodeSource).toBe("local");
});
});
// FNXC:StateMachine 2026-07-07-12:00: Signature 2 (FN-7641) end-to-end regression through the // FNXC:StateMachine 2026-07-07-12:00: Signature 2 (FN-7641) end-to-end regression through the
// real store.updateTask surface (not just the pure guard) — nodeId='end' must finalize-on-proof // real store.updateTask surface (not just the pure guard) — nodeId='end' must finalize-on-proof
// or error, never silently no-op, for both non-workflow and custom-workflow tasks. // or error, never silently no-op, for both non-workflow and custom-workflow tasks.

View File

@@ -0,0 +1,148 @@
import {describe, expect, it, vi} from "vitest";
import type {Task} from "../types.js";
import type {TaskStore} from "../store.js";
import {updateTaskUnlockedImpl} from "../task-store/task-update.js";
/*
FNXC:NodeRouting 2026-08-09-05:08:
Revert proof for #3365: removing the invalidation branch breaks the exact repro and clear assertions;
moving checkout capture to the field-write site breaks e2 after reassignment clears checkedOutBy; replacing
per-field decisions with one supplied flag breaks f2/f3 by retaining the stale companion snapshot field.
*/
function harness(task: Partial<Task>) {
const row = {
id: "FN-1", column: "todo", dependencies: [], steps: [], log: [], status: null,
title: "t", description: "d", createdAt: new Date(0).toISOString(), updatedAt: new Date(0).toISOString(),
...task,
} as unknown as Task;
const store = {
taskDir: () => "/tmp/does-not-matter",
readTaskJson: async () => row,
writeTaskJson: vi.fn(async () => undefined),
atomicWriteTaskJson: vi.fn(async () => undefined),
syncAgentTaskLinkOnReassignment: vi.fn(async () => undefined),
logEntry: vi.fn(async () => undefined),
getSettings: vi.fn(async () => ({})),
assertNoDependencyCycle: vi.fn(async () => undefined),
getTaskWorkflowSelection: () => undefined,
getTaskWorkflowSelectionAsync: async () => undefined,
emit: vi.fn(),
isWatching: false,
taskCache: new Map(),
laneCache: {set: vi.fn(), get: vi.fn(), invalidate: vi.fn()},
} as Record<string, unknown>;
const proxied = new Proxy(store, {
get(target, prop: string) {
if (prop in target) return target[prop];
return async () => undefined;
},
}) as unknown as TaskStore;
return {store: proxied, row};
}
const run = (store: TaskStore, updates: Record<string, unknown>) =>
updateTaskUnlockedImpl(store, "FN-1", updates as never).catch((error: unknown) => {
if (error instanceof Error && /ENOENT|EACCES|no such file/i.test(error.message)) return null;
throw error;
});
const staleRoute = {
column: "todo",
nodeId: "node-old",
effectiveNodeId: "node-old",
effectiveNodeSource: "task-override" as const,
};
const hasInvalidationLog = (row: Task) => row.log?.some((entry) =>
entry.action.includes("Effective route invalidated after node override change"),
) ?? false;
describe("updateTaskUnlockedImpl effective route invalidation", () => {
it("a: clears the issue's stale dispatch snapshot when nodeId changes", async () => {
const {store, row} = harness(staleRoute);
await run(store, {nodeId: "node-new"});
expect(row.nodeId).toBe("node-new");
expect(row.effectiveNodeId).toBeUndefined();
expect(row.effectiveNodeSource).toBeUndefined();
expect(hasInvalidationLog(row)).toBe(true);
});
it("b: invalidates after a null node override clear", async () => {
const {store, row} = harness(staleRoute);
await run(store, {nodeId: null});
expect(row.nodeId).toBeUndefined();
expect(row.effectiveNodeId).toBeUndefined();
expect(row.effectiveNodeSource).toBeUndefined();
});
it("c/d/d2: preserves routes without an actual override change", async () => {
const same = harness(staleRoute);
await run(same.store, {nodeId: "node-old"});
expect(same.row.effectiveNodeId).toBe("node-old");
expect(hasInvalidationLog(same.row)).toBe(false);
const omitted = harness(staleRoute);
await run(omitted.store, {});
expect(omitted.row.effectiveNodeId).toBe("node-old");
const partialWithoutOverride = harness(staleRoute);
await run(partialWithoutOverride.store, {effectiveNodeSource: "local"});
expect(partialWithoutOverride.row.effectiveNodeId).toBe("node-old");
expect(partialWithoutOverride.row.effectiveNodeSource).toBe("local");
});
it("e/e2/e3/e4: preserves routes held by checkout ownership", async () => {
const checkedOut = harness({...staleRoute, checkedOutBy: "agent-x"});
await run(checkedOut.store, {nodeId: "node-new"});
expect(checkedOut.row.effectiveNodeId).toBe("node-old");
const reassigned = harness({...staleRoute, assignedAgentId: "agent-x", checkedOutBy: "agent-x"});
await run(reassigned.store, {assignedAgentId: "agent-y", nodeId: "node-new"});
expect(reassigned.row.checkedOutBy).toBeUndefined();
expect(reassigned.row.effectiveNodeId).toBe("node-old");
expect(reassigned.row.effectiveNodeSource).toBe("task-override");
expect(hasInvalidationLog(reassigned.row)).toBe(false);
const checkoutStarted = harness(staleRoute);
await run(checkoutStarted.store, {checkedOutBy: "agent-x", nodeId: "node-new"});
expect(checkoutStarted.row.effectiveNodeId).toBe("node-old");
const released = harness({...staleRoute, checkedOutBy: "agent-x"});
await run(released.store, {checkedOutBy: null, nodeId: "node-new"});
expect(released.row.effectiveNodeId).toBe("node-old");
});
it("f/f2/f3/f4: honors replacements per field without retaining stale companions", async () => {
const complete = harness(staleRoute);
await run(complete.store, {
nodeId: "node-new", effectiveNodeId: "node-new", effectiveNodeSource: "task-override",
});
expect(complete.row.effectiveNodeId).toBe("node-new");
expect(complete.row.effectiveNodeSource).toBe("task-override");
expect(hasInvalidationLog(complete.row)).toBe(false);
const sourceOnly = harness(staleRoute);
await run(sourceOnly.store, {nodeId: "node-new", effectiveNodeSource: "local"});
expect(sourceOnly.row.effectiveNodeId).toBeUndefined();
expect(sourceOnly.row.effectiveNodeSource).toBe("local");
const idOnly = harness(staleRoute);
await run(idOnly.store, {nodeId: "node-new", effectiveNodeId: "node-new"});
expect(idOnly.row.effectiveNodeId).toBe("node-new");
expect(idOnly.row.effectiveNodeSource).toBeUndefined();
const explicitClear = harness(staleRoute);
await run(explicitClear.store, {nodeId: "node-new", effectiveNodeId: null});
expect(explicitClear.row.effectiveNodeId).toBeUndefined();
expect(explicitClear.row.effectiveNodeSource).toBeUndefined();
});
it("g: invalidates every dispatch source identically", async () => {
for (const effectiveNodeSource of ["project-default", "local"] as const) {
const {store, row} = harness({...staleRoute, effectiveNodeSource});
await run(store, {nodeId: "node-new"});
expect(row.effectiveNodeId).toBeUndefined();
expect(row.effectiveNodeSource).toBeUndefined();
}
});
});

View File

@@ -1331,6 +1331,11 @@ export {
type NodeOverrideValidationResult, type NodeOverrideValidationResult,
type NodeOverrideBlockReason, type NodeOverrideBlockReason,
} from "./mesh/node-override-guard.js"; } from "./mesh/node-override-guard.js";
export {
shouldInvalidateEffectiveRoute,
type EffectiveRouteInvalidationDecision,
type EffectiveRouteInvalidationInput,
} from "./mesh/effective-route-invalidation.js";
export { export {
SANDBOX_BACKEND_NAMES, SANDBOX_BACKEND_NAMES,
SANDBOX_FAILURE_MODES, SANDBOX_FAILURE_MODES,

View File

@@ -0,0 +1,58 @@
export interface EffectiveRouteInvalidationInput {
currentNodeId?: string | null;
nextNodeId: string | null | undefined;
currentEffectiveNodeId?: string;
currentEffectiveNodeSource?: string;
/** True only when checkout ownership was present immediately after loading the task. */
checkedOutOnRead: boolean;
/** True when this update begins a checkout, which owns the route for its lease. */
checkoutBeingSet: boolean;
/** Payload presence, not value: null intentionally clears a route field and is explicit. */
explicitEffectiveNodeIdSupplied: boolean;
/** Payload presence, not value: null intentionally clears a route field and is explicit. */
explicitEffectiveNodeSourceSupplied: boolean;
}
export interface EffectiveRouteInvalidationDecision {
invalidateNodeId: boolean;
invalidateNodeSource: boolean;
reason?: "node-override-changed";
}
function normalizeNodeOverride(nodeId: string | null | undefined): string | undefined {
const normalized = nodeId?.trim();
return normalized || undefined;
}
/**
* FNXC:NodeRouting 2026-08-09-05:05:
* A persisted dispatch snapshot derived from nodeId must not outlive a nodeId change (issue #3365).
* Checked-out tasks and fields explicitly supplied by the writer are deliberately excluded: a checkout
* owns its in-flight lease and a supplied value is an intentional replacement.
*
* updateTaskUnlockedImpl clears task.checkedOutBy during agent reassignment before this decision runs,
* so callers MUST capture checkedOutOnRead immediately after loading the task rather than read a
* partially-mutated working copy. effectiveNodeId and effectiveNodeSource are one snapshot stored in
* two columns; a single all-or-nothing supplied flag would leave a partial payload paired with stale
* data, so invalidation is evaluated independently for each field.
*/
export function shouldInvalidateEffectiveRoute(
input: EffectiveRouteInvalidationInput,
): EffectiveRouteInvalidationDecision {
const overrideChanged = input.nextNodeId !== undefined
&& normalizeNodeOverride(input.currentNodeId) !== normalizeNodeOverride(input.nextNodeId);
const hasPersistedRoute = input.currentEffectiveNodeId !== undefined
|| input.currentEffectiveNodeSource !== undefined;
const trigger = overrideChanged
&& !input.checkedOutOnRead
&& !input.checkoutBeingSet
&& hasPersistedRoute;
const invalidateNodeId = trigger && !input.explicitEffectiveNodeIdSupplied;
const invalidateNodeSource = trigger && !input.explicitEffectiveNodeSourceSupplied;
return {
invalidateNodeId,
invalidateNodeSource,
...(invalidateNodeId || invalidateNodeSource ? {reason: "node-override-changed" as const} : {}),
};
}

View File

@@ -23,6 +23,7 @@ import {validateCustomFieldPatch, CustomFieldRejectionError} from "../tasks/task
import "../builtin-traits.js"; import "../builtin-traits.js";
import {normalizeTaskPriority} from "../tasks/task-priority.js"; import {normalizeTaskPriority} from "../tasks/task-priority.js";
import {validateNodeOverrideChange, resolveNodeOverrideLanes} from "../mesh/node-override-guard.js"; import {validateNodeOverrideChange, resolveNodeOverrideLanes} from "../mesh/node-override-guard.js";
import {shouldInvalidateEffectiveRoute} from "../mesh/effective-route-invalidation.js";
import {isTaskTerminalNodeIdAsync} from "../workflows/workflow-ir-resolver.js"; import {isTaskTerminalNodeIdAsync} from "../workflows/workflow-ir-resolver.js";
import {extractTaskIdTokens, normalizeTitleForTaskId} from "../tasks/task-title-id-drift.js"; import {extractTaskIdTokens, normalizeTitleForTaskId} from "../tasks/task-title-id-drift.js";
import {buildBootstrapPrompt} from "../mesh/mesh-task-replication.js"; import {buildBootstrapPrompt} from "../mesh/mesh-task-replication.js";
@@ -100,6 +101,16 @@ export async function updateTaskUnlockedImpl(store: TaskStore, id: string, updat
const dir = store.taskDir(id); const dir = store.taskDir(id);
const task = await store.readTaskJson(dir); const task = await store.readTaskJson(dir);
/*
FNXC:NodeRouting 2026-08-09-05:08:
Capture checkout and route inputs immediately after the task is read. A combined assignedAgentId/nodeId
update clears checkedOutBy during reassignment later in this pass; reading the mutated task there would
wrongly invalidate the in-flight route of a task that was checked out on read (issue #3365).
*/
const wasCheckedOutOnRead = Boolean(task.checkedOutBy);
const preUpdateNodeId = task.nodeId;
const preUpdateEffectiveNodeId = task.effectiveNodeId;
const preUpdateEffectiveNodeSource = task.effectiveNodeSource;
const wasFailed = task.status === "failed"; const wasFailed = task.status === "failed";
const preUpdatePlanReviewResults = task.workflowStepResults?.filter( const preUpdatePlanReviewResults = task.workflowStepResults?.filter(
(result) => result.workflowStepId === PLAN_REVIEW_GROUP_ID, (result) => result.workflowStepId === PLAN_REVIEW_GROUP_ID,
@@ -507,6 +518,35 @@ export async function updateTaskUnlockedImpl(store: TaskStore, id: string, updat
} else if (updates.effectiveNodeSource !== undefined) { } else if (updates.effectiveNodeSource !== undefined) {
task.effectiveNodeSource = updates.effectiveNodeSource as Task["effectiveNodeSource"]; task.effectiveNodeSource = updates.effectiveNodeSource as Task["effectiveNodeSource"];
} }
/*
FNXC:NodeRouting 2026-08-09-05:08:
A non-checked-out node override change expires its dispatch-time snapshot after explicit route fields
are assigned. Each supplied field wins verbatim, while an unsupplied companion is cleared so a partial
replacement cannot retain a half-stale pair; tasks checked out on read remain bound to their lease.
*/
const effectiveRouteInvalidation = shouldInvalidateEffectiveRoute({
currentNodeId: preUpdateNodeId,
nextNodeId: updates.nodeId,
currentEffectiveNodeId: preUpdateEffectiveNodeId,
currentEffectiveNodeSource: preUpdateEffectiveNodeSource,
checkedOutOnRead: wasCheckedOutOnRead,
checkoutBeingSet: updates.checkedOutBy !== undefined && updates.checkedOutBy !== null,
explicitEffectiveNodeIdSupplied: updates.effectiveNodeId !== undefined,
explicitEffectiveNodeSourceSupplied: updates.effectiveNodeSource !== undefined,
});
if (effectiveRouteInvalidation.invalidateNodeId) task.effectiveNodeId = undefined;
if (effectiveRouteInvalidation.invalidateNodeSource) task.effectiveNodeSource = undefined;
if (effectiveRouteInvalidation.reason) {
const clearedFields = [
...(effectiveRouteInvalidation.invalidateNodeId ? ["effectiveNodeId"] : []),
...(effectiveRouteInvalidation.invalidateNodeSource ? ["effectiveNodeSource"] : []),
];
task.log.push({
timestamp: new Date().toISOString(),
action: `Effective route invalidated after node override change (prior node: ${preUpdateEffectiveNodeId ?? "none"}; cleared: ${clearedFields.join(", ")})`,
...(runContext ? {runContext} : {}),
});
}
if (updates.checkedOutBy === null) { if (updates.checkedOutBy === null) {
task.checkedOutBy = undefined; task.checkedOutBy = undefined;
task.checkedOutAt = undefined; task.checkedOutAt = undefined;