Runtime Execution
How runWorkflow() drives LWIR steps through the worker harness, with harness session lifecycle, fixer, and two-level durability.
Little Workflow: published on npm as little-workflow@0.2.0-alpha.1 (2026-10-06). Sections labeled UNRELEASED describe source-only changes. Release status
runtime.ts drives the execution of a locked WorkflowVersion. The core loop is unchanged: schedule steps in dependency order, execute each one, write every result boundary to Local World. The implementation of each step has changed fundamentally — all step types now go through the worker harness instead of separate RuntimeAiAdapter / RuntimeCodeRunner / tool-dispatch paths.
Two Entry Points
| Function | When to use |
|---|---|
runWorkflow(options) | Full path: planner harness session drafts LWIR, then worker harness sessions execute steps. This is what authors use. |
executeWorkflowVersion(options) | Post-compile path. Takes an already-locked WorkflowVersion. CLI uses this; tests use it for surgical scenarios. |
runWorkflow() is a thin wrapper. After the planner session completes and LWIR is locked, it appends planner events + WorkflowVersionRegistered + RunStarted, then hands off to executeWorkflowVersionUnlocked().
In-Process Fence
Both entry points wrap execution in withRunExecutionFence(world, runId, fn):
- An in-process
Set<string>rejects concurrent calls for the same(dataDir, runId). - A filesystem lock under
<dataDir>/locks/<sha256(runId)>rejects concurrent calls from other processes. - The lock owner file records
pidand an opaquetokenso stale locks can be reclaimed.
This is what makes kill-and-resume safe.
Full Run Lifecycle
Step Scheduling
The LWIR scheduler is unchanged from before this milestone. It schedules steps in dependency order, handles decision routing, and manages parallel branches concurrently.
Decision-target exclusion (P1.2 fix): decision targets are excluded from the runnable set until their decision step routes to them. A decision that commits { chosen: "targetId" } unlocks exactly one target.
Non-parallel in-flight resume (P1.4 fix): top-level non-parallel steps that were in-flight at kill time resume on the same attemptId rather than raising RuntimeIntegrityError.
const completed = completedStepIds(lwir.steps, state);
const remaining = new Set(lwir.steps.map((s) => s.id).filter((id) => !completed.has(id)));
const decisionTargetIds = new Set(collectDecisionTargetIds(lwir.steps));
while (remaining.size > 0) {
const nextStep = lwir.steps.find((step) =>
remaining.has(step.id) &&
!decisionTargetIds.has(step.id) &&
(step.needs ?? []).every((need) => completed.has(need)),
);
if (nextStep === undefined) {
throw new Error("No runnable LWIR step found; dependencies incomplete or cyclic.");
}
await dispatchStepToWorkerHarness(/* ... */);
completed.add(nextStep.id);
remaining.delete(nextStep.id);
}Harness Session Lifecycle Per Step
For every LWIR step, the runtime:
- Constructs a
WorkerManifest(harness id, workflow definition hash, workflow version id, step config hash, model slot, skills, tools). - Calls
worker.harness.run({ kind: "execute_step", step, stepInput, stepContext }, ctx). - The harness emits
harness.session.started(carrying the manifest + manifest hash) before doing any work. - The harness does its work (one model call for
ai.generate; direct execute forcode.run/tool.call/decision). - The harness emits
harness.session.completed(with output + usage) orharness.session.failed. - The runtime validates output against the step's output schema, writes the artifact, appends
StepCompletedorStepFailed.
harness.session.started carries the WorkerManifest. On resume, the manifest is recomputed and compared; a mismatch raises CapabilityDriftError (applied at the harness session level). See Identity + Manifests.
harness.session.started event shape
{
"type": "harness.session.started",
"payload": {
"runId": "run_abc123",
"role": "worker.ai-generate",
"task": { "kind": "execute_step" },
"manifest": {
"harnessId": "workflowHarness@1.0.0",
"workflowDefinitionHash": "sha256:def123",
"workflowVersionId": "wfv_xyz",
"stepPath": "review",
"stepConfigHash": "sha256:...",
"modelSlotId": "gpt-4o-mini",
"skillsHash": "sha256:...",
"allowedToolsHash": "sha256:...",
"memoryStoreIds": ["workflow:candidate-review"]
},
"manifestHash": "sha256:..."
}
}Worker Dispatch by Step Type
The worker harness (workflowHarness or custom) dispatches internally by step type:
step.uses | Worker behavior | Model in loop? |
|---|---|---|
ai.generate | One AI SDK streamText / generateText call — model only, no tools or bash | Yes |
code.run | Not executed by the default harness — see below. A custom harness receives {input, tools} | No |
tool.call | Direct invocation of the named tool from ctx.tools | No |
decision | Pure expression evaluation against step context | No |
parallel | Orchestrated by the LWIR scheduler, not the harness | — |
ai.generate steps — one model call
For AI steps, the default worker harness makes a single model call:
- Assembles the system prompt: the worker's system prompt and skill list, then the step's
with.systemafter a blank line. - Builds one user message: the step's
with.prompt, thenInput:and the resolved step input. The runtime resolves{{ ... }}expressions inwith.promptandwith.systembefore the harness sees the step. - Offers the model no tools and no bash. The call is one turn and nothing executes a tool the model asks for, so tool use in a workflow is an explicit
tool.callstep. A customworker.harnessstill receivesctx.tools. - Emits
harness.model.called→ invokes the model → emitsharness.model.responded. - Returns the output; the runtime validates it against the step's output contract.
code.run steps — the worker-harness contract
The default worker harness does not execute
code.runbodies. It validates the step's sandbox policy and then throws"cannot safely execute code.run in the default workflow harness — the declared sandbox.env/fs/network deny policy requires an isolated runtime".code.runis a valid LWIR step type, it compiles, and the runtime dispatches it, but actually running one requires supplying your ownworker.harnessthat implements theexecute_steptask. The steps below describe the contract such a harness must satisfy — not behavior you get for free.
For code steps, no model is invoked. A conforming harness:
- Emits
harness.session.started+harness.execute_step.started. - Injects a
toolsproxy into the JS function:async ({ input, tools }) => output. - Each
tools.x(args)call from within the code body emitsharness.tool_call.started(caller: "code")→ invokes the tool → emitsharness.tool_call.succeeded, harness.tool_call.failed. - Emits
harness.execute_step.succeeded+harness.session.completed.
The model never sees tool results from code.run — token savings vs. a model-in-the-loop approach.
Fixer — fix_step harness session
If a code.run step fails AND the step declares onFailure.fixer, the runtime spawns a Fixer harness session:
const fixResult = await worker.harness.run({
kind: "fix_step",
step,
stepInput,
originalAttemptError: errorEnvelope,
priorFixAttempts: 0,
}, ctx);The fixer is a full agent loop. It gets the failed code body, the error, the output schema, bash, and scratch. It iterates: edit code → run in scratch → inspect output → repeat. On success, harness.session.completed carries fixedSource. The fixed source is used for this run only — LWIR stays immutable. Persistent fixes go through memory: the fixer's system prompt encourages writing a lesson to /mnt/memory/workflow/lessons-learned.md for future planners.
The fixer task kind is fix_step — it maps to HarnessRole: "fixer" in the session started event.
See Fixer for the full fixer spec.
Per-Step Events
For each step, the runtime appends LWIR-level events alongside the harness session events:
StepScheduledStepAttemptStarted- (harness session events:
harness.session.started, model/tool events,harness.session.completed) StepOutputValidatedArtifactCreatedStepCompleted— durable boundary; inline output +outputRef
If the harness session fails:
StepFailed— witherror: { name, message, causeCode, retriable }.- If retriable and
attempt < maxAttempts: loop back toStepAttemptStarted. - If fixer declared and code.run: spawn fixer session before deciding terminal.
Terminal causes (cancelled, timeout, capability_drift, runtime_config_error, step_schema_error) skip retry.
Input Resolution
resolveStepInput() builds the input value the harness receives. Unchanged from prior alpha:
step.inputif present — resolved throughresolveExpressionValue()against(input, item, steps, workflow).- Otherwise an empty object.
Capability Drift Checks
Before dispatching each step, the runtime:
- Recomputes the
WorkerManifestfor this step. - Reads the
harness.session.startedevent for this step (if a prior attempt committed one). - Compares
manifestHash. Mismatch →CapabilityDriftError(causeCode: "capability_drift").
For ai.generate steps: model slot id changing is drift. For code.run steps: harness id or step config hash changing is drift. This replaces the prior per-field modelIdentityHash + tool descriptor checks with a single manifest hash comparison.
Outer-Loop Scheduler
The outer-loop scheduler in outer-loop.ts is unchanged. It wraps the per-cycle compile-and-execute loop:
- Reads or creates the outer-loop manifest at
<dataDir>/outer-loops/{outerLoopId}.json. - For each cycle: writes
pendingCycleRunId, runsrunWorkflow, appendsOuterLoopCycleCompleted. - Calls the planner's
supervise()method to get{ kind: "done" | "continue" }. - On
continue, the next cycle's planner harness session receivesouterLoopContextHashin its manifest (hash of prior cycles).
Run Finalization
After the LWIR loop exits cleanly:
- Resolves the final step (last step whose output matches the workflow's output schema).
- Reads the final step's
outputRefand rehydrates the payload. - Validates against the workflow's output schema.
- Appends
RunCompletedwithoutput,outputRef,terminalStepPath.
Non-recoverable errors append RunFailed with a structured error envelope and causeCode.
Resume Semantics
When executeWorkflowVersionUnlocked() starts with an existing event log:
- Materializes run state via
materializeRunStateFromEvents(). - Verifies persisted
RunStarted.inputmatchesoptions.input. - Verifies persisted
workflowVersionId/workflowVersionHashmatch the supplied lock. - For each completed step: verifies the artifact still exists with the right sha256.
- Skips steps whose
StepCompletedevents are committed. - For steps whose harness session started but did not complete: checks harness-level events — completed model/tool calls fast-forward from the event cache; only the in-flight call re-executes.
This is the two-level durability model: LWIR per-step memoization composed with harness per-model-call + per-tool-call memoization. See Replay for a full treatment.
Cancellation And Timeout
options.signalandoptions.timeoutmerge into a singleCancellationController.- Every harness context receives
ctx.abortSignal. The harness honors it: in-flight model calls are allowed to complete (best effort); in-flight tool calls are signaled;HarnessCancelledpropagates up. - The runtime writes
RunFailed(causeCode: "cancelled" | "timeout").
Idempotency Guarantees
- Same
runId+ same input → returns persisted result or replays persistedRunFailedError. RunCompletedis final: the terminal step'soutputRefis asserted equal to the run'soutputRef. Mismatch raisesRuntimeIntegrityError.- The fence prevents concurrent live processes from racing the same run.