feat(FN-4813): complete Step 3 — add owning-node handoff policy
Fusion-Task-Id: FN-4813 Fusion-Task-Lineage: 846893a5-2afa-4817-8f64-8c444d2fd713
This commit is contained in:
committed by
gsxdsm
parent
735c8f413b
commit
2ff19bc9c9
@@ -168,6 +168,7 @@ export const DEFAULT_GLOBAL_SETTINGS = {
|
||||
installedBinaryPath: undefined,
|
||||
onFailure: "fail",
|
||||
},
|
||||
owningNodeHandoffPolicy: "reassign-to-local",
|
||||
experimentalFeatures: {},
|
||||
} satisfies CompleteSettings<GlobalSettings>;
|
||||
|
||||
@@ -194,6 +195,7 @@ export const DEFAULT_PROJECT_SETTINGS = {
|
||||
pushAfterMerge: false,
|
||||
pushRemote: "origin",
|
||||
unavailableNodePolicy: "block",
|
||||
owningNodeHandoffPolicy: "reassign-to-local",
|
||||
defaultNodeId: undefined,
|
||||
worktreeInitCommand: undefined,
|
||||
testCommand: undefined,
|
||||
|
||||
@@ -282,6 +282,8 @@ export function normalizeAutoRecovery(value: unknown): AutoRecoverySettings {
|
||||
/** Policy for handling task execution when the selected node is unavailable/unhealthy. */
|
||||
export type UnavailableNodePolicy = "block" | "fallback-local";
|
||||
|
||||
export type OwningNodeHandoffPolicy = "block" | "reassign-to-local" | "reassign-any-healthy";
|
||||
|
||||
export interface ModelPreset {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -2049,6 +2051,8 @@ export interface GlobalSettings {
|
||||
ntfyDashboardHost?: string;
|
||||
/** Optional global fallback per-task token budget defaults. */
|
||||
taskTokenBudget?: TaskTokenBudget;
|
||||
/** Policy for recovering tasks whose existing owning node becomes unavailable. */
|
||||
owningNodeHandoffPolicy?: OwningNodeHandoffPolicy;
|
||||
/** How long a task must remain in `status='failed'` before a push notification fires.
|
||||
* Set to 0 to dispatch immediately (legacy behavior). Default: 30000 ms. */
|
||||
failureNotificationDelayMs?: number;
|
||||
@@ -2493,6 +2497,11 @@ export interface ProjectSettings {
|
||||
* - "block": prevent execution until the selected node is healthy/available (default)
|
||||
* - "fallback-local": run on the local node when the selected node is unavailable */
|
||||
unavailableNodePolicy?: UnavailableNodePolicy;
|
||||
/** Policy for tasks already owned by an unavailable node.
|
||||
* - "block": keep parked until owner recovers
|
||||
* - "reassign-to-local": let local node take over (default)
|
||||
* - "reassign-any-healthy": any healthy node may claim */
|
||||
owningNodeHandoffPolicy?: OwningNodeHandoffPolicy;
|
||||
/** Project-level research configuration overrides. */
|
||||
researchSettings?: ResearchProjectSettings;
|
||||
/** Sandbox command-execution settings.
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { Task } from "@fusion/core";
|
||||
import { decideOwningNodeHandoff } from "../node-routing-policy.js";
|
||||
|
||||
const baseTask: Task = {
|
||||
id: "FN-1",
|
||||
description: "test",
|
||||
column: "todo",
|
||||
dependencies: [],
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
prompt: "",
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
};
|
||||
|
||||
describe("decideOwningNodeHandoff", () => {
|
||||
it("parks when owner is online", () => {
|
||||
expect(decideOwningNodeHandoff({ task: baseTask, ownerNodeId: "node-a", ownerNodeHealth: "online", localNodeId: "node-b", handoffPolicy: "reassign-to-local" }))
|
||||
.toEqual({ action: "park", reason: "owner_recovered" });
|
||||
});
|
||||
|
||||
it("reassigns local when owner is local", () => {
|
||||
expect(decideOwningNodeHandoff({ task: baseTask, ownerNodeId: "node-a", ownerNodeHealth: "offline", localNodeId: "node-a", handoffPolicy: "block" }))
|
||||
.toEqual({ action: "reassign-local", reason: "owner_local_recover" });
|
||||
});
|
||||
|
||||
it("parks for block policy", () => {
|
||||
expect(decideOwningNodeHandoff({ task: baseTask, ownerNodeId: "node-a", ownerNodeHealth: "offline", localNodeId: "node-b", handoffPolicy: "block" }))
|
||||
.toEqual({ action: "park", reason: "handoff_blocked_by_policy" });
|
||||
});
|
||||
|
||||
it("reassigns local for reassign-to-local", () => {
|
||||
expect(decideOwningNodeHandoff({ task: baseTask, ownerNodeId: "node-a", ownerNodeHealth: "offline", localNodeId: "node-b", handoffPolicy: "reassign-to-local" }))
|
||||
.toEqual({ action: "reassign-local", reason: "owner_offline_local_takes_over" });
|
||||
});
|
||||
|
||||
it("reassigns any for reassign-any-healthy", () => {
|
||||
expect(decideOwningNodeHandoff({ task: baseTask, ownerNodeId: "node-a", ownerNodeHealth: "offline", localNodeId: "node-b", handoffPolicy: "reassign-any-healthy" }))
|
||||
.toEqual({ action: "reassign-any", reason: "owner_offline_any_healthy_eligible" });
|
||||
});
|
||||
|
||||
it("defaults to reassign-to-local when policy undefined", () => {
|
||||
expect(decideOwningNodeHandoff({ task: baseTask, ownerNodeId: "node-a", ownerNodeHealth: "error", localNodeId: "node-b", handoffPolicy: undefined }))
|
||||
.toEqual({ action: "reassign-local", reason: "owner_error_local_takes_over" });
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { NodeStatus, UnavailableNodePolicy } from "@fusion/core";
|
||||
import type { NodeStatus, OwningNodeHandoffPolicy, Task, UnavailableNodePolicy } from "@fusion/core";
|
||||
import type { EffectiveNode } from "./effective-node.js";
|
||||
|
||||
export type PolicyDecision =
|
||||
@@ -6,6 +6,11 @@ export type PolicyDecision =
|
||||
| { allowed: true; fallbackToLocal: true; reason: string }
|
||||
| { allowed: false; reason: string };
|
||||
|
||||
export type HandoffDecision =
|
||||
| { action: "park"; reason: string }
|
||||
| { action: "reassign-local"; reason: string }
|
||||
| { action: "reassign-any"; reason: string };
|
||||
|
||||
const UNHEALTHY_STATUSES: ReadonlySet<NodeStatus> = new Set(["offline", "error", "connecting"]);
|
||||
|
||||
export function applyUnavailableNodePolicy(params: {
|
||||
@@ -40,3 +45,36 @@ export function applyUnavailableNodePolicy(params: {
|
||||
reason: `Node ${effectiveNode.nodeId} is ${nodeHealth}; policy is block`,
|
||||
};
|
||||
}
|
||||
|
||||
export function decideOwningNodeHandoff(params: {
|
||||
task: Task;
|
||||
ownerNodeId: string;
|
||||
ownerNodeHealth: NodeStatus | undefined;
|
||||
localNodeId: string;
|
||||
handoffPolicy: OwningNodeHandoffPolicy | undefined;
|
||||
}): HandoffDecision {
|
||||
const { ownerNodeId, ownerNodeHealth, localNodeId, handoffPolicy } = params;
|
||||
if (ownerNodeHealth === "online") {
|
||||
return { action: "park", reason: "owner_recovered" };
|
||||
}
|
||||
|
||||
if (ownerNodeId === localNodeId) {
|
||||
return { action: "reassign-local", reason: "owner_local_recover" };
|
||||
}
|
||||
|
||||
if (handoffPolicy === "block") {
|
||||
return { action: "park", reason: "handoff_blocked_by_policy" };
|
||||
}
|
||||
|
||||
if (handoffPolicy === "reassign-any-healthy") {
|
||||
return {
|
||||
action: "reassign-any",
|
||||
reason: `owner_${ownerNodeHealth ?? "unknown"}_any_healthy_eligible`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
action: "reassign-local",
|
||||
reason: `owner_${ownerNodeHealth ?? "unknown"}_local_takes_over`,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user