
How to version long-running AI workflows without breaking active runs
Learn how to version workflow logic, state, prompts and tool contracts so active AI runs survive deployments without forced restarts or silent drift.
A workflow starts on Monday. It gathers documents, calls two tools, and pauses for a human approval. On Wednesday, you deploy a cleaner decision tree and rename a field in its persisted state. On Thursday, the approval arrives.
Which code should resume the run?
If the answer is simply “whatever is deployed now,” the workflow may take a branch that did not exist when it started. It may fail while reading old state, call a tool with a new payload, or repeat an external action. The deployment succeeded, every health check is green, and an active customer process still broke.
This is the versioning problem behind long-running AI workflows. It is not primarily a Git problem. It is a compatibility problem across time.
Every deployment is a compatibility event
A normal HTTP request lives for milliseconds or seconds. Deploying new code replaces the implementation used by the next request, which is usually what we want. A durable workflow is different. Its logical lifetime may be days, months, or longer, even if no PHP process stays alive during that time.
The run survives through persisted state, events, timers, queue messages and external side effects. When it wakes up, new application code interprets a history produced by old application code. That creates a compatibility boundary.
This is also why a queue alone is not durable execution. As I explained in durable execution versus queues, a queue transports work; it does not define how historical decisions should be replayed or how a multi-step process should evolve. The version must belong to the workflow run, not only to the current deployment.
There are two invariants I want from a production system:
- An existing run continues under rules compatible with the history it already produced.
- A new run starts on the intended current definition, with no accidental dependency on retired code.
Everything else—version columns, registries, migration jobs and worker pools—is implementation detail in service of those invariants.
The five surfaces that need independent versions
Teams often add a workflow_version column and believe the problem is solved. That column is necessary, but it can hide several different contracts. AI workflows have at least five versioned surfaces:
| Surface | What changes | Typical failure |
|---|---|---|
| Control flow | Steps, branches, retries, timers and approval points | An old run resumes at a step that moved or disappeared |
| Persisted state | Field names, types, defaults and invariants | New code cannot deserialize an old snapshot |
| Activities and tools | Input/output schemas, idempotency keys and error semantics | A resumed run sends an incompatible payload or repeats an action |
| AI artifacts | Prompt templates, model configuration, output schemas and policies | The same state produces a materially different decision |
| External dependencies | Provider APIs, document formats and business rules | The pinned workflow calls a contract that no longer exists |
These surfaces should not be collapsed into one vague release number. A state migration may be required without changing the workflow graph. A prompt can change while tool contracts remain stable. A provider may retire an API while old runs still reference it.
I prefer to store a small compatibility manifest on every run:
{
"workflow": "document-review",
"definition_version": 3,
"state_schema_version": 2,
"prompt_version": "review-v7",
"tool_contract_version": "2026-10",
"policy_version": "risk-policy-4",
"model_profile": "structured-reasoning-v2"
}
This does not mean every field needs a separate deployment. It means the system can answer a basic incident question: under which contracts did this run make its decisions?
Pin the definition when the run starts
The most useful rule is simple: choose the workflow definition at creation time and persist that choice immutably. Do not resolve “latest” every time the run wakes up.
A human-friendly alias such as current is fine at the API boundary. Resolve it once:
$definition = $registry->current('document-review');
$run = WorkflowRun::create([
'workflow_name' => $definition->name,
'definition_version' => $definition->version,
'state_schema_version' => $definition->stateSchemaVersion,
'status' => 'pending',
'state' => $definition->initialState($input),
]);
After creation, definition_version is part of the run’s identity. An operator may migrate the run through an explicit command, but ordinary resumption must never rewrite it as a side effect of deployment.
This is closely related to checkpointing an AI agent. A checkpoint is only useful when the code loading it understands the state and decisions represented by that checkpoint.
Classify changes before choosing a strategy
Not every code edit requires a new workflow definition. Logging, metrics and an internal performance improvement can often remain compatible. A new optional state field with a safe default may be compatible too.
I create a new definition version when a change can alter the historical meaning of a run:
- reordering or removing steps;
- changing a branch condition that an active run may evaluate;
- changing retry, timeout or compensation behaviour;
- making an optional field required;
- changing a tool’s side-effect or idempotency contract;
- changing a structured AI output in a way old consumers cannot read;
- changing an approval policy for work already awaiting approval.
When uncertain, version. Keeping a small old handler available is cheaper than explaining a silent change in business behaviour.
Four safe strategies for active runs
There is no universal migration mechanism. The right choice depends on run duration, side effects, regulatory requirements and the number of active instances.
| Strategy | Best for | Main cost |
|---|---|---|
| Pin and drain | Finite workflows with manageable maximum duration | Old code must remain deployed until the final run completes |
| Version-aware branch | Small compatible changes in replay-based engines | Compatibility branches accumulate and need careful retirement |
| Continue as new | Perpetual or very long workflows with clean checkpoints | A deliberate boundary is needed between histories |
| Explicit migration | Urgent fixes or expensive-to-drain runs | Migration logic needs its own tests, audit trail and rollback plan |
1. Pin and drain
New runs use version 4 while active version 3 runs continue on version 3. You retire the old worker only when its active count reaches zero. This is the safest default because no historical state changes shape mid-run.
It works particularly well when runs have a known upper bound. It is less attractive when a workflow may wait six months for a document or approval.
2. Add a version-aware branch
Some durable execution platforms support deterministic patches or workflow version markers. The history records which branch a run selected, so replay remains stable after the new branch is deployed. This is useful for a focused change, but it should not become a forest of permanent if version < 12 conditions.
3. Continue as new
At a safe checkpoint, close the current execution and start a fresh one using the new definition, carrying forward a compact, explicitly converted state. This limits unbounded history and creates a clear upgrade boundary.
The key phrase is safe checkpoint. Do not cross the boundary while an external tool result is ambiguous. Resolve whether the action happened first; otherwise the new run may repeat it. The same principle underpins idempotency in AI automation.
4. Run an explicit migration
Sometimes the old version contains a serious bug, an external API is being retired, or active runs are too valuable to abandon. Then migration is justified. Treat it as a product operation, not a database cleanup:
- pause the selected runs at known states;
- record their original definition and state versions;
- transform state with an idempotent migrator;
- validate post-migration invariants;
- switch the definition version in the same transaction;
- resume gradually and watch failure metrics.
A migration should produce an audit event that explains who or what initiated it, which converter ran, and the hashes of state before and after conversion.
A practical Laravel and PHP design
Laravel already gives us useful building blocks: database transactions, queues, casts, service containers and scheduler locks. The missing part is a version-aware domain model.
I keep immutable handlers behind a registry:
interface WorkflowDefinition
{
public function name(): string;
public function version(): int;
public function stateSchemaVersion(): int;
public function resume(WorkflowRun $run): Transition;
}
final class WorkflowRegistry
{
/** @param iterable<WorkflowDefinition> $definitions */
public function __construct(private iterable $definitions) {}
public function get(string $name, int $version): WorkflowDefinition
{
foreach ($this->definitions as $definition) {
if ($definition->name() === $name
&& $definition->version() === $version) {
return $definition;
}
}
throw new UnsupportedWorkflowVersion($name, $version);
}
}
The worker loads the run, resolves the exact definition, and applies one transition. It does not inject the latest definition through the container and hope it remains compatible.
$run = WorkflowRun::query()
->lockForUpdate()
->findOrFail($runId);
$definition = $registry->get(
$run->workflow_name,
$run->definition_version,
);
$transition = $definition->resume($run);
$transitionService->apply($run, $transition);
The database should enforce the important invariants. I would normally include:
- a unique run identifier;
- immutable workflow name and definition version;
- state schema version and JSON state;
- monotonic transition sequence for optimistic concurrency;
- the current status and step;
- artifact identifiers for prompts, tools and policy;
- timestamps for last progress and next wake-up.
For side effects, I pair this with a transactional outbox in Laravel. Committing the workflow transition and its outbound command together prevents a deployment from leaving state advanced without the corresponding message—or sending a message for state that never committed.
Version AI artifacts as data
Pinning only the model name is not reproducibility. Providers can update an alias, prompts can be edited in an admin panel, and a JSON schema can change without a PHP deployment.
Store immutable artifact identifiers or content hashes. For a model call, record at least the model profile, prompt template version, schema version, policy version and relevant inference settings. Secrets and personal data do not belong in the manifest; stable references do.
This does not guarantee identical model output. It gives you provenance: enough information to understand and test the contract under which the output was accepted.
A deployment sequence that keeps old runs alive
Safe rollout is mostly ordering. A practical sequence looks like this:
- Measure active versions. Know the count, age and current step of every active run.
- Deploy readers first. New code must read both old and new state before anything writes the new format.
- Register the new definition. Keep the old handler and tool contract available.
- Move new starts. Change the
currentalias to the new definition. - Canary new runs. Start with a small share and compare errors, latency, cost and human escalations.
- Drain or migrate old runs. Use an explicit strategy, not an implicit code replacement.
- Retire with evidence. Remove an old definition only when no active, sleeping or retryable run references it.
The dangerous moment is step two: a writer producing state that some active worker cannot read. Expand-and-contract database migrations apply here. Add the new representation, dual-read or dual-write briefly when necessary, backfill, switch consumers, and only then remove the old field.
Rollback deserves the same attention. Rolling application code back is not enough if new runs already wrote state or emitted tool commands under the new contract. Your rollback plan must say whether those runs stay on the new definition, migrate back through a tested converter, or pause for operators.
Test upgrades with real historical state
Unit tests built from today’s factory defaults rarely expose compatibility failures. Keep sanitized fixtures representing states produced by previous definitions, especially around waiting, retrying and partially completed side effects.
For every supported upgrade path, test four things:
- Load: can current code deserialize the historical state?
- Resume: does it select the expected next transition?
- Side effects: are completed actions protected from duplication?
- Failure: does an unsupported version stop visibly instead of guessing?
I also run transition tests rather than checking only the final model output. The method in testing AI agent state transitions is useful here: given a definition version, state and event, assert the next state, emitted commands and audit events.
Before moving a large cohort, replay or shadow representative histories against the candidate code. Compare decisions without executing external side effects. Differences are not automatically bugs, but they need an explanation before migration.
Make unsupported versions loud
If a worker sees a version it cannot execute, it should quarantine the run, emit a structured alert and preserve the input. Falling back to “latest” converts an operational problem into silent data corruption.
Your dashboard should show active runs by definition version and state schema version, oldest run age, migration failures, version-resolution errors, and the number of runs blocking retirement. Those numbers turn cleanup from guesswork into a controlled decision.
Production checklist
- Resolve and persist the workflow definition when a run starts.
- Version control flow, state schemas, tools and AI artifacts independently.
- Keep old handlers available while referenced runs are active.
- Classify each change as compatible, versioned, continue-as-new or migrated.
- Use idempotency keys and an outbox around external side effects.
- Deploy readers before writers for new state formats.
- Test with historical snapshots and partial-failure states.
- Record every migration as an auditable event.
- Never fall back silently from an unknown version to the latest one.
- Retire a version only after active, sleeping and retryable counts reach zero.
Versioning is how a workflow keeps its promises
Long-running workflows cross deployment boundaries by design. That is not an edge case to patch later; it is part of their data model.
The practical solution is to pin a definition at start, preserve the contracts needed to resume it, and move runs between versions only through explicit, observable operations. For AI systems, that contract includes prompts, policies, structured outputs and tool semantics—not just application code.
Once those versions are visible, deployments become less dramatic. New runs can improve without rewriting the past, while active runs finish under rules they understand.
If you are designing a Laravel or PHP automation that must survive retries, approvals and repeated deployments, I can help review the workflow model and rollout plan. See my consulting work or contact me.
Sources and methodology: architecture guidance was checked against the official Temporal PHP workflow versioning documentation, Microsoft Durable Functions versioning guidance, Dapr workflow versioning documentation, and Conductor workflow versioning guidance. Sources checked 23 October 2026. The platform-neutral Laravel examples and compatibility model are original synthesis based on production-oriented PHP and AI automation practice. This article was prepared with AI assistance and reviewed and edited for technical accuracy and practical usefulness. Cover photo: Pixabay.
