merge main: per-column agent assignment (binding validation, policy-escalation handshake, override notes) into workflow editor consolidation

Resolved 4 conflicts preserving both feature sets:
- WorkflowNodeEditor.tsx: kept card-node/edge/dialog/dirty-guard/auto-layout/
  onboarding/template/AI-edit features; wired main's columnAgentsEnabled flag
  gate, override-column agent registry load, and policy-escalation save retry
  (finishSave helper wraps both update payloads).
- WorkflowNodeEditor.test.tsx: kept U2/U4/AI-design describes plus main's U6
  column-agent describe; merged api mock + import lists.
- register-workflow-routes.ts: merged import sets (design DI seam + column-agent
  validators).
- agent-tools.ts: strip-approval-flags and column-agent binding assertion now
  both run in create/update tools.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-06-05 14:43:02 -07:00
55 changed files with 5313 additions and 225 deletions

View File

@@ -9,7 +9,7 @@ vi.mock("node:child_process", () => ({
spawn: mockSpawn,
}));
import { parseOpencodeModelsOutput, refreshOpencodeGoModels, syncStartupModels } from "../startup-model-sync.js";
import { normalizeOpencodeGoModel, parseOpencodeModelsOutput, refreshOpencodeGoModels, syncStartupModels } from "../startup-model-sync.js";
type MockProcess = EventEmitter & {
stdout: EventEmitter;
@@ -75,8 +75,8 @@ describe("startup-model-sync", () => {
expect(registerProvider).toHaveBeenCalledWith("openrouter", expect.objectContaining({ models: expect.any(Array) }));
expect(registerProvider).toHaveBeenCalledWith("opencode-go", expect.objectContaining({
models: expect.arrayContaining([
expect.objectContaining({ id: "opencode-go/gpt-5" }),
expect.objectContaining({ id: "opencode-go/custom" }),
expect.objectContaining({ id: "gpt-5" }),
expect.objectContaining({ id: "custom" }),
]),
}));
expect(log).toHaveBeenCalledWith("openrouter", expect.stringContaining("Synced"));
@@ -257,7 +257,7 @@ describe("startup-model-sync", () => {
expect(result).toEqual({ registeredCount: 1 });
expect(registerProvider).toHaveBeenCalledWith("opencode-go", expect.objectContaining({
models: [expect.objectContaining({ id: "opencode-go/gpt-5" })],
models: [expect.objectContaining({ id: "gpt-5" })],
}));
});
@@ -319,4 +319,52 @@ describe("startup-model-sync", () => {
"opencode-go/custom",
]);
});
it("deduplicates models when CLI emits both prefix forms", async () => {
mockSpawn.mockImplementation(() => {
const proc = createSpawnProcess();
queueMicrotask(() => {
proc.stdout.emit("data", Buffer.from("opencode/foo\nopencode-go/foo\nopencode/bar\n"));
proc.emit("exit", 0);
});
return proc;
});
const registerProvider = vi.fn();
await refreshOpencodeGoModels({ modelRegistry: { registerProvider }, log: vi.fn() });
expect(registerProvider).toHaveBeenCalledWith("opencode-go", expect.objectContaining({
models: [
expect.objectContaining({ id: "foo" }),
expect.objectContaining({ id: "bar" }),
],
}));
});
it("throws on empty model ID after prefix stripping", () => {
expect(() => normalizeOpencodeGoModel("opencode/")).toThrow("no model name");
expect(() => normalizeOpencodeGoModel("opencode-go/")).toThrow("no model name");
});
it("accepts apiKey and passes it as env var to spawn", async () => {
mockSpawn.mockImplementation(() => {
const proc = createSpawnProcess();
queueMicrotask(() => {
proc.stdout.emit("data", Buffer.from("opencode/foo\n"));
proc.emit("exit", 0);
});
return proc;
});
const registerProvider = vi.fn();
await refreshOpencodeGoModels({ modelRegistry: { registerProvider }, log: vi.fn(), apiKey: "test-key" });
expect(mockSpawn).toHaveBeenCalledWith(
"opencode",
["models", "opencode", "--refresh"],
expect.objectContaining({
env: expect.objectContaining({ OPENCODE_API_KEY: "test-key" }),
}),
);
});
});

View File

