From 65bf3097ff6132d546a9c452197adf0100161383 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sat, 8 Aug 2026 23:05:29 -0700 Subject: [PATCH] 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) --- .changeset/fn-8857-stale-effective-route.md | 7 + docs/architecture.md | 6 +- docs/task-management.md | 4 +- .../effective-route-invalidation.test.ts | 101 ++++++++++++ .../postgres/task-node-override.pg.test.ts | 63 ++++++++ ...pdate-effective-route-invalidation.test.ts | 148 ++++++++++++++++++ packages/core/src/index.ts | 5 + .../src/mesh/effective-route-invalidation.ts | 58 +++++++ packages/core/src/task-store/task-update.ts | 40 +++++ 9 files changed, 428 insertions(+), 4 deletions(-) create mode 100644 .changeset/fn-8857-stale-effective-route.md create mode 100644 packages/core/src/__tests__/effective-route-invalidation.test.ts create mode 100644 packages/core/src/__tests__/task-update-effective-route-invalidation.test.ts create mode 100644 packages/core/src/mesh/effective-route-invalidation.ts diff --git a/.changeset/fn-8857-stale-effective-route.md b/.changeset/fn-8857-stale-effective-route.md new file mode 100644 index 0000000000..054911a1a2 --- /dev/null +++ b/.changeset/fn-8857-stale-effective-route.md @@ -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. diff --git a/docs/architecture.md b/docs/architecture.md index cae08c4848..583fe64549 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1886,11 +1886,11 @@ Behavior summary: ### 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` - 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`) @@ -1961,7 +1961,7 @@ Task create/update now preserves both branch fields end-to-end: ### 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: - [Settings Reference → Node Routing settings](./settings-reference.md#node-routing-settings-project-scope) diff --git a/docs/task-management.md b/docs/task-management.md index 6ee48294fd..570e4bda96 100644 --- a/docs/task-management.md +++ b/docs/task-management.md @@ -829,10 +829,12 @@ Tasks execute on an effective node selected by routing precedence: 2. **Project default node** (`defaultNodeId` in project settings) 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` - `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 Cluster-aware task creation separates two routing decisions: diff --git a/packages/core/src/__tests__/effective-route-invalidation.test.ts b/packages/core/src/__tests__/effective-route-invalidation.test.ts new file mode 100644 index 0000000000..aa0bba1a89 --- /dev/null +++ b/packages/core/src/__tests__/effective-route-invalidation.test.ts @@ -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", + }); + }); +}); diff --git a/packages/core/src/__tests__/postgres/task-node-override.pg.test.ts b/packages/core/src/__tests__/postgres/task-node-override.pg.test.ts index 0df16a8b1d..cc152816fc 100644 --- a/packages/core/src/__tests__/postgres/task-node-override.pg.test.ts +++ b/packages/core/src/__tests__/postgres/task-node-override.pg.test.ts @@ -120,6 +120,69 @@ pgTest("task node override persistence (PostgreSQL)", () => { 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 // 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. diff --git a/packages/core/src/__tests__/task-update-effective-route-invalidation.test.ts b/packages/core/src/__tests__/task-update-effective-route-invalidation.test.ts new file mode 100644 index 0000000000..5fa974ec4e --- /dev/null +++ b/packages/core/src/__tests__/task-update-effective-route-invalidation.test.ts @@ -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) { + 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; + 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) => + 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(); + } + }); +}); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 7fbe7b077a..40bf04e16c 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1331,6 +1331,11 @@ export { type NodeOverrideValidationResult, type NodeOverrideBlockReason, } from "./mesh/node-override-guard.js"; +export { + shouldInvalidateEffectiveRoute, + type EffectiveRouteInvalidationDecision, + type EffectiveRouteInvalidationInput, +} from "./mesh/effective-route-invalidation.js"; export { SANDBOX_BACKEND_NAMES, SANDBOX_FAILURE_MODES, diff --git a/packages/core/src/mesh/effective-route-invalidation.ts b/packages/core/src/mesh/effective-route-invalidation.ts new file mode 100644 index 0000000000..68b698d1de --- /dev/null +++ b/packages/core/src/mesh/effective-route-invalidation.ts @@ -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} : {}), + }; +} diff --git a/packages/core/src/task-store/task-update.ts b/packages/core/src/task-store/task-update.ts index 58599e9b61..6d9f29314a 100644 --- a/packages/core/src/task-store/task-update.ts +++ b/packages/core/src/task-store/task-update.ts @@ -23,6 +23,7 @@ import {validateCustomFieldPatch, CustomFieldRejectionError} from "../tasks/task import "../builtin-traits.js"; import {normalizeTaskPriority} from "../tasks/task-priority.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 {extractTaskIdTokens, normalizeTitleForTaskId} from "../tasks/task-title-id-drift.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 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 preUpdatePlanReviewResults = task.workflowStepResults?.filter( (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) { 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) { task.checkedOutBy = undefined; task.checkedOutAt = undefined;