Skip to main content
The .lobsterX interpreter is the heart of Forge. It parses .lobsterX YAML workflow definitions, resolves the dependency graph into parallel execution layers, and orchestrates step-by-step execution through governance gates, condition evaluation, output validation, and failure recovery. The interpreter runs as a Convex durable workflow (@convex-dev/workflow), meaning it survives process restarts and resumes from its last event checkpoint. Source files:
FilePurpose
convex/interpreter.tsMain interpreter — workflow definition, event loop, dispatch functions (~2200 lines)
convex/lib/interpreter_utils.tsPure DAG resolution, dependency inference, condition evaluation
convex/lib/interpreter/completion_handlers.tsCompletion processing for agent steps, approvals, sub-workflows, failure policies
convex/lib/interpreter/helpers.tsEvent parsing, stdin resolution, artifact utilities
convex/lib/interpreter/side_effects.tsSync-path side effect processing (token events, etc.)
convex/validation.tsOutput coercion, schema validation, success criteria evaluation
convex/dispatch.tsStep dispatch with governance gate integration

DAG Resolution

When a workflow run starts, the interpreter parses the .lobsterX YAML string into a LobsterXDocument (Zod-validated via validateLobsterXDocument from @tangogroup/shared), then resolves the step dependency graph into execution layers — groups of steps that can run in parallel.

How YAML Becomes an Execution Graph

The resolution pipeline in the interpreter’s handler:
  1. Parse YAMLjs-yaml loads the string, Zod validates the structure.
  2. Resolve library steps — Steps referencing library_step merge their library definition with workflow-level overrides. Library provides defaults; workflow fields win.
  3. Infer dependencies — Each step’s dependencies come from two sources: the explicit depends_on array and implicit references in stdin (e.g., $init.stdout implies a dependency on step init).
  4. Build layersresolveExecutionLayers() performs a topological sort, grouping steps whose dependencies are all satisfied into the same layer.

Key Functions

inferDependencies (convex/lib/interpreter_utils.ts) — Extracts all dependencies for a step, combining explicit depends_on with stdin-derived implicit deps:
export function inferDependencies(step: LobsterXStep): string[] {
  const deps = new Set<string>(step.depends_on ?? []);

  if (step.stdin) {
    // Match "$step_id.stdout" or "$step_id.approved"
    const match = step.stdin.match(/^\$([a-z][a-z0-9_]*)\./);
    if (match?.[1]) {
      deps.add(match[1]);
    }
  }

  return Array.from(deps);
}
resolveExecutionLayers (convex/lib/interpreter_utils.ts) — Topological sort that produces layers of parallelizable steps:
export function resolveExecutionLayers(steps: LobsterXStep[]): LobsterXStep[][] {
  const stepMap = new Map<string, LobsterXStep>();
  const depsMap = new Map<string, string[]>();

  for (const step of steps) {
    stepMap.set(step.id, step);
    depsMap.set(step.id, inferDependencies(step));
  }

  const layers: LobsterXStep[][] = [];
  const resolved = new Set<string>();
  const remaining = new Set(steps.map((s) => s.id));

  while (remaining.size > 0) {
    const layer: LobsterXStep[] = [];

    for (const id of remaining) {
      const deps = depsMap.get(id) ?? [];
      if (deps.every((d) => resolved.has(d))) {
        layer.push(stepMap.get(id)!);
      }
    }

    if (layer.length === 0) {
      throw new Error(
        `Circular or unresolvable dependencies detected among steps: ${Array.from(remaining).join(", ")}`
      );
    }

    layers.push(layer);
    for (const step of layer) {
      resolved.add(step.id);
      remaining.delete(step.id);
    }
  }

  return layers;
}
If the algorithm reaches a state where no remaining step has all dependencies resolved, it throws a circular dependency error. This is defense-in-depth — the Zod schema catches most structural issues at parse time.

Step State Machine

