Files
windmill/backend/windmill-queue
Ruben Fiszel f02df7fc45 feat(monitor): make between-steps zombie flows hand-recoverable (#10287)
* feat(monitor): make between-steps zombie flows hand-recoverable

When a worker is OOM-killed mid state-transition, the flow is reaped as a
between-steps zombie (children all success, module still InProgress). We do
not auto-recover (a re-driven transition can OOM again), so instead:

- Append actionable recovery guidance to the cancellation reason when the
  reaped step's state is derivable (every child a success completion): which
  step, iterations completed, raise memory then restart-from-step (UI + API).
- Restart-from-step now reuses a zombie step verbatim (InProgress with all
  children successful) and restarts from the next step, so no completed child
  re-runs; downstream steps re-derive its result from flow_jobs on demand.
- Cast flow_status ::text in the reaper query: reading the jsonb column as
  Box<str> included the binary version byte and silently failed FlowStatus
  parsing (disabling the restart-not-yet-started branch since the v2 migration).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(monitor): only reuse a between-steps zombie step that provably finished

Address review findings on the zombie-restart reuse path:

- Require structural completeness (FlowStatusModule::is_between_steps_complete):
  a serial for-loop / branch-all reaped mid-fan-out has an all-success prefix but
  unrun remaining iterations, so the cursor must sit on the last element; while-loops
  are never derivable (continuation is a post-iteration condition). Parallel
  containers preallocate all children, so success alone is conclusive. Shared by the
  monitor guidance and the restart resolution.
- Decline reuse when the step carries stop_after_if / stop_after_all_iters_if: those
  predicates decide whether downstream steps run, and reuse would bypass them; such a
  step re-runs instead.
- Decline reuse when the zombie step is the last module (advancing past it lands on
  the failure step); it falls back to the existing re-run path.
- Unit tests for is_between_steps_complete and an integration test asserting a
  mid-iteration serial-loop zombie is re-run, not reused.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(monitor): align zombie recovery guidance with restart eligibility

Address CI review findings:

- Exclude skip_if / suspend / sleep (not just stop predicates) from reuse via
  FlowModule::allows_zombie_reuse, so a skipped/suspend-armed step is never
  synthesized as Success (which would strand a restart waiting on an approval it
  never armed).
- The reaper does not load the flow definition, so it cannot know whether restart
  will reuse or re-run a given step; reword the guidance to state both outcomes
  (reuse where derivable, re-run for the flow's last step or one carrying a
  stop/skip condition, approval, or sleep) instead of promising "no re-run".
- Make the mid-iteration regression test exercise the cursor-completeness guard:
  a downstream step makes the loop non-final, so reuse is prevented only by the
  guard; a truncated loop result would then fail the assertion.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(monitor): never let zombie reuse swallow a nested restart request

A nested restart (RestartedFrom.nested) descends into the restart step's child to
re-run an inner step. For an eligible zombie BranchOne/Subflow the outer
branch_or_iteration_n is None, so reuse fired, skipped the container, and the
explicitly requested inner step never re-ran. Thread the presence of a nested
chain into restarted_flows_resolution and decline reuse when set. Regression test
added (RED without the guard: the nested target is reused instead of re-run).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(monitor): don't auto-requeue preprocessor zombies as unstarted flows

The ::text parse fix re-activated the "hasn't started yet, restart it" branch,
but its `modules[0] == WaitingForPriorSteps` check also matches a flow whose
preprocessor is still InProgress (step == -1, first module waiting). Requeuing
such a flow re-runs the preprocessor, duplicating side effects / repeating the
OOM. Gate the branch on FlowStatus::is_not_yet_started, which also requires the
preprocessor (if any) to be WaitingForPriorSteps. Unit-tested.

Also drop the numbered procedural narration from the happy-path test comments.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(monitor): only emit restart guidance for restartable (deployed, top-level) flows

The recovery guidance points operators at the run page's "Re-start from" button
and the restart API, but both require a top-level deployed flow: a preview has no
flow path (the button is hidden, the API 400s) and a subflow child restarts via
its root, not itself. Gate the guidance on runnable_path IS NOT NULL AND
parent_job IS NULL so previews/subflows keep the existing wording instead of
being told to use a button/endpoint that isn't there. Verified end-to-end: a
reaped preview gets no RECOVERY block, a reaped deployed flow does.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(monitor): gate recovery guidance on kind='flow' to match the restart surface

Addresses review nit: a pathful editor preview (kind='flowpreview' with a
runnable_path) satisfied the previous runnable_path check but the run page only
renders the "Re-start from" button for kind='flow'. Match that condition exactly
so previews/singlestepflow keep the plain wording. Verified end-to-end: a reaped
pathful preview now gets no RECOVERY block.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(monitor): disable zombie reuse for raw-flow (editor preview) restarts

A JobPayload::RawFlow restart queues the request's current, possibly EDITED,
definition, but restarted_flows_resolution validates reuse against the completed
job's STORED definition. For an eligible preview zombie, editing the restart step
and restarting from it would synthesize Success from the old children and skip the
edit. Thread allow_zombie_reuse into the resolver (true only for
JobPayload::RestartedFlow, which queues the stored definition) and decline reuse
for raw-flow restarts. Regression test added (RED without the guard: the edited
step is skipped and the old result is reused).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore(sqlx): add offline cache for zombie_flow_recovery test queries

The integration test's UPDATE v2_job_completed queries had no .sqlx entry, so the
CI SQLX_OFFLINE build of the test failed to compile. Regenerated with
--all-targets --features deno_core,quickjs to capture the test-target queries.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(monitor): drop procedural narration from the raw-flow zombie test

Per AGENTS.md (comments record constraints, not narration): remove the two
step-describing comments the reviewer flagged; the test doc comment already
carries the durable rationale.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(monitor): restrict zombie reuse to monitor-reaped flows

The reuse predicate matched the InProgress/all-children-success shape without
checking provenance, so an ordinary force-cancel at the same boundary (a child
succeeded before its parent transition landed) would also be reused, dropping
the usual restart-from-step re-run. Gate reuse on canceled_by = 'monitor' (the
username the zombie reaper cancels with). Regression test added (RED without the
guard: a user-cancelled flow reuses the child instead of re-running it).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(monitor): reuse zombie step on Some(0) too, so the run-page button works

The run page's "Re-start from" button always sends branch_or_iteration_n = 0
(never omits it), but reuse only fired for None, so the exact UI path the
recovery message points to would re-run the children instead of reusing them.
Treat a whole-step restart (None or Some(0)) as reuse-eligible; Some(n>=1) keeps
the explicit partial-container restart. Verified against the live EE restart API
with branch_or_iteration_n=0: all loop-iteration child UUIDs are reused. Happy-
path test now sends Some(0) to match the button.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 19:09:28 +02:00
..