feat(core): column agent IR schema, validation, and effective-agent resolver

U1+U2 of the column-agent plan: WorkflowColumnAgent on WorkflowIrColumn
(defer/override), template-subgraph column validation, v2-only-feature
registration, plugin-sdk type parity, and the shared core resolver with
instanceNodeId format ownership moved to core.
This commit is contained in:
gsxdsm
2026-06-04 23:35:32 -07:00
parent f5f09c6e37
commit e421774f6a
8 changed files with 750 additions and 8 deletions

View File

@@ -0,0 +1,258 @@
// @vitest-environment node
//
// column-agent plan U2 — the shared effective-agent resolver.
//
// Proves the full mode × own-settings matrix (KTD-2/KTD-5):
// - override × own-settings present → column agent; override × bare → column.
// - defer × own agentId → own; defer × complete model pair → own;
// defer × lone provider (incomplete pair, no agentId) → column agent wins.
// - no node.column / column without binding → own-settings or none.
// - foreach instance inheritance + template-node own column wins.
// - parseInstanceNodeId round-trip incl. templateNodeId containing ':'.
// - two graphs differing only in binding diverge.
import { describe, expect, it } from "vitest";
import {
instanceNodeId,
parseInstanceNodeId,
resolveColumnAgentBinding,
resolveEffectiveAgent,
} from "../column-agent-resolver.js";
import type {
WorkflowColumnAgent,
WorkflowIrEdge,
WorkflowIrNode,
WorkflowIrV2,
} from "../workflow-ir-types.js";
function v2(
columns: WorkflowIrV2["columns"],
nodes: WorkflowIrNode[],
edges: WorkflowIrEdge[] = [],
): WorkflowIrV2 {
return { version: "v2", name: "test", columns, nodes, edges };
}
const overrideBinding: WorkflowColumnAgent = { agentId: "col-agent", mode: "override" };
const deferBinding: WorkflowColumnAgent = { agentId: "col-agent", mode: "defer" };
describe("resolveEffectiveAgent — precedence matrix (U2)", () => {
it("override × own settings present → column agent", () => {
expect(
resolveEffectiveAgent({
binding: overrideBinding,
ownAgentId: "own-agent",
ownModelProvider: "anthropic",
ownModelId: "claude-x",
}),
).toEqual({ source: "column-agent", agentId: "col-agent" });
});
it("override × bare → column agent", () => {
expect(resolveEffectiveAgent({ binding: overrideBinding })).toEqual({
source: "column-agent",
agentId: "col-agent",
});
});
it("defer × own agentId only → own settings win", () => {
expect(resolveEffectiveAgent({ binding: deferBinding, ownAgentId: "own-agent" })).toEqual({
source: "own-settings",
});
});
it("defer × complete own model pair only → own settings win", () => {
expect(
resolveEffectiveAgent({
binding: deferBinding,
ownModelProvider: "anthropic",
ownModelId: "claude-x",
}),
).toEqual({ source: "own-settings" });
});
it("defer × lone provider (incomplete pair, no agentId) → column agent wins", () => {
// An incomplete pair does NOT count as own settings (KTD-5; matches
// resolveExecutorSessionModel's both-present rule).
expect(
resolveEffectiveAgent({ binding: deferBinding, ownModelProvider: "anthropic" }),
).toEqual({ source: "column-agent", agentId: "col-agent" });
});
it("defer × bare → column agent wins", () => {
expect(resolveEffectiveAgent({ binding: deferBinding })).toEqual({
source: "column-agent",
agentId: "col-agent",
});
});
it("no binding × own settings → own-settings", () => {
expect(resolveEffectiveAgent({ binding: undefined, ownAgentId: "own-agent" })).toEqual({
source: "own-settings",
});
});
it("no binding × bare → none", () => {
expect(resolveEffectiveAgent({ binding: undefined })).toEqual({ source: "none" });
});
});
describe("resolveColumnAgentBinding — lookup (U2)", () => {
const ir = v2(
[
{ id: "todo", name: "todo", traits: [] },
{ id: "review", name: "review", traits: [], agent: overrideBinding },
],
[
{ id: "start", kind: "start", column: "todo" },
{ id: "work", kind: "prompt", column: "review", config: { prompt: "do" } },
{ id: "plain", kind: "prompt", column: "todo", config: { prompt: "do" } },
{ id: "nocol", kind: "prompt", config: { prompt: "do" } },
{ id: "end", kind: "end", column: "review" },
],
);
it("resolves the bound column's agent for a node declared in it", () => {
expect(resolveColumnAgentBinding(ir, "work")).toEqual(overrideBinding);
});
it("returns undefined for a node in a column without a binding", () => {
expect(resolveColumnAgentBinding(ir, "plain")).toBeUndefined();
});
it("returns undefined for a node with no declared column, even when other columns bind", () => {
expect(resolveColumnAgentBinding(ir, "nocol")).toBeUndefined();
});
it("returns undefined for an unknown node id", () => {
expect(resolveColumnAgentBinding(ir, "ghost")).toBeUndefined();
});
});
describe("resolveColumnAgentBinding — foreach instance inheritance (U2)", () => {
function foreachIr(opts: {
foreachColumn?: string;
templateNodeColumn?: string;
reviewAgent?: WorkflowColumnAgent;
todoAgent?: WorkflowColumnAgent;
}): WorkflowIrV2 {
return v2(
[
{ id: "todo", name: "todo", traits: [], ...(opts.todoAgent ? { agent: opts.todoAgent } : {}) },
{ id: "review", name: "review", traits: [], ...(opts.reviewAgent ? { agent: opts.reviewAgent } : {}) },
],
[
{ id: "start", kind: "start" },
{
id: "fe",
kind: "foreach",
...(opts.foreachColumn ? { column: opts.foreachColumn } : {}),
config: {
source: "task-steps",
template: {
nodes: [
{
id: "se",
kind: "prompt",
...(opts.templateNodeColumn ? { column: opts.templateNodeColumn } : {}),
config: { seam: "step-execute" },
},
{ id: "rev", kind: "step-review", config: { type: "code" } },
{ id: "exit", kind: "prompt" },
],
edges: [],
},
},
},
{ id: "end", kind: "end" },
],
);
}
it("instance node inherits the enclosing foreach node's column binding", () => {
const ir = foreachIr({ foreachColumn: "review", reviewAgent: overrideBinding });
const nodeId = instanceNodeId("fe", 0, "se");
expect(resolveColumnAgentBinding(ir, nodeId)).toEqual(overrideBinding);
});
it("template node's own declared column wins over inheritance", () => {
const ir = foreachIr({
foreachColumn: "review",
reviewAgent: overrideBinding,
templateNodeColumn: "todo",
todoAgent: deferBinding,
});
const nodeId = instanceNodeId("fe", 1, "se");
expect(resolveColumnAgentBinding(ir, nodeId)).toEqual(deferBinding);
});
it("instance node with no foreach column and no template column → no binding", () => {
const ir = foreachIr({ reviewAgent: overrideBinding });
const nodeId = instanceNodeId("fe", 0, "se");
expect(resolveColumnAgentBinding(ir, nodeId)).toBeUndefined();
});
});
describe("instanceNodeId / parseInstanceNodeId round-trip (U2)", () => {
it("round-trips a simple instance id", () => {
const id = instanceNodeId("fe", 3, "se");
expect(id).toBe("fe#3:se");
expect(parseInstanceNodeId(id)).toEqual({
foreachNodeId: "fe",
stepIndex: 3,
templateNodeId: "se",
});
});
it("round-trips when the templateNodeId itself contains ':'", () => {
// Defensive: split on the FIRST ':' of the remainder, keep the rest.
const id = instanceNodeId("fe", 2, "ns:inner:node");
expect(id).toBe("fe#2:ns:inner:node");
expect(parseInstanceNodeId(id)).toEqual({
foreachNodeId: "fe",
stepIndex: 2,
templateNodeId: "ns:inner:node",
});
});
it("returns undefined for non-instance ids", () => {
expect(parseInstanceNodeId("plain")).toBeUndefined();
expect(parseInstanceNodeId("fe#3")).toBeUndefined();
expect(parseInstanceNodeId("fe#:se")).toBeUndefined();
expect(parseInstanceNodeId("fe#x:se")).toBeUndefined();
});
});
describe("two graphs differing only in binding diverge (U2)", () => {
function graph(reviewAgent?: WorkflowColumnAgent): WorkflowIrV2 {
return v2(
[
{ id: "todo", name: "todo", traits: [] },
{ id: "review", name: "review", traits: [], ...(reviewAgent ? { agent: reviewAgent } : {}) },
],
[
{ id: "start", kind: "start", column: "todo" },
{ id: "work", kind: "prompt", column: "review", config: { prompt: "do" } },
{ id: "end", kind: "end", column: "review" },
],
);
}
it("the effective agent diverges when only the binding differs", () => {
const bound = graph(overrideBinding);
const unbound = graph();
// Same node, same own settings, different graph binding → different verdict.
const own = { ownAgentId: "task-agent" } as const;
const boundResult = resolveEffectiveAgent({
binding: resolveColumnAgentBinding(bound, "work"),
...own,
});
const unboundResult = resolveEffectiveAgent({
binding: resolveColumnAgentBinding(unbound, "work"),
...own,
});
expect(boundResult).toEqual({ source: "column-agent", agentId: "col-agent" });
expect(unboundResult).toEqual({ source: "own-settings" });
expect(boundResult).not.toEqual(unboundResult);
});
});

