Merge remote-tracking branch 'origin/main' into gsxdsm/cli-agent-interface

# Conflicts:
#	packages/dashboard/app/components/__tests__/ListView.test.tsx
This commit is contained in:
gsxdsm
2026-06-05 14:36:42 -07:00
52 changed files with 5276 additions and 180 deletions

View File

@@ -0,0 +1,15 @@
---
"@runfusion/fusion": patch
---
Fix opencode-go model sync: pass API key to CLI and strip provider prefix from model IDs
Two bugs when using OpenCode Go as a provider:
1. **Model discovery only returned free models** — the saved Go API key was never passed as `OPENCODE_API_KEY` to the spawned `opencode models opencode --refresh` process. The CLI's internal plugin checks this env var and, when absent, disables all paid models (those with `cost.input > 0`). Only 20 free models appeared instead of all 67.
2. **API requests failed with 401** — `normalizeOpencodeGoModel` was registering models with prefixed IDs like `opencode-go/deepseek-v4-flash`. The Pi SDK sends `model.id` verbatim in API requests; the OpenCode API expects bare model names (e.g. `deepseek-v4-flash`). The prefix is now stripped during normalization.
Also deduplicates models when the CLI emits both `opencode/foo` and `opencode-go/foo` for the same model, guards against empty model IDs, and refactors the duplicated `onApiKeySaved` handler into a shared `handleOpencodeGoApiKeySaved` helper.
After this change, users must re-select their opencode-go model in Settings because model IDs have changed from prefixed to bare names.

View File

@@ -0,0 +1,11 @@
---
"@runfusion/fusion": minor
---
Add per-column agent assignment for workflow columns, behind the combined `experimentalFeatures.workflowColumns` + `experimentalFeatures.workflowGraphExecutor` flags.
A workflow column can now name a permanent agent from the registry plus a mode — `defer` (the column agent is the default for work in that column that carries no agent/model settings of its own) or `override` (the column agent supersedes node- and task-level agent/model settings). The binding applies to all session-running work attributable to the column's nodes: custom prompt/gate/script nodes, the execute seam's coding session, and step-execute sessions. Precedence is resolved by one shared `@fusion/core` resolver (`resolveColumnAgentBinding` + `resolveEffectiveAgent`) consumed by every reader, with defer/override expressed as explicit named rules and defer granularity all-or-nothing (an own agent identity OR a complete `modelProvider`+`modelId` pair suppresses the column agent). The binding keys off the node's declared IR column; foreach template nodes inherit the enclosing foreach node's column. A missing/deleted agent at resolution time logs and falls back to normal resolution — a live session is never aborted. The built-in default workflow carries no column agents and stays byte-identical (parity oracle); with either flag off, column agents are inert.
The effective column agent is also the principal for the subsystems that previously assumed the running agent is always `task.assignedAgentId`: action gating (`buildActionGateContext` / `buildPermanentAgentGatingContext`) is computed for the agent actually running; heartbeat serialization honors it in both directions (the execute deferral gate, a second `resumeTaskForAgent` pass that re-dispatches tasks whose effective column agent matches, and a reverse-direction heartbeat-scheduler guard so an `allowParallelExecution=false` column agent never heartbeats concurrently with its own session); and a workflow-definition edit or agent runtimeConfig change that re-keys the column-effective agent/model hot-swaps the running graph session, while an agent deleted mid-session falls back without a restart.
Authoring lands in the workflow editor: the column panel gains a registry-backed per-column agent picker plus a defer/override mode toggle, bound columns are badged on their headers, and a node inside an override column shows that its own executor settings are superseded (so override never reads as a bug). Picker interaction states are explicit — flags off disables the picker with a tooltip naming both required flags, an in-flight fetch disables it, a failed fetch shows an inline error, and a stored `agentId` missing from the registry renders an "Agent not found" warning that preserves the IR until the author clears or replaces it. Agent references are validated at save time: the `POST`/`PATCH` workflow routes reject an unknown `agentId` with a typed 4xx naming the offending column, and binding an agent whose permission policy is broader than the project default requires an explicit `confirmPolicyEscalation` flag so override cannot silently re-key action gates to a more-privileged agent.

View File

@@ -7,3 +7,4 @@ Fix the workflow graph editor opening invisibly and bundle the Compound Engineer
- The "Graph editor" button now actually shows the editor: its overlay was rendered without the `open` class, leaving it `display: none`, so opening it looked like the workflow steps view was just dismissed.
- `fusion-plugin-compound-engineering` and `fusion-plugin-roadmap` are now listed in the dashboard's built-in plugins, so they appear under Settings → Built-in Plugins (they were implemented and registered but missing from the list).
- Installing Compound Engineering (and CLI Printing Press) from Settings → Built-in Plugins no longer fails with "Plugin manifest not found": both ids are now in the dashboard's bundled-plugin fallback set, and the Compound Engineering plugin is staged into `dist/plugins/` so packaged installs can resolve it.
- Plugins installed from Settings now load instead of erroring with "Plugin entry must be a file, got directory": the dashboard install routes register the plugin's loadable entry file (`bundled.js`/`dist/index.js`/`src/index.ts`) rather than the package directory, and enabling a plugin heals legacy directory-path registrations in place.

View File

@@ -141,6 +141,10 @@ A plugin that ships inside the Fusion distribution itself rather than being inst
*Avoid:* built-in plugin (as a distinct concept; the Settings label uses "Built-in" for the same thing)
A Bundled Plugin must be registered in several independently maintained surfaces — the Settings catalog, the dashboard server's bundled-id fallback set, the CLI's startup auto-install list, and the build step that stages a loadable copy into the distribution. The surfaces do not cross-check each other: a plugin registered in some but not all appears installable yet fails to install or load, so adding one means mirroring an existing bundled plugin across every surface.
### Plugin Entry
The single loadable file persisted as a plugin's path and dynamically imported by the loader. The contract is strict: a package directory is never a valid entry (ESM cannot import directories), so every install surface must resolve a concrete file before persisting, preferring the shipped bundle, then a prebuilt output, then raw workspace source. Legacy registrations that stored a directory are healed in place — re-pointed at a resolved entry — the next time the plugin is enabled or auto-installed.
## Workflow columns & traits
*Behind the `experimentalFeatures.workflowColumns` flag. With the flag off, the legacy fixed pipeline (the closed column enum + `VALID_TRANSITIONS`) is authoritative and unchanged.*

View File

