365 lines
14 KiB
TypeScript
365 lines
14 KiB
TypeScript
import type { Settings, TaskDetail, WorkflowIrEdge, WorkflowIrNode } from "@fusion/core";
|
|
import { WorkflowIrError } from "@fusion/core";
|
|
|
|
import type { WorkflowNodeOutcome, WorkflowNodeResult } from "./workflow-graph-executor.js";
|
|
import { schedulerLog } from "./logger.js";
|
|
|
|
/**
|
|
* Concurrent fan-out/join branch execution (U13, KTD-11, R21).
|
|
*
|
|
* When the sequential walker reaches a `split` node, every outgoing edge becomes
|
|
* a branch that walks concurrently up to the matching `join`. The join then
|
|
* synchronizes per its config (`all | any | quorum(n)`) and either fails fast
|
|
* (cancelling siblings via an AbortSignal) or collects all branch outcomes.
|
|
*
|
|
* This module owns ONLY the parallel window: it is handed a `runBranchNode`
|
|
* callback that reuses the executor's per-node retry logic, and a small set of
|
|
* graph lookups. The card's board position never forks — that invariant is
|
|
* upheld by the executor (no handler-driven column moves happen here).
|
|
*/
|
|
|
|
/** Per-branch persisted run state (ADR-0001 reconstructible). */
|
|
export interface WorkflowBranchRunState {
|
|
taskId: string;
|
|
runId: string;
|
|
branchId: string;
|
|
/** Node the branch is currently at / last completed. */
|
|
currentNodeId: string;
|
|
status: "running" | "completed" | "failed" | "aborted";
|
|
}
|
|
|
|
/**
|
|
* Persistence callback surface. Kept as an injected interface so the executor
|
|
* stays DI-pure and fake-friendly; the SQLite-backed implementation is wired
|
|
* separately (see workflow_run_branches table). All methods are optional so a
|
|
* fully in-memory run (tests, flag-off) needs no persistence at all.
|
|
*/
|
|
export interface WorkflowBranchPersistence {
|
|
/** Idempotent upsert of a branch's progress, keyed by (taskId, runId, branchId). */
|
|
saveBranchState?(state: WorkflowBranchRunState): void | Promise<void>;
|
|
/** Load any persisted branch states for a run (used on resume). */
|
|
loadBranchStates?(taskId: string, runId: string): WorkflowBranchRunState[] | Promise<WorkflowBranchRunState[]>;
|
|
/**
|
|
* Prune stale branch rows for a task, keeping only `keepRunId` (#1412).
|
|
* Called on run start and run completion to bound unbounded growth across a
|
|
* long-lived task's repeated runs.
|
|
*/
|
|
clearStaleBranchStates?(taskId: string, keepRunId: string): void | Promise<void>;
|
|
}
|
|
|
|
/**
|
|
* Await a `saveBranchState` call inside a guard so a Promise-returning impl
|
|
* cannot escape as an unhandled rejection, and so a persistence failure never
|
|
* kills branch execution (log-and-continue). For a synchronous impl this
|
|
* preserves the prior behavior (the write completes before the caller proceeds).
|
|
*/
|
|
async function persistBranchState(
|
|
persistence: WorkflowBranchPersistence | undefined,
|
|
state: WorkflowBranchRunState,
|
|
): Promise<void> {
|
|
try {
|
|
await persistence?.saveBranchState?.(state);
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : String(err);
|
|
schedulerLog.warn(
|
|
`saveBranchState failed for task ${state.taskId} run ${state.runId} branch ${state.branchId}: ${message}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
/** Minimal semaphore shape — structurally compatible with AgentSemaphore. */
|
|
export interface WorkflowBranchSemaphore {
|
|
run<T>(fn: () => Promise<T>): Promise<T>;
|
|
}
|
|
|
|
/** Snapshot of a single branch's progress, surfaced for dashboard badges (U9). */
|
|
export interface WorkflowBranchProgress {
|
|
branchId: string;
|
|
nodeId: string;
|
|
status: WorkflowBranchRunState["status"];
|
|
}
|
|
|
|
export interface BranchEnvironment {
|
|
task: TaskDetail;
|
|
settings: Pick<Settings, "experimentalFeatures"> | undefined;
|
|
runId: string;
|
|
nodeMap: Map<string, WorkflowIrNode>;
|
|
outgoingMap: Map<string, WorkflowIrEdge[]>;
|
|
/** Reuses the executor's executeNodeWithRetries (+ context bookkeeping). */
|
|
runBranchNode: (
|
|
node: WorkflowIrNode,
|
|
signal: AbortSignal,
|
|
) => Promise<WorkflowNodeResult>;
|
|
shouldTraverseEdge: (edge: WorkflowIrEdge, source: WorkflowNodeResult) => boolean;
|
|
persistence?: WorkflowBranchPersistence;
|
|
semaphore?: WorkflowBranchSemaphore;
|
|
/** Reports live per-branch progress for the card (no column move). */
|
|
onBranchProgress?: (progress: WorkflowBranchProgress) => void;
|
|
/** Node IDs already completed in a prior (crashed) run — skipped on resume. */
|
|
completedNodeIds?: Set<string>;
|
|
}
|
|
|
|
export interface SplitJoinResult {
|
|
/** The join node where branches converged. */
|
|
joinNodeId: string;
|
|
/** Join outcome: success if the mode was satisfied, else failure. */
|
|
outcome: WorkflowNodeOutcome;
|
|
/** Per-branch outcomes, exposed so the join's outgoing edge conditions can read them. */
|
|
branchOutcomes: { branchId: string; outcome: WorkflowNodeOutcome; nodeId: string }[];
|
|
/** Node IDs visited across all branches (for the executor's visited list). */
|
|
visitedNodeIds: string[];
|
|
}
|
|
|
|
interface ResolvedJoinConfig {
|
|
mode: "all" | "any" | { quorum: number };
|
|
onBranchFailure: "fail-fast" | "collect";
|
|
}
|
|
|
|
function resolveJoinConfig(join: WorkflowIrNode): ResolvedJoinConfig {
|
|
const rawMode = join.config?.mode;
|
|
let mode: ResolvedJoinConfig["mode"] = "all";
|
|
if (rawMode === "all" || rawMode === "any") mode = rawMode;
|
|
else if (rawMode && typeof rawMode === "object" && "quorum" in rawMode) {
|
|
const n = (rawMode as { quorum: unknown }).quorum;
|
|
if (typeof n === "number" && Number.isInteger(n) && n > 0) mode = { quorum: n };
|
|
else throw new WorkflowIrError(`join '${join.id}' quorum must be a positive integer`);
|
|
}
|
|
const rawFail = join.config?.onBranchFailure;
|
|
const onBranchFailure: ResolvedJoinConfig["onBranchFailure"] =
|
|
rawFail === "collect" ? "collect" : "fail-fast";
|
|
return { mode, onBranchFailure };
|
|
}
|
|
|
|
/** How many successful completions satisfy this join mode for `branchCount` branches. */
|
|
function requiredCompletions(mode: ResolvedJoinConfig["mode"], branchCount: number): number {
|
|
if (mode === "all") return branchCount;
|
|
if (mode === "any") return 1;
|
|
return Math.min(mode.quorum, branchCount);
|
|
}
|
|
|
|
/**
|
|
* Execute a split's branches concurrently and synchronize at the join.
|
|
*
|
|
* Returns once the join is satisfied (or definitively cannot be). Sibling
|
|
* branches are aborted on fail-fast via the shared AbortSignal; on collect they
|
|
* are awaited. Nested splits recurse: a branch walk that itself hits a `split`
|
|
* calls back into this function for its inner window.
|
|
*/
|
|
export async function runSplitJoin(
|
|
split: WorkflowIrNode,
|
|
env: BranchEnvironment,
|
|
): Promise<SplitJoinResult> {
|
|
const branchEdges = (env.outgoingMap.get(split.id) ?? []).filter(
|
|
(e) => env.shouldTraverseEdge(e, { outcome: "success" }),
|
|
);
|
|
if (branchEdges.length === 0) {
|
|
throw new WorkflowIrError(`split '${split.id}' has no traversable branches`);
|
|
}
|
|
|
|
const join = findMatchingJoin(branchEdges[0].to, env);
|
|
if (!join) throw new WorkflowIrError(`split '${split.id}' has no reachable matching join`);
|
|
const joinConfig = resolveJoinConfig(env.nodeMap.get(join)!);
|
|
|
|
const controller = new AbortController();
|
|
const branchCount = branchEdges.length;
|
|
const required = requiredCompletions(joinConfig.mode, branchCount);
|
|
|
|
const visitedNodeIds: string[] = [];
|
|
const branchOutcomes: SplitJoinResult["branchOutcomes"] = [];
|
|
let succeeded = 0;
|
|
let failed = 0;
|
|
let settled = false;
|
|
let resolveJoin!: (outcome: WorkflowNodeOutcome) => void;
|
|
const joinReached = new Promise<WorkflowNodeOutcome>((res) => {
|
|
resolveJoin = res;
|
|
});
|
|
|
|
const settle = (outcome: WorkflowNodeOutcome): void => {
|
|
if (settled) return;
|
|
settled = true;
|
|
resolveJoin(outcome);
|
|
};
|
|
|
|
// Re-evaluate the join after each branch settles. `lastWasFailure` only
|
|
// affects fail-fast (one failure cancels siblings immediately).
|
|
const evaluateJoin = (lastWasFailure: boolean): void => {
|
|
if (settled) return;
|
|
if (joinConfig.onBranchFailure === "fail-fast" && lastWasFailure) {
|
|
controller.abort();
|
|
settle("failure");
|
|
return;
|
|
}
|
|
if (succeeded >= required) {
|
|
// Mode satisfied. Fail-fast cancels any laggards; collect lets them finish.
|
|
if (joinConfig.onBranchFailure === "fail-fast") controller.abort();
|
|
settle("success");
|
|
return;
|
|
}
|
|
if (succeeded + failed >= branchCount) {
|
|
// All branches settled but the mode is unmet.
|
|
settle("failure");
|
|
}
|
|
};
|
|
|
|
const branchPromises = branchEdges.map((edge) => {
|
|
const branchId = edge.to;
|
|
return walkBranch(branchId, join, env, controller.signal, visitedNodeIds)
|
|
.then((result) => {
|
|
branchOutcomes.push({ branchId, outcome: result.outcome, nodeId: result.lastNodeId });
|
|
if (result.outcome === "success") succeeded += 1;
|
|
else failed += 1;
|
|
evaluateJoin(result.outcome === "failure");
|
|
})
|
|
.catch((err) => {
|
|
// An aborted branch settles silently; any other throw fails the join.
|
|
if (controller.signal.aborted) {
|
|
branchOutcomes.push({ branchId, outcome: "failure", nodeId: branchId });
|
|
return;
|
|
}
|
|
failed += 1;
|
|
branchOutcomes.push({ branchId, outcome: "failure", nodeId: branchId });
|
|
evaluateJoin(true);
|
|
void err;
|
|
});
|
|
});
|
|
|
|
// Wait for the join to resolve, then let in-flight branches settle so collect
|
|
// semantics (and persistence writes) complete before we return.
|
|
const outcome = await joinReached;
|
|
await Promise.allSettled(branchPromises);
|
|
|
|
return { joinNodeId: join, outcome, branchOutcomes, visitedNodeIds };
|
|
}
|
|
|
|
interface BranchWalkResult {
|
|
outcome: WorkflowNodeOutcome;
|
|
lastNodeId: string;
|
|
}
|
|
|
|
/**
|
|
* Walk a single branch from `startNodeId` up to (but not including) the join.
|
|
* Reuses the injected per-node runner; supports nested splits by recursing into
|
|
* runSplitJoin. Honors the AbortSignal (fail-fast cancellation) and skips nodes
|
|
* already completed in a prior run (crash resume idempotency).
|
|
*/
|
|
async function walkBranch(
|
|
startNodeId: string,
|
|
joinId: string,
|
|
env: BranchEnvironment,
|
|
signal: AbortSignal,
|
|
visitedNodeIds: string[],
|
|
): Promise<BranchWalkResult> {
|
|
let currentId = startNodeId;
|
|
let lastResult: WorkflowNodeResult = { outcome: "success" };
|
|
|
|
for (;;) {
|
|
if (signal.aborted) return { outcome: "failure", lastNodeId: currentId };
|
|
if (currentId === joinId) return { outcome: lastResult.outcome, lastNodeId: currentId };
|
|
|
|
const node = env.nodeMap.get(currentId);
|
|
if (!node) throw new WorkflowIrError(`Unknown workflow node: ${currentId}`);
|
|
|
|
if (node.kind === "split") {
|
|
// Nested split: resolve its inner window, then continue from the inner join.
|
|
const inner = await runSplitJoin(node, env);
|
|
visitedNodeIds.push(...inner.visitedNodeIds);
|
|
lastResult = { outcome: inner.outcome };
|
|
const next = nextEdge(inner.joinNodeId, env, lastResult);
|
|
if (!next) return { outcome: inner.outcome, lastNodeId: inner.joinNodeId };
|
|
currentId = next;
|
|
continue;
|
|
}
|
|
|
|
visitedNodeIds.push(currentId);
|
|
|
|
const alreadyDone = env.completedNodeIds?.has(currentId) ?? false;
|
|
if (alreadyDone) {
|
|
lastResult = { outcome: "success" };
|
|
} else {
|
|
const exec = async (): Promise<WorkflowNodeResult> => env.runBranchNode(node, signal);
|
|
lastResult = env.semaphore ? await env.semaphore.run(exec) : await exec();
|
|
await persistBranchState(env.persistence, {
|
|
taskId: env.task.id,
|
|
runId: env.runId,
|
|
branchId: startNodeId,
|
|
currentNodeId: currentId,
|
|
status: lastResult.outcome === "success" ? "running" : "failed",
|
|
});
|
|
env.onBranchProgress?.({
|
|
branchId: startNodeId,
|
|
nodeId: currentId,
|
|
status: lastResult.outcome === "success" ? "running" : "failed",
|
|
});
|
|
}
|
|
|
|
if (lastResult.outcome === "failure") {
|
|
await persistBranchState(env.persistence, {
|
|
taskId: env.task.id,
|
|
runId: env.runId,
|
|
branchId: startNodeId,
|
|
currentNodeId: currentId,
|
|
status: "failed",
|
|
});
|
|
return { outcome: "failure", lastNodeId: currentId };
|
|
}
|
|
|
|
const next = nextEdge(currentId, env, lastResult);
|
|
if (!next) {
|
|
// Dead-end before the join — treat as branch completion.
|
|
return { outcome: lastResult.outcome, lastNodeId: currentId };
|
|
}
|
|
if (next === joinId) {
|
|
await persistBranchState(env.persistence, {
|
|
taskId: env.task.id,
|
|
runId: env.runId,
|
|
branchId: startNodeId,
|
|
currentNodeId: currentId,
|
|
status: "completed",
|
|
});
|
|
env.onBranchProgress?.({ branchId: startNodeId, nodeId: currentId, status: "completed" });
|
|
return { outcome: lastResult.outcome, lastNodeId: currentId };
|
|
}
|
|
currentId = next;
|
|
}
|
|
}
|
|
|
|
/** The next node along a matching outgoing edge, or undefined if none matches. */
|
|
function nextEdge(
|
|
nodeId: string,
|
|
env: BranchEnvironment,
|
|
source: WorkflowNodeResult,
|
|
): string | undefined {
|
|
const edges = (env.outgoingMap.get(nodeId) ?? [])
|
|
.filter((e) => env.shouldTraverseEdge(e, source))
|
|
.sort((a, b) => a.to.localeCompare(b.to));
|
|
return edges[0]?.to;
|
|
}
|
|
|
|
/**
|
|
* Find the join node a branch starting at `startNodeId` converges on. Walks
|
|
* forward through the (non-failure) edges; recurses one level for nested splits
|
|
* so balanced nesting resolves to the correct outer join.
|
|
*/
|
|
function findMatchingJoin(startNodeId: string, env: BranchEnvironment): string | undefined {
|
|
const seen = new Set<string>();
|
|
let currentId: string | undefined = startNodeId;
|
|
while (currentId && !seen.has(currentId)) {
|
|
seen.add(currentId);
|
|
const node = env.nodeMap.get(currentId);
|
|
if (!node) return undefined;
|
|
if (node.kind === "join") return currentId;
|
|
if (node.kind === "split") {
|
|
const innerJoin = findMatchingJoin(
|
|
(env.outgoingMap.get(currentId) ?? [])[0]?.to ?? "",
|
|
env,
|
|
);
|
|
if (!innerJoin) return undefined;
|
|
currentId = (env.outgoingMap.get(innerJoin) ?? []).find((e) => e.condition !== "failure")?.to;
|
|
continue;
|
|
}
|
|
const out = env.outgoingMap.get(currentId) ?? [];
|
|
currentId = out.find((e) => e.condition !== "failure")?.to ?? out[0]?.to;
|
|
}
|
|
return undefined;
|
|
}
|