View File

@@ -0,0 +1,224 @@
// @vitest-environment node
//
// column-agent plan U1 — IR schema, validation, and parity registration for the
// per-column permanent-agent binding (`WorkflowIrColumn.agent`).
//
// Proves:
// - a column `agent` binding parses + round-trips; absent field parses as today.
// - typed validation errors for empty agentId / missing mode / unknown mode.
// - v1 upgrade synthesizes columns with NO `agent` field (absent, not null).
// - a template-subgraph node with a dangling `column` is a typed error.
// - the default workflow IR round-trips byte-identically; a graph carrying a
// column agent is flagged non-default (forces v2 — KTD-1/R9).
// - a removed binding omits the `agent` key entirely on serialization.
import { describe, expect, it } from "vitest";
import {
parseWorkflowIr,
serializeWorkflowIr,
downgradeIrToV1IfPure,
WorkflowIrError,
} from "../workflow-ir.js";
import { BUILTIN_CODING_WORKFLOW_IR } from "../builtin-coding-workflow-ir.js";
import type {
WorkflowColumnAgent,
WorkflowIrEdge,
WorkflowIrNode,
WorkflowIrV1,
WorkflowIrV2,
} from "../workflow-ir-types.js";
const baseColumns: WorkflowIrV2["columns"] = [
{ id: "todo", name: "todo", traits: [] },
{ id: "review", name: "review", traits: [] },
];
function v2(
columns: WorkflowIrV2["columns"],
nodes: WorkflowIrNode[],
edges: WorkflowIrEdge[],
extra: Partial<WorkflowIrV2> = {},
): WorkflowIrV2 {
return { version: "v2", name: "test", columns, nodes, edges, ...extra };
}
/** start → work → end, work in the second column. */
function simpleGraph(reviewAgent?: WorkflowColumnAgent): WorkflowIrV2 {
const columns: WorkflowIrV2["columns"] = [
{ id: "todo", name: "todo", traits: [] },
{ id: "review", name: "review", traits: [], ...(reviewAgent ? { agent: reviewAgent } : {}) },
];
return v2(
columns,
[
{ id: "start", kind: "start", column: "todo" },
{ id: "work", kind: "prompt", column: "review", config: { prompt: "do" } },
{ id: "end", kind: "end", column: "review" },
],
[
{ from: "start", to: "work" },
{ from: "work", to: "end" },
],
);
}
describe("column-agent IR schema + validation (U1)", () => {
it("parses and round-trips a column with a defer agent binding", () => {
const ir = simpleGraph({ agentId: "agent-001", mode: "defer" });
const parsed = parseWorkflowIr(serializeWorkflowIr(ir)) as WorkflowIrV2;
const col = parsed.columns.find((c) => c.id === "review")!;
expect(col.agent).toEqual({ agentId: "agent-001", mode: "defer" });
});
it("parses identically to today when no agent field is present", () => {
const ir = simpleGraph();
const parsed = parseWorkflowIr(serializeWorkflowIr(ir)) as WorkflowIrV2;
const col = parsed.columns.find((c) => c.id === "review")!;
expect("agent" in col).toBe(false);
});
it("rejects an empty agentId (typed error naming the column)", () => {
const ir = simpleGraph({ agentId: "", mode: "defer" });
expect(() => parseWorkflowIr(ir)).toThrow(WorkflowIrError);
expect(() => parseWorkflowIr(ir)).toThrow(/column 'review'.*non-empty agentId/);
});
it("rejects a missing mode", () => {
const ir = simpleGraph({ agentId: "agent-001" } as unknown as WorkflowColumnAgent);
expect(() => parseWorkflowIr(ir)).toThrow(/column 'review'.*mode must be/);
});
it("rejects an unknown mode value", () => {
const ir = simpleGraph({ agentId: "agent-001", mode: "always" as "defer" });
expect(() => parseWorkflowIr(ir)).toThrow(/column 'review'.*mode must be/);
});
it("v1 upgrade synthesizes columns with no agent field (absent, not null)", () => {
const v1: WorkflowIrV1 = {
version: "v1",
name: "legacy",
nodes: [
{ id: "start", kind: "start" },
{ id: "p", kind: "prompt", config: { prompt: "hi" } },
{ id: "end", kind: "end" },
],
edges: [
{ from: "start", to: "p" },
{ from: "p", to: "end" },
],
};
const upgraded = parseWorkflowIr(v1) as WorkflowIrV2;
for (const col of upgraded.columns) {
expect("agent" in col).toBe(false);
}
// And serialization carries no `agent` key at all.
expect(serializeWorkflowIr(upgraded)).not.toContain('"agent"');
});
it("rejects a foreach template node whose column does not resolve (typed, names node)", () => {
const ir = v2(
baseColumns,
[
{ id: "start", kind: "start" },
{
id: "ps",
kind: "parse-steps",
config: { artifact: "PROMPT.md", parser: "step-headings" },
},
{
id: "fe",
kind: "foreach",
config: {
source: "task-steps",
template: {
nodes: [
// Dangling column reference on a template node.
{ id: "se", kind: "prompt", column: "nope", config: { seam: "step-execute" } },
{ id: "rev", kind: "step-review", config: { type: "code" } },
{ id: "exit", kind: "prompt" },
],
edges: [
{ from: "se", to: "rev" },
{ from: "rev", to: "exit", condition: "outcome:approve" },
{ from: "rev", to: "se", condition: "outcome:revise", kind: "rework" },
],
},
},
},
{ id: "end", kind: "end" },
],
[
{ from: "start", to: "ps" },
{ from: "ps", to: "fe" },
{ from: "fe", to: "end" },
],
);
expect(() => parseWorkflowIr(ir)).toThrow(/node 'se' references undefined column 'nope'/);
});
it("accepts a foreach template node whose column resolves to a declared column", () => {
const ir = v2(
baseColumns,
[
{ id: "start", kind: "start" },
{
id: "ps",
kind: "parse-steps",
config: { artifact: "PROMPT.md", parser: "step-headings" },
},
{
id: "fe",
kind: "foreach",
column: "review",
config: {
source: "task-steps",
template: {
nodes: [
{ id: "se", kind: "prompt", column: "todo", config: { seam: "step-execute" } },
{ id: "rev", kind: "step-review", config: { type: "code" } },
{ id: "exit", kind: "prompt" },
],
edges: [
{ from: "se", to: "rev" },
{ from: "rev", to: "exit", condition: "outcome:approve" },
{ from: "rev", to: "se", condition: "outcome:revise", kind: "rework" },
],
},
},
},
{ id: "end", kind: "end" },
],
[
{ from: "start", to: "ps" },
{ from: "ps", to: "fe" },
{ from: "fe", to: "end" },
],
);
expect(() => parseWorkflowIr(ir)).not.toThrow();
});
});
describe("column-agent parity registration (U1, R9)", () => {
it("default workflow IR round-trips byte-identically", () => {
const serialized = serializeWorkflowIr(BUILTIN_CODING_WORKFLOW_IR);
const reparsed = parseWorkflowIr(serialized);
expect(serializeWorkflowIr(reparsed)).toBe(serialized);
});
it("a graph carrying a column agent is flagged non-default (forces v2)", () => {
// A pure default-shaped graph downgrades to v1; adding an agent binding must
// keep it v2 (the v2-only-feature gate registers the field).
const bound = simpleGraph({ agentId: "agent-001", mode: "override" });
expect(downgradeIrToV1IfPure(bound).version).toBe("v2");
});
it("serialization of a column whose binding was removed omits the key entirely", () => {
const bound = simpleGraph({ agentId: "agent-001", mode: "defer" });
const col = bound.columns.find((c) => c.id === "review")!;
delete col.agent;
const serialized = serializeWorkflowIr(bound);
expect(serialized).not.toContain('"agent"');
const reparsed = parseWorkflowIr(serialized) as WorkflowIrV2;
expect("agent" in reparsed.columns.find((c) => c.id === "review")!).toBe(false);
});
});

