#3334·Archon

Introduce IWorkflowEngine port to abstract workflow execution

Author: tbrandenburgCreated Sep 15, 2026Updated Sep 17, 2026

Problem

@archon/workflows' execution engine (executeWorkflow / hydrateResumableRun in packages/workflows/src/executor.ts, dag-executor.ts) is called directly by all four current hosts — packages/server/src/services/workflow-resume-service.ts, packages/cli/src/commands/workflow.ts, and two call sites in packages/core/src/orchestrator/{orchestrator,orchestrator-agent}.ts. There is no interface between "start/resume a run" and those callers, unlike every other seam in this codebase: IWorkflowStore abstracts persistence, IWorkflowPlatform abstracts chat/CLI/ Web transport, but execution itself is a direct function call into a 12k-line module.

This is the one place AGENTS.md's own rule — "Workflow execution depends on injected contracts, not on core database or adapter implementations" — is not yet applied to execution itself, only to what execution depends on.

Concretely today: cancellation is implemented three different, ad hoc ways depending on caller (CLI does a DB status write + hard process exit on SIGINT, writing status='failed'; the orchestrator's cancel/abandon tool action writes status='cancelled'; the resume poller has no cancel path at all — a permanently-blocked continuation just retries every 60s forever). Neither executeWorkflow nor hydrateResumableRun accepts an AbortSignal today: the DAG loop only cooperatively polls run status every ~10s and aborts in-flight node streams once it observes a non-running/paused status, so any cancel operation this issue introduces is necessarily a documented request, not a synchronous interrupt. Event streaming is split three ways (CLI subscribes to a process-local WorkflowEventEmitter, Web streams via its own SSE bridge plus a separate DB-polling bridge for cross-process runs, the resume poller doesn't observe events at all) with no single contract describing what "the engine" offers.

Why

The codebase already uses interface segregation for storage and platform I/O successfully (IWorkflowStore, IWorkflowPlatform, IRunTreeStore, IWorkflowRunNodeSessionStore). Extending the same pattern to execution:

  • Gives every caller (server, CLI, orchestrator) one documented contract instead of three independently-evolved call patterns to the same underlying functions.
  • Formalizes cancellation as a real operation instead of three different DB-write-and-exit variants, one of which (the resume poller) doesn't have one at all today.
  • Costs nothing at runtime for current behavior: the first (and, for this issue, only) implementation is a thin wrapper around the exact code already running.

Why now

Low cost, no urgency-driven deadline — but doing it before any second execution substrate is ever considered (in-process is the only one that exists or is planned) means the interface gets shaped by what four real callers actually need, not by a hypothetical future backend. That's a better basis for the contract than designing it later around a specific second implementation's constraints.

Desired outcome

An IWorkflowEngine interface exists in @archon/workflows, with a single in-process implementation that wraps today's executeWorkflow/hydrateResumableRun with no behavior change, and all four current callers go through it instead of calling the engine functions directly. Cancellation and event subscription become real, documented operations on the interface rather than caller-specific ad hoc code.

Acceptance

  • IWorkflowEngine interface defined (submit, resume, cancel, subscribe) in @archon/workflows, alongside its existing IWorkflowStore/IWorkflowPlatform ports. Follows their id-based convention (operations take a plain runId: string, not an opaque handle/token type — no handle exists anywhere in this codebase today).
    • subscribe(runId, listener) delivers events for that run and its descendant sub-runs, resolved via the existing IRunTreeStore. It is not keyed by conversation id: a conversation is a transport concept owned by IWorkflowPlatform, and keying execution by it would couple execution to transport. Note today's CLI does subscribe by conversation id (packages/cli/src/commands/workflow.ts:3093subscribeForConversation, filtering via conversationMap.get(event.runId) at packages/workflows/src/event-emitter.ts:316), so naive run-id keying would silently drop child sub-run rendering. No includeDescendants option — there is one consumer and it needs descendants.
    • subscribe reads through IWorkflowStore's event-read methods, not a database directly, so a file-backed store satisfies the contract (direction.md §standalone-core: a database is an optional persistence adapter, not a prerequisite).
    • cancel(runId, reason?) is documented in the interface as a cooperative request, not a synchronous interrupt: the DAG loop observes run status on a CANCEL_CHECK_INTERVAL_MS = 10_000 throttle (packages/workflows/src/dag-executor.ts:953-955) and paused deliberately does not abort an in-flight stream (:976).
    • The emitter's existing 1-arg subscribe(listener) (packages/workflows/src/event-emitter.ts:295) is renamed rather than overloaded.
  • One implementation (InProcessWorkflowEngine) delegates submit/resume to today's executeWorkflow/hydrateResumableRun with no logic change; a new shared contract-test suite (e.g. runWorkflowEngineContractTests(makeEngine), authored from scratch — no such suite exists for IWorkflowStore/IWorkflowPlatform either) confirms identical behavior to calling those functions directly. The suite must be observed failing against a deliberately broken engine before it counts as evidence (engineering.md §Taste: "a guard is evidence only after it has been seen red").
  • Port code handles WorkflowExecutionResult correctly: it discriminates on success: boolean only, and the paused member {success: true; paused: true} structurally overlaps plain success (packages/workflows/src/schemas/workflow.ts:392-395). Narrowing is via 'paused' in result, not a three-way tag.
  • workflow-resume-service.ts calls engine.resume(...) instead of hydrateResumableRun + executeWorkflow directly; existing tests pass unmodified.
  • The event fields the CLI renders become persisted, written into the existing remote_agent_workflow_events.data JSON column — no DDL, no migration: workflow_started.transcriptPath (rendered at packages/cli/src/commands/workflow.ts:1002, absent from the fields persisted at packages/workflows/src/executor.ts:2903-2916), the node display name on node_completed/node_failed (packages/workflows/src/node-event-write.ts:66-68,129 vs. packages/workflows/src/dag-executor.ts:3206-3220, 3930-3942), and the node id (no node_id column exists; only step_name carrying a loop prefix). Without this, a durable-log-backed subscribe cannot reproduce today's CLI output. This is engineering.md §Taste, "fix drift on the path you touch — one recording a field the other omits".
  • CLI's SIGINT/SIGTERM handling calls engine.cancel(...) instead of its current ad hoc DB-write-and-exit. Note: today's SIGINT/SIGTERM path writes status='failed' (failWorkflowRun), not status='cancelled' — routing it through engine.cancel() (backed by cancelWorkflowRun) means an operator-initiated Ctrl-C will now produce status='cancelled' instead of 'failed'. That is a deliberate, in-scope behavior change (an operator stop is not an execution failure), called out explicitly since it is the one place this migration isn't a pure no-op wrapper — not an oversight. Three invariants must survive:
    • The runLiveOwner?.isStopRequested() handoff at packages/cli/src/commands/workflow.ts:2991 is preserved exactly. It already implements AGENTS.md's rule against marking work terminal when a live owner cannot be distinguished; engine.cancel() must not flatten it.
    • failWorkflowRun throws on CAS miss (packages/core/src/db/workflows.ts:1286) while cancelWorkflowRun returns {cancelled: false} idempotently (:1333). The two are not conflated; the port keeps the Result-object convention.
    • This criterion sits in two of engineering.md §Risk taxonomy's categories at once (terminal state transitions; lifecycle ownership) and therefore takes adversarial review depth and explicit SIGINT/SIGTERM integration coverage, not diff review.
  • CLI's event rendering subscribes via engine.subscribe(...) backed by the durable event log instead of the process-local WorkflowEventEmitter. (Web SSE and the dashboard's separate DB-polling bridge are not touched by this issue — only CLI's consumer needs to move onto engine.subscribe(...).)
  • All host call sites go through engine.submit/engine.resume: the 4 in orchestrator.ts/orchestrator-agent.ts, plus CLI and the resume service. Explicitly excluded: the two intra-module recursive calls in executor.ts (:1504 child sub-run, :1715 parent auto-resume, with hydrateResumableRun at :1390/:1684). They remain direct calls internal to InProcessWorkflowEngine; routing them through the port would require injecting the engine into WorkflowDeps, which the engine itself constructs — new circular coupling with no caller requiring it. There are 8 production executeWorkflow call sites in total, not 6.
  • No public export is removed from @archon/workflows (executeWorkflow and hydrateResumableRun may remain exported, used internally by the in-process implementation) — this issue does not require or perform that removal.
  • No second IWorkflowEngine implementation (Temporal or otherwise) is built as part of this issue. Design choices in this issue are justified on their own merits — durable-backed subscribe for surviving a CLI process restart mid-run, cancel() for removing three inconsistent terminal-status writers — not on portability to a hypothetical second backend, which is not an admissible justification under YAGNI.

Constraints and related work

  • Must remain true: no version bump, no schema migration (no DDL: added event fields go into the existing data JSON column of remote_agent_workflow_events), no removal of an existing public export; ExecuteWorkflowOptions/WorkflowExecutionResult shapes are unchanged (the former is the actual exported type name in executor.ts, not WorkflowExecutionOptions).
  • Scope boundary: this issue is the interface + in-process wrapper + caller migration only. It explicitly does not include designing or building a Temporal-backed (or any other second) engine implementation — that would be a separate, later initiative requiring its own product-direction decision. It also does not include unifying Web SSE / the dashboard's DB-polling bridge onto engine.subscribe(...) — CLI only, per Acceptance.
  • Known prerequisites or blockers: none — IWorkflowStore and IWorkflowPlatform already exist as the precedent pattern to follow. No shared contract-test infrastructure exists yet for those two interfaces either, so the contract-test suite required by Acceptance needs to be authored from scratch, not extended from an existing one.
  • Solution steering: Hint — follow the existing IWorkflowStore/IWorkflowPlatform interface-segregation style (narrow ports, composed rather than one god-interface, id-based rather than handle-based operations) rather than introducing a new DI convention.

Additional notes

This mirrors a pattern already proven outside this repo: @workflowbuilder/temporal (a sibling project) makes the same "engine executes a graph of typed nodes" contract swappable between an in-process runner and Temporal, via a narrow WorkflowEnginePort. Archon's node/graph model (WorkflowDefinition/GraphPlan/DagNode) is already structurally comparable. This issue only proposes the interface-extraction half of that — not adopting Temporal — but keeps that door open for a future, separately-decided initiative without committing to it now.