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:
7
.changeset/fn-8857-stale-effective-route.md
Normal file
7
.changeset/fn-8857-stale-effective-route.md
Normal 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.
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
101
packages/core/src/__tests__/effective-route-invalidation.test.ts
Normal file
101
packages/core/src/__tests__/effective-route-invalidation.test.ts
Normal 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",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
|||||||
58
packages/core/src/mesh/effective-route-invalidation.ts
Normal file
58
packages/core/src/mesh/effective-route-invalidation.ts
Normal 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} : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user