Skip to content

INTR-688 - Add a standby workflow state that stops a workflow deployments child - #248

Open
FarinM wants to merge 20 commits into
mainfrom
intr-688-add-a-parked-state-that-stops-a-workflow-deployments-child
Open

FarinM wants to merge 20 commits into
mainfrom
intr-688-add-a-parked-state-that-stops-a-workflow-deployments-child

Conversation

@FarinM

@FarinM FarinM commented Nov 1, 2026 •

Copy link
Copy Markdown
Contributor

Summary

A deployment waiting on an approval, a signal, mail or its next trigger kept its workflow child running for the whole wait. Now a user can put it in standby: the sidecar stops the child once every run only waits on outside input, and runs it again on mail, a signal, an approval decision, a due timer, a cancellation or a wake request. The deployment keeps its capacity and stays routed.

What changes

  • Child. On quiet.request the child reports whether its runs are quiet, plus the earliest timer it has queued. A map or a step with a timeout counts as work until it ends, since a fresh child could not continue it.
  • Supervisor. It stops the child only after two quiet reports, with dispatch and signals held in between, and records the standby first, so a sidecar restart keeps the deployment in standby while the run's history is unchanged.
  • Sidecar link. New standby and wake commands. The sidecar answers with the history tips the stopped child left, reports each wake and why, and says in hello whether the child is in standby. It refuses standby while mail waits for the child.
  • Hub. POST .../standby and .../wake record the request, and the lifecycle sweep carries it out in the background. The Hub records standby only once it holds the child's history. Cancellation and expiry end standby, and replacement capacity starts awake and re-applies standby once every run waits.
  • Resume fixes standby depends on. Standby restarts the child far more often than a crash does, which surfaced these bugs:
    • parks a childWorkflow, loop or onTrigger container never relayed are relayed on resume;
    • a resumed run and a resumed loop or onTrigger body get the input they started with;
    • mail a run already took stays with it across a respawn instead of reaching the new child again;
    • boot recovery and the dispatch read committed history instead of the checkout;
    • mail that arrives before a new child re-registers its parks is delivered instead of waiting out the 300 s backstop;
    • a max-uptime recycle no longer recycles every replacement;
    • a shutdown waits for a respawn's mail replay.

Design decisions

  • The sidecar wakes the child itself, and the Hub only checks that it holds the history. Waking through the Hub matters only once a deployment can resume elsewhere.
  • A standby request is retried until the Hub holds the history. History that never arrives means the Hub or the sidecar is broken, so giving up would not fix anything. The sweep stops once the run ends, is cancelled or loses its capacity.
  • A drain deadline does not carry across standby, as it does not across a recycle.

API changes

  • New POST /api/tenants/:tenantId/workflows/runs/:runId/standby and .../wake, both needing manage. They return 202 while pending, 204 when already in that state, and 409 for a terminal or cancelling run or one without usable sidecar capacity.
  • The lifecycle status gains standby: { state, changedAt, wakeAt, reason }, with state one of awake, entering, standby or waking.

Known limitations

  • A standby request whose history never reaches the Hub stays entering for as long as the run is live, with no error recorded; the sweep only logs each retry at info level.

Future work

  • Pause. Release the capacity of a deployment in standby and resume it on new capacity (or the same) when woken. It stays paused unless explicitly woken up - regular mail or signals can't trigger it.
  • Automatic standby. A lifecycle policy rule that puts a deployment in standby once all its runs have been parked for a set time, and later pauses one that stays in standby. The Hub's parked-run classifier tells whether a run is parked but not since when, so it needs that first.

@FarinM
FarinM added this pull request to stack #249 November 1, 2026 01:01
@linear

linear Bot commented Nov 1, 2026

Copy link
Copy Markdown

INTR-688

