Skip to content

Diagnostics Guide

Use this page when the whip check, whip run, or whip revise command reports an error. Also use this page when a command for inspection at run time reports an error. For the syntax of a command and for the shapes of the JSON, refer to the CLI reference and to the JSON reference.

Read the diagnostic first. A message names what it rejected, points at it, and carries the repair, so most faults need nothing from this page. This page is for the categories — the design rule behind a refusal — and for the cases a message cannot fully explain on one screen.

How to read a diagnostic

error[type.unknown_enum_variant]: enum `ReviewStatus` has no variant `Maybe`
   --> examples/invalid/bad-record.whip:25:12
   |
25 |     status Maybe
   |            ^^^^^
   = note: `status` is declared `ReviewStatus` here (examples/invalid/bad-record.whip:15:10)
   = help: use one of: Accept, Blocked, Revise

Four parts, and each one has a job:

  • The head gives a severity, a code in brackets, and what the compiler rejected.
  • The location gives the file, the line and the column, with a caret under the token the fault is about — not under the rule that contains it.
  • A = note: line appears when some other place in the source explains the error. Here it is the declaration the value had to satisfy.
  • A = help: line gives the repair. Where the compiler can tell which name you meant, or which values are legal, the help line says so.

An error refuses the program. A warning does not: it reports a hazard that the runtime resolves by a documented default. Read a warning before you decide to keep it.

Diagnostic codes

The bracketed code — type.unknown_enum_variant above — names the fault, not the stage of the compiler that caught it. Two places that reject the same mistake carry the same code, and a code is never reused for a different mistake.

A code is stable. Once any program in the WhippleScript source tree is known to make the compiler emit a given code, that code is frozen, so it is safe to grep for in a log, to pin an acceptance fixture on, and to search this page by. The source tree carries the register of every code the compiler can emit, in spec/diagnostic-codes.txt; a code no program has yet produced is marked there as provisional and may still be corrected before it freezes.

whip check --json puts the code in a code field beside the severity, the span and the help text, and the language server carries the same. Match on the code rather than on the wording: the wording is written for a person, and is rewritten whenever it can be made clearer.

Parse And Source Shape

A file that is not WhippleScript

The parser knows the Gherkin and Cucumber keywords by name and rejects each one where it stands, because pasting a feature file into a .whip file is the common way to arrive here. The repair is not a translation of the steps. A when clause is a typed readiness pattern over facts, not a step in prose; the build-a-workflow tutorial writes that intent as a workflow.

More than one workflow in one bundle

The diagnostic lists the workflows it found and asks for --root. The flag means the same thing on check, run, start, step, and revise.

Type And Schema Checks

Object literal without an expected type

An object literal has no type of its own. The checker takes its type from the position the literal sits in, and the typed positions are record Class { ... }, complete output { ... }, a coerce fn(...) argument, and a hosted exec capability with <record> -> Type statement. A literal anywhere else — in a guard, say — is refused because there is nothing to check it against.

Liveness

Two refusals share one rule: a workflow must be able to end, and a rule must be able to fire.

error[graph.unreachable_terminal]: workflow `AlertWatch` has no rule that reaches `complete` or `fail`
  --> examples/diagnostics/no-terminal-rule.whip:1:1
  |
1 | workflow AlertWatch
  | ^
  = help: add a rule that runs `complete <output> { ... }` or `fail <failure> { ... }`, or tag the workflow `@service` if it intentionally runs forever
error[graph.rule_never_fires]: rule `escalate` can never fire: nothing produces `Escalation`
   --> examples/invalid/rule-never-fires.whip:36:8
   |
36 |   when Escalation as escalation
   |        ^^^^^^^^^^^^^^^^^^^^^^^^
   = help: seed `Escalation` from a table, record it in another rule, declare it as a workflow input, or tag the rule `@external` if it arrives from an external system

The tags in those help lines are declarations, not silencers. @service on a workflow says the workflow need not terminate — which is why an invoke of a @service workflow is itself refused further down this page. @external on a rule says the fact it matches arrives from outside the program, so the checker stops requiring the program to produce it. Neither tag is the answer to a workflow you meant to end, or to a rule whose trigger you forgot to record.

Effect Graph Checks

An effect output is out of scope

The output of an effect exists only inside the branch that proved the effect settled. A read of x outside after x succeeds — or fails, or completes — is refused because at that point the effect has no terminal status and so no payload. This is the same property that makes an inline "await" impossible: the branch is how the program observes that the world answered.

Coordination Checks

More than one lease in one progression

A progression may hold at most one lease. The limit is structural rather than a setting: two held leases is hold-and-wait, which is the deadlock condition. Divide the work across rules, or model the resource as one lease under a key wide enough to cover both.

Coordination outcomes are exhaustive

An acquire settles as held or contended, and a consume settles as ok or over. Both are branches rather than failures, and the checker requires a handler for each, exactly as it requires a case to cover its domain. A missing handler is not a fallthrough — it is a path the program has no plan for.

