Merge pull request #1432 from Runfusion/feat/column-agent-assignment
feat: per-column agent assignment for workflow columns
This commit is contained in:
11
.changeset/workflow-column-agent-assignment.md
Normal file
11
.changeset/workflow-column-agent-assignment.md
Normal 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.
|
||||
345
docs/plans/2026-06-04-002-feat-column-agent-assignment-plan.md
Normal file
345
docs/plans/2026-06-04-002-feat-column-agent-assignment-plan.md
Normal 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`).
|
||||
@@ -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.
|
||||
@@ -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**.
|
||||
|
||||
@@ -1972,6 +1972,11 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
|
||||
});
|
||||
},
|
||||
store,
|
||||
// Dev-mode scheduler: no TaskExecutor runs here (engine not started), so
|
||||
// neither `isTaskExecuting` nor the U5 reverse-direction
|
||||
// `isAgentEffectivelyExecuting` guard has a source — both stay unwired (the
|
||||
// guards simply never fire), matching the prior `isTaskExecuting` omission.
|
||||
// The real wiring is the InProcessRuntime construction site.
|
||||
);
|
||||
triggerScheduler.start();
|
||||
|
||||
|
||||
288
packages/core/src/__tests__/column-agent-resolver.test.ts
Normal file
288
packages/core/src/__tests__/column-agent-resolver.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
224
packages/core/src/__tests__/workflow-ir-column-agent.test.ts
Normal file
224
packages/core/src/__tests__/workflow-ir-column-agent.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
104
packages/core/src/column-agent-binding-validation.ts
Normal file
104
packages/core/src/column-agent-binding-validation.ts
Normal 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",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
219
packages/core/src/column-agent-resolver.ts
Normal file
219
packages/core/src/column-agent-resolver.ts
Normal 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 };
|
||||
}
|
||||
@@ -60,6 +60,7 @@ export type {
|
||||
WorkflowIrNodeKind,
|
||||
WorkflowIrColumn,
|
||||
WorkflowIrColumnTrait,
|
||||
WorkflowColumnAgent,
|
||||
WorkflowHoldRelease,
|
||||
WorkflowJoinMode,
|
||||
WorkflowJoinBranchFailure,
|
||||
@@ -71,6 +72,17 @@ export type {
|
||||
WorkflowFieldOption,
|
||||
WorkflowFieldRender,
|
||||
} from "./workflow-ir-types.js";
|
||||
export {
|
||||
instanceNodeId,
|
||||
parseInstanceNodeId,
|
||||
resolveColumnAgentBinding,
|
||||
resolveEffectiveAgent,
|
||||
} from "./column-agent-resolver.js";
|
||||
export type {
|
||||
ParsedInstanceNodeId,
|
||||
EffectiveAgentInput,
|
||||
EffectiveAgentResult,
|
||||
} from "./column-agent-resolver.js";
|
||||
export { BUILTIN_CODING_WORKFLOW_IR } from "./builtin-coding-workflow-ir.js";
|
||||
export { BUILTIN_STEPWISE_CODING_WORKFLOW_IR } from "./builtin-stepwise-coding-workflow-ir.js";
|
||||
|
||||
@@ -292,8 +304,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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -105,11 +105,32 @@ export interface WorkflowIrColumnTrait {
|
||||
config?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** Per-column permanent-agent binding (column-agent plan KTD-1). A column may name
|
||||
* one agent from the registry plus a mode that decides precedence against
|
||||
* node-level / task-level agent and model settings:
|
||||
* - `defer`: the column agent applies only when the work carries no own settings
|
||||
* (no agent identity and no complete modelProvider+modelId pair — KTD-5).
|
||||
* - `override`: the column agent supersedes node/task settings wholesale.
|
||||
* This is execution identity (consumed by the executor's session-building paths),
|
||||
* not a board-transition trait — hence a first-class typed field, not a trait
|
||||
* config blob (KTD-1). Agent *existence* is not an IR concern (no agent store at
|
||||
* this layer); it is enforced at write time (route) and falls back at read time. */
|
||||
export interface WorkflowColumnAgent {
|
||||
/** Registry agent id that staffs the column. Non-empty. */
|
||||
agentId: string;
|
||||
/** Precedence mode against node/task settings. */
|
||||
mode: "defer" | "override";
|
||||
}
|
||||
|
||||
/** A workflow-defined board column. */
|
||||
export interface WorkflowIrColumn {
|
||||
id: string;
|
||||
name: string;
|
||||
traits: WorkflowIrColumnTrait[];
|
||||
/** Optional permanent-agent binding (column-agent plan KTD-1). Additive and
|
||||
* omitted entirely when unset — never serialized as `agent: null` — so legacy
|
||||
* and default workflows stay byte-identical (R9). */
|
||||
agent?: WorkflowColumnAgent;
|
||||
}
|
||||
|
||||
/** Release conditions for a `hold` node (KTD-2, R3). */
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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,
|
||||
@@ -147,6 +148,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(() => {
|
||||
@@ -466,15 +475,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) {
|
||||
@@ -550,10 +586,44 @@ 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[]>([]);
|
||||
|
||||
const currentExecutor = (selectedNode?.data.config?.executor as ExecutorKind | undefined) ?? "model";
|
||||
|
||||
// The override binding governing the selected node, if any: its declared
|
||||
// column carries an `agent` in `override` mode. Drives the "overridden by
|
||||
// column agent" note so authors don't diagnose override as a bug (R11). Keyed
|
||||
// on the column id + binding, not array identity.
|
||||
const overrideColumnBinding = useMemo(() => {
|
||||
// Foreach template children don't carry their own column in irToFlow — they
|
||||
// inherit the enclosing foreach group's column at execution (R4). Mirror that
|
||||
// inheritance here so a step-execute prompt inside an override-bound foreach
|
||||
// still shows the note (PR #1432 review).
|
||||
const columnId =
|
||||
selectedNode?.data.column
|
||||
?? (selectedNode?.parentId
|
||||
? nodes.find((n) => n.id === selectedNode.parentId)?.data.column
|
||||
: undefined);
|
||||
if (!columnId) return undefined;
|
||||
const col = columns.find((c) => c.id === columnId);
|
||||
if (!col?.agent || col.agent.mode !== "override") return undefined;
|
||||
return col.agent;
|
||||
}, [selectedNode?.data.column, selectedNode?.parentId, nodes, columns]);
|
||||
|
||||
// Resolve the override agent's display name from the loaded registry; when the
|
||||
// id is stale (not in the list) fall back to the not-found treatment.
|
||||
const overrideAgent = useMemo(
|
||||
() => (overrideColumnBinding ? agents.find((a) => a.id === overrideColumnBinding.agentId) : undefined),
|
||||
[overrideColumnBinding, agents],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// step-review offers an optional review model picker (KTD-4).
|
||||
if (selectedNode?.data.kind === "step-review" && models.length === 0) {
|
||||
@@ -568,7 +638,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) {
|
||||
@@ -587,6 +660,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 (
|
||||
@@ -722,6 +814,7 @@ function InnerEditor({
|
||||
readOnly={isBuiltin}
|
||||
projectId={projectId}
|
||||
addToast={addToast}
|
||||
columnAgentsEnabled={columnAgentsEnabled}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -777,6 +870,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>
|
||||
@@ -795,20 +901,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">
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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({
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -589,7 +589,13 @@ export class InProcessRuntime
|
||||
});
|
||||
},
|
||||
this.taskStore,
|
||||
{ isTaskExecuting: (taskId) => this.executor.getExecutingTaskIds().has(taskId) },
|
||||
{
|
||||
isTaskExecuting: (taskId) => this.executor.getExecutingTaskIds().has(taskId),
|
||||
// Column-agent principal alignment (plan U5, R6): reverse-direction guard
|
||||
// — an override/defer column agent must not heartbeat concurrently with a
|
||||
// column-bound session it runs but is not assigned to.
|
||||
isAgentEffectivelyExecuting: (agentId) => this.executor.isAgentEffectivelyExecuting(agentId),
|
||||
},
|
||||
);
|
||||
this.triggerScheduler.start();
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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): {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -6700,8 +6700,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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user