Merge pull request #1536 from Runfusion/feature/workflow-mapping
feat(FN-6035): route execution through workflow primitives
This commit is contained in:
5
.changeset/workflow-native-runtime-primitives.md
Normal file
5
.changeset/workflow-native-runtime-primitives.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
Route task execution through workflow-native runtime primitives and make the built-in coding workflow explicitly own planning before execute/review/merge.
|
||||
12
CONCEPTS.md
12
CONCEPTS.md
@@ -79,6 +79,18 @@ A Feature's position in the execution loop (being implemented, awaiting or under
|
||||
### Task
|
||||
The core board entity: a unit of work that moves through columns (triage, todo, in-progress, in-review, done, archived) and is executed by agents. A Task carries its own per-task settings that can override project-level defaults.
|
||||
|
||||
### Workflow Runtime
|
||||
The authoritative task lifecycle runtime. It resolves a Task to workflow IR, walks the graph, routes node outcomes, and invokes runtime primitives for side effects. The engine substrate still owns scheduling, routing claims, persistence, concurrency, process supervision, storage, and audit plumbing; lifecycle policy lives in workflow nodes and built-in workflow IR.
|
||||
|
||||
### Runtime Primitive
|
||||
A named, injected operation a workflow node can call to perform side effects without depending on `executor.ts` lifecycle branches. Examples include planning session, coding session, step execution/reset, review, verification, workflow step, transition, merge request, abort, and audit. Primitives are the boundary between workflow policy and engine substrate.
|
||||
|
||||
### Built-in Lifecycle Node
|
||||
A node in a built-in workflow that expresses default Fusion behavior, such as planning, execute, review, merge, parse-steps, step-review, or PR lifecycle actions. Built-in lifecycle nodes are the compatibility layer for existing behavior: changing default execution means changing the built-in workflow and its primitive wiring, not adding hidden imperative branches.
|
||||
|
||||
### Recovery Event
|
||||
A workflow-observable condition that requires recovery policy, such as implementation incomplete, review unavailable, merge timeout, manual merge required, integration conflict, or hard cancel. Recovery may use engine primitives for aborting processes, writing audit entries, resetting steps, or parking tasks, but the routing decision belongs to workflow logic.
|
||||
|
||||
### Auto-merge
|
||||
The named process that automatically lands a completed Task's branch onto its merge target once the Task reaches In-review and passes its merge blockers. Gated twice: a project-level setting enables it globally, and each Task may carry an explicit per-task override.
|
||||
|
||||
|
||||
@@ -0,0 +1,372 @@
|
||||
---
|
||||
title: "refactor: Big-bang workflow-native execution cutover"
|
||||
type: refactor
|
||||
status: active
|
||||
date: 2026-06-09
|
||||
depth: deep
|
||||
origin: none (solo planning bootstrap; supersedes docs/plans/2026-06-07-001-refactor-workflow-runtime-cutover-plan.md with big-bang posture)
|
||||
---
|
||||
|
||||
# refactor: Big-bang workflow-native execution cutover
|
||||
|
||||
## Summary
|
||||
|
||||
Replace Fusion's legacy executor/triage lifecycle with a workflow-native execution model in one coordinated cutover. Built-in workflows become the complete expression of default engine behavior, nodes own execution and recovery policy, and the engine remains the runtime substrate for scheduling, routing, persistence, concurrency, process supervision, storage, and audit plumbing.
|
||||
|
||||
---
|
||||
|
||||
## Problem Frame
|
||||
|
||||
The workflow stack already has a graph executor, built-in workflow IR, step inversion, workflow-defined columns, workflow settings, node handlers, custom nodes, PR nodes, and plugin workflow extensions. But executable tasks still pass through `TaskExecutor.execute()` and `TriageProcessor.specifyTask()` for most lifecycle policy. The current graph path uses seams and completion interceptors to call back into legacy executor logic, so the system has two control planes: workflow IR says it owns sequencing, while `executor.ts`, `triage.ts`, and self-healing sweeps still own planning, implementation, verification, review, merge routing, and recovery decisions.
|
||||
|
||||
This plan intentionally changes the migration posture. The target is not a parity-gated rollout with old-engine fallback. The target is a single big-bang replacement branch where workflow runtime becomes the only task lifecycle driver before merge. The implementation can be internally sequenced and heavily characterized, but the shipped state must not contain a production alternate path that reruns legacy executor orchestration.
|
||||
|
||||
---
|
||||
|
||||
## Requirements
|
||||
|
||||
- R1. All executable task lifecycle policy is represented by selected workflow IR, built-in workflow IR, node handlers, or workflow extension nodes.
|
||||
- R2. Built-in workflows fully express the current default coding, stepwise coding, planning/specification, verification, review, merge, PR response, and recovery behavior.
|
||||
- R3. The engine keeps scheduler dispatch, node routing, persistence adapters, concurrency/semaphore control, process supervision, storage access, and audit/log sinks.
|
||||
- R4. The engine does not keep lifecycle policy hidden in `TaskExecutor.execute()`, `TriageProcessor.specifyTask()`, self-healing mutation sweeps, or graph-to-legacy completion interceptors.
|
||||
- R5. Runtime mechanics are extracted as typed primitives that nodes can call: worktree preparation, session execution, command execution, artifact IO, task transition, merge operation, reviewer invocation, process abort, and audit emission.
|
||||
- R6. Recovery is workflow/node-owned policy. Sweeps and timers may wake workflow recovery nodes, but they must not directly decide lifecycle repair behavior for task execution.
|
||||
- R7. Existing invariants remain non-configurable: user move `in-progress -> todo` is hard cancel, `autoMerge:false` is terminal until human merge except scoped shared-branch member integration, file-scope and squash overlap guards remain authoritative, worktree ownership remains enforced, and branch-group merge target rules remain intact.
|
||||
- R8. Effective execution principal resolution applies across every workflow execution surface, including custom nodes, planning nodes, coding nodes, step-execute, review nodes, heartbeat serialization, resume wakeups, permissions, and hot-swap/change detection.
|
||||
- R9. A workflow run is durable and inspectable: current node, side-effect boundary, active sessions, branch/foreach instances, terminal outcome, recovery decision, and audit correlation are persisted or derivable through one runtime state model.
|
||||
- R10. The shipped cutover has no production fallback to old lifecycle orchestration. If workflow runtime fails after merge, failure routes through workflow-defined recovery/parking behavior, not a legacy executor retry path.
|
||||
- R11. Tests assert the invariant across all known execution surfaces: default workflow, stepwise workflow, custom workflow, plugin work engine extension, PR nodes, triage/planning, pause/cancel, auto-merge off, branch groups, workflow settings/test mode, cross-node runtime routing, and restart recovery.
|
||||
|
||||
---
|
||||
|
||||
## Scope Boundaries
|
||||
|
||||
### In Scope
|
||||
|
||||
- Promote `WorkflowTaskRuntime` into the only executable task runtime.
|
||||
- Replace production `WorkflowLegacySeams` with first-class node handlers backed by typed runtime primitives.
|
||||
- Move built-in triage/spec generation into workflow nodes and a built-in planning region.
|
||||
- Move deterministic verification, pre-merge workflow steps, revision/fix routing, review handoff, merge/manual-required behavior, and completed-task recovery into built-in workflow IR.
|
||||
- Convert self-healing execution repair into workflow recovery wakeups plus node-authored recovery branches.
|
||||
- Preserve existing stores, task rows, task documents, reviewer implementation, merger implementation, agent runtimes, plugin runtime, and scheduler infrastructure as mechanics.
|
||||
- Delete or demote old lifecycle orchestration so no production path depends on graph re-entry into `TaskExecutor.execute()`.
|
||||
|
||||
### Out of Scope
|
||||
|
||||
- Replacing SQLite storage or the task model identity.
|
||||
- Rewriting git merge algorithms or GitHub PR operations.
|
||||
- Rewriting agent runtime protocols.
|
||||
- Redesigning dashboard workflow authoring UI beyond what is necessary to expose new built-in node kinds.
|
||||
- Removing workflow authoring surfaces or plugin workflow extension surfaces.
|
||||
- Changing published CLI release process.
|
||||
|
||||
### Deferred to Follow-Up Work
|
||||
|
||||
- Cosmetic cleanup of old file names after the cutover if the implementation leaves mechanically named modules like `executor-primitives.ts`.
|
||||
- Broad dashboard UX redesign for workflow debugging beyond required runtime state display.
|
||||
- New plugin APIs unrelated to execution/recovery ownership.
|
||||
|
||||
---
|
||||
|
||||
## Key Technical Decisions
|
||||
|
||||
- KTD-1. Big-bang means no production fallback. Implementation may use characterization tests and temporary adapters while the branch is under construction, but the final branch must remove old lifecycle dispatch and old fallback flags before merge.
|
||||
- KTD-2. Workflow IR expresses policy; runtime primitives express mechanics. A node may call `prepareWorktree`, `runAgentSession`, `runReviewer`, or `requestMerge`, but the decision to call them, retry them, recover from them, or park the task belongs to the workflow graph.
|
||||
- KTD-3. Built-in workflows are source of truth. `builtin:coding`, `builtin:stepwise-coding`, and any new built-in planning/recovery/PR workflows must encode the current engine logic rather than relying on comments or hidden `executor.ts` branches.
|
||||
- KTD-4. Recovery becomes routable graph behavior. Timers and sweeps identify stale runtime state and enqueue recovery work; recovery nodes decide whether to resume, reset, requeue, park, request human review, or no-op.
|
||||
- KTD-5. Preserve centralized invariants as substrate guards. File-scope enforcement, worktree ownership checks, transition validation, merge target resolution, auto-merge processing predicates, and process supervision stay centralized and are invoked by nodes as non-bypassable guards.
|
||||
- KTD-6. Remove mutable per-task graph seam slots. Existing `graphCompletionInterceptors`, `graphStepRunOnce`, `graphSeamGoverningNodeId`, and similar slots are symptoms of graph-to-executor re-entry. Runtime state should be explicit per workflow run and per node/session.
|
||||
- KTD-7. Effective principal is resolved once per node invocation and carried through all identity-sensitive subsystems. The column-agent blast-radius checklist applies to every node that can run a session or wake a session.
|
||||
- KTD-8. Cross-node execution remains a runtime routing concern. A remote node executes the workflow runtime in its own process; the central node routes, monitors, and receives events. It does not execute task lifecycle policy locally for remote work.
|
||||
- KTD-9. Characterization coverage is allowed; compatibility code is not. Tests may compare old behavior during implementation, but final production code should not keep old orchestration for rollback.
|
||||
|
||||
---
|
||||
|
||||
## High-Level Technical Design
|
||||
|
||||
### Ownership Boundary
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
Scheduler[Scheduler / task event / recovery timer] --> Claim[Workflow runtime claim]
|
||||
Claim --> Router{Local or remote node}
|
||||
Router -->|local| Runtime[WorkflowTaskRuntime]
|
||||
Router -->|remote| Remote[Remote Fusion API executes WorkflowTaskRuntime]
|
||||
|
||||
Runtime --> Resolve[Resolve workflow IR + effective settings + principal]
|
||||
Resolve --> Graph[WorkflowGraphExecutor]
|
||||
|
||||
Graph --> Nodes[Workflow node handlers]
|
||||
Nodes --> Primitives[Runtime primitives]
|
||||
Primitives --> Store[(TaskStore / CentralCore)]
|
||||
Primitives --> Git[git / worktrees]
|
||||
Primitives --> Agents[agent sessions / CLI sessions]
|
||||
Primitives --> Merge[merger / PR operations]
|
||||
Primitives --> Audit[run audit / logs]
|
||||
|
||||
Graph --> RunState[(workflow run state)]
|
||||
Scheduler --> RunState
|
||||
```
|
||||
|
||||
The engine owns when a run may start, where it runs, how many concurrent units may execute, how subprocesses are supervised, and where state is stored. The workflow owns what happens next.
|
||||
|
||||
### Built-In Workflow Shape
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
Intake[start/intake] --> Plan[planning/spec node]
|
||||
Plan --> PlanReview[spec review node]
|
||||
PlanReview -->|approve| Hold[todo hold/capacity]
|
||||
PlanReview -->|revise| Plan
|
||||
Hold --> Prepare[prepare worktree]
|
||||
Prepare --> Implement[implementation session or foreach steps]
|
||||
Implement --> Verify[deterministic verification + workflow checks]
|
||||
Verify -->|approve| Review[review handoff]
|
||||
Verify -->|revise/fix| Implement
|
||||
Review --> MergeGate[merge/manual-required gate]
|
||||
MergeGate -->|auto/processable| Merge[merge operation]
|
||||
MergeGate -->|manual-required| Manual[in-review hold]
|
||||
Merge -->|success| Done[done]
|
||||
Merge -->|failure| Recovery[recovery router]
|
||||
Recovery --> Implement
|
||||
Recovery --> Review
|
||||
Recovery --> Manual
|
||||
Recovery --> Hold
|
||||
```
|
||||
|
||||
The exact node names are implementation details, but every box above must be represented in built-in workflow IR or a built-in reusable subgraph. No box should be hidden inside a monolithic executor function.
|
||||
|
||||
### Recovery Model
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> Running
|
||||
Running --> Paused: user/global pause
|
||||
Running --> Aborting: hard cancel
|
||||
Running --> FailedNode: node failure
|
||||
Running --> Stale: timer/sweep detects stale run
|
||||
FailedNode --> RecoveryNode
|
||||
Stale --> RecoveryNode
|
||||
RecoveryNode --> Running: resume/retry
|
||||
RecoveryNode --> TodoHold: clean requeue
|
||||
RecoveryNode --> ReviewHold: needs human review
|
||||
RecoveryNode --> ManualMergeHold: autoMerge false/manual required
|
||||
RecoveryNode --> Done: already completed/finalized
|
||||
Aborting --> TodoHold
|
||||
Paused --> Running: unpause
|
||||
```
|
||||
|
||||
Sweeps do not mutate task lifecycle directly. They materialize a workflow recovery event or wake the recovery node for a task/run. The recovery node then uses the same transition primitives and invariants as ordinary execution.
|
||||
|
||||
---
|
||||
|
||||
## Implementation Units
|
||||
|
||||
### U1. Runtime Primitive Boundary
|
||||
|
||||
- **Goal:** Extract lifecycle mechanics from `executor.ts`, `triage.ts`, and self-healing into typed primitives that workflow nodes can call without invoking legacy orchestration.
|
||||
- **Requirements:** R3, R5, R7.
|
||||
- **Dependencies:** None.
|
||||
- **Files:** `packages/engine/src/runtime-primitives.ts` (new), `packages/engine/src/executor.ts`, `packages/engine/src/triage.ts`, `packages/engine/src/step-session-executor.ts`, `packages/engine/src/reviewer.ts`, `packages/engine/src/merger.ts`, `packages/engine/src/worktree-acquisition.ts`, `packages/engine/src/__tests__/runtime-primitives.test.ts`, `packages/engine/src/__tests__/executor-worktree.test.ts`, `packages/engine/src/__tests__/executor-step-session.test.ts`, `packages/engine/src/__tests__/base-commit-capture.real-git.test.ts`.
|
||||
- **Approach:** Define primitive interfaces for worktree preparation, base/fork-point capture, task document/artifact IO, planning session execution, coding session execution, single-step execution, reviewer invocation, deterministic command execution, workflow-step execution, transition writes, merge request/merge operation, process abort, and audit emission. Move code behind those interfaces in small extractions, but keep behavior identical. Primitives must accept a workflow run context and node context so audit and effective principal are never inferred from task fields alone.
|
||||
- **Execution note:** Characterization-first. Preserve old behavior with tests before extraction, especially worktree liveness, local-first base capture, file-scope checks, token persistence, and abort cleanup.
|
||||
- **Patterns to follow:** `step-runner.ts` DI style, `worktree-acquisition.ts`, `base-commit-capture.ts`, `createRunAuditor`, and existing real-git tests.
|
||||
- **Test scenarios:** worktree preparation refuses invalid/unowned worktrees; base capture uses local integration branch before origin; deterministic commands use async execution with timeout; abort cancels active sessions and subprocesses; reviewer primitive maps verdicts without mutating unrelated state; merge primitive honors branch-group targets; primitive audit includes run id, node id, and effective principal.
|
||||
- **Verification:** Primitive tests pass independently of workflow graph tests; old executor characterization tests still pass while extraction is in progress.
|
||||
|
||||
### U2. Workflow Run State Authority
|
||||
|
||||
- **Goal:** Create one durable runtime state model for active workflow runs, side-effect boundaries, node progress, active sessions, recovery events, branch rows, and foreach instances.
|
||||
- **Requirements:** R6, R9, R10.
|
||||
- **Dependencies:** U1.
|
||||
- **Files:** `packages/core/src/db.ts`, `packages/core/src/store.ts`, `packages/core/src/types.ts`, `packages/engine/src/workflow-task-runtime.ts`, `packages/engine/src/workflow-graph-executor.ts`, `packages/engine/src/workflow-graph-branches.ts`, `packages/engine/src/workflow-graph-foreach.ts`, `packages/core/src/__tests__/central-db.test.ts`, `packages/core/src/__tests__/store-workflow-runtime.test.ts` (new), `packages/engine/src/__tests__/workflow-task-runtime.test.ts`.
|
||||
- **Approach:** Add or consolidate top-level workflow run records around the existing branch and foreach persistence. Persist run id, workflow id, current node, side-effect-started marker, active primitive/session ids, effective principal, terminal outcome, recovery event queue, and last activity timestamp. Keep branch and step-instance tables as detail tables keyed by the same run id.
|
||||
- **Patterns to follow:** `workflow_run_branches`, `workflow_run_step_instances`, `runAuditEvents`, `transitionPending`.
|
||||
- **Test scenarios:** a run starts once for a task; current node updates idempotently; active session ids clear on terminal outcomes; stale run rows prune without deleting current run rows; side-effect-started prevents fallback reruns; recovery events are append-only or idempotently consumed; restart can reconstruct active branches and foreach instances.
|
||||
- **Verification:** Store/runtime tests prove workflow run state is sufficient to decide resume, abort, or park without inspecting `TaskExecutor` in-memory sets.
|
||||
|
||||
### U3. Built-In Workflow IR Full Lifecycle Expression
|
||||
|
||||
- **Goal:** Expand built-in workflows so they encode all default task lifecycle policy currently hidden in executor, triage, workflow-step orchestration, review handoff, merge handling, and recovery.
|
||||
- **Requirements:** R1, R2, R4, R6, R7.
|
||||
- **Dependencies:** U1, U2.
|
||||
- **Files:** `packages/core/src/builtin-coding-workflow-ir.ts`, `packages/core/src/builtin-stepwise-coding-workflow-ir.ts`, `packages/core/src/builtin-pr-workflow-ir.ts`, `packages/core/src/workflow-ir-types.ts`, `packages/core/src/workflow-ir.ts`, `packages/core/src/builtin-workflows.ts`, `packages/core/src/__tests__/builtin-coding-workflow-ir.test.ts` (new), `packages/core/src/__tests__/builtin-stepwise-coding-workflow-ir.test.ts`, `packages/core/src/__tests__/workflow-ir.test.ts`, `packages/core/src/__tests__/workflow-ir-loop.test.ts`, `packages/core/src/__tests__/workflow-ir-foreach.test.ts`.
|
||||
- **Approach:** Add built-in node kinds or reserved node configs for planning/spec generation, spec review, todo capacity hold, stale-spec handling, dependency gate, worktree preparation, implementation session, deterministic verification, pre-merge workflow checks, revision/fix loop, review handoff, merge/manual-required routing, post-merge checks, and recovery router. Prefer explicit built-in node kinds for non-authorable engine primitives; use prompt/script/code nodes for authorable behavior.
|
||||
- **Patterns to follow:** Existing `parse-steps`, `foreach`, `loop`, `step-review`, `code`, PR node validation, and workflow setting declarations.
|
||||
- **Test scenarios:** built-in coding workflow validates; null workflow selection resolves to the built-in workflow; default workflow includes every legacy phase in a phase-map test; stepwise workflow includes parse/foreach/review/rework; `executionMode: "fast"` bypasses pre-merge checks but not post-merge checks; `autoMerge:false` routes to manual-required hold; branch-group member integration routes to member landing; malformed built-in IR fails tests at parse time.
|
||||
- **Verification:** Core tests make built-in IR the explicit compatibility contract before runtime dispatch changes.
|
||||
|
||||
### U4. Node Handler Runtime
|
||||
|
||||
- **Goal:** Replace production legacy seam handlers with node handlers that call runtime primitives directly.
|
||||
- **Requirements:** R1, R4, R5, R8, R10.
|
||||
- **Dependencies:** U1, U2, U3.
|
||||
- **Files:** `packages/engine/src/workflow-node-handlers.ts`, `packages/engine/src/workflow-task-runtime.ts`, `packages/engine/src/workflow-graph-executor.ts`, `packages/engine/src/pr-nodes.ts`, `packages/engine/src/code-node-runner.ts`, `packages/engine/src/__tests__/workflow-node-handlers.test.ts`, `packages/engine/src/__tests__/workflow-node-handler-extensions.test.ts`, `packages/engine/src/__tests__/workflow-verdict-provider-extensions.test.ts`, `packages/engine/src/__tests__/code-node.test.ts`.
|
||||
- **Approach:** Implement handlers for the new built-in lifecycle nodes and rework existing prompt/script/gate behavior so seam configuration is no longer a production path. Custom prompt/script/gate/code nodes continue to use custom-node machinery. Handler results should produce explicit outcome values like `spec-approved`, `capacity-blocked`, `implementation-complete`, `revision-requested`, `workflow-check-failed`, `manual-required`, `merge-timeout`, `recover-resume`, and `recover-park-review`.
|
||||
- **Patterns to follow:** `createParseStepsHandler`, `createStepReviewHandler`, `createPrNodeHandlers`, plugin node-handler fallback/degradation.
|
||||
- **Test scenarios:** planning node writes expected artifacts and step projection; implementation node invokes coding primitive once; verification node routes revise vs approve; workflow-check node handles gate/advisory modes; merge node honors manual-required; recovery node consumes recovery event and routes to the correct edge; missing primitive wiring fails closed; plugin node handler degradation never bypasses built-in guard nodes.
|
||||
- **Verification:** Node handler tests cover success/failure for every built-in lifecycle node kind without using `TaskExecutor.execute()`.
|
||||
|
||||
### U5. Planning And Triage Workflow Nodes
|
||||
|
||||
- **Goal:** Move built-in triage/specification behavior out of `TriageProcessor` lifecycle orchestration and into planning workflow nodes.
|
||||
- **Requirements:** R1, R2, R4, R6, R11.
|
||||
- **Dependencies:** U1, U2, U3, U4.
|
||||
- **Files:** `packages/engine/src/triage.ts`, `packages/engine/src/workflow-node-handlers.ts`, `packages/engine/src/workflow-task-runtime.ts`, `packages/engine/src/agent-tools.ts`, `packages/engine/src/__tests__/triage-core.test.ts`, `packages/engine/src/__tests__/triage-soft-delete-abort.test.ts`, `packages/engine/src/__tests__/workflow-planning-nodes.test.ts` (new), `packages/engine/src/__tests__/reliability-interactions/ghost-bug-preflight.test.ts`.
|
||||
- **Approach:** Extract triage session mechanics into planning primitives, then create planning/spec-review nodes in built-in workflows. The scheduler can still identify intake tasks and respect capacity, but planning behavior is a workflow region. Tools like task creation, task document writing, spec review, ghost-bug preflight, duplicate detection, and starved refinement recovery must be called by nodes or recovery branches.
|
||||
- **Patterns to follow:** Existing prompt layer construction in `triage.ts`, `fn_task_create` tool surface, task document APIs, ghost-bug preflight tests.
|
||||
- **Test scenarios:** intake task runs planning node and writes `PROMPT.md`; spec review APPROVE routes to todo hold; REVISE routes back to planning; split-into-subtasks path creates child tasks and parks parent according to existing behavior; soft-deleted task aborts planning; orphaned planning session recovery wakes recovery node instead of directly clearing status.
|
||||
- **Verification:** Triage behavior tests pass through workflow runtime, and no production path calls `TriageProcessor.specifyTask()` as lifecycle owner.
|
||||
|
||||
### U6. Scheduler, Routing, And Dispatch Cutover
|
||||
|
||||
- **Goal:** Route all executable work through `WorkflowTaskRuntime` while keeping scheduling, node routing, concurrency, and remote runtime boundaries in the engine.
|
||||
- **Requirements:** R1, R3, R8, R10, R11.
|
||||
- **Dependencies:** U2, U3, U4, U5.
|
||||
- **Files:** `packages/engine/src/scheduler.ts`, `packages/engine/src/runtimes/in-process-runtime.ts`, `packages/engine/src/runtimes/remote-node-runtime.ts`, `packages/engine/src/runtimes/remote-node-client.ts`, `packages/engine/src/project-manager.ts`, `packages/engine/src/hybrid-executor.ts`, `packages/engine/src/executor.ts`, `packages/engine/src/__tests__/scheduler-node-routing.test.ts`, `packages/engine/src/__tests__/hybrid-executor-multi-node-routing.test.ts`, `packages/engine/src/runtimes/__tests__/remote-node-runtime.test.ts`, `packages/engine/src/__tests__/cross-node-claim-mutex.integration.test.ts`.
|
||||
- **Approach:** Replace `TaskExecutor.execute()` dispatch ownership with runtime dispatch ownership. Keep remote nodes as remote workflow runtimes: local central routing sends execute requests/events, but the remote side resolves and executes the workflow. Concurrency limits are acquired by the runtime before side-effecting nodes and released by terminal/paused outcomes. Existing task-moved and heartbeat wake paths should call the runtime facade.
|
||||
- **Patterns to follow:** `ProjectRuntime` boundary, `RemoteNodeRuntime`, `executingTaskLock`, node routing policy tests.
|
||||
- **Test scenarios:** local task dispatch starts one workflow run; remote task dispatch calls remote execute API and streams events; duplicate dispatch is claimed once; unavailable node policy still blocks or falls back according to settings; heartbeat completion wakes tasks by effective principal; workflow runtime respects global pause and engine pause before starting new side effects.
|
||||
- **Verification:** Scheduler and runtime tests show no dispatch path invokes legacy executor lifecycle.
|
||||
|
||||
### U7. Recovery As Workflow Policy
|
||||
|
||||
- **Goal:** Convert execution self-healing and restart recovery from direct lifecycle mutations into workflow recovery events handled by recovery nodes.
|
||||
- **Requirements:** R6, R7, R9, R10, R11.
|
||||
- **Dependencies:** U2, U4, U6.
|
||||
- **Files:** `packages/engine/src/self-healing.ts`, `packages/engine/src/restart-recovery-coordinator.ts`, `packages/engine/src/recovery-policy.ts`, `packages/engine/src/auto-recovery.ts`, `packages/engine/src/auto-recovery-handlers/branch-worktree.ts`, `packages/engine/src/auto-recovery-handlers/contamination.ts`, `packages/engine/src/auto-recovery-handlers/file-scope.ts`, `packages/engine/src/workflow-task-runtime.ts`, `packages/engine/src/__tests__/self-healing.test.ts`, `packages/engine/src/__tests__/workflow-recovery-nodes.test.ts` (new), `packages/engine/src/__tests__/reliability-interactions/workflow-interpreter-cutover.test.ts`, `packages/engine/src/__tests__/reliability-interactions/active-worktree-removal-liveness.test.ts`.
|
||||
- **Approach:** Keep sweep timing and detection in the engine, but change detected conditions into typed recovery events on the workflow run. Built-in recovery nodes handle completed-but-stranded work, failed pre-merge checks, no-progress no-task-done, partial-progress no-task-done, in-progress limbo, missing worktree review failure, contamination recovery, file-scope recovery, branch conflict recovery, stale planning, and orphaned sessions. Some non-task agent/heartbeat recovery can remain engine-owned when it is not task lifecycle policy.
|
||||
- **Patterns to follow:** `recovery-policy.ts`, run audit events, `allowsAutoMergeProcessing`, branch-group recovery tests.
|
||||
- **Test scenarios:** completed stranded task routes through recovery node to review handoff; failed pre-merge check routes to fix loop; no-progress failure cleanly requeues through todo hold; partial-progress failure preserves progress; contamination recovery routes to human review when unique commits remain; `autoMerge:false` in-review tasks are inspected but not moved backward; branch-group member landing uses group target; recovery events are idempotent across repeated sweeps.
|
||||
- **Verification:** Reliability interaction tests prove sweeps no longer mutate execution lifecycle directly and recovery outcomes are authored by workflow nodes.
|
||||
|
||||
### U8. Effective Principal And Permission Surface Audit
|
||||
|
||||
- **Goal:** Re-key every execution identity, permission, serialization, wakeup, and hot-swap reader to use the node's effective principal.
|
||||
- **Requirements:** R8, R11.
|
||||
- **Dependencies:** U4, U6.
|
||||
- **Files:** `packages/engine/src/executor.ts`, `packages/engine/src/agent-heartbeat.ts`, `packages/engine/src/workflow-task-runtime.ts`, `packages/engine/src/session-skill-context.ts`, `packages/engine/src/agent-tools.ts`, `packages/engine/src/action-gate.ts` or adjacent action gate modules, `packages/core/src/workflow-ir-resolver.ts`, `packages/core/src/workflow-columns-settings.ts`, `packages/engine/src/__tests__/agent-workflow-tools-exposure.test.ts`, `packages/engine/src/__tests__/agent-tools-workflow-settings.test.ts`, `packages/engine/src/__tests__/executor-column-agent-custom-node.test.ts`, `packages/engine/src/__tests__/effective-node.test.ts`.
|
||||
- **Approach:** Centralize effective principal resolution for each node invocation. Pass the resolved identity through session setup, prompt/persona/memory, permission gates, heartbeat serialization in both directions, resume queries, change detection, audit attribution, and missing-agent fallback. Search all `assignedAgentId` execution readers and either re-key them or document why they are task metadata readers only.
|
||||
- **Execution note:** Apply the full checklist from `docs/solutions/architecture-patterns/per-entity-execution-principal-override-blast-radius.md`.
|
||||
- **Patterns to follow:** Existing column-agent resolver, `resolveEffectiveAgent`, heartbeat serialization guards.
|
||||
- **Test scenarios:** override column agent governs custom prompt node, planning node, coding node, step-execute, and review node; defer mode loses to own agent/model settings; missing agent degrades; heartbeat does not run concurrently with its effective task session; resume wakes by effective principal; permission gate sees the effective agent.
|
||||
- **Verification:** Identity matrix tests cover mode x surface x own-settings x missing-agent.
|
||||
|
||||
### U9. Old Orchestration Deletion
|
||||
|
||||
- **Goal:** Remove production lifecycle orchestration from `TaskExecutor`, `TriageProcessor`, graph seams, and self-healing callbacks after workflow runtime and built-ins cover the behavior.
|
||||
- **Requirements:** R4, R10, R11.
|
||||
- **Dependencies:** U1, U2, U3, U4, U5, U6, U7, U8.
|
||||
- **Files:** `packages/engine/src/executor.ts`, `packages/engine/src/triage.ts`, `packages/engine/src/workflow-graph-task-runner.ts`, `packages/engine/src/workflow-authoritative-driver.ts`, `packages/engine/src/workflow-task-runtime.ts`, `packages/engine/src/workflow-node-handlers.ts`, `packages/engine/src/__tests__/workflow-graph-entry.test.ts` if present, `packages/engine/src/__tests__/stepwise-workflow-parity.test.ts`, `packages/engine/src/__tests__/executor-core.test.ts`.
|
||||
- **Approach:** Delete `maybeExecuteWorkflowGraph`, `runImplementationPhase`, `graphCompletionInterceptors`, production `WorkflowLegacySeams`, authoritative-driver fallback, dual-observe gating for production authority, triage lifecycle loops, and self-healing callbacks that directly call executor/triage recovery methods. Keep or rename files only when they contain primitives. Any remaining `executor.ts` code should be mechanical session/worktree/tool support, not lifecycle owner.
|
||||
- **Patterns to follow:** Search-based deletion gates used in existing no-nohup and lazy-view inventory tests.
|
||||
- **Test scenarios:** search tests fail if production code references `graphCompletionInterceptors`, `runImplementationPhase`, `maybeExecuteWorkflowGraph`, `WorkflowAuthoritativeDriver`, or production `WorkflowLegacySeams`; runtime tests prove null workflow selection executes built-in workflow; no tests need legacy fallback to pass.
|
||||
- **Verification:** A grep audit plus tests show no production lifecycle path bypasses workflow runtime.
|
||||
|
||||
### U10. Surface Enumeration And Gate Test Matrix
|
||||
|
||||
- **Goal:** Build the regression matrix required for a big-bang cutover across every known execution surface.
|
||||
- **Requirements:** R7, R8, R10, R11.
|
||||
- **Dependencies:** U3, U4, U5, U6, U7, U8, U9.
|
||||
- **Files:** `packages/engine/src/__tests__/workflow-runtime-cutover.matrix.test.ts` (new), `packages/engine/src/__tests__/workflow-work-engine-dispatch.test.ts`, `packages/engine/src/__tests__/pr-workflow-e2e.test.ts`, `packages/engine/src/__tests__/workflow-step-integration-cwd.test.ts`, `packages/engine/src/__tests__/reliability-interactions/workflow-and-file-scope.test.ts`, `packages/engine/src/__tests__/reliability-interactions/owning-node-unavailable-interactions.test.ts`, `packages/engine/src/__tests__/reliability-interactions/cross-node-assignment-wake.test.ts`, `packages/engine/src/__tests__/reliability-interactions/workflow-interpreter-cutover.test.ts`, `packages/engine/vitest.config.ts`.
|
||||
- **Approach:** Enumerate surfaces explicitly and add targeted tests rather than relying on old executor suites. Cover default workflow, stepwise workflow, custom prompt/script/gate/code nodes, plugin work-engine extension, PR nodes, planning nodes, recovery nodes, local runtime, remote runtime, pause/cancel, branch-group integration, auto-merge global/per-task matrix, test mode/mock provider, workflow settings fallback, and file-scope/squash guards.
|
||||
- **Patterns to follow:** `docs/testing.md` Surface Enumeration checklist, existing reliability-interactions tests, thin trusted gate constraints.
|
||||
- **Test scenarios:** every surface above has at least one happy path and one failure/recovery path; gate suite gets only high-signal cutover invariants; slow/full-suite coverage remains opt-in unless evidence supports gate admission.
|
||||
- **Verification:** `pnpm test:gate`, `pnpm lint`, `pnpm build`, and targeted engine/core suites pass without relying on old executor fallback.
|
||||
|
||||
### U11. Documentation And Concept Refresh
|
||||
|
||||
- **Goal:** Update docs and vocabulary so the repository describes workflow-native execution as the actual architecture.
|
||||
- **Requirements:** R1, R2, R3, R6, R9.
|
||||
- **Dependencies:** U9, U10.
|
||||
- **Files:** `docs/architecture.md`, `docs/workflow-steps.md`, `docs/testing.md`, `docs/agents.md`, `docs/settings-reference.md`, `CONCEPTS.md`, `packages/engine/CHANGELOG.md`, `packages/engine/src/__tests__/workflow-settings-fallback-alignment.test.ts`.
|
||||
- **Approach:** Replace interpreter-scaffold and parity-gated rollout language with the new ownership boundary. Add Concepts entries for Workflow runtime, Runtime primitive, Recovery event, and Built-in lifecycle node. Document what remains engine-owned and what is workflow-owned. Update testing docs with the new cutover matrix and recovery-node pattern.
|
||||
- **Patterns to follow:** Existing Concepts style, Architecture package responsibility tables, workflow-steps IR sections.
|
||||
- **Test scenarios:** documentation inventory tests still pass; lazy-heavy view inventory is unaffected; workflow settings docs describe built-in workflow settings as runtime-owned defaults.
|
||||
- **Verification:** Docs use current code names and no longer describe legacy executor/reviewer/merger/scheduler flow as authoritative for task lifecycle.
|
||||
|
||||
---
|
||||
|
||||
## Surface Enumeration
|
||||
|
||||
The cutover must cover these surfaces before it can be considered complete:
|
||||
|
||||
| Surface | Required coverage |
|
||||
|---|---|
|
||||
| Default coding workflow | planning, implementation, verification, review, merge, failure, recovery |
|
||||
| Stepwise coding workflow | parse-steps, foreach, step-execute, step-review, rework, integration conflict |
|
||||
| Custom workflows | prompt, script, gate, code, split/join, loop, plugin node handler fallback |
|
||||
| Planning/triage | new task intake, refinement, split subtasks, spec review, duplicate/ghost preflight |
|
||||
| Recovery | completed stranded, failed pre-merge, no-progress, partial-progress, limbo, contamination, branch conflict |
|
||||
| Merge | global auto-merge on/off, per-task override, manual-required, branch-group member landing, group promotion |
|
||||
| Runtime routing | local runtime, remote node runtime, unavailable-node policy, cross-node assignment wake |
|
||||
| Identity | assigned agent, column-agent defer, column-agent override, missing agent fallback, heartbeat serialization |
|
||||
| Safety guards | hard cancel, file-scope guard, squash overlap, worktree ownership, process abort |
|
||||
| Settings | workflow settings defaults, task overrides, test mode/mock provider, model lane resolution |
|
||||
| Plugins | workflow work engine extension, node-handler extension degradation, PR node dependencies |
|
||||
|
||||
---
|
||||
|
||||
## System-Wide Impact
|
||||
|
||||
This is a high-risk architectural replacement. It changes the mental model for every developer-facing execution path: instead of learning `scheduler -> executor -> workflow steps -> review -> merge -> self-healing`, readers should learn `scheduler/router -> workflow runtime -> graph nodes -> primitives`. The user-visible behavior should remain recognizable, but the authorship surface changes: default behavior is inspectable in built-in workflows, and recovery decisions are visible as graph edges or recovery nodes.
|
||||
|
||||
Stakeholders affected:
|
||||
|
||||
- Developers need new runtime primitive and node-handler patterns.
|
||||
- Reviewers need to inspect built-in workflow IR as executable policy.
|
||||
- Operators/support gain durable workflow run state but lose the old executor-log-only mental model.
|
||||
- Plugin authors must treat workflow extension handlers as first-class execution participants.
|
||||
- Users should see fewer stranded tasks because recovery decisions become explicit and inspectable.
|
||||
|
||||
---
|
||||
|
||||
## Risks & Mitigations
|
||||
|
||||
| Risk | Mitigation |
|
||||
|---|---|
|
||||
| The big-bang branch is too large to reason about | Keep implementation units independently testable, but require final deletion of legacy orchestration before merge. |
|
||||
| Hidden lifecycle behavior in `executor.ts` is missed | U3 phase-map tests and U10 surface enumeration must map every current branch before deletion. |
|
||||
| Recovery becomes less safe during the cutover | U2 run state and U7 recovery events land before old sweeps are demoted. |
|
||||
| Auto-merge/manual-required regresses | Preserve `allowsAutoMergeProcessing`, branch-group merge target resolution, and merger authority as substrate guards; cross global x per-task tests. |
|
||||
| Effective principal is partially re-keyed | U8 applies the full blast-radius checklist and grep audit for old identity readers. |
|
||||
| Built-in workflow IR becomes unreadable | Use reusable built-in subgraphs or explicit node kinds for engine-owned primitives; do not encode everything as opaque prompt nodes. |
|
||||
| Tests become slow or flaky | Put only high-signal cutover invariants in the gate; keep broader matrix targeted and avoid real polling/network. |
|
||||
|
||||
---
|
||||
|
||||
## Acceptance Examples
|
||||
|
||||
- AE1. Given a task with no workflow selection, when the scheduler dispatches it, then `WorkflowTaskRuntime` resolves `builtin:coding` and no production code invokes legacy executor lifecycle fallback.
|
||||
- AE2. Given a new task in intake, when planning starts, then a workflow planning node produces/reviews the spec and routes to todo hold without `TriageProcessor` owning lifecycle control.
|
||||
- AE3. Given a coding task completes implementation, when verification fails with revision feedback, then workflow edges route back to the implementation/fix node and preserve existing progress semantics.
|
||||
- AE4. Given `settings.autoMerge:false` and no per-task override, when the merge gate is reached, then the workflow routes to manual-required/in-review hold and self-healing does not move the task backward.
|
||||
- AE5. Given `settings.autoMerge:false` and `task.autoMerge:true`, when the merge gate is reached, then trigger processing is allowed and the merge node proceeds through the normal merge primitive.
|
||||
- AE6. Given a user moves an active task from `in-progress` to `todo`, when any node-owned session or command is active, then the runtime aborts it and parks the task with user-paused hard-cancel semantics.
|
||||
- AE7. Given a crashed run with side effects already started, when restart recovery runs, then the scheduler wakes a workflow recovery event and does not rerun old executor orchestration.
|
||||
- AE8. Given a column-agent override on a workflow column, when any node in that column runs, then session identity, permission gates, heartbeat serialization, and audit attribution all use the effective principal.
|
||||
- AE9. Given a remote node owns a project, when central dispatches a task, then the remote node executes the workflow runtime and central only observes/routes events.
|
||||
|
||||
---
|
||||
|
||||
## Documentation / Operational Notes
|
||||
|
||||
- The final branch changes architecture docs, not just code. `docs/architecture.md` must describe workflow runtime as authoritative.
|
||||
- `docs/workflow-steps.md` must stop describing `workflowGraphExecutor` and `workflowInterpreterAuthoritative` as rollout flags for production authority once the cutover lands.
|
||||
- `CONCEPTS.md` should add Workflow runtime, Runtime primitive, Recovery event, and Built-in lifecycle node.
|
||||
- This change affects private packages, but if behavior in published `@runfusion/fusion` changes through CLI/runtime behavior, the implementation work should add an appropriate changeset.
|
||||
|
||||
---
|
||||
|
||||
## Sources & Research
|
||||
|
||||
- `docs/plans/2026-06-07-001-refactor-workflow-runtime-cutover-plan.md` is the closest prior plan and is superseded here by the user's big-bang requirement.
|
||||
- `docs/plans/2026-06-03-002-feat-workflow-interpreter-cutover-plan.md` documents the earlier seam/fallback interpreter path that this plan removes.
|
||||
- `docs/plans/2026-06-04-001-feat-step-inversion-workflow-modelable-steps-plan.md` documents parse-steps, foreach, step-review, code nodes, and step-instance persistence.
|
||||
- `docs/workflow-steps.md` documents current workflow IR, step inversion, workflow steps, parity flags, and reliability invariants.
|
||||
- `CONCEPTS.md` defines Task, Default workflow, Step instance, In-review, Merge queue, Manual-required, Self-healing sweep, Column agent, and Effective agent.
|
||||
- `docs/solutions/architecture-patterns/per-entity-execution-principal-override-blast-radius.md` supplies the identity blast-radius checklist used by U8.
|
||||
- `docs/solutions/logic-errors/per-task-auto-merge-override-ignored-by-trigger-gates.md` supplies the additive trigger-gate lesson used by merge/recovery tests.
|
||||
- `docs/solutions/logic-errors/files-changed-inflated-by-origin-first-base-commit.md` supplies the local-first fork-point invariant used by U1.
|
||||
- `packages/engine/src/workflow-task-runtime.ts`, `workflow-graph-executor.ts`, `workflow-node-handlers.ts`, `workflow-graph-foreach.ts`, and `workflow-graph-loop.ts` are the existing workflow runtime foundation.
|
||||
- `packages/engine/src/executor.ts`, `triage.ts`, `self-healing.ts`, `scheduler.ts`, and `runtimes/in-process-runtime.ts` are the legacy ownership surfaces to subsume.
|
||||
@@ -0,0 +1,143 @@
|
||||
---
|
||||
title: "Workflow-native execution through runtime primitives"
|
||||
date: 2026-06-09
|
||||
category: architecture-patterns
|
||||
module: engine
|
||||
problem_type: architecture_pattern
|
||||
component: tooling
|
||||
severity: high
|
||||
applies_when:
|
||||
- "A workflow graph claims lifecycle authority but nodes still call back into legacy orchestration"
|
||||
- "Built-in workflows need to express default engine behavior without hidden executor branches"
|
||||
- "Recovery, review, merge, or planning policy is split between workflow IR and imperative runtime code"
|
||||
- "A graph runtime needs side effects without giving nodes direct access to scheduler or executor internals"
|
||||
tags: [workflow-runtime, runtime-primitives, builtin-workflows, graph-executor, recovery-policy, engine-boundary]
|
||||
related:
|
||||
- docs/plans/2026-06-09-001-refactor-big-bang-workflow-native-execution-plan.md
|
||||
- docs/solutions/architecture-patterns/per-entity-execution-principal-override-blast-radius.md
|
||||
---
|
||||
|
||||
# Workflow-native execution through runtime primitives
|
||||
|
||||
## Context
|
||||
|
||||
The workflow graph executor already modeled task sequencing, but default task execution still depended on `WorkflowLegacySeams` that called back into `TaskExecutor.execute()`, review handoff, merge queue requests, and step-review helpers. That left two control planes: workflow IR described the lifecycle, while imperative code in the engine still decided large pieces of planning, execution, review, merge, and recovery.
|
||||
|
||||
The cutover in PR #1536 introduced a typed runtime primitive boundary and primitive-backed node handlers. The engine still owns substrate concerns, but built-in workflows and node handlers now own lifecycle policy.
|
||||
|
||||
Session history search: no relevant prior sessions were found for this specific workflow-runtime primitive cutover topic.
|
||||
|
||||
## Guidance
|
||||
|
||||
Use a **workflow policy / runtime primitive / engine substrate** split when moving lifecycle behavior into workflows.
|
||||
|
||||
**Workflow policy** lives in IR and node routing:
|
||||
|
||||
- the selected or built-in workflow defines node order and recovery branches;
|
||||
- built-in lifecycle nodes express default behavior such as planning, execute, review, merge, parse-steps, step-review, and PR lifecycle;
|
||||
- edge outcomes carry decisions like success, failure, revise, rethink, unavailable, manual-required, merge-timeout, or recovery routing.
|
||||
|
||||
**Runtime primitives** are the only side-effect boundary nodes call:
|
||||
|
||||
```ts
|
||||
export interface WorkflowRuntimePrimitives {
|
||||
prepareWorktree(ctx, task): Promise<RuntimePrimitiveResult<PreparedWorktree>>;
|
||||
runPlanningSession(ctx, task): Promise<RuntimePrimitiveResult<PlanningSessionResult>>;
|
||||
runCodingSession(ctx, task, prepared): Promise<RuntimePrimitiveResult<CodingSessionResult>>;
|
||||
runTaskStep(ctx, task, stepIndex): Promise<RunTaskStepResult>;
|
||||
resetTaskStep(ctx, task, stepIndex, baselineSha?, checkpointId?): Promise<ResetStepResult>;
|
||||
runReview(ctx, task, input): Promise<RuntimePrimitiveResult<ReviewPrimitiveResult>>;
|
||||
requestMerge(ctx, task, input?): Promise<RuntimePrimitiveResult<MergePrimitiveResult>>;
|
||||
transitionTask(ctx, task, input): Promise<RuntimePrimitiveResult>;
|
||||
abortRun(ctx, task, input): Promise<RuntimePrimitiveResult>;
|
||||
audit(ctx, input): Promise<void> | void;
|
||||
}
|
||||
```
|
||||
|
||||
Every primitive receives a workflow run context and node context, so audit, side-effect boundaries, effective principal, retry attempt, and recovery event identity do not have to be inferred from the mutable task row.
|
||||
|
||||
**Engine substrate** remains centralized:
|
||||
|
||||
- scheduler dispatch and routing claims;
|
||||
- local vs remote node routing;
|
||||
- persistence adapters and workflow run state;
|
||||
- concurrency/semaphore control;
|
||||
- process supervision and abort mechanics;
|
||||
- storage access and audit/log sinks;
|
||||
- non-bypassable guards such as file scope, worktree ownership, branch-group merge targets, and transition validation.
|
||||
|
||||
In code, the graph executor should prefer primitive handlers when primitives are supplied:
|
||||
|
||||
```ts
|
||||
const promptLike = deps?.primitives
|
||||
? createPrimitivePromptLikeHandler(deps.primitives, runCustomNode)
|
||||
: createPromptLikeHandler(seams, runCustomNode);
|
||||
```
|
||||
|
||||
Keep legacy seam adapters only as a compatibility wrapper for older tests or transitional callers. Do not let the production workflow path re-enter a monolithic executor lifecycle after a workflow run starts.
|
||||
|
||||
## Why This Matters
|
||||
|
||||
The primitive boundary prevents graph nodes from becoming thin aliases for old orchestration. A workflow can decide *what happens next* while the engine still provides safe, centralized mechanics for *how a side effect happens*.
|
||||
|
||||
This avoids several failure modes:
|
||||
|
||||
- a graph node starts side effects, throws, and then the old executor reruns the same lifecycle;
|
||||
- built-in workflows drift from hidden imperative branches;
|
||||
- recovery sweeps mutate task lifecycle directly instead of waking/routing workflow recovery;
|
||||
- effective principal, audit, or side-effect identity is reconstructed differently in each subsystem;
|
||||
- tests prove graph traversal but miss the actual task lifecycle path.
|
||||
|
||||
The cutover also exposed a useful testing boundary: old minimal executor-core fakes did not implement workflow-selection APIs. Those fakes should stay on the legacy characterization path unless the graph flag is explicitly enabled, while real workflow-aware stores can default unselected tasks to `builtin:coding`.
|
||||
|
||||
## When to Apply
|
||||
|
||||
- Converting an imperative task lifecycle into a graph/workflow runtime.
|
||||
- Adding a built-in workflow that is supposed to be the compatibility contract for existing behavior.
|
||||
- Moving recovery out of sweeps or background repair code and into routable workflow behavior.
|
||||
- Introducing a side-effecting node kind that needs git, agents, review, merge, task transitions, or audit.
|
||||
- Reviewing graph runtime changes where a node still calls a large executor method instead of a named primitive.
|
||||
|
||||
## Examples
|
||||
|
||||
### Built-in workflow owns the compatibility path
|
||||
|
||||
Before the cutover, `builtin:coding` started at execute:
|
||||
|
||||
```text
|
||||
start -> execute -> review -> merge -> end
|
||||
```
|
||||
|
||||
After the cutover, planning and pre-merge workflow steps are explicit:
|
||||
|
||||
```text
|
||||
start -> planning -> execute -> workflow-step -> review -> merge -> end
|
||||
```
|
||||
|
||||
The workflow compiler also treats `planning` and `workflow-step` as seam anchors in the canonical lifecycle order, so built-in graph shape and compile-time compatibility stay aligned.
|
||||
|
||||
### Graph routing owns workflow-aware tasks first
|
||||
|
||||
The production executor gives graph routing first claim for workflow-aware stores. It pins `workflowGraphExecutor` for the run, synthesizes `builtin:coding` for unselected tasks, and parks interpreter failures as workflow failures instead of falling through to legacy execution.
|
||||
|
||||
Minimal old test fakes without workflow-selection support still fall through unless they explicitly enable the graph flag. This keeps characterization tests useful without weakening the production boundary.
|
||||
|
||||
### Primitive-backed node handlers preserve routing semantics
|
||||
|
||||
Primitive handlers map old seam names to explicit operations:
|
||||
|
||||
- `planning` -> `runPlanningSession`
|
||||
- `execute` -> `prepareWorktree` then `runCodingSession`
|
||||
- `workflow-step` -> `runWorkflowStep`
|
||||
- `review` -> `runReview`
|
||||
- `merge` -> `requestMerge`
|
||||
- `step-execute` -> `runTaskStep`
|
||||
- `step-review` -> `runReview` with step index and bounded unavailable retry
|
||||
|
||||
The node still returns graph-native `{ outcome, value, contextPatch }`, so edge routing remains workflow-owned.
|
||||
|
||||
## Related
|
||||
|
||||
- `docs/workflow-steps.md` now documents the workflow runtime as authoritative and describes primitives as the side-effect boundary.
|
||||
- `CONCEPTS.md` defines Workflow Runtime, Runtime Primitive, Built-in Lifecycle Node, and Recovery Event.
|
||||
- `docs/solutions/architecture-patterns/per-entity-execution-principal-override-blast-radius.md` covers the adjacent identity/principal blast-radius checklist for workflow nodes.
|
||||
@@ -28,23 +28,21 @@ Out of scope for v1:
|
||||
- Execution history/runtime traces
|
||||
- Migration tooling for future schema versions (future versions should use explicit `schemaVersion` migrations)
|
||||
|
||||
### Workflow Graph Executor (interpreter scaffold)
|
||||
### Workflow Runtime
|
||||
|
||||
FN-5766 adds a **flagged-off** interpreter scaffold in `@fusion/engine` (`WorkflowGraphExecutor`) plus a built-in coding lifecycle IR in `@fusion/core` (`BUILTIN_CODING_WORKFLOW_IR`).
|
||||
The workflow runtime is the authoritative execution path for task lifecycle work. `WorkflowGraphExecutor` owns graph traversal and routing; node handlers call runtime primitives supplied by `TaskExecutor` for side-effecting operations such as planning, coding sessions, review, step execution/reset, merge requests, transitions, and audit.
|
||||
|
||||
- Feature flag key: `experimentalFeatures.workflowGraphExecutor`
|
||||
- Default: **OFF**
|
||||
- OFF behavior is strict no-op (no task mutations, no session/git side effects), so the legacy imperative pipeline remains authoritative.
|
||||
The engine remains the substrate for scheduler dispatch, routing claims, persistence, concurrency limits, process supervision, storage, and audit plumbing. Lifecycle policy belongs in built-in or custom workflows.
|
||||
|
||||
The default built-in catalog entry `builtin:coding` is backed by the canonical `BUILTIN_CODING_WORKFLOW_IR`, which is also the resolver/runtime fallback for tasks with no workflow selection, an explicit default selection, or a missing/corrupt custom selection. That IR currently encodes the legacy lifecycle path as graph stages:
|
||||
The default built-in catalog entry `builtin:coding` is backed by the canonical `BUILTIN_CODING_WORKFLOW_IR`, which is also the resolver/runtime fallback for tasks with no workflow selection or an explicit default selection. Missing/corrupt explicit custom selections fail closed as workflow-resolution failures instead of silently running the default. The built-in IR encodes the legacy lifecycle path as graph stages:
|
||||
|
||||
- `triage` → `execute` → `review` → `merge` → `end`
|
||||
- `triage/planning` → `execute` → `workflow-step` → `review` → `merge` → `end`
|
||||
|
||||
`builtin:stepwise-coding` is a separate opt-in graph-mode variant backed by `BUILTIN_STEPWISE_CODING_WORKFLOW_IR`; it keeps the same lifecycle columns/traits while modeling per-step parse/execute/review as authored graph structure, and requires the `workflowGraphExecutor` flag at runtime.
|
||||
`builtin:stepwise-coding` is a separate graph variant backed by `BUILTIN_STEPWISE_CODING_WORKFLOW_IR`; it keeps the same lifecycle columns/traits while modeling per-step parse/execute/review/rework as authored graph structure.
|
||||
|
||||
#### Interpreter-parity gating criterion
|
||||
#### Runtime invariant criterion
|
||||
|
||||
Interpreter authority is gated on parity: interpreter-driven coding runs must match legacy behavior for observable task transitions and reliability invariants (file-scope guards including `FileScopeViolationError`, squash/merge contract, self-healing expectations, `autoMerge:false` terminal-until-merged, and `moveTask(in-progress→todo)` hard-cancel semantics).
|
||||
Workflow-driven coding runs must preserve observable task transitions and reliability invariants: file-scope guards including `FileScopeViolationError`, squash/merge contract, recovery expectations, `autoMerge:false` terminal-until-merged, and `moveTask(in-progress→todo)` hard-cancel semantics.
|
||||
|
||||
For grouped branch flows (`branch_groups`), auto-merge precedence is split: per-task `autoMerge` controls member→group-integration landing, while group `autoMerge` controls group→default-branch promotion eligibility.
|
||||
|
||||
@@ -485,13 +483,14 @@ Prompt-mode workflow agents should emit a trailing JSON object:
|
||||
- Backward compatibility remains for legacy prose-only responses via heuristic fallback (`REQUEST REVISION` and approval keywords).
|
||||
- If neither structured JSON nor fallback prose can be interpreted, output is recorded as `malformed` (no inferable verdict) instead of hard-failing the task.
|
||||
|
||||
## Workflow Graph Executor (interpreter)
|
||||
## Workflow Graph Executor
|
||||
|
||||
The experimental `workflowGraphExecutor` path remains **default OFF** and only runs when `settings.experimentalFeatures.workflowGraphExecutor = true`.
|
||||
Workflow graph execution is the task lifecycle runtime. `TaskExecutor` pins `workflowGraphExecutor` for the run and unselected tasks resolve to `builtin:coding`.
|
||||
|
||||
When enabled, interpreter nodes dispatch through DI-backed legacy seams:
|
||||
- `prompt` / `script` nodes with `config.seam` dispatch to `execute`, `review`, `merge`, or `schedule`
|
||||
- `gate` nodes evaluate context-key expectations and return success/failure outcomes
|
||||
Default node dispatch:
|
||||
- `prompt` / `script` nodes with `config.seam` dispatch through workflow runtime primitives (`planning`, `execute`, `workflow-step`, `review`, `merge`, `schedule`, `step-execute`)
|
||||
- `step-review`, `parse-steps`, `code`, and PR nodes use their dedicated primitive/dependency adapters
|
||||
- `gate` nodes evaluate context-key expectations or run configured executable checks
|
||||
|
||||
Traversal semantics:
|
||||
- edge with no condition or `success` routes on success
|
||||
@@ -500,18 +499,13 @@ Traversal semantics:
|
||||
- unsupported conditions throw `WorkflowIrError`
|
||||
- per-node retries are bounded and deterministic
|
||||
|
||||
Parity coverage includes flag-OFF no-op behavior, lifecycle ordering parity vs legacy seams, merge/file-scope-like failure routing, and downstream halt behavior for hard-cancel/self-healing style failures.
|
||||
Coverage includes lifecycle ordering, primitive invocation, merge/file-scope failure routing, and downstream halt behavior for hard-cancel/recovery style failures.
|
||||
|
||||
### Interpreter-authoritative cutover
|
||||
### Workflow-native Cutover
|
||||
|
||||
A second default-OFF flag, `experimentalFeatures.workflowInterpreterAuthoritative`, promotes the interpreter from shadow/selected-workflow sequencing to the **authoritative** lifecycle driver for default coding tasks.
|
||||
`TaskExecutor.execute()` gives graph routing first claim. The graph runtime resolves a workflow selection, using `builtin:coding` for unselected/default tasks, failing closed for missing explicit custom workflows, and parking interpreter failures as workflow failures instead of re-running the old imperative lifecycle.
|
||||
|
||||
The cutover stays opt-in, guarded, and reversible:
|
||||
- **Default OFF:** legacy executor/reviewer/merger/scheduler flow remains authoritative.
|
||||
- **Guarded ON:** the engine only routes through the authoritative driver when `evaluateInterpreterCutoverReadiness(...)` reports ready. The guard consumes explicit rollout evidence (cutover flag enabled, dual-observe enabled, non-empty parity observations, zero unresolved drift).
|
||||
- **Rollback:** turning `workflowInterpreterAuthoritative` back OFF immediately restores the legacy path; no migration or cleanup step is required.
|
||||
|
||||
When the guard passes, the runtime binds real DI seams from `TaskExecutor` into the built-in coding IR and drives `BUILTIN_CODING_WORKFLOW_IR` through `WorkflowGraphExecutor`. The interpreter does **not** reimplement lifecycle behavior: it delegates execute/review/merge to the same legacy seams already used by the imperative path.
|
||||
The legacy seam adapter remains as a compatibility layer for older tests and callers, but authoritative node execution uses `WorkflowRuntimePrimitives`. The built-in coding workflow now includes explicit planning and pre-merge workflow-step gates before review/merge.
|
||||
|
||||
Reliability invariants preserved under authoritative mode:
|
||||
- file-scope enforcement including `FileScopeViolationError`
|
||||
|
||||
@@ -36,7 +36,7 @@ describe("builtin coding workflow ir", () => {
|
||||
const seams = BUILTIN_CODING_WORKFLOW_IR.nodes
|
||||
.map((node) => String(node.config?.seam ?? ""))
|
||||
.filter((seam) => seam.length > 0);
|
||||
expect(seams).toEqual(expect.arrayContaining(["execute", "review", "merge"]));
|
||||
expect(seams).toEqual(expect.arrayContaining(["execute", "workflow-step", "review", "merge"]));
|
||||
expect(seams).not.toContain("triage");
|
||||
});
|
||||
|
||||
@@ -66,13 +66,15 @@ describe("builtin coding workflow ir", () => {
|
||||
it("places seam nodes in their columns", () => {
|
||||
const byId = new Map(BUILTIN_CODING_WORKFLOW_IR.nodes.map((n) => [n.id, n]));
|
||||
expect(byId.get("execute")?.column).toBe("in-progress");
|
||||
expect(byId.get("workflow-step")?.column).toBe("in-progress");
|
||||
expect(byId.get("review")?.column).toBe("in-review");
|
||||
expect(byId.get("merge")?.column).toBe("in-review");
|
||||
});
|
||||
|
||||
it("assigns descriptive names to execute/review/merge seam nodes", () => {
|
||||
it("assigns descriptive names to execute/workflow-step/review/merge seam nodes", () => {
|
||||
const byId = new Map(BUILTIN_CODING_WORKFLOW_IR.nodes.map((n) => [n.id, n]));
|
||||
expect(byId.get("execute")?.config?.name).toBe("Execute");
|
||||
expect(byId.get("workflow-step")?.config?.name).toBe("Pre-merge workflow steps");
|
||||
expect(byId.get("review")?.config?.name).toBe("Review");
|
||||
expect(byId.get("merge")?.config?.name).toBe("Merge boundary");
|
||||
});
|
||||
@@ -85,8 +87,10 @@ describe("builtin coding workflow ir", () => {
|
||||
expect(config.maxRetries).toBeLessThanOrEqual(10);
|
||||
|
||||
const byId = new Map(BUILTIN_CODING_WORKFLOW_IR.nodes.map((n) => [n.id, n]));
|
||||
expect(byId.get("workflow-step")?.config?.name).toBe("Pre-merge workflow steps");
|
||||
expect(byId.get("review")?.config?.name).toBe("Review");
|
||||
expect(byId.get("merge")?.config?.name).toBe("Merge boundary");
|
||||
expect(byId.get("workflow-step")?.config?.maxRetries).toBeUndefined();
|
||||
expect(byId.get("review")?.config?.maxRetries).toBeUndefined();
|
||||
expect(byId.get("merge")?.config?.maxRetries).toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -133,6 +133,7 @@ describe("built-in workflows", () => {
|
||||
|
||||
const byId = new Map(ir.nodes.map((node) => [node.id, node]));
|
||||
expect(byId.get("execute")?.column).toBe("in-progress");
|
||||
expect(byId.get("workflow-step")?.column).toBe("in-progress");
|
||||
expect(byId.get("review")?.column).toBe("in-review");
|
||||
expect(byId.get("merge")?.column).toBe("in-review");
|
||||
expect(ir.settings).toEqual(BUILTIN_WORKFLOW_SETTINGS);
|
||||
@@ -169,8 +170,10 @@ describe("built-in workflows", () => {
|
||||
expect(executeConfig?.maxRetries).toBeLessThanOrEqual(10);
|
||||
|
||||
const byId = new Map(candidate.nodes.map((node) => [node.id, node]));
|
||||
expect(byId.get("workflow-step")?.config?.name).toBe("Pre-merge workflow steps");
|
||||
expect(byId.get("review")?.config?.name).toBe("Review");
|
||||
expect(byId.get("merge")?.config?.name).toBe("Merge boundary");
|
||||
expect(byId.get("workflow-step")?.config?.maxRetries).toBeUndefined();
|
||||
expect(byId.get("review")?.config?.maxRetries).toBeUndefined();
|
||||
expect(byId.get("merge")?.config?.maxRetries).toBeUndefined();
|
||||
}
|
||||
|
||||
@@ -145,7 +145,7 @@ describe("compileWorkflowToSteps (U2)", () => {
|
||||
expect(err).toBeInstanceOf(WorkflowCompileError);
|
||||
});
|
||||
|
||||
it("rejects seams that are out of the execute -> review -> merge order", () => {
|
||||
it("rejects seams that are out of the planning -> execute -> workflow-step -> review -> merge order", () => {
|
||||
const ir: WorkflowIr = {
|
||||
version: "v1",
|
||||
name: "misordered-seams",
|
||||
@@ -163,7 +163,7 @@ describe("compileWorkflowToSteps (U2)", () => {
|
||||
};
|
||||
const err = validateLinearity(ir);
|
||||
expect(err).toBeInstanceOf(WorkflowCompileError);
|
||||
expect(err?.message).toMatch(/execute -> review -> merge order/);
|
||||
expect(err?.message).toMatch(/planning -> execute -> workflow-step -> review -> merge order/);
|
||||
});
|
||||
|
||||
it("rejects a graph with a duplicated seam role", () => {
|
||||
|
||||
@@ -18,9 +18,11 @@ import { builtinPromptConfig } from "./builtin-workflow-prompts.js";
|
||||
* done = complete
|
||||
* archived = archived
|
||||
*
|
||||
* The seam nodes (execute/review/merge) are placed in their columns; the graph
|
||||
* walk (edges) is byte-identical to the prior v1 coding pipeline, so the graph
|
||||
* executor continues to drive execute → review → merge unchanged.
|
||||
* The lifecycle seam nodes are placed in their columns. Planning is explicit so
|
||||
* the built-in workflow owns the specification phase rather than relying on
|
||||
* triage code that runs outside the graph; workflow-step keeps the legacy
|
||||
* pre-merge quality gate between implementation and review; execute/review/
|
||||
* merge keep the same observable pipeline and failure routing.
|
||||
*/
|
||||
const RAW_BUILTIN_CODING_WORKFLOW_IR: WorkflowIr = {
|
||||
version: "v2",
|
||||
@@ -47,22 +49,40 @@ const RAW_BUILTIN_CODING_WORKFLOW_IR: WorkflowIr = {
|
||||
],
|
||||
nodes: [
|
||||
{ id: "start", kind: "start", column: "triage" },
|
||||
{
|
||||
id: "planning",
|
||||
kind: "prompt",
|
||||
column: "triage",
|
||||
config: builtinPromptConfig("planning", "Plan / specify"),
|
||||
},
|
||||
{
|
||||
id: "execute",
|
||||
kind: "prompt",
|
||||
column: "in-progress",
|
||||
config: { ...builtinPromptConfig("execute", "Execute"), maxRetries: 2 },
|
||||
},
|
||||
{
|
||||
id: "workflow-step",
|
||||
kind: "prompt",
|
||||
column: "in-progress",
|
||||
config: builtinPromptConfig("workflow-step", "Pre-merge workflow steps"),
|
||||
},
|
||||
{ id: "review", kind: "prompt", column: "in-review", config: builtinPromptConfig("review", "Review") },
|
||||
{ id: "merge", kind: "prompt", column: "in-review", config: builtinPromptConfig("merge", "Merge boundary") },
|
||||
{ id: "end", kind: "end", column: "done" },
|
||||
],
|
||||
edges: [
|
||||
{ from: "start", to: "execute" },
|
||||
{ from: "execute", to: "review", condition: "success" },
|
||||
{ from: "start", to: "planning" },
|
||||
{ from: "planning", to: "execute", condition: "success" },
|
||||
{ from: "execute", to: "workflow-step", condition: "success" },
|
||||
{ from: "workflow-step", to: "review", condition: "success" },
|
||||
{ from: "workflow-step", to: "end", condition: "outcome:remediation-scheduled" },
|
||||
{ from: "workflow-step", to: "end", condition: "outcome:deferred-paused" },
|
||||
{ from: "review", to: "merge", condition: "success" },
|
||||
{ from: "merge", to: "end", condition: "success" },
|
||||
{ from: "planning", to: "end", condition: "failure" },
|
||||
{ from: "execute", to: "end", condition: "failure" },
|
||||
{ from: "workflow-step", to: "end", condition: "failure" },
|
||||
{ from: "review", to: "end", condition: "failure" },
|
||||
{ from: "merge", to: "end", condition: "failure" },
|
||||
],
|
||||
|
||||
@@ -9,6 +9,7 @@ const BUILTIN_SEAM_PROMPTS: Record<string, string> = {
|
||||
execute: DEFAULT_EXECUTOR_PROMPT,
|
||||
planning: DEFAULT_TRIAGE_PROMPT,
|
||||
"step-execute": DEFAULT_EXECUTOR_PROMPT,
|
||||
"workflow-step": DEFAULT_REVIEWER_PROMPT,
|
||||
review: DEFAULT_REVIEWER_PROMPT,
|
||||
merge: DEFAULT_MERGER_PROMPT,
|
||||
};
|
||||
|
||||
@@ -16,8 +16,9 @@ export class WorkflowCompileError extends Error {
|
||||
}
|
||||
|
||||
/** Seam anchor kinds, encoded on IR nodes as `config.seam`. These map to the
|
||||
* fixed execute → review → merge pipeline and are not emitted as steps. */
|
||||
const SEAM_NAMES = new Set(["execute", "review", "merge"]);
|
||||
* fixed planning → execute → workflow-step → review → merge pipeline and are
|
||||
* not emitted as steps. */
|
||||
const SEAM_NAMES = new Set(["planning", "execute", "workflow-step", "review", "merge"]);
|
||||
|
||||
function seamOf(node: WorkflowIrNode): string | undefined {
|
||||
const seam = node.config?.seam;
|
||||
@@ -77,7 +78,8 @@ export function validateLinearity(ir: WorkflowIr): WorkflowCompileError | null {
|
||||
const seam = seamOf(node);
|
||||
if (seam) {
|
||||
const failureEdges = outs.filter((edge) => edge.condition === "failure");
|
||||
const mainEdges = outs.filter((edge) => edge.condition !== "failure");
|
||||
const mainEdges = outs.filter((edge) => !edge.condition || edge.condition === "success");
|
||||
const outcomeEdges = outs.filter((edge) => edge.condition?.startsWith("outcome:"));
|
||||
if (mainEdges.length !== 1) {
|
||||
return new WorkflowCompileError(`seam '${node.id}' must have exactly one success path`);
|
||||
}
|
||||
@@ -87,6 +89,10 @@ export function validateLinearity(ir: WorkflowIr): WorkflowCompileError | null {
|
||||
if (failureEdges[0] && failureEdges[0].to !== endNode.id) {
|
||||
return new WorkflowCompileError(`seam '${node.id}' failure edge must target the end node`);
|
||||
}
|
||||
const nonTerminalOutcomeEdge = outcomeEdges.find((edge) => edge.to !== endNode.id);
|
||||
if (nonTerminalOutcomeEdge) {
|
||||
return new WorkflowCompileError(`seam '${node.id}' outcome edge must target the end node`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -106,12 +112,12 @@ export function validateLinearity(ir: WorkflowIr): WorkflowCompileError | null {
|
||||
}
|
||||
|
||||
// Reachability: the single main path must reach end and cover every node.
|
||||
// While walking, enforce the canonical seam pipeline: each of execute/review/
|
||||
// merge may appear at most once and only in that order. The compiler treats
|
||||
// seams as a fixed execute → review → merge boundary (merge flips pre- to
|
||||
// post-merge), so out-of-order or duplicate seams would compile inconsistently
|
||||
// with the runtime contract.
|
||||
const expectedSeamOrder = ["execute", "review", "merge"] as const;
|
||||
// While walking, enforce the canonical seam pipeline: each of planning/
|
||||
// execute/workflow-step/review/merge may appear at most once and only in that
|
||||
// order. The compiler treats seams as a fixed lifecycle boundary (merge flips
|
||||
// pre- to post-merge), so out-of-order or duplicate seams would compile
|
||||
// inconsistently with the runtime contract.
|
||||
const expectedSeamOrder = ["planning", "execute", "workflow-step", "review", "merge"] as const;
|
||||
const seenSeams = new Set<string>();
|
||||
let nextExpectedSeamIndex = 0;
|
||||
const visited = new Set<string>();
|
||||
@@ -131,7 +137,9 @@ export function validateLinearity(ir: WorkflowIr): WorkflowCompileError | null {
|
||||
nextExpectedSeamIndex += 1;
|
||||
}
|
||||
if (expectedSeamOrder[nextExpectedSeamIndex] !== seam) {
|
||||
return new WorkflowCompileError("seams must follow the execute -> review -> merge order");
|
||||
return new WorkflowCompileError(
|
||||
"seams must follow the planning -> execute -> workflow-step -> review -> merge order",
|
||||
);
|
||||
}
|
||||
seenSeams.add(seam);
|
||||
nextExpectedSeamIndex += 1;
|
||||
|
||||
@@ -138,6 +138,7 @@ export const DEFAULT_WORKFLOW_COLUMN_IDS = [
|
||||
function defaultColumnForNode(node: WorkflowIrNode): string {
|
||||
const seam = node.config?.seam;
|
||||
if (seam === "execute") return "in-progress";
|
||||
if (seam === "workflow-step") return "in-progress";
|
||||
if (seam === "review") return "in-review";
|
||||
if (seam === "merge") return "in-review";
|
||||
return "todo";
|
||||
|
||||
57
packages/engine/src/__tests__/runtime-primitives.test.ts
Normal file
57
packages/engine/src/__tests__/runtime-primitives.test.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { markSideEffectsStarted, primitiveNodeContext } from "../runtime-primitives.js";
|
||||
|
||||
describe("runtime primitives", () => {
|
||||
it("creates a workflow primitive context from a run and node", () => {
|
||||
const run = {
|
||||
runId: "run-1",
|
||||
taskId: "FN-1",
|
||||
workflowId: "coding",
|
||||
};
|
||||
const node = {
|
||||
id: "execute",
|
||||
kind: "prompt" as const,
|
||||
column: "in-progress",
|
||||
config: { prompt: "implement" },
|
||||
};
|
||||
|
||||
const ctx = primitiveNodeContext(run, node, {
|
||||
effectivePrincipalId: "agent:builder",
|
||||
attempt: 2,
|
||||
context: { priorOutcome: "revise" },
|
||||
});
|
||||
|
||||
expect(ctx).toEqual({
|
||||
run,
|
||||
node: {
|
||||
node,
|
||||
effectivePrincipalId: "agent:builder",
|
||||
attempt: 2,
|
||||
context: { priorOutcome: "revise" },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("marks side effects on an immutable context copy", () => {
|
||||
const ctx = primitiveNodeContext(
|
||||
{
|
||||
runId: "run-1",
|
||||
taskId: "FN-1",
|
||||
workflowId: "coding",
|
||||
},
|
||||
{ id: "execute", kind: "prompt" as const },
|
||||
);
|
||||
|
||||
const marked = markSideEffectsStarted(ctx);
|
||||
|
||||
expect(marked).toEqual({
|
||||
...ctx,
|
||||
run: {
|
||||
...ctx.run,
|
||||
sideEffectsStarted: true,
|
||||
},
|
||||
});
|
||||
expect(ctx.run.sideEffectsStarted).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,8 @@
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// PARITY SUBJECT (test-file ownership, U7 / KTD-9):
|
||||
// This suite owns DEFAULT-WORKFLOW BYTE-IDENTITY parity — it proves the graph
|
||||
// executor reproduces the legacy monolithic execute → review → merge seam
|
||||
// executor reproduces the workflow-native planning → execute → workflow-step
|
||||
// → review → merge seam
|
||||
// sequence exactly (the parity ORACLE per KTD-1). It deliberately does NOT
|
||||
// cover per-step / updateStep-trajectory parity.
|
||||
//
|
||||
@@ -22,13 +23,27 @@ import { WorkflowGraphExecutor } from "../workflow-graph-executor.js";
|
||||
import type { WorkflowLegacySeams } from "../workflow-node-handlers.js";
|
||||
|
||||
const task = { id: "FN-5767" } as TaskDetail;
|
||||
type BaseSeam = "planning" | "execute" | "workflow-step" | "review" | "merge" | "schedule";
|
||||
|
||||
function runBaseSeam(seams: WorkflowLegacySeams, seam: BaseSeam, task: TaskDetail, context: Record<string, unknown>) {
|
||||
if (seam === "workflow-step") {
|
||||
return seams.workflowStep?.(task, context) ?? Promise.resolve({ outcome: "success" as const });
|
||||
}
|
||||
return seams[seam](task, context);
|
||||
}
|
||||
|
||||
function runLegacy(seams: WorkflowLegacySeams) {
|
||||
return async () => {
|
||||
const events: string[] = [];
|
||||
const planning = await seams.planning(task, {});
|
||||
events.push(`planning:${planning.outcome}`);
|
||||
if (planning.outcome !== "success") return events;
|
||||
const execute = await seams.execute(task, {});
|
||||
events.push(`execute:${execute.outcome}`);
|
||||
if (execute.outcome !== "success") return events;
|
||||
const workflowStep = await seams.workflowStep?.(task, {}) ?? { outcome: "success" as const };
|
||||
events.push(`workflow-step:${workflowStep.outcome}`);
|
||||
if (workflowStep.outcome !== "success") return events;
|
||||
const review = await seams.review(task, {});
|
||||
events.push(`review:${review.outcome}`);
|
||||
if (review.outcome !== "success") return events;
|
||||
@@ -47,20 +62,20 @@ describe("WorkflowGraphExecutor interpreter-parity", () => {
|
||||
expect(prompt).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("matches legacy execute-review-merge success path", async () => {
|
||||
it("matches default planning-execute-review-merge success path", async () => {
|
||||
const events: string[] = [];
|
||||
const seams: WorkflowLegacySeams = {
|
||||
planning: async () => ({ outcome: "success" }),
|
||||
execute: async () => ({ outcome: "success" }),
|
||||
workflowStep: async () => ({ outcome: "success" }),
|
||||
review: async () => ({ outcome: "success" }),
|
||||
merge: async () => ({ outcome: "success" }),
|
||||
schedule: async () => ({ outcome: "success" }),
|
||||
};
|
||||
const legacyEvents = await runLegacy(seams)();
|
||||
type BaseSeam = "planning" | "execute" | "review" | "merge" | "schedule";
|
||||
const executor = new WorkflowGraphExecutor({ seams, handlers: { prompt: async (node, ctx) => {
|
||||
const seam = String(node.config?.seam);
|
||||
const result = await seams[seam as BaseSeam](ctx.task, ctx.context);
|
||||
const seam = String(node.config?.seam) as BaseSeam;
|
||||
const result = await runBaseSeam(seams, seam, ctx.task, ctx.context);
|
||||
events.push(`${seam}:${result.outcome}`);
|
||||
return result;
|
||||
} } });
|
||||
@@ -74,6 +89,7 @@ describe("WorkflowGraphExecutor interpreter-parity", () => {
|
||||
const seams: WorkflowLegacySeams = {
|
||||
planning: async () => ({ outcome: "success" }),
|
||||
execute: async () => ({ outcome: "success" }),
|
||||
workflowStep: async () => ({ outcome: "success" }),
|
||||
review: async () => ({ outcome: "success" }),
|
||||
merge: async () => ({ outcome: "failure", value: "FileScopeViolationError" }),
|
||||
schedule: async () => ({ outcome: "success" }),
|
||||
@@ -82,7 +98,7 @@ describe("WorkflowGraphExecutor interpreter-parity", () => {
|
||||
const executor = new WorkflowGraphExecutor({ seams });
|
||||
const result = await executor.run(task, { experimentalFeatures: { workflowGraphExecutor: true } });
|
||||
expect(result.outcome).toBe("failure");
|
||||
expect(legacyEvents).toEqual(["execute:success", "review:success", "merge:failure"]);
|
||||
expect(legacyEvents).toEqual(["planning:success", "execute:success", "workflow-step:success", "review:success", "merge:failure"]);
|
||||
});
|
||||
|
||||
it("preserves autoMerge:false terminal in-review semantics via review failure", async () => {
|
||||
@@ -159,18 +175,18 @@ describe("column-agent feature is invisible when unbound (U7 / R9)", () => {
|
||||
const seams: WorkflowLegacySeams = {
|
||||
planning: async () => ({ outcome: "success" }),
|
||||
execute: async () => ({ outcome: "success" }),
|
||||
workflowStep: 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);
|
||||
return runBaseSeam(seams, seam, ctx.task, ctx.context);
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -182,13 +198,16 @@ describe("column-agent feature is invisible when unbound (U7 / R9)", () => {
|
||||
// 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"]);
|
||||
expect(stages).toEqual(["planning", "execute", "workflow-step", "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"] },
|
||||
);
|
||||
const legacyObs = buildWorkflowObservation({
|
||||
stageTransitions: ["triage", "planning", "execute", "workflow-step", "review", "merge"],
|
||||
terminalColumn: "done",
|
||||
terminalStatus: "done",
|
||||
reviewVerdict: "approve",
|
||||
mergeOutcome: "merged",
|
||||
});
|
||||
// Interpreter (binding-free) observation assembled from the same run.
|
||||
const interpreterObs = buildWorkflowObservation({
|
||||
stageTransitions: ["triage", ...stages] as WorkflowStage[],
|
||||
|
||||
@@ -69,6 +69,7 @@ function recordingSeams(calls: string[], overrides: Partial<Record<string, Workf
|
||||
return {
|
||||
planning: seam("planning"),
|
||||
execute: seam("execute"),
|
||||
workflowStep: seam("workflow-step"),
|
||||
review: seam("review"),
|
||||
merge: seam("merge"),
|
||||
schedule: seam("schedule"),
|
||||
@@ -236,7 +237,7 @@ describe("WorkflowGraphTaskRunner (CU-U2)", () => {
|
||||
const result = await runner.run(task, flagOn);
|
||||
|
||||
expect(result.disposition).toBe("completed");
|
||||
expect(calls).toEqual(["execute", "review", "merge"]);
|
||||
expect(calls).toEqual(["planning", "execute", "workflow-step", "review", "merge"]);
|
||||
expect(result.reason).toBeUndefined();
|
||||
expect(getWorkflowDefinition).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { Settings, TaskDetail, WorkflowIr } from "@fusion/core";
|
||||
|
||||
import { WorkflowTaskRuntime, type WorkflowTaskRuntimeDeps } from "../workflow-task-runtime.js";
|
||||
import type { WorkflowNodeResult } from "../workflow-graph-executor.js";
|
||||
import type { WorkflowLegacySeams } from "../workflow-node-handlers.js";
|
||||
import type { PreparedWorktree, WorkflowRuntimePrimitives } from "../runtime-primitives.js";
|
||||
|
||||
const task = { id: "FN-9002" } as TaskDetail;
|
||||
const flagOff = { experimentalFeatures: {} } as unknown as Pick<Settings, "experimentalFeatures">;
|
||||
@@ -27,35 +27,96 @@ function selectedIr(): WorkflowIr {
|
||||
};
|
||||
}
|
||||
|
||||
function recordingSeams(calls: string[], overrides: Partial<Record<string, WorkflowNodeResult>> = {}): WorkflowLegacySeams {
|
||||
const seam = (name: keyof WorkflowLegacySeams) => async (): Promise<WorkflowNodeResult> => {
|
||||
calls.push(name);
|
||||
return overrides[name] ?? { outcome: "success" };
|
||||
};
|
||||
function recordingPrimitives(
|
||||
calls: string[],
|
||||
overrides: Partial<Record<"prepare" | "execute" | "workflowStep", WorkflowNodeResult>> & {
|
||||
prepareData?: PreparedWorktree | null;
|
||||
} = {},
|
||||
observed: { prepared?: PreparedWorktree } = {},
|
||||
): WorkflowRuntimePrimitives {
|
||||
const prepared: PreparedWorktree = { worktreePath: "/tmp/fusion-worktree" };
|
||||
return {
|
||||
planning: seam("planning"),
|
||||
execute: seam("execute"),
|
||||
review: seam("review"),
|
||||
merge: seam("merge"),
|
||||
schedule: seam("schedule"),
|
||||
prepareWorktree: async () => {
|
||||
calls.push("prepare-worktree");
|
||||
return {
|
||||
outcome: overrides.prepare?.outcome ?? "success",
|
||||
value: overrides.prepare?.value,
|
||||
contextPatch: overrides.prepare?.contextPatch,
|
||||
data: overrides.prepare?.outcome === "failure"
|
||||
? undefined
|
||||
: overrides.prepareData === null
|
||||
? undefined
|
||||
: overrides.prepareData ?? prepared,
|
||||
};
|
||||
},
|
||||
readArtifact: async () => undefined,
|
||||
writeArtifact: async (_ctx, _task, key) => ({ outcome: "success", data: { key } }),
|
||||
runPlanningSession: async () => {
|
||||
calls.push("planning");
|
||||
return { outcome: "success", data: { approved: true, artifactKeys: [] } };
|
||||
},
|
||||
runCodingSession: async (_ctx, _task, preparedWorktree) => {
|
||||
calls.push("execute");
|
||||
observed.prepared = preparedWorktree;
|
||||
const override = overrides.execute;
|
||||
return {
|
||||
outcome: override?.outcome ?? "success",
|
||||
value: override?.value ?? "implemented",
|
||||
contextPatch: override?.contextPatch,
|
||||
data: { taskDone: override?.outcome !== "failure", modifiedFiles: [] },
|
||||
};
|
||||
},
|
||||
runTaskStep: async () => ({ outcome: "success" }),
|
||||
resetTaskStep: async () => ({ ok: true }),
|
||||
runReview: async (_ctx, _task, input) => {
|
||||
calls.push(input.stepIndex === undefined ? "review" : "step-review");
|
||||
return {
|
||||
outcome: "success",
|
||||
value: input.stepIndex === undefined ? "in-review" : "approve",
|
||||
data: { verdict: "APPROVE" },
|
||||
};
|
||||
},
|
||||
runVerification: async () => ({ outcome: "success", data: { verdict: "skipped" } }),
|
||||
runWorkflowStep: async () => {
|
||||
calls.push("workflow-step");
|
||||
const override = overrides.workflowStep;
|
||||
return {
|
||||
outcome: override?.outcome ?? "success",
|
||||
value: override?.value ?? "workflow-steps-passed",
|
||||
contextPatch: override?.contextPatch,
|
||||
data: { allPassed: override?.value !== "remediation-scheduled" },
|
||||
};
|
||||
},
|
||||
updateSteps: async (_ctx, _task, steps) => ({ outcome: "success", data: { count: steps.length } }),
|
||||
transitionTask: async () => {
|
||||
calls.push("schedule");
|
||||
return { outcome: "success" };
|
||||
},
|
||||
requestMerge: async () => {
|
||||
calls.push("merge");
|
||||
return { outcome: "success", value: "merged", data: { status: "merged" } };
|
||||
},
|
||||
abortRun: async () => ({ outcome: "success" }),
|
||||
audit: () => undefined,
|
||||
};
|
||||
}
|
||||
|
||||
describe("WorkflowTaskRuntime", () => {
|
||||
it("requires execution wiring at the type boundary", () => {
|
||||
// @ts-expect-error WorkflowTaskRuntime is an execution entry point, so seams are required.
|
||||
const missingSeams: WorkflowTaskRuntimeDeps = {
|
||||
// @ts-expect-error WorkflowTaskRuntime is an execution entry point, so primitives are required.
|
||||
const missingPrimitives: WorkflowTaskRuntimeDeps = {
|
||||
store: {
|
||||
getTaskWorkflowSelection: () => undefined,
|
||||
getWorkflowDefinition: async () => undefined,
|
||||
},
|
||||
runCustomNode: async () => ({ outcome: "success" }),
|
||||
};
|
||||
expect(missingSeams).toBeDefined();
|
||||
expect(missingPrimitives).toBeDefined();
|
||||
});
|
||||
|
||||
it("runs a selected workflow through the graph engine", async () => {
|
||||
const calls: string[] = [];
|
||||
const observed: { prepared?: PreparedWorktree } = {};
|
||||
let workflowSelectionReads = 0;
|
||||
const runtime = new WorkflowTaskRuntime({
|
||||
store: {
|
||||
@@ -65,7 +126,14 @@ describe("WorkflowTaskRuntime", () => {
|
||||
},
|
||||
getWorkflowDefinition: async () => ({ ir: selectedIr() }),
|
||||
},
|
||||
seams: recordingSeams(calls),
|
||||
primitives: recordingPrimitives(
|
||||
calls,
|
||||
{
|
||||
prepare: { outcome: "success", contextPatch: { preparedKey: "from-prepare" } },
|
||||
execute: { outcome: "success", contextPatch: { executeKey: "from-execute" } },
|
||||
},
|
||||
observed,
|
||||
),
|
||||
runCustomNode: async (node) => {
|
||||
calls.push(`custom:${node.id}`);
|
||||
return { outcome: "success" };
|
||||
@@ -75,11 +143,38 @@ describe("WorkflowTaskRuntime", () => {
|
||||
const result = await runtime.run(task, flagOff);
|
||||
|
||||
expect(result.disposition).toBe("completed");
|
||||
expect(calls).toEqual(["custom:prepare", "execute"]);
|
||||
expect(calls).toEqual(["custom:prepare", "prepare-worktree", "execute"]);
|
||||
expect(result.visitedNodeIds).toEqual(["start", "prepare", "execute"]);
|
||||
expect(observed.prepared).toEqual({ worktreePath: "/tmp/fusion-worktree" });
|
||||
expect(result.context.preparedKey).toBe("from-prepare");
|
||||
expect(result.context.executeKey).toBe("from-execute");
|
||||
expect(workflowSelectionReads).toBe(1);
|
||||
});
|
||||
|
||||
it("fails execute instead of skipping coding when prepare succeeds without worktree data", async () => {
|
||||
const calls: string[] = [];
|
||||
const runtime = new WorkflowTaskRuntime({
|
||||
store: {
|
||||
getTaskWorkflowSelection: () => ({ workflowId: "WF-001", stepIds: [] }),
|
||||
getWorkflowDefinition: async () => ({ ir: selectedIr() }),
|
||||
},
|
||||
primitives: recordingPrimitives(calls, {
|
||||
prepare: { outcome: "success", value: "prepared-without-data" },
|
||||
prepareData: null,
|
||||
}),
|
||||
runCustomNode: async (node) => {
|
||||
calls.push(`custom:${node.id}`);
|
||||
return { outcome: "success" };
|
||||
},
|
||||
});
|
||||
|
||||
const result = await runtime.run(task, flagOff);
|
||||
|
||||
expect(result.disposition).toBe("failed");
|
||||
expect(calls).toEqual(["custom:prepare", "prepare-worktree"]);
|
||||
expect(result.visitedNodeIds).toEqual(["start", "prepare", "execute"]);
|
||||
});
|
||||
|
||||
it("resolves an unselected task to the built-in coding workflow instead of falling back", async () => {
|
||||
const calls: string[] = [];
|
||||
const runtime = new WorkflowTaskRuntime({
|
||||
@@ -87,7 +182,7 @@ describe("WorkflowTaskRuntime", () => {
|
||||
getTaskWorkflowSelection: () => undefined,
|
||||
getWorkflowDefinition: async () => undefined,
|
||||
},
|
||||
seams: recordingSeams(calls),
|
||||
primitives: recordingPrimitives(calls),
|
||||
runCustomNode: async (node) => {
|
||||
calls.push(`custom:${node.id}`);
|
||||
return { outcome: "success" };
|
||||
@@ -97,60 +192,67 @@ describe("WorkflowTaskRuntime", () => {
|
||||
const result = await runtime.run(task, flagOff);
|
||||
|
||||
expect(result.disposition).toBe("completed");
|
||||
expect(calls).toEqual(["execute", "review", "merge"]);
|
||||
expect(result.visitedNodeIds).toEqual(["start", "execute", "review", "merge"]);
|
||||
expect(calls).toEqual(["planning", "prepare-worktree", "execute", "workflow-step", "review", "merge"]);
|
||||
expect(result.visitedNodeIds).toEqual(["start", "planning", "execute", "workflow-step", "review", "merge"]);
|
||||
});
|
||||
|
||||
it("turns selected workflow lookup failures into the built-in workflow target", async () => {
|
||||
it("stops the built-in workflow before review when workflow-step remediation is scheduled", async () => {
|
||||
const calls: string[] = [];
|
||||
const runtime = new WorkflowTaskRuntime({
|
||||
store: {
|
||||
getTaskWorkflowSelection: () => undefined,
|
||||
getWorkflowDefinition: async () => undefined,
|
||||
},
|
||||
primitives: recordingPrimitives(calls, {
|
||||
workflowStep: { outcome: "success", value: "remediation-scheduled" },
|
||||
}),
|
||||
runCustomNode: async (node) => {
|
||||
calls.push(`custom:${node.id}`);
|
||||
return { outcome: "success" };
|
||||
},
|
||||
});
|
||||
|
||||
const result = await runtime.run(task, flagOff);
|
||||
|
||||
expect(result.disposition).toBe("completed");
|
||||
expect(calls).toEqual(["planning", "prepare-worktree", "execute", "workflow-step"]);
|
||||
expect(result.visitedNodeIds).toEqual(["start", "planning", "execute", "workflow-step"]);
|
||||
});
|
||||
|
||||
it("fails selected workflow lookup misses instead of running the built-in workflow", async () => {
|
||||
const calls: string[] = [];
|
||||
const observedRunIds: string[] = [];
|
||||
const runtime = new WorkflowTaskRuntime({
|
||||
store: {
|
||||
getTaskWorkflowSelection: () => ({ workflowId: "WF-MISSING", stepIds: [] }),
|
||||
getWorkflowDefinition: async () => undefined,
|
||||
},
|
||||
seams: recordingSeams(calls),
|
||||
primitives: recordingPrimitives(calls),
|
||||
runCustomNode: async () => ({ outcome: "success" }),
|
||||
branchPersistence: {
|
||||
loadBranchStates: (_taskId, runId) => {
|
||||
observedRunIds.push(runId);
|
||||
return [];
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = await runtime.run(task, flagOff);
|
||||
|
||||
expect(result.disposition).toBe("completed");
|
||||
expect(calls).toEqual(["execute", "review", "merge"]);
|
||||
expect(observedRunIds).toContain("FN-9002:builtin:coding");
|
||||
expect(observedRunIds).not.toContain("FN-9002:WF-MISSING");
|
||||
expect(result.disposition).toBe("failed");
|
||||
expect(result.reason).toContain("workflow-resolution-error: workflow-missing: WF-MISSING");
|
||||
expect(calls).toEqual([]);
|
||||
});
|
||||
|
||||
it("turns corrupt selected workflow definitions into the built-in workflow target", async () => {
|
||||
it("fails corrupt selected workflow definitions instead of running the built-in workflow", async () => {
|
||||
const calls: string[] = [];
|
||||
const observedRunIds: string[] = [];
|
||||
const runtime = new WorkflowTaskRuntime({
|
||||
store: {
|
||||
getTaskWorkflowSelection: () => ({ workflowId: "WF-CORRUPT", stepIds: [] }),
|
||||
getWorkflowDefinition: async () => ({ ir: "not a workflow ir" }),
|
||||
},
|
||||
seams: recordingSeams(calls),
|
||||
primitives: recordingPrimitives(calls),
|
||||
runCustomNode: async () => ({ outcome: "success" }),
|
||||
branchPersistence: {
|
||||
loadBranchStates: (_taskId, runId) => {
|
||||
observedRunIds.push(runId);
|
||||
return [];
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = await runtime.run(task, flagOff);
|
||||
|
||||
expect(result.disposition).toBe("completed");
|
||||
expect(calls).toEqual(["execute", "review", "merge"]);
|
||||
expect(observedRunIds).toContain("FN-9002:builtin:coding");
|
||||
expect(observedRunIds).not.toContain("FN-9002:WF-CORRUPT");
|
||||
expect(result.disposition).toBe("failed");
|
||||
expect(result.reason).toContain("workflow-resolution-error:");
|
||||
expect(calls).toEqual([]);
|
||||
});
|
||||
|
||||
it("forces only the graph executor flag while preserving other settings", async () => {
|
||||
@@ -160,7 +262,7 @@ describe("WorkflowTaskRuntime", () => {
|
||||
getTaskWorkflowSelection: () => ({ workflowId: "WF-001", stepIds: [] }),
|
||||
getWorkflowDefinition: async () => ({ ir: selectedIr() }),
|
||||
},
|
||||
seams: recordingSeams([]),
|
||||
primitives: recordingPrimitives([]),
|
||||
runCustomNode: async () => ({ outcome: "success" }),
|
||||
handlers: {
|
||||
prompt: async (_node, context) => {
|
||||
@@ -189,7 +291,7 @@ describe("WorkflowTaskRuntime", () => {
|
||||
getTaskWorkflowSelection: () => ({ workflowId: "WF-001", stepIds: [] }),
|
||||
getWorkflowDefinition: async () => ({ ir: selectedIr() }),
|
||||
},
|
||||
seams: recordingSeams([]),
|
||||
primitives: recordingPrimitives([]),
|
||||
runCustomNode: async () => ({ outcome: "success" }),
|
||||
branchPersistence: {
|
||||
loadBranchStates: (_taskId, runId) => {
|
||||
@@ -211,7 +313,7 @@ describe("WorkflowTaskRuntime", () => {
|
||||
getTaskWorkflowSelection: () => undefined,
|
||||
getWorkflowDefinition: async () => undefined,
|
||||
},
|
||||
seams: recordingSeams([]),
|
||||
primitives: recordingPrimitives([]),
|
||||
runCustomNode: async () => ({ outcome: "success" }),
|
||||
branchPersistence: {
|
||||
loadBranchStates: (_taskId, runId) => {
|
||||
@@ -233,7 +335,7 @@ describe("WorkflowTaskRuntime", () => {
|
||||
getTaskWorkflowSelection: () => ({ workflowId: "WF-001", stepIds: [] }),
|
||||
getWorkflowDefinition: async () => ({ ir: selectedIr() }),
|
||||
},
|
||||
seams: recordingSeams(calls, { execute: { outcome: "failure", value: "implementation-incomplete" } }),
|
||||
primitives: recordingPrimitives(calls, { execute: { outcome: "failure", value: "implementation-incomplete" } }),
|
||||
runCustomNode: async (node) => {
|
||||
calls.push(`custom:${node.id}`);
|
||||
return { outcome: "success" };
|
||||
@@ -244,7 +346,7 @@ describe("WorkflowTaskRuntime", () => {
|
||||
|
||||
expect(result.disposition).toBe("failed");
|
||||
expect(result.outcome).toBe("failure");
|
||||
expect(calls).toEqual(["custom:prepare", "execute"]);
|
||||
expect(calls).toEqual(["custom:prepare", "prepare-worktree", "execute"]);
|
||||
});
|
||||
|
||||
it("converts interpreter throws into workflow-engine failures", async () => {
|
||||
@@ -262,7 +364,7 @@ describe("WorkflowTaskRuntime", () => {
|
||||
getTaskWorkflowSelection: () => ({ workflowId: "WF-001", stepIds: [] }),
|
||||
getWorkflowDefinition: async () => ({ ir: badIr }),
|
||||
},
|
||||
seams: recordingSeams([]),
|
||||
primitives: recordingPrimitives([]),
|
||||
runCustomNode: async () => ({ outcome: "success" }),
|
||||
});
|
||||
|
||||
@@ -292,7 +394,7 @@ describe("WorkflowTaskRuntime", () => {
|
||||
getTaskWorkflowSelection: () => ({ workflowId: "WF-001", stepIds: [] }),
|
||||
getWorkflowDefinition: async () => ({ ir: cyclicIr }),
|
||||
},
|
||||
seams: recordingSeams([]),
|
||||
primitives: recordingPrimitives([]),
|
||||
runCustomNode: async () => ({ outcome: "success" }),
|
||||
});
|
||||
|
||||
@@ -309,7 +411,7 @@ describe("WorkflowTaskRuntime", () => {
|
||||
getTaskWorkflowSelection: () => ({ workflowId: "WF-001", stepIds: [] }),
|
||||
getWorkflowDefinition: async () => ({ ir: selectedIr() }),
|
||||
},
|
||||
seams: recordingSeams([]),
|
||||
primitives: recordingPrimitives([]),
|
||||
runCustomNode: async () => ({ outcome: "success" }),
|
||||
onEvent: () => {
|
||||
throw new Error("diagnostics failed");
|
||||
|
||||
@@ -9,7 +9,7 @@ import { delimiter, isAbsolute, join, relative, resolve as resolvePath } from "n
|
||||
import { existsSync, realpathSync } from "node:fs";
|
||||
import { readFile, rm, writeFile } from "node:fs/promises";
|
||||
import type { TaskStore, Task, TaskDetail, TaskTokenUsage, StepStatus, Settings, WorkflowStep, MissionStore, Slice, AgentState, AgentCapability, RunMutationContext, AgentHeartbeatConfig, Agent, AgentMemoryInclusionMode, ProjectSettings, MergeResult, WorkflowIrNode } from "@fusion/core";
|
||||
import { RetryStormError, TaskDeletedError, serializeRetryStormError, isExperimentalFeatureEnabled, isWorkflowColumnsEnabled, resolveWorkflowIrForTask, resolveColumnAgentBinding, resolveEffectiveAgent, instanceNodeId, getWorkflowExtensionRegistry } from "@fusion/core";
|
||||
import { RetryStormError, TaskDeletedError, serializeRetryStormError, isExperimentalFeatureEnabled, isWorkflowColumnsEnabled, resolveWorkflowIrForTask, resolveColumnAgentBinding, resolveEffectiveAgent, instanceNodeId, getWorkflowExtensionRegistry, getBuiltinWorkflow } from "@fusion/core";
|
||||
import { mergeEffectiveSettings } from "./effective-settings.js";
|
||||
import type { TaskStep, WorkflowIr, WorkflowFieldDefinition, WorkflowColumnAgent, EffectiveAgentInput, WorkflowWorkEngineDispatchResult } from "@fusion/core";
|
||||
import {
|
||||
@@ -30,10 +30,17 @@ import { observeWorkflowParity, WORKFLOW_INTERPRETER_DUAL_OBSERVE_FLAG } from ".
|
||||
import {
|
||||
FOREACH_ACTIVE_CONTEXT_KEY,
|
||||
SEAM_GOVERNING_NODE_CONTEXT_KEY,
|
||||
SPLIT_ACTIVE_CONTEXT_KEY,
|
||||
type ForeachActiveContext,
|
||||
type WorkflowLegacySeams,
|
||||
} from "./workflow-node-handlers.js";
|
||||
import type { WorkflowNodeResult } from "./workflow-graph-executor.js";
|
||||
import type {
|
||||
AuditPrimitiveInput,
|
||||
PreparedWorktree,
|
||||
WorkflowPrimitiveContext,
|
||||
WorkflowRuntimePrimitives,
|
||||
} from "./runtime-primitives.js";
|
||||
import {
|
||||
ApprovalRequestStore,
|
||||
buildExecutionMemoryInstructions,
|
||||
@@ -3630,9 +3637,9 @@ export class TaskExecutor {
|
||||
private static processWideGraphRouting = new Set<string>();
|
||||
|
||||
/** Wired by the runtime to ProjectEngine.onMerge — resolves with the merge outcome. */
|
||||
private mergeRequester?: (taskId: string) => Promise<MergeResult>;
|
||||
private mergeRequester?: (taskId: string, options?: { signal?: AbortSignal }) => Promise<MergeResult>;
|
||||
|
||||
setMergeRequester(requestMerge: (taskId: string) => Promise<MergeResult>): void {
|
||||
setMergeRequester(requestMerge: (taskId: string, options?: { signal?: AbortSignal }) => Promise<MergeResult>): void {
|
||||
this.mergeRequester = requestMerge;
|
||||
}
|
||||
|
||||
@@ -3649,19 +3656,38 @@ export class TaskExecutor {
|
||||
let settings: Settings;
|
||||
try {
|
||||
settings = await this.store.getSettings();
|
||||
} catch {
|
||||
return false;
|
||||
} catch (err) {
|
||||
await this.handleGraphFailure(task, {
|
||||
disposition: "failed",
|
||||
outcome: "failure",
|
||||
reason: `settings-load-failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||
visitedNodeIds: [],
|
||||
});
|
||||
return true;
|
||||
}
|
||||
if (!isExperimentalFeatureEnabled(settings, "workflowGraphExecutor")) return false;
|
||||
if (typeof this.store.getTaskWorkflowSelection !== "function") return false;
|
||||
|
||||
const hasWorkflowResolver = typeof this.store.getTaskWorkflowSelection === "function";
|
||||
const explicitlyEnabled = isExperimentalFeatureEnabled(settings, "workflowGraphExecutor");
|
||||
if (!hasWorkflowResolver && !explicitlyEnabled) return false;
|
||||
settings = {
|
||||
...settings,
|
||||
experimentalFeatures: {
|
||||
...(settings.experimentalFeatures ?? {}),
|
||||
workflowGraphExecutor: true,
|
||||
},
|
||||
};
|
||||
let selection: { workflowId: string; stepIds: string[] } | undefined;
|
||||
try {
|
||||
selection = this.store.getTaskWorkflowSelection(task.id);
|
||||
} catch {
|
||||
return false;
|
||||
selection = this.store.getTaskWorkflowSelection?.(task.id);
|
||||
} catch (err) {
|
||||
await this.handleGraphFailure(task, {
|
||||
disposition: "failed",
|
||||
outcome: "failure",
|
||||
reason: `workflow-selection-failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||
visitedNodeIds: [],
|
||||
});
|
||||
return true;
|
||||
}
|
||||
if (!selection) return false;
|
||||
selection ??= { workflowId: "builtin:coding", stepIds: [] };
|
||||
|
||||
// Resolve the production run id ONCE, here, so it is the single source of
|
||||
// truth shared by the runner AND the executor-side persistence deps
|
||||
@@ -3673,7 +3699,9 @@ export class TaskExecutor {
|
||||
// prior behavior — so this never strands a task.
|
||||
let resolvedRunId: string | undefined;
|
||||
try {
|
||||
const definition = await this.store.getWorkflowDefinition?.(selection.workflowId);
|
||||
const definition = selection.workflowId === "builtin:coding"
|
||||
? { id: "builtin:coding" }
|
||||
: await this.store.getWorkflowDefinition?.(selection.workflowId);
|
||||
if (definition) resolvedRunId = `${task.id}:${definition.id}`;
|
||||
} catch {
|
||||
// Definition load failure — leave undefined; deps/runner use fallbacks.
|
||||
@@ -3710,8 +3738,16 @@ export class TaskExecutor {
|
||||
}
|
||||
|
||||
const runner = new WorkflowGraphTaskRunner({
|
||||
store: this.store,
|
||||
store: {
|
||||
...this.store,
|
||||
getTaskWorkflowSelection: (taskId: string) =>
|
||||
this.store.getTaskWorkflowSelection?.(taskId) ?? { workflowId: "builtin:coding", stepIds: [] },
|
||||
getWorkflowDefinition: async (id: string) =>
|
||||
(await this.store.getWorkflowDefinition?.(id))
|
||||
?? (id === "builtin:coding" ? getBuiltinWorkflow("builtin:coding") : undefined),
|
||||
},
|
||||
runId: resolvedRunId,
|
||||
primitives: this.createAuthoritativeWorkflowPrimitives(settings),
|
||||
seams: this.createAuthoritativeWorkflowSeams(settings),
|
||||
runCustomNode: (node, nodeTask) =>
|
||||
this.runGraphCustomNode(node, nodeTask, settings, resolveBindingForNode(node.id)),
|
||||
@@ -3758,16 +3794,26 @@ export class TaskExecutor {
|
||||
const detail = await this.store.getTask(task.id);
|
||||
result = await runner.run(detail, settings);
|
||||
} catch (err) {
|
||||
// A thrown interpreter error must not strand the task in-progress: fall
|
||||
// back to the legacy pipeline so the normal executor lock + flow runs.
|
||||
executorLog.error(
|
||||
`[workflow-graph] ${task.id} interpreter threw — falling back to legacy pipeline: ${err instanceof Error ? err.message : String(err)}`,
|
||||
`[workflow-graph] ${task.id} interpreter threw — parking task as workflow failure: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
return false;
|
||||
await this.handleGraphFailure(task, {
|
||||
disposition: "failed",
|
||||
outcome: "failure",
|
||||
reason: `interpreter-error: ${err instanceof Error ? err.message : String(err)}`,
|
||||
visitedNodeIds: [],
|
||||
});
|
||||
return true;
|
||||
}
|
||||
if (result.disposition === "fell-back") {
|
||||
executorLog.log(`[workflow-graph] ${task.id} fell back to legacy pipeline: ${result.reason}`);
|
||||
return false;
|
||||
executorLog.warn(`[workflow-graph] ${task.id} could not resolve workflow — parking task instead of legacy fallback: ${result.reason}`);
|
||||
await this.handleGraphFailure(task, {
|
||||
...result,
|
||||
disposition: "failed",
|
||||
outcome: "failure",
|
||||
reason: result.reason ?? "workflow-resolution-failed",
|
||||
});
|
||||
return true;
|
||||
}
|
||||
if (result.disposition === "failed") {
|
||||
await this.handleGraphFailure(task, result);
|
||||
@@ -4415,13 +4461,23 @@ export class TaskExecutor {
|
||||
* interceptor makes execute() stop at the completion boundary instead of
|
||||
* running workflow steps and the review handoff.
|
||||
*/
|
||||
private async runImplementationPhase(task: Task): Promise<{ taskDone: boolean; modifiedFiles: string[] }> {
|
||||
private async runImplementationPhase(
|
||||
task: Task,
|
||||
prepared?: PreparedWorktree,
|
||||
): Promise<{ taskDone: boolean; modifiedFiles: string[] }> {
|
||||
let captured: { taskDone: boolean; modifiedFiles: string[] } = { taskDone: false, modifiedFiles: [] };
|
||||
this.graphCompletionInterceptors.set(task.id, (info) => {
|
||||
captured = { taskDone: true, modifiedFiles: info.modifiedFiles };
|
||||
});
|
||||
const executionTask = prepared
|
||||
? {
|
||||
...task,
|
||||
worktree: prepared.worktreePath || task.worktree,
|
||||
branch: prepared.branchName || task.branch,
|
||||
}
|
||||
: task;
|
||||
try {
|
||||
await this.execute(task);
|
||||
await this.execute(executionTask);
|
||||
} finally {
|
||||
this.graphCompletionInterceptors.delete(task.id);
|
||||
}
|
||||
@@ -4553,6 +4609,304 @@ export class TaskExecutor {
|
||||
|
||||
/** Public authoritative-driver seam factory: exposes the same real lifecycle
|
||||
* seams the internal graph runner uses, without changing legacy behavior. */
|
||||
public createAuthoritativeWorkflowPrimitives(settings: Settings): WorkflowRuntimePrimitives {
|
||||
const logAudit = async (taskId: string | undefined, input: AuditPrimitiveInput): Promise<void> => {
|
||||
if (!taskId) return;
|
||||
try {
|
||||
await this.store.logEntry(taskId, input.message, input.metadata ? JSON.stringify(input.metadata) : undefined);
|
||||
} catch {
|
||||
// Audit is diagnostic-only and must not affect workflow execution.
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
prepareWorktree: async (_ctx, task) => {
|
||||
const live = await this.store.getTask(task.id);
|
||||
const prepared: PreparedWorktree = {
|
||||
worktreePath: live.worktree || this.rootDir,
|
||||
branchName: live.branch,
|
||||
};
|
||||
return { outcome: "success", value: "worktree-ready", data: prepared };
|
||||
},
|
||||
readArtifact: async (_ctx, task, key) => {
|
||||
const deps = this.buildParseStepsDeps(`${task.id}:artifact-read`);
|
||||
return deps.readArtifact(task, key);
|
||||
},
|
||||
writeArtifact: async (ctx, task, key, content) => {
|
||||
const writer = (this.store as unknown as {
|
||||
writeTaskDocument?: (taskId: string, key: string, content: string) => Promise<void>;
|
||||
}).writeTaskDocument;
|
||||
if (!writer) {
|
||||
await logAudit(task.id, {
|
||||
type: "artifact-write-unavailable",
|
||||
message: `Workflow node ${ctx.node.node.id} could not write artifact ${key}: store writer unavailable`,
|
||||
});
|
||||
return { outcome: "failure", value: "artifact-write-unavailable" };
|
||||
}
|
||||
await writer.call(this.store, task.id, key, content);
|
||||
return { outcome: "success", value: "artifact-written", data: { key } };
|
||||
},
|
||||
runPlanningSession: async () => ({ outcome: "success", value: "pre-specified", data: {
|
||||
approved: true,
|
||||
artifactKeys: [],
|
||||
} }),
|
||||
runCodingSession: async (ctx, task, prepared) => {
|
||||
const governingNodeId = ctx.node.context?.[SEAM_GOVERNING_NODE_CONTEXT_KEY];
|
||||
if (typeof governingNodeId === "string") {
|
||||
this.graphSeamGoverningNodeId.set(task.id, governingNodeId);
|
||||
}
|
||||
let result: { taskDone: boolean; modifiedFiles: string[] };
|
||||
try {
|
||||
result = await this.runImplementationPhase(task, prepared);
|
||||
} finally {
|
||||
this.graphSeamGoverningNodeId.delete(task.id);
|
||||
}
|
||||
if (result.taskDone) {
|
||||
return { outcome: "success", value: "implemented", data: result };
|
||||
}
|
||||
let paused = this.pausedAborted.has(task.id);
|
||||
if (!paused) {
|
||||
try {
|
||||
paused = Boolean((await this.store.getTask(task.id)).paused);
|
||||
} catch {
|
||||
// Best-effort pause probe; fall through to the failure value.
|
||||
}
|
||||
}
|
||||
return {
|
||||
outcome: "failure",
|
||||
value: paused ? "implementation-paused" : "implementation-incomplete",
|
||||
data: result,
|
||||
};
|
||||
},
|
||||
runTaskStep: async (ctx, task, stepIndex) => {
|
||||
const context = ctx.node.context ?? {};
|
||||
const active = context[FOREACH_ACTIVE_CONTEXT_KEY] as ForeachActiveContext | undefined;
|
||||
if (!active || typeof active.stepIndex !== "number") {
|
||||
return { outcome: "failure" };
|
||||
}
|
||||
const live = await this.store.getTask(task.id);
|
||||
const worktreePath = active.worktreePath || live.worktree || this.rootDir;
|
||||
this.graphStepActiveContext.set(this.graphActiveContextKey(task.id, active.instanceId), active);
|
||||
const stepGoverningNodeId = context[SEAM_GOVERNING_NODE_CONTEXT_KEY];
|
||||
return await runTaskStep(
|
||||
{
|
||||
store: this.store,
|
||||
worktreePath,
|
||||
runStep: (idx) =>
|
||||
this.runGraphTaskStep(
|
||||
task,
|
||||
idx,
|
||||
active.instanceId,
|
||||
typeof stepGoverningNodeId === "string" ? stepGoverningNodeId : undefined,
|
||||
),
|
||||
},
|
||||
{ id: task.id, steps: live.steps },
|
||||
stepIndex,
|
||||
{ markDoneOnSuccess: active.deferDoneToReview !== true },
|
||||
);
|
||||
},
|
||||
resetTaskStep: async (ctx, task, stepIndex, baselineSha, checkpointId) => {
|
||||
const active = ctx.node.context?.[FOREACH_ACTIVE_CONTEXT_KEY] as ForeachActiveContext | undefined;
|
||||
const branchScoped = typeof active?.worktreePath === "string" && active.worktreePath.length > 0;
|
||||
let worktreePath = active?.worktreePath ?? this.rootDir;
|
||||
if (!branchScoped) {
|
||||
try {
|
||||
worktreePath = (await this.store.getTask(task.id)).worktree || this.rootDir;
|
||||
} catch {
|
||||
// Best-effort worktree resolution; fall back to rootDir.
|
||||
}
|
||||
}
|
||||
const liveSteps = await this.store.getTask(task.id).then((t) => t.steps).catch(() => []);
|
||||
return await resetStepToBaseline(
|
||||
{
|
||||
store: this.store,
|
||||
worktreePath,
|
||||
sessionRef: { current: null },
|
||||
reviewType: "code",
|
||||
blastRadiusGuard: branchScoped
|
||||
? undefined
|
||||
: makeAncestryBlastRadiusGuard({
|
||||
worktreePath,
|
||||
task: { id: task.id, steps: liveSteps },
|
||||
stepIndex,
|
||||
}),
|
||||
},
|
||||
{ id: task.id, steps: liveSteps },
|
||||
stepIndex,
|
||||
baselineSha,
|
||||
checkpointId,
|
||||
);
|
||||
},
|
||||
runReview: async (ctx, task, input) => {
|
||||
if (typeof input.stepIndex === "number") {
|
||||
const context = ctx.node.context ?? {};
|
||||
const active = context[FOREACH_ACTIVE_CONTEXT_KEY] as ForeachActiveContext | undefined;
|
||||
if (!active || typeof active.stepIndex !== "number") {
|
||||
return {
|
||||
outcome: "success",
|
||||
value: "unavailable",
|
||||
data: { verdict: "UNAVAILABLE", review: "no active step instance" },
|
||||
};
|
||||
}
|
||||
const config = {
|
||||
type: input.type,
|
||||
advisory: context[SPLIT_ACTIVE_CONTEXT_KEY] === true,
|
||||
} as const;
|
||||
const seamResult = await this.createAuthoritativeWorkflowSeams(settings).stepReview?.(
|
||||
task,
|
||||
context,
|
||||
config,
|
||||
);
|
||||
return {
|
||||
outcome: "success",
|
||||
value: seamResult?.verdict === "APPROVE" ? "approve" : seamResult?.verdict === "REVISE" ? "revise" : seamResult?.verdict === "RETHINK" ? "rethink" : "unavailable",
|
||||
data: seamResult ?? { verdict: "UNAVAILABLE", review: "step review unavailable" },
|
||||
};
|
||||
}
|
||||
const live = await this.store.getTask(task.id);
|
||||
await this.persistTokenUsage(task.id);
|
||||
await this.handoffTaskToReview(live, "workflow-graph-review");
|
||||
return {
|
||||
outcome: "success",
|
||||
value: "in-review",
|
||||
data: { verdict: "APPROVE", summary: "Task handed off for merge review" },
|
||||
};
|
||||
},
|
||||
runVerification: async () => ({ outcome: "success", value: "verification-skipped", data: {
|
||||
verdict: "skipped",
|
||||
} }),
|
||||
runWorkflowStep: async (_ctx, task, input) => {
|
||||
if (input.phase !== "pre-merge") {
|
||||
return { outcome: "success", value: "workflow-step-skipped", data: { allPassed: true } };
|
||||
}
|
||||
const live = await this.store.getTask(task.id);
|
||||
if (live.executionMode === "fast") {
|
||||
executorLog.log(`${task.id}: fast mode — skipping pre-merge workflow steps`);
|
||||
await this.store.logEntry(task.id, "Fast mode — pre-merge workflow steps skipped", undefined, this.getRunContextFor(task.id));
|
||||
return { outcome: "success", value: "workflow-step-skipped", data: { allPassed: true } };
|
||||
}
|
||||
if (await this.shouldDeferCompletionForGlobalPause(task.id, "before workflow steps after task completion")) {
|
||||
return { outcome: "success", value: "deferred-paused", data: { allPassed: false } };
|
||||
}
|
||||
const worktreePath = input.worktreePath || live.worktree || this.rootDir;
|
||||
const workflowResult = await this.runWorkflowSteps(live, worktreePath, settings, undefined);
|
||||
if (workflowResult === "deferred-paused") {
|
||||
if (await this.parkTaskAfterWorkflowStepPause(task.id)) {
|
||||
this.pausedAborted.delete(task.id);
|
||||
} else if (this.pausedAborted.has(task.id)) {
|
||||
this.pausedAborted.delete(task.id);
|
||||
}
|
||||
return { outcome: "success", value: "deferred-paused", data: { allPassed: false } };
|
||||
}
|
||||
if (!workflowResult.allPassed) {
|
||||
const feedback = workflowResult.feedback || "Workflow step failed";
|
||||
const stepName = workflowResult.stepName || "Unknown";
|
||||
if (workflowResult.revisionRequested) {
|
||||
const rerunScheduled = await this.handleWorkflowRevisionRequest(
|
||||
live,
|
||||
worktreePath,
|
||||
feedback,
|
||||
stepName,
|
||||
settings,
|
||||
);
|
||||
if (!rerunScheduled) {
|
||||
return {
|
||||
outcome: "failure",
|
||||
value: "workflow-step-revision-unhandled",
|
||||
data: workflowResult,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
const retried = await this.handleWorkflowStepFailure(
|
||||
live,
|
||||
worktreePath,
|
||||
feedback,
|
||||
stepName,
|
||||
);
|
||||
if (!retried) {
|
||||
await this.sendTaskBackForFix(
|
||||
live,
|
||||
worktreePath,
|
||||
feedback,
|
||||
stepName,
|
||||
"Workflow step failed",
|
||||
);
|
||||
}
|
||||
}
|
||||
return { outcome: "success", value: "remediation-scheduled", data: workflowResult };
|
||||
}
|
||||
await this.store.updateTask(task.id, { workflowStepRetries: undefined, taskDoneRetryCount: null });
|
||||
return { outcome: "success", value: "workflow-steps-passed", data: workflowResult };
|
||||
},
|
||||
updateSteps: async (_ctx, task, steps) => {
|
||||
await this.store.updateTask(task.id, { steps });
|
||||
return { outcome: "success", value: "steps-updated", data: { count: steps.length } };
|
||||
},
|
||||
transitionTask: async (_ctx, task, input) => {
|
||||
const patch: Partial<TaskDetail> = {};
|
||||
if (input.column !== undefined) patch.column = input.column;
|
||||
if (input.status !== undefined && input.status !== null) patch.status = input.status;
|
||||
if (Object.keys(patch).length > 0) {
|
||||
await this.store.updateTask(task.id, patch);
|
||||
}
|
||||
return { outcome: "success", value: input.reason };
|
||||
},
|
||||
requestMerge: async (ctx, task) => {
|
||||
if (!this.mergeRequester) {
|
||||
return { outcome: "failure", value: "merge-unavailable", data: { status: "failed", reason: "merge-unavailable" } };
|
||||
}
|
||||
const GRAPH_MERGE_TIMEOUT_MS = 30 * 60 * 1000;
|
||||
const controller = new AbortController();
|
||||
let timeoutHandle: ReturnType<typeof setTimeout> | undefined;
|
||||
const timeout = new Promise<"timeout">((resolve) => {
|
||||
timeoutHandle = setTimeout(() => {
|
||||
controller.abort();
|
||||
resolve("timeout");
|
||||
}, GRAPH_MERGE_TIMEOUT_MS);
|
||||
timeoutHandle.unref?.();
|
||||
});
|
||||
try {
|
||||
const result = await Promise.race([this.mergeRequester(task.id, { signal: controller.signal }), timeout]);
|
||||
if (result === "timeout") {
|
||||
executorLog.warn(`${task.id}: workflow merge primitive timed out after ${GRAPH_MERGE_TIMEOUT_MS}ms`);
|
||||
return { outcome: "failure", value: "merge-timeout", data: { status: "timeout" } };
|
||||
}
|
||||
if (result.merged || result.noOp) {
|
||||
return {
|
||||
outcome: "success",
|
||||
value: result.noOp ? "merge-noop" : "merged",
|
||||
data: { status: "merged", noOp: result.noOp },
|
||||
};
|
||||
}
|
||||
return {
|
||||
outcome: "failure",
|
||||
value: result.reason ?? result.error ?? "merge-failed",
|
||||
data: { status: "failed", reason: result.reason ?? result.error ?? "merge-failed" },
|
||||
};
|
||||
} finally {
|
||||
if (timeoutHandle) clearTimeout(timeoutHandle);
|
||||
await logAudit(task.id, {
|
||||
type: "merge-requested",
|
||||
message: `Workflow node ${ctx.node.node.id} requested merge`,
|
||||
});
|
||||
}
|
||||
},
|
||||
abortRun: async (_ctx, task, input) => {
|
||||
if (input.hardCancel) {
|
||||
this.pausedAborted.add(task.id);
|
||||
}
|
||||
await this.store.updateTask(task.id, {
|
||||
paused: true,
|
||||
pausedReason: input.reason,
|
||||
} as Partial<TaskDetail>);
|
||||
return { outcome: "success", value: "aborted" };
|
||||
},
|
||||
audit: async (ctx: WorkflowPrimitiveContext, input) => {
|
||||
await logAudit(ctx.run.taskId, input);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
public createAuthoritativeWorkflowSeams(_settings: Settings): WorkflowLegacySeams {
|
||||
return {
|
||||
// Built-in triage/spec generation runs upstream of the interpreter today,
|
||||
@@ -4593,6 +4947,58 @@ export class TaskExecutor {
|
||||
value: paused ? "implementation-paused" : "implementation-incomplete",
|
||||
};
|
||||
},
|
||||
workflowStep: async (seamTask) => {
|
||||
const live = await this.store.getTask(seamTask.id);
|
||||
if (live.executionMode === "fast") {
|
||||
executorLog.log(`${seamTask.id}: fast mode — skipping pre-merge workflow steps`);
|
||||
await this.store.logEntry(seamTask.id, "Fast mode — pre-merge workflow steps skipped", undefined, this.getRunContextFor(seamTask.id));
|
||||
return { outcome: "success", value: "workflow-step-skipped" };
|
||||
}
|
||||
const worktreePath = live.worktree || this.rootDir;
|
||||
const settings = await this.store.getSettings();
|
||||
const workflowResult = await this.runWorkflowSteps(live, worktreePath, settings, undefined);
|
||||
if (workflowResult === "deferred-paused") {
|
||||
if (await this.parkTaskAfterWorkflowStepPause(seamTask.id)) {
|
||||
this.pausedAborted.delete(seamTask.id);
|
||||
} else if (this.pausedAborted.has(seamTask.id)) {
|
||||
this.pausedAborted.delete(seamTask.id);
|
||||
}
|
||||
return { outcome: "success", value: "deferred-paused" };
|
||||
}
|
||||
if (!workflowResult.allPassed) {
|
||||
const feedback = workflowResult.feedback || "Workflow step failed";
|
||||
const stepName = workflowResult.stepName || "Unknown";
|
||||
if (workflowResult.revisionRequested) {
|
||||
const rerunScheduled = await this.handleWorkflowRevisionRequest(
|
||||
live,
|
||||
worktreePath,
|
||||
feedback,
|
||||
stepName,
|
||||
settings,
|
||||
);
|
||||
if (!rerunScheduled) return { outcome: "failure", value: "workflow-step-revision-unhandled" };
|
||||
} else {
|
||||
const retried = await this.handleWorkflowStepFailure(
|
||||
live,
|
||||
worktreePath,
|
||||
feedback,
|
||||
stepName,
|
||||
);
|
||||
if (!retried) {
|
||||
await this.sendTaskBackForFix(
|
||||
live,
|
||||
worktreePath,
|
||||
feedback,
|
||||
stepName,
|
||||
"Workflow step failed",
|
||||
);
|
||||
}
|
||||
}
|
||||
return { outcome: "success", value: "remediation-scheduled" };
|
||||
}
|
||||
await this.store.updateTask(seamTask.id, { workflowStepRetries: undefined, taskDoneRetryCount: null });
|
||||
return { outcome: "success", value: "workflow-steps-passed" };
|
||||
},
|
||||
review: async (seamTask) => {
|
||||
// The legacy "review" stage is the in-review handoff: per-step AI review
|
||||
// already ran during implementation (fn_review_step), and the in-review
|
||||
@@ -5710,10 +6116,10 @@ export class TaskExecutor {
|
||||
executorLog.log(`execute() called for ${task.id} while graph routing is active — skipping duplicate`);
|
||||
return;
|
||||
}
|
||||
const authoritativeOwned = await this.options.workflowAuthoritativeDispatch?.(task);
|
||||
if (authoritativeOwned) return;
|
||||
const graphOwned = await this.maybeExecuteWorkflowGraph(task);
|
||||
if (graphOwned) return;
|
||||
const authoritativeOwned = await this.options.workflowAuthoritativeDispatch?.(task);
|
||||
if (authoritativeOwned) return;
|
||||
}
|
||||
|
||||
// FN-4811 follow-up (FN-4814/FN-4809/FN-4811 production failure): claim a
|
||||
|
||||
@@ -40,10 +40,14 @@ export {
|
||||
} from "./workflow-graph-branches.js";
|
||||
export {
|
||||
createDefaultNodeHandlers,
|
||||
createPrimitivePromptLikeHandler,
|
||||
createPrimitiveStepReviewHandler,
|
||||
createNoopLegacySeams,
|
||||
createParseStepsHandler,
|
||||
createCodeNodeHandler,
|
||||
PARSE_STEPS_DEFAULT_ARTIFACT,
|
||||
WORKFLOW_ID_CONTEXT_KEY,
|
||||
WORKFLOW_RUN_ID_CONTEXT_KEY,
|
||||
type WorkflowCustomNodeRunner,
|
||||
type WorkflowLegacySeams,
|
||||
type WorkflowSeamName,
|
||||
@@ -51,6 +55,28 @@ export {
|
||||
type CodeNodeRunner,
|
||||
type DefaultNodeHandlerDeps,
|
||||
} from "./workflow-node-handlers.js";
|
||||
export {
|
||||
markSideEffectsStarted,
|
||||
primitiveNodeContext,
|
||||
type RuntimePrimitiveName,
|
||||
type WorkflowRuntimeRunContext,
|
||||
type WorkflowRuntimeNodeContext,
|
||||
type WorkflowPrimitiveContext,
|
||||
type RuntimePrimitiveResult,
|
||||
type PreparedWorktree,
|
||||
type PlanningSessionResult,
|
||||
type CodingSessionResult,
|
||||
type ReviewPrimitiveResult,
|
||||
type VerificationPrimitiveResult,
|
||||
type WorkflowStepPrimitiveInput,
|
||||
type WorkflowStepPrimitiveResult,
|
||||
type TransitionPrimitiveInput,
|
||||
type MergePrimitiveInput,
|
||||
type MergePrimitiveResult,
|
||||
type AbortPrimitiveInput,
|
||||
type AuditPrimitiveInput,
|
||||
type WorkflowRuntimePrimitives,
|
||||
} from "./runtime-primitives.js";
|
||||
export {
|
||||
createPrNodeHandlers,
|
||||
createAutoMergeGateHandler,
|
||||
|
||||
@@ -331,6 +331,14 @@ export class ProjectEngine {
|
||||
else this.manualMergeResolvers.set(taskId, [r]);
|
||||
}
|
||||
|
||||
private removeMergeResolver(taskId: string, resolver: MergeResolver): void {
|
||||
const list = this.manualMergeResolvers.get(taskId);
|
||||
if (!list) return;
|
||||
const next = list.filter((candidate) => candidate !== resolver);
|
||||
if (next.length > 0) this.manualMergeResolvers.set(taskId, next);
|
||||
else this.manualMergeResolvers.delete(taskId);
|
||||
}
|
||||
|
||||
/** Remove and return all waiters for a task (empty array if none). */
|
||||
private takeMergeResolvers(taskId: string): MergeResolver[] {
|
||||
const list = this.manualMergeResolvers.get(taskId);
|
||||
@@ -426,7 +434,7 @@ export class ProjectEngine {
|
||||
// Workflow-graph interpreter merge seam: routes through the auto-merge
|
||||
// eligibility gate (requestInterpreterMerge), NOT the human "merge now"
|
||||
// bypass, so a graph merge node can't override an autoMerge-off project.
|
||||
this.runtime.setMergeRequester?.((taskId) => this.requestInterpreterMerge(taskId));
|
||||
this.runtime.setMergeRequester?.((taskId, options) => this.requestInterpreterMerge(taskId, options));
|
||||
}
|
||||
|
||||
getActiveMergeTaskId(): string | null {
|
||||
@@ -1072,21 +1080,56 @@ export class ProjectEngine {
|
||||
* Returns the full MergeResult so it can be used as the `onMerge` callback
|
||||
* in createServer().
|
||||
*/
|
||||
async onMerge(taskId: string): Promise<MergeResult> {
|
||||
// If this task is already queued or actively merging, wait for the
|
||||
// existing merge to finish rather than starting a second one.
|
||||
if (this.mergeActive.has(taskId)) {
|
||||
return new Promise<MergeResult>((resolve, reject) => {
|
||||
this.addMergeResolver(taskId, { resolve, reject });
|
||||
// Don't re-enqueue — the task is already in the queue/active
|
||||
});
|
||||
async onMerge(taskId: string, options: { signal?: AbortSignal } = {}): Promise<MergeResult> {
|
||||
const signal = options.signal;
|
||||
if (signal?.aborted) {
|
||||
throw new Error(`Merge request for ${taskId} aborted`);
|
||||
}
|
||||
|
||||
return new Promise<MergeResult>((resolve, reject) => {
|
||||
this.addMergeResolver(taskId, { resolve, reject });
|
||||
let settled = false;
|
||||
let abort: () => void = () => undefined;
|
||||
const cleanup = () => {
|
||||
signal?.removeEventListener("abort", abort);
|
||||
};
|
||||
const resolver: MergeResolver = {
|
||||
resolve: (result) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
cleanup();
|
||||
resolve(result);
|
||||
},
|
||||
reject: (err) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
cleanup();
|
||||
reject(err);
|
||||
},
|
||||
};
|
||||
abort = () => {
|
||||
this.removeMergeResolver(taskId, resolver);
|
||||
if (this.activeMergeTaskId === taskId) {
|
||||
this.mergeAbortController?.abort();
|
||||
this.mergeAbortController = null;
|
||||
this.activeMergeSession?.dispose();
|
||||
this.activeMergeSession = null;
|
||||
} else if (!this.hasMergeResolvers(taskId)) {
|
||||
this.mergeQueue = this.mergeQueue.filter((queuedTaskId) => queuedTaskId !== taskId);
|
||||
this.mergeActive.delete(taskId);
|
||||
}
|
||||
resolver.reject(new Error(`Merge request for ${taskId} aborted`));
|
||||
};
|
||||
|
||||
signal?.addEventListener("abort", abort, { once: true });
|
||||
this.addMergeResolver(taskId, resolver);
|
||||
|
||||
// If this task is already queued or actively merging, wait for the
|
||||
// existing merge to finish rather than starting a second one.
|
||||
if (this.mergeActive.has(taskId)) return;
|
||||
|
||||
if (!this.internalEnqueueMerge(taskId)) {
|
||||
// Drop just-added waiter(s) for this task and fail them.
|
||||
this.rejectMergeResolvers(taskId, new Error(`Merge enqueue rejected for ${taskId}`));
|
||||
this.removeMergeResolver(taskId, resolver);
|
||||
resolver.reject(new Error(`Merge enqueue rejected for ${taskId}`));
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1099,7 +1142,7 @@ export class ProjectEngine {
|
||||
* it as "manual merge required" and parks the task in review — preserving the
|
||||
* contract that autoMerge-off leaves in-review terminal until a human merges.
|
||||
*/
|
||||
async requestInterpreterMerge(taskId: string): Promise<MergeResult> {
|
||||
async requestInterpreterMerge(taskId: string, options: { signal?: AbortSignal } = {}): Promise<MergeResult> {
|
||||
let task: Task | null = null;
|
||||
let settings: Settings | undefined;
|
||||
try {
|
||||
@@ -1135,7 +1178,7 @@ export class ProjectEngine {
|
||||
} as MergeResult;
|
||||
}
|
||||
// Eligible: route through the normal serialized merge path.
|
||||
return this.onMerge(taskId);
|
||||
return this.onMerge(taskId, options);
|
||||
}
|
||||
|
||||
private setRestoreDiagnostics(
|
||||
|
||||
238
packages/engine/src/runtime-primitives.ts
Normal file
238
packages/engine/src/runtime-primitives.ts
Normal file
@@ -0,0 +1,238 @@
|
||||
import type { TaskDetail, TaskStep, WorkflowIrNode } from "@fusion/core";
|
||||
|
||||
import type { PrMergeCallResult } from "./pr-nodes.js";
|
||||
import type { RunTaskStepResult, ResetStepResult } from "./step-runner.js";
|
||||
import type { WorkflowNodeOutcome } from "./workflow-graph-executor.js";
|
||||
|
||||
export type RuntimePrimitiveName =
|
||||
| "prepare-worktree"
|
||||
| "read-artifact"
|
||||
| "write-artifact"
|
||||
| "planning-session"
|
||||
| "coding-session"
|
||||
| "step-session"
|
||||
| "reset-step"
|
||||
| "review"
|
||||
| "verification"
|
||||
| "workflow-step"
|
||||
| "transition"
|
||||
| "merge"
|
||||
| "abort"
|
||||
| "audit";
|
||||
|
||||
export interface WorkflowRuntimeRunContext {
|
||||
runId: string;
|
||||
taskId: string;
|
||||
workflowId: string;
|
||||
/** True after any primitive with task/git/session side effects starts. */
|
||||
sideEffectsStarted?: boolean;
|
||||
recoveryEventId?: string;
|
||||
}
|
||||
|
||||
export interface WorkflowRuntimeNodeContext {
|
||||
node: Pick<WorkflowIrNode, "id" | "kind" | "column" | "config">;
|
||||
effectivePrincipalId?: string;
|
||||
attempt?: number;
|
||||
context?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface WorkflowPrimitiveContext {
|
||||
run: WorkflowRuntimeRunContext;
|
||||
node: WorkflowRuntimeNodeContext;
|
||||
}
|
||||
|
||||
export interface RuntimePrimitiveResult<TValue = unknown> {
|
||||
outcome: WorkflowNodeOutcome;
|
||||
value?: string;
|
||||
data?: TValue;
|
||||
contextPatch?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface PreparedWorktree {
|
||||
worktreePath: string;
|
||||
branchName?: string;
|
||||
baseCommitSha?: string;
|
||||
modifiedFiles?: string[];
|
||||
}
|
||||
|
||||
export interface PlanningSessionResult {
|
||||
approved: boolean;
|
||||
artifactKeys: string[];
|
||||
createdTaskIds?: string[];
|
||||
feedback?: string;
|
||||
}
|
||||
|
||||
export interface CodingSessionResult {
|
||||
taskDone: boolean;
|
||||
modifiedFiles: string[];
|
||||
summary?: string;
|
||||
}
|
||||
|
||||
export interface ReviewPrimitiveResult {
|
||||
verdict: "APPROVE" | "REVISE" | "RETHINK" | "UNAVAILABLE";
|
||||
review?: string;
|
||||
summary?: string;
|
||||
}
|
||||
|
||||
export interface VerificationPrimitiveResult {
|
||||
verdict: "approve" | "revise" | "failed" | "advisory-failed" | "skipped";
|
||||
feedback?: string;
|
||||
stepName?: string;
|
||||
}
|
||||
|
||||
export interface WorkflowStepPrimitiveInput {
|
||||
phase: "pre-merge" | "post-merge";
|
||||
stepId?: string;
|
||||
worktreePath?: string;
|
||||
}
|
||||
|
||||
export interface WorkflowStepPrimitiveResult {
|
||||
allPassed: boolean;
|
||||
revisionRequested?: boolean;
|
||||
feedback?: string;
|
||||
stepName?: string;
|
||||
}
|
||||
|
||||
export interface TransitionPrimitiveInput {
|
||||
column?: string;
|
||||
status?: string | null;
|
||||
reason: string;
|
||||
preserveProgress?: boolean;
|
||||
}
|
||||
|
||||
export interface MergePrimitiveInput {
|
||||
expectedHeadOid?: string;
|
||||
manualAllowed?: boolean;
|
||||
}
|
||||
|
||||
export type MergePrimitiveResult =
|
||||
| { status: "merged"; noOp?: boolean }
|
||||
| { status: "manual-required"; reason?: string }
|
||||
| { status: "failed"; reason: string }
|
||||
| { status: "timeout" }
|
||||
| PrMergeCallResult;
|
||||
|
||||
export interface AbortPrimitiveInput {
|
||||
reason: string;
|
||||
hardCancel?: boolean;
|
||||
}
|
||||
|
||||
export interface AuditPrimitiveInput {
|
||||
type: string;
|
||||
message: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface WorkflowRuntimePrimitives {
|
||||
prepareWorktree(
|
||||
ctx: WorkflowPrimitiveContext,
|
||||
task: TaskDetail,
|
||||
): Promise<RuntimePrimitiveResult<PreparedWorktree>>;
|
||||
|
||||
readArtifact(
|
||||
ctx: WorkflowPrimitiveContext,
|
||||
task: TaskDetail,
|
||||
key: string,
|
||||
): Promise<string | undefined>;
|
||||
|
||||
writeArtifact(
|
||||
ctx: WorkflowPrimitiveContext,
|
||||
task: TaskDetail,
|
||||
key: string,
|
||||
content: string,
|
||||
): Promise<RuntimePrimitiveResult<{ key: string }>>;
|
||||
|
||||
runPlanningSession(
|
||||
ctx: WorkflowPrimitiveContext,
|
||||
task: TaskDetail,
|
||||
): Promise<RuntimePrimitiveResult<PlanningSessionResult>>;
|
||||
|
||||
runCodingSession(
|
||||
ctx: WorkflowPrimitiveContext,
|
||||
task: TaskDetail,
|
||||
prepared: PreparedWorktree,
|
||||
): Promise<RuntimePrimitiveResult<CodingSessionResult>>;
|
||||
|
||||
runTaskStep(
|
||||
ctx: WorkflowPrimitiveContext,
|
||||
task: TaskDetail,
|
||||
stepIndex: number,
|
||||
): Promise<RunTaskStepResult>;
|
||||
|
||||
resetTaskStep(
|
||||
ctx: WorkflowPrimitiveContext,
|
||||
task: TaskDetail,
|
||||
stepIndex: number,
|
||||
baselineSha?: string,
|
||||
checkpointId?: string,
|
||||
): Promise<ResetStepResult>;
|
||||
|
||||
runReview(
|
||||
ctx: WorkflowPrimitiveContext,
|
||||
task: TaskDetail,
|
||||
input: { type: "plan" | "code"; stepIndex?: number; baselineSha?: string },
|
||||
): Promise<RuntimePrimitiveResult<ReviewPrimitiveResult>>;
|
||||
|
||||
runVerification(
|
||||
ctx: WorkflowPrimitiveContext,
|
||||
task: TaskDetail,
|
||||
prepared: PreparedWorktree,
|
||||
): Promise<RuntimePrimitiveResult<VerificationPrimitiveResult>>;
|
||||
|
||||
runWorkflowStep(
|
||||
ctx: WorkflowPrimitiveContext,
|
||||
task: TaskDetail,
|
||||
input: WorkflowStepPrimitiveInput,
|
||||
): Promise<RuntimePrimitiveResult<WorkflowStepPrimitiveResult>>;
|
||||
|
||||
updateSteps(
|
||||
ctx: WorkflowPrimitiveContext,
|
||||
task: TaskDetail,
|
||||
steps: TaskStep[],
|
||||
): Promise<RuntimePrimitiveResult<{ count: number }>>;
|
||||
|
||||
transitionTask(
|
||||
ctx: WorkflowPrimitiveContext,
|
||||
task: TaskDetail,
|
||||
input: TransitionPrimitiveInput,
|
||||
): Promise<RuntimePrimitiveResult>;
|
||||
|
||||
requestMerge(
|
||||
ctx: WorkflowPrimitiveContext,
|
||||
task: TaskDetail,
|
||||
input?: MergePrimitiveInput,
|
||||
): Promise<RuntimePrimitiveResult<MergePrimitiveResult>>;
|
||||
|
||||
abortRun(
|
||||
ctx: WorkflowPrimitiveContext,
|
||||
task: TaskDetail,
|
||||
input: AbortPrimitiveInput,
|
||||
): Promise<RuntimePrimitiveResult>;
|
||||
|
||||
audit(ctx: WorkflowPrimitiveContext, input: AuditPrimitiveInput): Promise<void> | void;
|
||||
}
|
||||
|
||||
export function markSideEffectsStarted(ctx: WorkflowPrimitiveContext): WorkflowPrimitiveContext {
|
||||
return {
|
||||
...ctx,
|
||||
run: {
|
||||
...ctx.run,
|
||||
sideEffectsStarted: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function primitiveNodeContext(
|
||||
run: WorkflowRuntimeRunContext,
|
||||
node: WorkflowRuntimeNodeContext["node"],
|
||||
extras: Omit<WorkflowRuntimeNodeContext, "node"> = {},
|
||||
): WorkflowPrimitiveContext {
|
||||
return {
|
||||
run,
|
||||
node: {
|
||||
...extras,
|
||||
node,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -142,7 +142,10 @@ export class InProcessRuntime
|
||||
* before `start()` via `setMergeEnqueuer`.
|
||||
*/
|
||||
private mergeEnqueuer?: (taskId: string) => boolean;
|
||||
private mergeRequester?: (taskId: string) => Promise<import("@fusion/core").MergeResult>;
|
||||
private mergeRequester?: (
|
||||
taskId: string,
|
||||
options?: { signal?: AbortSignal },
|
||||
) => Promise<import("@fusion/core").MergeResult>;
|
||||
private clearMergeActive?: (taskId: string) => void;
|
||||
private activeMergeTaskIdProvider?: () => string | null;
|
||||
/** Tracks whether startup recovery was intentionally deferred due to pause state. */
|
||||
@@ -1140,7 +1143,12 @@ export class InProcessRuntime
|
||||
* forwards immediately when the executor already exists, and is re-applied at
|
||||
* executor construction during start().
|
||||
*/
|
||||
setMergeRequester(requestMerge: (taskId: string) => Promise<import("@fusion/core").MergeResult>): void {
|
||||
setMergeRequester(
|
||||
requestMerge: (
|
||||
taskId: string,
|
||||
options?: { signal?: AbortSignal },
|
||||
) => Promise<import("@fusion/core").MergeResult>,
|
||||
): void {
|
||||
this.mergeRequester = requestMerge;
|
||||
this.executor?.setMergeRequester(requestMerge);
|
||||
}
|
||||
|
||||
@@ -14,6 +14,8 @@ import type { TaskExecutor } from "./executor.js";
|
||||
import { executorLog } from "./logger.js";
|
||||
import { WORKFLOW_INTERPRETER_DUAL_OBSERVE_FLAG } from "./workflow-parity-observer.js";
|
||||
import { WorkflowGraphTaskRunner, type WorkflowGraphTaskRunResult } from "./workflow-graph-task-runner.js";
|
||||
import type { StepReviewSeamResult, WorkflowLegacySeams } from "./workflow-node-handlers.js";
|
||||
import type { PreparedWorktree, WorkflowRuntimePrimitives } from "./runtime-primitives.js";
|
||||
|
||||
const AUTHORITATIVE_WORKFLOW_ID = "workflow-interpreter-authoritative";
|
||||
|
||||
@@ -26,7 +28,7 @@ export interface WorkflowAuthoritativeDriverStore {
|
||||
|
||||
export interface WorkflowAuthoritativeDriverDeps {
|
||||
store: WorkflowAuthoritativeDriverStore;
|
||||
executor: Pick<TaskExecutor, "createAuthoritativeWorkflowSeams">;
|
||||
executor: Pick<TaskExecutor, "createAuthoritativeWorkflowSeams"> & Partial<Pick<TaskExecutor, "createAuthoritativeWorkflowPrimitives">>;
|
||||
minimumObservedRuns?: number;
|
||||
}
|
||||
|
||||
@@ -49,6 +51,81 @@ function buildAuthoritativeSettings(settings: Settings): Settings {
|
||||
};
|
||||
}
|
||||
|
||||
function primitivesFromLegacySeams(seams: WorkflowLegacySeams): WorkflowRuntimePrimitives {
|
||||
const mapStepReviewValue = (verdict: StepReviewSeamResult["verdict"] | undefined): string => {
|
||||
switch (verdict) {
|
||||
case "APPROVE":
|
||||
return "approve";
|
||||
case "REVISE":
|
||||
return "revise";
|
||||
case "RETHINK":
|
||||
return "rethink";
|
||||
default:
|
||||
return "unavailable";
|
||||
}
|
||||
};
|
||||
|
||||
// Legacy seams do not consume PreparedWorktree; filesystem/session state is
|
||||
// still owned inside the seam implementation they delegate to.
|
||||
const prepared: PreparedWorktree = { worktreePath: "" };
|
||||
return {
|
||||
prepareWorktree: async () => ({ outcome: "success", data: prepared }),
|
||||
readArtifact: async () => undefined,
|
||||
writeArtifact: async (_ctx, _task, key) => ({ outcome: "success", data: { key } }),
|
||||
runPlanningSession: async (ctx, task) => {
|
||||
const result = await seams.planning(task, ctx.node.context ?? {});
|
||||
return { ...result, data: { approved: result.outcome === "success", artifactKeys: [] } };
|
||||
},
|
||||
runCodingSession: async (ctx, task) => {
|
||||
const result = await seams.execute(task, ctx.node.context ?? {});
|
||||
return {
|
||||
...result,
|
||||
data: { taskDone: result.outcome === "success", modifiedFiles: [] },
|
||||
};
|
||||
},
|
||||
runTaskStep: async (ctx, task) => {
|
||||
const result = await seams.stepExecute?.(task, ctx.node.context ?? {});
|
||||
return { outcome: result?.outcome ?? "failure" };
|
||||
},
|
||||
resetTaskStep: async () => ({ ok: true }),
|
||||
runReview: async (ctx, task, input) => {
|
||||
if (typeof input.stepIndex === "number") {
|
||||
const result = await seams.stepReview?.(task, ctx.node.context ?? {}, { type: input.type });
|
||||
return {
|
||||
outcome: "success",
|
||||
value: mapStepReviewValue(result?.verdict),
|
||||
data: result ?? { verdict: "UNAVAILABLE" },
|
||||
};
|
||||
}
|
||||
const result = await seams.review(task, ctx.node.context ?? {});
|
||||
return { ...result, data: { verdict: result.outcome === "success" ? "APPROVE" : "REVISE" } };
|
||||
},
|
||||
runVerification: async () => ({ outcome: "success", data: { verdict: "skipped" } }),
|
||||
runWorkflowStep: async (ctx, task) => {
|
||||
const result = await seams.workflowStep?.(task, ctx.node.context ?? {});
|
||||
return {
|
||||
outcome: result?.outcome ?? "success",
|
||||
value: result?.value ?? "workflow-step-skipped",
|
||||
contextPatch: result?.contextPatch,
|
||||
data: { allPassed: result?.outcome !== "failure" },
|
||||
};
|
||||
},
|
||||
updateSteps: async (_ctx, _task, steps) => ({ outcome: "success", data: { count: steps.length } }),
|
||||
transitionTask: async (ctx, task) => seams.schedule(task, ctx.node.context ?? {}),
|
||||
requestMerge: async (ctx, task) => {
|
||||
const result = await seams.merge(task, ctx.node.context ?? {});
|
||||
return {
|
||||
...result,
|
||||
data: result.outcome === "success"
|
||||
? { status: "merged" as const }
|
||||
: { status: "failed" as const, reason: result.value ?? "merge-failed" },
|
||||
};
|
||||
},
|
||||
abortRun: async () => ({ outcome: "success" }),
|
||||
audit: () => undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export class WorkflowAuthoritativeDriver {
|
||||
public constructor(private readonly deps: WorkflowAuthoritativeDriverDeps) {}
|
||||
|
||||
@@ -114,6 +191,7 @@ export class WorkflowAuthoritativeDriver {
|
||||
readinessReasons: [],
|
||||
};
|
||||
}
|
||||
const seams = this.deps.executor.createAuthoritativeWorkflowSeams(settings);
|
||||
const runner = new WorkflowGraphTaskRunner({
|
||||
store: {
|
||||
getTaskWorkflowSelection: () => ({ workflowId: AUTHORITATIVE_WORKFLOW_ID, stepIds: [] }),
|
||||
@@ -123,7 +201,8 @@ export class WorkflowAuthoritativeDriver {
|
||||
ir: BUILTIN_CODING_WORKFLOW_IR,
|
||||
} satisfies Pick<WorkflowDefinition, "id" | "name" | "ir"> as WorkflowDefinition),
|
||||
},
|
||||
seams: this.deps.executor.createAuthoritativeWorkflowSeams(settings),
|
||||
primitives: this.deps.executor.createAuthoritativeWorkflowPrimitives?.(settings) ?? primitivesFromLegacySeams(seams),
|
||||
seams,
|
||||
runCustomNode: async (node) => {
|
||||
throw new Error(`unexpected custom node in builtin authoritative workflow: ${node.id}`);
|
||||
},
|
||||
|
||||
@@ -5,12 +5,15 @@ import {
|
||||
createDefaultNodeHandlers,
|
||||
createNoopLegacySeams,
|
||||
SPLIT_ACTIVE_CONTEXT_KEY,
|
||||
WORKFLOW_ID_CONTEXT_KEY,
|
||||
WORKFLOW_RUN_ID_CONTEXT_KEY,
|
||||
type CodeNodeRunner,
|
||||
type ForeachActiveContext,
|
||||
type ParseStepsHandlerDeps,
|
||||
type WorkflowCustomNodeRunner,
|
||||
type WorkflowLegacySeams,
|
||||
} from "./workflow-node-handlers.js";
|
||||
import type { WorkflowRuntimePrimitives } from "./runtime-primitives.js";
|
||||
import type { PrNodeDeps } from "./pr-nodes.js";
|
||||
import {
|
||||
runSplitJoin,
|
||||
@@ -48,6 +51,9 @@ export type WorkflowNodeHandler = (node: WorkflowIrNode, context: WorkflowNodeEx
|
||||
|
||||
export interface WorkflowGraphExecutorDeps {
|
||||
handlers?: Partial<Record<WorkflowIrNode["kind"], WorkflowNodeHandler>>;
|
||||
/** Workflow-native runtime primitives. When present, default nodes call these
|
||||
* directly instead of legacy executor/reviewer/merge seams. */
|
||||
primitives?: WorkflowRuntimePrimitives;
|
||||
seams?: WorkflowLegacySeams;
|
||||
/** Executes custom (non-seam) prompt/script/gate nodes. */
|
||||
runCustomNode?: WorkflowCustomNodeRunner;
|
||||
@@ -150,6 +156,7 @@ export class WorkflowGraphExecutor {
|
||||
this.maxRetriesPerNode = Math.max(1, Math.floor(deps.maxRetriesPerNode ?? 2));
|
||||
this.handlers = {
|
||||
...createDefaultNodeHandlers(deps.seams ?? createNoopLegacySeams(), deps.runCustomNode, {
|
||||
primitives: deps.primitives,
|
||||
parseSteps: deps.parseStepsDeps,
|
||||
runCode: deps.runCode,
|
||||
prNodes: deps.prNodes,
|
||||
@@ -181,10 +188,13 @@ export class WorkflowGraphExecutor {
|
||||
outgoingMap.set(edge.from, list);
|
||||
}
|
||||
|
||||
const context: Record<string, unknown> = {};
|
||||
const runId = this.deps.runId ?? `${task.id}:run`;
|
||||
const context: Record<string, unknown> = {
|
||||
[WORKFLOW_RUN_ID_CONTEXT_KEY]: runId,
|
||||
[WORKFLOW_ID_CONTEXT_KEY]: ir.name || "unknown",
|
||||
};
|
||||
const visitedNodeIds: string[] = [];
|
||||
const inStack = new Set<string>();
|
||||
const runId = this.deps.runId ?? `${task.id}:run`;
|
||||
|
||||
// Bounded-rework generalization (U6). A `kind: "rework"` edge is the only
|
||||
// legal cycle: it loops back to a "rework region head" (the edge's `to` node).
|
||||
@@ -420,7 +430,12 @@ export class WorkflowGraphExecutor {
|
||||
return sourceResult;
|
||||
}
|
||||
|
||||
const matching = edges.filter((edge) => this.shouldTraverseEdge(edge, sourceResult));
|
||||
const outcomeMatching = edges.filter((edge) =>
|
||||
edge.condition?.startsWith("outcome:") && this.shouldTraverseEdge(edge, sourceResult)
|
||||
);
|
||||
const matching = outcomeMatching.length > 0
|
||||
? outcomeMatching
|
||||
: edges.filter((edge) => this.shouldTraverseEdge(edge, sourceResult));
|
||||
if (matching.length === 0) {
|
||||
return sourceResult;
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import type {
|
||||
} from "./workflow-graph-branches.js";
|
||||
import type { ForeachEnvironment, WorkflowStepInstancePersistence } from "./workflow-graph-foreach.js";
|
||||
import type { PrNodeDeps } from "./pr-nodes.js";
|
||||
import type { WorkflowPrimitiveContext, WorkflowRuntimePrimitives } from "./runtime-primitives.js";
|
||||
// (Both types are also used as values in the side-effect tracking wrappers below.)
|
||||
|
||||
/**
|
||||
@@ -46,6 +47,7 @@ export interface WorkflowGraphRunnerStore {
|
||||
export interface WorkflowGraphTaskRunnerDeps {
|
||||
store: WorkflowGraphRunnerStore;
|
||||
seams: WorkflowLegacySeams;
|
||||
primitives?: WorkflowRuntimePrimitives;
|
||||
runCustomNode: WorkflowCustomNodeRunner;
|
||||
maxRetriesPerNode?: number;
|
||||
/** Optional diagnostics hook (audit/log emission). Never throws into the run. */
|
||||
@@ -169,6 +171,11 @@ export class WorkflowGraphTaskRunner {
|
||||
const wrappedSeams: WorkflowLegacySeams = {
|
||||
planning: (t, c) => ((sideEffectsRan = true), invoked.push("planning"), seams.planning(t, c)),
|
||||
execute: (t, c) => ((sideEffectsRan = true), invoked.push("execute"), seams.execute(t, c)),
|
||||
workflowStep: (t, c) => {
|
||||
sideEffectsRan = true;
|
||||
invoked.push("workflow-step");
|
||||
return seams.workflowStep?.(t, c) ?? Promise.resolve({ outcome: "success", value: "workflow-step-skipped" });
|
||||
},
|
||||
review: (t, c) => ((sideEffectsRan = true), invoked.push("review"), seams.review(t, c)),
|
||||
merge: (t, c) => ((sideEffectsRan = true), invoked.push("merge"), seams.merge(t, c)),
|
||||
schedule: (t, c) => ((sideEffectsRan = true), invoked.push("schedule"), seams.schedule(t, c)),
|
||||
@@ -186,10 +193,25 @@ export class WorkflowGraphTaskRunner {
|
||||
invoked.push(node.id);
|
||||
return this.deps.runCustomNode(node, t, c);
|
||||
};
|
||||
const wrappedPrimitives = this.deps.primitives
|
||||
? new Proxy(this.deps.primitives, {
|
||||
get: (target, prop, receiver) => {
|
||||
const value = Reflect.get(target, prop, receiver);
|
||||
if (typeof value !== "function") return value;
|
||||
return (...args: unknown[]) => {
|
||||
sideEffectsRan = true;
|
||||
const ctx = args[0] as WorkflowPrimitiveContext | undefined;
|
||||
invoked.push(ctx?.node?.node?.id ?? String(prop));
|
||||
return value.apply(target, args);
|
||||
};
|
||||
},
|
||||
}) as WorkflowRuntimePrimitives
|
||||
: undefined;
|
||||
|
||||
try {
|
||||
const executor = new WorkflowGraphExecutor({
|
||||
seams: wrappedSeams,
|
||||
primitives: wrappedPrimitives,
|
||||
runCustomNode: wrappedRunCustomNode,
|
||||
maxRetriesPerNode: this.deps.maxRetriesPerNode,
|
||||
branchPersistence: this.deps.branchPersistence,
|
||||
|
||||
@@ -3,8 +3,20 @@ import type { TaskDetail, TaskStep, WorkflowIrNode } from "@fusion/core";
|
||||
|
||||
import type { WorkflowNodeHandler, WorkflowNodeResult } from "./workflow-graph-executor.js";
|
||||
import { createPrNodeHandlers, createAutoMergeGateHandler, type PrNodeDeps } from "./pr-nodes.js";
|
||||
import {
|
||||
primitiveNodeContext,
|
||||
type WorkflowPrimitiveContext,
|
||||
type WorkflowRuntimePrimitives,
|
||||
} from "./runtime-primitives.js";
|
||||
|
||||
export type WorkflowSeamName = "planning" | "execute" | "review" | "merge" | "schedule" | "step-execute";
|
||||
export type WorkflowSeamName =
|
||||
| "planning"
|
||||
| "execute"
|
||||
| "workflow-step"
|
||||
| "review"
|
||||
| "merge"
|
||||
| "schedule"
|
||||
| "step-execute";
|
||||
|
||||
export interface WorkflowLegacySeams {
|
||||
/** Planning/spec stage. Built-in triage runs upstream of the interpreter
|
||||
@@ -12,6 +24,7 @@ export interface WorkflowLegacySeams {
|
||||
* custom planning behavior is expressed as a custom prompt node. */
|
||||
planning: (task: TaskDetail, context: Record<string, unknown>) => Promise<WorkflowNodeResult>;
|
||||
execute: (task: TaskDetail, context: Record<string, unknown>) => Promise<WorkflowNodeResult>;
|
||||
workflowStep?: (task: TaskDetail, context: Record<string, unknown>) => Promise<WorkflowNodeResult>;
|
||||
review: (task: TaskDetail, context: Record<string, unknown>) => Promise<WorkflowNodeResult>;
|
||||
merge: (task: TaskDetail, context: Record<string, unknown>) => Promise<WorkflowNodeResult>;
|
||||
schedule: (task: TaskDetail, context: Record<string, unknown>) => Promise<WorkflowNodeResult>;
|
||||
@@ -104,6 +117,12 @@ export const SPLIT_ACTIVE_CONTEXT_KEY = "split:active";
|
||||
*/
|
||||
export const INTEGRATION_CONFLICT_CONTEXT_KEY = "integration:conflict";
|
||||
|
||||
/** Reserved graph context key for the current workflow run id. */
|
||||
export const WORKFLOW_RUN_ID_CONTEXT_KEY = "workflow:run-id";
|
||||
|
||||
/** Reserved graph context key for the current workflow id. */
|
||||
export const WORKFLOW_ID_CONTEXT_KEY = "workflow:id";
|
||||
|
||||
/** Shape of the value stored under {@link FOREACH_ACTIVE_CONTEXT_KEY}. */
|
||||
export interface ForeachActiveContext {
|
||||
foreachNodeId: string;
|
||||
@@ -149,6 +168,34 @@ export type WorkflowCustomNodeRunner = (
|
||||
context: Record<string, unknown>,
|
||||
) => Promise<WorkflowNodeResult>;
|
||||
|
||||
function primitiveContextForNode(
|
||||
node: WorkflowIrNode,
|
||||
task: TaskDetail,
|
||||
context: Record<string, unknown>,
|
||||
attempt?: number,
|
||||
): WorkflowPrimitiveContext {
|
||||
return primitiveNodeContext(
|
||||
{
|
||||
runId: typeof context[WORKFLOW_RUN_ID_CONTEXT_KEY] === "string"
|
||||
? context[WORKFLOW_RUN_ID_CONTEXT_KEY]
|
||||
: `${task.id}:workflow`,
|
||||
taskId: task.id,
|
||||
workflowId: typeof context[WORKFLOW_ID_CONTEXT_KEY] === "string"
|
||||
? context[WORKFLOW_ID_CONTEXT_KEY]
|
||||
: "unknown",
|
||||
},
|
||||
node,
|
||||
{
|
||||
attempt,
|
||||
context,
|
||||
effectivePrincipalId:
|
||||
typeof context["workflow:effective-principal-id"] === "string"
|
||||
? context["workflow:effective-principal-id"]
|
||||
: undefined,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/** Resolve a node's seam name, or undefined for custom (non-seam) nodes. */
|
||||
export function resolveSeamName(node: { config?: Record<string, unknown> }): WorkflowSeamName | undefined {
|
||||
const seam = node.config?.seam;
|
||||
@@ -156,6 +203,7 @@ export function resolveSeamName(node: { config?: Record<string, unknown> }): Wor
|
||||
if (
|
||||
seam === "planning" ||
|
||||
seam === "execute" ||
|
||||
seam === "workflow-step" ||
|
||||
seam === "review" ||
|
||||
seam === "merge" ||
|
||||
seam === "schedule" ||
|
||||
@@ -211,6 +259,11 @@ export function createPromptLikeHandler(
|
||||
// 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;
|
||||
if (seam === "workflow-step") {
|
||||
return seams.workflowStep
|
||||
? seams.workflowStep(context.task, context.context)
|
||||
: { outcome: "success", value: "workflow-step-skipped" };
|
||||
}
|
||||
return seams[seam]!(context.task, context.context);
|
||||
}
|
||||
if (!runCustomNode) {
|
||||
@@ -220,6 +273,106 @@ export function createPromptLikeHandler(
|
||||
};
|
||||
}
|
||||
|
||||
export function createPrimitivePromptLikeHandler(
|
||||
primitives: WorkflowRuntimePrimitives,
|
||||
runCustomNode?: WorkflowCustomNodeRunner,
|
||||
): WorkflowNodeHandler {
|
||||
return async (node, context) => {
|
||||
const seam = resolveSeamName(node);
|
||||
if (seam === "step-execute") {
|
||||
const active = context.context[FOREACH_ACTIVE_CONTEXT_KEY] as
|
||||
| ForeachActiveContext
|
||||
| undefined;
|
||||
if (!active || typeof active.stepIndex !== "number") {
|
||||
throw new WorkflowIrError(
|
||||
`step-execute node '${node.id}' reached without an active foreach instance context`,
|
||||
);
|
||||
}
|
||||
context.context[SEAM_GOVERNING_NODE_CONTEXT_KEY] = instanceNodeId(
|
||||
active.foreachNodeId,
|
||||
active.stepIndex,
|
||||
node.id,
|
||||
);
|
||||
const result = await primitives.runTaskStep(
|
||||
primitiveContextForNode(node, context.task, context.context),
|
||||
context.task,
|
||||
active.stepIndex,
|
||||
);
|
||||
active.baselineSha = result.baselineSha;
|
||||
active.checkpointId = result.checkpointId;
|
||||
return {
|
||||
outcome: result.outcome,
|
||||
value: result.outcome === "success" ? "step-done" : "step-failed",
|
||||
contextPatch: {
|
||||
[FOREACH_ACTIVE_CONTEXT_KEY]: active,
|
||||
},
|
||||
};
|
||||
}
|
||||
if (seam) {
|
||||
context.context[SEAM_GOVERNING_NODE_CONTEXT_KEY] = node.id;
|
||||
const primitiveCtx = primitiveContextForNode(node, context.task, context.context);
|
||||
if (seam === "planning") {
|
||||
const result = await primitives.runPlanningSession(primitiveCtx, context.task);
|
||||
return { outcome: result.outcome, value: result.value, contextPatch: result.contextPatch };
|
||||
}
|
||||
if (seam === "execute") {
|
||||
const prepared = await primitives.prepareWorktree(primitiveCtx, context.task);
|
||||
if (prepared.outcome !== "success" || !prepared.data) {
|
||||
return {
|
||||
outcome: prepared.outcome === "success" ? "failure" : prepared.outcome,
|
||||
value: prepared.value ?? "prepare-worktree-failed",
|
||||
contextPatch: prepared.contextPatch,
|
||||
};
|
||||
}
|
||||
const result = await primitives.runCodingSession(primitiveCtx, context.task, prepared.data);
|
||||
const contextPatch = prepared.contextPatch || result.contextPatch
|
||||
? {
|
||||
...(prepared.contextPatch ?? {}),
|
||||
...(result.contextPatch ?? {}),
|
||||
}
|
||||
: undefined;
|
||||
return {
|
||||
outcome: result.outcome,
|
||||
value: result.value,
|
||||
contextPatch: {
|
||||
...(contextPatch ?? {}),
|
||||
"workflow:worktree-path": prepared.data.worktreePath,
|
||||
},
|
||||
};
|
||||
}
|
||||
if (seam === "workflow-step") {
|
||||
const worktreePath = typeof context.context["workflow:worktree-path"] === "string"
|
||||
? context.context["workflow:worktree-path"]
|
||||
: undefined;
|
||||
const result = await primitives.runWorkflowStep(primitiveCtx, context.task, {
|
||||
phase: "pre-merge",
|
||||
worktreePath,
|
||||
});
|
||||
return { outcome: result.outcome, value: result.value, contextPatch: result.contextPatch };
|
||||
}
|
||||
if (seam === "review") {
|
||||
const result = await primitives.runReview(primitiveCtx, context.task, { type: "code" });
|
||||
return { outcome: result.outcome, value: result.value, contextPatch: result.contextPatch };
|
||||
}
|
||||
if (seam === "merge") {
|
||||
const result = await primitives.requestMerge(primitiveCtx, context.task);
|
||||
return { outcome: result.outcome, value: result.value, contextPatch: result.contextPatch };
|
||||
}
|
||||
if (seam === "schedule") {
|
||||
const result = await primitives.transitionTask(primitiveCtx, context.task, {
|
||||
reason: "workflow-schedule",
|
||||
preserveProgress: true,
|
||||
});
|
||||
return { outcome: result.outcome, value: result.value, contextPatch: result.contextPatch };
|
||||
}
|
||||
}
|
||||
if (!runCustomNode) {
|
||||
throw new WorkflowIrError(`No custom-node runner registered for node: ${node.id}`);
|
||||
}
|
||||
return runCustomNode(node, context.task, context.context);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Gate handler. Two forms:
|
||||
* - Context gate (original scaffold contract): `config.expect` compared against
|
||||
@@ -337,6 +490,65 @@ export function createStepReviewHandler(seams: WorkflowLegacySeams): WorkflowNod
|
||||
};
|
||||
}
|
||||
|
||||
export function createPrimitiveStepReviewHandler(primitives: WorkflowRuntimePrimitives): WorkflowNodeHandler {
|
||||
return async (node, ctx) => {
|
||||
const active = ctx.context[FOREACH_ACTIVE_CONTEXT_KEY] as ForeachActiveContext | undefined;
|
||||
if (!active || typeof active.stepIndex !== "number") {
|
||||
throw new WorkflowIrError(
|
||||
`step-review node '${node.id}' reached without an active foreach instance context`,
|
||||
);
|
||||
}
|
||||
|
||||
const advisory = ctx.context[SPLIT_ACTIVE_CONTEXT_KEY] === true;
|
||||
const config = resolveStepReviewConfig(node, advisory);
|
||||
let result: StepReviewSeamResult = {
|
||||
verdict: "UNAVAILABLE",
|
||||
};
|
||||
let primitivePatch: Record<string, unknown> | undefined;
|
||||
for (let attempt = 0; attempt <= STEP_REVIEW_UNAVAILABLE_RETRY_CAP; attempt++) {
|
||||
const primitiveResult = await primitives.runReview(
|
||||
primitiveContextForNode(node, ctx.task, ctx.context, attempt + 1),
|
||||
ctx.task,
|
||||
{
|
||||
type: config.type,
|
||||
stepIndex: active.stepIndex,
|
||||
baselineSha: config.type === "code" ? active.baselineSha : undefined,
|
||||
},
|
||||
);
|
||||
if (primitiveResult.outcome !== "success") {
|
||||
return {
|
||||
outcome: primitiveResult.outcome,
|
||||
value: primitiveResult.value,
|
||||
contextPatch: primitiveResult.contextPatch,
|
||||
};
|
||||
}
|
||||
primitivePatch = primitiveResult.contextPatch;
|
||||
result = primitiveResult.data ?? { verdict: "UNAVAILABLE" as const };
|
||||
if (result.verdict !== "UNAVAILABLE") break;
|
||||
}
|
||||
|
||||
if (!advisory) {
|
||||
active.verdict = result.verdict;
|
||||
}
|
||||
const patch: Record<string, unknown> = {
|
||||
...(primitivePatch ?? {}),
|
||||
[FOREACH_ACTIVE_CONTEXT_KEY]: active,
|
||||
[`node:${node.id}:verdict`]: result.verdict,
|
||||
};
|
||||
|
||||
const value =
|
||||
result.verdict === "APPROVE"
|
||||
? "approve"
|
||||
: result.verdict === "REVISE"
|
||||
? "revise"
|
||||
: result.verdict === "RETHINK"
|
||||
? "rethink"
|
||||
: "unavailable";
|
||||
|
||||
return { outcome: "success", value, contextPatch: patch };
|
||||
};
|
||||
}
|
||||
|
||||
// ── parse-steps node (U12, KTD-12) ──────────────────────────────────────────
|
||||
|
||||
/** The implicit default step-source artifact when a workflow declares no
|
||||
@@ -542,6 +754,8 @@ export function createCodeNodeHandler(runCode?: CodeNodeRunner): WorkflowNodeHan
|
||||
}
|
||||
|
||||
export interface DefaultNodeHandlerDeps {
|
||||
/** Workflow-native runtime primitives. When present they replace legacy seams. */
|
||||
primitives?: WorkflowRuntimePrimitives;
|
||||
/** parse-steps node deps (U12). When absent, a parse-steps node fails cleanly. */
|
||||
parseSteps?: ParseStepsHandlerDeps;
|
||||
/** code node runner (U14). When absent, a code node fails cleanly. */
|
||||
@@ -566,7 +780,9 @@ export function createDefaultNodeHandlers(
|
||||
| "pr-merge",
|
||||
WorkflowNodeHandler
|
||||
> {
|
||||
const promptLike = createPromptLikeHandler(seams, runCustomNode);
|
||||
const promptLike = deps?.primitives
|
||||
? createPrimitivePromptLikeHandler(deps.primitives, runCustomNode)
|
||||
: createPromptLikeHandler(seams, runCustomNode);
|
||||
// parse-steps without deps fails closed (would otherwise have no handler at
|
||||
// all and throw "No handler registered"); a clean failure is the safe posture.
|
||||
const parseSteps: WorkflowNodeHandler = deps?.parseSteps
|
||||
@@ -596,7 +812,9 @@ export function createDefaultNodeHandlers(
|
||||
prompt: promptLike,
|
||||
script: promptLike,
|
||||
gate,
|
||||
"step-review": createStepReviewHandler(seams),
|
||||
"step-review": deps?.primitives
|
||||
? createPrimitiveStepReviewHandler(deps.primitives)
|
||||
: createStepReviewHandler(seams),
|
||||
"parse-steps": parseSteps,
|
||||
code: createCodeNodeHandler(deps?.runCode),
|
||||
...prNodes,
|
||||
@@ -611,6 +829,7 @@ export function createNoopLegacySeams(): WorkflowLegacySeams {
|
||||
return {
|
||||
planning: success,
|
||||
execute: success,
|
||||
workflowStep: success,
|
||||
review: success,
|
||||
merge: success,
|
||||
schedule: success,
|
||||
|
||||
@@ -15,9 +15,10 @@ import {
|
||||
} from "./workflow-graph-executor.js";
|
||||
import {
|
||||
createDefaultNodeHandlers,
|
||||
createNoopLegacySeams,
|
||||
type WorkflowCustomNodeRunner,
|
||||
type WorkflowLegacySeams,
|
||||
} from "./workflow-node-handlers.js";
|
||||
import type { WorkflowRuntimePrimitives } from "./runtime-primitives.js";
|
||||
|
||||
export type WorkflowTaskRuntimeDisposition = "completed" | "failed";
|
||||
|
||||
@@ -31,7 +32,7 @@ export interface WorkflowTaskRuntimeResult {
|
||||
|
||||
export interface WorkflowTaskRuntimeDeps extends Omit<WorkflowGraphExecutorDeps, "seams" | "runCustomNode"> {
|
||||
store: WorkflowIrResolverStore;
|
||||
seams: WorkflowLegacySeams;
|
||||
primitives: WorkflowRuntimePrimitives;
|
||||
runCustomNode: WorkflowCustomNodeRunner;
|
||||
onEvent?: (event: { type: "start" | "terminal"; taskId: string; detail: string }) => void;
|
||||
}
|
||||
@@ -39,11 +40,11 @@ export interface WorkflowTaskRuntimeDeps extends Omit<WorkflowGraphExecutorDeps,
|
||||
/**
|
||||
* WorkflowTaskRuntime is the workflow-engine execution facade.
|
||||
*
|
||||
* It always resolves a task to a workflow IR: explicit selections resolve to
|
||||
* their selected workflow, and tasks without a selection resolve to the built-in
|
||||
* coding workflow. This is intentionally
|
||||
* different from `WorkflowGraphTaskRunner`, whose current contract still models
|
||||
* "no selection" as legacy fallback.
|
||||
* It always resolves a task to a workflow IR: explicit selections resolve only
|
||||
* to their selected workflow, and tasks without a selection resolve to the
|
||||
* built-in coding workflow. This is intentionally different from
|
||||
* `WorkflowGraphTaskRunner`, whose current contract still models "no selection"
|
||||
* as legacy fallback.
|
||||
*/
|
||||
export class WorkflowTaskRuntime {
|
||||
public constructor(private readonly deps: WorkflowTaskRuntimeDeps) {}
|
||||
@@ -80,6 +81,7 @@ export class WorkflowTaskRuntime {
|
||||
const invoked: string[] = [];
|
||||
const executor = new WorkflowGraphExecutor({
|
||||
...this.deps,
|
||||
primitives: this.deps.primitives,
|
||||
handlers: this.recordingHandlers(invoked),
|
||||
// WorkflowTaskRuntime is the execution engine, so internally the graph
|
||||
// executor is authoritative even before the old feature flag plumbing is
|
||||
@@ -116,31 +118,28 @@ export class WorkflowTaskRuntime {
|
||||
let workflowId: string | undefined;
|
||||
try {
|
||||
workflowId = this.deps.store.getTaskWorkflowSelection(taskId)?.workflowId;
|
||||
} catch {
|
||||
return builtinCodingTarget();
|
||||
} catch (err) {
|
||||
throw new Error(`workflow-selection-failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
|
||||
if (!workflowId) return builtinCodingTarget();
|
||||
|
||||
if (isBuiltinWorkflowId(workflowId)) {
|
||||
const builtin = getBuiltinWorkflow(workflowId);
|
||||
if (!builtin) return builtinCodingTarget();
|
||||
if (!builtin) throw new Error(`workflow-missing: ${workflowId}`);
|
||||
const ir = typeof builtin.ir === "string" ? parseWorkflowIr(builtin.ir) : builtin.ir;
|
||||
return { workflowId, ir };
|
||||
}
|
||||
|
||||
try {
|
||||
const def = await this.deps.store.getWorkflowDefinition(workflowId);
|
||||
if (!def) return builtinCodingTarget();
|
||||
const ir = typeof def.ir === "string" ? parseWorkflowIr(def.ir) : def.ir;
|
||||
return { workflowId, ir };
|
||||
} catch {
|
||||
return builtinCodingTarget();
|
||||
}
|
||||
const def = await this.deps.store.getWorkflowDefinition(workflowId);
|
||||
if (!def) throw new Error(`workflow-missing: ${workflowId}`);
|
||||
const ir = typeof def.ir === "string" ? parseWorkflowIr(def.ir) : def.ir;
|
||||
return { workflowId, ir };
|
||||
}
|
||||
|
||||
private recordingHandlers(invoked: string[]): Partial<Record<WorkflowIrNode["kind"], WorkflowNodeHandler>> {
|
||||
const defaultHandlers = createDefaultNodeHandlers(this.deps.seams, this.deps.runCustomNode, {
|
||||
const defaultHandlers = createDefaultNodeHandlers(createNoopLegacySeams(), this.deps.runCustomNode, {
|
||||
primitives: this.deps.primitives,
|
||||
parseSteps: this.deps.parseStepsDeps,
|
||||
runCode: this.deps.runCode,
|
||||
prNodes: this.deps.prNodes,
|
||||
|
||||
Reference in New Issue
Block a user