Every step in a workflow run is tracked as a stepResults row in Convex. The step status progresses through a defined set of states:
// convex/schema/step_results.ts
export const STEP_STATUSES = [
  "pending",
  "running",
  "completed",
  "failed",
  "skipped",
  "timed_out",
] as const;
export type StepStatus = (typeof STEP_STATUSES)[number];

State Transitions

Step State Machine

FromToTrigger
pendingrunningmarkRunning — step dispatched to gateway or edge agent
pendingskippedmarkSkipped — condition not met, or all dependencies skipped
runningcompletedmarkCompleted — agent returned successfully, outputs validated
runningfailedmarkFailed — agent error, output validation failure, governance block, or timeout
runningskippedmarkSkippedon_failure: skip policy applied after failure
runningtimed_outTimeout exceeded — step did not complete within its configured timeout
failedrunningmarkRunning (retry) — on_failure: retry_once or retry_once_then_escalate triggers re-dispatch
AnyfailedCascade halt — upstream dependency failed or was cancelled

In-Memory Tracking Sets

The interpreter’s event loop maintains several Set<string> collections that track step progress without hitting the database on every check:
SetPurpose
completedStepsSteps that finished successfully
skippedStepsSteps skipped by condition, cascade, or failure policy
dispatchedStepsSteps currently dispatched (in-flight)
failedStepsSteps that halted (failed with no recovery)
cancelledStepsSteps cancelled by fail_fast parallel failure policy
retryDispatchedSteps currently dispatched for a retry attempt
escalationResolvedSteps that already went through escalation (prevents infinite loops)

The Execution Loop

The interpreter uses a single-event loop pattern rather than spawning concurrent awaitEvent calls. This is a deliberate design choice: @convex-dev/workflow’s event barrier means concurrent await calls block each other. The single-loop approach guarantees at most one awaitEvent is in flight at a time.

Loop Structure

while (true) {
  1. Find ready steps (deps met, not dispatched/completed/skipped/failed/cancelled)
  2. Dispatch each ready step (fire-and-forget)
  3. Check termination: all steps resolved? -> break
  4. awaitEvent("completion") -- single wait for any completion
  5. Route completion by type (step_complete | approval | subworkflow)
  6. Process result -> update state accumulators
  7. Loop back to 1
}

Step 1: Finding Ready Steps

For each step not yet resolved, the loop checks:
  1. All dependencies resolved? — Every dep must be in completedSteps or skippedSteps.
  2. Upstream halted? — If any dependency is in failedSteps or cancelledSteps, the step is cascade-halted. The interpreter creates a stepResult row and marks it failed with error "Blocked by upstream failure".
  3. Deterministic ordering — Ready steps are sorted by step.id (localeCompare) so replay order is deterministic.

Step 2: Dispatch Routing

Each ready step goes through dispatch routing based on its type:
Step TypeDispatch FunctionDescription
approval: "required"dispatchApproval()Creates a pending approval record, pauses for human decision
workflow_ref setdispatchSubWorkflow()Launches a child workflow run and awaits its completion
Default (agent step)dispatchAgentStep()Routes to gateway via three-way fork: edge, sync cloud, or async cloud
dispatchAgentStep returns either a StepCompleteEvent (for sync-eligible steps executed inline) or null (for async steps that will call back later). Sync completions are processed immediately without waiting for an event.

Steps 3-4: Termination and Event Wait

The loop checks two termination conditions:
  • All resolved: completedSteps + skippedSteps + failedSteps + cancelledSteps >= allStepIds — all steps have a final disposition.
  • Stalled: No ready steps and no in-flight steps, but unresolved steps remain. This indicates an unresolvable dependency and throws "Workflow stalled".
If neither condition is met and there are in-flight steps, the loop calls step.awaitEvent({ name: "completion" }) to block until a completion event arrives.

Steps 5-6: Completion Routing

Completion events are parsed and routed by type:
// convex/lib/interpreter/helpers.ts
export type CompletionEventType = "step_complete" | "approval" | "subworkflow";

