Runtime & operations¶
What happens after a workflow compiles: where state lives, how instances and effects move through their lifecycles, how failures surface, and how to operate running instances.
Runtime loops¶
The runtime separates deterministic progress from provider execution:
| Loop | Responsibility |
|---|---|
| starter | Create an instance and seed input facts/events. |
| stepper | Evaluate ready rules; commit facts, effects, and dependencies atomically. |
| worker | Claim ready effects, run providers under leases, record completions. |
| projection | Maintain current views (facts, effects, status, traces) over the event log. |
step never executes providers. worker never invents policy. dev
composes the loops for local convenience without changing the boundaries.
Concurrent effect execution¶
A worker pass executes its ready set of effects concurrently on a bounded
thread pool — the ready set is mutually independent (a dependent effect is not
claimable until its dependency's terminal), and the durable lease plus per-row
idempotency guarantee exactly-once even under concurrency (the
AtMostOneRunExecutingEffect invariant in models/tla/ControlPlaneLifecycle.tla).
This is what lets a fan-out of agent turns or coerce calls run in parallel
instead of one at a time, and gives agent X { capacity N } real runtime
meaning: a worker starts at most N turns of an agent at once and defers the
rest to a later pass. WHIPPLESCRIPT_WORKER_CONCURRENCY sets the per-pass bound
(default tracks available CPUs, capped); set it to 1 for a fully serial pass.
Each effect runs synchronously on its own pool thread (whip is not async); WAL
mode and a busy timeout let the per-effect store writes coexist safely, while
the slow provider I/O runs outside any transaction. Scale further by running
more worker processes against the same store.
The store¶
State lives in a SQLite file, by default .whipplescript/store.sqlite.
Select one explicitly per environment with --store <path> or the
WHIPPLESCRIPT_STORE environment variable; every command that touches an
instance must use the store that created it.
The store holds program versions, instances, the append-only event log, and projections over it: facts, effects and their dependencies, provider runs, leases, workflow invocations, inbox items, evidence, artifacts, and registered capabilities, profiles, packages, and providers. The event log is the source of truth; everything else can be rebuilt from it.
Instance lifecycle¶
running -> paused -> running (pause / resume)
running -> completed (a rule ran `complete`)
running -> failed (a rule ran `fail`)
running -> cancelled (operator ran `cancel`)
completed, failed, and cancelled are terminal: the instance rejects
further rule commits and lifecycle transitions. Cancellation is an operator
action, distinct from workflow fail — there is no source syntax for it.
Effects, runs, and leases¶
An effect is a durable request for external work. A run is one provider attempt at an effect. A lease protects a running attempt from being claimed by a second worker.
queued -> running -> completed | failed | timed_out | cancelled
queued -> blocked_by_dependency | blocked_by_capacity
| blocked_by_capability | blocked_by_profile
queued -> blocked (provider binding unavailable; recoverable)
A blocked effect is recoverable, not terminal: a later worker pass runs it
once the block clears. A provider-binding failure detected before provider
execution — the provider sidecar cannot launch, or a present provider config
lacks a required credential reference — blocks the effect rather than failing it,
so fixing the binding lets the run resume without a manual re-trigger. Every
blocked effect carries a categorized reason in whip effects/status as
policy_block: { category, detail }, where category is one of capability,
profile, capacity, dependency (scheduling-time) or provider_health,
credentials (binding-time). The detail never contains secret values.
A provider failure is recorded as effect/run state, events, and evidence — it
does not fail the workflow. Rules decide policy: retry, escalate, ignore,
or execute fail. This is the central operational property; an instance with
ten failed provider runs is still running until a rule or operator says
otherwise.
| Outcome | Recorded as | Instance state |
|---|---|---|
| Provider run failed or timed out | effect/run terminal state, events, evidence | unchanged until rules react |
Rule executed fail ... { ... } |
workflow.failed event, terminal payload |
failed |
Operator ran cancel |
transition event | cancelled |
Provider adapters capture real diagnostics — exit codes, stderr excerpts, SDK errors, timeout reasons, artifact paths, correlation ids — as evidence. Secrets are never persisted.
Inspecting an instance¶
whip --store <store> instances
whip --store <store> status <instance>
whip --store <store> log <instance>
whip --store <store> facts <instance>
whip --store <store> effects <instance>
whip --store <store> runs <instance>
whip --store <store> diagnostics <instance>
whip --store <store> --json evidence <instance>
whip --store <store> --json trace <instance> --check
When an effect did not run, work down this list in order: effects (status
and policy_block_reason), runs (provider attempts), diagnostics,
evidence, then trace --check for lifecycle conformance. Add --json to
any of these for machine-readable output.
Operating an instance¶
whip --store <store> pause <instance> # block new provider starts
whip --store <store> resume <instance>
whip --store <store> cancel <instance> # terminal
whip --store <store> retry <instance> <effect>
retry moves an eligible failed or timed-out effect back to queued.
recover reconciles interrupted native provider runs from persisted
evidence after a crash; see providers & packages. When a run
started but crashed before any terminal or evidence was recorded and the
provider offers no idempotent re-query, recovery resolves it to an uncertain
run terminal: the effect becomes failed (so rules' fails branches react)
and the run carries the runtime.recovery_uncertain diagnostic, marking "we
could not confirm whether the external side effect happened." Recovery never
silently re-runs such an effect; retry is an explicit operator decision.
Child workflows¶
invoke Workflow { ... } as child creates a durable child instance with its
own event log. The parent's invocation effect resolves from the child's
terminal state — declared output payload on completion, declared failure
payload on failure. The parent never reads child-local facts directly.
Revising a running instance¶
whip revise switches a non-terminal instance to a new program version
after compatibility checks. Always preview first:
whip --store <store> revise <instance> candidate.whip --root Workflow --dry-run
whip --store <store> revise <instance> candidate.whip --root Workflow --cancel keep
Revision is append-only: the runtime records an activation event, a new
revision epoch, and diagnostics. Existing effects keep their original
program version; future commits use the new one. The --cancel policy
controls what happens to old-version work:
| Policy | Effect on old-version work |
|---|---|
keep |
Stays claimable and runnable. |
queued |
Queued/blocked/claimable effects are terminally cancelled. |
running |
As queued, plus cancellation is requested for running effects. |
A running cancellation is a request, not a result — the provider still
records the terminal outcome through the normal effect lifecycle.
Revision is limited to the same root workflow in v0. Root changes and
schema-breaking fact migrations are tracked in
spec/workflow-revision-followups-tracker.md.
Capturing an incident¶
Before repairing or deleting runtime state, capture the JSON views:
for cmd in status log facts effects runs evidence; do
whip --store <store> --json $cmd <instance> > incident-$cmd.json
done
whip --store <store> --json trace <instance> --check > incident-trace.json
For provider issues, also preserve artifacts and the provider configuration names involved — never credential values.