Recursion And Namespace Checks

Recursive pattern application

error[graph.unbounded_pattern_recursion]: recursive pattern application is not allowed: expansion cycle Loop -> Loop
   --> examples/invalid/recursive-pattern.whip:10:3
   |
10 |   apply Loop<T> as inner {
   |   ^^^^^^^^^^^^^^^^^^^^^^^^
   = help: break the cycle: pattern expansion must elaborate into a finite program

The cause is an apply statement of a pattern that expands into itself. The expansion can be direct or through a cycle. The expansion of a pattern must give a program with a finite size.

Recursive workflow invocation

error[graph.unbounded_workflow_invocation_recursion]: recursive workflow invocation is not allowed: invocation cycle Ping -> Pong -> Ping
   --> examples/invalid/recursive-workflow-invocation.whip:12:6
   |
12 |   => {
   |      ^
   = help: break the cycle: a runtime `invoke` cycle has no compile-time convergence proof; route the recurrence through an external event, clock, or durable boundary instead

The cause is a cycle of invoke statements between workflows. Such a cycle has no proof of convergence at compile time.

Effectful rule cycle

error[graph.unbounded_effect_recursion]: effectful rule cycle is not allowed: rule cycle ping_step -> pong_step -> ping_step turns inside one commit, and rule `ping_step` runs effects on every turn of it
   --> examples/invalid/effectful-rule-cycle.whip:31:3
   |
31 |   tell worker "ping {{ p.n }}"
   |   ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
   = help: field `n` rises by 1 on every hop, but no rule on the cycle bounds it, so nothing stops the turn; every edge of this cycle records its fact in the same commit that read one, so nothing paces it and each turn enqueues fresh effects at commit speed — put the recurrence behind an effect terminal (`after <effect> succeeds { record ... }`) so each turn waits on the world, give the cycle a measure that bounds it, or tag a rule `@external` when its facts genuinely arrive from outside the workflow

The cause is a cycle in the rule dependency graph in which one rule or more runs an effect, and in which each record lands in the same commit as the fact that the rule matched. Nothing paces such a cycle. It turns as fast as the store commits, and each turn requests fresh external effects under a new idempotency key, so the exactly-once guarantee never stops it.

A cycle that waits on the world is a different thing, and the compiler permits it. When the record of a rule sits inside an after block, the fact of the next turn does not exist until the terminal of an effect arrives. Such a loop turns at the pace of the agent or the service that it talks to. That loop is the long-running agent loop of the language, and it needs no tag. The rules of liveness still govern it: the workflow needs a rule that reaches complete or fail, or the @service tag.

A cycle of ONE rule gets this same diagnostic, because a loop in one rule is the loop of two rules with fewer names. The retry pattern of Agent patterns keeps compiling: it records inside an after block, so it waits on the world each turn. A rule that preserves its own trigger, rather than advancing it, has a diagnostic of its own and keeps it.

Effect cycle in a bounded workflow

error[graph.bounded_workflow_effect_cycle]: effect cycle in a bounded workflow is not allowed: workflow `BoundedWorkflowEffectCycle` is `@bounded`, and rule cycle ping_step -> pong_step -> ping_step runs the effects of rule `ping_step` on every turn
   --> examples/invalid/bounded-workflow-effect-cycle.whip:30:3
   |
30 |   tell worker "ping {{ p.n }}" as t
   |   ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
   = help: field `n` rises by 1 on every hop, but no rule on the cycle bounds it, so nothing stops the turn; a bounded workflow settles instead of turning, so it may not loop with the world without a proof: give the cycle a measure — an `int` field every hop advances by a literal step, with a rule on the cycle bounding it — or break the cycle, or drop `@bounded` if this workflow is meant to keep going

The cause is an effect-bearing cycle in a workflow that declares that it settles. The @bounded tag is the opposite of the @service tag. The @service tag declares that a workflow runs for as long as the world gives it work. The @bounded tag declares that the workflow reaches a terminal after a number of steps that the program fixes, not the data. A loop with the world breaks that promise, so a @bounded workflow may not carry a cycle of rules that runs an effect, even one that waits on a terminal.

A @tool workflow carries the same promise with no tag. An agent invokes a tool inside a turn, and DR-0025 requires the turn to end, so the check reads a @tool workflow as a bounded one. The message names DR-0025 in that case.

Recursive agent tool grant

error[graph.unbounded_tool_grant_recursion]: recursive agent tool grant is not allowed: invoke-tool cycle Alpha -> Beta -> Alpha
   --> examples/invalid/tool-grant-cycle.whip:34:11
   |
34 |     tools [Beta]
   |           ^^^^^^
   = help: break the cycle: an agent may call a granted `@tool` workflow synchronously, so a cycle in the grant graph has unbounded recursion depth and no compile-time convergence proof

The cause is a cycle in the invoke-tool graph. An agent may call a granted @tool workflow synchronously inside a turn, and that workflow's own agents may call further tools. A cycle in the grant graph therefore has an unbounded depth of recursion and no proof of convergence at compile time. A grant of a workflow to its own agent is a cycle of length one.

The check reads the grants of the bundle. A grant of a name that no workflow of the bundle declares gives no edge: such a name is a package export, and the manifest checks the convergence of a package export when it attests it.

Invocation of a @service workflow

error[graph.invoke_awaits_service_workflow]: rule `relay` invokes `Forever`, which is tagged `@service`: `@service` declares that a workflow need not terminate, and an invocation awaits its terminal output
   --> examples/invalid/invoke-service-workflow.whip:22:5
   |
22 |     invoke Forever { ask { id ticket.id  n 0 } } as sub
   |     ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
   = help: remove `@service` from the target if it does terminate — non-termination is a root-only privilege, not for an awaited sub-workflow; to hand work to a genuinely long-running service, emit a signal or event it observes instead of awaiting it

The cause is an invoke statement whose target carries the @service tag. The parent of an invocation observes the typed terminal output of the child. The @service tag is the declaration that a workflow is not required to terminate: it is the escape from the check that otherwise demands a rule that reaches complete or fail.

Note what the tag does not mean. It is not a proof that the workflow runs forever. A @service workflow that carries a completing rule reaches a terminal at run time in the usual way. The refusal rests on the missing PROMISE. A caller that blocks on a terminal has nothing to hold the callee to, and if no terminal comes, the instance stays in the running state with no terminal. The automatic failure does not catch that condition, because that mechanism observes an effect that FAILED, and here nothing fails.

This is the same rule that the agent-tool seam applies. A granted @tool workflow that also carries @service is refused on the tag alone, with the same reasoning and whether or not it carries a completing rule.

The tag itself stays legitimate. This diagnostic refuses the AWAIT of a @service workflow, never the declaration of one. Non-termination is a privilege of the root.

Evidence-only fact matched as a fact

error[graph.unmatchable_fact]: rule `react_to_stream` matches evidence-only fact `agent.turn.streamed`: in-turn observations are evidence, not rule-matchable facts
  --> examples/invalid/evidence-fact-match.whip:9:8
  |
9 |   when fact agent.turn.streamed as ev
  |        ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  = help: match a lifecycle fact (`agent.turn.completed`/`failed`/`timed_out`/`cancelled`) and read in-turn detail from its evidence

The cause is a when clause of a rule that matches an observation in a turn. An example is agent.turn.streamed. The system records such an observation as evidence. The system does not record such an observation as a lifecycle fact that a rule can match.

Runtime And Provider Diagnostics

A failure of a provider does not fail the workflow automatically. Such a failure appears as the state of an effect and a run, as a durable diagnostic, as evidence, and as a record in a trace.

Examine the run in this sequence:

whip status <instance>
whip effects <instance>
whip runs <instance>
whip diagnostics <instance>
whip evidence instance <instance-id>
whip trace <instance> --check

These are the usual repairs:

Symptom Probable cause Repair
blocked_by_capacity The capacity of the agent is full. Wait, decrease the concurrency, or examine the effects that run.
blocked_by_capability The agent or the provider does not expose the necessary capability. Correct the capabilities field or the configuration of the provider.
blocked_by_profile The policy of the profile denied the effect. Use an effect with less authority, or bind a profile that permits the effect.
A run of a provider has the failed status. The adapter, the model, the script, or a boundary failed. Read the diagnostics output and the evidence output. Then write a rule for the policy: a retry, an escalation, or a fail statement.
A run of a provider has the timed_out status. The timeout ended. Add an after x times out branch or an after x completes branch. As an alternative, add a policy for a retry.

Revision Diagnostics

The whip revise --dry-run command reports the compatibility and does not change the store. A revision that the command rejects does not change the active version of the program.

These are the usual failures:

Diagnostic family Meaning Repair
The root workflow changed. The candidate source changes the root of the instance. Use the same root in v0. As an alternative, start a new instance.
The contract changed and the change is not compatible. The contract of the input, the output, or the failure no longer agrees with the state in operation. Keep the contract. As an alternative, wait until the instance is terminal.
A removed agent still has old work. Work of the old version still targets an agent that the candidate removes. Keep the agent. As an alternative, cancel the old work, or complete the instance first.

Assertion And Fixture Diagnostics

An assertion runs after the run command gets to the idle state. An assertion that fails records a durable diagnostic. The system links the diagnostic to the event of the assertion. Use the --include-tag flag and the --exclude-tag flag to make the group of the assertions more narrow during a debug operation.

An acceptance fixture validates its own shape before it runs. The system rejects an expectation with an incorrect type, a setup.effects field, a setup.artifacts field, and an absent selector for a read of an assertion. The system rejects each of these as an error of the fixture. The system does not ignore them.