View File

@@ -0,0 +1,186 @@
/**
* Column-agent effective resolution (column-agent plan KTD-2).
*
* One shared resolver in `@fusion/core` consumed by every reader (the three engine
* resolution sites and the dashboard write-validation route) so engine and route
* can never drift — the route/engine predicate-drift learning
* (`docs/solutions/integration-issues/branch-group-single-pr-synthetic-id-dead-wiring.md`).
*
* Two pure functions:
* - `resolveColumnAgentBinding(ir, nodeId)` — declared-column lookup with foreach
* template inheritance — answers "which column binding (if any) governs this
* node's work?".
* - `resolveEffectiveAgent(...)` — defer/override precedence as EXPLICIT named
* branches (never a `??` effective-value collapse), per the per-task
* auto-merge-override learning
* (`docs/solutions/logic-errors/per-task-auto-merge-override-ignored-by-trigger-gates.md`).
* Returns a discriminated result so callers and audit logs can state *why* an
* agent was chosen.
*
* This module must stay DI-clean: `@fusion/core` never imports from `@fusion/engine`.
*/
import type { WorkflowColumnAgent, WorkflowForeachConfig, WorkflowIr } from "./workflow-ir-types.js";
// ── Foreach instance node-id ownership (column-agent plan KTD-2) ──────────────
// The instance-id FORMAT (`<foreachId>#<stepIndex>:<templateNodeId>`) now has
// exactly one owner here in core. The engine re-points its import (was
// `workflow-graph-foreach.ts`). The format itself is unchanged.
/** Materialize a deterministic foreach instance node id (step-inversion KTD-3).
* Pure, no IR mutation. Format: `<foreachId>#<stepIndex>:<templateNodeId>`. */
export function instanceNodeId(
foreachNodeId: string,
stepIndex: number,
templateNodeId: string,
): string {
return `${foreachNodeId}#${stepIndex}:${templateNodeId}`;
}
/** Parsed components of a foreach instance node id. */
export interface ParsedInstanceNodeId {
foreachNodeId: string;
stepIndex: number;
templateNodeId: string;
}
/** Parse a foreach instance node id back into its components, or `undefined` when
* `nodeId` is not in instance form. Defensive against `templateNodeId` itself
* containing `:` — split on the FIRST `#`, then the FIRST `:` of the remainder,
* and keep everything after that as the template node id. The `templateNodeId` is
* not sanitized against `:`, so a greedy/last-delimiter split would corrupt it. */
export function parseInstanceNodeId(nodeId: string): ParsedInstanceNodeId | undefined {
const hashIndex = nodeId.indexOf("#");
if (hashIndex < 0) return undefined;
const foreachNodeId = nodeId.slice(0, hashIndex);
const remainder = nodeId.slice(hashIndex + 1);
const colonIndex = remainder.indexOf(":");
if (colonIndex < 0) return undefined;
const stepIndexRaw = remainder.slice(0, colonIndex);
const templateNodeId = remainder.slice(colonIndex + 1);
if (foreachNodeId === "" || templateNodeId === "") return undefined;
// stepIndex must be a non-negative integer; reject anything else as non-instance.
if (!/^\d+$/.test(stepIndexRaw)) return undefined;
const stepIndex = Number(stepIndexRaw);
return { foreachNodeId, stepIndex, templateNodeId };
}
// ── Binding lookup ───────────────────────────────────────────────────────────
/** Index a graph's top-level nodes by id (handles v1 + v2 shapes). */
function topLevelNodesById(ir: WorkflowIr): Map<string, WorkflowIr["nodes"][number]> {
return new Map(ir.nodes.map((n) => [n.id, n]));
}
/** Resolve the agent binding (if any) that governs the work of `nodeId`.
*
* A column WITHOUT an `agent` field yields `undefined` — that, not "column
* undeclared," is the operative guarantee, since v1→v2 upgrade synthesizes a
* column for every node (column-agent plan KTD-2).
*
* Foreach instance ids (`<foreachId>#<i>:<templateNodeId>`) resolve against the
* ENCLOSING foreach node's column, but a template node that declares its OWN
* `column` wins over inheritance (R4). */
export function resolveColumnAgentBinding(
ir: WorkflowIr,
nodeId: string,
): WorkflowColumnAgent | undefined {
// v1 graphs have no columns and therefore no bindings. (Callers normally parse
// to v2 first, but stay defensive.)
if (ir.version !== "v2") return undefined;
const columnsById = new Map(ir.columns.map((c) => [c.id, c]));
const bindingForColumn = (columnId: string | undefined): WorkflowColumnAgent | undefined => {
if (columnId === undefined) return undefined;
return columnsById.get(columnId)?.agent;
};
const nodesById = topLevelNodesById(ir);
// Direct (top-level) node.
const direct = nodesById.get(nodeId);
if (direct) {
return bindingForColumn(direct.column);
}
// Foreach instance node: resolve against the enclosing foreach, honoring a
// template node's own declared column.
const parsed = parseInstanceNodeId(nodeId);
if (!parsed) return undefined;
const foreachNode = nodesById.get(parsed.foreachNodeId);
if (!foreachNode || foreachNode.kind !== "foreach") return undefined;
const cfg = foreachNode.config as Partial<WorkflowForeachConfig> | undefined;
const templateNodes = cfg?.template?.nodes ?? [];
const templateNode = templateNodes.find((n) => n.id === parsed.templateNodeId);
// Template node's own column wins; otherwise inherit the foreach node's column.
if (templateNode?.column !== undefined) {
return bindingForColumn(templateNode.column);
}
return bindingForColumn(foreachNode.column);
}
// ── Effective-agent precedence (defer / override) ────────────────────────────
/** Inputs to the effective-agent decision. `ownAgentId` is the work's own agent
* identity (node `cfg.agentId` or `task.assignedAgentId`); `ownModelProvider` /
* `ownModelId` are the work's own model pair (node cfg or task model fields). */
export interface EffectiveAgentInput {
/** The binding governing this node, from `resolveColumnAgentBinding`. */
binding: WorkflowColumnAgent | undefined;
/** The work's own agent identity, if any. */
ownAgentId?: string;
/** The work's own model provider, if any. */
ownModelProvider?: string;
/** The work's own model id, if any. */
ownModelId?: string;
}
/** Discriminated result of effective-agent resolution: callers and audit logs can
* state *why* an agent was (or was not) chosen (column-agent plan KTD-2). */
export type EffectiveAgentResult =
| { source: "column-agent"; agentId: string }
| { source: "own-settings" }
| { source: "none" };
/** Does the work carry "own settings" that suppress a `defer` column agent
* (column-agent plan KTD-5)? All-or-nothing: an own agent identity OR a COMPLETE
* modelProvider+modelId pair counts. A lone provider with no modelId and no
* agentId does NOT count — matching `resolveExecutorSessionModel`'s both-present
* rule (`packages/engine/src/agent-session-helpers.ts:147-150`). */
function hasOwnSettings(input: EffectiveAgentInput): boolean {
const hasOwnAgent = typeof input.ownAgentId === "string" && input.ownAgentId !== "";
const hasCompletePair =
typeof input.ownModelProvider === "string" &&
input.ownModelProvider !== "" &&
typeof input.ownModelId === "string" &&
input.ownModelId !== "";
return hasOwnAgent || hasCompletePair;
}
/** Decide the effective agent for a node's work using the two EXPLICIT named rules
* (column-agent plan KTD-2/KTD-5):
* - No binding → `own-settings` if the work has any, else `none`.
* - `override` → the column agent ALWAYS (identity + model + persona).
* - `defer` → the column agent ONLY when the work has no own settings; otherwise
* own settings win.
* No `??` collapse: each branch is named so audit can explain the choice. */
export function resolveEffectiveAgent(input: EffectiveAgentInput): EffectiveAgentResult {
const { binding } = input;
if (!binding) {
return hasOwnSettings(input) ? { source: "own-settings" } : { source: "none" };
}
if (binding.mode === "override") {
return { source: "column-agent", agentId: binding.agentId };
}
// mode === "defer": column agent only when the work carries no own settings.
if (hasOwnSettings(input)) {
return { source: "own-settings" };
}
return { source: "column-agent", agentId: binding.agentId };
}

