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. Their active child stages fail in the same transaction, with completion times and errors; terminal stages remain unchanged.
- 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.cancelmarks the run and its open stages cancelled, and once that commits it purges the run's queued jobs from the queue viajobTransport.cancelByRun(). 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.
- What it looks like: the code is
P2028on Prisma 6, 7 and 8, but the error differs. On Prisma 6 and 7 it is aPrismaClientKnownRequestErrorwithmeta, and the message containsA query cannot be executed on an expired transaction(Prisma 6 putsTransaction already closed:in front of it). On Prisma 8 it is a plainErrorwith nometa, raised byfromPrisma8, and the message isTransaction already closed: the 1000ms timeout passed.(with your timeout). A log search for the Prisma 6 and 7 text finds nothing on Prisma 8, so search for the code.
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. On Prisma 8 there is no generated client to be incomplete;fromPrisma8(db)throwsthe contract has no model for the engine table "..."instead, naming the table to copy fromcontract.prisma. It also throws when a timestamp column is notTimestampString(3)orstatusis not the nativeStatusenum, each with the fix in the message.
10. DefinitionRegistrationRetryableError during registration
- Symptom: Definition registration throws
DefinitionRegistrationRetryableErrorwithcode: "DEFINITION_REGISTRATION_RETRY". - Cause: A concurrent winner is outside a
REPEATABLE READ/SERIALIZABLEsnapshot, PostgreSQL reports40001/ PrismaP2034, or the follow-up read finds no row. - Fix: Catch it outside the caller transaction, let it roll back, and retry the whole caller transaction. It may already be aborted; retrying only registration inside it is insufficient. See Definition Versioning.
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. Selection commits before cleanup starts. For each run, the step ledger is cleared through the StepLedger port, job rows go through the JobTransport, and blobs under workflow-v2/<workflowType>/<runId>/, workflow-v2/spill/jobs/<runId>/ and workflow-v2/spill/steps/<stageRecordId>/ are removed. Only then is the run deleted through PersistenceCore.deleteRun (stages, logs, artifacts, annotations cascade). A run redriven during the sweep is kept, and the result names only the runs deleted. Cleanup already done is not undone, so what follows depends on when the redrive lands: before cleanup starts, nothing is touched; after the ledger is cleared, completed steps can run again; after the job rows or blobs are gone, the stage output is missing and the run ends FAILED through run.reapStuck. Cleanup uses the ports outside the selection transaction. If any cleanup or the final delete fails, dispatch rejects and the run remains eligible for a later purge; cleanup can be partial, so custom ports must tolerate repeated cleanup. 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. |