export type StepCompleteEvent = {
  type: "step_complete";
  stepId: string;
  status?: string;
  outputs?: unknown;
  error?: string;
  startedAt?: number;
  completedAt?: number;
  model?: string;
  inputTokens?: number;
  outputTokens?: number;
  thinkingTokens?: number;
  totalTokens?: number;
  cost?: number;
  sessionId?: string;
  executionPath?: "convex-native" | "bridge-sync" | "bridge";
};
Each type delegates to its handler: processAgentCompletion, processApprovalCompletion, or processSubWorkflowCompletion.

Condition Evaluation

Steps can declare a condition field that determines whether the step should execute based on upstream outputs. The interpreter evaluates conditions before dispatching.

Condition Expressions

evaluateCondition() in convex/lib/interpreter_utils.ts supports:
PatternExampleDescription
Comparisoninit.outputs.vendor_type == 'saas'Compare step output to a literal value
Approval check$final_approval.approvedCheck if an approval step was approved
Operators==, !=, >, >=, <, <=, inFull comparison operator set
Dot-path resolutionstep_id.outputs.field.nestedTraverse nested output structures
List membershipvendor_type in ['saas','paas']Check if a value is in a set

Condition Evaluation Flow

condition defined?
+-- No -> dispatch step normally
+-- Yes -> evaluateCondition(condition, accumulatedOutputs, variables)
    +-- Parse error -> fail step, apply on_failure policy
    +-- shouldRun: true -> dispatch step
    +-- shouldRun: false -> mark skipped, emit notification

Dependency-Based Parallelism

The DAG naturally expresses parallelism. Steps in the same execution layer (no inter-dependencies) are dispatched in the same loop iteration. The interpreter does not explicitly schedule “parallel groups” — parallelism is an emergent property of the dependency graph.

Skip Cascading

When ALL of a step’s dependencies were skipped (not just some), the step itself is skipped. This prevents dispatching work that depends entirely on outputs that were never produced.

Variable Resolution

Step outputs are accumulated in a variables map. Commands can reference variables with ${var} interpolation. The resolveDotPath() function resolves dot-separated paths against both accumulated step outputs and runtime variables:
export function resolveDotPath(
  path: string,
  outputs: Record<string, Record<string, unknown>>,
  variables: Record<string, unknown>,
): unknown {
  const parts = path.split(".");

  // Try step outputs first: "step_id.outputs.field" or "step_id.field"
  if (parts.length >= 2) {
    const stepId = parts[0]!;
    const stepOutputs = outputs[stepId];
    if (stepOutputs) {
      let current: unknown = stepOutputs;
      const fieldParts = parts[1] === "outputs" ? parts.slice(2) : parts.slice(1);
      for (const part of fieldParts) {
        if (current && typeof current === "object"
            && part in (current as Record<string, unknown>)) {
          current = (current as Record<string, unknown>)[part];
        } else {
          return undefined;
        }
      }
      return current;
    }
  }

  // Fall back to variables
  return variables[path] ?? variables[parts[0]!];
}

Dispatch Integration

When the interpreter dispatches a step, it calls into convex/dispatch.ts, which runs the governance pipeline before forwarding to the execution adapter.

Governance Gate Flow

dispatchStep (an internalAction in convex/dispatch.ts) runs the governance pipeline before any execution:
  1. Run governance gates — Calls trust_fabric.bindings.runGovernanceGates which evaluates gateway health, concurrency limits, and budget constraints.
  2. Retry retryable blocks — If the gate returns block with retryable: true, dispatch retries up to 3 times with exponential backoff (1s, 2s, 4s).
  3. Persist decision — The GovernanceDecision is logged to the audit trail.
  4. Route by disposition:
    • allow — proceed to dispatch
    • block (non-retryable) — mark step failed, throw SafetyGateError
    • hold — create a pending approval, pause step until human decides
// convex/dispatch.ts
export class SafetyGateError extends Error {
  errorCode: string;
  retryable: boolean;

