Summary#
A run in flight keeps executing the version it started under. That is the guarantee that makes deploying safe while work is in progress — and it means a deploy leaves runs behind, on purpose.
A deploy moves them forward for you, as its last act. migrate is how you finish the job afterwards — for runs a
deploy would not move until you authored a verb for them, for runs parked mid-body, and for a deploy you deliberately
told not to drain. It applies whatever the deploys in between said should happen to each run. workflow-runs shows
you what is still behind before you do it, and --dry-run shows you exactly what would happen without doing anything.
Signature#
$ osy workflow-runs # what is in flight, and what is behind
$ osy migrate --dry-run # decide everything, write nothing
$ osy migrate # move themDescription#
Seeing what is behind#
versions answers "why is this app still carrying six schemas" — a count of runs per version. That is the right
question for reclaiming storage and the wrong one after a deploy, because a count cannot tell forty healthy runs from
forty stragglers nobody can move.
workflow-runs names the work instead:
$ osy workflow-runs
╭────────────┬──────────────────┬─────────┬──────┬───────────────┬────────╮
│ workflow │ state │ status │ runs │ version │ oldest │
├────────────┼──────────────────┼─────────┼──────┼───────────────┼────────┤
│ OrderFlow │ AwaitingPayment │ waiting │ 6 │ 1.2.0 (behind)│ 11d │
│ OrderFlow │ Draft │ waiting │ 41 │ 1.3.0 │ 2h │
╰────────────┴──────────────────┴─────────┴──────┴───────────────┴────────╯Rows that are BEHIND come first and are called out, because they are the ones you can act on.
The drift migrate cannot see: an older revision#
There is a second way a run drifts from your source, and it is invisible to everything above — because the run never goes behind at all.
While you are developing, a compile edits the current version in place. Change a workflow and the platform mints a new revision of it and keeps the old one, so a run that is parked mid-flight goes on resolving through the revision it started under. That is deliberate and it works: the run resumes and finishes normally, even if you deleted the state it is sitting in.
What it means, though, is that the run is executing a definition you can no longer read. And since the run is on
the current version, migrate correctly reports that there is nothing to move — an answer that is true and reads like
a clean bill of health.
So workflow-runs says it outright, and sorts those rows above even the stragglers:
$ osy workflow-runs
╭────────────┬───────────────────────────────────┬─────────┬──────┬─────────┬────────╮
│ workflow │ state │ status │ runs │ version │ oldest │
├────────────┼───────────────────────────────────┼─────────┼──────┼─────────┼────────┤
│ OrderFlow │ Draft (not in current source) │ waiting │ 3 │ 1.3.0 │ 20m │
╰────────────┴───────────────────────────────────┴─────────┴──────┴─────────┴────────╯
Some runs are parked on a state your current source no longer declares. They still run — each keeps the workflow
revision it started under — but they are executing a definition you can no longer read. `migrate` will not report
them: they are already on the current version. Re-add the state, or cancel those runs (`osy cancel-runs`).⚠ A deploy does not have this shape: it mints a new app version, and a migration then has to say where every parked run goes before it can move one. This is a development-loop phenomenon, which is exactly why it needed saying out loud — it is the one place a run and its source part company with nothing stopping it.
Moving them#
migrate takes every run that is not on the current version, one at a time, under that run's own lock. For each it
applies the Migrating runs that are still in flight each deploy authored — hop by hop, in order, so a run several versions behind gets
every author's decision rather than a guess made from the endpoints.
A pass has three outcomes and they are reported separately, because they are not variations of each other:
- moved — the ordinary case. The run is now resolving the current version's code.
- ended — an authored
terminatestopped it, on the version it was already on, with the reason somebody wrote at the time. You are seeing the effect of a decision made weeks ago; the message is the thing to read. - refused — nothing could be decided safely, so nothing was done to that run. The reason names what could not be matched and the verb that would answer it. The run is untouched and can still finish on its own version.
Always dry-run first#
--dry-run decides everything and writes nothing. It is not a partial answer or an estimate — it is the same pass,
with the writes withheld, so the report is exactly what a real run would produce.
That matters most for the two outcomes you cannot undo by re-running: an ended run is ended, and a moved run has left the version it was on. Reading the report first costs a few seconds.
Refusals are a normal outcome, not an error#
A refusal means a state, a slot or a deadline in the new version could not be matched by name, and nothing in a migration file said what to do about it. The run keeps working: it stays where it is, on the version that still declares everything it needs, and can be completed by whoever is holding it.
The fix is a Migrating runs that are still in flight in the next deploy, and the refusal names the verb. compile --generate-migration
writes the file for you — including a keep; for every state nothing happened to, and a placeholder that will not
deploy for anything it could not decide.
What a move does to a run's deadlines#
Every live deadline the run holds is re-pointed at the new version's declaration AND re-read against it, so a budget you changed takes effect on runs that were already waiting. A budget you did not change carries silently, and so does a declaration you merely moved between a slot, its state and the workflow — that is the same deadline written at a different level of reach.
Changing a live budget is the one case that cannot be settled here, because it has to be settled at DEPLOY time: the
deploy refuses until the migration says carry clock <Kind> on <Slot>; (keep the time already spent) or
reset clock <Kind> on <Slot>; (start the new budget from now). See Migrating runs that are still in flight. So by the time you run
osy migrate, that decision has already been made and written down.
A deadline you REMOVED is retired, and the pass tells you. Deleting a declaration has one reading — the promise was withdrawn — so the timer stops, the run moves normally, and nothing breaches. A run that can no longer be late is not a run that failed, and refusing the move would strand somebody's work over a deadline you deliberately deleted.
$ osy migrate
40 run(s) moved:
OrderFlow AwaitingApproval app_v2 → app_v3
1 deadline(s) RETIRED — the new version no longer declares them:
OrderFlow Finished on Payee — 40 run(s); they keep going, with one fewer obligation. Nothing breached.It is grouped by deadline, not listed per run: the useful number is which promise stopped, for how many. Each run
also carries a Retired entry on its own timeline, so it is still explicable months later — osy inspect and the
Admin Runs tab both read it.
A deploy already does this for you#
Since 2026-08-08 a deploy moves the runs it strands, as its last act — so in the ordinary case you never run this command at all. A deploy is exactly the moment runs get left behind (they were parked when the version changed under them), and a follow-up command an operator has to remember is one that does not get run on a scheduled or automated deploy.
What the deploy reports is what this command would have: how many moved, how many an authored terminate ended, and
— as warnings on the compile — anything it would not move, grouped by reason. A refusal never fails the deploy:
the version is already minted and live by the time the runs are moved, so a refusal is an outcome to read, not an
error to retry.
It also means an old version is reclaimed by the same deploy that drains it, rather than the next one: the runs move first, and the version garbage collector then finds nothing holding the old schema open.
So you run osy migrate for the cases the deploy could not finish:
- you have authored the verbs a previous deploy's refusals asked for and want to move those runs now, without waiting for another deploy;
- a run was parked mid-body (an awaited child or a saga step) and could not be moved yet;
- you deployed with the drain turned off deliberately — see below.
And it keeps trying afterwards#
A deploy's drain is one pass, and a single pass does not always finish: a run can fail to move because something else was holding its data, an old parent can start an old child while the pass is running, and a very large backlog can outlast the pass's own backstop. So the platform re-runs the pass on a schedule, per app, with no configuration and no cron of yours. It is the same pass this command runs — there is one answer to "where does this run go", not one for you and a different one for the background.
It knows the difference between "not yet" and "not ever", and that is the point. A run refused because a state has
no counterpart, or because its author has not written reenter;, will be refused identically for ever — coming back
in an hour cannot help it. Those refusals are reported once and then counted, and they do not make the sweep hurry.
Only something that could genuinely go differently next time — a run that errored mid-move — brings the next pass
forward.
The practical consequence: you do not have to babysit a deploy's refusals. Author the verb whenever you get to it
and deploy; if instead the problem was transient, it will already have cleared itself. Nothing accumulates silently —
osy workflow-runs still shows exactly what is behind.
Deploying now and draining later#
The drain takes each run's own lock and re-bases its clocks, so on an app with thousands of runs parked it is real
work. A deploy can skip it and leave the runs for a controlled window; nothing is lost by waiting, because a parked
run keeps executing against the version it started on — that is the whole point of the versioning model. The only
cost is that the old schemas stay held until you run osy migrate.
Examples#
The usual sequence after a deploy that renamed a state:
$ osy workflow-runs
# → 6 OrderFlow runs in AwaitingPayment, on the previous version
$ osy migrate --dry-run
Dry run — nothing was written.
6 run(s) moved:
OrderFlow AwaitingSettlement app → app_v2
$ osy migrate
6 run(s) moved.And a pass with something to look at:
$ osy migrate
4 run(s) moved:
OrderFlow Draft app_v2 → app_v3
1 run(s) ENDED by an authored `terminate`:
OrderFlow in Abandoned on app_v2 → cancel: the offline-payment route was removed in v3
2 run(s) could not be moved:
OrderFlow on app_v2: slot 'Payer' of state 'AwaitingPayment' has no match in 'app_v3' — it was
removed or renamed. An authored migration must re-point it (`rename slot Payer -> <New>;`) or drop
it (`drop slot Payer;`), which cancels its claim explicitly.See also#
- Workflows that outlive the code that started them — the model these commands operate on: how a run's position survives a deploy
- Migrating runs that are still in flight — the file that says where a parked run goes, and how it is generated
- Deploying while workflows are running — what a version is, and which runs are holding one open
- Stopping runs after a bad deploy — the other way out: stop runs on a bad version rather than move them
- Renaming and removing things that hold data — the same file's other half, about the DATA a change touches