View File

@@ -60,6 +60,7 @@ export type {
WorkflowIrNodeKind,
WorkflowIrColumn,
WorkflowIrColumnTrait,
WorkflowColumnAgent,
WorkflowHoldRelease,
WorkflowJoinMode,
WorkflowJoinBranchFailure,
@@ -71,6 +72,17 @@ export type {
WorkflowFieldOption,
WorkflowFieldRender,
} from "./workflow-ir-types.js";
export {
instanceNodeId,
parseInstanceNodeId,
resolveColumnAgentBinding,
resolveEffectiveAgent,
} from "./column-agent-resolver.js";
export type {
ParsedInstanceNodeId,
EffectiveAgentInput,
EffectiveAgentResult,
} from "./column-agent-resolver.js";
export { BUILTIN_CODING_WORKFLOW_IR } from "./builtin-coding-workflow-ir.js";
export { BUILTIN_STEPWISE_CODING_WORKFLOW_IR } from "./builtin-stepwise-coding-workflow-ir.js";

View File

@@ -105,11 +105,32 @@ export interface WorkflowIrColumnTrait {
config?: Record<string, unknown>;
}
/** Per-column permanent-agent binding (column-agent plan KTD-1). A column may name
* one agent from the registry plus a mode that decides precedence against
* node-level / task-level agent and model settings:
* - `defer`: the column agent applies only when the work carries no own settings
* (no agent identity and no complete modelProvider+modelId pair — KTD-5).
* - `override`: the column agent supersedes node/task settings wholesale.
* This is execution identity (consumed by the executor's session-building paths),
* not a board-transition trait — hence a first-class typed field, not a trait
* config blob (KTD-1). Agent *existence* is not an IR concern (no agent store at
* this layer); it is enforced at write time (route) and falls back at read time. */
export interface WorkflowColumnAgent {
/** Registry agent id that staffs the column. Non-empty. */
agentId: string;
/** Precedence mode against node/task settings. */
mode: "defer" | "override";
}
/** A workflow-defined board column. */
export interface WorkflowIrColumn {
id: string;
name: string;
traits: WorkflowIrColumnTrait[];
/** Optional permanent-agent binding (column-agent plan KTD-1). Additive and
* omitted entirely when unset — never serialized as `agent: null` — so legacy
* and default workflows stay byte-identical (R9). */
agent?: WorkflowColumnAgent;
}
/** Release conditions for a `hold` node (KTD-2, R3). */

View File

@@ -271,7 +271,11 @@ function reachableFrom(
* - rework edges legal only when both endpoints are inside this template;
* - step-review verdict routing rules (KTD-4).
*/
function validateForeach(node: WorkflowIrNode, topLevelNodeIds: Set<string>): void {
function validateForeach(
node: WorkflowIrNode,
topLevelNodeIds: Set<string>,
columnIds: Set<string>,
): void {
const cfg = node.config as Partial<WorkflowForeachConfig> | undefined;
if (!cfg || cfg.source !== "task-steps") {
throw new WorkflowIrError(
@@ -341,13 +345,20 @@ function validateForeach(node: WorkflowIrNode, topLevelNodeIds: Set<string>): vo
);
}
// No nested foreach.
// No nested foreach. Also: a template node's declared `column` must resolve to a
// top-level column id (column-agent plan KTD-1) — otherwise a dangling reference
// is a silent no-binding no-op at runtime instead of a typed authoring error.
for (const inner of templateNodes) {
if (inner.kind === "foreach") {
throw new WorkflowIrError(
`foreach node '${node.id}' template may not contain a nested foreach ('${inner.id}')`,
);
}
if (inner.column !== undefined && !columnIds.has(inner.column)) {
throw new WorkflowIrError(
`Workflow node '${inner.id}' references undefined column '${inner.column}'`,
);
}
}
// Edge endpoints must reference template nodes; rework edges must stay intra-template.
@@ -742,6 +753,29 @@ function validateColumns(ir: WorkflowIrV2): void {
if (!Array.isArray(column.traits)) {
throw new WorkflowIrError(`Workflow IR column '${column.id}' traits must be an array`);
}
validateColumnAgent(column);
}
}
/** Validate a column's optional permanent-agent binding (column-agent plan KTD-1).
* Mirrors the `validateFields` early-return shape: absent → no-op; present →
* `agentId` must be a non-empty string and `mode` exactly `defer`/`override`.
* Agent existence is NOT checked here (no agent store at the IR layer). */
function validateColumnAgent(column: WorkflowIrColumn): void {
const agent = column.agent;
if (agent === undefined) return;
if (!agent || typeof agent !== "object") {
throw new WorkflowIrError(`Workflow IR column '${column.id}' agent must be an object`);
}
if (typeof agent.agentId !== "string" || agent.agentId === "") {
throw new WorkflowIrError(
`Workflow IR column '${column.id}' agent must have a non-empty agentId`,
);
}
if (agent.mode !== "defer" && agent.mode !== "override") {
throw new WorkflowIrError(
`Workflow IR column '${column.id}' agent mode must be 'defer' or 'override' (got '${String(agent.mode)}')`,
);
}
}
@@ -775,7 +809,7 @@ function validateV2(ir: WorkflowIrV2): void {
const topLevelIds = new Set(ir.nodes.map((n) => n.id));
validateStepExecutePlacement(ir.nodes);
for (const node of ir.nodes) {
if (node.kind === "foreach") validateForeach(node, topLevelIds);
if (node.kind === "foreach") validateForeach(node, topLevelIds, columnIds);
}
validateStepReviewRouting(ir.nodes, outgoing, nodesById, false);
validateParseStepsNodes(ir);
@@ -884,6 +918,9 @@ export function downgradeIrToV1IfPure(ir: WorkflowIr): WorkflowIr {
if (col.id !== expectedId || col.name !== expectedId || col.traits.length !== 0) {
return ir;
}
// A permanent-agent binding is a v2-only feature (column-agent plan, R9): a
// graph that staffs a column can never round-trip through a pre-v2 binary.
if (col.agent !== undefined) return ir;
}
// Every node must sit in its default seam-derived column. A node placed

View File

@@ -1,5 +1,5 @@
import type { TaskDetail, TaskStep, WorkflowIrEdge, WorkflowIrNode } from "@fusion/core";
import { WorkflowIrError } from "@fusion/core";
import { WorkflowIrError, instanceNodeId } from "@fusion/core";
import type { WorkflowNodeOutcome, WorkflowNodeResult } from "./workflow-graph-executor.js";
import {
@@ -247,10 +247,10 @@ export interface ForeachRunResult {
visitedNodeIds: string[];
}
/** Materialize a deterministic instance node id (KTD-3) — pure, no IR mutation. */
export function instanceNodeId(foreachNodeId: string, stepIndex: number, templateNodeId: string): string {
return `${foreachNodeId}#${stepIndex}:${templateNodeId}`;
}
// `instanceNodeId` now lives in `@fusion/core` (column-agent plan KTD-2) so the
// instance-id format has exactly one owner. Re-exported here (the imported binding)
// for back-compat with any local callers; the format is unchanged.
export { instanceNodeId };
/** Resolve the foreach config, validating the bits this module relies on. */
function resolveForeachConfig(node: WorkflowIrNode): {

View File

@@ -109,6 +109,10 @@ export type {
WorkflowIrNode,
WorkflowIrEdge,
WorkflowIrNodeKind,
// Columns + per-column permanent-agent binding (column-agent plan KTD-1, R12).
WorkflowIrColumn,
WorkflowIrColumnTrait,
WorkflowColumnAgent,
// Foreach / artifacts / custom fields (step inversion).
WorkflowForeachConfig,
WorkflowIrArtifact,