Troubleshooting
This guide covers common issues encountered when running workflow-engine, how the engine recovers, and steps to manually inspect and debug your runs.
Common Issues & Symptoms
1. Runs Stuck in PENDING
- Symptom: New runs stay in
PENDINGand never start execution. - Cause:
- The orchestration tick loop is not running.
- The host process is crashed or offline.
- The workflow definition is missing from the kernel's registry.
- The run is pinned to a definition version no running host serves (a deploy changed the pipeline's structure while runs were pending).
- Check:
- Check host stats using
host.getStats(). EnsureorchestrationTicksis incrementing. - Dispatch
run.listVersions:unservedHerelists the versions with live runs that this build does not present. See Definition Versioning. - With a hand-written
{ getWorkflow }registry (orserves: "all"), a run whose workflow is missing is adopted and markedFAILEDwithWORKFLOW_NOT_FOUND. WithcreateWorkflowRegistry, the claim query does not return it at all and it staysPENDINGfor a host that has the workflow.
- Check host stats using
- Fix:
- Ensure
host.start()was called. - Verify that the workflow ID matches a workflow in
createKernel({ registry: createWorkflowRegistry([...]) }). - For a stranded version, redrive the run onto the current build with
run.redriveanddefinitionVersion: "latest"(see Retry, Restart and Rerun).
- Ensure
2. Runs Stuck in RUNNING
- Symptom: Runs are marked
RUNNINGbut no progress is made. No new jobs are being enqueued, and logs have ceased. - Cause:
- A host worker crashed midway through executing a stage, leaving the stage stuck in
RUNNINGwith an active lease. - A database or network failure prevented the host from calling
run.transitionafter completing a job.
- A host worker crashed midway through executing a stage, leaving the stage stuck in
- Self-Healing (Reap Stuck):
- The kernel's
run.reapStuckcommand runs automatically on every host orchestration tick. - It detects
RUNNINGruns that have had no database updates within the threshold (default:max(3 * staleLeaseThresholdMs, 5 minutes)). - Stuck runs are failed with the error code
STUCK_RUN_REAPED.
- The kernel's
- Manual Recovery:
- If a run was completed but failed to transition, you can manually trigger a transition:
await kernel.dispatch({type: "run.transition",workflowRunId: "your-stuck-run-id"});
- If a run was completed but failed to transition, you can manually trigger a transition:
3. Ghost Jobs
- Symptom: Workers are processing stages for workflow runs that have already been
FAILEDorCANCELLED, creating zombie loops. - Cause:
- If a workflow is cancelled, active jobs might still reside in the worker's queue.
- Self-Healing:
- Authoritative Cancellation: Calling
run.cancelautomatically purges queued jobs from the queue viajobTransport.cancelByRun(), and the job executing at that moment is told to stop throughctx.abortSignalon its next lease heartbeat. - Ghost Job Guard:
job.executeverifies that the run is inRUNNINGstatus both before and after executing a stage. - Reason-specific handling: If the run is not something this worker should execute, the result is discarded and returned with
ghost: trueand aghostReason."orphan"(run cancelled or finished) is failed terminally without a retry;"race"(the run is stillPENDINGbecause the claim that enqueued the job had not committed) is re-delivered;"version"(the run is pinned to a definition version this build does not serve) is deferred without spending an attempt. See Execution Model.
- Authoritative Cancellation: Calling
4. Crash resumption of a durable step waits minutes
- Symptom: A worker died inside a
ctx.step.runbody; the replay on another worker reportsStepInFlightand the stage only resumes about five minutes later. - Cause: The step's lease. A
workflow_stepsrow carries no worker identity, so the lease (StepRunOptions.lease, default five minutes) is the only liveness signal the step has, and nothing — notlease.reapStale, which releases job leases only — releases it early without risking a second execution of the body. - Fix: Size
leaseper step to the longest the body should take plus headroom ({ lease: "30s" }recovers in about 30 s), and useheartbeatfor a body whose length you cannot bound. See Durable Steps.
5. A stage is stuck on waitForSignal
- Symptom: A stage is
SUSPENDEDwith asignalsteppendingand nobody delivered the signal. - Fix: Dispatch
{ type: "step.signal", workflowRunId, stageId, stepId, payload }, or use Deliver signal on the step in the console. Delivery wakes the stage on the next maintenance tick regardless of the step'skeepalive. A step past itstimeoutcannot be signalled; redrive the run instead.
6. StepNotReplaySafeError or a step.outcome-conflict annotation
- Symptom: A stage failed with
StepNotReplaySafeErrornaming a step and its external key, or the run carries astep.outcome-conflictannotation. - Cause: A
runbody declaredonReclaim: "fail"lost its lease and the engine refused to re-execute it (the error), or two workers executed the same body and the second outcome lost the compare-and-set (the annotation). In both cases the body may have run more than once. - Fix: Search the provider for the effect under the step's
externalKey(shown on the console's run detail, orSELECT "externalKey" FROM workflow_steps WHERE "stageRecordId" = ? AND "stepId" = ?). Then either complete the run by hand or redrive it once you know the effect is absent.
7. Prisma P2002 (Unique Constraint) Errors
- Symptom: Error logs show unique constraint violations on stage creation.
- Cause:
- In older versions, if a crashed worker left an orphaned stage record, enqueuing the stage again threw a
P2002conflict on(workflowRunId, stageId).
- In older versions, if a crashed worker left an orphaned stage record, enqueuing the stage again threw a
- Self-Healing:
run.claimPendingandrun.transitionuse idempotent stage upserts (upsertStage). If a record exists, the engine preserves it and enqueues jobs only for stages that are stillPENDING, resolving retry-loop lockups.
8. Prisma P2028 (Transaction Timeout) on Suspended Stages
- Symptom: Logs show transaction timeouts when polling suspended stages.
- Cause:
- Making external API calls to batch providers (like OpenAI or Google) inside a database transaction exceeds Prisma's interactive transaction timeout (5 seconds by default).
- Self-Healing:
stage.pollSuspendedreplays the stage body (and runscheckCompletion()) outside database transactions. State updates are committed in a subsequent short transaction, resolving P2028 database timeouts.
9. PrismaClient is not assignable to EnginePrismaClient
- Symptom: A wall of type errors when passing your Prisma client to
createPrismaWorkflowPersistence,createPrismaStepLedgerorcreatePrismaJobQueue. - Cause: The generated client is missing a delegate the adapter calls — after 1.0, almost always
workflowStep(theWorkflowStepmodel) orworkflowDefinition. - Fix: Add the missing models from Prisma Setup and re-run
prisma generate.
Orchestration Tick Flow
The host orchestration tick executes these operations sequentially. Each operation runs inside its own try/catch block, preventing a single failure (e.g. one bad database query) from starving unrelated operations:
run.claimPending: Discovers new runs this build serves, creates stage rows, and enqueues jobs after the claim commits.stage.pollSuspended: Replays suspended durable stages whosenextPollAthas passed (awaitForpoll, a batch status check, a sleep or signal keepalive).lease.reapStale: Recovers job locks from crashed worker processes and fails jobs past the absolute cap.outbox.flush: Emits events to theEventSink.run.reapStuck: Cleans up zombie runs that have lost database activity.run.purge(only when the host is givenretention): Deletes terminal runs past their retention age.
Run Retention
Nothing deletes a finished run by itself. Opt in per host with retention: { olderThanMs, statuses?, limit? } (off by default) and the maintenance tick dispatches run.purge, which deletes COMPLETED/FAILED/CANCELLED runs that finished at or before the cutoff, limit (default 100) per tick, oldest first: the step ledger is cleared through the StepLedger port, job rows go through the JobTransport, the run through PersistenceCore.deleteRun (stages, logs, artifacts, annotations cascade), and the run's blobs (workflow-v2/<workflowType>/<runId>/, workflow-v2/spill/jobs/<runId>/, workflow-v2/spill/steps/<stageRecordId>/) are removed after commit. No events are emitted. The command can also be dispatched directly:
await kernel.dispatch({ type: "run.purge", olderThan: new Date(Date.now() - 30 * 86_400_000) });
Deleting by SQL instead: with the reference schema, DELETE FROM "workflow_runs" WHERE ... cascades to stages, logs, artifacts and annotations, and from workflow_stages to workflow_steps only once the workflow_steps_stageRecordId_fkey foreign key exists (package schema from the release that added run.purge; earlier 1.0 alphas need the ADD CONSTRAINT in the 0.13 → 1.0 migration guide). job_queue has no foreign key to the run and must be deleted explicitly, and blobs live outside the database.
Error Codes Reference
When a workflow fails, the error details are persisted inside the WorkflowRun.output JSON column under the error key.
| Error Code | Location | Description |
|---|---|---|
WORKFLOW_NOT_FOUND | run.claimPending | The workflow ID does not exist in the kernel's registry. |
EMPTY_STAGE_GRAPH | run.claimPending | The workflow definition contains no stages in its first execution group. |
CLAIM_FAILED | run.claimPending | An unexpected database exception occurred while claiming. The run is failed, but other runs in the batch continue processing. |
STUCK_RUN_REAPED | run.reapStuck | The workflow run ceased database updates and exceeded the stuck threshold. |
LEASE_HEARTBEAT_LOST | lease.reapStale (prefix on job_queue.lastError) | The job's lease went unheartbeated past staleLeaseThresholdMs; the job was requeued. |
LEASE_ABSOLUTE_CAP | lease.reapStale (prefix on job_queue.lastError) | The job ran past jobAbsoluteTimeoutMs; it was failed terminally. |