Files
fusion/packages/core/src/workflow-capacity.ts

131 lines
5.8 KiB
TypeScript

/**
* Workflow capacity resolution (U6, KTD-10, R9 capacity half).
*
* WIP/capacity limits are trait *configuration*; their *enforcement* is a
* substrate capability that runs INSIDE `moveTaskInternal`'s transaction and is
* NEVER bypassable (not a guard — runs regardless of bypassGuards/recoveryRehome
* /moveSource). This module is the pure resolution layer shared by both the
* in-txn check (`store.ts`) and the hold/release sweep (`@fusion/engine`
* `hold-release.ts`): given a workflow IR + a column id + settings it answers
* - does this column have a `wip` (capacity) trait?
* - what is its effective limit (read-through to `settings.maxConcurrent` for
* the default workflow's in-progress column so the legacy knob keeps working
* — U6 scheduler-integration half)?
* - does its config opt into counting mid-`transitionPending` cards?
*
* It performs NO DB access and NO counting — the caller owns the count (the
* store counts in-txn; the sweep counts from a listTasks snapshot). Keeping the
* resolution pure means the two enforcement points can never disagree on what a
* limit *is*, only on the live count, which is exactly the serialization the
* in-txn check arbitrates (two holds, one slot → one wins).
*/
import type { Settings } from "./types.js";
import type { WorkflowIr, WorkflowIrV2, WorkflowIrColumn } from "./workflow-ir-types.js";
import { DEFAULT_WORKFLOW_COLUMN_IDS } from "./workflow-ir.js";
import { getTraitRegistry } from "./trait-registry.js";
/** The default-workflow column whose WIP limit read-through is
* `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. */
hasCapacity: boolean;
/** The effective max concurrent cards. `Infinity` means "no finite limit"
* (a capacity trait with no resolvable limit does not gate). */
limit: number;
/** Whether mid-`transitionPending` cards (holding their destination slot from
* commit time) count toward the limit. Defaults true: a card that has
* committed its move into the column holds the slot even before its
* post-commit hooks finish (KTD-10). */
countPending: boolean;
}
const NO_CAPACITY: ColumnCapacity = { hasCapacity: false, limit: Infinity, countPending: true };
function findColumn(ir: WorkflowIr, columnId: string): WorkflowIrColumn | undefined {
const v2 = ir as WorkflowIrV2;
if (!Array.isArray(v2.columns)) return undefined;
return v2.columns.find((c) => c.id === columnId);
}
/** True when the IR's column set is exactly the default-workflow column ids. */
function isDefaultWorkflowColumns(ir: WorkflowIr): boolean {
const v2 = ir as WorkflowIrV2;
if (!Array.isArray(v2.columns)) return false;
const ids = v2.columns.map((c) => c.id);
if (ids.length !== DEFAULT_WORKFLOW_COLUMN_IDS.length) return false;
const set = new Set(ids);
return DEFAULT_WORKFLOW_COLUMN_IDS.every((id) => set.has(id));
}
/**
* Resolve the capacity configuration for `columnId` under `ir`.
*
* Limit resolution order:
* 1. An explicit numeric `limit` in the column's `wip` trait config wins.
* 2. A `limitSetting: "maxConcurrent"` declaration reads through to the
* project setting, making the built-in workflow's capacity policy explicit.
* 3. Otherwise, for the DEFAULT workflow's `in-progress` column, read through
* to `settings.maxConcurrent` (default 2) so the legacy knob keeps working
* and flag-ON default-workflow scheduling matches flag-OFF (legacy parity).
* 4. Otherwise the column has a capacity trait but no resolvable finite limit
* → `Infinity` (does not gate; the trait is inert until configured).
*/
export function resolveColumnCapacity(
ir: WorkflowIr,
columnId: string,
settings?: Pick<Settings, "maxConcurrent"> | undefined,
): ColumnCapacity {
const column = findColumn(ir, columnId);
if (!column) return NO_CAPACITY;
const flags = getTraitRegistry().resolveColumnFlags(column);
if (!flags.countsTowardWip) return NO_CAPACITY;
// The capacity trait config (the `wip` trait carries `limit` + `countPending`).
// Find the first trait config whose trait sets countsTowardWip.
let configLimit: number | undefined;
let limitSetting: string | undefined;
let countPending = true;
for (const ct of column.traits) {
const def = getTraitRegistry().getTrait(ct.trait);
if (!def?.flags.countsTowardWip) continue;
const cfg = ct.config ?? {};
if (typeof cfg.limit === "number" && Number.isFinite(cfg.limit)) {
configLimit = cfg.limit;
}
if (typeof cfg.limitSetting === "string") {
limitSetting = cfg.limitSetting;
}
if (typeof cfg.countPending === "boolean") {
countPending = cfg.countPending;
}
break;
}
let limit: number;
if (configLimit !== undefined) {
limit = configLimit;
} else if (limitSetting === "maxConcurrent") {
const maxConcurrent = settings?.maxConcurrent;
limit = typeof maxConcurrent === "number" && Number.isFinite(maxConcurrent) ? maxConcurrent : 2;
} else if (columnId === DEFAULT_WIP_COLUMN_ID && isDefaultWorkflowColumns(ir)) {
// Read-through: legacy maxConcurrent maps onto the default workflow's
// in-progress WIP limit (U6 scheduler integration).
const maxConcurrent = settings?.maxConcurrent;
limit = typeof maxConcurrent === "number" && Number.isFinite(maxConcurrent) ? maxConcurrent : 2;
} else {
limit = Infinity;
}
return { hasCapacity: true, limit, countPending };
}