fix(review): apply autofix feedback

This commit is contained in:
gsxdsm
2026-06-04 02:50:54 -07:00
parent eee715218e
commit 30e2fd7b09
18 changed files with 248 additions and 61 deletions

View File

@@ -206,6 +206,38 @@ describe("U12 rollback safety — flag OFF after flag ON keeps legacy behavior",
expect(caught).toBeInstanceOf(Error);
expect((caught as Error).message).toMatch(/Invalid transition/);
});
it("a card stranded in a custom column when the flag is toggled OFF degrades to a clean Invalid-transition error (no TypeError) and listTasks stays healthy", async () => {
// Flag ON: select a custom workflow whose entry column is custom, so the
// card is re-homed into a column that VALID_TRANSITIONS never keys.
await store.updateGlobalSettings({ experimentalFeatures: { workflowColumns: true } });
const wf = await store.createWorkflowDefinition({
name: "stranded",
ir: customIr("stranded", ["intake", "build", "ship"], "intake"),
});
const task = await store.createTask({ description: "stranded card" });
await store.selectTaskWorkflowAndReconcile(task.id, wf.id);
expect((await store.getTask(task.id)).column).toBe("intake");
// Toggle the flag OFF — the card stays in the custom "intake" column.
await store.updateGlobalSettings({ experimentalFeatures: { workflowColumns: false } });
expect((await store.getTask(task.id)).column).toBe("intake");
// listTasks must not throw with a task sitting in an unknown column.
await expect(store.listTasks()).resolves.toBeDefined();
// A move attempt degrades to the legacy "Invalid transition" error rather
// than a TypeError on the undefined VALID_TRANSITIONS lookup.
let caught: unknown;
try {
await store.moveTask(task.id, "in-progress", { moveSource: "user" });
} catch (e) {
caught = e;
}
expect(caught).toBeInstanceOf(Error);
expect((caught as Error).message).toMatch(/Invalid transition/);
expect((caught as Error)).not.toBeInstanceOf(TypeError);
});
});
describe("Residual B: getBranchProgressByTask reads workflow_run_branches", () => {

View File

@@ -145,11 +145,12 @@ export {
} from "./plugin-gate-verdict.js";
export type { PluginGateVerdict, ColumnPluginGate } from "./plugin-gate-verdict.js";
// ── U6: workflow capacity (WIP) resolution shared by store + sweep ───────────
export { resolveColumnCapacity } from "./workflow-capacity.js";
export { resolveColumnCapacity, DEFAULT_WORKFLOW_POOL_ID } from "./workflow-capacity.js";
export type { ColumnCapacity } from "./workflow-capacity.js";
// ── U5: workflow lifecycle reconciliation (switch / edit / delete) ───────────
export {
OccupiedColumnsError,
InvalidRehomeTargetError,
resolveEntryColumnId,
resolveSwitchReconciliation,
computeRemovedOccupiedColumns,

View File

@@ -16,7 +16,7 @@ import {
resolveColumnPluginGates,
} from "./plugin-gate-verdict.js";
import { getTraitRegistry, assertColumnTraitsValid } from "./trait-registry.js";
import { resolveColumnCapacity } from "./workflow-capacity.js";
import { resolveColumnCapacity, DEFAULT_WORKFLOW_POOL_ID } from "./workflow-capacity.js";
import {
OccupiedColumnsError,
assertRehomeTargetValid,
@@ -1150,8 +1150,10 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
private static readonly ACTIVE_TASKS_WHERE = '"deletedAt" IS NULL';
/** U6: sentinel effective-workflow id for default-workflow (null-selection)
* tasks, so they all share one per-column capacity pool (KTD-10). It is not a
* real workflow row id (no `builtin:`/custom collision possible). */
private static readonly DEFAULT_WORKFLOW_POOL_ID = "__default-workflow__";
* real workflow row id (no `builtin:`/custom collision possible). Re-exposed
* as a static member for internal call sites; the canonical const lives in
* `workflow-capacity.ts` (`DEFAULT_WORKFLOW_POOL_ID`). */
private static readonly DEFAULT_WORKFLOW_POOL_ID = DEFAULT_WORKFLOW_POOL_ID;
static async getOrCreateForProject(
projectId?: string,
@@ -5050,12 +5052,6 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
// Group by task; for each task keep only the branches of its most-recent
// run (the runId of the row with the latest updatedAt).
const latestRunByTask = new Map<string, string>();
for (const row of rows) {
const known = latestRunByTask.get(row.taskId);
if (!known) latestRunByTask.set(row.taskId, row.runId);
}
// Re-derive the latest runId precisely from the max-updatedAt row.
const maxByTask = new Map<string, { runId: string; updatedAt: string }>();
for (const row of rows) {
const cur = maxByTask.get(row.taskId);
@@ -5933,7 +5929,10 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
}
} else {
// ── Flag-OFF legacy path (unchanged) ───────────────────────────────────
const validTargets = VALID_TRANSITIONS[task.column];
// A task can sit in a custom column when the flag was toggled ON→OFF;
// `VALID_TRANSITIONS` only keys the legacy columns, so a missing entry
// degrades to the legacy "Invalid transition" error instead of a TypeError.
const validTargets = VALID_TRANSITIONS[task.column as Column] ?? [];
if (!validTargets.includes(toColumn)) {
throw new Error(
`Invalid transition: '${task.column}' → '${toColumn}'. ` +

View File

@@ -29,6 +29,11 @@ import { getTraitRegistry } from "./trait-registry.js";
* `settings.maxConcurrent` (the legacy "N agents in-progress" gate). */
const DEFAULT_WIP_COLUMN_ID = "in-progress";
/** U6 (KTD-10): sentinel effective-workflow id for default-workflow
* (null-selection) tasks, so they all share one per-column capacity pool. It
* is not a real workflow row id (no `builtin:`/custom collision possible). */
export const DEFAULT_WORKFLOW_POOL_ID = "__default-workflow__";
/** Resolved capacity configuration for a single column. */
export interface ColumnCapacity {
/** True when the column carries a capacity (`wip`/`countsTowardWip`) trait. */

View File

@@ -148,6 +148,25 @@ export function computeRemovedOccupiedColumns(
return removed;
}
/**
* Thrown when a supplied `rehomeTo` names a column that does not exist in the
* post-edit workflow. Distinct from {@link OccupiedColumnsError} (which signals
* a conflict needing a re-home target) — this is a bad-request input error and
* the dashboard maps it to a 400, not a 409.
*/
export class InvalidRehomeTargetError extends Error {
readonly workflowId: string;
readonly rehomeTo: string;
constructor(workflowId: string, rehomeTo: string) {
super(
`Workflow '${workflowId}' has no column '${rehomeTo}' to re-home occupants into.`,
);
this.name = "InvalidRehomeTargetError";
this.workflowId = workflowId;
this.rehomeTo = rehomeTo;
}
}
/**
* Validate that `rehomeTo` (when supplied for an edit that removes occupied
* columns) names a column that survives in `nextIr`. Throws when it does not, so
@@ -155,9 +174,9 @@ export function computeRemovedOccupiedColumns(
*/
export function assertRehomeTargetValid(nextIr: WorkflowIr, rehomeTo: string): void {
if (!workflowHasColumn(nextIr, rehomeTo)) {
throw new OccupiedColumnsError(
throw new InvalidRehomeTargetError(
(nextIr as WorkflowIrV2).name ?? "(unknown)",
[],
rehomeTo,
);
}
}

View File

@@ -5075,8 +5075,18 @@ export function selectTaskWorkflow(
taskId: string,
workflowId: string | null,
projectId?: string,
): Promise<{ workflowId: string | null; enabledWorkflowSteps: string[] }> {
return api<{ workflowId: string | null; enabledWorkflowSteps: string[] }>(
): Promise<{
workflowId: string | null;
enabledWorkflowSteps: string[];
// U5 (R20): present (flag ON) when the switch re-homed the card; `preserved`
// false means the card moved columns and the board needs a refresh.
reconciliation?: { preserved: boolean; fromColumn: string; toColumn: string };
}> {
return api<{
workflowId: string | null;
enabledWorkflowSteps: string[];
reconciliation?: { preserved: boolean; fromColumn: string; toColumn: string };
}>(
withProjectId(`/tasks/${encodeURIComponent(taskId)}/workflow`, projectId),
{
method: "PUT",

View File

@@ -282,19 +282,39 @@ export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onPauseTask
return new Set();
});
// Fetch board workflow lanes for the project. Deliberately NOT keyed on
// `tasks` — that refetched on every SSE tick. Instead we refetch on project
// change and when the tab regains visibility/focus. A stale-response guard
// (monotonic sequence ref) drops out-of-order responses.
// TODO: replace the visibility/focus staleness stopgap with a
// `workflow:updated` SSE event when one exists.
const boardWorkflowsFetchSeqRef = useRef(0);
useEffect(() => {
let cancelled = false;
fetchBoardWorkflows(projectId)
.then((payload) => {
if (!cancelled) setBoardWorkflows(payload);
})
.catch(() => {
if (!cancelled) setBoardWorkflows({ flagEnabled: false, defaultWorkflowId: "builtin:coding", workflows: [], taskWorkflowIds: {} });
});
return () => {
cancelled = true;
const runFetch = () => {
const seq = ++boardWorkflowsFetchSeqRef.current;
fetchBoardWorkflows(projectId)
.then((payload) => {
if (seq === boardWorkflowsFetchSeqRef.current) setBoardWorkflows(payload);
})
.catch(() => {
if (seq === boardWorkflowsFetchSeqRef.current) {
setBoardWorkflows({ flagEnabled: false, defaultWorkflowId: "builtin:coding", workflows: [], taskWorkflowIds: {} });
}
});
};
}, [projectId, tasks]);
runFetch();
const onVisible = () => {
if (typeof document === "undefined" || document.visibilityState === "visible") runFetch();
};
if (typeof document !== "undefined") document.addEventListener("visibilitychange", onVisible);
if (typeof window !== "undefined") window.addEventListener("focus", onVisible);
return () => {
// Advance the seq so any in-flight response is dropped on cleanup.
boardWorkflowsFetchSeqRef.current++;
if (typeof document !== "undefined") document.removeEventListener("visibilitychange", onVisible);
if (typeof window !== "undefined") window.removeEventListener("focus", onVisible);
};
}, [projectId]);
const handleToggleLaneCollapse = useCallback((workflowId: string) => {
setCollapsedLanes((prev) => {

View File

@@ -1971,6 +1971,18 @@ export function TaskDetailContent({
}
}, [task.id, projectId, workflowEnabledSteps, onTaskUpdated, addToast]);
// U5 (R20): a workflow switch re-homed the card to a new column. Refetch the
// task and push it up so the board reflects the move before the SSE catch-up.
const handleWorkflowReconciled = useCallback(async () => {
try {
const detail = await fetchTaskDetail(task.id, projectId);
setFullDetail(detail);
onTaskUpdated?.(detail);
} catch {
// Best-effort refresh; the SSE stream will catch the board up regardless.
}
}, [task.id, projectId, onTaskUpdated]);
const loadAgents = useCallback(async () => {
setAgentsLoading(true);
try {
@@ -2761,6 +2773,7 @@ export function TaskDetailContent({
&& task.status !== "awaiting-cli-approval"
}
onWorkflowStepsChange={handleWorkflowStepsChange}
onWorkflowReconciled={handleWorkflowReconciled}
taskStatus={task.status}
taskPausedReason={task.pausedReason}
/>

View File

@@ -36,9 +36,17 @@ export function WorkflowColumnPanel({
const [catalog, setCatalog] = useState<TraitCatalogEntry[]>([]);
useEffect(() => {
let cancelled = false;
fetchTraits(projectId)
.then(setCatalog)
.catch((err) => addToast(getErrorMessage(err) || t("workflowColumns.traitsLoadFailed", "Failed to load traits"), "error"));
.then((catalog) => {
if (!cancelled) setCatalog(catalog);
})
.catch((err) => {
if (!cancelled) addToast(getErrorMessage(err) || t("workflowColumns.traitsLoadFailed", "Failed to load traits"), "error");
});
return () => {
cancelled = true;
};
}, [projectId, addToast, t]);
const workflowWide = violations.filter((v) => v.columnId === null);

View File

@@ -42,7 +42,7 @@ import {
emptyWorkflowLayout,
columnsOf,
columnsToBandNodes,
columnForY,
strictColumnForY,
validateColumnsClient,
unplacedNodeIds,
isColumnBandNode,
@@ -120,9 +120,17 @@ function InnerEditor({
// Trait catalog (for client-side composition validation; the panel fetches its
// own copy for the picker, but the editor needs the flags to validate).
useEffect(() => {
fetchTraits(projectId).then(setTraitCatalog).catch(() => {
// Non-fatal: validation degrades to server-side parse on save.
});
let cancelled = false;
fetchTraits(projectId)
.then((catalog) => {
if (!cancelled) setTraitCatalog(catalog);
})
.catch(() => {
// Non-fatal: validation degrades to server-side parse on save.
});
return () => {
cancelled = true;
};
}, [projectId]);
// Composition violations (client mirror of validateColumnTraits).
@@ -194,7 +202,9 @@ function InnerEditor({
const onNodeDragStop = useCallback(
(_evt: unknown, node: FlowNode<WorkflowFlowNodeData>) => {
if (isColumnBandNode(node.id) || columns.length === 0) return;
const column = columnForY(node.position.y, columns);
// strictColumnForY (not the clamping columnForY): a node dragged above or
// below all bands keeps no column rather than snapping to the nearest one.
const column = strictColumnForY(node.position.y, columns);
if (!column) return;
setNodes((ns) =>
ns.map((n) => (n.id === node.id ? { ...n, data: { ...n.data, column } } : n)),

View File

@@ -46,6 +46,10 @@ interface WorkflowResultsTabProps {
onWorkflowStepsChange?: (steps: string[]) => void;
taskStatus?: string;
taskPausedReason?: string;
/** U5 (R20): called after a workflow switch re-homed the card to a new column
* (reconciliation present and not preserved) so the board can refresh before
* the SSE catch-up arrives. */
onWorkflowReconciled?: () => void;
}
/** Extract the user-facing question from a workflow-input paused reason.
@@ -227,6 +231,7 @@ export function WorkflowResultsTab({
onWorkflowStepsChange,
taskStatus,
taskPausedReason,
onWorkflowReconciled,
}: WorkflowResultsTabProps) {
const { t } = useTranslation("app");
const [expandedOutputs, setExpandedOutputs] = useState<Record<string, boolean>>({});
@@ -270,8 +275,13 @@ export function WorkflowResultsTab({
const res = await selectTaskWorkflow(taskId, workflowId, projectId);
setSelectedWorkflowId(res.workflowId);
onWorkflowStepsChange?.(res.enabledWorkflowSteps);
// U5 (R20): the switch re-homed the card to a new column — refresh the
// board now rather than waiting for the SSE catch-up.
if (res.reconciliation && !res.reconciliation.preserved) {
onWorkflowReconciled?.();
}
},
[taskId, projectId, onWorkflowStepsChange],
[taskId, projectId, onWorkflowStepsChange, onWorkflowReconciled],
);
// Check if any result has pending status

View File

@@ -95,19 +95,25 @@ async function describeWorkflow(
store: Pick<TaskStore, "getWorkflowDefinition">,
workflowId: string,
): Promise<BoardWorkflowDefinition> {
const ir = await resolveWorkflowIr(store, workflowId);
// The display name comes from the persisted definition when available,
// otherwise the IR's own name (default workflow).
let name = ir.name;
if (isBuiltinWorkflowId(workflowId)) {
name = getBuiltinWorkflow(workflowId)?.name ?? name;
} else {
try {
const def = await store.getWorkflowDefinition(workflowId);
if (def?.name) name = def.name;
} catch {
// fall through to IR name
const ir = await resolveWorkflowIr(store, workflowId);
const name = getBuiltinWorkflow(workflowId)?.name ?? ir.name;
return { id: workflowId, name, columns: describeColumns(ir) };
}
// Custom workflow: fetch the definition once and derive both IR and name from
// it (previously getWorkflowDefinition was called twice per workflow).
let ir: WorkflowIr = BUILTIN_CODING_WORKFLOW_IR;
let name = ir.name;
try {
const def = await store.getWorkflowDefinition(workflowId);
if (def) {
ir = typeof def.ir === "string" ? parseWorkflowIr(def.ir) : def.ir;
name = def.name || ir.name;
}
} catch {
// fall through to the default IR/name
}
return { id: workflowId, name, columns: describeColumns(ir) };
}

View File

@@ -1,5 +1,5 @@
import type { WorkflowIr } from "@fusion/core";
import { ColumnTraitValidationError, OccupiedColumnsError, WorkflowCompileError, WorkflowIrError, compileWorkflowToSteps, listTraits } from "@fusion/core";
import { ColumnTraitValidationError, OccupiedColumnsError, InvalidRehomeTargetError, WorkflowCompileError, WorkflowIrError, compileWorkflowToSteps, listTraits } from "@fusion/core";
import { ApiError, badRequest, conflict, notFound } from "../api-error.js";
import type { ApiRoutesContext } from "./types.js";
@@ -122,6 +122,11 @@ export function registerWorkflowRoutes(ctx: ApiRoutesContext): void {
if (err instanceof OccupiedColumnsError) {
throw conflict(err.message, { workflowId: err.workflowId, occupancies: err.occupancies });
}
// A supplied rehomeTo naming a non-existent column is a bad request (400),
// not a 409 conflict.
if (err instanceof InvalidRehomeTargetError) {
throw badRequest(err.message, { workflowId: err.workflowId, rehomeTo: err.rehomeTo });
}
if (err instanceof WorkflowIrError) throw badRequest(err.message);
if (err instanceof ColumnTraitValidationError) {
throw badRequest(err.message, { violations: err.violations });

View File

@@ -121,6 +121,7 @@ const qualityAppComponentTests = [
"GitHubBadge",
"GroupTaskModal",
"InlineCreateCard",
"Lane",
"LoginInstructions",
"MemoryView",
"MergeAdvanceNotice",
@@ -174,6 +175,8 @@ const qualityAppComponentTests = [
"TrackingRepoSelect",
"WorkflowNodeEditor",
"WorkflowResultsTab",
"WorkflowSelector",
"workflow-flow-mapping",
"WorktrunkInstallApprovalDetails",
] as const;
@@ -187,7 +190,7 @@ const batchedQualityAppComponentTestsB = batchedQualityAppComponentTests.slice(b
function buildComponentQualityInclude(testNames: readonly string[]): string[] {
return testNames.length > 0
? [`app/components/__tests__/{${testNames.join(",")}}.test.tsx`]
? [`app/components/__tests__/{${testNames.join(",")}}.test.{ts,tsx}`]
: [];
}

View File

@@ -41,6 +41,7 @@ import {
resolveColumnCapacity,
resolveColumnFlags,
resolveColumnAdjacency,
DEFAULT_WORKFLOW_POOL_ID,
TransitionRejectionError,
BUILTIN_CODING_WORKFLOW_IR,
getBuiltinWorkflow,
@@ -54,8 +55,6 @@ import {
} from "@fusion/core";
import { schedulerLog } from "./logger.js";
const DEFAULT_WORKFLOW_POOL_ID = "__default-workflow__";
/** A reservation handle returned by {@link HoldReleaseDeps.reserveSlot}. The
* sweep calls `release()` if the subsequent move rejects on capacity. */
export interface SlotReservation {
@@ -95,7 +94,13 @@ export interface HoldReleaseResult {
// ── Workflow IR resolution (read-only, mirrors store + merge-trait) ───────────
async function resolveTaskWorkflowIr(store: TaskStore, taskId: string): Promise<WorkflowIr> {
async function resolveTaskWorkflowIr(
store: TaskStore,
taskId: string,
// Optional per-sweep cache keyed by workflowId so each distinct workflow's IR
// is resolved (and its definition fetched) at most once per sweep.
irCache?: Map<string, WorkflowIr>,
): Promise<WorkflowIr> {
let workflowId: string | undefined;
try {
workflowId = store.getTaskWorkflowSelection(taskId)?.workflowId;
@@ -103,14 +108,20 @@ async function resolveTaskWorkflowIr(store: TaskStore, taskId: string): Promise<
workflowId = undefined;
}
if (!workflowId) return BUILTIN_CODING_WORKFLOW_IR;
const cached = irCache?.get(workflowId);
if (cached) return cached;
if (isBuiltinWorkflowId(workflowId)) {
const builtin = getBuiltinWorkflow(workflowId);
return builtin?.ir ?? BUILTIN_CODING_WORKFLOW_IR;
const ir = builtin?.ir ?? BUILTIN_CODING_WORKFLOW_IR;
irCache?.set(workflowId, ir);
return ir;
}
try {
const def = await store.getWorkflowDefinition(workflowId);
if (!def) return BUILTIN_CODING_WORKFLOW_IR;
return typeof def.ir === "string" ? parseWorkflowIr(def.ir) : def.ir;
const ir = typeof def.ir === "string" ? parseWorkflowIr(def.ir) : def.ir;
irCache?.set(workflowId, ir);
return ir;
} catch {
return BUILTIN_CODING_WORKFLOW_IR;
}
@@ -287,15 +298,17 @@ function resolveTimerDeadline(holdConfig: Record<string, unknown>, task: Task):
* arbitration is still the in-txn check, which rejects a losing racer.
*/
function countCapacitySlot(
store: TaskStore,
allTasks: Task[],
// Pre-built taskId → effective workflowId map (one pass per sweep) so this
// counting loop avoids a per-task `effectiveWorkflowId` DB call.
effectiveWorkflowIdByTask: Map<string, string>,
targetColumn: string,
workflowId: string,
countPending: boolean,
): number {
let count = 0;
for (const t of allTasks) {
if (effectiveWorkflowId(store, t.id) !== workflowId) continue;
if ((effectiveWorkflowIdByTask.get(t.id) ?? DEFAULT_WORKFLOW_POOL_ID) !== workflowId) continue;
if (t.column === targetColumn) {
count += 1;
continue;
@@ -325,6 +338,17 @@ export async function runHoldReleaseSweep(
const allTasks = await store.listTasks({ includeArchived: false });
// Per-sweep caches. `allTasks` is a snapshot-stable read within a sweep, so we
// resolve each workflow's IR at most once (irCache) and pre-build the
// taskId → effective-workflowId map a single time rather than per-task DB
// calls inside the capacity counting loop. The authoritative in-txn capacity
// check is unaffected — this only trims the sweep pre-check cost.
const irCache = new Map<string, WorkflowIr>();
const effectiveWorkflowIdByTask = new Map<string, string>();
for (const t of allTasks) {
effectiveWorkflowIdByTask.set(t.id, effectiveWorkflowId(store, t.id));
}
for (const task of allTasks) {
// Skip paused / recovery-backoff tasks exactly as the legacy scheduler does.
if (task.paused || task.userPaused) {
@@ -334,7 +358,7 @@ export async function runHoldReleaseSweep(
continue;
}
const ir = await resolveTaskWorkflowIr(store, task.id);
const ir = await resolveTaskWorkflowIr(store, task.id, irCache);
if (!isHeldTask(ir, task)) continue;
const column = findColumn(ir, task.column);
@@ -372,8 +396,8 @@ export async function runHoldReleaseSweep(
}
const capacity = resolveColumnCapacity(ir, target, settings);
if (capacity.hasCapacity && Number.isFinite(capacity.limit)) {
const workflowId = effectiveWorkflowId(store, task.id);
const occupants = countCapacitySlot(store, allTasks, target, workflowId, capacity.countPending);
const workflowId = effectiveWorkflowIdByTask.get(task.id) ?? DEFAULT_WORKFLOW_POOL_ID;
const occupants = countCapacitySlot(allTasks, effectiveWorkflowIdByTask, target, workflowId, capacity.countPending);
if (occupants >= capacity.limit) {
result.held.push({ taskId: task.id, reason: "downstream-full" });
continue;

View File

@@ -51,6 +51,7 @@ import {
type WorkflowIr,
type WorkflowIrColumn,
} from "@fusion/core";
import { mergerLog } from "./logger.js";
// ── Resolved merge policy ────────────────────────────────────────────────────
@@ -242,7 +243,7 @@ async function mergeOnEnter(store: TaskStore, task: Pick<Task, "id" | "priority"
// the card is never stranded and the queue is never corrupted. The store
// already audits the rejection.
const message = err instanceof Error ? err.message : String(err);
void message;
mergerLog.warn(`merge enqueue skipped for task ${task.id}: ${message}`);
}
}

View File

@@ -32,7 +32,7 @@ import type { AutoClaimSnapshotManager } from "./auto-claim-snapshot.js";
import { StaleTaskReporter } from "./stale-task-reporter.js";
import { BacklogPressureReporter } from "./backlog-pressure-reporter.js";
import { createRunAuditor, generateSyntheticRunId } from "./run-audit.js";
import { isWorkflowColumnsEnabled } from "@fusion/core";
import { isWorkflowColumnsEnabled, DEFAULT_WORKFLOW_POOL_ID } from "@fusion/core";
import { runHoldReleaseSweep, type SlotReservation } from "./hold-release.js";
/**
@@ -1251,7 +1251,7 @@ export class Scheduler {
// Additive: omitted flag-OFF so the three-gate report shape is unchanged.
const perColumnGates = isWorkflowColumnsEnabled(settings)
? [{
workflowId: "__default-workflow__",
workflowId: DEFAULT_WORKFLOW_POOL_ID,
columnId: "in-progress",
used: agentSlots,
limit: maxConcurrent,

View File

@@ -2,6 +2,7 @@ import type { Settings, TaskDetail, WorkflowIrEdge, WorkflowIrNode } from "@fusi
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).
@@ -40,6 +41,26 @@ export interface WorkflowBranchPersistence {
loadBranchStates?(taskId: string, runId: string): WorkflowBranchRunState[] | Promise<WorkflowBranchRunState[]>;
}
/**
* 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>;
@@ -251,7 +272,7 @@ async function walkBranch(
} else {
const exec = async (): Promise<WorkflowNodeResult> => env.runBranchNode(node, signal);
lastResult = env.semaphore ? await env.semaphore.run(exec) : await exec();
env.persistence?.saveBranchState?.({
await persistBranchState(env.persistence, {
taskId: env.task.id,
runId: env.runId,
branchId: startNodeId,
@@ -266,7 +287,7 @@ async function walkBranch(
}
if (lastResult.outcome === "failure") {
env.persistence?.saveBranchState?.({
await persistBranchState(env.persistence, {
taskId: env.task.id,
runId: env.runId,
branchId: startNodeId,
@@ -282,7 +303,7 @@ async function walkBranch(
return { outcome: lastResult.outcome, lastNodeId: currentId };
}
if (next === joinId) {
env.persistence?.saveBranchState?.({
await persistBranchState(env.persistence, {
taskId: env.task.id,
runId: env.runId,
branchId: startNodeId,