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:
| File | Purpose |
|---|
convex/interpreter.ts | Main interpreter — workflow definition, event loop, dispatch functions (~2200 lines) |
convex/lib/interpreter_utils.ts | Pure DAG resolution, dependency inference, condition evaluation |
convex/lib/interpreter/completion_handlers.ts | Completion processing for agent steps, approvals, sub-workflows, failure policies |
convex/lib/interpreter/helpers.ts | Event parsing, stdin resolution, artifact utilities |
convex/lib/interpreter/side_effects.ts | Sync-path side effect processing (token events, etc.) |
convex/validation.ts | Output coercion, schema validation, success criteria evaluation |
convex/dispatch.ts | Step 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:
- Parse YAML —
js-yaml loads the string, Zod validates the structure.
- Resolve library steps — Steps referencing
library_step merge their library definition with workflow-level overrides. Library provides defaults; workflow fields win.
- 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).
- Build layers —
resolveExecutionLayers() 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
| From | To | Trigger |
|---|
pending | running | markRunning — step dispatched to gateway or edge agent |
pending | skipped | markSkipped — condition not met, or all dependencies skipped |
running | completed | markCompleted — agent returned successfully, outputs validated |
running | failed | markFailed — agent error, output validation failure, governance block, or timeout |
running | skipped | markSkipped — on_failure: skip policy applied after failure |
running | timed_out | Timeout exceeded — step did not complete within its configured timeout |
failed | running | markRunning (retry) — on_failure: retry_once or retry_once_then_escalate triggers re-dispatch |
| Any | failed | Cascade 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:
| Set | Purpose |
|---|
completedSteps | Steps that finished successfully |
skippedSteps | Steps skipped by condition, cascade, or failure policy |
dispatchedSteps | Steps currently dispatched (in-flight) |
failedSteps | Steps that halted (failed with no recovery) |
cancelledSteps | Steps cancelled by fail_fast parallel failure policy |
retryDispatched | Steps currently dispatched for a retry attempt |
escalationResolved | Steps 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:
- All dependencies resolved? — Every dep must be in
completedSteps or skippedSteps.
- 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".
- 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 Type | Dispatch Function | Description |
|---|
approval: "required" | dispatchApproval() | Creates a pending approval record, pauses for human decision |
workflow_ref set | dispatchSubWorkflow() | 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:
| Pattern | Example | Description |
|---|
| Comparison | init.outputs.vendor_type == 'saas' | Compare step output to a literal value |
| Approval check | $final_approval.approved | Check if an approval step was approved |
| Operators | ==, !=, >, >=, <, <=, in | Full comparison operator set |
| Dot-path resolution | step_id.outputs.field.nested | Traverse nested output structures |
| List membership | vendor_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:
- Run governance gates — Calls
trust_fabric.bindings.runGovernanceGates which evaluates gateway health, concurrency limits, and budget constraints.
- Retry retryable blocks — If the gate returns
block with retryable: true, dispatch retries up to 3 times with exponential backoff (1s, 2s, 4s).
- Persist decision — The
GovernanceDecision is logged to the audit trail.
- 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:
| Path | Condition | Behavior |
|---|
| Edge dispatch | executionTarget === "convex-native" | Executes directly in Convex via agents/edge_dispatch. Supports multi-turn continuation. Returns result synchronously. |
| Sync cloud dispatch | Cloud agent with timeout of 270s or less | Calls Bridge sync endpoint. Returns result synchronously. |
| Async cloud dispatch | Everything else | Fire-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:
- Layer 0: Pinned gateway (from agent deployment binding)
- Layer 1: Capability matching (required capabilities from step or agent definition)
- Layer 2: Cost/provider preferences
- Layer 3: Deploy-aware routing (when feature flag is enabled)
- 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()
| Policy | Behavior |
|---|
halt (default) | Stop the workflow. The step is marked failed, a failure snapshot is saved, and the error propagates up. |
skip | Mark 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_once | Re-dispatch the step for one retry attempt. If the retry also fails, halt. |
retry_once_then_escalate | Retry 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()
| Policy | Behavior |
|---|
wait_all (default) | Other in-flight steps continue. Failure is processed after all sibling completions. |
fail_fast | When 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 Type | Actual Value | Coercion |
|---|
string | object or array | JSON.stringify(value) |
string | number or boolean | String(value) |
number / integer | string "42" | Number(value), rounded for integer |
boolean | string "true" / "yes" | true |
boolean | object { result: true } | Extract from known keys (result, value, verified, passed, status) |
array | single value | Wrap in array: [value] |
Stage 2: Validation
validateOutputs() checks the coerced outputs against the step’s output schema. Supported constraints:
| Constraint | Applies To | Example |
|---|
type | All fields | string, number, boolean, object, array, integer |
required | All fields | Defaults to true if not specified |
enum | String/number | ["acceptable", "needs_remediation"] — includes prefix-match tolerance |
minimum / maximum | Numbers | Range bounds |
minLength / maxLength | Strings | Length bounds |
pattern | Strings | Regex pattern |
items.type | Arrays | Validates 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.
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.