@FarinM
FarinM force-pushed the intr-688-add-a-parked-state-that-stops-a-workflow-deployments-child branch 3 times, most recently from a90aedc to 9a55d0b Compare November 2, 2026 13:53
@FarinM
FarinM force-pushed the intr-688-add-a-parked-state-that-stops-a-workflow-deployments-child branch 4 times, most recently from afbe688 to 1e3fceb Compare November 3, 2026 17:45
Base automatically changed from intr-606-persist-step-state-on-the-hub-so-workflows-fail-over-without to main November 3, 2026 17:50
@FarinM
FarinM force-pushed the intr-688-add-a-parked-state-that-stops-a-workflow-deployments-child branch 3 times, most recently from c1387c3 to 844152f Compare November 4, 2026 14:58
@FarinM
FarinM marked this pull request as ready for review November 4, 2026 15:18
@FarinM
FarinM force-pushed the intr-688-add-a-parked-state-that-stops-a-workflow-deployments-child branch 2 times, most recently from 39cf558 to ef248eb Compare November 6, 2026 00:44
FarinM added 12 commits November 8, 2026 19:28
A childWorkflow step relays its child's parks to the parent one at a
time and holds the rest in memory. When the process stopped while an
approval waited there, resume found the child parked on an approval the
parent had never relayed, with its snapshot in neither log, and failed
the run. The snapshot was durable all along: the parked step stores it
with its pending operation. Resume now reads it from there through the
env's new `loadParkedApproval` and relays the approval, and the sidecar
wires its step-store read into the top-level and nested run envs. The
lookup that follows a relay down to the step that parked moves into the
runtime, so re-registering parked correlations and resume share it.
A host that stops a workflow child has to know whether the trigger it
handed the child survives the stop. A run's started promise settles
once its RunStarted is durable, or reports that the run settled without
one or resumed from a log that had already ended.
A write puts its files in the checkout before it commits, so one that
failed in between left an event there that the resumed run never reads.
Boot recovery read the checkout, so it could hold back the mail that
started such a run, and nothing delivered it again. The dispatch's check
of whether a run started read the checkout too, so it then waited on a
run that never resumed. Both now read committed history. Boot recovery
reads one committed snapshot, which parses each tree once, since a walk
of runs/ would otherwise parse that tree again for every run.
A recycle or crash respawn moved every mail still in processing back to
the inbox, so mail a live run had already taken reached the new child
again: a turn the run already had stalled the dispatch on a re-park that
never came, and the start mail came back as a new turn. A respawn now
leaves that mail for the resumed run and consumes it once the run next
parks or ends. Mail of a run that had already ended goes back to the
inbox, where the dispatch refuses it.

A recycle replayed that mail before it killed the old child, which could
still take mail in between. The replay now waits for the old child to be
gone, for the writes it asked for, and for a boot replay still running,
which would put back mail the replacement takes. A write waiting on a
merge from a child that exited fails instead of holding the repository
lock those replays need.
A host that stops a workflow child loses whatever the child was doing,
so it may stop the child only while no run works and no committed input
is still on its way to the run that waits for it. On `quiet.request` the
child answers that the run has ended, or whether it is quiet together
with the earliest timer its scheduler has queued, so the host can wake
it for that timer. Waits on signals and child handles take a required
`onWake`, so whatever wakes a waiting step counts it as working before
the step stops counting itself.

Scheduler recovery no longer queues a timer whose step settled after it
was set, or whose run ended, since the answer would report a wake for
it that does nothing.
The supervisor, recycle, wiring, run-child and parked-correlation tests
made their data dirs under the system temp dir and left them there, so
each run of those files added more. They now remove them once their
tests are done.
Only a fresh run was given its trigger payload, so a run resumed after a
restart or respawn read null for trigger.payload in every later step,
though its RunStarted records the payload.
A restart can find a loop or onTrigger body holding parks its container
never relayed, as it can a childWorkflow child. Resume handed the
container a single token for the park it had relayed, so the others
were never relayed and the run waited forever. Both containers now
resume through the childWorkflow's relay plan, which relays every park
the body holds that the container never did.

The plan also counts an approval park a body step stored but crashed
before logging, and re-registering parked correlations reads bodies the
same way, so both find the same parks. A body whose log is gone starts
over: the container re-links the relay it holds for it once, and fails
the step when input was delivered after the spawn, which it cannot hand
down again. A re-established relay lets a nested container hand down a
signal its parent took before the restart. The Hub's parked-run check
reads the body's log too, so an onTrigger body parked on a signal its
section never relayed counts as parked.

A timed step can leave an approval and go on, so a body may no longer
hold the approval its container relays. A loop or onTrigger container
now retires that relay and carries on, whether the body left before the
restart or while the relay waits. A spawn step still fails on it.
SignalAwaitAbandoned now accepts the approval park of a step that
spawned a child, which changes which logs replay as valid. A plan that
only retires a relay holds no park, so the parked-run check does not
count it.