@@ -71,7 +71,7 @@ import { createReadOnlyAuthFileStorage, mergeAuthStorageReads, wrapAuthStorageWi
import { getClaudeCodeCredentialPaths, getCodexCliAuthPath, getFusionAuthPath, getLegacyAuthPaths, getModelRegistryModelsPath, getPackageManagerAgentDir } from "./auth-paths.js";
import { resolveProject } from "../project-context.js";
import { ensureBundledDependencyGraphPluginInstalled } from "../plugins/bundled-plugin-install.js";
import { refreshOpencodeGoModels, syncStartupModels } from "./startup-model-sync.js";
import { handleOpencodeGoApiKeySaved, syncStartupModels } from "./startup-model-sync.js";
import { ensureCwdProjectRegistered } from "./ensure-project-registered.js";
const DIAGNOSTIC_INTERVAL_MS = 30 * 60 * 1000; // 30 minutes
@@ -720,14 +720,12 @@ export async function runDaemon(opts: DaemonOptions = {}) {
if (providerId !== "opencode" && providerId !== "opencode-go") {
return undefined;
}
const settings = await store.getSettings();
if (settings.opencodeGoModelSync === false) {
return { registeredCount: 0, reason: "disabled-by-settings" };
}
return await refreshOpencodeGoModels({
return await handleOpencodeGoApiKeySaved(
dashboardAuthStorage,
store,
modelRegistry,
log: (scope, message) => console.log(`[${scope}] ${message}`),
});
(scope, message) => console.log(`[${scope}] ${message}`),
);
},
getClaudeCliExtensionStatus: () => {
const r = getCachedClaudeCliResolution();

View File

@@ -84,7 +84,7 @@ import { getCachedUpdateStatus, isUpdateCheckEnabled } from "../update-cache.js"
import { resolveSelfExtension } from "./self-extension.js";
import { ensureBundledDependencyGraphPluginInstalled, ensureBundledPluginInstalled, isBundledPluginId } from "../plugins/bundled-plugin-install.js";
import { registerCustomProviders, reregisterCustomProviders } from "./custom-provider-registry.js";
import { refreshOpencodeGoModels, syncStartupModels } from "./startup-model-sync.js";
import { handleOpencodeGoApiKeySaved, syncStartupModels } from "./startup-model-sync.js";
import { DashboardTUI, DashboardLogSink, isTTYAvailable, type SystemInfo, type GitStatus, type GitCommit, type GitCommitDetail, type GitBranch, type GitWorktree, type FileEntry, type FileReadResult, type TaskStep as TUITaskStep, type TaskLogEntry as TUITaskLogEntry, type TaskDetailData, type TaskEvent } from "./dashboard-tui/index.js";
import { DASHBOARD_STARTUP_STATUS, runTuiStartupPrelude } from "./dashboard-startup-chain.js";
@@ -1761,14 +1761,12 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
if (providerId !== "opencode" && providerId !== "opencode-go") {
return undefined;
}
const settings = await store.getSettings();
if (settings.opencodeGoModelSync === false) {
return { registeredCount: 0, reason: "disabled-by-settings" };
}
return await refreshOpencodeGoModels({
return await handleOpencodeGoApiKeySaved(
dashboardAuthStorage,
store,
modelRegistry,
log: (scope, message) => logSink.log(message, scope),
});
(scope, message) => logSink.log(message, scope),
);
},
getClaudeCliExtensionStatus: () => {
const r = getCachedClaudeCliResolution();
@@ -1974,6 +1972,11 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
});
},
store,
// Dev-mode scheduler: no TaskExecutor runs here (engine not started), so
// neither `isTaskExecuting` nor the U5 reverse-direction
// `isAgentEffectivelyExecuting` guard has a source — both stay unwired (the
// guards simply never fire), matching the prior `isTaskExecuting` omission.
// The real wiring is the InProcessRuntime construction site.
);
triggerScheduler.start();
@@ -2082,14 +2085,12 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
if (providerId !== "opencode" && providerId !== "opencode-go") {
return undefined;
}
const settings = await store.getSettings();
if (settings.opencodeGoModelSync === false) {
return { registeredCount: 0, reason: "disabled-by-settings" };
}
return await refreshOpencodeGoModels({
return await handleOpencodeGoApiKeySaved(
dashboardAuthStorage,
store,
modelRegistry,
log: (scope, message) => logSink.log(message, scope),
});
(scope, message) => logSink.log(message, scope),
);
},
getClaudeCliExtensionStatus: () => {
const r = getCachedClaudeCliResolution();

View File

@@ -71,7 +71,7 @@ import {
} from "./llama-cpp-extension.js";
import { resolveSelfExtension } from "./self-extension.js";
import { registerCustomProviders, reregisterCustomProviders } from "./custom-provider-registry.js";
import { refreshOpencodeGoModels, syncStartupModels } from "./startup-model-sync.js";
import { handleOpencodeGoApiKeySaved, syncStartupModels } from "./startup-model-sync.js";
import { ensureBundledDependencyGraphPluginInstalled, ensureBundledPluginInstalled, isBundledPluginId } from "../plugins/bundled-plugin-install.js";
import { ensureCwdProjectRegistered } from "./ensure-project-registered.js";
@@ -827,14 +827,12 @@ export async function runServe(
if (providerId !== "opencode" && providerId !== "opencode-go") {
return undefined;
}
const settings = await store.getSettings();
if (settings.opencodeGoModelSync === false) {
return { registeredCount: 0, reason: "disabled-by-settings" };
}
return await refreshOpencodeGoModels({
return await handleOpencodeGoApiKeySaved(
dashboardAuthStorage,
store,
modelRegistry,
log: (scope, message) => console.log(`[${scope}] ${message}`),
});
(scope, message) => console.log(`[${scope}] ${message}`),
);
},
getClaudeCliExtensionStatus: () => {
const r = getCachedClaudeCliResolution();

View File

@@ -205,15 +205,22 @@ async function syncOpenRouterModels(options: StartupSyncOptions, settings: Setti
export function normalizeOpencodeGoModel(modelId: string): ModelConfig {
const trimmed = modelId.trim();
const normalizedId = trimmed.startsWith("opencode/")
? `opencode-go/${trimmed.slice("opencode/".length)}`
: trimmed.startsWith("opencode-go/")
? trimmed
: `opencode-go/${trimmed}`;
// Strip the provider prefix (opencode/ or opencode-go/) — the Pi SDK
// already routes requests by provider, and the OpenCode API expects the
// bare model name (e.g. "deepseek-v4-flash", not "opencode-go/deepseek-v4-flash").
const bareModel = trimmed.startsWith("opencode-go/")
? trimmed.slice("opencode-go/".length)
: trimmed.startsWith("opencode/")
? trimmed.slice("opencode/".length)
: trimmed;
if (!bareModel) {
throw new Error(`Invalid opencode-go model ID: "${modelId}" has no model name after provider prefix`);
}
return {
id: normalizedId,
name: normalizedId,
id: bareModel,
name: bareModel,
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
@@ -233,10 +240,15 @@ export function parseOpencodeModelsOutput(stdout: string): string[] {
return [...ids];
}
export async function discoverOpencodeGoModels(): Promise<string[]> {
export async function discoverOpencodeGoModels(apiKey?: string): Promise<string[]> {
return await new Promise<string[]>((resolve, reject) => {
const env: Record<string, string> = { ...process.env as Record<string, string> };
if (apiKey) {
env.OPENCODE_API_KEY = apiKey;
}
const proc = spawn("opencode", ["models", "opencode", "--refresh"], {
stdio: ["ignore", "pipe", "pipe"],
env,
});
let stdout = "";
@@ -272,16 +284,25 @@ export async function discoverOpencodeGoModels(): Promise<string[]> {
export async function refreshOpencodeGoModels(options: {
modelRegistry: ModelRegistryLike;
log: (scope: string, message: string) => void;
apiKey?: string;
}): Promise<OpencodeGoRefreshResult> {
try {
const { modelRegistry, log } = options;
const modelIds = await discoverOpencodeGoModels();
const { modelRegistry, log, apiKey } = options;
const modelIds = await discoverOpencodeGoModels(apiKey);
if (modelIds.length === 0) {
log("opencode-go", "No models discovered from opencode CLI refresh");
return { registeredCount: 0, reason: "no-models-from-cli" };
}
const models = modelIds.map(normalizeOpencodeGoModel);
const normalized = modelIds.map(normalizeOpencodeGoModel);
// Deduplicate: CLI can emit both "opencode/foo" and "opencode-go/foo"
// which normalize to the same bare ID.
const seen = new Set<string>();
const models = normalized.filter((m) => {
if (seen.has(m.id)) return false;
seen.add(m.id);
return true;
});
modelRegistry.registerProvider("opencode-go", {
baseUrl: "https://api.opencode.ai/v1",
apiKey: "OPENCODE_API_KEY",
@@ -310,6 +331,27 @@ export async function syncStartupModels(options: StartupSyncOptions): Promise<vo
}
if (settings.opencodeGoModelSync !== false) {
await refreshOpencodeGoModels({ modelRegistry: options.modelRegistry, log: options.log });
const opencodeGoApiKey = await options.authStorage.getApiKey("opencode-go") ?? await options.authStorage.getApiKey("opencode");
await refreshOpencodeGoModels({ modelRegistry: options.modelRegistry, log: options.log, apiKey: opencodeGoApiKey });
}
}
/**
* Shared handler for the onApiKeySaved callback used by serve, daemon, and
* dashboard. Resolves the opencode-go API key from auth storage (falling back
* to the "opencode" provider ID) and triggers a model refresh, respecting the
* opencodeGoModelSync setting.
*/
export async function handleOpencodeGoApiKeySaved(
dashboardAuthStorage: AuthStorageLike,
store: { getSettings: () => Promise<SettingsLike> },
modelRegistry: ModelRegistryLike,
log: (scope: string, message: string) => void,
): Promise<OpencodeGoRefreshResult | undefined> {
const settings = await store.getSettings();
if (settings.opencodeGoModelSync === false) {
return { registeredCount: 0, reason: "disabled-by-settings" };
}
const opencodeGoKey = await dashboardAuthStorage.getApiKey("opencode-go") ?? await dashboardAuthStorage.getApiKey("opencode");
return await refreshOpencodeGoModels({ modelRegistry, log, apiKey: opencodeGoKey });
}

View File

@@ -0,0 +1,57 @@
/**
* Drift guard for the intentionally duplicated resolvePluginEntryPath.
*
* The CLI keeps a local copy in bundled-plugin-install.ts (so its fs mocks
* work in tests) while @fusion/core owns the copy used by the dashboard
* install/enable routes. This test runs both against real on-disk layouts and
* asserts identical results, so a candidate-list change applied to one copy
* but not the other fails CI instead of silently diverging.
*
* No fs mocks here on purpose — vitest module mocks don't reach the
* externalized @fusion/core import, so real temp directories are the only
* seam that exercises both implementations equally.
*/
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { resolvePluginEntryPath as cliResolve } from "../bundled-plugin-install.js";
import { resolvePluginEntryPath as coreResolve } from "@fusion/core";
describe("resolvePluginEntryPath: CLI copy stays in sync with @fusion/core", () => {
let dir: string;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), "entry-path-sync-"));
});
afterEach(() => {
rmSync(dir, { recursive: true, force: true });
});
function touch(relative: string) {
const full = join(dir, relative);
mkdirSync(join(full, ".."), { recursive: true });
writeFileSync(full, "// entry\n");
}
const layouts: Array<{ name: string; files: string[]; expected: string | null }> = [
{ name: "bundled.js only", files: ["bundled.js"], expected: "bundled.js" },
{ name: "dist/index.js only", files: ["dist/index.js"], expected: "dist/index.js" },
{ name: "src/index.ts only", files: ["src/index.ts"], expected: "src/index.ts" },
{ name: "bundled.js preferred over src", files: ["bundled.js", "src/index.ts"], expected: "bundled.js" },
{ name: "dist preferred over src", files: ["dist/index.js", "src/index.ts"], expected: "dist/index.js" },
{ name: "all three → bundled.js", files: ["bundled.js", "dist/index.js", "src/index.ts"], expected: "bundled.js" },
{ name: "no entry files", files: ["README.md"], expected: null },
];
for (const layout of layouts) {
it(`resolves identically for: ${layout.name}`, () => {
for (const f of layout.files) touch(f);
const expected = layout.expected === null ? null : join(dir, layout.expected);
expect(cliResolve(dir)).toBe(expected);
expect(coreResolve(dir)).toBe(expected);
});
}
});

View File

@@ -78,6 +78,9 @@ function resolveBundledPluginDir(pluginId: string): string | null {
* Returns null when the directory exists but none of the loadable entry files
* are present. Callers must treat that as a missing bundle rather than
* persisting a directory path that Node cannot import.
*
* Keep in sync with resolvePluginEntryPath in @fusion/core (plugin-loader.ts),
* which the dashboard install/enable routes use for the same contract.
*/
export function resolvePluginEntryPath(pluginDir: string): string | null {
const candidates = [

View File

@@ -0,0 +1,288 @@
// @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 × lone modelId (incomplete pair, no agentId) → column agent wins", () => {
// Symmetric incomplete-pair surface (FN-5893: assert the invariant across
// ALL known surfaces, not only the provider-only reproduction).
expect(resolveEffectiveAgent({ binding: deferBinding, ownModelId: "claude-x" })).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();
});
it("skips a candidate whose templateNodeId doesn't exist under the foreach", () => {
// PR #1432 review: a bogus prefix candidate can name a real foreach while its
// parsed templateNodeId resolves to nothing — it must be skipped, not treated
// as inheriting the foreach's column.
const ir = foreachIr({ foreachColumn: "review", reviewAgent: overrideBinding });
expect(resolveColumnAgentBinding(ir, instanceNodeId("fe", 0, "nope"))).toBeUndefined();
});
it("resolves bindings when the foreach node id itself contains '#'", () => {
// The instance-id format is delimiter-ambiguous; the resolver validates each
// candidate split against real foreach nodes instead of trusting the first '#'
// (PR #1432 review).
const ir = foreachIr({ foreachColumn: "review", reviewAgent: overrideBinding });
const fe = ir.nodes.find((n) => n.id === "fe");
if (!fe) throw new Error("fixture foreach missing");
fe.id = "fe#a";
const nodeId = instanceNodeId("fe#a", 0, "se");
expect(nodeId).toBe("fe#a#0:se");
expect(resolveColumnAgentBinding(ir, nodeId)).toEqual(overrideBinding);
});
});
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

@@ -156,3 +156,62 @@ export function resolveEffectiveAgentPermissionPolicy(
rules: policy.rules,
});
}
/**
* Disposition strictness rank for column-agent policy-escalation comparison
* (R13). A LOWER rank is *broader* (more privileged): `allow` lets an action
* through unconditionally, `require-approval` gates it, `block` denies it. An
* agent whose policy is broader than the project default on ANY action category
* is an escalation that must be explicitly confirmed at save time.
*/
const DISPOSITION_BREADTH_RANK: Record<AgentPermissionPolicyDisposition, number> = {
allow: 0,
"require-approval": 1,
block: 2,
};
/**
* The broadest (most-privileged) rank — used as the fallback when a category is
* absent from a policy's rules map. Treating a missing category as the broadest
* possible disposition (`allow`) ensures an absent key can never silently
* *suppress* a genuine escalation: the comparison only flags when the agent is
* at least as broad as the default, so an unknown agent-side category errs
* toward flagging, and an unknown default-side category errs toward the most
* permissive default (the conservative direction for escalation detection).
*/
const BROADEST_RANK = DISPOSITION_BREADTH_RANK.allow;
function dispositionRank(
rules: AgentPermissionPolicyRules,
category: (typeof AGENT_PERMISSION_POLICY_ACTION_CATEGORIES)[number],
): number {
const disposition = rules[category];
if (disposition === undefined) {
// An absent category must not suppress escalation. Treat the agent side as
// broadest (most privileged) so a missing key never narrows the comparison.
return BROADEST_RANK;
}
return DISPOSITION_BREADTH_RANK[disposition];
}
/**
* True when `agentPolicy`'s effective policy is broader (more privileged) than
* the project `defaultPolicy` on at least one action category (R13).
*
* Both arguments should already be resolved via
* {@link resolveEffectiveAgentPermissionPolicy}, which fills every category. The
* defensive per-category handling here guards against a partial/custom rules
* map slipping through with a missing category key — an absent key must never
* silently suppress a genuine escalation.
*/
export function isPolicyBroaderThanDefault(
agentPolicy: AgentPermissionPolicy,
defaultPolicy: AgentPermissionPolicy,
): boolean {
for (const category of AGENT_PERMISSION_POLICY_ACTION_CATEGORIES) {
const agentRank = dispositionRank(agentPolicy.rules, category);
const defaultRank = dispositionRank(defaultPolicy.rules, category);
if (agentRank < defaultRank) return true;
}
return false;
}

View File

@@ -0,0 +1,104 @@
import type { AgentStore } from "./agent-store.js";
import type { Settings } from "./types.js";
import type { WorkflowIr, WorkflowIrColumn } from "./workflow-ir-types.js";
import {
isPolicyBroaderThanDefault,
resolveEffectiveAgentPermissionPolicy,
} from "./agent-permission-policy.js";
/**
* Typed error raised when a workflow IR binds a column to an agent that fails a
* write-time check (existence or policy escalation, R11/R13). Carries the
* offending column id and a `reason` discriminant so each write surface can map
* it to its own transport (the dashboard route → an HTTP 400; the agent tools →
* a structured tool error) without re-deriving the message.
*
* Shared between the dashboard workflow route and the `fn_workflow_create` /
* `fn_workflow_update` agent tools so both write paths enforce the SAME gate —
* an agent must not be able to persist a binding the UI would reject.
*/
export class ColumnAgentBindingError extends Error {
readonly columnId: string;
readonly agentId: string;
readonly reason: "unknown-agent" | "policy-escalation";
constructor(args: {
message: string;
columnId: string;
agentId: string;
reason: "unknown-agent" | "policy-escalation";
}) {
super(args.message);
this.name = "ColumnAgentBindingError";
this.columnId = args.columnId;
this.agentId = args.agentId;
this.reason = args.reason;
}
}
/**
* Write-time column-agent validation (U6, R11/R13), shared by every write
* surface. Inspects an IR's columns BEFORE it is persisted and throws a typed
* {@link ColumnAgentBindingError} naming the offending column. Never mutates the
* IR and never touches the store/scheduler.
*
* Two checks per bound column:
* 1. Existence — every `column.agent.agentId` must resolve in the agent
* registry; an unknown id throws (`reason: "unknown-agent"`) so the binding
* can't be saved and silently fall back at execution time.
* 2. Policy escalation (R13) — if the bound agent's effective permission policy
* is broader (more privileged) than the project default on any action
* category, the write requires an explicit `confirmPolicyEscalation` flag,
* else it throws (`reason: "policy-escalation"`). Override must never
* silently re-key action gates to a more-privileged agent.
*
* Config is data: bindings are accepted regardless of feature flags — flags gate
* execution, not storage. A null/non-object IR or columns array is left to the
* store's own validator (this only inspects shapes it can read).
*/
export async function validateColumnAgentBindings(args: {
ir: WorkflowIr | unknown;
agentStore: AgentStore;
settings: Pick<Settings, "defaultAgentPermissionPolicy">;
confirmPolicyEscalation: boolean;
}): Promise<void> {
const { ir, agentStore, settings, confirmPolicyEscalation } = args;
const columns = (ir as { columns?: unknown })?.columns;
if (!Array.isArray(columns)) return;
const bound = (columns as WorkflowIrColumn[]).filter(
(col) => col && typeof col === "object" && col.agent && typeof col.agent.agentId === "string",
);
if (bound.length === 0) return;
const defaultPolicy = resolveEffectiveAgentPermissionPolicy(
undefined,
settings.defaultAgentPermissionPolicy,
);
for (const col of bound) {
const agentId = col.agent!.agentId;
const agent = await agentStore.getAgent(agentId);
if (!agent) {
throw new ColumnAgentBindingError({
message: `Column '${col.id}' binds unknown agent '${agentId}'`,
columnId: col.id,
agentId,
reason: "unknown-agent",
});
}
const agentPolicy = resolveEffectiveAgentPermissionPolicy(
agent.permissionPolicy,
settings.defaultAgentPermissionPolicy,
);
if (isPolicyBroaderThanDefault(agentPolicy, defaultPolicy) && !confirmPolicyEscalation) {
throw new ColumnAgentBindingError({
message:
`Column '${col.id}' binds agent '${agentId}' whose permission policy is broader than ` +
`the project default; set confirmPolicyEscalation: true to confirm`,
columnId: col.id,
agentId,
reason: "policy-escalation",
});
}
}
}

View File

@@ -0,0 +1,219 @@
/**
* 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.
*
* NOTE: a `foreachNodeId` that itself contains `#` is ambiguous under any single
* split. Callers that hold the IR should use {@link parseInstanceNodeIdCandidates}
* and validate each candidate's `foreachNodeId` against the graph (as
* `resolveColumnAgentBinding` does) instead of trusting one split position. */
export function parseInstanceNodeId(nodeId: string): ParsedInstanceNodeId | undefined {
const hashIndex = nodeId.indexOf("#");
if (hashIndex < 0) return undefined;
return parseInstanceNodeIdAt(nodeId, hashIndex);
}
/** Parse treating the `#` at `hashIndex` as the instance-id delimiter. */
function parseInstanceNodeIdAt(nodeId: string, hashIndex: number): ParsedInstanceNodeId | 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 };
}
/** Every plausible parse of `nodeId` as an instance id — one candidate per `#`
* whose suffix matches the `<digits>:` shape. The id format is ambiguous when
* node ids themselves contain `#` (e.g. foreach `f#a`, instance `f#a#0:t` — both
* the first and second `#` look like delimiters), so callers with access to the
* graph validate each candidate's `foreachNodeId` against real foreach nodes
* rather than committing to a single split position. Ordered left-to-right. */
export function parseInstanceNodeIdCandidates(nodeId: string): ParsedInstanceNodeId[] {
const candidates: ParsedInstanceNodeId[] = [];
for (let i = nodeId.indexOf("#"); i >= 0; i = nodeId.indexOf("#", i + 1)) {
const parsed = parseInstanceNodeIdAt(nodeId, i);
if (parsed) candidates.push(parsed);
}
return candidates;
}
// ── 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. The instance-id format is ambiguous when
// node ids contain `#`, so try every plausible split and accept the first whose
// foreachNodeId names a REAL foreach node in this graph — a single fixed split
// (first-# or last-#) silently bypasses bindings for ids on the other side of
// the ambiguity (PR #1432 review).
for (const parsed of parseInstanceNodeIdCandidates(nodeId)) {
const foreachNode = nodesById.get(parsed.foreachNodeId);
if (!foreachNode || foreachNode.kind !== "foreach") continue;
const cfg = foreachNode.config as Partial<WorkflowForeachConfig> | undefined;
const templateNodes = cfg?.template?.nodes ?? [];
const templateNode = templateNodes.find((n) => n.id === parsed.templateNodeId);
// Disambiguation guard (PR #1432 review): a bogus prefix candidate can name a
// real foreach while its templateNodeId doesn't exist under it — skip it so a
// later exact parse isn't masked. A template with no nodes still inherits.
if (templateNodes.length > 0 && !templateNode) continue;
// 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);
}
return undefined;
}
// ── 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

@@ -61,6 +61,7 @@ export type {
WorkflowIrNodeKind,
WorkflowIrColumn,
WorkflowIrColumnTrait,
WorkflowColumnAgent,
WorkflowHoldRelease,
WorkflowJoinMode,
WorkflowJoinBranchFailure,
@@ -72,6 +73,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";
@@ -299,8 +311,13 @@ export {
normalizeAgentPermissionPolicy,
resolveEffectiveAgentPermissionPolicy,
isAgentPermissionPolicyPresetId,
isPolicyBroaderThanDefault,
} from "./agent-permission-policy.js";
export type { BuiltInAgentPermissionPolicyPreset } from "./agent-permission-policy.js";
export {
validateColumnAgentBindings,
ColumnAgentBindingError,
} from "./column-agent-binding-validation.js";
export { AgentStore, DEFAULT_AGENT_HEARTBEAT_INTERVAL_MS } from "./agent-store.js";
export type { AgentStoreEvents } from "./agent-store.js";
export {
@@ -771,7 +788,7 @@ export {
} from "./plugin-types.js";
export { PluginStore } from "./plugin-store.js";
export type { PluginStoreEvents, PluginRegistrationInput, PluginUpdateInput } from "./plugin-store.js";
export { PluginLoader } from "./plugin-loader.js";
export { PluginLoader, resolvePluginEntryPath } from "./plugin-loader.js";
export { scanPluginSecurity } from "./plugin-security-scan.js";
export type { PluginSecurityScanResult, PluginSecurityFinding } from "./plugin-security-scan.js";
export type {

View File

@@ -9,7 +9,8 @@
* - Error isolation (plugin crashes don't crash the loader)
*/
import { basename, dirname, extname, isAbsolute, resolve } from "node:path";
import { basename, dirname, extname, isAbsolute, join, resolve } from "node:path";
import { existsSync } from "node:fs";
import { stat } from "node:fs/promises";
import { copyFile } from "node:fs/promises";
import { pathToFileURL } from "node:url";
@@ -48,6 +49,35 @@ import { scanPluginSecurity } from "./plugin-security-scan.js";
const MINIMUM_FUSION_VERSION = "0.1.0";
let moduleImportVersion = 0;
/**
* Resolve the actual loadable entry FILE path for a plugin directory. Node ESM
* does not allow directory imports, so the registered plugin path must be the
* explicit file the loader will dynamic-import. Preference order:
* 1. ./bundled.js (esbuild-bundled, shipped in npm tarball)
* 2. ./dist/index.js (legacy prebuilt fallback)
* 3. ./src/index.ts (workspace/dev fallback when no bundle exists)
*
* Returns null when the directory exists but none of the loadable entry files
* are present. Callers must treat that as a missing/unloadable plugin rather
* than persisting a directory path that Node cannot import.
*
* Keep in sync with resolvePluginEntryPath in the CLI's
* bundled-plugin-install.ts, which keeps a local copy so its fs mocks work.
*/
export function resolvePluginEntryPath(pluginDir: string): string | null {
const candidates = [
join(pluginDir, "bundled.js"),
join(pluginDir, "dist", "index.js"),
join(pluginDir, "src", "index.ts"),
];
for (const candidate of candidates) {
if (existsSync(candidate)) {
return candidate;
}
}
return null;
}
export interface PluginLoaderOptions {
/** Plugin store for persistence */
pluginStore: PluginStore;

View File

@@ -59,6 +59,13 @@ export interface WorkflowDefinitionUpdate {
* the `workflowColumns` flag is ON.
*/
rehomeTo?: string;
/**
* Column-agent policy escalation (column-agent plan R13): set true to confirm
* binding a column agent whose permission policy is broader than the project
* default. Without it, the write surfaces (dashboard routes, fn_workflow_*
* tools) reject such bindings with a typed policy-escalation error.
*/
confirmPolicyEscalation?: boolean;
/**
* U11/KTD-13: when an IR update changes a custom field's type incompatibly for
* tasks that already hold a value under that field, the update is blocked with

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

@@ -139,7 +139,7 @@
width: 14px;
height: 14px;
border-radius: 50%;
background: #fff;
background: var(--card);
transition: transform 0.15s ease;
}

View File

@@ -1,8 +1,9 @@
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Plus, Trash2, ChevronUp, ChevronDown, AlertTriangle } from "lucide-react";
import type { WorkflowIrColumn, TraitViolation } from "@fusion/core";
import { fetchTraits, type TraitCatalogEntry } from "../api";
import { Plus, Trash2, ChevronUp, ChevronDown, AlertTriangle, Bot } from "lucide-react";
import type { WorkflowIrColumn, WorkflowColumnAgent, TraitViolation } from "@fusion/core";
import { fetchTraits, fetchAgents, type TraitCatalogEntry } from "../api";
import type { Agent } from "../api";
import { getErrorMessage } from "@fusion/core";
import type { ToastType } from "../hooks/useToast";
@@ -16,6 +17,12 @@ interface WorkflowColumnPanelProps {
readOnly: boolean;
projectId?: string;
addToast: (message: string, type?: ToastType) => void;
/** True only when BOTH `experimentalFeatures.workflowColumns` AND
* `experimentalFeatures.workflowGraphExecutor` are on. When false, the
* per-column agent picker is disabled (not hidden) with a hint naming both
* flags — config is data, so bindings still round-trip, but column agents are
* inert at execution time (R10). */
columnAgentsEnabled: boolean;
}
let columnSeq = 0;
@@ -31,9 +38,13 @@ export function WorkflowColumnPanel({
readOnly,
projectId,
addToast,
columnAgentsEnabled,
}: WorkflowColumnPanelProps) {
const { t } = useTranslation("app");
const [catalog, setCatalog] = useState<TraitCatalogEntry[]>([]);
const [agents, setAgents] = useState<Agent[]>([]);
const [agentsLoading, setAgentsLoading] = useState(true);
const [agentsError, setAgentsError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
@@ -49,6 +60,94 @@ export function WorkflowColumnPanel({
};
}, [projectId, addToast, t]);
// Eagerly load the agent registry for the per-column picker (R11). Mirrors the
// fetchTraits-on-mount pattern above (cancelled guard + toast), but ALSO keeps
// an inline error near the picker rather than only a toast, so a failed fetch
// is visible at the point of use.
useEffect(() => {
let cancelled = false;
setAgentsLoading(true);
setAgentsError(null);
// Promise.resolve guards against test mocks that return undefined.
Promise.resolve(fetchAgents(undefined, projectId))
.then((list) => {
if (cancelled) return;
setAgents(list ?? []);
setAgentsLoading(false);
})
.catch((err) => {
if (cancelled) return;
const message = getErrorMessage(err) || t("workflowColumns.agentsLoadFailed", "Failed to load agents");
setAgentsError(message);
setAgentsLoading(false);
addToast(message, "error");
});
return () => {
cancelled = true;
};
}, [projectId, addToast, t]);
// Key derived agent lookups on the joined id string, never on array identity —
// SWR/dedupe can hand back a fresh array with identical ids and we must not
// churn selection/derived state on that (skill-autocomplete SWR learning).
const agentIdsKey = useMemo(() => agents.map((a) => a.id).join(","), [agents]);
const agentById = useMemo(() => {
const map = new Map<string, Agent>();
for (const a of agents) map.set(a.id, a);
return map;
// Keyed on the joined id string (not array identity) per the SWR-identity
// learning: a fresh array with identical ids must not churn derived state.
// (exhaustive-deps is not enforced in this package; the omission of `agents`
// from the dep array is intentional — agentIdsKey is the stable identity.)
}, [agentIdsKey]);
const setColumnAgent = useCallback(
(id: string, agent: WorkflowColumnAgent | undefined) => {
onChange(
columns.map((c) => {
if (c.id !== id) return c;
if (!agent) {
// Clearing to "(none)" REMOVES the key entirely — never write
// `agent: null` (R9 parity: omitted-when-unset).
const { agent: _omit, ...rest } = c;
return rest;
}
return { ...c, agent };
}),
);
},
[columns, onChange],
);
const selectColumnAgentId = useCallback(
(id: string, agentId: string) => {
if (!agentId) {
setColumnAgent(id, undefined);
return;
}
const existing = columns.find((c) => c.id === id)?.agent;
// Preserve an existing mode; default new selections to "defer" (the less
// surprising mode).
setColumnAgent(id, { agentId, mode: existing?.mode ?? "defer" });
},
[columns, setColumnAgent],
);
const setColumnAgentMode = useCallback(
(id: string, mode: "defer" | "override") => {
const existing = columns.find((c) => c.id === id)?.agent;
if (!existing) return;
setColumnAgent(id, { ...existing, mode });
},
[columns, setColumnAgent],
);
// `!!agentsError` (PR #1432 review): when the registry fetch failed, the select
// would render enabled with only "(none)" while the bound id has no matching
// option — interacting with it could silently clear a binding. Disabled while
// the registry is unavailable, consistent with the loading guard.
const agentPickerDisabled = readOnly || !columnAgentsEnabled || agentsLoading || !!agentsError;
const workflowWide = violations.filter((v) => v.columnId === null);
const violationsFor = useCallback(
(columnId: string) => violations.filter((v) => v.columnId === columnId),
@@ -135,6 +234,16 @@ export function WorkflowColumnPanel({
<ul className="wf-column-list">
{columns.map((col, index) => {
const colViolations = violationsFor(col.id);
const boundAgentId = col.agent?.agentId;
const boundAgent = boundAgentId ? agentById.get(boundAgentId) : undefined;
// A stored id that is not in the loaded registry list is "stale":
// render a not-found warning and PRESERVE the IR value until the
// author explicitly clears or replaces it (R11).
const boundAgentStale = !!boundAgentId && !agentsLoading && !agentsError && !boundAgent;
const boundAgentLabel = boundAgent?.name
?? (boundAgentStale
? t("workflowColumns.agentNotFound", "Agent not found — {{id}}", { id: boundAgentId ?? "" })
: boundAgentId);
return (
<li
key={col.id}
@@ -150,6 +259,17 @@ export function WorkflowColumnPanel({
disabled={readOnly}
onChange={(e) => renameColumn(col.id, e.target.value)}
/>
{boundAgentId && (
<span
className={`wf-column-agent-badge${boundAgentStale ? " wf-column-agent-badge--stale" : ""}`}
data-testid={`wf-column-agent-badge-${col.id}`}
title={col.agent?.mode === "override"
? t("workflowColumns.agentBadgeOverride", "Column agent (override)")
: t("workflowColumns.agentBadgeDefer", "Column agent (defer)")}
>
<Bot size={11} aria-hidden /> {boundAgentLabel}
</span>
)}
<div className="wf-column-item-actions">
<button
className="wf-column-move"
@@ -203,6 +323,79 @@ export function WorkflowColumnPanel({
})}
</div>
</div>
<div className="wf-column-agent">
<span className="wf-column-agent-label">{t("workflowColumns.agent", "Column agent")}</span>
<select
className="wf-column-agent-select"
data-testid={`wf-column-agent-select-${col.id}`}
aria-label={t("workflowColumns.agentLabel", "Column agent")}
value={boundAgentId ?? ""}
disabled={agentPickerDisabled}
title={!columnAgentsEnabled
? t(
"workflowColumns.agentFlagHint",
"Enable both experimentalFeatures.workflowColumns and experimentalFeatures.workflowGraphExecutor to staff columns with agents",
)
: readOnly
? t("workflowColumns.readOnlyHint", "Built-in workflows are read-only — duplicate to edit")
: undefined}
onChange={(e) => selectColumnAgentId(col.id, e.target.value)}
>
<option value="">{t("workflowColumns.agentNone", "(none)")}</option>
{/* Stale id: keep it selectable so the IR value is preserved
until the author explicitly clears or replaces it (R11). */}
{boundAgentStale && boundAgentId && (
<option value={boundAgentId}>
{t("workflowColumns.agentNotFound", "Agent not found — {{id}}", { id: boundAgentId })}
</option>
)}
{agents.map((a) => (
<option key={a.id} value={a.id}>{a.name}</option>
))}
</select>
{agentsError && (
<p className="wf-column-agent-error" role="alert">
<AlertTriangle size={12} aria-hidden /> {agentsError}
</p>
)}
{boundAgentStale && (
<p className="wf-column-agent-stale" role="alert" data-testid={`wf-column-agent-stale-${col.id}`}>
<AlertTriangle size={12} aria-hidden />{" "}
{t("workflowColumns.agentNotFound", "Agent not found — {{id}}", { id: boundAgentId ?? "" })}
</p>
)}
{boundAgentId && (
<div className="wf-column-agent-mode" role="radiogroup" aria-label={t("workflowColumns.agentMode", "Agent mode")}>
<label className="wf-column-agent-mode-option">
<input
type="radio"
name={`wf-column-agent-mode-${col.id}`}
checked={(col.agent?.mode ?? "defer") === "defer"}
disabled={agentPickerDisabled}
onChange={() => setColumnAgentMode(col.id, "defer")}
/>
<span title={t("workflowColumns.agentModeDeferHint", "Column agent applies only when the work carries no agent/model settings of its own")}>
{t("workflowColumns.agentModeDefer", "Defer")}
</span>
</label>
<label className="wf-column-agent-mode-option">
<input
type="radio"
name={`wf-column-agent-mode-${col.id}`}
checked={col.agent?.mode === "override"}
disabled={agentPickerDisabled}
onChange={() => setColumnAgentMode(col.id, "override")}
/>
<span title={t("workflowColumns.agentModeOverrideHint", "Column agent supersedes node- and task-level agent/model settings")}>
{t("workflowColumns.agentModeOverride", "Override")}
</span>
</label>
</div>
)}
</div>
</li>
);
})}

View File

@@ -41,6 +41,7 @@ import type { ToastType } from "../hooks/useToast";
import { useOverlayDismiss } from "../hooks/useOverlayDismiss";
import { useConfirm } from "../hooks/useConfirm";
import { useModalResizePersist } from "../hooks/useModalResizePersist";
import { useAppSettings } from "../hooks/useAppSettings";
import { workflowNodeTypes, type WorkflowFlowNodeData, type WorkflowEditorNodeKind } from "./nodes/WorkflowNodeTypes";
import { WorkflowEditorCatalogContext } from "./nodes/WorkflowEditorCatalogContext";
import type { NodeSummaryCatalogs } from "./nodes/node-summary";
@@ -757,6 +758,14 @@ function InnerEditor({
return !nodes.some((n) => USER_NODE_KINDS.has(n.data.kind));
}, [activeWorkflow, isBuiltin, nodes]);
// Column-agent authoring requires BOTH flags (R10). When either is off, the
// picker is disabled (not hidden) and bound columns are inert at execution
// time; config still round-trips (flags gate execution, not storage).
const { experimentalFeatures } = useAppSettings(projectId);
const columnAgentsEnabled =
experimentalFeatures?.workflowColumns === true &&
experimentalFeatures?.workflowGraphExecutor === true;
// 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(() => {
@@ -1574,47 +1583,71 @@ function InnerEditor({
// loaded workflow (KTD-10 inline rename/description persist here).
const nameChanged = trimmedName !== activeWorkflow.name;
const descChanged = description !== (activeWorkflow.description ?? "");
const updated = await updateWorkflow(
activeWorkflow.id,
{
ir,
layout,
...(nameChanged ? { name: trimmedName } : {}),
...(descChanged ? { description } : {}),
},
projectId,
);
setWorkflows((ws) => ws.map((w) => (w.id === updated.id ? updated : w)));
// Re-baseline the dirty snapshot to the just-saved state so the editor is
// clean immediately after a successful save.
loadedSnapshotRef.current = serializeGraph(
updated.name,
updated.description ?? "",
nodes,
edges,
columns,
fields,
);
setName(updated.name);
setDescription(updated.description ?? "");
// Validate by compiling — surfaces non-linear graphs as a banner.
try {
await compileWorkflow(updated.id, projectId);
addToast(t("workflows.saved", "Workflow saved"), "success");
} catch (compileErr) {
const compileMsg = getErrorMessage(compileErr) || "";
// KTD-4: branching graphs reject with this shared suffix from
// workflow-compiler.ts (both the fan-out and off-main-path messages).
// Such a graph still runs on the interpreter — present it as info, not a
// warning. NOTE: this string is coupled to the compiler's message; if
// that wording changes, update both sites (see compiler message site).
if (compileMsg.includes("require the workflow interpreter (deferred)")) {
setInterpreterOnly(true);
} else {
setValidationError(
compileMsg || t("workflows.savedNotCompilable", "Workflow saved but cannot be compiled"),
);
const finishSave = async (updated: Awaited<ReturnType<typeof updateWorkflow>>) => {
setWorkflows((ws) => ws.map((w) => (w.id === updated.id ? updated : w)));
// Re-baseline the dirty snapshot to the just-saved state so the editor is
// clean immediately after a successful save.
loadedSnapshotRef.current = serializeGraph(
updated.name,
updated.description ?? "",
nodes,
edges,
columns,
fields,
);
setName(updated.name);
setDescription(updated.description ?? "");
// Validate by compiling — surfaces non-linear graphs as a banner.
try {
await compileWorkflow(updated.id, projectId);
addToast(t("workflows.saved", "Workflow saved"), "success");
} catch (compileErr) {
const compileMsg = getErrorMessage(compileErr) || "";
// KTD-4: branching graphs reject with this shared suffix from
// workflow-compiler.ts (both the fan-out and off-main-path messages).
// Such a graph still runs on the interpreter — present it as info, not a
// warning. NOTE: this string is coupled to the compiler's message; if
// that wording changes, update both sites (see compiler message site).
if (compileMsg.includes("require the workflow interpreter (deferred)")) {
setInterpreterOnly(true);
} else {
setValidationError(
compileMsg || t("workflows.savedNotCompilable", "Workflow saved but cannot be compiled"),
);
}
}
};
const savePayload = {
ir,
layout,
...(nameChanged ? { name: trimmedName } : {}),
...(descChanged ? { description } : {}),
};
try {
await finishSave(await updateWorkflow(activeWorkflow.id, savePayload, projectId));
} catch (err) {
// Policy-escalation handshake (R13, PR #1432 review): the route rejects a
// binding to a broader-than-default agent until the author explicitly
// confirms. Surface the server's explanation, then retry with the flag —
// otherwise such bindings would be unsavable from the dashboard.
// Shape-checked rather than `instanceof ApiRequestError` so test doubles
// (and any error wrapper) that carry the details payload still route here.
const escalation =
(err as { details?: { policyEscalation?: boolean } } | null)?.details?.policyEscalation === true;
if (!escalation) throw err;
const proceed = window.confirm(
`${getErrorMessage(err)}\n\n${t(
"workflowColumns.confirmPolicyEscalation",
"Bind it anyway? The column agent will run with broader permissions than this project's default.",
)}`,
);
if (!proceed) {
addToast(t("workflowColumns.escalationDeclined", "Save cancelled — column agent binding not confirmed"), "error");
return;
}
await finishSave(
await updateWorkflow(activeWorkflow.id, { ...savePayload, confirmPolicyEscalation: true }, projectId),
);
}
} catch (err) {
const message = getErrorMessage(err) || t("workflows.saveFailed", "Failed to save workflow");
@@ -1693,6 +1726,13 @@ function InnerEditor({
// non-fatal — summaries fall back to raw ids — so the prefetch is toastless.
const [models, setModels] = useState<ModelInfo[]>([]);
const [agents, setAgents] = useState<Agent[]>([]);
// The agent fetches are project-scoped, but this cache survives project
// switches — both load paths short-circuit on agents.length > 0, which would
// keep showing (and let the editor bind) the PREVIOUS project's registry.
// Reset on project change so the next consumer refetches (PR #1432 review).
useEffect(() => {
setAgents([]);
}, [projectId]);
const [skills, setSkills] = useState<DiscoveredSkill[]>([]);
useEffect(() => {
@@ -1733,6 +1773,33 @@ function InnerEditor({
const currentExecutor = (selectedNode?.data.config?.executor as ExecutorKind | undefined) ?? "model";
// The override binding governing the selected node, if any: its declared
// column carries an `agent` in `override` mode. Drives the "overridden by
// column agent" note so authors don't diagnose override as a bug (R11). Keyed
// on the column id + binding, not array identity.
const overrideColumnBinding = useMemo(() => {
// Foreach template children don't carry their own column in irToFlow — they
// inherit the enclosing foreach group's column at execution (R4). Mirror that
// inheritance here so a step-execute prompt inside an override-bound foreach
// still shows the note (PR #1432 review).
const columnId =
selectedNode?.data.column
?? (selectedNode?.parentId
? nodes.find((n) => n.id === selectedNode.parentId)?.data.column
: undefined);
if (!columnId) return undefined;
const col = columns.find((c) => c.id === columnId);
if (!col?.agent || col.agent.mode !== "override") return undefined;
return col.agent;
}, [selectedNode?.data.column, selectedNode?.parentId, nodes, columns]);
// Resolve the override agent's display name from the loaded registry; when the
// id is stale (not in the list) fall back to the not-found treatment.
const overrideAgent = useMemo(
() => (overrideColumnBinding ? agents.find((a) => a.id === overrideColumnBinding.agentId) : undefined),
[overrideColumnBinding, agents],
);
useEffect(() => {
// step-review offers an optional review model picker (KTD-4).
if (selectedNode?.data.kind === "step-review" && models.length === 0) {
@@ -1747,7 +1814,10 @@ function InnerEditor({
addToast(getErrorMessage(err) || "Failed to load models", "error");
});
} else if (currentExecutor === "agent" && agents.length === 0) {
fetchAgents().then(setAgents).catch((err) => {
// Project-scoped, matching WorkflowColumnPanel's fetchAgents(undefined,
// projectId) — an unscoped fetch returns the wrong registry in
// multi-project deployments (PR #1432 review).
fetchAgents(undefined, projectId).then(setAgents).catch((err) => {
addToast(getErrorMessage(err) || "Failed to load agents", "error");
});
} else if (currentExecutor === "skill" && skills.length === 0) {
@@ -1807,6 +1877,25 @@ function InnerEditor({
[guardedDismiss, activeId],
);
// When the selected node sits in an override column, eagerly load the agent
// registry so the "overridden by column agent <name>" note can resolve the
// name even if this node's own executor isn't "agent".
useEffect(() => {
if (!overrideColumnBinding || agents.length > 0) return;
let cancelled = false;
// Project-scoped (PR #1432 review): without projectId this resolves from the
// wrong scope in multi-project deployments — the override note would show a
// false "not found" for a perfectly valid project agent.
Promise.resolve(fetchAgents(undefined, projectId)).then((list) => {
if (!cancelled) setAgents(list ?? []);
}).catch((err) => {
if (!cancelled) addToast(getErrorMessage(err) || "Failed to load agents", "error");
});
return () => {
cancelled = true;
};
}, [overrideColumnBinding, agents.length, projectId, addToast]);
const overlayProps = useOverlayDismiss(requestClose);
return (
@@ -1951,6 +2040,7 @@ function InnerEditor({
readOnly={isBuiltin}
projectId={projectId}
addToast={addToast}
columnAgentsEnabled={columnAgentsEnabled}
/>
)}
</section>
@@ -2483,6 +2573,19 @@ function InnerEditor({
</select>
</label>
{overrideColumnBinding && (
<p className="wf-inspector-note wf-inspector-note--warn" data-testid="wf-node-overridden-by-column-agent">
{t(
"workflowColumns.overriddenByColumnAgent",
"Overridden by column agent {{name}} — this node's executor settings are superseded.",
{
name: overrideAgent?.name
?? t("workflowColumns.agentNotFound", "Agent not found — {{id}}", { id: overrideColumnBinding.agentId }),
},
)}
</p>
)}
{currentExecutor === "model" && (
<label className="wf-field">
<span>Model</span>
@@ -2501,20 +2604,37 @@ function InnerEditor({
</label>
)}
{currentExecutor === "agent" && (
<label className="wf-field">
<span>Agent</span>
<select
value={String(selectedNode.data.config?.agentId ?? "")}
onChange={(e) => updateSelectedData({ config: { agentId: e.target.value || undefined } })}
>
<option value="">— select agent —</option>
{agents.map((a) => (
<option key={a.id} value={a.id}>{a.name}</option>
))}
</select>
</label>
)}
{currentExecutor === "agent" && (() => {
const nodeAgentId = String(selectedNode.data.config?.agentId ?? "");
// A stored id absent from the loaded registry would render the
// select blank; instead surface a not-found option that
// preserves the IR value until the author clears/replaces it.
const nodeAgentStale = nodeAgentId !== "" && !agents.some((a) => a.id === nodeAgentId);
return (
<label className="wf-field">
<span>Agent</span>
<select
value={nodeAgentId}
onChange={(e) => updateSelectedData({ config: { agentId: e.target.value || undefined } })}
>
<option value="">— select agent —</option>
{nodeAgentStale && (
<option value={nodeAgentId}>
{t("workflowColumns.agentNotFound", "Agent not found — {{id}}", { id: nodeAgentId })}
</option>
)}
{agents.map((a) => (
<option key={a.id} value={a.id}>{a.name}</option>
))}
</select>
{nodeAgentStale && (
<p className="wf-inspector-note wf-inspector-note--warn" data-testid="wf-node-agent-stale">
{t("workflowColumns.agentNotFound", "Agent not found — {{id}}", { id: nodeAgentId })}
</p>
)}
</label>
);
})()}
{currentExecutor === "skill" && (
<label className="wf-field">

View File

@@ -23,6 +23,7 @@ vi.mock("../../api", () => ({
fetchTaskDetail: vi.fn(),
batchUpdateTaskModels: vi.fn(),
fetchNodes: vi.fn().mockResolvedValue([]),
fetchBoardWorkflows: vi.fn().mockResolvedValue({ flagEnabled: false, defaultWorkflowId: "", workflows: [], taskWorkflowIds: {} }),
}));
import { fetchTaskDetail, batchUpdateTaskModels, fetchNodes } from "../../api";

View File

@@ -1,6 +1,7 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, waitFor, cleanup, within } from "@testing-library/react";
import type { WorkflowDefinition } from "@fusion/core";
import type { WorkflowDefinition, Settings } from "@fusion/core";
import type { Agent } from "../../api";
import { irToFlow, flowToIr, emptyWorkflowIr, emptyWorkflowLayout, foreachChildFlowId } from "../workflow-flow-mapping";
import { BUILTIN_STEPWISE_CODING_WORKFLOW_IR } from "@fusion/core";
@@ -31,15 +32,52 @@ vi.mock("../../api", () => ({
// exercise the Templates section don't reject the on-open prefetch.
fetchWorkflowStepTemplates: vi.fn().mockResolvedValue({ templates: [] }),
fetchPluginWorkflowStepTemplates: vi.fn().mockResolvedValue({ templates: [] }),
// useAppSettings (threaded into the editor for the column-agent flag gate, U6)
// imports these from the same module; provide resolved stubs so the real hook
// does not throw on undefined fns.
fetchConfig: vi.fn(),
fetchSettings: vi.fn(),
updateSettings: vi.fn(),
updateGlobalSettings: vi.fn(),
}));
import { fireEvent } from "@testing-library/react";
import { fetchWorkflows, fetchTraits, fetchStepParsers, updateWorkflow, compileWorkflow, createWorkflow, deleteWorkflow, fetchModels, migrateLegacyWorkflowSteps, exportWorkflow, importWorkflow, designWorkflow, ApiRequestError, fetchWorkflowStepTemplates, fetchPluginWorkflowStepTemplates } from "../../api";
import {
fetchWorkflows,
fetchTraits,
fetchStepParsers,
updateWorkflow,
compileWorkflow,
createWorkflow,
deleteWorkflow,
fetchModels,
migrateLegacyWorkflowSteps,
exportWorkflow,
importWorkflow,
designWorkflow,
ApiRequestError,
fetchWorkflowStepTemplates,
fetchPluginWorkflowStepTemplates,
fetchAgents,
fetchConfig,
fetchSettings,
} from "../../api";
import type { TraitCatalogEntry } from "../../api";
import type { WorkflowStepTemplate } from "@fusion/core";
import { beforeEach as viBeforeEach } from "vitest";
import { WorkflowNodeEditor } from "../WorkflowNodeEditor";
import { ConfirmDialogProvider } from "../../hooks/useConfirm";
// useAppSettings (threaded into the editor for the column-agent flag gate)
// fetches config + settings on mount via the mocked api module. Default both to
// resolved empties for every test so the real hook never rejects; column-agent
// tests override fetchSettings to flip the flags on. fetchAgents defaults empty.
viBeforeEach(() => {
vi.mocked(fetchConfig).mockResolvedValue({ maxConcurrent: 2, rootDir: "." });
vi.mocked(fetchSettings).mockResolvedValue({} as never);
vi.mocked(fetchAgents).mockResolvedValue([]);
});
const TRAIT_CATALOG: TraitCatalogEntry[] = [
{ id: "intake", name: "Intake", builtin: true, flags: { intake: true } },
{ id: "complete", name: "Complete", builtin: true, flags: { complete: true } },
@@ -492,12 +530,16 @@ describe("WorkflowNodeEditor — U8 step-inversion authoring", () => {
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
await screen.findByText("Save");
// Wait for graph/column hydration before driving the palette — clicking
// mid-hydration races the flow-state seeding and the added node may never
// render (same flake class as the seam-in-branch badge deflake, 86867c57b).
expect(await screen.findByTestId("wf-column-panel")).toBeInTheDocument();
// Adding a foreach renders a group node with an empty inspector hint absent
// (it has a child) and an inspector for the foreach.
fireEvent.click(screen.getByText("For-each step").closest("button")!);
// 3s timeout: React Flow group-node mount can exceed the 1s default under
// 5s timeout: React Flow group-node mount can exceed the 1s default under
// cold-transform shard load (observed intermittently in CI-like runs).
await waitFor(() => expect(screen.getByTestId("wf-node-foreach")).toBeInTheDocument(), { timeout: 3000 });
await waitFor(() => expect(screen.getByTestId("wf-node-foreach")).toBeInTheDocument(), { timeout: 5000 });
// The foreach inspector shows the Mode select (KTD-3).
expect(screen.getByText("Mode")).toBeInTheDocument();
// No empty-state hint because the palette seeded a step-execute child.
@@ -1852,3 +1894,156 @@ describe("WorkflowNodeEditor — U10 design-with-AI", () => {
expect(screen.queryByTestId("wf-node-script")).not.toBeInTheDocument();
});
});
// ── U6: per-column agent picker, mode toggle, stale-id + override surfaces ────
function flagsOn(): Settings {
return { experimentalFeatures: { workflowColumns: true, workflowGraphExecutor: true } } as Settings;
}
function agentList(): Agent[] {
return [
{ id: "agent-001", name: "Reviewer" } as Agent,
{ id: "agent-002", name: "Implementer" } as Agent,
];
}
/** A v2 def whose `triage` column binds agent-001 in the given mode, and whose
* `step` node is declared in `triage` (so an override note can surface). */
function boundDef(mode: "defer" | "override", agentId = "agent-001"): WorkflowDefinition {
const d = v2Def();
if (d.ir.version === "v2") {
d.ir.columns = d.ir.columns.map((c) =>
c.id === "triage" ? { ...c, agent: { agentId, mode } } : c,
);
}
return d;
}
describe("WorkflowNodeEditor — U6 column agents", () => {
beforeEach(() => {
vi.mocked(fetchTraits).mockResolvedValue(TRAIT_CATALOG);
vi.mocked(fetchStepParsers).mockResolvedValue(["step-headings", "json-steps"]);
vi.mocked(fetchSettings).mockResolvedValue(flagsOn());
vi.mocked(fetchAgents).mockResolvedValue(agentList());
vi.mocked(fetchModels).mockResolvedValue({ models: [] });
});
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
it("renders the per-column agent picker enabled with registry agents when flags are on", async () => {
vi.mocked(fetchWorkflows).mockResolvedValue([v2Def()]);
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
const picker = (await screen.findByTestId("wf-column-agent-select-triage")) as HTMLSelectElement;
await waitFor(() => expect(picker.disabled).toBe(false));
await waitFor(() =>
expect(Array.from(picker.options).some((o) => o.value === "agent-001")).toBe(true),
);
// "(none)" is the default selection for an unbound column.
expect(picker.value).toBe("");
});
it("disables the picker with a flag-naming hint when the flags are off", async () => {
vi.mocked(fetchSettings).mockResolvedValue({} as Settings);
vi.mocked(fetchWorkflows).mockResolvedValue([v2Def()]);
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
const picker = (await screen.findByTestId("wf-column-agent-select-triage")) as HTMLSelectElement;
await waitFor(() => expect(picker.disabled).toBe(true));
expect(picker.title).toMatch(/workflowColumns/);
expect(picker.title).toMatch(/workflowGraphExecutor/);
});
it("selecting an agent reveals the defer/override mode toggle (default defer) and writes the binding", async () => {
vi.mocked(fetchWorkflows).mockResolvedValue([v2Def()]);
vi.mocked(updateWorkflow).mockImplementation(async (_id, updates) => ({ ...v2Def(), ...(updates as object) }));
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
const picker = (await screen.findByTestId("wf-column-agent-select-triage")) as HTMLSelectElement;
await waitFor(() => expect(picker.disabled).toBe(false));
fireEvent.change(picker, { target: { value: "agent-001" } });
// Mode toggle appears; defer is checked by default.
const deferRadio = (await screen.findByText("Defer")).closest("label")!.querySelector("input")! as HTMLInputElement;
expect(deferRadio.checked).toBe(true);
// Badge reflects the bound agent name.
expect(await screen.findByTestId("wf-column-agent-badge-triage")).toHaveTextContent("Reviewer");
// Save round-trips the binding into the IR.
fireEvent.click(screen.getByText("Save").closest("button")!);
await waitFor(() => expect(updateWorkflow).toHaveBeenCalled());
const [, updates] = vi.mocked(updateWorkflow).mock.calls[0];
const cols = (updates as { ir: { columns: { id: string; agent?: { agentId: string; mode: string } }[] } }).ir.columns;
const triage = cols.find((c) => c.id === "triage");
expect(triage?.agent).toEqual({ agentId: "agent-001", mode: "defer" });
});
it("toggling the mode to override saves the binding with mode: override", async () => {
// Start from a deferred binding so the mode toggle is already visible.
vi.mocked(fetchWorkflows).mockResolvedValue([boundDef("defer")]);
vi.mocked(updateWorkflow).mockImplementation(async (_id, updates) => ({ ...boundDef("defer"), ...(updates as object) }));
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
const picker = (await screen.findByTestId("wf-column-agent-select-triage")) as HTMLSelectElement;
await waitFor(() => expect(picker.value).toBe("agent-001"));
// Defer is the initial mode; flip to Override.
const deferRadio = (await screen.findByText("Defer")).closest("label")!.querySelector("input")! as HTMLInputElement;
expect(deferRadio.checked).toBe(true);
const overrideRadio = screen.getByText("Override").closest("label")!.querySelector("input")! as HTMLInputElement;
fireEvent.click(overrideRadio);
await waitFor(() => expect(overrideRadio.checked).toBe(true));
// Save round-trips the updated mode into the IR.
fireEvent.click(screen.getByText("Save").closest("button")!);
await waitFor(() => expect(updateWorkflow).toHaveBeenCalled());
const [, updates] = vi.mocked(updateWorkflow).mock.calls[0];
const cols = (updates as { ir: { columns: { id: string; agent?: { agentId: string; mode: string } }[] } }).ir.columns;
const triage = cols.find((c) => c.id === "triage");
expect(triage?.agent).toEqual({ agentId: "agent-001", mode: "override" });
});
it("clearing to (none) removes the agent key entirely (no agent: null)", async () => {
vi.mocked(fetchWorkflows).mockResolvedValue([boundDef("defer")]);
vi.mocked(updateWorkflow).mockImplementation(async (_id, updates) => ({ ...boundDef("defer"), ...(updates as object) }));
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
const picker = (await screen.findByTestId("wf-column-agent-select-triage")) as HTMLSelectElement;
await waitFor(() => expect(picker.value).toBe("agent-001"));
fireEvent.change(picker, { target: { value: "" } });
fireEvent.click(screen.getByText("Save").closest("button")!);
await waitFor(() => expect(updateWorkflow).toHaveBeenCalled());
const [, updates] = vi.mocked(updateWorkflow).mock.calls[0];
const cols = (updates as { ir: { columns: { id: string; agent?: unknown }[] } }).ir.columns;
const triage = cols.find((c) => c.id === "triage")!;
expect("agent" in triage).toBe(false);
});
it("renders a not-found warning for a stored agentId absent from the registry, preserving the value", async () => {
vi.mocked(fetchWorkflows).mockResolvedValue([boundDef("defer", "agent-ghost")]);
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
// The stale id surfaces a not-found annotation and remains the picker value.
const stale = await screen.findByTestId("wf-column-agent-stale-triage");
expect(stale).toHaveTextContent(/agent-ghost/);
const picker = screen.getByTestId("wf-column-agent-select-triage") as HTMLSelectElement;
expect(picker.value).toBe("agent-ghost");
});
it("surfaces an inline error near the picker when the agents fetch fails", async () => {
vi.mocked(fetchAgents).mockRejectedValue(new Error("agents offline"));
vi.mocked(fetchWorkflows).mockResolvedValue([v2Def()]);
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
await screen.findByTestId("wf-column-panel");
await waitFor(() => expect(screen.getAllByText(/agents offline/i).length).toBeGreaterThan(0));
});
it("shows the overridden-by-column-agent note on a node inside an override column", async () => {
vi.mocked(fetchWorkflows).mockResolvedValue([boundDef("override")]);
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
// Select the prompt node placed in the override column.
const node = await screen.findByTestId("wf-node-prompt");
fireEvent.click(node);
const note = await screen.findByTestId("wf-node-overridden-by-column-agent");
expect(note).toHaveTextContent(/Overridden by column agent/i);
expect(note).toHaveTextContent("Reviewer");
});
});

View File

@@ -209,13 +209,13 @@ describe("auto-merge toggle mobile blank regression", () => {
expectBoardVisible();
act(() => {
vi.runAllTimers();
vi.runOnlyPendingTimers();
});
board.scrollLeft = 240;
act(() => {
visualViewport.dispatchResize();
vi.runAllTimers();
vi.runOnlyPendingTimers();
});
expect(board.scrollLeft).toBe(0);
@@ -227,7 +227,7 @@ describe("auto-merge toggle mobile blank regression", () => {
board.scrollLeft = 240;
act(() => {
visualViewport.dispatchResize();
vi.runAllTimers();
vi.runOnlyPendingTimers();
});
expectBoardVisible();
@@ -248,7 +248,7 @@ describe("auto-merge toggle mobile blank regression", () => {
const board = document.querySelector("main.board") as HTMLElement;
act(() => {
vi.runAllTimers();
vi.runOnlyPendingTimers();
});
const toggle = screen.getByRole("checkbox", { name: "Auto-merge" });
@@ -260,7 +260,7 @@ describe("auto-merge toggle mobile blank regression", () => {
board.scrollLeft = 180;
act(() => {
visualViewport.dispatchResize();
vi.runAllTimers();
vi.runOnlyPendingTimers();
});
expectBoardVisible();
expect(board.scrollLeft).toBe(0);
@@ -270,7 +270,7 @@ describe("auto-merge toggle mobile blank regression", () => {
board.scrollLeft = 180;
act(() => {
visualViewport.dispatchResize();
vi.runAllTimers();
vi.runOnlyPendingTimers();
});
expectBoardVisible();
expect(board.scrollLeft).toBe(0);
@@ -296,7 +296,7 @@ describe("auto-merge toggle mobile blank regression", () => {
);
act(() => {
vi.runAllTimers();
vi.runOnlyPendingTimers();
});
expect(screen.getByTestId("task-card-FN-5936")).toHaveTextContent("true");
@@ -322,7 +322,7 @@ describe("auto-merge toggle mobile blank regression", () => {
const board = document.querySelector("main.board") as HTMLElement;
act(() => {
vi.runAllTimers();
vi.runOnlyPendingTimers();
});
fireEvent.click(screen.getByRole("checkbox", { name: "Auto-merge" }));
@@ -332,7 +332,7 @@ describe("auto-merge toggle mobile blank regression", () => {
Object.defineProperty(pageShow, "persisted", { configurable: true, value: true });
act(() => {
window.dispatchEvent(pageShow);
vi.runAllTimers();
vi.runOnlyPendingTimers();
});
expectBoardVisible();

View File

@@ -109,7 +109,7 @@ describe("Board mobile initial render stabilization (FN-4574)", () => {
board.scrollLeft = 500;
act(() => {
vi.runAllTimers();
vi.runOnlyPendingTimers();
});
expect(board.scrollLeft).toBe(0);
expect(raf).toHaveBeenCalled();
@@ -130,7 +130,7 @@ describe("Board mobile initial render stabilization (FN-4574)", () => {
const board = document.querySelector("main.board") as HTMLElement;
act(() => {
vi.runAllTimers();
vi.runOnlyPendingTimers();
});
expect(board.scrollLeft).toBe(0);
@@ -140,7 +140,7 @@ describe("Board mobile initial render stabilization (FN-4574)", () => {
window.dispatchEvent(pageShow);
act(() => {
vi.runAllTimers();
vi.runOnlyPendingTimers();
});
expect(board.scrollLeft).toBe(0);
@@ -190,7 +190,7 @@ describe("Board mobile initial render stabilization (FN-4574)", () => {
window.dispatchEvent(pageShow);
act(() => {
vi.runAllTimers();
vi.runOnlyPendingTimers();
});
expect(board.scrollLeft).toBe(500);
expect(addEventListenerSpy).not.toHaveBeenCalledWith("pageshow", expect.any(Function));

View File

@@ -388,7 +388,18 @@ export function flowToIr(
const ir: WorkflowIrV2 = {
version: "v2",
name,
columns: hasColumns ? columns!.map((c) => ({ id: c.id, name: c.name, traits: c.traits })) : [],
// Preserve the optional column-agent binding through the editor round-trip
// (column-agent plan U6). Omit the `agent` key entirely when unset so
// legacy/default workflows stay byte-identical (R9) — never emit
// `agent: undefined`/`agent: null`.
columns: hasColumns
? columns!.map((c) => ({
id: c.id,
name: c.name,
traits: c.traits,
...(c.agent ? { agent: c.agent } : {}),
}))
: [],
nodes: irNodes,
edges: irEdges,
};

View File

@@ -28,6 +28,7 @@ vi.mock("@fusion/core", () => ({
summarizeTitle: vi.fn(),
AgentStore: vi.fn(),
ChatStore: vi.fn(),
registerTraitHookImpl: vi.fn(),
}));
describe("resolveFileReferences", () => {

View File

@@ -280,6 +280,9 @@ describe("POST /api/plugins mode:install — package root path", () => {
beforeEach(() => {
vi.clearAllMocks();
// Install now registers the loadable entry file; pretend each
// package ships an esbuild bundle.
mockExistsSync.mockImplementation((p: string) => p.endsWith("bundled.js"));
pluginStore = createMockPluginStore();
pluginLoader = createMockPluginLoader();
store = createMockTaskStore({
@@ -313,11 +316,54 @@ describe("POST /api/plugins mode:install — package root path", () => {
expect(pluginStore.registerPlugin).toHaveBeenCalledWith(
expect.objectContaining({
manifest: expect.objectContaining({ id: "my-plugin" }),
path: pkgRoot,
// Registered path is the loadable entry file inside the package root
path: `${pkgRoot}/bundled.js`,
}),
);
});
it("falls back to dist/index.js when no bundled.js exists", async () => {
const pkgRoot = "/home/user/plugins/my-plugin";
mockAccess.mockImplementation((p: string) => {
if (p === pkgRoot || p === `${pkgRoot}/manifest.json`) return Promise.resolve();
return Promise.reject(new Error("not found"));
});
mockReadFile.mockResolvedValue(JSON.stringify(VALID_MANIFEST));
mockExistsSync.mockImplementation((p: string) => p === `${pkgRoot}/dist/index.js`);
(pluginStore.registerPlugin as ReturnType<typeof vi.fn>).mockResolvedValue(INSTALLED_PLUGIN);
const res = await REQUEST(buildApp(), "POST", "/api/plugins", {
mode: "install",
path: pkgRoot,
});
expect(res.status).toBe(201);
expect(pluginStore.registerPlugin).toHaveBeenCalledWith(
expect.objectContaining({ path: `${pkgRoot}/dist/index.js` }),
);
});
it("falls back to src/index.ts for workspace-dev packages without build outputs", async () => {
const pkgRoot = "/home/user/plugins/my-plugin";
mockAccess.mockImplementation((p: string) => {
if (p === pkgRoot || p === `${pkgRoot}/manifest.json`) return Promise.resolve();
return Promise.reject(new Error("not found"));
});
mockReadFile.mockResolvedValue(JSON.stringify(VALID_MANIFEST));
mockExistsSync.mockImplementation((p: string) => p === `${pkgRoot}/src/index.ts`);
(pluginStore.registerPlugin as ReturnType<typeof vi.fn>).mockResolvedValue(INSTALLED_PLUGIN);
const res = await REQUEST(buildApp(), "POST", "/api/plugins", {
mode: "install",
path: pkgRoot,
});
expect(res.status).toBe(201);
expect(pluginStore.registerPlugin).toHaveBeenCalledWith(
expect.objectContaining({ path: `${pkgRoot}/src/index.ts` }),
);
});
it("accepts a dist folder path with valid manifest.json and returns 201", async () => {
const distPath = "/home/user/plugins/my-plugin/dist";
mockAccess.mockImplementation((p: string) => {
@@ -338,7 +384,7 @@ describe("POST /api/plugins mode:install — package root path", () => {
expect(res.status).toBe(201);
expect(res.body).toMatchObject({ id: "my-plugin" });
expect(pluginStore.registerPlugin).toHaveBeenCalledWith(
expect.objectContaining({ path: distPath }),
expect.objectContaining({ path: `${distPath}/bundled.js` }),
);
});
@@ -434,6 +480,7 @@ describe("POST /api/plugins central persistence integration", () => {
if (p === pluginPath || p === `${pluginPath}/manifest.json`) return Promise.resolve();
return Promise.reject(new Error("not found"));
});
mockExistsSync.mockImplementation((p: string) => p.endsWith("bundled.js"));
mockReadFile.mockResolvedValueOnce(JSON.stringify(VALID_MANIFEST));
const app = buildRealApp(pluginStore);
@@ -471,6 +518,9 @@ describe("POST /api/plugins mode:install — bundled plugin path fallback", () =
beforeEach(() => {
vi.clearAllMocks();
// Install now registers the loadable entry file; pretend each
// package ships an esbuild bundle.
mockExistsSync.mockImplementation((p: string) => p.endsWith("bundled.js"));
pluginStore = createMockPluginStore();
pluginLoader = createMockPluginLoader();
store = createMockTaskStore({
@@ -491,7 +541,7 @@ describe("POST /api/plugins mode:install — bundled plugin path fallback", () =
id: "fusion-plugin-dependency-graph",
name: "Dependency Graph",
};
mockExistsSync.mockImplementation((p: string) => p.includes("fusion-plugin-dependency-graph/manifest.json"));
mockExistsSync.mockImplementation((p: string) => p.includes("fusion-plugin-dependency-graph/manifest.json") || p.endsWith("bundled.js"));
mockAccess.mockImplementation((p: string) => {
if (p.includes("fusion-plugin-dependency-graph")) return Promise.resolve();
return Promise.reject(new Error("not found"));
@@ -512,7 +562,7 @@ describe("POST /api/plugins mode:install — bundled plugin path fallback", () =
expect(pluginStore.registerPlugin).toHaveBeenCalledWith(
expect.objectContaining({
manifest: expect.objectContaining({ id: "fusion-plugin-dependency-graph" }),
path: expect.stringContaining("fusion-plugin-dependency-graph"),
path: expect.stringMatching(/fusion-plugin-dependency-graph[\\/]bundled\.js$/),
}),
);
});
@@ -523,7 +573,7 @@ describe("POST /api/plugins mode:install — bundled plugin path fallback", () =
id: "fusion-plugin-reports",
name: "Reports",
};
mockExistsSync.mockImplementation((p: string) => p.includes("fusion-plugin-reports/manifest.json"));
mockExistsSync.mockImplementation((p: string) => p.includes("fusion-plugin-reports/manifest.json") || p.endsWith("bundled.js"));
mockAccess.mockImplementation((p: string) => {
if (p.includes("fusion-plugin-reports")) return Promise.resolve();
return Promise.reject(new Error("not found"));
@@ -544,7 +594,7 @@ describe("POST /api/plugins mode:install — bundled plugin path fallback", () =
expect(pluginStore.registerPlugin).toHaveBeenCalledWith(
expect.objectContaining({
manifest: expect.objectContaining({ id: "fusion-plugin-reports" }),
path: expect.stringContaining("fusion-plugin-reports"),
path: expect.stringMatching(/fusion-plugin-reports[\\/]bundled\.js$/),
}),
);
});
@@ -557,7 +607,9 @@ describe("POST /api/plugins mode:install — bundled plugin path fallback", () =
};
// Only the staged bundled copy under dist/plugins exists — the
// cwd-relative path must miss so the bundled fallback is exercised.
mockExistsSync.mockImplementation((p: string) => p.includes("dist/plugins/fusion-plugin-compound-engineering/manifest.json"));
mockExistsSync.mockImplementation((p: string) =>
p.includes("dist/plugins/fusion-plugin-compound-engineering/manifest.json")
|| p.includes("dist/plugins/fusion-plugin-compound-engineering/bundled.js"));
mockAccess.mockImplementation((p: string) => {
if (p.includes("dist/plugins/fusion-plugin-compound-engineering")) return Promise.resolve();
return Promise.reject(new Error("not found"));
@@ -575,10 +627,12 @@ describe("POST /api/plugins mode:install — bundled plugin path fallback", () =
});
expect(res.status).toBe(201);
// The registered path must be the loadable entry FILE, not the
// package directory — the loader rejects directory imports.
expect(pluginStore.registerPlugin).toHaveBeenCalledWith(
expect.objectContaining({
manifest: expect.objectContaining({ id: "fusion-plugin-compound-engineering" }),
path: expect.stringContaining("fusion-plugin-compound-engineering"),
path: expect.stringMatching(/fusion-plugin-compound-engineering[\\/]bundled\.js$/),
}),
);
});
@@ -591,7 +645,9 @@ describe("POST /api/plugins mode:install — bundled plugin path fallback", () =
};
// Only the staged bundled copy under dist/plugins exists — the
// cwd-relative path must miss so the bundled fallback is exercised.
mockExistsSync.mockImplementation((p: string) => p.includes("dist/plugins/fusion-plugin-cli-printing-press/manifest.json"));
mockExistsSync.mockImplementation((p: string) =>
p.includes("dist/plugins/fusion-plugin-cli-printing-press/manifest.json")
|| p.includes("dist/plugins/fusion-plugin-cli-printing-press/bundled.js"));
mockAccess.mockImplementation((p: string) => {
if (p.includes("dist/plugins/fusion-plugin-cli-printing-press")) return Promise.resolve();
return Promise.reject(new Error("not found"));
@@ -612,7 +668,7 @@ describe("POST /api/plugins mode:install — bundled plugin path fallback", () =
expect(pluginStore.registerPlugin).toHaveBeenCalledWith(
expect.objectContaining({
manifest: expect.objectContaining({ id: "fusion-plugin-cli-printing-press" }),
path: expect.stringContaining("fusion-plugin-cli-printing-press"),
path: expect.stringMatching(/fusion-plugin-cli-printing-press[\\/]bundled\.js$/),
}),
);
});
@@ -648,7 +704,7 @@ describe("POST /api/plugins mode:install — bundled plugin path fallback", () =
});
});
describe("POST /api/plugins mode:install — negative paths", () => {
describe("POST /api/plugins/:id/enable — legacy directory path heal", () => {
let pluginStore: PluginStore;
let pluginLoader: PluginLoader;
let store: TaskStore;
@@ -669,6 +725,110 @@ describe("POST /api/plugins mode:install — negative paths", () => {
return app;
}
it("re-points a directory plugin path at its loadable entry before loading", async () => {
// Legacy registration stored the package directory; the loader rejects
// directory imports, so enable must heal the path first.
const dirPath = "/home/user/plugins/my-plugin";
mockStatSync.mockReturnValue({ isDirectory: () => true });
mockExistsSync.mockImplementation((p: string) => p === `${dirPath}/bundled.js`);
(pluginStore.enablePlugin as ReturnType<typeof vi.fn>).mockResolvedValue({
...INSTALLED_PLUGIN,
path: dirPath,
});
(pluginStore.updatePlugin as ReturnType<typeof vi.fn>).mockResolvedValue({
...INSTALLED_PLUGIN,
path: `${dirPath}/bundled.js`,
});
const res = await REQUEST(buildApp(), "POST", "/api/plugins/my-plugin/enable", {});
expect(res.status).toBe(200);
expect(pluginStore.updatePlugin).toHaveBeenCalledWith("my-plugin", { path: `${dirPath}/bundled.js` });
expect(pluginLoader.loadPlugin).toHaveBeenCalledWith("my-plugin");
});
it("heals directory paths in createPluginRouter's enable handler too", async () => {
const dirPath = "/home/user/plugins/my-plugin";
mockStat.mockResolvedValue({ isDirectory: () => true });
mockExistsSync.mockImplementation((p: string) => p === `${dirPath}/bundled.js`);
(pluginStore.enablePlugin as ReturnType<typeof vi.fn>).mockResolvedValue({
...INSTALLED_PLUGIN,
path: dirPath,
});
(pluginStore.updatePlugin as ReturnType<typeof vi.fn>).mockResolvedValue({
...INSTALLED_PLUGIN,
path: `${dirPath}/bundled.js`,
});
const app = express();
app.use(express.json());
app.use("/api/plugins", createPluginRouter(pluginStore, pluginLoader));
const res = await REQUEST(app, "POST", "/api/plugins/my-plugin/enable", {});
expect(res.status).toBe(200);
expect(pluginStore.updatePlugin).toHaveBeenCalledWith("my-plugin", { path: `${dirPath}/bundled.js` });
expect(pluginLoader.loadPlugin).toHaveBeenCalledWith("my-plugin");
});
it("leaves file paths untouched on enable", async () => {
mockStatSync.mockReturnValue({ isDirectory: () => false });
(pluginStore.enablePlugin as ReturnType<typeof vi.fn>).mockResolvedValue({
...INSTALLED_PLUGIN,
path: "/home/user/plugins/my-plugin/bundled.js",
});
const res = await REQUEST(buildApp(), "POST", "/api/plugins/my-plugin/enable", {});
expect(res.status).toBe(200);
expect(pluginStore.updatePlugin).not.toHaveBeenCalled();
expect(pluginLoader.loadPlugin).toHaveBeenCalledWith("my-plugin");
});
});
describe("POST /api/plugins mode:install — negative paths", () => {
let pluginStore: PluginStore;
let pluginLoader: PluginLoader;
let store: TaskStore;
beforeEach(() => {
vi.clearAllMocks();
// Install now registers the loadable entry file; pretend each
// package ships an esbuild bundle.
mockExistsSync.mockImplementation((p: string) => p.endsWith("bundled.js"));
pluginStore = createMockPluginStore();
pluginLoader = createMockPluginLoader();
store = createMockTaskStore({
getPluginStore: vi.fn().mockReturnValue(pluginStore),
});
});
function buildApp() {
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store, { pluginStore, pluginLoader }));
return app;
}
it("returns 400 when the package has no loadable entry file", async () => {
const pkgRoot = "/home/user/plugins/my-plugin";
mockAccess.mockImplementation((p: string) => {
if (p === pkgRoot || p === `${pkgRoot}/manifest.json`) return Promise.resolve();
return Promise.reject(new Error("not found"));
});
mockReadFile.mockResolvedValue(JSON.stringify(VALID_MANIFEST));
// Manifest resolves, but no bundled.js / dist/index.js / src/index.ts exists.
mockExistsSync.mockReturnValue(false);
const res = await REQUEST(buildApp(), "POST", "/api/plugins", {
mode: "install",
path: pkgRoot,
});
expect(res.status).toBe(400);
expect(res.body.error).toContain("no loadable entry file");
expect(pluginStore.registerPlugin).not.toHaveBeenCalled();
});
it("returns 404 when path does not exist", async () => {
mockAccess.mockRejectedValue(new Error("not found"));
@@ -835,6 +995,9 @@ describe("POST /api/plugins mode:install — manifest validation edge cases", ()
beforeEach(() => {
vi.clearAllMocks();
// Install now registers the loadable entry file; pretend each
// package ships an esbuild bundle.
mockExistsSync.mockImplementation((p: string) => p.endsWith("bundled.js"));
pluginStore = createMockPluginStore();
pluginLoader = createMockPluginLoader();
store = createMockTaskStore({
@@ -921,6 +1084,9 @@ describe("POST /api/plugins mode:install — dist-folder parent resolution", ()
beforeEach(() => {
vi.clearAllMocks();
// Install now registers the loadable entry file; pretend each
// package ships an esbuild bundle.
mockExistsSync.mockImplementation((p: string) => p.endsWith("bundled.js"));
pluginStore = createMockPluginStore({
registerPlugin: vi.fn().mockResolvedValue(INSTALLED_PLUGIN),
});
@@ -954,7 +1120,7 @@ describe("POST /api/plugins mode:install — dist-folder parent resolution", ()
expect(res.status).toBe(201);
expect(pluginStore.registerPlugin).toHaveBeenCalledWith(
expect.objectContaining({ path: parentPath }),
expect.objectContaining({ path: `${parentPath}/bundled.js` }),
);
});
@@ -974,7 +1140,7 @@ describe("POST /api/plugins mode:install — dist-folder parent resolution", ()
expect(res.status).toBe(201);
expect(pluginStore.registerPlugin).toHaveBeenCalledWith(
expect.objectContaining({ path: parentPath }),
expect.objectContaining({ path: `${parentPath}/bundled.js` }),
);
});
@@ -994,7 +1160,7 @@ describe("POST /api/plugins mode:install — dist-folder parent resolution", ()
expect(res.status).toBe(201);
expect(pluginStore.registerPlugin).toHaveBeenCalledWith(
expect.objectContaining({ path: parentPath }),
expect.objectContaining({ path: `${parentPath}/bundled.js` }),
);
});
@@ -1032,9 +1198,9 @@ describe("POST /api/plugins mode:install — dist-folder parent resolution", ()
});
expect(res.status).toBe(201);
// Should use the dist dir path since it has its own manifest
// Should use the dist dir entry since it has its own manifest
expect(pluginStore.registerPlugin).toHaveBeenCalledWith(
expect.objectContaining({ path: distPath }),
expect.objectContaining({ path: `${distPath}/bundled.js` }),
);
});
});
@@ -1049,6 +1215,9 @@ describe("GET /api/plugins/dashboard-views", () => {
beforeEach(() => {
vi.clearAllMocks();
// Install now registers the loadable entry file; pretend each
// package ships an esbuild bundle.
mockExistsSync.mockImplementation((p: string) => p.endsWith("bundled.js"));
pluginStore = createMockPluginStore();
pluginLoader = createMockPluginLoader();
store = createMockTaskStore({
@@ -1166,6 +1335,9 @@ describe("GET /api/plugins/ui-slots", () => {
beforeEach(() => {
vi.clearAllMocks();
// Install now registers the loadable entry file; pretend each
// package ships an esbuild bundle.
mockExistsSync.mockImplementation((p: string) => p.endsWith("bundled.js"));
pluginStore = createMockPluginStore();
pluginLoader = createMockPluginLoader();
store = createMockTaskStore({
@@ -1335,6 +1507,9 @@ describe("GET /api/plugins/ui-contributions", () => {
beforeEach(() => {
vi.clearAllMocks();
// Install now registers the loadable entry file; pretend each
// package ships an esbuild bundle.
mockExistsSync.mockImplementation((p: string) => p.endsWith("bundled.js"));
pluginStore = createMockPluginStore();
pluginLoader = createMockPluginLoader();
store = createMockTaskStore({
@@ -1842,6 +2017,9 @@ describe("GET /api/plugins/runtimes", () => {
beforeEach(() => {
vi.clearAllMocks();
// Install now registers the loadable entry file; pretend each
// package ships an esbuild bundle.
mockExistsSync.mockImplementation((p: string) => p.endsWith("bundled.js"));
pluginStore = createMockPluginStore();
pluginLoader = createMockPluginLoader();
store = createMockTaskStore({

View File

@@ -75,6 +75,7 @@ vi.mock("@fusion/core", () => {
isEphemeralAgent: (agent: { metadata?: Record<string, unknown> }) =>
agent?.metadata?.agentKind === "task-worker",
deterministicGuardLocks: new Map(),
registerTraitHookImpl: () => {},
};
});

View File

@@ -446,3 +446,205 @@ describe("workflow routes (U4)", () => {
});
});
});
// ── U6: write-time column-agent validation (existence + policy escalation) ────
describe("workflow routes — column agents (U6)", () => {
let store: TaskStore;
let rootDir: string;
let globalDir: string;
let app: express.Express;
/** A v2 workflow whose `triage` column optionally binds an agent. */
function boundIr(agent?: { agentId: string; mode: "defer" | "override" }): WorkflowIr {
return {
version: "v2",
name: "bound",
columns: [
{ id: "triage", name: "Triage", traits: [{ trait: "intake" }], ...(agent ? { agent } : {}) },
{ id: "done", name: "Done", traits: [{ trait: "complete" }] },
],
nodes: [
{ id: "start", kind: "start", column: "triage" },
{ id: "work", kind: "prompt", column: "triage", config: { prompt: "do" } },
{ id: "end", kind: "end", column: "done" },
],
edges: [
{ from: "start", to: "work", condition: "success" },
{ from: "work", to: "end", condition: "success" },
],
} as WorkflowIr;
}
async function makeAgent(input: { permissionPolicy?: { presetId: "unrestricted" | "approval-required" | "locked-down" | "custom"; rules?: Record<string, string> } }): Promise<string> {
const { AgentStore } = await import("@fusion/core");
const agentStore = new AgentStore({ rootDir: store.getFusionDir() });
await agentStore.init();
const agent = await agentStore.createAgent({
name: `Agent ${Math.random().toString(36).slice(2, 8)}`,
role: "executor",
permissionPolicy: input.permissionPolicy as never,
});
return agent.id;
}
beforeEach(async () => {
rootDir = mkdtempSync(join(tmpdir(), "wf-ca-root-"));
globalDir = mkdtempSync(join(tmpdir(), "wf-ca-global-"));
store = new TaskStore(rootDir, globalDir, { inMemoryDb: true });
await store.init();
app = express();
app.use(express.json());
const router = express.Router();
registerWorkflowRoutes({
router,
getProjectContext: async () => ({ store, engine: undefined, projectId: undefined }),
rethrowAsApiError: (err: unknown) => {
throw err instanceof ApiError ? err : new ApiError(500, err instanceof Error ? err.message : String(err));
},
} as unknown as Parameters<typeof registerWorkflowRoutes>[0]);
app.use("/api", router);
app.use((err: unknown, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
if (err instanceof ApiError) sendErrorResponse(res, err.statusCode, err.message, { details: err.details });
else sendErrorResponse(res, 500, err instanceof Error ? err.message : String(err));
});
});
afterEach(() => {
store.close();
rmSync(rootDir, { recursive: true, force: true });
rmSync(globalDir, { recursive: true, force: true });
});
const post = (path: string, body: unknown) =>
request(app, "POST", path, JSON.stringify(body), { "content-type": "application/json" });
const patch = (path: string, body: unknown) =>
request(app, "PATCH", path, JSON.stringify(body), { "content-type": "application/json" });
const get = (path: string) => request(app, "GET", path);
it("persists a valid agent binding and round-trips it through GET", async () => {
const agentId = await makeAgent({});
const res = await post("/api/workflows", { name: "Bound", ir: boundIr({ agentId, mode: "defer" }) });
expect(res.status).toBe(201);
const id = (res.body as { id: string }).id;
const fetched = await get(`/api/workflows/${id}`);
expect(fetched.status).toBe(200);
const ir = (fetched.body as { ir: { columns: Array<{ id: string; agent?: { agentId: string; mode: string } }> } }).ir;
const triage = ir.columns.find((c) => c.id === "triage");
expect(triage?.agent).toEqual({ agentId, mode: "defer" });
});
it("rejects an unknown agentId with a 400 naming the column; definition is unchanged", async () => {
const res = await post("/api/workflows", { name: "Ghost", ir: boundIr({ agentId: "agent-ghost", mode: "defer" }) });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/triage/);
expect(res.body.error).toMatch(/agent-ghost/);
// Nothing persisted (no custom "Ghost" workflow created; built-ins remain).
const list = await get("/api/workflows");
expect((list.body as Array<{ name: string }>).some((w) => w.name === "Ghost")).toBe(false);
});
it("rejects a more-privileged agent without confirmPolicyEscalation, then persists with the flag", async () => {
// Project default is restrictive; the bound agent is unrestricted (broader).
await store.updateSettings({ defaultAgentPermissionPolicy: { rules: { file_write_delete: "block" } } as never });
const agentId = await makeAgent({ permissionPolicy: { presetId: "unrestricted" } });
const denied = await post("/api/workflows", { name: "Esc", ir: boundIr({ agentId, mode: "override" }) });
expect(denied.status).toBe(400);
expect(denied.body.error).toMatch(/broader/i);
expect((denied.body as { details?: { policyEscalation?: boolean } }).details?.policyEscalation).toBe(true);
const ok = await post("/api/workflows", {
name: "Esc2",
ir: boundIr({ agentId, mode: "override" }),
confirmPolicyEscalation: true,
});
expect(ok.status).toBe(201);
});
it("saves without the flag when the agent policy equals the project default (no escalation)", async () => {
// Project default and the bound agent are both fully restrictive (locked-down):
// equal policies are NOT broader, so no confirmation is required.
await store.updateSettings({
defaultAgentPermissionPolicy: { rules: { file_write_delete: "block", command_execution: "block" } } as never,
});
const agentId = await makeAgent({
permissionPolicy: { presetId: "custom", rules: { file_write_delete: "block", command_execution: "block" } },
});
const res = await post("/api/workflows", { name: "Equal", ir: boundIr({ agentId, mode: "override" }) });
expect(res.status).toBe(201);
});
it("saves without the flag when the project default is unset (unrestricted) and the agent is unrestricted", async () => {
// No project default configured → effective default is `unrestricted` (allow-all).
// An unrestricted agent is equal, not broader, so no escalation.
const agentId = await makeAgent({ permissionPolicy: { presetId: "unrestricted" } });
const res = await post("/api/workflows", { name: "Unrestricted", ir: boundIr({ agentId, mode: "override" }) });
expect(res.status).toBe(201);
});
it("still detects escalation when the agent's custom rules map omits a category the default blocks", async () => {
// Default blocks two categories. The agent's custom rules map names only ONE
// of them (the other is absent → resolves to the unrestricted `allow` seed),
// so the agent is genuinely broader on the omitted category. A missing key
// must NOT silently suppress this escalation.
await store.updateSettings({
defaultAgentPermissionPolicy: { rules: { file_write_delete: "block", command_execution: "block" } } as never,
});
const agentId = await makeAgent({
// Only file_write_delete declared; command_execution omitted → allow (broader).
permissionPolicy: { presetId: "custom", rules: { file_write_delete: "block" } },
});
const denied = await post("/api/workflows", { name: "PartialEsc", ir: boundIr({ agentId, mode: "override" }) });
expect(denied.status).toBe(400);
expect(denied.body.error).toMatch(/broader/i);
expect((denied.body as { details?: { policyEscalation?: boolean } }).details?.policyEscalation).toBe(true);
const ok = await post("/api/workflows", {
name: "PartialEsc2",
ir: boundIr({ agentId, mode: "override" }),
confirmPolicyEscalation: true,
});
expect(ok.status).toBe(201);
});
it("stores no agent key when the binding is absent (omission, R9)", async () => {
const res = await post("/api/workflows", { name: "Plain", ir: boundIr() });
expect(res.status).toBe(201);
const id = (res.body as { id: string }).id;
const fetched = await get(`/api/workflows/${id}`);
const ir = (fetched.body as { ir: { columns: Array<{ id: string; agent?: unknown }> } }).ir;
const triage = ir.columns.find((c) => c.id === "triage")!;
expect("agent" in triage).toBe(false);
});
it("PATCH validates an unknown agentId the same way as POST", async () => {
const created = await post("/api/workflows", { name: "Editable", ir: boundIr() });
const id = (created.body as { id: string }).id;
const res = await patch(`/api/workflows/${id}`, { ir: boundIr({ agentId: "agent-ghost", mode: "override" }) });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/triage/);
});
it("PATCH enforces the policy-escalation gate the same way as POST (FN-5893)", async () => {
await store.updateSettings({ defaultAgentPermissionPolicy: { rules: { file_write_delete: "block" } } as never });
const agentId = await makeAgent({ permissionPolicy: { presetId: "unrestricted" } });
const created = await post("/api/workflows", { name: "EditableEsc", ir: boundIr() });
expect(created.status).toBe(201);
const id = (created.body as { id: string }).id;
const denied = await patch(`/api/workflows/${id}`, { ir: boundIr({ agentId, mode: "override" }) });
expect(denied.status).toBe(400);
expect(denied.body.error).toMatch(/broader/i);
expect((denied.body as { details?: { policyEscalation?: boolean } }).details?.policyEscalation).toBe(true);
const ok = await patch(`/api/workflows/${id}`, {
ir: boundIr({ agentId, mode: "override" }),
confirmPolicyEscalation: true,
});
expect(ok.status).toBe(200);
});
});

View File

@@ -24,7 +24,7 @@ import type {
PluginStore,
PluginContext,
} from "@fusion/core";
import { validatePluginManifest } from "@fusion/core";
import { resolvePluginEntryPath, validatePluginManifest } from "@fusion/core";
import {
ApiError,
badRequest,
@@ -251,7 +251,16 @@ export function createPluginRouter(
if (source.path) {
const resolved = await resolvePluginManifest(source.path);
manifest = resolved.manifest;
installPath = resolved.manifestDir;
// Register the loadable entry FILE, not the package directory — Node
// ESM cannot import directories, so the loader rejects directory paths.
const entryPath = resolvePluginEntryPath(resolved.manifestDir);
if (!entryPath) {
throw badRequest(
`Plugin at ${resolved.manifestDir} has no loadable entry file `
+ "(expected bundled.js, dist/index.js, or src/index.ts)",
);
}
installPath = entryPath;
} else if (source.package) {
// npm packages not yet supported
throw badRequest("Installing plugins from npm packages is not yet implemented");
@@ -298,6 +307,20 @@ export function createPluginRouter(
// Enable in store
let plugin = await pluginStore.enablePlugin(id);
// Heal legacy registrations that stored the package directory instead of
// a loadable entry file (Node ESM cannot import directories). Mirrors the
// heal in routes.ts's enable handler and the CLI's startup heal.
try {
if ((await stat(plugin.path)).isDirectory()) {
const entryPath = resolvePluginEntryPath(plugin.path);
if (entryPath) {
plugin = await pluginStore.updatePlugin(id, { path: entryPath });
}
}
} catch {
// Path missing or unreadable — let loadPlugin surface the real error.
}
// Start the plugin
try {
await pluginLoader.loadPlugin(id);

View File

@@ -29,6 +29,7 @@ import {
listAgentMemoryFiles,
readAgentMemoryFile,
resolvePlanningSettingsModel,
resolvePluginEntryPath,
resolveProjectDefaultModel,
resolveTitleSummarizerSettingsModel,
writeAgentMemoryFile,
@@ -3618,10 +3619,20 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
// Resolve manifest — supports package root and dist-folder selections
const { manifestDir, manifest } = await resolvePluginManifest(manifestPathForInstall);
// Register the loadable entry FILE, not the package directory — Node ESM
// cannot import directories, so the loader rejects directory paths.
const entryPath = resolvePluginEntryPath(manifestDir);
if (!entryPath) {
throw badRequest(
`Plugin at ${manifestDir} has no loadable entry file `
+ "(expected bundled.js, dist/index.js, or src/index.ts)",
);
}
try {
const plugin = await pluginStore.registerPlugin({
manifest,
path: manifestDir,
path: entryPath,
...(typeof aiScanOnLoad === "boolean" ? { aiScanOnLoad } : {}),
});
@@ -3668,6 +3679,20 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
let plugin = await pluginStore.enablePlugin(id);
// Heal legacy registrations that stored the package directory instead of
// a loadable entry file (Node ESM cannot import directories). Mirrors the
// CLI's startup heal in ensureBundledPluginInstalled.
try {
if (nodeFs.statSync(plugin.path).isDirectory()) {
const entryPath = resolvePluginEntryPath(plugin.path);
if (entryPath) {
plugin = await pluginStore.updatePlugin(id, { path: entryPath });
}
}
} catch {
// Path missing or unreadable — let loadPlugin surface the real error.
}
// Start the plugin if loader is available
if (options?.pluginLoader) {
try {

View File

@@ -1,5 +1,5 @@
import type { WorkflowDefinition, WorkflowDefinitionKind, WorkflowIr, WorkflowIrNode } from "@fusion/core";
import { ColumnTraitValidationError, OccupiedColumnsError, InvalidRehomeTargetError, WorkflowCompileError, WorkflowIrError, SCHEMA_VERSION, assertColumnTraitsValid, compileWorkflowToSteps, layoutForIr, listTraits, listStepParsers, parseWorkflowIr, resolvePlanningSettingsModel, stripApprovalBypassFlags } from "@fusion/core";
import type { WorkflowDefinition, WorkflowDefinitionKind, WorkflowIr, WorkflowIrNode, TaskStore } from "@fusion/core";
import { ColumnTraitValidationError, OccupiedColumnsError, InvalidRehomeTargetError, WorkflowCompileError, WorkflowIrError, ColumnAgentBindingError, SCHEMA_VERSION, assertColumnTraitsValid, compileWorkflowToSteps, layoutForIr, listTraits, listStepParsers, parseWorkflowIr, resolvePlanningSettingsModel, stripApprovalBypassFlags, AgentStore, validateColumnAgentBindings } from "@fusion/core";
import { createFnAgent as engineCreateFnAgent, validateCodeNodeSources } from "@fusion/engine";
import { ApiError, badRequest, conflict, notFound, rateLimited } from "../api-error.js";
import { emitWorkflowSseEvent } from "../sse.js";
@@ -159,6 +159,40 @@ export function registerWorkflowRoutes(ctx: ApiRoutesContext): void {
}
}
/**
* Write-time column-agent validation (U6, R11/R13). Delegates to the shared
* `validateColumnAgentBindings` helper in @fusion/core (the SAME gate the
* `fn_workflow_*` agent tools run), then maps its typed
* {@link ColumnAgentBindingError} onto an HTTP 400 carrying the structured
* fields the client UI consumes. Inspects columns BEFORE persisting and never
* mutates the IR.
*/
async function assertColumnAgentsExist(
ir: unknown,
store: TaskStore,
confirmPolicyEscalation: boolean,
): Promise<void> {
// Skip store/agent-registry I/O entirely when no column carries a binding.
const columns = (ir as { columns?: unknown })?.columns;
if (!Array.isArray(columns) || !columns.some((c) => c?.agent?.agentId)) return;
const agentStore = new AgentStore({ rootDir: store.getFusionDir() });
await agentStore.init();
const settings = await store.getSettings();
try {
await validateColumnAgentBindings({ ir, agentStore, settings, confirmPolicyEscalation });
} catch (err: unknown) {
if (err instanceof ColumnAgentBindingError) {
throw badRequest(err.message, {
columnId: err.columnId,
agentId: err.agentId,
...(err.reason === "policy-escalation" ? { policyEscalation: true } : {}),
});
}
throw err;
}
}
// GET /api/traits — trait catalog for the node editor's trait picker (U10).
// Returns the registry's listTraits() (built-ins + any registered plugin
// traits): id, name, description, flags, hook descriptors, and config schema.
@@ -218,12 +252,13 @@ export function registerWorkflowRoutes(ctx: ApiRoutesContext): void {
router.post("/workflows", async (req, res) => {
try {
const { store, projectId } = await getProjectContext(req);
const { name, description, layout } = req.body ?? {};
const { name, description, layout, confirmPolicyEscalation } = req.body ?? {};
if (!name || typeof name !== "string" || !name.trim()) {
throw badRequest("name is required");
}
const ir = requireIr(req.body);
await assertCodeNodesCompile(ir);
await assertColumnAgentsExist(ir, store, confirmPolicyEscalation === true);
const created = await store.createWorkflowDefinition({ name, description, ir, layout });
emitWorkflowSseEvent("workflow:created", created, projectId);
res.status(201).json(created);
@@ -256,7 +291,7 @@ export function registerWorkflowRoutes(ctx: ApiRoutesContext): void {
router.patch("/workflows/:id", async (req, res) => {
try {
const { store, projectId } = await getProjectContext(req);
const { name, description, ir, layout, rehomeTo } = req.body ?? {};
const { name, description, ir, layout, rehomeTo, confirmPolicyEscalation } = req.body ?? {};
if (name !== undefined && (typeof name !== "string" || !name.trim())) {
throw badRequest("name must be a non-empty string");
}
@@ -268,6 +303,7 @@ export function registerWorkflowRoutes(ctx: ApiRoutesContext): void {
}
if (ir !== undefined) {
await assertCodeNodesCompile(ir);
await assertColumnAgentsExist(ir, store, confirmPolicyEscalation === true);
}
const updated = await store.updateWorkflowDefinition(req.params.id, {
name,

View File

@@ -567,6 +567,134 @@ describe("createWorkflowCreateTool", () => {
const text = result.content[0]?.type === "text" ? result.content[0].text : "";
expect(text).toMatch(/name is required/);
});
// R13: the column-agent policy-escalation gate (shared with the dashboard
// route) must also fire on the agent-tool write path. A binding to an agent
// whose policy is broader than the project default is rejected unless the
// tool is called with confirm_policy_escalation: true.
it("rejects a binding to a more-privileged agent without confirm_policy_escalation", async () => {
const rootDir = await mkdtemp(join(tmpdir(), "wf-tool-ca-root-"));
const globalDir = await mkdtemp(join(tmpdir(), "wf-tool-ca-global-"));
const store = new core.TaskStore(rootDir, globalDir);
try {
await store.init();
// Restrict the project default; the bound agent is unrestricted (broader).
await store.updateSettings({ defaultAgentPermissionPolicy: { rules: { file_write_delete: "block" } } } as any);
const agentStore = new core.AgentStore({ rootDir: store.getFusionDir() });
await agentStore.init();
const agent = await agentStore.createAgent({
name: "Privileged",
role: "executor",
permissionPolicy: { presetId: "unrestricted" },
} as any);
const ir = {
version: "v2",
name: "bound",
columns: [
{ id: "triage", name: "Triage", traits: [{ trait: "intake" }], agent: { agentId: agent.id, mode: "override" } },
{ id: "done", name: "Done", traits: [{ trait: "complete" }] },
],
nodes: [
{ id: "start", kind: "start", column: "triage" },
{ id: "work", kind: "prompt", column: "triage", config: { prompt: "do" } },
{ id: "end", kind: "end", column: "done" },
],
edges: [
{ from: "start", to: "work", condition: "success" },
{ from: "work", to: "end", condition: "success" },
],
};
const tool = createWorkflowCreateTool(store as any);
const denied = await tool.execute("c", { name: "Esc", ir } as any, undefined, undefined, {} as any);
expect((denied as { isError?: boolean }).isError).toBe(true);
const text = denied.content[0]?.type === "text" ? denied.content[0].text : "";
expect(text).toMatch(/triage/);
expect(text).toMatch(/confirm_policy_escalation: true/);
expect(denied.details).toMatchObject({ columnId: "triage", agentId: agent.id, reason: "policy-escalation" });
// With the flag set, the gate passes and the store write proceeds.
const ok = await tool.execute("c", { name: "Esc2", ir, confirm_policy_escalation: true } as any, undefined, undefined, {} as any);
expect((ok as { isError?: boolean }).isError).toBeFalsy();
const okText = ok.content[0]?.type === "text" ? ok.content[0].text : "";
expect(okText).toMatch(/Created workflow/);
} finally {
store.close();
await rm(rootDir, { recursive: true, force: true });
await rm(globalDir, { recursive: true, force: true });
}
});
// FN-5893: the escalation invariant must hold on ALL workflow write surfaces —
// the update tool is the second one (the dashboard route has its own tests).
it("update tool enforces the same policy-escalation gate", async () => {
const rootDir = await mkdtemp(join(tmpdir(), "wf-tool-ca-upd-root-"));
const globalDir = await mkdtemp(join(tmpdir(), "wf-tool-ca-upd-global-"));
const store = new core.TaskStore(rootDir, globalDir);
try {
await store.init();
await store.updateSettings({ defaultAgentPermissionPolicy: { rules: { file_write_delete: "block" } } } as any);
const agentStore = new core.AgentStore({ rootDir: store.getFusionDir() });
await agentStore.init();
const agent = await agentStore.createAgent({
name: "Privileged",
role: "executor",
permissionPolicy: { presetId: "unrestricted" },
} as any);
const boundIr = (name: string) => ({
version: "v2",
name,
columns: [
{ id: "triage", name: "Triage", traits: [{ trait: "intake" }], agent: { agentId: agent.id, mode: "override" } },
{ id: "done", name: "Done", traits: [{ trait: "complete" }] },
],
nodes: [
{ id: "start", kind: "start", column: "triage" },
{ id: "work", kind: "prompt", column: "triage", config: { prompt: "do" } },
{ id: "end", kind: "end", column: "done" },
],
edges: [
{ from: "start", to: "work", condition: "success" },
{ from: "work", to: "end", condition: "success" },
],
});
// Seed an unbound workflow to update.
const unbound = { ...boundIr("plain"), columns: boundIr("plain").columns.map(({ agent: _a, ...c }) => c) };
const created = await store.createWorkflowDefinition({ name: "plain", ir: unbound as any });
const tool = createWorkflowUpdateTool(store as any);
const denied = await tool.execute(
"c",
{ workflow_id: created.id, ir: boundIr("bound") } as any,
undefined,
undefined,
{} as any,
);
expect((denied as { isError?: boolean }).isError).toBe(true);
const text = denied.content[0]?.type === "text" ? denied.content[0].text : "";
expect(text).toMatch(/triage/);
expect(text).toMatch(/confirm_policy_escalation: true/);
expect(denied.details).toMatchObject({ columnId: "triage", agentId: agent.id, reason: "policy-escalation" });
const ok = await tool.execute(
"c",
{ workflow_id: created.id, ir: boundIr("bound"), confirm_policy_escalation: true } as any,
undefined,
undefined,
{} as any,
);
expect((ok as { isError?: boolean }).isError).toBeFalsy();
const okText = ok.content[0]?.type === "text" ? ok.content[0].text : "";
expect(okText).toMatch(/Updated workflow/);
} finally {
store.close();
await rm(rootDir, { recursive: true, force: true });
await rm(globalDir, { recursive: true, force: true });
}
});
});
describe("createWorkflowUpdateTool", () => {

View File

@@ -0,0 +1,265 @@
// Column-agent custom-node resolution (plan U3, R2/R3/R4/R8, KTD-2/KTD-3/KTD-6).
//
// `runGraphCustomNode` synthesizes a `WorkflowStep` and runs it on the proven
// WorkflowStep machinery. The seam wiring (maybeExecuteWorkflowGraph) resolves
// the per-node column-agent binding and threads it in as a parameter. These
// tests call `runGraphCustomNode` directly with that binding and assert the
// synthesized step's model/persona plus the audit log entries — mirroring the
// established executor harness (executor-workflow-step-scope.test.ts): build a
// real TaskExecutor over a mock store and spy on `executeWorkflowStep` /
// `executeScriptWorkflowStep` to capture the synthesized step.
import { beforeEach, describe, expect, it, vi } from "vitest";
import "./executor-test-helpers.js";
import { TaskExecutor } from "../executor.js";
import { createMockStore, resetExecutorMocks } from "./executor-test-helpers.js";
import type { WorkflowColumnAgent } from "@fusion/core";
function makeAgent(overrides: Record<string, unknown> = {}) {
return {
id: "agent-col",
name: "Column Agent",
soul: "I am the senior reviewer.",
instructionsText: "Always be thorough.",
runtimeConfig: { executorProvider: "anthropic", executorModelId: "claude-col" },
...overrides,
};
}
function makeExecutor(store: ReturnType<typeof createMockStore>, agent: unknown | null) {
const agentStore = {
getAgent: vi.fn().mockResolvedValue(agent),
};
const executor = new TaskExecutor(store as any, "/tmp/test", { agentStore } as any);
return { executor, agentStore };
}
/** Spy both session-running paths; return the captured synthesized step. */
function spyStep(executor: TaskExecutor) {
const captured: { step?: any } = {};
vi.spyOn(executor as any, "executeWorkflowStep").mockImplementation(async (...args: any[]) => {
captured.step = args[1];
return { success: true, output: "ok" };
});
vi.spyOn(executor as any, "executeScriptWorkflowStep").mockImplementation(async (...args: any[]) => {
captured.step = args[1];
return { success: true, output: "ok" };
});
return captured;
}
function loggedLines(store: ReturnType<typeof createMockStore>): string[] {
return store.logEntry.mock.calls.map((call: any[]) => String(call[1] ?? ""));
}
const OVERRIDE: WorkflowColumnAgent = { agentId: "agent-col", mode: "override" };
const DEFER: WorkflowColumnAgent = { agentId: "agent-col", mode: "defer" };
describe("runGraphCustomNode column-agent resolution (plan U3)", () => {
beforeEach(() => {
resetExecutorMocks();
});
it("override column: node with own cfg.agentId runs as column agent (model+persona) and logs substitution+mode", async () => {
const store = createMockStore();
store.getTask.mockResolvedValue({ id: "FN-001", worktree: "/tmp/wt", log: [] } as any);
const { executor, agentStore } = makeExecutor(store, makeAgent());
const captured = spyStep(executor);
const node = {
id: "review",
kind: "prompt",
column: "review",
config: {
executor: "agent",
agentId: "node-own-agent",
modelProvider: "openai",
modelId: "gpt-node",
prompt: "Review the diff.",
},
};
const result = await (executor as any).runGraphCustomNode(node, { id: "FN-001" }, {}, OVERRIDE);
expect(result.outcome).toBe("success");
// Column agent fetched (not the node's own agent).
expect(agentStore.getAgent).toHaveBeenCalledWith("agent-col");
// Column agent's model wins over the node's own pair.
expect(captured.step.modelProvider).toBe("anthropic");
expect(captured.step.modelId).toBe("claude-col");
// Column agent's persona (soul + instructionsText) prepended to the prompt.
expect(captured.step.prompt).toContain("I am the senior reviewer.");
expect(captured.step.prompt).toContain("Always be thorough.");
expect(captured.step.prompt).toContain("Review the diff.");
// Audit log records substitution + mode.
expect(
loggedLines(store).some((l) => l.includes("running as column agent 'agent-col' (override)")),
).toBe(true);
});
it("defer column: node with own cfg.agentId keeps it; bare node adopts the column agent", async () => {
// (a) own agentId present → defer yields own settings, column agent untouched.
{
const store = createMockStore();
store.getTask.mockResolvedValue({ id: "FN-001", worktree: "/tmp/wt", log: [] } as any);
const nodeOwnAgent = makeAgent({ id: "node-own-agent", soul: "node persona", instructionsText: "", runtimeConfig: { executorProvider: "openai", executorModelId: "gpt-node" } });
const { executor, agentStore } = makeExecutor(store, nodeOwnAgent);
const captured = spyStep(executor);
const node = {
id: "review",
kind: "prompt",
column: "review",
config: { executor: "agent", agentId: "node-own-agent", prompt: "Do it." },
};
await (executor as any).runGraphCustomNode(node, { id: "FN-001" }, {}, DEFER);
// Own agent fetched, NOT the column agent.
expect(agentStore.getAgent).toHaveBeenCalledWith("node-own-agent");
expect(agentStore.getAgent).not.toHaveBeenCalledWith("agent-col");
expect(captured.step.modelProvider).toBe("openai");
expect(captured.step.modelId).toBe("gpt-node");
expect(
loggedLines(store).some((l) => l.includes("running as column agent")),
).toBe(false);
}
// (b) bare node (no own agent/model) → defer adopts the column agent.
{
const store = createMockStore();
store.getTask.mockResolvedValue({ id: "FN-001", worktree: "/tmp/wt", log: [] } as any);
const { executor, agentStore } = makeExecutor(store, makeAgent());
const captured = spyStep(executor);
const node = { id: "review", kind: "prompt", column: "review", config: { prompt: "Plain." } };
await (executor as any).runGraphCustomNode(node, { id: "FN-001" }, {}, DEFER);
expect(agentStore.getAgent).toHaveBeenCalledWith("agent-col");
expect(captured.step.modelProvider).toBe("anthropic");
expect(captured.step.modelId).toBe("claude-col");
expect(captured.step.prompt).toContain("I am the senior reviewer.");
expect(
loggedLines(store).some((l) => l.includes("running as column agent 'agent-col' (defer)")),
).toBe(true);
}
});
it("override column: bare node adopts the column agent (own-absent cell)", async () => {
// override × own-absent: nothing to supersede, the column agent is adopted.
const store = createMockStore();
store.getTask.mockResolvedValue({ id: "FN-001", worktree: "/tmp/wt", log: [] } as any);
const { executor, agentStore } = makeExecutor(store, makeAgent());
const captured = spyStep(executor);
const node = { id: "review", kind: "prompt", column: "review", config: { prompt: "Plain." } };
const result = await (executor as any).runGraphCustomNode(node, { id: "FN-001" }, {}, OVERRIDE);
expect(result.outcome).toBe("success");
expect(agentStore.getAgent).toHaveBeenCalledWith("agent-col");
expect(captured.step.modelProvider).toBe("anthropic");
expect(captured.step.modelId).toBe("claude-col");
expect(captured.step.prompt).toContain("I am the senior reviewer.");
expect(
loggedLines(store).some((l) => l.includes("running as column agent 'agent-col' (override)")),
).toBe(true);
});
it("missing column agent in registry → logged, node falls back, step still executes", async () => {
const store = createMockStore();
store.getTask.mockResolvedValue({ id: "FN-001", worktree: "/tmp/wt", log: [] } as any);
// agentStore returns null for the column agent.
const { executor } = makeExecutor(store, null);
const captured = spyStep(executor);
const node = { id: "review", kind: "prompt", column: "review", config: { prompt: "Plain." } };
const result = await (executor as any).runGraphCustomNode(node, { id: "FN-001" }, {}, OVERRIDE);
expect(result.outcome).toBe("success");
// No column-agent model adopted (agent missing) → step has no model pair.
expect(captured.step.modelProvider).toBeUndefined();
expect(captured.step.modelId).toBeUndefined();
expect(
loggedLines(store).some((l) => l.includes("column agent 'agent-col' not found")),
).toBe(true);
});
it("column agent lookup THROWS (store/agentStore error) → node still succeeds, 'lookup failed' logged (R8)", async () => {
// adoptColumnAgentForNode is best-effort: an agentStore.getAgent rejection must
// be swallowed and the node must fall back to node/default resolution rather
// than the graph node failing.
const store = createMockStore();
store.getTask.mockResolvedValue({ id: "FN-001", worktree: "/tmp/wt", log: [] } as any);
const agentStore = {
getAgent: vi.fn().mockRejectedValue(new Error("agent store unavailable")),
};
const executor = new TaskExecutor(store as any, "/tmp/test", { agentStore } as any);
const captured = spyStep(executor);
const node = { id: "review", kind: "prompt", column: "review", config: { prompt: "Plain." } };
const result = await (executor as any).runGraphCustomNode(node, { id: "FN-001" }, {}, OVERRIDE);
// Node did NOT fail despite the lookup throwing.
expect(result.outcome).toBe("success");
// No column-agent model adopted (lookup failed) → node falls back.
expect(captured.step.modelProvider).toBeUndefined();
expect(captured.step.modelId).toBeUndefined();
// The catch-path fallback audit fired.
expect(
loggedLines(store).some(
(l) => l.includes("column agent 'agent-col' lookup failed") && l.includes("falling back"),
),
).toBe(true);
});
it("node with no declared column → untouched resolution even when a binding is passed as undefined", async () => {
const store = createMockStore();
store.getTask.mockResolvedValue({ id: "FN-001", worktree: "/tmp/wt", log: [] } as any);
const { executor, agentStore } = makeExecutor(store, makeAgent());
const captured = spyStep(executor);
// No declared column → the seam wiring resolves no binding (undefined).
const node = {
id: "review",
kind: "prompt",
config: { executor: "model", modelProvider: "openai", modelId: "gpt-node", prompt: "Plain." },
};
await (executor as any).runGraphCustomNode(node, { id: "FN-001" }, {}, undefined);
// Column agent never fetched; node's own model preserved.
expect(agentStore.getAgent).not.toHaveBeenCalled();
expect(captured.step.modelProvider).toBe("openai");
expect(captured.step.modelId).toBe("gpt-node");
expect(loggedLines(store).some((l) => l.includes("column agent"))).toBe(false);
});
it("CLI-executor node (raw command) in override column → mechanics unchanged, audit notes the skip", async () => {
const store = createMockStore();
store.getTask.mockResolvedValue({ id: "FN-001", worktree: "/tmp/wt", log: [] } as any);
store.isWorkflowCliCommandApproved = vi.fn().mockResolvedValue(true);
const { executor, agentStore } = makeExecutor(store, makeAgent());
// Raw CLI runs runRawCliCommand, not a session — stub it.
const rawSpy = vi.spyOn(executor as any, "runRawCliCommand").mockResolvedValue({ success: true });
const node = {
id: "lint",
kind: "script",
column: "review",
config: { executor: "cli", cliCommand: "npm run lint", cliSkipApproval: true, prompt: "" },
};
const result = await (executor as any).runGraphCustomNode(node, { id: "FN-001" }, {}, OVERRIDE);
expect(result.outcome).toBe("success");
// Raw CLI mechanics unchanged: command still ran.
expect(rawSpy).toHaveBeenCalled();
// Column agent NOT fetched/adopted for raw CLI execution.
expect(agentStore.getAgent).not.toHaveBeenCalled();
// Audit explains the skip.
expect(
loggedLines(store).some(
(l) =>
l.includes("column agent 'agent-col' (override) not applied") &&
l.includes("raw CLI execution runs no session"),
),
).toBe(true);
});
});

View File

@@ -0,0 +1,623 @@
// Column-agent PRINCIPAL alignment (plan U5, R5/R6/R7, KTD-3/KTD-4).
//
// The three subsystems that historically assumed "the running agent is
// task.assignedAgentId" must consult the EFFECTIVE column agent instead:
// (a) action gating (buildActionGateContext / buildPermanentAgentGatingContext)
// — gate for the agent actually running (R5);
// (b) heartbeat serialization in BOTH directions (R6):
// - the execute() deferral gate consults the effective principal;
// - resumeTaskForAgent re-dispatches column-effective tasks via a second
// pass the assignedAgentId-only filter would miss;
// - the heartbeat scheduler's reverse guard (isAgentEffectivelyExecuting)
// blocks a column agent from heartbeating concurrently with its own session;
// (c) the restart watcher hot-swaps when a workflow edit / agent-config change
// re-keys the column-effective agent/model mid-flight, and falls back (no
// restart storm) when the column agent is deleted (R7/KTD-4/R8).
//
// Harness mirrors executor-column-agent-seams.test.ts: a real TaskExecutor over a
// mock store with createFnAgent + StepSessionExecutor mocked. The per-run seam
// slots (graphSeamGoverningNodeId / graphColumnAgentResolver) are seeded directly,
// then runImplementationPhase drives the production session-build path.
import { beforeEach, describe, expect, it, vi } from "vitest";
import "./executor-test-helpers.js";
import { TaskExecutor } from "../executor.js";
import {
createMockStore,
mockedCreateFnAgent,
resetExecutorMocks,
} from "./executor-test-helpers.js";
import type { WorkflowColumnAgent, WorkflowIr } from "@fusion/core";
const OVERRIDE_COL: WorkflowColumnAgent = { agentId: "agent-X", mode: "override" };
const DEFER_COL: WorkflowColumnAgent = { agentId: "agent-X", mode: "defer" };
// agent-X = the column agent (allowParallelExecution=false unless overridden).
// agent-Y = the task's assigned agent.
function makeColumnAgent(overrides: Record<string, unknown> = {}) {
return {
id: "agent-X",
name: "Column Agent X",
soul: "I am X.",
instructionsText: "X persona.",
memory: undefined,
permissionPolicy: { rules: {} },
runtimeConfig: { model: "anthropic/claude-x", allowParallelExecution: false },
...overrides,
};
}
function makeAssignedAgent(overrides: Record<string, unknown> = {}) {
return {
id: "agent-Y",
name: "Assigned Agent Y",
soul: "I am Y.",
instructionsText: "Y persona.",
memory: undefined,
permissionPolicy: { rules: {} },
runtimeConfig: { model: "openai/gpt-y" },
...overrides,
};
}
function installTaskDoneAgent() {
mockedCreateFnAgent.mockImplementation((async (opts: any) => {
const tools = opts.customTools || [];
return {
session: {
prompt: vi.fn().mockImplementation(async () => {
const done = tools.find((t: any) => t.name === "fn_task_done");
if (done) await done.execute("tool-1", {});
}),
dispose: vi.fn(),
subscribe: vi.fn(),
on: vi.fn(),
setModel: vi.fn(),
sessionManager: { getLeafId: vi.fn().mockReturnValue("leaf-1") },
state: {},
},
};
}) as any);
}
function makeExecutor(
store: ReturnType<typeof createMockStore>,
agentsById: Record<string, unknown>,
heartbeatRunsByAgent: Record<string, unknown> = {},
) {
const agentStore = {
getAgent: vi.fn(async (id: string) => agentsById[id] ?? null),
getActiveHeartbeatRun: vi.fn(async (id: string) => heartbeatRunsByAgent[id] ?? null),
};
const executor = new TaskExecutor(store as any, "/tmp/test", { agentStore } as any);
return { executor, agentStore };
}
function singleSessionTask(overrides: Record<string, unknown> = {}) {
return {
id: "FN-001",
title: "Test",
description: "Test task",
column: "in-progress",
dependencies: [],
steps: [{ name: "Implement", status: "in-progress" }],
currentStep: 0,
log: [],
prompt: "# test\n## Steps\n### Step 0: Implement\n- [ ] implement",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
...overrides,
};
}
function seedSeam(executor: TaskExecutor, taskId: string, governingNodeId: string, binding: WorkflowColumnAgent | undefined) {
(executor as any).graphSeamGoverningNodeId.set(taskId, governingNodeId);
(executor as any).graphColumnAgentResolver.set(taskId, (nodeId: string) =>
nodeId === governingNodeId ? binding : undefined,
);
}
function lastFnAgentOpts() {
const calls = mockedCreateFnAgent.mock.calls;
return calls[calls.length - 1]?.[0] as any;
}
function loggedLines(store: ReturnType<typeof createMockStore>): string[] {
return store.logEntry.mock.calls.map((call: any[]) => String(call[1] ?? ""));
}
/** v2 IR with an execute-seam prompt node whose column binds `binding`. */
function irWithExecuteSeamColumn(binding: WorkflowColumnAgent): WorkflowIr {
return {
version: "v2",
name: "test-wf",
columns: [
{ id: "in-progress", name: "In Progress", traits: [], agent: binding },
{ id: "todo", name: "Todo", traits: [] },
],
nodes: [
{ id: "exec-node", kind: "prompt", column: "in-progress", config: { seam: "execute" } } as any,
],
edges: [],
} as unknown as WorkflowIr;
}
describe("column-agent principal alignment (plan U5)", () => {
beforeEach(() => {
resetExecutorMocks();
});
// ── (a) Action gating principal (R5) ──────────────────────────────────────
describe("action gating principal", () => {
it("override column governs → gating context built for X (not the assigned Y)", async () => {
const store = createMockStore();
const task = singleSessionTask({ assignedAgentId: "agent-Y" });
store.getTask.mockResolvedValue(task as any);
const { executor } = makeExecutor(store, {
"agent-Y": makeAssignedAgent(),
"agent-X": makeColumnAgent(),
});
installTaskDoneAgent();
seedSeam(executor, task.id, "exec-node", OVERRIDE_COL);
await (executor as any).runImplementationPhase(task);
const opts = lastFnAgentOpts();
// R5: action gating is computed for the agent ACTUALLY running.
expect(opts.actionGateContext?.agentId).toBe("agent-X");
expect(opts.permanentAgentGating?.requester?.actorId).toBe("agent-X");
});
it("no binding → gating context built for the assigned Y (byte-identical)", async () => {
const store = createMockStore();
const task = singleSessionTask({ assignedAgentId: "agent-Y" });
store.getTask.mockResolvedValue(task as any);
const { executor } = makeExecutor(store, { "agent-Y": makeAssignedAgent() });
installTaskDoneAgent();
// No seam slots seeded → legacy path.
await (executor as any).runImplementationPhase(task);
const opts = lastFnAgentOpts();
expect(opts.actionGateContext?.agentId).toBe("agent-Y");
expect(opts.permanentAgentGating?.requester?.actorId).toBe("agent-Y");
});
});
// ── (b) Heartbeat deferral — forward direction (R6) ───────────────────────
describe("heartbeat deferral: effective principal", () => {
it("override column X (allowParallelExecution=false) with an active heartbeat run → resolveEffectivePrincipalId returns X and defers", async () => {
const store = createMockStore();
const task = singleSessionTask({ assignedAgentId: "agent-Y" });
const { executor } = makeExecutor(
store,
{ "agent-Y": makeAssignedAgent(), "agent-X": makeColumnAgent() },
{ "agent-X": { id: "run-x" } }, // active heartbeat run for X
);
// Seam binding is known at the deferral gate (set by the seam before
// re-entering execute()).
seedSeam(executor, task.id, "exec-node", OVERRIDE_COL);
// The effective principal for this seam is X, not the assigned Y.
const principal = (executor as any).resolveEffectivePrincipalId(task, task);
expect(principal).toBe("agent-X");
// X has allowParallelExecution=false AND an active run → defer.
expect(await (executor as any).shouldDeferForHeartbeat("agent-X")).toBe(true);
// Y has no such constraint → the legacy filter alone would NOT defer.
expect(await (executor as any).shouldDeferForHeartbeat("agent-Y")).toBe(false);
});
it("no binding → effective principal is the assigned agent (byte-identical)", () => {
const store = createMockStore();
const task = singleSessionTask({ assignedAgentId: "agent-Y" });
const { executor } = makeExecutor(store, { "agent-Y": makeAssignedAgent() });
// No seam slots → legacy.
expect((executor as any).resolveEffectivePrincipalId(task, task)).toBe("agent-Y");
});
});
// ── (b) resumeTaskForAgent two-pass (R6) ──────────────────────────────────
describe("resumeTaskForAgent: effective-agent second pass", () => {
function resumeStore(task: any, ir: WorkflowIr) {
const store = createMockStore();
store.getSettings.mockResolvedValue({
globalPause: false,
enginePaused: false,
// R10: column agents require BOTH flags — pass 2 is gated on
// workflowColumns too (kill-switch, PR #1432 review).
experimentalFeatures: { workflowGraphExecutor: true, workflowColumns: true },
} as any);
store.listTasks.mockResolvedValue([task] as any);
store.getTaskWorkflowSelection = vi.fn().mockReturnValue({ workflowId: "wf-1", stepIds: [] });
store.getWorkflowDefinition = vi.fn().mockResolvedValue({ ir });
return store;
}
it("override column re-keys an in-progress task to X → pass 2 re-dispatches it (the assignedAgentId filter alone misses it)", async () => {
// Task assigned to Y, but its execute-seam column binds X (override).
const task = singleSessionTask({ id: "FN-RES", assignedAgentId: "agent-Y" });
const store = resumeStore(task, irWithExecuteSeamColumn(OVERRIDE_COL));
const { executor } = makeExecutor(store, {
"agent-Y": makeAssignedAgent(),
"agent-X": makeColumnAgent(),
});
const executeSpy = vi.spyOn(executor, "execute").mockResolvedValue(undefined as any);
// Pass 1 (assignedAgentId === "agent-X") would NOT match — Y is assigned.
await executor.resumeTaskForAgent("agent-X");
// Pass 2 (effective column agent === X) re-dispatched it.
expect(executeSpy).toHaveBeenCalledTimes(1);
expect(executeSpy.mock.calls[0][0]).toMatchObject({ id: "FN-RES" });
});
it("pass 1 still re-dispatches directly-assigned tasks (legacy)", async () => {
const task = singleSessionTask({ id: "FN-ASG", assignedAgentId: "agent-X" });
const store = resumeStore(task, irWithExecuteSeamColumn(OVERRIDE_COL));
const { executor } = makeExecutor(store, { "agent-X": makeColumnAgent() });
const executeSpy = vi.spyOn(executor, "execute").mockResolvedValue(undefined as any);
await executor.resumeTaskForAgent("agent-X");
expect(executeSpy).toHaveBeenCalledTimes(1); // not double-dispatched by pass 2
});
it("defer column with task own complete model pair → X is NOT the effective agent, pass 2 does not fire", async () => {
const task = singleSessionTask({
id: "FN-DEF",
assignedAgentId: "agent-Y",
modelProvider: "task-prov",
modelId: "task-model",
});
const store = resumeStore(task, irWithExecuteSeamColumn(DEFER_COL));
const { executor } = makeExecutor(store, {
"agent-Y": makeAssignedAgent(),
"agent-X": makeColumnAgent(),
});
const executeSpy = vi.spyOn(executor, "execute").mockResolvedValue(undefined as any);
// #12 distinguishability: spy on the pass-2 matcher to prove pass-2 was
// actually REACHED (not silently skipped) and returned false because the
// task's own complete model pair suppresses the defer column agent — rather
// than a false-pass where pass-2 never ran.
const matchSpy = vi.spyOn(executor as any, "taskEffectiveAgentMatches");
await executor.resumeTaskForAgent("agent-X");
expect(matchSpy).toHaveBeenCalledTimes(1);
expect(matchSpy.mock.calls[0][1]).toBe("agent-X");
await expect(matchSpy.mock.results[0].value).resolves.toBe(false);
expect(executeSpy).not.toHaveBeenCalled();
});
it("kill-switch: workflowColumns off → pass 2 is inert even with a live override binding (R10)", async () => {
// The documented rollback is disabling workflowColumns alone; pass 2
// resolves the IR directly (not via the per-run resolver map), so it
// carries its own flag guard (PR #1432 review).
const task = singleSessionTask({ assignedAgentId: "agent-Y" });
const store = resumeStore(task, irWithExecuteSeamColumn(OVERRIDE_COL));
store.getSettings.mockResolvedValue({
globalPause: false,
enginePaused: false,
experimentalFeatures: { workflowGraphExecutor: true, workflowColumns: false },
} as any);
const { executor } = makeExecutor(store, { "agent-X": makeColumnAgent() });
await expect((executor as any).taskEffectiveAgentMatches(task, "agent-X")).resolves.toBe(false);
});
it("step-execute template node binding governs → pass 2 matches a foreach-template-bound column agent (walks template subgraphs)", async () => {
// R6: step-execute seam nodes live ONLY inside a foreach template, never in
// ir.nodes. Pass 2 must walk foreach template subgraphs to find them; before
// the template-walk fix this returned false and the task was never re-dispatched.
const task = singleSessionTask({ id: "FN-STEP", assignedAgentId: "agent-Y" });
const ir = {
version: "v2",
name: "test-wf",
columns: [
{ id: "step-col", name: "Step Col", traits: [], agent: OVERRIDE_COL },
{ id: "todo", name: "Todo", traits: [] },
],
nodes: [
{
id: "foreach-1",
kind: "foreach",
column: "todo",
config: {
template: {
nodes: [
{ id: "step-exec", kind: "prompt", column: "step-col", config: { seam: "step-execute" } },
],
},
},
},
],
edges: [],
} as unknown as WorkflowIr;
const store = resumeStore(task, ir);
const { executor } = makeExecutor(store, {
"agent-Y": makeAssignedAgent(),
"agent-X": makeColumnAgent(),
});
const executeSpy = vi.spyOn(executor, "execute").mockResolvedValue(undefined as any);
await executor.resumeTaskForAgent("agent-X");
expect(executeSpy).toHaveBeenCalledTimes(1);
expect(executeSpy.mock.calls[0][0]).toMatchObject({ id: "FN-STEP" });
});
});
// ── (b) Reverse direction: isAgentEffectivelyExecuting (R6) ───────────────
describe("reverse-direction guard: isAgentEffectivelyExecuting", () => {
it("X executing an override-column task it is NOT assigned to → effective-executing is true for X", async () => {
const store = createMockStore();
const task = singleSessionTask({ assignedAgentId: "agent-Y" });
store.getTask.mockResolvedValue(task as any);
const { executor } = makeExecutor(store, {
"agent-Y": makeAssignedAgent(),
"agent-X": makeColumnAgent(),
});
installTaskDoneAgent();
// Before any session: nothing effectively executing.
expect(executor.isAgentEffectivelyExecuting("agent-X")).toBe(false);
// While the override session runs, the map is populated. We assert the map
// directly to avoid coupling to teardown timing of the mocked session.
seedSeam(executor, task.id, "exec-node", OVERRIDE_COL);
const setSpy = vi.spyOn((executor as any).effectiveColumnAgentByTask, "set");
await (executor as any).runImplementationPhase(task);
// The execute seam recorded X as the effective principal for the task.
expect(setSpy).toHaveBeenCalledWith(task.id, "agent-X");
});
it("the heartbeat scheduler reverse guard consults the injected callback", async () => {
// Mirror the in-process-runtime wiring: the scheduler gets
// isAgentEffectivelyExecuting from the executor. Prove the guard short-circuits.
const store = createMockStore();
store.getTask.mockResolvedValue(singleSessionTask({ assignedAgentId: "agent-Y" }) as any);
const { executor } = makeExecutor(store, {});
// Pretend X is effectively executing some task.
(executor as any).effectiveColumnAgentByTask.set("FN-Z", "agent-X");
const cb = (agentId: string) => executor.isAgentEffectivelyExecuting(agentId);
expect(cb("agent-X")).toBe(true);
expect(cb("agent-Y")).toBe(false);
});
});
// ── (c) Restart watcher via re-resolution (R7/KTD-4) ──────────────────────
describe("restart watcher: column-agent invalidation", () => {
function activeGraphSession(executor: TaskExecutor, taskId: string, governing: string, binding: WorkflowColumnAgent) {
const setModel = vi.fn();
const session = { setModel, dispose: vi.fn() } as any;
seedSeam(executor, taskId, governing, binding);
(executor as any).activeSessions.set(taskId, {
session,
seenSteeringIds: new Set<string>(),
lastResolvedModelProvider: "anthropic",
lastResolvedModelId: "claude-x",
lastTaskModelProvider: undefined,
lastTaskModelId: undefined,
lastAssignedAgentId: "agent-Y",
lastEffectiveColumnAgentId: "agent-X",
});
return { setModel };
}
it("workflow edit changes the column agent's model while a session runs → restart (model hot-swap) fires", async () => {
const store = createMockStore();
// modelRegistry.find returns a truthy model so setModel is invoked.
const find = vi.fn().mockReturnValue({ provider: "anthropic", modelId: "claude-x2" });
const task = singleSessionTask({ assignedAgentId: "agent-Y" });
// Column agent X now advertises a NEW model (workflow edit re-pointed / agent config changed).
const { executor } = makeExecutor(store, {
"agent-X": makeColumnAgent({ runtimeConfig: { model: "anthropic/claude-x2", allowParallelExecution: false } }),
});
(executor as any)._modelRegistry = { find };
const { setModel } = activeGraphSession(executor, task.id, "exec-node", OVERRIDE_COL);
// The watcher fires on task:updated.
store._trigger("task:updated", task);
await vi.waitFor(() => expect(setModel).toHaveBeenCalled());
expect(find).toHaveBeenCalledWith("anthropic", "claude-x2");
expect(loggedLines(store).some((l) => l.includes("Column agent changed"))).toBe(true);
});
it("column agent deleted mid-session → no restart storm, no setModel, fallback recorded (R8)", async () => {
const store = createMockStore();
const find = vi.fn().mockReturnValue({ provider: "anthropic", modelId: "claude-x" });
const task = singleSessionTask({ assignedAgentId: "agent-Y" });
// agent-X is ABSENT from the registry (deleted).
const { executor } = makeExecutor(store, {});
(executor as any)._modelRegistry = { find };
const { setModel } = activeGraphSession(executor, task.id, "exec-node", OVERRIDE_COL);
store._trigger("task:updated", task);
// Wait for the async handler to record the fallback.
await vi.waitFor(() =>
expect(loggedLines(store).some((l) => l.includes("deleted mid-session") && l.includes("no restart"))).toBe(true),
);
// No model swap — the running session keeps its current model.
expect(setModel).not.toHaveBeenCalled();
expect(find).not.toHaveBeenCalled();
// Tracked id cleared so we stop probing every tick.
expect((executor as any).activeSessions.get(task.id).lastEffectiveColumnAgentId).toBeNull();
});
it("no-op tick: same effective column agent + already-resolved model → setModel NOT called", async () => {
// The active session is already running as X on X's advertised model. A
// task:updated tick that changes nothing about the effective agent/model must
// not re-issue a setModel (no churn / no spurious hot-swap).
const store = createMockStore();
const find = vi.fn().mockReturnValue({ provider: "anthropic", modelId: "claude-x" });
const task = singleSessionTask({ assignedAgentId: "agent-Y" });
// Column agent X advertises EXACTLY the model the session already resolved.
const { executor } = makeExecutor(store, {
"agent-X": makeColumnAgent({ runtimeConfig: { model: "anthropic/claude-x", allowParallelExecution: false } }),
});
(executor as any)._modelRegistry = { find };
// activeGraphSession seeds lastResolvedModelProvider/Id = anthropic/claude-x
// and lastEffectiveColumnAgentId = agent-X — matching the agent's model.
const { setModel } = activeGraphSession(executor, task.id, "exec-node", OVERRIDE_COL);
await store._triggerAsync("task:updated", task);
// No agent change, no model change → no hot-swap.
expect(setModel).not.toHaveBeenCalled();
expect(loggedLines(store).some((l) => l.includes("Column agent changed"))).toBe(false);
// The legacy task-model block must also not fire a model swap for the override session.
expect(loggedLines(store).some((l) => l.startsWith("Model changed"))).toBe(false);
});
it("override session + mid-flight task model/assigned-agent edit → column agent's model is preserved (legacy hot-swap does NOT clobber it)", async () => {
// R3: under an OVERRIDE column, the column agent owns the model. A user editing
// the task's modelProvider/modelId or assignedAgentId mid-flight must NOT cause
// the legacy task-model hot-swap to resolve the assigned/own model and clobber
// the column agent's model.
const store = createMockStore();
const find = vi.fn().mockReturnValue({ provider: "openai", modelId: "gpt-edited" });
// Edited task: now carries a complete own model pair AND a different assigned agent.
const task = singleSessionTask({
assignedAgentId: "agent-Z",
modelProvider: "openai",
modelId: "gpt-edited",
});
// Column agent X advertises its own (unchanged) model.
const { executor } = makeExecutor(store, {
"agent-X": makeColumnAgent({ runtimeConfig: { model: "anthropic/claude-x", allowParallelExecution: false } }),
"agent-Z": makeAssignedAgent({ id: "agent-Z", runtimeConfig: { model: "openai/gpt-edited" } }),
});
(executor as any)._modelRegistry = { find };
const { setModel } = activeGraphSession(executor, task.id, "exec-node", OVERRIDE_COL);
await store._triggerAsync("task:updated", task);
// The legacy block is short-circuited under override: the assigned/own model
// (openai/gpt-edited) is NEVER applied via setModel.
expect(find).not.toHaveBeenCalledWith("openai", "gpt-edited");
const setModelArgs = setModel.mock.calls.map((c: any[]) => c[0]);
expect(setModelArgs).not.toContainEqual({ provider: "openai", modelId: "gpt-edited" });
// No legacy "Model changed to openai/gpt-edited" audit line either.
expect(loggedLines(store).some((l) => l.includes("openai/gpt-edited"))).toBe(false);
// The tracked effective principal stays the column agent.
expect((executor as any).activeSessions.get(task.id).lastEffectiveColumnAgentId).toBe("agent-X");
});
it("binding removed by a workflow edit → session reverts to own-settings model and the reverse guard releases", async () => {
// PR #1432 review: when the binding disappears (or defer re-resolves to own
// settings) the watcher must hand the session back to normal resolution —
// hot-swap to the assigned/task model, clear the tracked column agent, and
// release isAgentEffectivelyExecuting() for the old agent.
const store = createMockStore();
const find = vi.fn().mockReturnValue({ provider: "openai", modelId: "gpt-y" });
const task = singleSessionTask({ assignedAgentId: "agent-Y" });
const { executor } = makeExecutor(store, {
"agent-Y": makeAssignedAgent({ id: "agent-Y", runtimeConfig: { model: "openai/gpt-y" } }),
});
(executor as any)._modelRegistry = { find };
const { setModel } = activeGraphSession(executor, task.id, "exec-node", OVERRIDE_COL);
// The workflow edit removed the binding: re-seed the resolver to yield none,
// and mark X as effectively executing so we can observe the release.
seedSeam(executor, task.id, "exec-node", undefined);
(executor as any).effectiveColumnAgentByTask.set(task.id, "agent-X");
await store._triggerAsync("task:updated", task);
// Session reverted to the assigned agent's model.
expect(find).toHaveBeenCalledWith("openai", "gpt-y");
expect(setModel).toHaveBeenCalledWith({ provider: "openai", modelId: "gpt-y" });
// Column-agent tracking cleared; reverse heartbeat guard released.
expect((executor as any).activeSessions.get(task.id).lastEffectiveColumnAgentId).toBeNull();
expect(executor.isAgentEffectivelyExecuting("agent-X")).toBe(false);
expect(loggedLines(store).some((l) => l.includes("binding released"))).toBe(true);
});
it("defer binding stays but the task regains own settings → release path fires (FN-5893)", async () => {
// Second release surface: the binding is still present, but a mid-flight
// task edit gave it a complete own model pair, so `defer` now resolves to
// own-settings. The watcher must release exactly like binding removal.
const store = createMockStore();
const find = vi.fn().mockReturnValue({ provider: "openai", modelId: "gpt-own" });
const task = singleSessionTask({
assignedAgentId: "agent-Y",
modelProvider: "openai",
modelId: "gpt-own",
});
const { executor } = makeExecutor(store, {
"agent-Y": makeAssignedAgent({ id: "agent-Y", runtimeConfig: { model: "openai/gpt-own" } }),
});
(executor as any)._modelRegistry = { find };
const { setModel } = activeGraphSession(executor, task.id, "exec-node", {
agentId: "agent-X",
mode: "defer",
});
(executor as any).effectiveColumnAgentByTask.set(task.id, "agent-X");
await store._triggerAsync("task:updated", task);
expect(setModel).toHaveBeenCalledWith({ provider: "openai", modelId: "gpt-own" });
expect((executor as any).activeSessions.get(task.id).lastEffectiveColumnAgentId).toBeNull();
expect(executor.isAgentEffectivelyExecuting("agent-X")).toBe(false);
expect(loggedLines(store).some((l) => l.includes("binding released"))).toBe(true);
});
it("legacy entry (no effective column agent) → the column-invalidation block is skipped", async () => {
const store = createMockStore();
const find = vi.fn();
const task = singleSessionTask({ assignedAgentId: "agent-Y" });
const { executor } = makeExecutor(store, { "agent-X": makeColumnAgent() });
(executor as any)._modelRegistry = { find };
const setModel = vi.fn();
(executor as any).activeSessions.set(task.id, {
session: { setModel, dispose: vi.fn() },
seenSteeringIds: new Set<string>(),
lastResolvedModelProvider: "openai",
lastResolvedModelId: "gpt-y",
lastTaskModelProvider: undefined,
lastTaskModelId: undefined,
lastAssignedAgentId: "agent-Y",
lastEffectiveColumnAgentId: null, // legacy
});
// No seam slots seeded.
await store._triggerAsync("task:updated", task);
// The column-invalidation block never ran (no column-agent fetch / swap).
expect(loggedLines(store).some((l) => l.includes("Column agent changed"))).toBe(false);
});
});
// ── Split-branch note ─────────────────────────────────────────────────────
// Per-session principals: the executor tracks the effective principal per TASK
// (effectiveColumnAgentByTask) and per active session-build, so two distinct
// tasks bound to different columns yield two principals. Asserting TWO truly
// concurrent split-branch SESSIONS for ONE task is not cheaply expressible with
// this single-session mock harness (it pins one createFnAgent call per
// runImplementationPhase), so we assert the per-task divergence instead.
describe("per-task principal divergence (split-branch surrogate)", () => {
it("two tasks bound to different column agents resolve to different effective principals", () => {
const store = createMockStore();
const { executor } = makeExecutor(store, {});
const taskA = singleSessionTask({ id: "FN-A", assignedAgentId: "agent-Y" });
const taskB = singleSessionTask({ id: "FN-B", assignedAgentId: "agent-Y" });
seedSeam(executor, "FN-A", "exec-node", { agentId: "agent-X", mode: "override" });
seedSeam(executor, "FN-B", "exec-node", { agentId: "agent-Z", mode: "override" });
expect((executor as any).resolveEffectivePrincipalId(taskA, taskA)).toBe("agent-X");
expect((executor as any).resolveEffectivePrincipalId(taskB, taskB)).toBe("agent-Z");
});
});
});

View File

@@ -0,0 +1,536 @@
// Column-agent coding seams: execute + step-execute sessions (plan U4,
// R2/R3/R4/R8, KTD-2/KTD-3/KTD-5/KTD-6).
//
// ─────────────────────────────────────────────────────────────────────────────
// SURFACE-ENUMERATION MATRIX AUDIT (plan U7 / FN-5893)
//
// The invariant is proven across mode × surface × own-settings. Every cell that
// matters has at least one assertion in one of the five column-agent test files;
// this block is the completeness ledger (cell → file → test). `own-present` =
// node cfg.agentId OR complete task model pair; `own-absent` = bare.
//
// resolver = column-agent-resolver.test.ts (core, pure precedence)
// custom = executor-column-agent-custom-node.test.ts
// seams = executor-column-agent-seams.test.ts (this file)
// princ = executor-column-agent-principal.test.ts
//
// SURFACE: custom node ───────────────────────────────────────────────────────
// override × own-present → custom "override column: node with own cfg.agentId…"
// override × own-absent → custom "override column: bare node adopts the column agent"
// defer × own-present → custom "defer column: node with own cfg.agentId keeps it…" (a)
// defer × own-absent → custom "defer column: …bare node adopts the column agent" (b)
//
// SURFACE: execute seam ──────────────────────────────────────────────────────
// override × own-present → seams "override column, task assigned to Y → …X's model"
// override × own-absent → seams "override column, bare task (no own settings) → column agent"
// defer × own-present → seams "defer column, task with complete modelProvider/modelId…"
// defer × own-absent → seams "defer column, bare task (no own settings) → column agent adopted"
//
// SURFACE: step-execute ───────────────────────────────────────────────────────
// override × own-present → seams "foreach instance node inherits the foreach's bound column…"
// override × own-absent → seams "step-execute override, bare task → column-agent attribution"
// defer × own-present → seams "defer column with task own complete model pair → assigned attribution"
// defer × own-absent → seams "step-execute defer, bare task → column-agent attribution adopted"
//
// SURFACE: heartbeat-deferred (principal) ─────────────────────────────────────
// override × own-present → princ "override column X (allowParallelExecution=false)…defers"
// + princ "resumeTaskForAgent…pass 2 re-dispatches it"
// override × own-absent → princ "two tasks bound to different column agents…" (bare tasks)
// defer × own-present → princ "defer column with task own complete model pair → X NOT effective, pass 2 does not fire"
// defer × own-absent → resolver "defer × bare → column agent wins" (gate input);
// the deferral gate consumes resolveEffectivePrincipalId, exercised override-side above
//
// SURFACE: missing-agent fallback ─────────────────────────────────────────────
// custom (override) → custom "missing column agent in registry → logged, node falls back…"
// execute seam → seams "column agent missing from registry at seam time → fallback…"
// step-execute → seams "column agent missing from registry at step-execute seam → fallback…"
// restart watcher → princ "column agent deleted mid-session → no restart storm…fallback (R8)"
//
// NO-BINDING (parity / invisibility) ──────────────────────────────────────────
// execute seam → seams characterization "execute seam: …assigned agent, no column-agent log"
// step-execute → seams characterization "step session: attribution falls back to assignedAgentId"
// gating principal → princ "no binding → gating context built for the assigned Y (byte-identical)"
// default workflow → workflow-graph-executor-parity.test.ts "column agent feature is invisible…"
//
// Cells deliberately NOT separately pinned: defer × heartbeat × own-absent at the
// *surface* level — the deferral gate's only column-agent input is the resolver
// verdict (proven in resolver) routed through resolveEffectivePrincipalId (proven
// override-side, where the principal differs from assignedAgentId; under defer ×
// own-absent the principal is still the column agent by the same code path).
// ─────────────────────────────────────────────────────────────────────────────
//
// The graph EXECUTE seam (single coding session) and STEP-EXECUTE seam
// (StepSessionExecutor per-step sessions) must run as the column agent when the
// governing seam node's DECLARED column carries a binding. Session identity =
// model + persona + attribution (gating/heartbeat/restart are U5, untouched here).
//
// Harness: mirrors executor-step-session.test.ts / executor-column-agent-custom-
// node.test.ts — a real TaskExecutor over a mock store with `createFnAgent`
// (the outermost session-spawn boundary) mocked, plus the entirely-mocked
// StepSessionExecutor from executor-test-helpers so the step-session branch's
// constructor options are observable.
//
// The two per-run seam slots the executor reads — `graphSeamGoverningNodeId` and
// `graphColumnAgentResolver` — are normally stamped by the graph seam wiring
// (createPromptLikeHandler → execute/stepExecute seams). We seed them directly and
// drive `runImplementationPhase` (the exact call the execute seam makes, which
// registers a completion interceptor so graph routing is skipped) so the session
// build runs the production resolution path with no scripted session layer.
import { beforeEach, describe, expect, it, vi } from "vitest";
import "./executor-test-helpers.js";
import { TaskExecutor } from "../executor.js";
import {
createMockStore,
mockedCreateFnAgent,
mockedStepSessionExecutor,
mockExecuteAll,
resetExecutorMocks,
} from "./executor-test-helpers.js";
import type { WorkflowColumnAgent } from "@fusion/core";
// The mocked resolveExecutorSessionModel (executor-test-helpers) reads
// `runtimeConfig.model` in "provider/modelId" form, so the column agent advertises
// its model that way; the assigned agent advertises a different one so we can prove
// which one reached the session.
function makeColumnAgent(overrides: Record<string, unknown> = {}) {
return {
id: "agent-col",
name: "Senior Reviewer",
soul: "I am the senior reviewer.",
instructionsText: "Always be thorough.",
memory: undefined,
runtimeConfig: { model: "anthropic/claude-col", runtimeHint: "col-hint" },
...overrides,
};
}
function makeAssignedAgent(overrides: Record<string, unknown> = {}) {
return {
id: "agent-Y",
name: "Assigned Agent",
soul: "I am the assigned agent.",
instructionsText: "Assigned persona.",
memory: undefined,
runtimeConfig: { model: "openai/gpt-assigned", runtimeHint: "assigned-hint" },
...overrides,
};
}
/** A mock fn agent that immediately calls fn_task_done so execute() completes. */
function installTaskDoneAgent() {
mockedCreateFnAgent.mockImplementation((async (opts: any) => {
const tools = opts.customTools || [];
return {
session: {
prompt: vi.fn().mockImplementation(async () => {
const done = tools.find((t: any) => t.name === "fn_task_done");
if (done) await done.execute("tool-1", {});
}),
dispose: vi.fn(),
subscribe: vi.fn(),
on: vi.fn(),
sessionManager: { getLeafId: vi.fn().mockReturnValue("leaf-1") },
state: {},
},
};
}) as any);
}
function makeExecutor(store: ReturnType<typeof createMockStore>, agentsById: Record<string, unknown>) {
const agentStore = {
getAgent: vi.fn(async (id: string) => agentsById[id] ?? null),
};
const executor = new TaskExecutor(store as any, "/tmp/test", { agentStore } as any);
return { executor, agentStore };
}
/**
* Seed the per-run column-agent seam slots the executor reads at session-build
* time, then drive the implementation phase the way the execute seam does.
*/
async function runExecuteSeam(
executor: TaskExecutor,
task: any,
governingNodeId: string,
binding: WorkflowColumnAgent | undefined,
) {
(executor as any).graphSeamGoverningNodeId.set(task.id, governingNodeId);
(executor as any).graphColumnAgentResolver.set(task.id, (nodeId: string) =>
nodeId === governingNodeId ? binding : undefined,
);
return (executor as any).runImplementationPhase(task);
}
/** Force the step-session physics path and seed the seam slots, then run. */
async function runStepSessionSeam(
executor: TaskExecutor,
task: any,
governingNodeId: string,
binding: WorkflowColumnAgent | undefined,
) {
(executor as any).graphStepSessionPinned.add(task.id);
(executor as any).graphSeamGoverningNodeId.set(task.id, governingNodeId);
(executor as any).graphColumnAgentResolver.set(task.id, (nodeId: string) =>
nodeId === governingNodeId ? binding : undefined,
);
return (executor as any).runImplementationPhase(task);
}
function singleSessionTask(overrides: Record<string, unknown> = {}) {
return {
id: "FN-001",
title: "Test",
description: "Test task",
column: "in-progress",
dependencies: [],
steps: [{ name: "Implement", status: "in-progress" }],
currentStep: 0,
log: [],
prompt: "# test\n## Steps\n### Step 0: Implement\n- [ ] implement",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
...overrides,
};
}
function lastFnAgentOpts() {
const calls = mockedCreateFnAgent.mock.calls;
return calls[calls.length - 1]?.[0] as any;
}
function lastStepExecutorOpts() {
const calls = mockedStepSessionExecutor.mock.calls;
return calls[calls.length - 1]?.[0] as any;
}
function loggedLines(store: ReturnType<typeof createMockStore>): string[] {
return store.logEntry.mock.calls.map((call: any[]) => String(call[1] ?? ""));
}
const OVERRIDE_COL: WorkflowColumnAgent = { agentId: "agent-col", mode: "override" };
const DEFER_COL: WorkflowColumnAgent = { agentId: "agent-col", mode: "defer" };
describe("column-agent coding seams (plan U4)", () => {
beforeEach(() => {
resetExecutorMocks();
});
// ── Characterization (pre-substitution behavior) ──────────────────────────
// These pin the assignedAgentId-driven session identity that exists today and
// MUST stay byte-identical on the no-binding path after substitution.
describe("characterization: no binding → assignedAgentId session identity unchanged", () => {
it("execute seam: session model/persona built from the assigned agent, no column-agent log", async () => {
const store = createMockStore();
const task = singleSessionTask({ assignedAgentId: "agent-Y" });
store.getTask.mockResolvedValue(task as any);
const { executor } = makeExecutor(store, { "agent-Y": makeAssignedAgent() });
installTaskDoneAgent();
// No governing node / no binding seeded → legacy path.
await (executor as any).runImplementationPhase(task);
const opts = lastFnAgentOpts();
// Model resolved from the ASSIGNED agent's runtimeConfig.model.
expect(opts.defaultProvider).toBe("openai");
expect(opts.defaultModelId).toBe("gpt-assigned");
// No column-agent adoption logged.
expect(loggedLines(store).some((l) => l.includes("running as column agent"))).toBe(false);
});
it("step session: attribution falls back to assignedAgentId; no effectiveAgentId override", async () => {
const store = createMockStore();
const task = singleSessionTask({ assignedAgentId: "agent-Y" });
store.getTask.mockResolvedValue(task as any);
const { executor } = makeExecutor(store, { "agent-Y": makeAssignedAgent() });
installTaskDoneAgent();
(executor as any).graphStepSessionPinned.add(task.id);
await (executor as any).runImplementationPhase(task);
const opts = lastStepExecutorOpts();
// No column agent governs → no attribution override (StepSessionExecutor
// falls back to taskDetail.assignedAgentId ?? "executor").
expect(opts.effectiveAgentId).toBeUndefined();
// Model precedence input is the assigned agent's runtimeConfig.
expect(opts.assignedAgentRuntimeConfig).toEqual(makeAssignedAgent().runtimeConfig);
expect(loggedLines(store).some((l) => l.includes("running as column agent"))).toBe(false);
});
});
// ── Execute seam (single coding session) ──────────────────────────────────
describe("execute seam", () => {
it("override column, task assigned to Y → session uses column agent X's model/persona/identity + audit", async () => {
const store = createMockStore();
const task = singleSessionTask({ assignedAgentId: "agent-Y" });
store.getTask.mockResolvedValue(task as any);
const { executor, agentStore } = makeExecutor(store, {
"agent-Y": makeAssignedAgent(),
"agent-col": makeColumnAgent(),
});
installTaskDoneAgent();
await runExecuteSeam(executor, task, "execute-node", OVERRIDE_COL);
const opts = lastFnAgentOpts();
// Column agent X's model supersedes the assigned agent Y's.
expect(opts.defaultProvider).toBe("anthropic");
expect(opts.defaultModelId).toBe("claude-col");
// Persona: column agent's soul + instructionsText reach the session system
// prompt layers (KTD-6 typed fields).
const promptText = JSON.stringify(opts.systemPromptLayers ?? "") + (opts.systemPrompt ?? "");
expect(promptText).toContain("I am the senior reviewer.");
expect(promptText).toContain("Always be thorough.");
// The column agent was fetched (identity), not just the assigned agent.
expect(agentStore.getAgent).toHaveBeenCalledWith("agent-col");
// Audit names the substitution + mode.
expect(
loggedLines(store).some(
(l) => l.includes("running as column agent 'agent-col' (override)") && l.includes("execute-node"),
),
).toBe(true);
});
it("defer column, task with complete modelProvider/modelId → task settings win", async () => {
const store = createMockStore();
// Task carries a complete own model pair → defer must yield own settings.
const task = singleSessionTask({ modelProvider: "task-prov", modelId: "task-model" });
store.getTask.mockResolvedValue(task as any);
const { executor, agentStore } = makeExecutor(store, { "agent-col": makeColumnAgent() });
installTaskDoneAgent();
await runExecuteSeam(executor, task, "execute-node", DEFER_COL);
const opts = lastFnAgentOpts();
// The task's own complete pair wins (mocked resolver: no agent runtimeConfig
// model, falls through to the task pair).
expect(opts.defaultProvider).toBe("task-prov");
expect(opts.defaultModelId).toBe("task-model");
// Column agent never fetched/adopted.
expect(agentStore.getAgent).not.toHaveBeenCalledWith("agent-col");
expect(loggedLines(store).some((l) => l.includes("running as column agent"))).toBe(false);
});
it("defer column, task with own assignedAgentId only (NO model pair) → own-settings win, column agent NOT adopted", async () => {
// KTD-5 at the seam: an own agent IDENTITY alone counts as own-settings even
// without a complete model pair, so a defer column must NOT adopt the column
// agent. (Distinct from the bare-task case below where the column agent wins.)
const store = createMockStore();
const task = singleSessionTask({ assignedAgentId: "agent-Y" }); // no modelProvider/modelId
store.getTask.mockResolvedValue(task as any);
const { executor, agentStore } = makeExecutor(store, {
"agent-Y": makeAssignedAgent(),
"agent-col": makeColumnAgent(),
});
installTaskDoneAgent();
await runExecuteSeam(executor, task, "execute-node", DEFER_COL);
const opts = lastFnAgentOpts();
// Fell back to the assigned agent's model — column agent's model not adopted.
expect(opts.defaultProvider).toBe("openai");
expect(opts.defaultModelId).toBe("gpt-assigned");
// Column agent never fetched/adopted, no adoption audit.
expect(agentStore.getAgent).not.toHaveBeenCalledWith("agent-col");
expect(loggedLines(store).some((l) => l.includes("running as column agent"))).toBe(false);
});
it("defer column, bare task (no own settings) → column agent adopted", async () => {
const store = createMockStore();
const task = singleSessionTask(); // no assignedAgentId, no model pair
store.getTask.mockResolvedValue(task as any);
const { executor, agentStore } = makeExecutor(store, { "agent-col": makeColumnAgent() });
installTaskDoneAgent();
await runExecuteSeam(executor, task, "execute-node", DEFER_COL);
const opts = lastFnAgentOpts();
expect(opts.defaultProvider).toBe("anthropic");
expect(opts.defaultModelId).toBe("claude-col");
expect(agentStore.getAgent).toHaveBeenCalledWith("agent-col");
expect(
loggedLines(store).some((l) => l.includes("running as column agent 'agent-col' (defer)")),
).toBe(true);
});
it("override column, bare task (no own settings) → column agent adopted", async () => {
// override × own-absent at the execute seam: the column agent wins
// regardless of own settings, and here there are none to begin with.
const store = createMockStore();
const task = singleSessionTask(); // no assignedAgentId, no model pair
store.getTask.mockResolvedValue(task as any);
const { executor, agentStore } = makeExecutor(store, { "agent-col": makeColumnAgent() });
installTaskDoneAgent();
await runExecuteSeam(executor, task, "execute-node", OVERRIDE_COL);
const opts = lastFnAgentOpts();
expect(opts.defaultProvider).toBe("anthropic");
expect(opts.defaultModelId).toBe("claude-col");
expect(agentStore.getAgent).toHaveBeenCalledWith("agent-col");
expect(
loggedLines(store).some((l) => l.includes("running as column agent 'agent-col' (override)")),
).toBe(true);
});
it("column agent missing from registry at seam time → fallback to assignedAgentId path, logged, run proceeds", async () => {
const store = createMockStore();
const task = singleSessionTask({ assignedAgentId: "agent-Y" });
store.getTask.mockResolvedValue(task as any);
// Column agent absent from the registry; assigned agent present.
const { executor } = makeExecutor(store, { "agent-Y": makeAssignedAgent() });
installTaskDoneAgent();
await runExecuteSeam(executor, task, "execute-node", OVERRIDE_COL);
const opts = lastFnAgentOpts();
// Fell back to the assigned agent's model.
expect(opts.defaultProvider).toBe("openai");
expect(opts.defaultModelId).toBe("gpt-assigned");
// Fallback audited; no adoption claim.
expect(
loggedLines(store).some(
(l) => l.includes("column agent 'agent-col' not found") && l.includes("falling back"),
),
).toBe(true);
expect(loggedLines(store).some((l) => l.includes("running as column agent"))).toBe(false);
// Run still proceeded: a session was built and the task done tool fired
// (the missing column agent never aborted the session — R8).
expect(mockedCreateFnAgent).toHaveBeenCalled();
});
it("integration: the column agent's executor model reaches createResolvedAgentSession options end-to-end", async () => {
// Per the plugin-skills learning — prove with the REAL resolution layers
// (only the outermost createFnAgent/session-spawn boundary is mocked).
const store = createMockStore();
const task = singleSessionTask({ assignedAgentId: "agent-Y" });
store.getTask.mockResolvedValue(task as any);
const { executor } = makeExecutor(store, {
"agent-Y": makeAssignedAgent(),
"agent-col": makeColumnAgent({ runtimeConfig: { model: "anthropic/claude-e2e", runtimeHint: "e2e-hint" } }),
});
installTaskDoneAgent();
await runExecuteSeam(executor, task, "execute-node", OVERRIDE_COL);
const opts = lastFnAgentOpts();
expect(opts.defaultProvider).toBe("anthropic");
expect(opts.defaultModelId).toBe("claude-e2e");
// Runtime hint also follows the column agent end-to-end.
expect(opts.runtimeHint).toBe("e2e-hint");
});
});
// ── Step-execute seam (StepSessionExecutor per-step sessions) ─────────────
describe("step-execute seam", () => {
it("foreach instance node inherits the foreach's bound column → instance session carries column agent identity (attribution asserted)", async () => {
const store = createMockStore();
const task = singleSessionTask({ assignedAgentId: "agent-Y" });
store.getTask.mockResolvedValue(task as any);
const { executor, agentStore } = makeExecutor(store, {
"agent-Y": makeAssignedAgent(),
"agent-col": makeColumnAgent(),
});
installTaskDoneAgent();
// Governing node is the foreach INSTANCE id; the resolver (which the real
// core resolver implements via template inheritance) returns the foreach's
// bound column binding for that instance id.
const instanceNodeId = "foreach-1#0:step-exec";
await runStepSessionSeam(executor, task, instanceNodeId, OVERRIDE_COL);
const opts = lastStepExecutorOpts();
// Attribution: the per-step session is attributed to the column agent.
expect(opts.effectiveAgentId).toBe("agent-col");
// Model precedence input is the column agent's runtimeConfig (not the
// assigned agent's).
expect(opts.assignedAgentRuntimeConfig).toEqual(makeColumnAgent().runtimeConfig);
expect(agentStore.getAgent).toHaveBeenCalledWith("agent-col");
expect(mockExecuteAll).toHaveBeenCalled();
expect(
loggedLines(store).some((l) => l.includes("running as column agent 'agent-col' (override)")),
).toBe(true);
});
it("defer column with task own complete model pair → step session keeps assigned-agent attribution", async () => {
const store = createMockStore();
const task = singleSessionTask({
assignedAgentId: "agent-Y",
modelProvider: "task-prov",
modelId: "task-model",
});
store.getTask.mockResolvedValue(task as any);
const { executor, agentStore } = makeExecutor(store, {
"agent-Y": makeAssignedAgent(),
"agent-col": makeColumnAgent(),
});
installTaskDoneAgent();
await runStepSessionSeam(executor, task, "foreach-1#0:step-exec", DEFER_COL);
const opts = lastStepExecutorOpts();
// Own settings (complete model pair) suppress the defer column agent.
expect(opts.effectiveAgentId).toBeUndefined();
expect(opts.assignedAgentRuntimeConfig).toEqual(makeAssignedAgent().runtimeConfig);
expect(agentStore.getAgent).not.toHaveBeenCalledWith("agent-col");
});
it("override, bare task (no own settings) → step session carries column-agent attribution", async () => {
// override × own-absent at the step-execute seam.
const store = createMockStore();
const task = singleSessionTask(); // no assignedAgentId, no model pair
store.getTask.mockResolvedValue(task as any);
const { executor, agentStore } = makeExecutor(store, { "agent-col": makeColumnAgent() });
installTaskDoneAgent();
await runStepSessionSeam(executor, task, "foreach-1#0:step-exec", OVERRIDE_COL);
const opts = lastStepExecutorOpts();
expect(opts.effectiveAgentId).toBe("agent-col");
expect(opts.assignedAgentRuntimeConfig).toEqual(makeColumnAgent().runtimeConfig);
expect(agentStore.getAgent).toHaveBeenCalledWith("agent-col");
});
it("defer, bare task (no own settings) → step session adopts column-agent attribution", async () => {
// defer × own-absent at the step-execute seam: nothing suppresses defer, so
// the column agent is adopted.
const store = createMockStore();
const task = singleSessionTask(); // no assignedAgentId, no model pair
store.getTask.mockResolvedValue(task as any);
const { executor, agentStore } = makeExecutor(store, { "agent-col": makeColumnAgent() });
installTaskDoneAgent();
await runStepSessionSeam(executor, task, "foreach-1#0:step-exec", DEFER_COL);
const opts = lastStepExecutorOpts();
expect(opts.effectiveAgentId).toBe("agent-col");
expect(opts.assignedAgentRuntimeConfig).toEqual(makeColumnAgent().runtimeConfig);
expect(agentStore.getAgent).toHaveBeenCalledWith("agent-col");
expect(
loggedLines(store).some((l) => l.includes("running as column agent 'agent-col' (defer)")),
).toBe(true);
});
it("column agent missing from registry at step-execute seam → fallback to assigned-agent attribution, logged", async () => {
const store = createMockStore();
const task = singleSessionTask({ assignedAgentId: "agent-Y" });
store.getTask.mockResolvedValue(task as any);
const { executor } = makeExecutor(store, { "agent-Y": makeAssignedAgent() });
installTaskDoneAgent();
await runStepSessionSeam(executor, task, "foreach-1#0:step-exec", OVERRIDE_COL);
const opts = lastStepExecutorOpts();
expect(opts.effectiveAgentId).toBeUndefined();
expect(opts.assignedAgentRuntimeConfig).toEqual(makeAssignedAgent().runtimeConfig);
expect(
loggedLines(store).some(
(l) => l.includes("column agent 'agent-col' not found") && l.includes("falling back"),
),
).toBe(true);
});
});
});

View File

@@ -315,6 +315,15 @@ export function createMockStore() {
_trigger(event: string, ...args: unknown[]) {
for (const fn of listeners.get(event) || []) fn(...args);
},
/** Like `_trigger`, but awaits every (possibly async) listener — deterministic
* synchronization for tests asserting NEGATIVE outcomes after an event
* (e.g. "setModel was NOT called"), where `vi.waitFor` cannot apply and a
* bare `setTimeout(0)` is a brittle real-timer wait. */
async _triggerAsync(event: string, ...args: unknown[]) {
await Promise.allSettled(
(listeners.get(event) || []).map((fn) => Promise.resolve(fn(...args))),
);
},
emit: vi.fn(),
listTasks: vi.fn().mockResolvedValue([]),
getTask: vi.fn().mockResolvedValue({

View File

@@ -10,7 +10,13 @@
// suite `stepwise-workflow-parity.test.ts`. Keep the two concerns separate.
// ─────────────────────────────────────────────────────────────────────────────
import { describe, expect, it, vi } from "vitest";
import type { TaskDetail } from "@fusion/core";
import type { TaskDetail, WorkflowIrV2, WorkflowStage } from "@fusion/core";
import {
BUILTIN_CODING_WORKFLOW_IR,
buildWorkflowObservation,
buildWorkflowObservationFromTask,
compareWorkflowRunObservations,
} from "@fusion/core";
import { WorkflowGraphExecutor } from "../workflow-graph-executor.js";
import type { WorkflowLegacySeams } from "../workflow-node-handlers.js";
@@ -123,3 +129,77 @@ describe("WorkflowGraphExecutor interpreter-parity", () => {
expect(seams.merge).not.toHaveBeenCalled();
});
});
// ─────────────────────────────────────────────────────────────────────────────
// COLUMN-AGENT INVISIBILITY PARITY (plan U7 / R9)
//
// The per-column agent feature must be invisible when no column carries a
// binding: the built-in default workflow synthesizes no `agent` field on any
// column, and a binding-free run produces observations identical to legacy via
// the same `compareWorkflowRunObservations` machinery the dual-observe gate uses.
// This is the byte-identity / parity oracle for the feature being unbound.
// ─────────────────────────────────────────────────────────────────────────────
describe("column-agent feature is invisible when unbound (U7 / R9)", () => {
it("the default built-in workflow synthesizes NO column agent field on any column", () => {
const ir = BUILTIN_CODING_WORKFLOW_IR as WorkflowIrV2;
expect(ir.version).toBe("v2");
expect(ir.columns.length).toBeGreaterThan(0);
for (const col of ir.columns) {
// Absent, not `null` and not an explicit default — R9 omission guarantee.
expect("agent" in col).toBe(false);
}
});
it("a binding-free run yields observations identical to legacy (compareWorkflowRunObservations agrees)", async () => {
// Drive the graph executor over the default execute→review→merge sequence and
// collect the stage transitions; with zero column bindings, the column-agent
// feature contributes nothing, so the interpreter observation must equal the
// legacy authoritative observation with no drift.
const stages: string[] = [];
const seams: WorkflowLegacySeams = {
planning: async () => ({ outcome: "success" }),
execute: async () => ({ outcome: "success" }),
review: async () => ({ outcome: "success" }),
merge: async () => ({ outcome: "success" }),
schedule: async () => ({ outcome: "success" }),
};
type BaseSeam = "planning" | "execute" | "review" | "merge" | "schedule";
const executor = new WorkflowGraphExecutor({
seams,
handlers: {
prompt: async (node, ctx) => {
const seam = String(node.config?.seam) as BaseSeam;
stages.push(seam);
return seams[seam](ctx.task, ctx.context);
},
},
});
const result = await executor.run(task, {
experimentalFeatures: { workflowGraphExecutor: true },
});
expect(result.outcome).toBe("success");
// Bind the invariant to actual executor behavior (PR #1432 review): the
// observation below derives from the run-captured seam sequence, so seam
// drift fails here instead of being masked by a hard-coded literal.
expect(stages).toEqual(["execute", "review", "merge"]);
// Legacy authoritative observation: a clean run that lands in `done`/merged.
const legacyObs = buildWorkflowObservationFromTask(
{ column: "done", status: "done", review: { verdict: "approve" } },
{ columnSequence: ["todo", "in-progress", "in-review", "done"] },
);
// Interpreter (binding-free) observation assembled from the same run.
const interpreterObs = buildWorkflowObservation({
stageTransitions: ["triage", ...stages] as WorkflowStage[],
terminalColumn: "done",
terminalStatus: "done",
reviewVerdict: "approve",
mergeOutcome: "merged",
});
const report = compareWorkflowRunObservations(legacyObs, interpreterObs);
expect(report.agree).toBe(true);
expect(report.diffs).toEqual([]);
});
});

View File

@@ -3649,17 +3649,26 @@ export class HeartbeatTriggerScheduler {
private configRevisionListener: ((agentId: string, revision: AgentConfigRevision) => void) | null = null;
private deletedListener: ((agentId: string) => void) | null = null;
private isTaskExecuting?: (taskId: string) => boolean;
/** Column-agent principal alignment (plan U5, R6). True when the agent is the
* EFFECTIVE column-agent principal of some currently-executing task — i.e. an
* override/defer-bound column staffs it, even though the agent is not that task's
* `assignedAgentId`. The reverse-direction parallel-execution guards consult this
* in addition to `isTaskExecuting(agent.taskId)` so an `allowParallelExecution=false`
* column agent does not heartbeat concurrently with its own override session.
* Absent (legacy/no executor wiring) → treated as never effectively executing. */
private isAgentEffectivelyExecuting?: (agentId: string) => boolean;
private timerAuditIntervalHandle: ReturnType<typeof setInterval> | null = null;
private static readonly TIMER_AUDIT_INTERVAL_MS = 60_000;
private static readonly DEFAULT_REPAIR_STALE_MULTIPLIER = 2;
private static readonly DEFAULT_HEARTBEAT_TIMEOUT_MS = 60_000;
constructor(store: AgentStore, callback: TriggerCallback, taskStore?: TaskStore, options?: { isTaskExecuting?: (taskId: string) => boolean }) {
constructor(store: AgentStore, callback: TriggerCallback, taskStore?: TaskStore, options?: { isTaskExecuting?: (taskId: string) => boolean; isAgentEffectivelyExecuting?: (agentId: string) => boolean }) {
this.store = store;
this.callback = callback;
this.taskStore = taskStore;
this.isTaskExecuting = options?.isTaskExecuting;
this.isAgentEffectivelyExecuting = options?.isAgentEffectivelyExecuting;
}
/**
@@ -3955,9 +3964,16 @@ export class HeartbeatTriggerScheduler {
return;
}
// Guard: when parallel execution is disabled, skip if the bound task is actively executing
if (runtimeConfig.allowParallelExecution === false && this.isTaskExecuting?.(taskId)) {
heartbeatLog.log(`Assignment tick skipped for ${agent.id} (parallel execution disabled, task ${taskId} executing)`);
// Guard: when parallel execution is disabled, skip if the bound task is
// actively executing — OR (plan U5, R6, reverse direction) if this agent is
// the EFFECTIVE column-agent principal of some other actively-executing task
// it is not assigned to. Without the second check an override-column agent
// would heartbeat concurrently with its own column-bound session.
if (
runtimeConfig.allowParallelExecution === false
&& (this.isTaskExecuting?.(taskId) || this.isAgentEffectivelyExecuting?.(agent.id))
) {
heartbeatLog.log(`Assignment tick skipped for ${agent.id} (parallel execution disabled, task ${taskId} or column-bound session executing)`);
return;
}
@@ -4323,9 +4339,19 @@ export class HeartbeatTriggerScheduler {
return;
}
// Guard: when parallel execution is disabled, skip if the agent's bound task is actively executing
if (timerRc.allowParallelExecution === false && agent.taskId && this.isTaskExecuting?.(agent.taskId)) {
heartbeatLog.log(`Timer tick skipped for ${agentId} (parallel execution disabled, task ${agent.taskId} executing)`);
// Guard: when parallel execution is disabled, skip if the agent's bound task is
// actively executing — OR (plan U5, R6, reverse direction) if this agent is the
// EFFECTIVE column-agent principal of some actively-executing task it is not
// assigned to (override/defer column staffing). `agent.taskId` may be empty in
// the column-bound case, so the effective check is independent of it.
if (
timerRc.allowParallelExecution === false
&& (
(agent.taskId && this.isTaskExecuting?.(agent.taskId))
|| this.isAgentEffectivelyExecuting?.(agentId)
)
) {
heartbeatLog.log(`Timer tick skipped for ${agentId} (parallel execution disabled, bound task ${agent.taskId ?? "—"} or column-bound session executing)`);
return;
}

View File

@@ -11,8 +11,8 @@ import { appendFile, mkdir, readFile, readdir, stat, writeFile } from "node:fs/p
import { existsSync } from "node:fs";
import { createHash } from "node:crypto";
import { join, relative, resolve } from "node:path";
import type { AgentStore, AgentState, AgentCapability, AgentUpdateInput, TaskDocument, TaskDocumentCreateInput, TaskStore, RunMutationContext, MessageStore, Message, SourceType, Settings, ResearchRun, ResearchRunStatus, TaskCreateInput, ReflectionStore, ApprovalRequestStore, ProjectSettings, ChatStore } from "@fusion/core";
import { listTraits, isBuiltinWorkflowId, stripApprovalBypassFlags } from "@fusion/core";
import type { AgentState, AgentCapability, AgentUpdateInput, TaskDocument, TaskDocumentCreateInput, TaskStore, RunMutationContext, MessageStore, Message, SourceType, Settings, ResearchRun, ResearchRunStatus, TaskCreateInput, ReflectionStore, ApprovalRequestStore, ProjectSettings, ChatStore } from "@fusion/core";
import { listTraits, isBuiltinWorkflowId, AgentStore, validateColumnAgentBindings, ColumnAgentBindingError, stripApprovalBypassFlags } from "@fusion/core";
import { promoteHeldTask } from "./hold-release.js";
import { DASHBOARD_USER_ID, canAgentTakeImplementationTaskForExplicitRouting, dailyMemoryPath, ensureOpenClawMemoryFiles, extractAgentProvisioningRequest, formatRoleMismatchReason, getMemoryBackendCapabilities, getProjectMemory, isEphemeralAgent, memoryLongTermPath, normalizeMessageParticipant, reconcileDeterministicDuplicate, resolveAgentProvisioningPolicy, resolveMemoryBackend, resolveResearchSettings, resolveTaskGithubTracking, resolveTitleSummarizerSettingsModel, runDeterministicDuplicateGuard, scheduleQmdProjectMemoryRefresh, searchProjectMemory, shouldSkipBackgroundQmdRefresh, summarizeTitle } from "@fusion/core";
import { ResearchOrchestrator } from "./research-orchestrator.js";
@@ -102,6 +102,14 @@ export const workflowCreateParams = Type.Object({
description: "Optional node layout map keyed by node id.",
}),
),
confirm_policy_escalation: Type.Optional(
Type.Boolean({
description:
"Set true to confirm binding a column to an agent whose permission policy is broader " +
"(more privileged) than the project default. Required when such a binding is present; " +
"the create is otherwise rejected naming the offending column.",
}),
),
});
export const workflowUpdateParams = Type.Object({
@@ -117,6 +125,14 @@ export const workflowUpdateParams = Type.Object({
"Required to resolve an OccupiedColumns conflict; the target must exist in the new IR.",
}),
),
confirm_policy_escalation: Type.Optional(
Type.Boolean({
description:
"Set true to confirm binding a column to an agent whose permission policy is broader " +
"(more privileged) than the project default. Required when such a binding is present; " +
"the update is otherwise rejected naming the offending column.",
}),
),
});
export const workflowDeleteParams = Type.Object({
@@ -1197,6 +1213,48 @@ export function createTaskPromoteTool(store: TaskStore, currentTaskId: string):
};
}
/**
* Shared write-time column-agent gate for the `fn_workflow_*` tools (R11/R13).
* Runs the SAME `validateColumnAgentBindings` check the dashboard route runs, so
* an agent cannot persist a binding the UI would reject (existence +
* policy-escalation). Constructs a per-call AgentStore from the store's fusion
* dir (the connection is process-cached) and feeds it the project settings.
*
* A {@link ColumnAgentBindingError} propagates unchanged; each tool's catch
* surfaces its message (which names the column and, for an escalation, instructs
* passing `confirm_policy_escalation: true`).
*/
async function assertWorkflowColumnAgentBindings(
store: TaskStore,
ir: unknown,
confirmPolicyEscalation: boolean,
): Promise<void> {
const columns = (ir as { columns?: unknown })?.columns;
if (!Array.isArray(columns) || !columns.some((c) => c?.agent?.agentId)) return;
const agentStore = new AgentStore({ rootDir: store.getFusionDir() });
await agentStore.init();
const settings = await store.getSettings();
await validateColumnAgentBindings({ ir, agentStore, settings, confirmPolicyEscalation });
}
/**
* Render a {@link ColumnAgentBindingError} as a structured tool error result.
* Re-phrases the escalation guidance with the tool's snake_case flag name
* (`confirm_policy_escalation`) rather than the route's camelCase variant.
*/
function columnAgentBindingErrorResult(err: ColumnAgentBindingError) {
const text =
err.reason === "policy-escalation"
? `Column '${err.columnId}' binds agent '${err.agentId}' whose permission policy is broader than ` +
`the project default; pass confirm_policy_escalation: true to confirm.`
: err.message;
return {
content: [{ type: "text" as const, text: `ERROR: ${text}` }],
details: { columnId: err.columnId, agentId: err.agentId, reason: err.reason },
isError: true as const,
};
}
/**
* Create a `fn_workflow_create` tool — a thin wrapper over the store's workflow
* definition create. The IR is validated server-side; a malformed graph rejects.
@@ -1225,7 +1283,11 @@ export function createWorkflowCreateTool(
"`code` node {source, timeoutMs?} runs sandboxed TypeScript returning {outcome?, contextPatch?, customFields?}. " +
"Declare task documents via `artifacts: [{key, title?, producedBy?, role?}]` and custom task fields via " +
"`fields: [{id, name, type, required?, default?, options?, render?}]` (types: string/text/number/boolean/" +
"enum/multi-enum/date/url; render.placement card|detail|detail-section, render.badge for card chips).",
"enum/multi-enum/date/url; render.placement card|detail|detail-section, render.badge for card chips).\n" +
"Bind a column to a permanent agent via `columns[].agent: { agentId, mode }`: `mode:'defer'` applies the " +
"column agent only when the work carries no own agent/model settings, while `mode:'override'` supersedes " +
"node/task settings wholesale. The bound agent must exist; if its permission policy is broader than the " +
"project default, pass `confirm_policy_escalation: true` to confirm (the create is otherwise rejected).",
parameters: workflowCreateParams,
execute: async (_id: string, params: Static<typeof workflowCreateParams>) => {
try {
@@ -1237,6 +1299,7 @@ export function createWorkflowCreateTool(
ir = result.ir;
if (result.stripped) approvalNote = " (approval-bypass flags removed)";
}
await assertWorkflowColumnAgentBindings(store, ir, params.confirm_policy_escalation === true);
const created = await store.createWorkflowDefinition({
name: params.name,
description: params.description,
@@ -1250,6 +1313,9 @@ export function createWorkflowCreateTool(
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (err: any) {
if (err instanceof ColumnAgentBindingError) {
return columnAgentBindingErrorResult(err);
}
return {
content: [{ type: "text" as const, text: `ERROR: Failed to create workflow: ${err?.message ?? err}` }],
details: {},
@@ -1279,17 +1345,24 @@ export function createWorkflowUpdateTool(
"occupied columns — retry with rehome_to set to a column id that survives in the new IR. " +
"The IR accepts the same step-inversion constructs as fn_workflow_create (foreach with mode/isolation/" +
"concurrency, step-execute, step-review, parse-steps, code nodes, rework edges, artifacts, fields). " +
"Editing `fields` orphans (never destroys) existing task values for removed/incompatible fields.",
"Editing `fields` orphans (never destroys) existing task values for removed/incompatible fields.\n" +
"Bind a column to a permanent agent via `columns[].agent: { agentId, mode }`: `mode:'defer'` applies the " +
"column agent only when the work carries no own agent/model settings, while `mode:'override'` supersedes " +
"node/task settings wholesale. The bound agent must exist; if its permission policy is broader than the " +
"project default, pass `confirm_policy_escalation: true` to confirm (the update is otherwise rejected).",
parameters: workflowUpdateParams,
execute: async (_id: string, params: Static<typeof workflowUpdateParams>) => {
try {
let approvalNote = "";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let ir = params.ir as any;
if (opts?.stripApprovalFlags && ir !== undefined && ir !== null) {
const result = stripApprovalBypassFlags(ir);
ir = result.ir;
if (result.stripped) approvalNote = " (approval-bypass flags removed)";
if (ir !== undefined && ir !== null) {
if (opts?.stripApprovalFlags) {
const result = stripApprovalBypassFlags(ir);
ir = result.ir;
if (result.stripped) approvalNote = " (approval-bypass flags removed)";
}
await assertWorkflowColumnAgentBindings(store, ir, params.confirm_policy_escalation === true);
}
const updated = await store.updateWorkflowDefinition(params.workflow_id, {
name: params.name,
@@ -1305,6 +1378,9 @@ export function createWorkflowUpdateTool(
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (err: any) {
if (err instanceof ColumnAgentBindingError) {
return columnAgentBindingErrorResult(err);
}
// Surface the typed OccupiedColumnsError as a structured, retryable result.
if (err?.name === "OccupiedColumnsError") {
const occupancies = err.occupancies ?? [];

File diff suppressed because it is too large Load Diff

View File

@@ -589,7 +589,13 @@ export class InProcessRuntime
});
},
this.taskStore,
{ isTaskExecuting: (taskId) => this.executor.getExecutingTaskIds().has(taskId) },
{
isTaskExecuting: (taskId) => this.executor.getExecutingTaskIds().has(taskId),
// Column-agent principal alignment (plan U5, R6): reverse-direction guard
// — an override/defer column agent must not heartbeat concurrently with a
// column-bound session it runs but is not assigned to.
isAgentEffectivelyExecuting: (agentId) => this.executor.isAgentEffectivelyExecuting(agentId),
},
);
this.triggerScheduler.start();

View File

@@ -125,6 +125,18 @@ export interface StepSessionExecutorOptions {
permanentAgentGating?: PermanentAgentGatingContext;
/** Task-scoped environment injected into non-git subprocesses. */
taskEnv?: NodeJS.ProcessEnv;
/**
* Column-agent identity override for session attribution (column-agent plan U4,
* R2/R3/R4). When the governing foreach/step-execute node's declared column
* binds an agent that supersedes the task's `assignedAgentId` (override, or
* defer with no own settings), the executor passes the column agent's id here so
* the per-step run auditor attributes the session to who actually ran — not
* `taskDetail.assignedAgentId`. Absent → attribution falls back to
* `taskDetail.assignedAgentId ?? "executor"` (byte-identical legacy path). The
* column agent's MODEL flows separately via {@link assignedAgentRuntimeConfig}
* (the executor swaps it to the column agent's `runtimeConfig` at the seam).
*/
effectiveAgentId?: string;
}
// ── File Scope Extraction ─────────────────────────────────────────────
@@ -1018,7 +1030,10 @@ Follow instructions precisely and avoid unrelated changes.`,
defaultThinkingLevel: taskDetail.thinkingLevel ?? settings.defaultThinkingLevel,
runAuditor: createRunAuditor(this.store, {
runId: generateSyntheticRunId("workflow-step", taskDetail.id),
agentId: taskDetail.assignedAgentId ?? "executor",
// Column-agent attribution (U4): the effective column agent is the
// principal that actually ran when the seam node's column governs;
// fall back to the task's assigned agent (legacy, byte-identical).
agentId: this.options.effectiveAgentId ?? taskDetail.assignedAgentId ?? "executor",
taskId: taskDetail.id,
taskLineageId: taskDetail.lineageId,
phase: "execute",

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

@@ -1,4 +1,4 @@
import { WorkflowIrError, getStepParser } from "@fusion/core";
import { WorkflowIrError, getStepParser, instanceNodeId } from "@fusion/core";
import type { TaskDetail, TaskStep, WorkflowIrNode } from "@fusion/core";
import type { WorkflowNodeHandler, WorkflowNodeResult } from "./workflow-graph-executor.js";
@@ -71,6 +71,19 @@ export interface StepReviewSeamResult {
* which step they operate on and the per-instance baseline/checkpoint state. */
export const FOREACH_ACTIVE_CONTEXT_KEY = "foreach:active";
/**
* Reserved context key carrying the GOVERNING graph node id into the legacy
* coding seams (column-agent plan U4, R4). The execute seam reads the seam node's
* own id; the step-execute seam reads the foreach INSTANCE node id
* (`<foreachId>#<i>:<templateNodeId>`) so the core column-agent resolver can map
* it through template inheritance to the governing column's binding. The seam
* stamps it into a per-run executor slot before driving the implementation pass,
* so the binding the session runs under keys off the node's DECLARED IR column
* — never the task's current board lane. Custom (non-seam) nodes never use this:
* runGraphCustomNode receives its binding directly as a parameter (U3).
*/
export const SEAM_GOVERNING_NODE_CONTEXT_KEY = "workflow:seam-governing-node-id";
/**
* Reserved context marker set by the split sub-walk (`runSplitJoin`) for the
* duration of its branches' execution and cleared at the join (KTD-4, U5). A
@@ -179,9 +192,24 @@ export function createPromptLikeHandler(
// succeed — that would merge a task with no step work done.
return { outcome: "failure", value: "step-execute-unwired" };
}
// Column-agent seam wiring (U4, R4): the GOVERNING node for a step-execute
// session is the foreach INSTANCE node id, so the core resolver can map it
// through template inheritance to the enclosing foreach's bound column (or
// the template node's own column when it declares one). The template node id
// is THIS node's id; the foreach node id + step index come from the active
// instance context. Stamped so the seam threads it into the session build.
context.context[SEAM_GOVERNING_NODE_CONTEXT_KEY] = instanceNodeId(
active.foreachNodeId,
active.stepIndex,
node.id,
);
return seams.stepExecute(context.task, context.context);
}
if (seam) {
// Column-agent seam wiring (U4, R4): for the execute seam the governing node
// IS the seam node, so its declared column drives the binding. (Other seams
// — planning/review/merge/schedule — stamp it too; only execute reads it.)
context.context[SEAM_GOVERNING_NODE_CONTEXT_KEY] = node.id;
return seams[seam]!(context.task, context.context);
}
if (!runCustomNode) {

View File

@@ -6711,8 +6711,24 @@
},
"workflowColumns": {
"add": "Add column",
"agent": "Column agent",
"agentBadgeDefer": "Column agent (defer)",
"agentBadgeOverride": "Column agent (override)",
"agentFlagHint": "Enable both experimentalFeatures.workflowColumns and experimentalFeatures.workflowGraphExecutor to staff columns with agents",
"agentLabel": "Column agent",
"agentMode": "Agent mode",
"agentModeDefer": "Defer",
"agentModeDeferHint": "Column agent applies only when the work carries no agent/model settings of its own",
"agentModeOverride": "Override",
"agentModeOverrideHint": "Column agent supersedes node- and task-level agent/model settings",
"agentNone": "(none)",
"agentNotFound": "Agent not found — {{id}}",
"agentsLoadFailed": "Failed to load agents",
"compositionBlocked": "Resolve trait conflicts on highlighted columns before saving",
"confirmPolicyEscalation": "Bind it anyway? The column agent will run with broader permissions than this project's default.",
"empty": "No columns yet. Add a column to place nodes into board lanes.",
"escalationDeclined": "Save cancelled — column agent binding not confirmed",
"overriddenByColumnAgent": "Overridden by column agent {{name}} — this node's executor settings are superseded.",
"moveDown": "Move column down",
"moveUp": "Move column up",
"nameLabel": "Column name",

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,