@@ -0,0 +1,345 @@
---
title: "feat: Per-column agent assignment — permanent agents for workflow columns"
type: feat
status: completed
date: 2026-06-04
depth: standard
origin: none (solo planning bootstrap; extends docs/plans/2026-06-03-003-feat-workflow-custom-columns-traits-plan.md)
---
# feat: Per-column agent assignment — permanent agents for workflow columns
## Summary
Let a workflow-defined column name a **permanent agent** from the agent registry, with a per-column mode: **defer** (column agent is the default for work in that column that carries no agent/model settings of its own) or **override** (column agent wins over node-level and task-level agent/model settings). The binding applies to all session-running work attributable to the column — custom prompt/gate/script nodes, the execute seam's coding session, and step-execute sessions — and the column-resolved agent becomes the *principal* for action gating, heartbeat deferral, and session-restart detection, not merely a model source. The built-in default workflow carries no column agents and stays byte-identical (parity oracle).
---
## Problem Frame
The columns/traits track (`docs/plans/2026-06-03-003-feat-workflow-custom-columns-traits-plan.md`, PR #1418) made columns first-class workflow IR entities with composable traits, and the step-inversion track (active on this branch) is making steps workflow-modelable. But **who does the work** in a column is still decided node-by-node or task-by-task: a custom node can set `executor: "agent"` + `agentId` in its config (`packages/engine/src/executor.ts:4546`), and a task can carry `assignedAgentId` / `modelProvider` + `modelId` — there is no way to say "everything that runs in my Review column runs as the senior-reviewer agent."
A user authoring a workflow with specialized columns (planning, implementation, review, docs) wants to staff each column once and have every card flowing through inherit that staffing — while still being able to either respect finer-grained node settings (defer) or enforce the column's agent unconditionally (override).
---
## Requirements
**Binding & precedence**
- R1. A workflow column can optionally name an agent from the agent registry plus a mode, `defer` or `override`.
- R2. Defer: the column agent applies only when the work carries no agent/model settings of its own — for custom nodes, no `cfg.agentId` and no `cfg.modelProvider`+`cfg.modelId` pair; for coding seams, no `task.assignedAgentId` and no `task.modelProvider`+`task.modelId` pair. Granularity is all-or-nothing: any own agent identity or complete model pair suppresses the column agent entirely.
- R3. Override: the column agent supersedes node-level and task-level agent/model settings — identity, model, and persona.
- R4. The binding keys off the node's **declared** IR column (`node.column`), never the task's current board lane. Foreach template nodes inherit the enclosing foreach node's column unless they declare their own. A node with no declared column resolves normally (no column agent), even in override mode.
**Principal semantics**
- R5. Under an effective column agent, action gating (`buildPermanentAgentGatingContext` / `buildActionGateContext`) is computed for the column agent — the agent actually running — not `task.assignedAgentId`.
- R6. Heartbeat deferral (`shouldDeferForHeartbeat`) and resume (`resumeTaskForAgent`) honor the effective column agent: a column agent with `allowParallelExecution=false` is serialized the same way an assigned agent is. This includes `resumeTaskForAgent`'s task-selection query (it must re-dispatch tasks whose *effective* agent matches, not only `assignedAgentId` matches) and the heartbeat scheduler's reverse-direction guards keyed on `agent.taskId`.
- R7. Column-agent-driven changes to the effective model/agent (workflow-definition edit, agent `runtimeConfig` change) hot-swap a running session with the same user-visible effect as a `task.modelProvider` change today, via save-event invalidation feeding the restart watcher (KTD-4). Agent deletion falls back without a restart storm.
**Resilience & parity**
- R8. A missing/deleted agent at resolution time logs and falls back to normal resolution (mirrors the existing best-effort posture at `packages/engine/src/executor.ts:4555`); a live session is never aborted because its column agent was deleted mid-flight.
- R9. The built-in default workflow is untouched: the new IR field is omitted entirely when unset (never serialized as `agent: null` / explicit defaults), v2-only-feature detection registers it, and the existing parity suites stay green.
- R10. Feature behavior requires both `experimentalFeatures.workflowColumns` and `experimentalFeatures.workflowGraphExecutor`; with either off, column agents are inert and the editor surfaces that.
**Authoring surface**
- R11. The workflow editor's column panel gets a per-column agent picker (registry-backed) plus a defer/override mode toggle; agent references are validated at write time with a clear error for unknown agents. Bound columns are visibly indicated, and a node inside an override column shows that its own executor settings are superseded — override must never look like a bug to the author.
- R12. New IR types are re-exported type-only from `@fusion/plugin-sdk` (`WorkflowColumnAgent`; verify whether `WorkflowIrColumn`/`WorkflowIrColumnTrait` are already reachable through the existing core re-export block and add them only if absent).
- R13. Binding an agent whose permission policy is broader than the project default requires explicit confirmation at save time — override cannot silently re-key action gates to a more-privileged agent.
---
## Key Technical Decisions
- **KTD-1 — First-class optional field on `WorkflowIrColumn`, not a trait.** Traits are board-transition policy (flags + lifecycle hooks consumed by the move machinery); the agent binding is *execution identity* consumed by the executor's session-building paths. A typed `agent?: { agentId: string; mode: "defer" | "override" }` field gets schema validation, plugin-sdk type parity, and a purpose-built picker UI — a trait would bury it in an opaque `config: Record<string, unknown>` and overload the trait registry with a concept the transition machinery never reads. Follows the additive-optional-field precedent of `artifacts?`/`fields?` on `WorkflowIrV2`.
- **KTD-2 — One shared resolver in `@fusion/core`; defer/override are explicit named rules, never a `??` collapse.** A single `resolveColumnAgentBinding(ir, nodeId)` (declared-column lookup + foreach template inheritance) and an effective-agent precedence function live in core and are consumed by every reader — the three engine resolution sites and the dashboard write-validation route. Two institutional learnings drive this: the per-task auto-merge override died because the override was honored at the action site but not at the 20+ trigger-layer gates (`docs/solutions/logic-errors/per-task-auto-merge-override-ignored-by-trigger-gates.md`), and route-vs-engine predicate duplication drifted into a data-loss hazard (`docs/solutions/integration-issues/branch-group-single-pr-synthetic-id-dead-wiring.md`).
- **KTD-3 — The effective column agent is the principal.** Several subsystems assume the running agent is `task.assignedAgentId` today: the restart watcher (`packages/engine/src/executor.ts:2060`), heartbeat deferral/resume (defined `:3031`/`:3056`; the deferral gate call is `:4723`, and `resumeTaskForAgent`'s task-selection query filters on `assignedAgentId`), the heartbeat scheduler's reverse-direction guards keyed on `agent.taskId` (`packages/engine/src/agent-heartbeat.ts`), and permanent-agent action gating (`:1515`, `:1581`). Under override, the agent actually running differs from `assignedAgentId` — computing permission gates for the wrong principal is a security boundary error, and bypassing `allowParallelExecution=false` violates the agent's own contract. All of these must consult the effective agent. The gating-context builders already accept an `Agent` object parameter (callers resolve and pass it in), so principal substitution there is a call-site object swap, not gating-internals surgery — the real risk is resolving the right agent per session and closing the resume/heartbeat reverse-mapping gaps (U5).
- **KTD-4 — Mid-flight edits hot-swap via save-event invalidation; no new edit guard.** The existing restart watcher diffs cached *task* fields — a workflow-definition edit or agent `runtimeConfig` change mutates nothing it observes, so "just feed the diff" is not a mechanism that exists. The primary mechanism is event-driven invalidation: workflow-definition saves and agent-config updates re-resolve the column-effective provider/model/agent into the watcher's tracked state, which then triggers the same restart path a `task.modelProvider` change does today. (Per-tick IR re-resolution is the fallback only if event hooks prove insufficient — it is hot-path-expensive, not the default.) The invalidation hook distinguishes agent-deleted (fall back per R8, no restart) from agent-changed (restart). We deliberately do not mirror `packages/core/src/node-override-guard.ts` (which blocks node-override edits while in-progress): hot-swap is the established posture for model/agent changes, and blocking workflow saves because some card somewhere is in a bound column would make workflow editing unusably brittle. Pause of the effective agent routes through heartbeat deferral (R6).
- **KTD-5 — Defer granularity is all-or-nothing.** "Own settings" means an own agent identity OR a complete `modelProvider`+`modelId` pair; either suppresses column defer entirely. An incomplete model pair with no agent identity does not count (the existing resolver already ignores incomplete pairs — `resolveExecutorSessionModel`'s both-present semantics, `packages/engine/src/agent-session-helpers.ts:147-150`). The column agent is never blended with own settings: filling "only the missing half" would create hybrid identities (column agent's model with the task agent's persona) that are impossible to reason about in audit.
- **KTD-6 — Persona injection follows the coding-session path, and reconciles the field drift.** The custom-node `"agent"` branch reads `agent.customInstructions` (`packages/engine/src/executor.ts:4553`) while the `Agent` type exposes `soul`/`instructionsText` (`packages/core/src/types.ts:5955-5957`) and the coding session resolves persona via `resolveInstructionsForRole` + `buildPromptLayers` (`executor.ts:5800-5840`). The column-agent path uses the typed fields consistently in both places; U3 fixes the custom-node branch to read the same fields rather than perpetuating the drift.
- **KTD-7 — No store schema bump.** The binding lives inside the JSON-serialized workflow IR (parsed by `parseWorkflowIr`); workflow definitions are stored as blobs, so no `SCHEMA_VERSION` change is needed. Write-time validation happens in the dashboard route; read-time misses degrade gracefully (R8).
---
## High-Level Technical Design
Effective-agent resolution — one core function, three engine consumers, one dashboard consumer:
```mermaid
flowchart TB
subgraph core["@fusion/core (new: column-agent-resolver)"]
B[resolveColumnAgentBinding\nir + nodeId → binding?]
P[resolveEffectiveAgent\nbinding × own settings → principal]
B --> P
end
subgraph engine["@fusion/engine consumers"]
N["runGraphCustomNode\n(custom prompt/gate/script nodes)"]
E["execute seam\n(single coding session)"]
S["step-execute\n(StepSessionExecutor)"]
end
D["dashboard route\n(write-time validation)"]
P --> N
P --> E
P --> S
B --> D
P --> G["principal subsystems:\naction gating · heartbeat deferral\nrestart watcher"]
```
Precedence per node (the two named rules):
```mermaid
flowchart TB
A[node executes] --> C{node.column declared?\nforeach templates inherit\nthe foreach node's column}
C -->|no| F[normal resolution\nnode cfg → task → settings]
C -->|yes| H{column has agent binding?}
H -->|no| F
H -->|yes| M{mode}
M -->|override| O[column agent wins:\nidentity + model + persona\n+ gating principal]
M -->|defer| Q{work has own settings?\nagentId OR complete\nmodelProvider+modelId pair}
Q -->|yes| F
Q -->|no| O
O --> R{agent resolves\nin registry?}
R -->|yes| Z[session runs as column agent]
R -->|no| L[log + fall back] --> F
```
Directional guidance, refined during implementation — the prose requirements are authoritative.
---
## Implementation Units
### U1. IR schema, validation, and parity registration
**Goal:** `WorkflowIrColumn` gains an optional, additively-validated `agent` binding that never perturbs legacy or default workflows.
**Requirements:** R1, R9, R12
**Dependencies:** none
**Files:**
- `packages/core/src/workflow-ir-types.ts` — `WorkflowColumnAgent` interface; `agent?: WorkflowColumnAgent` on `WorkflowIrColumn`
- `packages/core/src/workflow-ir.ts` — extend `validateColumns` (`:729`); register in v2-only-feature detection (`:858-878`); ensure serialization omits the field when unset
- `packages/plugin-sdk/src/index.ts` — type-only re-export `WorkflowColumnAgent`; check whether `WorkflowIrColumn`/`WorkflowIrColumnTrait` are already reachable through the existing `@fusion/core` re-export block and add them only if absent (R12)
- `packages/core/src/__tests__/workflow-ir-column-agent.test.ts` (new)
**Approach:** Mirror the `validateFields` pattern (`workflow-ir.ts:660` — early return when absent). Validation when present: `agentId` non-empty string, `mode` one of `defer`/`override`. Additionally validate that every `node.column` reference — **including nodes inside foreach `template` subgraphs** — resolves to a declared column id, so a template node with a dangling column is a typed validation error rather than a silent no-binding no-op at runtime. Agent *existence* is not an IR-validation concern (the IR layer has no agent store) — that's write-time route validation (U6) and read-time fallback (U3/U4).
**Patterns to follow:** `artifacts?`/`fields?` additive-optional precedent on `WorkflowIrV2`; `validateFields` early-return validator shape.
**Test scenarios:**
- Column with `agent: { agentId: "agent-001", mode: "defer" }` parses and round-trips; field absent → parses identically to today.
- `agent` with empty `agentId`, missing `mode`, or unknown `mode` value → typed validation error naming the column id.
- v1 graph upgrade via `synthesizeDefaultColumns` produces columns with no `agent` field (absent, not null).
- Template-subgraph node with a `column` value matching no declared column id → typed validation error naming the node.
- Default workflow IR (`builtin-coding-workflow-ir.ts`) round-trips byte-identically; v2-only-feature detection flags a graph with a column agent as non-default.
- Serialization of a column whose binding was removed omits the key entirely.
**Verification:** core IR tests green; existing `workflow-ir.test.ts`, `migration-workflow-columns.test.ts`, and the cli `plugin-sdk-export` test untouched-green.
---
### U2. Core effective-agent resolver
**Goal:** A single `@fusion/core` module owns "which agent does this node's work" — binding lookup and the two named precedence rules — so engine and dashboard can never drift.
**Requirements:** R2, R3, R4
**Dependencies:** U1
**Files:**
- `packages/core/src/column-agent-resolver.ts` (new)
- `packages/core/src/index.ts` — export
- `packages/engine/src/workflow-graph-foreach.ts` — re-point `instanceNodeId` import to core (format ownership moves)
- `packages/core/src/__tests__/column-agent-resolver.test.ts` (new)
**Approach:** Two pure functions. `resolveColumnAgentBinding(ir, nodeId)` resolves the node's `column` against `ir.columns` and returns the binding or undefined (a column without an `agent` field yields no binding — that, not "column undeclared," is the operative guarantee, since v1→v2 upgrade synthesizes a column for every node); for foreach instance node ids (`<foreachId>#<i>:<templateNodeId>`) it resolves the *enclosing foreach node's* column, honoring a template node's own declared column when present. The instance-id format currently lives engine-side (`workflow-graph-foreach.ts` `instanceNodeId`): move `instanceNodeId` plus a paired `parseInstanceNodeId` into `@fusion/core` and re-point the engine import, so the format has exactly one owner (the route/engine predicate-drift learning). Parse defensively — split on the first `#`, then the first `:`, since `templateNodeId` is not sanitized against containing `:`. `resolveEffectiveAgent({ binding, ownAgentId, ownModelPair })` implements R2/R3 as explicit branches (per the auto-merge-override learning: distinct named rules, no effective-value `??` collapse) and returns a discriminated result (`column-agent` | `own-settings` | `none`) so callers and audit logs can state *why* an agent was chosen.
**Test scenarios:**
- Override × own settings present → column agent. Override × no own settings → column agent.
- Defer × own agentId only → own settings win. Defer × complete own model pair only → own settings win. Defer × lone provider with no modelId and no agentId → column agent wins (an incomplete pair does not count as own settings, matching `resolveExecutorSessionModel`'s both-present rule, KTD-5). Pin all three explicitly.
- No `node.column` → no binding, even when other columns carry override agents.
- Foreach instance id resolves to the foreach node's column; template node with its own `column` wins over inheritance.
- Two tasks differing only in column binding diverge (the divergence-assertion pattern from the auto-merge learning).
**Verification:** resolver tests enumerate the full mode × own-settings matrix; no engine import in the module (core stays DI-clean).
---
### U3. Custom-node resolution honors the column binding
**Goal:** Prompt/gate/script/skill nodes in a bound column run as the column agent per mode.
**Requirements:** R2, R3, R4, R8
**Dependencies:** U2
**Files:**
- `packages/engine/src/executor.ts` — `runGraphCustomNode` (`:4498-4644`)
- `packages/engine/src/__tests__/executor-column-agent-custom-node.test.ts` (new)
**Approach:** The IR is *not* in scope inside `runGraphCustomNode` — resolve the column binding in the `runCustomNode` seam wiring (`executor.ts:3327`, where the graph runner's callbacks are constructed and the resolved IR is available) and pass the binding into `runGraphCustomNode` as a parameter; if resolution must happen inside instead, use `resolveWorkflowIrForTask` with the `hold-release.ts` irCache pattern — never an uncached per-node store fetch. On `column-agent`: fetch via `agentStore.getAgent` (best-effort, log + fall back on null — same posture as `:4555`), adopt `runtimeConfig.executorProvider/executorModelId` and persona, and emit a `logEntry` naming the substitution and mode (e.g., "running as column agent X (override)") so the audit trail explains who ran and why — mirroring the `:4556` fallback-log pattern. Override replaces the node's own `agentId`/model/persona wholesale; defer fires only when the resolver said so. Persona uses the typed `soul`/`instructionsText` fields and this unit fixes the existing `customInstructions` drift (KTD-6). `executorKind: "cli"`/`"skill"` nodes keep their execution mechanics; the column agent contributes model/persona where a session runs (skill prompt sessions), and is a no-op for raw CLI script execution — log the skip so audit explains it.
**Patterns to follow:** the existing `"agent"` branch at `executor.ts:4546-4560` (model adoption + persona prepend + best-effort fallback).
**Test scenarios:**
- Override column: node with its own `cfg.agentId` runs as the column agent (model + persona from column agent asserted on the synthesized `WorkflowStep`), and the task log records the substitution and mode.
- Defer column: node with own `cfg.agentId` keeps it; bare node adopts the column agent.
- Missing column agent in registry → logged, node falls back to its own/default resolution, node still executes.
- Node with no declared column in a graph that has bound columns → untouched resolution.
- CLI-executor node in an override column → mechanics unchanged, audit log notes the skip.
**Verification:** new tests green; existing `workflow-graph-executor-handlers.test.ts` and `workflow-node-handlers.test.ts` untouched-green.
---
### U4. Coding seams: execute + step-execute sessions
**Goal:** The main coding session and per-step sessions run as the column agent when the seam node's column is bound — the "does whatever work for that column's steps" half.
**Requirements:** R2, R3, R4, R8
**Dependencies:** U2
**Files:**
- `packages/engine/src/executor.ts` — execute-seam session build (`:5649-5767`), step-session branch (`:5154-5183`), graph seam wiring (`:4203-4223`)
- `packages/engine/src/step-session-executor.ts` — model/agent resolution (`:985-1021`)
- `packages/engine/src/agent-session-helpers.ts` — only if the effective-agent input needs threading into `resolveExecutorSessionModel` callers
- `packages/engine/src/__tests__/executor-column-agent-seams.test.ts` (new)
**Approach:** At the graph seams the executor knows the seam node and the resolved IR. Resolve the effective agent once per seam invocation; when it yields `column-agent`, substitute that agent where `assignedAgentId`'s agent flows today — `resolveExecutorSessionModel`'s `assignedAgentRuntimeConfig` argument, `extractRuntimeHint`, persona via `resolveInstructionsForRole`/`buildPromptLayers`, memory tools, and the session's `agentId` attribution in `StepSessionExecutor`. Adoption is audited via `logEntry` at the seam (same contract and wording shape as U3). Defer mode maps onto the resolver verdict computed from `task.assignedAgentId` + `task.modelProvider/modelId`. Foreach instances inherit the foreach node's column (resolver handles id parsing, U2). Flag-OFF and legacy (non-graph) execution never reach this code path — the legacy executor doesn't read `node.column` at all, preserving R10 structurally.
**Execution note:** characterization-first — pin the current `assignedAgentId` session-identity behavior for both seams before introducing the substitution, so the no-binding path is provably byte-identical.
**Test scenarios:**
- Execute seam, override column, task with `assignedAgentId` Y → session built with column agent X's model/persona/identity; audit shows the `column-agent` reason.
- Execute seam, defer column, task with complete `modelProvider/modelId` → task settings win.
- Step sessions: foreach template `step-execute` node inherits the foreach node's bound column; each instance session carries the column agent's identity (`agentId` attribution asserted).
- No binding anywhere → session construction byte-identical to the pinned characterization (parity).
- Column agent missing from registry at seam time → fallback to `assignedAgentId` path, logged, run proceeds.
- Integration scenario (per the plugin-skills learning — prove with a real resolver, not a scripted session): a real session-build path carries the column agent's `executorProvider/executorModelId` end-to-end into `createResolvedAgentSession` options.
**Verification:** new seam tests green; `step-session-executor.test.ts`, `agent-session-helpers.test.ts`, and `workflow-graph-executor-parity.test.ts` untouched-green.
---
### U5. Principal alignment: gating, heartbeat deferral, restart watcher
**Goal:** The three subsystems that assume "the running agent is `task.assignedAgentId`" consult the effective column agent instead, closing the security and serialization gaps.
**Requirements:** R5, R6, R7
**Dependencies:** U4
**Files:**
- `packages/engine/src/executor.ts` — restart watcher (`:2060-2090`), `shouldDeferForHeartbeat` (defined `:3031`; the deferral gate call site that must consult the effective principal is `:4723`), `resumeTaskForAgent` (defined `:3056` — both its gate input AND its task-selection query change), gating-context builders (`:1515`, `:1581`)
- `packages/engine/src/agent-heartbeat.ts` — reverse-direction `agent.taskId` parallel-execution guards
- `packages/engine/src/__tests__/executor-column-agent-principal.test.ts` (new)
**Approach:** Introduce `resolveEffectivePrincipal(task, resolvedBinding)` — **session-scoped**, receiving the binding already computed by the U2 resolver for the specific governing node (not a task-wide lookup), returning the principal (column agent when the binding governs, else `assignedAgentId`). Feed it to: (a) `buildPermanentAgentGatingContext`/`buildActionGateContext` — both already accept an `Agent` object, so this is a call-site object swap at the session-build sites; (b) heartbeat serialization in **both directions**: the deferral gate at `:4723` consults the effective principal, `resumeTaskForAgent`'s task-selection query gains a second pass that re-dispatches tasks whose effective column agent matches (after the existing `assignedAgentId` filter), and the heartbeat scheduler's `agent.taskId`-keyed guards in `agent-heartbeat.ts` learn that an agent may be effectively executing column-bound tasks it is not assigned to — otherwise an `allowParallelExecution=false` column agent heartbeats concurrently with its own override session; (c) the restart watcher via save-event invalidation (KTD-4): workflow-definition saves and agent-config updates re-resolve the column-effective provider/model/agent into the watcher's tracked state, distinguishing agent-deleted (fall back per R8, no restart) from agent-changed (restart). Per-node resolution means a task may have >1 effective agent across concurrent split-branch sessions — deferral/gating evaluate per session, not per task.
**Test scenarios:**
- Override column, task assigned to Y, column agent X with `allowParallelExecution=false` and an active heartbeat run → execute defers; `resumeTaskForAgent(X)` re-dispatches it via the effective-agent pass (the `assignedAgentId` filter alone would miss it — assert the second pass fires).
- Reverse direction: agent X (`allowParallelExecution=false`) is executing an override-column task it is not assigned to → X's heartbeat timer does not fire concurrently.
- Action gating context built for X (not Y) when the column binding governs; built for Y when no binding.
- Workflow edit changes the column's agent while a session runs → restart watcher fires (mirrors the existing model-change restart assertion shape at `executor.ts:2062-2075` tests).
- Column agent deleted mid-session → no restart-storm, session finishes, next resolution falls back (R8).
- Split branches with different bound columns → two sessions, two principals, each gated independently.
**Verification:** principal tests green; no regression in existing heartbeat/gating suites (`agent-*` engine tests).
---
### U6. Dashboard: column agent picker, mode toggle, write-time validation
**Goal:** Workflow authors staff a column from the editor; invalid agent references are rejected at save.
**Requirements:** R10, R11
**Dependencies:** U1
**Files:**
- `packages/dashboard/app/components/WorkflowColumnPanel.tsx` — agent picker + defer/override toggle per column, bound-column indicator
- `packages/dashboard/app/components/WorkflowNodeEditor.tsx` — "overridden by column agent" note on nodes in override columns; stale-agentId treatment shared with the column picker
- `packages/dashboard/src/routes/register-workflow-routes.ts` — extend the POST `/api/workflows` and PATCH `/api/workflows/:id` handlers: `assertColumnAgentsExist(ir, agentStore)` helper parallel to `assertCodeNodesCompile`, plus the policy-escalation confirmation (R13)
- `packages/dashboard/src/__tests__/workflow-routes.test.ts` — extend
- `packages/dashboard/src/routes/__tests__/board-workflows.test.ts` — extend if column payloads surface there
**Approach:** Mirror the `fetchAgents()` dropdown pattern from `WorkflowNodeEditor.tsx:560-571, 800-803`, loading eagerly on panel mount. Picker renders "(none)" + registry agents; selecting one reveals the defer/override toggle (default `defer` — the less surprising mode). Interaction states are specified, not implementer-invented: **flags off** → disabled (not hidden) with a tooltip naming both required flags, matching the existing `readOnly` title-hint pattern (`WorkflowColumnPanel.tsx:113-115`); **fetch in flight** → picker disabled; **fetch failed** → inline error on the picker, not only a toast; **stored `agentId` absent from the registry** → render "Agent not found — \<id\>" warning instead of a blank select, preserving the IR until the author explicitly clears or replaces it (apply the same stale-id treatment to the node-level picker). **Override visibility (R11):** a bound column shows the agent name/badge on its header, and a node inside an override column shows an "overridden by column agent" note beside its own executor settings — without this, authors diagnose override as a bug. **Write-time validation (R13):** `assertColumnAgentsExist` returns a typed 4xx naming the column for unknown agents; when the bound agent's `permissionPolicy` is broader than the project default, the save requires an explicit `confirmPolicyEscalation` flag in the request body so override cannot silently re-key action gates to a more-privileged agent. Per the SWR-identity learning, key any selection/reset state on agent ids, not cached array identity.
**Test scenarios:**
- Save with valid `agent` binding persists and round-trips through the definition GET.
- Save referencing an unknown `agentId` → typed 4xx naming the column; definition unchanged.
- Save binding a more-privileged agent without `confirmPolicyEscalation` → typed 4xx naming the policy gap; with the flag → persists (R13).
- Save with binding absent → stored IR has no `agent` key (omission asserted, R9).
- Stored `agentId` missing from the registry response → picker renders the not-found warning with the stale id; IR untouched until explicitly cleared (component-level).
- Node inside an override column renders the overridden-by-column-agent note (component-level).
- Flags off → picker disabled with the flag-naming hint (component-level), and the route still accepts/round-trips bindings (config is data; execution is what's gated).
**Verification:** dashboard route tests green; manual editor check via the worktree-safe dashboard flow (`docs/solutions/` browser-testing note) if UI verification is wanted.
---
### U7. Surface-enumeration test matrix, parity proof, changeset, docs
**Goal:** Prove the invariant across every surface and both modes; document the feature.
**Requirements:** R9, plus cross-cutting assertions for R1-R8
**Dependencies:** U3, U4, U5, U6
**Files:**
- `packages/engine/src/__tests__/workflow-graph-executor-parity.test.ts` — extend: default workflow with no bindings is byte-identical
- new matrix coverage distributed into the U3/U4/U5 test files (this unit audits completeness rather than duplicating)
- `.changeset/*.md` — minor, `@runfusion/fusion`
- docs: workflow-authoring docs section covering column agents, defer/override semantics, and the foreach inheritance rule
**Approach:** Per FN-5893 surface enumeration, the matrix is mode (`defer`/`override`) × surface (custom node, execute seam, step-execute, heartbeat-deferred, missing-agent fallback) × own-settings (present/absent). Most cells land in U3-U5; this unit's job is the completeness audit, the parity extension, and the explicit two-tasks-differing-only-in-binding divergence test if not already present.
**Test scenarios:**
- Matrix audit: every mode × surface × own-settings cell has an assertion somewhere (enumerate in a comment block or table in the parity test).
- Default workflow parity: graph with zero bindings produces identical observations via `compareWorkflowRunObservations`.
**Verification:** `pnpm test` (changed) green; `pnpm lint` and `pnpm build` green; changeset present.
---
## Scope Boundaries
### Deferred to Follow-Up Work
- **Legacy (non-graph) executor support** — column agents only act under `workflowGraphExecutor`; teaching the legacy fixed pipeline about column staffing is not planned (the legacy path is slated for post-graduation removal per the columns track).
- **Per-column agent *pools*** (multiple agents per column with load-balancing) — single agent per column this round; the IR field shape (`agent?` object) leaves room to widen.
- **Exclusive reservation semantics** — the binding is execution identity, not a scheduling reservation; the column agent can still do unrelated work. Capacity remains the `wip` trait + `AgentSemaphore`'s job.
- **Plugin-authored column agents in manifests** — plugins get the types (R12) but no manifest contribution surface for column bindings this round.
### Outside this product's identity
- Human assignee semantics (columns "assigned" to people, approvals routing) — agents only; human gates remain the `human-review` trait's territory.
---
## Risks & Dependencies
- **Step-inversion track is active on this branch.** U4 touches the same seam code (`step-execute`, `StepSessionExecutor`) that track is building. Sequence this plan's U4 after the step-inversion units that establish `runTaskStep` land, or coordinate in the same PR series — implementer should check branch state at execution time.
- **Principal substitution (U5) is the highest-risk unit** — it alters permission-gating identity. The characterization-first posture in U4 plus the no-binding byte-identical assertions are the guardrails; any ambiguity during implementation should resolve toward "gate as the agent actually running."
- **Restart-watcher integration** is event-driven (KTD-4): workflow-definition saves and agent-config updates are the invalidation triggers. If an event path proves unreliable, per-tick IR re-resolution is the (hot-path-expensive) fallback — a contained implementation decision inside U5. Note the weaker guarantee either way: a stale session restarts on the *event*, not on an arbitrary-time diff.
---
## Sources & Research
- Node-level agent adoption template: `packages/engine/src/executor.ts:4546-4560`; canonical model precedence: `packages/engine/src/agent-session-helpers.ts:134-164`.
- Column IR + validation: `packages/core/src/workflow-ir-types.ts:103-148`, `packages/core/src/workflow-ir.ts:660, 729-798, 858-878`.
- Graph executor never reads `node.column` today (confirmed by sweep) — the binding lookup is net-new plumbing at the seams, not a change to walk routing.
- Editor patterns: `WorkflowNodeEditor.tsx` agent dropdown (`:560-571, 800-803`); `WorkflowColumnPanel.tsx` (traits-only today).
- Institutional learnings applied: per-task auto-merge override trigger-gap (`docs/solutions/logic-errors/per-task-auto-merge-override-ignored-by-trigger-gates.md`), route/engine predicate drift (`docs/solutions/integration-issues/branch-group-single-pr-synthetic-id-dead-wiring.md`), registry-declared-but-unwired no-op (`docs/solutions/integration-issues/plugin-bundled-skills-not-loading-in-interactive-sessions.md`), SSE/store enrichment authority (`docs/solutions/logic-errors/queued-chat-message-flush-trusts-stale-isgenerating.md`), SWR identity churn (`docs/solutions/ui-bugs/skill-autocomplete-highlight-reset-on-swr-revalidation.md`).

View File

@@ -0,0 +1,16 @@
# Residual Review Findings — feat/column-agent-assignment
Source: ce-code-review autofix run `20260605-003401-136dbfd5` (artifact: `/tmp/compound-engineering/ce-code-review/20260605-003401-136dbfd5/`), reviewing the column-agent-assignment feature against `docs/plans/2026-06-04-002-feat-column-agent-assignment-plan.md`.
All other actionable findings from the review (validated P1 correctness bugs, R13 agent-path gate bypass, reliability guards, test gaps — findings #1-#10, #12-#18) were fixed on-branch in commit `fix(review): apply autofix feedback` before PR creation. One design-level residual was deferred to the tracker:
## Residual Review Findings
- **[P2]** `packages/dashboard/src/routes/register-workflow-routes.ts:119` — Column-agent policy-escalation gate is save-time-only (TOCTOU): broadening a bound agent's policy (or narrowing the project default) after save silently escalates the substituted principal with no re-confirmation. Filed: https://github.com/Runfusion/Fusion/issues/1431
## Advisory notes (report-only, no action required)
- `confirmPolicyEscalation` is a transient per-request flag; no persisted record of which policy state was confirmed.
- KTD-4 hot-swap covers execute-seam sessions; step-session tasks are not hot-swapped mid-flight (documented limitation).
- `resumeTaskForAgent` pass-2 performs sequential per-candidate IR resolution; consider a fast-path skip when no bindings are active if it shows up in profiles.
- `effectiveColumnAgentByTask` is per-executor-instance while `graphRouting` is process-static — a second `TaskExecutor` instance would not see the first's column-bound sessions in the heartbeat reverse guard.

View File

@@ -12,7 +12,8 @@ symptoms:
root_cause: incomplete_setup
resolution_type: code_fix
severity: medium
tags: [plugins, bundled-plugins, settings, install, tsup, registration-drift]
last_updated: 2026-06-05
tags: [plugins, bundled-plugins, settings, install, tsup, registration-drift, entry-file, fs-mock]
---
# Bundled plugins must be registered in 4 independent places — they drift
@@ -63,6 +64,14 @@ await bundlePluginEntry({
});
```
## Follow-up failure: directory registered as plugin path
Fixing the fallback surfaced a second, independent bug (fixed in PR #1428): both dashboard install routes registered the **manifest directory** as the plugin path, but since FN-4128 the loader requires a loadable entry **file** (Node ESM cannot import directories) — enable then failed with `Plugin entry must be a file, got directory: <dir>`. Only the CLI startup path had been migrated to `resolvePluginEntryPath` (`bundled.js` → `dist/index.js` → `src/index.ts`), which is why CLI-auto-installed plugins worked and Settings-installed ones never did. Fix: both install routes now resolve and register the entry file (helper added to `@fusion/core`; 400 with "no loadable entry file" when none exists), and **both** enable routes heal legacy directory-path rows in place before `loadPlugin` — mirroring the CLI's startup heal — so pre-fix broken registrations self-repair on first enable without a migration.
### Trap: vitest fs mocks don't reach externalized workspace deps
Moving `resolvePluginEntryPath` to `@fusion/core` and re-exporting from the CLI broke the CLI's tests: `vi.mock("node:fs")` in the CLI package does **not** intercept fs calls made inside the externalized `@fusion/core` import (vitest only inlines/mocks modules in the test package's transform graph — the dashboard package inlines core, the CLI doesn't). Resolution: the CLI keeps an intentionally duplicated local copy (its fs mocks work against it), both copies carry keep-in-sync comments, and a **real-fs drift-guard test** (`packages/cli/src/plugins/__tests__/resolve-plugin-entry-path-sync.test.ts`) imports both copies and asserts identical resolution across real temp-dir layouts — each candidate alone, precedence pairs, all three, and the no-entry → `null` case. Real directories are the only seam that exercises both implementations equally; a candidate-list change applied to one copy but not the other now fails CI.
## Why This Works
The Settings card sends a relative `./plugins/<id>` path. The server resolves it against `process.cwd()` — normally the user's project dir, not the Fusion repo — so it 404s and falls back to `extractBundledPluginId()`, which only recognizes ids in routes.ts's `BUNDLED_PLUGIN_IDS`. Adding the id makes the fallback resolve the staged bundled copy; the tsup staging block guarantees that copy exists in packaged installs.
@@ -71,9 +80,13 @@ The Settings card sends a relative `./plugins/<id>` path. The server resolves it
- **When adding a bundled plugin, grep for an existing one** (e.g. `rg -l "fusion-plugin-roadmap" packages/` ) and mirror every hit — that surfaces all four lists plus view registration.
- Route tests must force the fallback: mock fs so the cwd-relative path **misses** and only `dist/plugins/<id>` exists (see "installs bundled compound engineering plugin when relative path misses cwd" in `packages/dashboard/src/__tests__/plugin-routes.test.ts`). A mock that matches any path containing the plugin id tests nothing.
- **Pin assertions to the exact contract, not substring containment.** `stringContaining(pluginId)` passed for both the correct entry-file path and the buggy directory path — when a mock or matcher can satisfy both the correct and the buggy value, the test proves nothing. Route tests now assert the registered path ends in an entry-file suffix, cover the `dist/index.js` and `src/index.ts` fallbacks, and the 400 no-entry branch.
- When duplicating a helper is forced by test infrastructure (fs mocks vs externalized deps), add a real-fs drift-guard test that runs every copy against the same on-disk fixtures and asserts identical output.
- Consider a future consistency test asserting every `BUILTIN_PLUGINS` UI entry with a `path` is present in both server-side `BUNDLED_PLUGIN_IDS` sets.
## Related Issues
- PR #1423 — the fix
- PR #1423 — the registration-drift fix
- PR #1428 — the entry-file/heal follow-up fix
- Issue #1096 — same Settings-install bundled-plugin failure family (missing-bundle symptom for the Paperclip runtime in global npm installs); different root cause
- Commit `ff0750cd1` — added CE/Roadmap to the UI list (2 of 4 registrations)

View File

@@ -61,6 +61,36 @@ FN-5769 evaluated whether those conventions required a `1.1.0` schema bump and r
The `workflowColumns` track introduces **IR v2** (`version: "v2"`), where a workflow additionally defines its own **columns** (`{ id, name, traits: [{ trait, config }] }`), places nodes in columns (`node.column`), and gains `hold`, `split`, and `join` node kinds. Columns become first-class, workflow-defined task state carrying composable **traits** (declarative flags + lifecycle hooks); this generalizes the fixed pipeline + the `gateMode` semantics documented below into per-column trait configuration. v1 graphs still parse and upgrade by synthesizing default-workflow columns. The column/trait model — the trait vocabulary, the substrate/policy line, the transition authority, and the graduation gate — is documented in **`docs/architecture.md` § 9 "Workflow-defined columns & traits"** and the **Concepts** glossary (column, trait, lane, hold node, split/join, default workflow, `transitionPending`). The whole v2 model is gated behind `experimentalFeatures.workflowColumns`; with the flag off, the v1 IR and the quality-gate `WorkflowStep` model below are unchanged.
### Workflow IR v2 — per-column agent assignment
A v2 column can optionally name a **permanent agent** from the agent registry, staffing every card that flows through it once instead of node-by-node or task-by-task. The binding is a first-class optional field on the column (not a trait — traits are board-transition policy; this is execution identity):
```ts
{ id: "review", name: "Review", traits: [],
agent: { agentId: "agent-001", mode: "defer" | "override" } }
```
**Binding shape.** `agent.agentId` is a non-empty registry agent id; `agent.mode` is `defer` or `override`. The field is omitted entirely when unset — a column with no `agent` key yields no binding, and the built-in default workflow carries none (it stays byte-identical, the parity oracle). Adding a binding forces the workflow to v2.
**Which column governs.** The binding keys off the node's **declared** IR column (`node.column`), never the task's current board lane. A node with no declared column resolves normally (no column agent), even when other columns carry override bindings.
**`defer` vs `override`.**
- **`defer`** — the column agent is the default *only* when the work carries no agent/model settings of its own. "Own settings" is all-or-nothing: an own agent identity **or** a complete `modelProvider`+`modelId` pair suppresses the column agent entirely. An incomplete model pair (provider with no model id) does **not** count as own settings, so the column agent still wins (matching the executor's both-present model rule). The column agent is never blended with own settings — filling only the missing half would create hybrid identities that are impossible to audit.
- **`override`** — the column agent supersedes node-level and task-level agent/model settings: identity, model, **and** persona.
**Where it applies.** The effective agent governs all session-running work attributable to the column's nodes: custom prompt/gate/script nodes, the execute seam's coding session, and step-execute sessions. Raw CLI script nodes run no session, so the binding is a no-op there (the skip is audited). Every adoption is logged (`running as column agent '<id>' (<mode>)`) so the audit trail explains who ran and why.
**Foreach template inheritance.** A node inside a `foreach` template subgraph inherits the **enclosing foreach node's** column, unless the template node declares its own `column` (which then wins). Each per-step instance session is attributed to the resolved column agent.
**Principal semantics.** The effective column agent becomes the **principal**, not merely a model source. Action gating is computed for the agent actually running (a security boundary — never `task.assignedAgentId` when an override governs). Heartbeat serialization follows it in both directions: a column agent with `allowParallelExecution=false` is serialized like an assigned agent, the engine re-dispatches tasks whose *effective* column agent matches (not only `assignedAgentId` matches), and the heartbeat scheduler never lets a column agent heartbeat concurrently with its own override session. A workflow-definition edit or agent `runtimeConfig` change that re-keys the effective agent/model hot-swaps a running session, the same way a `task.modelProvider` change does today.
**Missing-agent fallback.** A missing or deleted agent at resolution time logs and falls back to normal resolution — a live session is never aborted because its column agent was deleted mid-flight.
**Flag requirements.** Column agents act only when **both** `experimentalFeatures.workflowColumns` and `experimentalFeatures.workflowGraphExecutor` are on; with either off the binding is inert (config is still stored and round-trips — only execution is gated), and the editor surfaces that the picker is disabled with a tooltip naming both flags.
**Write-time validation.** Saving a workflow validates agent references: an unknown `agentId` is rejected with a typed 4xx naming the column. Binding an agent whose permission policy is broader than the project default requires an explicit policy-escalation confirmation (`confirmPolicyEscalation`) at save time, so override cannot silently re-key action gates to a more-privileged agent.
### Workflow IR v2 — step inversion (foreach, step-review, parse-steps, code)
The **step-inversion** track makes task *steps* themselves workflow-modelable. Today the engine owns step policy end-to-end (PROMPT.md parsing, per-step review verdicts, RETHINK/REVISE control flow, merge blocking). Step inversion extracts exactly one new substrate capability — *run one step inside a task's session, and reset one step to its baseline* — and exposes everything else as authored graph structure. It is additive to IR v2 and gated by `experimentalFeatures.workflowGraphExecutor`. The default coding workflow is untouched and byte-identical (it keeps its monolithic `execute` seam and is the parity oracle); inversion is opt-in via custom workflows and a new built-in **stepwise coding workflow**.

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

@@ -87,7 +87,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";
@@ -1788,14 +1788,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();
@@ -2001,6 +1999,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();
@@ -2109,14 +2112,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,
@@ -75,6 +76,17 @@ export type {
WorkflowNodeExecutorKind,
WorkflowNodeExecutorConfig,
} 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";
@@ -296,8 +308,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 {
@@ -767,7 +784,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

@@ -48,6 +48,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

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

View File

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

View File

@@ -1,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

@@ -34,6 +34,7 @@ import type { DiscoveredSkill } from "../api";
import type { ToastType } from "../hooks/useToast";
import { useOverlayDismiss } from "../hooks/useOverlayDismiss";
import { useModalResizePersist } from "../hooks/useModalResizePersist";
import { useAppSettings } from "../hooks/useAppSettings";
import { workflowNodeTypes, type WorkflowFlowNodeData, type WorkflowEditorNodeKind } from "./nodes/WorkflowNodeTypes";
import {
irToFlow,
@@ -163,6 +164,14 @@ function InnerEditor({
const activeWorkflow = useMemo(() => workflows.find((w) => w.id === activeId), [workflows, activeId]);
const isBuiltin = !!activeWorkflow && isBuiltinWorkflowId(activeWorkflow.id);
// 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(() => {
@@ -482,15 +491,42 @@ function InnerEditor({
columns.length ? columns : undefined,
fields.length ? fields : undefined,
);
const updated = await updateWorkflow(activeWorkflow.id, { ir, layout }, projectId);
setWorkflows((ws) => ws.map((w) => (w.id === updated.id ? updated : w)));
// Validate by compiling — surfaces non-linear graphs as a banner.
const finishSave = async (updated: Awaited<ReturnType<typeof updateWorkflow>>) => {
setWorkflows((ws) => ws.map((w) => (w.id === updated.id ? updated : w)));
// 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) {
setValidationError(
getErrorMessage(compileErr) || t("workflows.savedNotCompilable", "Workflow saved but cannot be compiled"),
);
}
};
try {
await compileWorkflow(updated.id, projectId);
addToast(t("workflows.saved", "Workflow saved"), "success");
} catch (compileErr) {
setValidationError(
getErrorMessage(compileErr) || t("workflows.savedNotCompilable", "Workflow saved but cannot be compiled"),
await finishSave(await updateWorkflow(activeWorkflow.id, { ir, layout }, 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, { ir, layout, confirmPolicyEscalation: true }, projectId),
);
}
} catch (err) {
@@ -566,6 +602,13 @@ function InnerEditor({
// Lazy-loaded executor resources
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[]>([]);
// CLI-agent adapter catalog (U15). Falls back to the static list when the API
// fetch fails so the picker is always usable.
@@ -573,6 +616,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(() => {
if (currentExecutor !== "cli-agent") return;
let cancelled = false;
@@ -604,7 +674,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) {
@@ -623,6 +696,25 @@ function InnerEditor({
skills.length,
]);
// 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(onClose);
return (
@@ -758,6 +850,7 @@ function InnerEditor({
readOnly={isBuiltin}
projectId={projectId}
addToast={addToast}
columnAgentsEnabled={columnAgentsEnabled}
/>
)}
@@ -814,6 +907,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>
@@ -832,20 +938,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,12 +23,7 @@ vi.mock("../../api", () => ({
fetchTaskDetail: vi.fn(),
batchUpdateTaskModels: vi.fn(),
fetchNodes: vi.fn().mockResolvedValue([]),
fetchBoardWorkflows: vi.fn().mockResolvedValue({
flagEnabled: false,
defaultWorkflowId: null,
workflows: [],
taskWorkflowIds: {},
}),
fetchBoardWorkflows: vi.fn().mockResolvedValue({ flagEnabled: false, defaultWorkflowId: "", workflows: [], taskWorkflowIds: {} }),
api: vi.fn().mockResolvedValue({ sessions: [] }),
}));

View File

@@ -1,6 +1,7 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, waitFor, cleanup } 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";
@@ -15,13 +16,42 @@ vi.mock("../../api", () => ({
fetchModels: vi.fn(),
fetchAgents: vi.fn(),
fetchDiscoveredSkills: vi.fn(),
// 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, fetchModels } from "../../api";
import {
fetchWorkflows,
fetchTraits,
fetchStepParsers,
updateWorkflow,
compileWorkflow,
createWorkflow,
fetchModels,
fetchAgents,
fetchConfig,
fetchSettings,
} from "../../api";
import type { TraitCatalogEntry } from "../../api";
import { beforeEach as viBeforeEach } from "vitest";
import { WorkflowNodeEditor } from "../WorkflowNodeEditor";
// 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 } },
@@ -348,10 +378,17 @@ 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")!);
await waitFor(() => expect(screen.getByTestId("wf-node-foreach")).toBeInTheDocument());
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.
@@ -625,3 +662,156 @@ describe("WorkflowNodeEditor — built-in stepwise selection render path", () =>
expect(approve?.kind).toBeUndefined();
});
});
// ── 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

@@ -360,7 +360,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,
@@ -3627,10 +3628,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 } : {}),
});
@@ -3677,6 +3688,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,21 @@
import type { WorkflowIr, WorkflowIrNode } from "@fusion/core";
import { ColumnTraitValidationError, OccupiedColumnsError, InvalidRehomeTargetError, WorkflowCompileError, WorkflowIrError, compileWorkflowToSteps, listTraits, listStepParsers } from "@fusion/core";
import type {
WorkflowIr,
WorkflowIrNode,
TaskStore,
} from "@fusion/core";
import {
ColumnTraitValidationError,
OccupiedColumnsError,
InvalidRehomeTargetError,
WorkflowCompileError,
WorkflowIrError,
ColumnAgentBindingError,
compileWorkflowToSteps,
listTraits,
listStepParsers,
AgentStore,
validateColumnAgentBindings,
} from "@fusion/core";
import { validateCodeNodeSources } from "@fusion/engine";
import { ApiError, badRequest, conflict, notFound } from "../api-error.js";
import { emitWorkflowSseEvent } from "../sse.js";
@@ -41,6 +57,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.
@@ -100,12 +150,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);
@@ -138,7 +189,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");
}
@@ -150,6 +201,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 } 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 } 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.
@@ -1222,10 +1280,15 @@ export function createWorkflowCreateTool(store: TaskStore): ToolDefinition {
"`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 {
await assertWorkflowColumnAgentBindings(store, params.ir, params.confirm_policy_escalation === true);
const created = await store.createWorkflowDefinition({
name: params.name,
description: params.description,
@@ -1240,6 +1303,9 @@ export function createWorkflowCreateTool(store: TaskStore): ToolDefinition {
};
// 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: {},
@@ -1266,10 +1332,17 @@ export function createWorkflowUpdateTool(store: TaskStore): ToolDefinition {
"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 {
if (params.ir !== undefined) {
await assertWorkflowColumnAgentBindings(store, params.ir, params.confirm_policy_escalation === true);
}
const updated = await store.updateWorkflowDefinition(params.workflow_id, {
name: params.name,
description: params.description,
@@ -1285,6 +1358,9 @@ export function createWorkflowUpdateTool(store: TaskStore): ToolDefinition {
};
// 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

@@ -620,7 +620,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

@@ -6751,8 +6751,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,