  constructor(errorCode: string, retryable: boolean, message: string) {
    super(message);
    this.name = "SafetyGateError";
    this.errorCode = errorCode;
    this.retryable = retryable;
  }
}

Three-Way Dispatch Fork

After governance approval, dispatchAgentStep in convex/interpreter.ts determines the execution path:
PathConditionBehavior
Edge dispatchexecutionTarget === "convex-native"Executes directly in Convex via agents/edge_dispatch. Supports multi-turn continuation. Returns result synchronously.
Sync cloud dispatchCloud agent with timeout of 270s or lessCalls Bridge sync endpoint. Returns result synchronously.
Async cloud dispatchEverything elseFire-and-forget to Bridge. Gateway calls back to Convex when done.

Routing

Before dispatch, the interpreter resolves which gateway should handle the step using a layered routing stack:
  1. Layer 0: Pinned gateway (from agent deployment binding)
  2. Layer 1: Capability matching (required capabilities from step or agent definition)
  3. Layer 2: Cost/provider preferences
  4. Layer 3: Deploy-aware routing (when feature flag is enabled)
  5. Layer 4: Health ranking
Routing decisions are audited with category "routing".

Failure Handling

The interpreter supports configurable failure policies per step via the on_failure field. When a step fails — whether from agent error, output validation failure, success criteria failure, or timeout — the failure policy determines what happens next.

Failure Policies

// From the .lobsterX schema (packages/shared/src/lobsterx-schema.ts)
on_failure: z.enum(["halt", "retry_once", "retry_once_then_escalate", "skip"]).optional()
PolicyBehavior
halt (default)Stop the workflow. The step is marked failed, a failure snapshot is saved, and the error propagates up.
skipMark the step as skipped with _skipped: true in its outputs. Downstream steps see the skip and may cascade-skip if all their deps are skipped.
retry_onceRe-dispatch the step for one retry attempt. If the retry also fails, halt.
retry_once_then_escalateRetry once. If the retry fails, create an escalation approval for a human to decide: approve/retry, skip, or abort. Prevents infinite escalation loops — if the step already went through escalation, it halts.

Failure Policy Resolution

The handleFailurePolicy function in convex/lib/interpreter/completion_handlers.ts returns a PolicyResult:
export type PolicyResult = {
  result: "completed" | "skipped" | "retry" | "escalate" | "halted";
  error?: string;
};
The interpreter’s event loop uses this result to decide the next action:
  • completed — add to completedSteps, save checkpoint
  • skipped — add to skippedSteps
  • retry — mark failed (to release concurrency slot), re-dispatch with retryAttempt >= 1
  • escalate — dispatch an escalation approval, keep in dispatchedSteps
  • halted — add to failedSteps, throw error to halt the workflow

Parallel Failure Policy

Steps can also declare parallel_failure_policy:
parallel_failure_policy: z.enum(["wait_all", "fail_fast"]).optional()
PolicyBehavior
wait_all (default)Other in-flight steps continue. Failure is processed after all sibling completions.
fail_fastWhen a step fails, all in-flight sibling steps are immediately cancelled. Their stepResult rows are marked failed with errorCode: "condition_failed".

Failure Snapshots and Restart

When the workflow fails, the interpreter saves a failure snapshot to the workflowRuns document before throwing:
{
  completedStepIds: Array.from(completedSteps),
  skippedStepIds: Array.from(skippedSteps),
  cancelledStepIds: Array.from(cancelledSteps),
  variableOverwriteOrder,
  variables,
  accumulatedOutputs,
}
This snapshot enables restart-from-step: a new run can be created from a failed run, and the interpreter will pre-populate its state from the snapshot. computeTransitiveDependents() determines which steps need to re-run (the failed step plus all its transitive dependents), while previously completed steps are carried over.

Output Coercion

LLM agents frequently return outputs in structurally compatible but type-mismatched formats. The validation layer in convex/validation.ts provides a two-stage pipeline: coerce first, validate second.

Stage 1: Coercion

coerceOutputs() transforms common LLM output quirks into the expected types:
Expected TypeActual ValueCoercion
stringobject or arrayJSON.stringify(value)
stringnumber or booleanString(value)
number / integerstring "42"Number(value), rounded for integer
booleanstring "true" / "yes"true
booleanobject { result: true }Extract from known keys (result, value, verified, passed, status)
arraysingle valueWrap in array: [value]

Stage 2: Validation

validateOutputs() checks the coerced outputs against the step’s output schema. Supported constraints:
ConstraintApplies ToExample
typeAll fieldsstring, number, boolean, object, array, integer
requiredAll fieldsDefaults to true if not specified
enumString/number["acceptable", "needs_remediation"] — includes prefix-match tolerance
minimum / maximumNumbersRange bounds
minLength / maxLengthStringsLength bounds
patternStringsRegex pattern
items.typeArraysValidates element types (shallow, first-bad-item only)
The validator does not use AJV or any library that relies on dynamic code evaluation. Convex disallows dynamic code construction, so the validator is a lightweight, hand-rolled implementation that covers the .lobsterX output field subset.

Enum Prefix Tolerance

Both validateOutputs() and evaluateIn() support prefix matching for enum values. LLMs frequently append explanatory context after the expected token (e.g., "COMPLETE -- all checks passed" when "COMPLETE" was expected). The validator accepts this as a match.

Stage 3: Success Criteria

After outputs pass validation, optional success_criteria expressions are evaluated:
success_criteria:
  - "outputs.risk_score >= 1"
  - "outputs.findings.length > 0"
  - "outputs.budget_impact in ['low','medium','high']"
evaluateSuccessCriteria() supports operators ==, !=, >, >=, <, <=, and in. All criteria must pass. Failures are routed through the step’s on_failure policy.

Input Passthrough

When a step declares inputs, any input field that is NOT also declared in outputs is treated as a passthrough. The upstream authoritative value is preserved in accumulatedOutputs and the agent cannot overwrite it. This prevents a downstream agent from accidentally corrupting data it received as input (e.g., a classifier flipping a boolean it was only supposed to read).
// From completion_handlers.ts
const merged = { ...agentOutputs };
if (lobsterXStep.inputs) {
  for (const [key, inputDef] of Object.entries(lobsterXStep.inputs)) {
    if (lobsterXStep.outputs && key in lobsterXStep.outputs) continue; // agent owns this field
    // ... resolve from upstream outputs and carry forward
  }
}
Additionally, convex/validation.ts includes a structural check (validateLobsterXStructure) that warns at parse time if a field appears in both inputs and outputs for the same step. These pass-through outputs are flagged because agents that re-emit upstream data risk corrupting it.

Checkpointing

The interpreter saves a checkpoint after every step completion (both sync and async paths). Checkpoints are stored via workflowCheckpoints.saveCheckpoint and contain the full interpreter state: completed step IDs, skipped step IDs, variable overwrite order, variables, and accumulated outputs. This enables recovery if the Convex durable workflow is interrupted mid-execution.

StepContext — The Interpreter’s State Bag

All dispatch and completion handler functions receive a StepContext that holds the mutable state of the running workflow:
export interface StepContext {
  runId: Id<"workflowRuns">;
  workflowName: string;
  workflowVersion: number;
  gatewayId: Id<"gatewayRegistry">;
  roleBindings: Record<string, string>;
  accumulatedOutputs: Record<string, Record<string, unknown>>;
  stepResults: Record<string, { outputs?: unknown; stdout?: string }>;
  skippedSteps: Set<string>;
  variables: Record<string, unknown>;
  definition: LobsterXDocument;
  variableOverwriteOrder: string[];
  traceId?: string;
  rootSpanId?: string;
}
accumulatedOutputs is keyed by step ID and holds the merged outputs (agent outputs + input passthroughs). variables is a flat key-value map that grows as steps complete — later steps overwrite earlier values for the same key. variableOverwriteOrder tracks which step wrote which variable, enabling correct replay during restart.