A loop or onTrigger body its container resumes is given its input, so a
body whose log is gone starts over with it. An onTrigger section
recovers that input from its own log: the run's trigger payload for its
first event, and what its input park delivered for a later one.
The recycle policy kept the first child's start time, so once a
max-uptime recycle had run, every later tick recycled the replacement
again.
A shutdown during a respawn did not wait for its replay of processing/,
which could write after the host removed the run repository and
recreate it. A replay that has not begun when shutdown starts no longer
runs.
The supervisor needs only this shape, but importing it loaded the whole
store adapter into the sidecar's boot, along with everything the adapter
depends on.
The runtime's subscribe contract replays from a seq, but the child's
proxy store only tails from head, so a consumer missed whatever landed
before it subscribed. A timed gate woken by its due timer waited
forever on a TimerFired already in its log.
A re-park read the run's state twice. A TimerFired between the reads
left no timer to adopt, so the gate minted a second TimerSet with a
fresh deadline. A gate that lost its timer watch also waited on with
no deadline at all, so that now fails the run.

A gate its own timer moved also resumes when another gate shares its
name. The refusal there guards against binding a signal's payload to
the wrong gate, and a timeout has no payload.
A container handed a decision down as soon as it had it. A body still
resuming acts on a park it read before the decision landed, so an agent
step looked crashed and failed.
The drain timeout signed and committed its CancelRequested itself, with
the substrate and signing bindings threaded in for it alone. It now
hands the cancellation to a commit the supervisor injects, so the
supervisor decides how a cancellation it owns is committed. What gets
committed is unchanged.
It also drops "on spawn" from the recovery's log messages, since the
function need not know what called it.
A deployment waiting on an approval, a signal or its next trigger kept
its workflow child running for the whole wait. The supervisor now stops
the child once it reports quiet twice, and a re-registration delivers
waiting mail, since mail that wakes the child from standby would
otherwise wait out the 300 s backstop.

A supervisor in standby respawns its child for mail, a signal, a
cancellation, its earliest pending timer or a wake request. A host that
records each standby through recordStandby can bring the deployment back
with restoreInStandby after a restart, with no child running. A new
input.taken frame answers a trigger or signal that asks for it once the
child has committed the input or declined it, so an input the child took
is not dropped with the stopped child's writes.

A dispatch now consumes a signal's mail only once that answer says the
child committed it, since another step's park or the run's end says
nothing about this mail. A declined mail is rejected if the committed
log shows its run ended. On a live run it fails the dispatch and stays
in processing/ until a respawn replays it, since forcing a recycle to
retry it could loop on a persistent fault.
A signal committed while a resumed run plans moves a step the run has
just read as parked, which fails an agent step or loses a section's
input. A resume guard cannot tell this from a crash after the decision
was taken, when re-invoking the step could run a tool twice, so the
child holds the signal instead.
The supervisor can put a waiting deployment's child in standby, but
nothing outside the sidecar could ask it to. The Hub can now send
standby and wake commands over the link, and confirms a standby request
only once it holds the history the stopped child left. The deployment
stays routed, so an input wakes it; the sidecar reports that wake,
holding the report through a link outage, and every hello says whether
the child is in standby. The run record keeps what the child was parked
on, so a sidecar restart keeps the deployment in standby without the
child while its history is unchanged.

The sidecar bounds the length of a control answer's error, and the Hub
rejects a longer one. An answer that fails validation rejects the
pending control it names, which could otherwise wait forever.
A user could not ask for a deployment to be put in standby or woken.
The API records the request and the lifecycle sweep carries it out
against the sidecar, so the call does not wait for every run to go
quiet.

The Hub now also acts on what the sidecar reports about standby: a child
in standby that nobody asked for is recorded waking and woken, and a
hello finding the child running ends a recorded standby. Moving a
deployment to replacement capacity resets its standby state, since the
replacement starts awake: a standby still wanted is asked for again
there, and a wake has nothing left to do.
@FarinM
FarinM force-pushed the intr-688-add-a-parked-state-that-stops-a-workflow-deployments-child branch from ef248eb to 65a681a Compare November 8, 2026 22:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant