Mark
04/24/2026, 10:28 AMMarvin
04/24/2026, 10:28 AMMarvin
04/24/2026, 10:32 AMCancelled state.
So, to answer your direct question:
What is needed for a run to actually go into Cancelled?
A Prefect worker/infrastructure manager must observe the run in Cancelling, attempt to terminate the infrastructure (or determine it is already gone), and then successfully propose the Cancelled state back to the API.
If that process never happens, the run can remain stuck in Cancelling.
---
### Why this happens with OOMKilled Kubernetes pods
An OOMKill is abrupt. The process inside the pod does not shut down cleanly, so it often cannot:
- catch the event,
- run cleanup logic,
- send a final `Failed`/`Crashed`/`TimedOut` state.
That means Prefect still thinks the run is active unless an external component notices the pod/job is gone or failed.
In Kubernetes-based execution, that “external component” is typically the worker plus Kubernetes job observation logic. If that observation/cancellation path is interrupted or missing, the run can remain Running.
---
### Why timeout may not transition the run
A timeout in Prefect is not magic by itself if the underlying infrastructure has already vanished or stopped heartbeating in a way that prevents enforcement.
In practice:
- the pod dies,
- no final state is reported,
- the run can stay Running,
- and the timeout won’t necessarily immediately move it to a terminal state unless another orchestration/infrastructure actor evaluates and applies that transition.
So your observation that it doesn’t move to timed out “unless some action is taken” is consistent with the infrastructure disappearing before Prefect can finish orchestration around the run.
---
### Why manual cancel can get stuck on Cancelling
Cancelling is an intermediate state. It means:
“Prefect has accepted the cancellation request, and now some worker/infrastructure process needs to terminate the run infrastructure and finalize the state.”For it to become
Cancelled, one of these must happen:
1. The worker finds the infrastructure and kills it, then marks the run Cancelled.
2. The worker checks and determines the infrastructure is already gone, then marks the run Cancelled.
If no worker is available to do that, or if there is a bug/error in that path, the run can remain Cancelling.
There has also been at least one reported issue around Kubernetes jobs getting stuck in Cancelling in certain prefect-kubernetes versions:
- GitHub issue #16001
---
### In your exact scenario
For a pod that was OOMKilled:
- If the Kubernetes worker/observer detects the job failure cleanly, the run should generally move to a terminal state such as Crashed rather than hang forever.
- If you manually cancel after the pod is already gone, the worker still needs to reconcile that fact and submit Cancelled.
- If there is no active worker processing that cancellation, or the worker cannot reconcile the infrastructure PID/job state, the run may sit in Cancelling.
---
### What to check
1. Make sure a worker is running and healthy
- The worker is what processes cancellation requests and infrastructure cleanup.
2. Check worker logs around the time of cancellation
- Look for errors related to:
- killing infrastructure
- missing infrastructure PID
- Kubernetes API errors
- permissions deleting jobs/podsMarvin
04/24/2026, 10:32 AMprefect-kubernetes versions
- Especially if you’re seeing Cancelling get stuck.
- The issue above may be relevant.
5. Review memory limits / requests
- If the pod is being OOMKilled, increasing memory limits or reducing workload memory pressure will prevent the root cause.
---
### CLI reference
To cancel a flow run by ID, the CLI is:
bash
prefect flow-run cancel <FLOW_RUN_ID>
Verified via CLI help.
---
### Practical expectation
If the run infrastructure is already dead, a healthy worker should still be able to notice that and finalize the run. So if it stays in Cancelling, that usually means one of:
- no worker is available to process it,
- the worker cannot access/reconcile the Kubernetes job,
- or you’ve hit a bug/version-specific issue.
---
### Recommended next steps
Please check and share:
- your Prefect version
- your prefect-kubernetes version
- whether you are using a Kubernetes work pool / worker or something else
- worker logs from when the run entered Cancelling
If you want, I can help you narrow this down to:
- expected behavior due to lost infrastructure, or
- a specific stuck-cancellation bug/version issue.Mark
04/24/2026, 3:47 PMA timeout in Prefect is not magic by itself if the underlying infrastructure has already vanished or stopped heartbeating in a way that prevents enforcement.I understand that work is needed to transition from
running to time out state, though I assumed this would be covered by the prefect API. Isn't that the whole point of having central orchestrator; to orchestrate?
The worker is what processes cancellation requests and infrastructure cleanup.So you need a worker (not just a runner) to perform those cleanups? If so, this seems like a pretty serious issue with simplistic setup of just having a runner instead of a full worker, which you mentioned in another thread is a valid approach?
Mark
04/28/2026, 8:41 AMRunner, adding a flow to it using add_flow and starting it using its start method. From what I understood, this means, we only have a runner and we don't have a worker. Is that correct?
From your previous response I get the impression that this is the reason why tasks stay on the "running" and "cancelling" status. Is this also correct?
If the above is correct, what would be the simplest way to create a worker? We'd like to stay with our current minimal approach for our runners, because we we tried to serve flows using the documenten methods (using the serve method), it would do things like "downloading" flow implementations, which caused other complications. So we want a simple runner (like we have right now), but also a worker for handling the status changes.Marvin
04/28/2026, 8:41 AMMarvin
04/28/2026, 8:43 AMpython
runner = Runner(...)
runner.add_flow(...)
await runner.start()
then you are using a Runner-based setup, not a Worker-based setup.
In that mode:
- you do not have a worker
- you do not have a work pool involved
- the runner is responsible for polling, launching, and reconciling those runs
So if your current execution model is just Runner + add_flow + start, there is no separate worker in the architecture.
---
### 2) Is that why runs stay in Running / Cancelling?
Very likely yes, especially for the way you’re using Kubernetes.
The important distinction is:
- Runners are meant to manage the lifecycle of runs they launch
- Workers are the component designed for infrastructure-backed execution via work pools, including Kubernetes job lifecycle handling
If your flow execution pod is OOMKilled and disappears before reporting a final state, then some external orchestration component needs to notice that and reconcile the Prefect state.
In a worker/work-pool Kubernetes setup, that role is handled by the worker + Kubernetes infrastructure handling.
In your custom minimal runner setup, if the runner process does not have a robust mechanism to observe that Kubernetes infrastructure failure and finalize the run, then runs can remain:
- Running if nothing proposes a terminal state
- Cancelling if cancellation was requested but nothing completes the cancellation handshake
So yes: the absence of a worker/infrastructure reconciliation path is a plausible reason for the stuck states you’re seeing.
---
### 3) Can you “just add a worker” to your current runner-managed flows?
Not really.
Workers and runners are different execution models.
A worker works with:
- deployments
- work pools
- infrastructure-specific job submission
A runner works with:
- flows added directly with add_flow
- runner-managed polling/execution
So there is not really a supported “runner + separate worker for cleanup only” architecture.
If a flow run is created and managed in runner mode, you cannot just attach a worker to it later and expect the worker to reconcile it.
---
### 4) Simplest way to get worker behavior while staying minimal
The simplest supported path is:
- move from manual runner-managed flows to deployment + work pool + worker
- keep your code packaging simple
- avoid from_source(...) / remote code pulling if that caused the “downloading” behavior you disliked
In other words:
- don’t use serve() if that model caused problems for you
- don’t use remote source-based deployments if you don’t want code download/pull behavior
- instead use flow.deploy(...) with a Kubernetes work pool and an image that already contains your flow code
That gives you:
- Kubernetes-native execution
- worker-managed cancellation
- better reconciliation when pods die
- no source download step at runtime
---
### 5) Recommended minimal architecture
#### A. Put your flow code into a container image
Build your own image containing:
- your flow code
- your dependencies
- Prefect
#### B. Create a Kubernetes work pool
You can verify the CLI like this:
bash
prefect work-pool --help
and then create a pool. Before suggesting the exact command, I should verify it if you want, but conceptually it’s a Kubernetes work pool.
#### C. Deploy the flow to that work pool
Example pattern in Prefect 3:
python
from prefect import flow
@flow
def my_flow():
print("hello")
if __name__ == "__main__":
my_flow.deploy(
name="my-flow",
work_pool_name="my-k8s-pool",
image="my-registry/my-image:latest",
build=False,
push=False,
)
This assumes the image already exists and already contains the code.
#### D. Run a worker for that poolMarvin
04/28/2026, 8:43 AMserve() and “downloading code”
What you described about “downloading” flow implementations is usually associated with remote source/storage patterns, not plain local serve() itself.
There are two different ideas here:
#### Plain local serve
python
my_flow.serve(...)
This is runner-based and runs from local code. No remote code pull is inherent in that by itself.
#### Source-backed flow
python
flow.from_source(...).serve(...)
This does involve pulling/downloading code from the configured source.
So if your earlier setup was pulling code, I suspect it may have involved a source-backed pattern rather than plain local serve().
---
### 7) Bottom line
Your conclusions are basically right:
- Yes, your current setup is runner-only, not worker-based.
- Yes, that is likely related to why runs can stay stuck in Running or Cancelling when Kubernetes infrastructure dies unexpectedly.
- No, there isn’t really a simple supported way to bolt a worker onto runner-managed flows just for status handling.
- Yes, the simplest robust alternative is to switch those flows to deployment + Kubernetes work pool + worker, while keeping things minimal by packaging code directly into the image and avoiding source download patterns.
---
### 8) What I’d recommend for you
If your main goals are:
- minimal setup
- no remote source downloads
- robust Kubernetes cancellation/state handling
then use:
- flow.deploy(...)
- a prebuilt Docker image containing your code
- a Kubernetes work pool
- a Kubernetes worker
That is the smallest supported architecture that gives you proper worker-based reconciliation.
---
### Links
- Prefect deployment concepts: https://docs.prefect.io/
- Workers and work pools: https://docs.prefect.io/
- Relevant stuck-cancellation issue: GitHub issue #16001
---
If you want, I can give you a minimal end-to-end example for:
1. creating a Kubernetes work pool
2. deploying your existing flow with flow.deploy(...)
3. starting the worker
4. doing it in a way that does not use remote source downloads.Mark
04/28/2026, 9:03 AMMarvin
04/28/2026, 9:03 AMMarvin
04/28/2026, 9:05 AMRunner does not generally “re-adopt” old in-flight runs and reconcile stale Running / Cancelling states from before it crashed.
### Why
In runner mode, the runner tracks the runs/processes it launched in its own runtime state. If the runner pod gets OOMKilled:
- that in-memory tracking is lost
- on restart, the runner starts fresh
- it polls for new scheduled work
- it does not automatically reconstruct ownership of previously running/cancelling runs
So if a run was left in:
- Running
- Cancelling
because the runner died mid-lifecycle, the restarted runner typically will not clean that up automatically just by coming back.
---
### So is there a supported “runner restart recovery” path?
Not really in the sense you’re asking.
There is no general built-in orphan adoption mechanism for runner-managed runs where the restarted runner says:
“these stale runs used to be mine; I’ll now inspect them and finalize them.”That’s why stale states can persist in this setup. --- ### What can you do instead? If you want to stay with runners, the best-supported approach is to add server-side stale run detection, not a worker. The main pattern is: - enable flow heartbeats - create an automation that marks runs as
Crashed if heartbeats stop arriving
That way, if the runner dies and the run is stranded in Running, Prefect can eventually mark it terminal without needing the original runner to come back and finish cleanup.
Prefect documents this as zombie-flow detection:
- Detect zombie flows
---
### Important nuance: Running vs Cancelling
This heartbeat/automation approach is best for stale Running runs.
For stale Cancelling runs, things are trickier:
- Cancelling implies cleanup was supposed to happen
- if the runner that was supposed to perform that cleanup is gone, there is no built-in restart recovery that reliably resumes that cancellation process
So for runner-only setups:
- stale Running can often be handled with heartbeat-based crash detection
- stale Cancelling is harder and may require manual or custom reconciliation
---
### Practical answer to your question
is it possible to have the runner, after it is restarted, manage the stale states instead of creating a worker?Not in a built-in, reliable way. If you stay with runners, your realistic options are: 1. Use heartbeats + automation to mark stale
Running runs as Crashed
2. Build your own reconciliation script that periodically finds old Running / Cancelling runs and force-updates them via the API
3. Move to worker/work-pool architecture if you want more robust infrastructure lifecycle reconciliation
---
### What I’d recommend for your case
If you want to preserve the minimal runner approach, then the simplest improvement is:
- keep the runner
- add heartbeat-based zombie detection automation
- optionally add a small periodic cleanup job for stale Cancelling runs
If you want, I can help you with either of these next:
1. a minimal zombie-flow automation for your runner setup
2. a small reconciliation script that finds stale Running / Cancelling runs and marks them terminal
3. a comparison of those options vs switching to a worker-based Kubernetes setupMark
04/30/2026, 3:09 PMMarvin
04/30/2026, 3:09 PMMarvin
04/30/2026, 3:12 PMchange-flow-run-state → mark the zombie flow run Crashed
2. run-deployment → trigger a cleanup/reconciliation flow that updates your application state
3. optionally send-notification or call-webhook
So your pattern would be:
- Zombie detected (missing heartbeat)
- Automation fires
- Action 1 crashes the Prefect flow run
- Action 2 runs a separate deployment that marks your application object/job/entity as failed
This is the relevant docs area:
- Detect zombie flows
- Automations
- Event triggers
- Creating automations
Important caveat about sequencing
Automations support multiple actions, but they are not really “A then B with dependency semantics.” In practice:
- you can attach both actions to the same automation
- you should not assume strict transactional behavior between them
If your app-state update must happen with special logic, the cleanest approach is often:
- use the automation to mark the flow Crashed
- have the automation also run a dedicated cleanup/reconciliation deployment
- put all your application-specific failure handling in that cleanup flow
That gives you a clearer place to implement retries, idempotency, auditing, etc.
Can you make it depend on the “type” of flow run that crashed?
Usually yes, if that type is represented in Prefect metadata.
Common ways to scope it:
- specific deployment
- specific tag(s)
- specific work pool / work queue
- sometimes related flow/deployment resources
This is supported through automation event matching/filtering on the flow run event and its related resources:
- Flow run events
Practical recommendation
If your “type” is application-specific, the easiest option is usually to encode it as a deployment or tag, then filter the automation on that.
For example:
- tag certain runs/deployments with app-type-x
- create the zombie automation to only match runs related to prefect.tag.app-type-x
What this likely looks like conceptually
You’d configure one zombie automation with:
- a proactive trigger for missing heartbeat
- a filter for only the runs you care about
- two actions:
- ChangeFlowRunState(...CRASHED...)
- RunDeployment(...your cleanup deployment...)
If you want, I can help you write:
1. a UI-based setup for this automation, or
2. a Python example for an automation that:
- crashes zombie runs
- triggers another deployment
- only applies to a chosen deployment/tag/work pool
If you share how you distinguish your “flow-run type” today (deployment name, tags, parameters, work pool, etc.), I can suggest the exact filter to use.Mark
04/30/2026, 3:35 PMMarvin
04/30/2026, 3:35 PMMarvin
04/30/2026, 3:37 PMRunDeployment action.
What you can’t do directly
- You cannot target a deployment “just by name” in RunDeployment.
- RunDeployment requires a deployment ID when you explicitly choose the target deployment.
So the answer is:
- flow name filter by name? → Yes
- run deployment by deployment name only? → No, not directly; use deployment ID
Why
For flow-run events, Prefect includes:
- the flow run as the primary resource
- the flow as a related resource, including prefect.resource.name
So automations can match on the related flow’s name.
But for RunDeployment, the action is designed to target a deployment by UUID, not by deployment name string.
Docs
- Automations
- Event triggers
- Flow run events
- Detect zombie flows
Practical pattern I’d recommend
If you want “when flow named X becomes zombie, run cleanup Y”:
- filter the automation by flow name
- configure the action to run a specific deployment ID for cleanup/reconciliation
So operationally it still feels name-based on the trigger side, but the action target is pinned to a concrete deployment.
Example shape in Python
Something like:
trigger:
match prefect.flow-run.*
match_related:
prefect.resource.role: flow
prefect.resource.name: my-flow-name
actions:
- change flow run state to Crashed
- run deployment with deployment_id=<cleanup deployment uuid>
One more nuance
If by “trigger a flow using just their name” you mean “dynamically choose which deployment to run based on the crashed flow’s name,” Prefect automations don’t provide a native “lookup deployment by name at execution time” action.
In that case, common options are:
- create one automation per flow name and point each to the right deployment ID
- or trigger a single router/reconciliation deployment that receives {{ flow.name }} / {{ flow_run.id }} and performs whatever app-specific logic you want
That second option is often the cleanest.
Good news on templating
Action parameters can use templates, so your reconciliation deployment can receive context like:
- {{ flow.name }}
- {{ flow_run.id }}
- {{ flow_run.parameters[...] }}
That makes it easy to drive your downstream state update logic.
If you want, I can draft:
- a UI configuration for this zombie-flow automation, or
- a full Python example that filters by flow name and triggers a cleanup deployment with templated parameters.Mark
04/30/2026, 3:43 PMRunner.add_flow and Runner.run, so we don't have a worker. How would I trigger a flow that is being served this way in an automation?Marvin
04/30/2026, 3:43 PMMarvin
04/30/2026, 3:46 PMRunner.add_flow(...) / Runner.run() setup and no worker, the served flow is still backed by a deployment. That’s the key piece that makes automations work.
How it works
When you do something like:
runner.add_flow(my_flow, name="my-deployment")
runner.run()
Prefect registers a deployment for that flow. Then:
1. the automation’s run-deployment action creates a scheduled flow run for that deployment
2. your Runner polls for scheduled runs for the deployments it is serving
3. the Runner executes that run locally
So:
- worker required? → No
- deployment required? → Yes
- can Runner-served flows be triggered by automations? → Yes
Important implication
If you want an automation to trigger one of these served flows, that flow must be:
- added to the same runner process (or another running runner)
- registered as a deployment
- actively being polled by that runner
What you cannot do
An automation cannot say “call this in-memory Python function by name on that runner.”
It always works through Prefect’s orchestration layer, i.e. by creating a run for a deployment.
So the pattern is
- serve the target flow with Runner.add_flow(...)
- note the deployment it creates
- configure automation action RunDeployment to target that deployment
- the Runner picks it up and runs it
Your use case
For your zombie-flow scenario, that likely means:
- Flow A is running under your runner
- zombie automation fires for Flow A
- action 1 marks Flow A as Crashed
- action 2 runs deployment for Flow B, also served by a runner
- Flow B updates your application-specific state to failed
One caveat from your earlier question
You said you’d like to trigger by name. The trigger side can be name-based enough, but the action side still wants a deployment ID, not just a deployment name.
So in practice:
- filter automation by flow name → yes
- target served cleanup flow by deployment name only → no, use deployment ID
Good news
Runner.add_flow(...) returns the deployment UUID, so you can capture it when registering the served flow.
From the verified signature, Runner.add_flow(...) returns a UUID.
So conceptually:
cleanup_deployment_id = runner.add_flow(cleanup_flow, name="cleanup")
main_deployment_id = runner.add_flow(main_flow, name="main")
runner.run()
Then your automation can use cleanup_deployment_id.
Alternative that may be even cleaner
Because Runner.add_flow accepts triggers=..., you may be able to define the event trigger directly on the served deployment itself, instead of creating it separately in the UI/API.
That gives you a deployment-backed flow that is still served by the runner, but event-triggered.
Relevant docs:
- Deployments
- Automations
- Creating deployment triggers
- Detect zombie flows
Bottom line
Yes — to trigger a flow served with Runner.add_flow / Runner.run, configure the automation to run that flow’s deployment. The Runner will pick up the scheduled run and execute it locally; no worker is needed.
If you want, I can sketch a concrete example showing:
- a runner serving main_flow and cleanup_flow
- a zombie automation filtered by flow name
- the automation triggering the served cleanup_flow deployment.Mark
04/30/2026, 3:51 PMMarvin
04/30/2026, 3:51 PMMarvin
04/30/2026, 3:52 PMRunner.add_flow(...), Prefect uses a create-or-update by deployment name pattern. If you keep the same:
- flow name
- deployment name
then on restart Prefect should update the existing deployment rather than create a completely new one, and the deployment ID remains stable.
So for your scenario, the cleanest approach is:
Recommended pattern
1. Start the runner
2. Call runner.add_flow(...) for each served flow with stable names
3. Capture the returned deployment IDs
4. Create or update the automation programmatically
5. Run the runner
That makes startup idempotent:
- first startup creates deployments + automation
- later startups update/reuse them
You were right to push on the ordering question.
Why this works
Runner.add_flow(...) does not just blindly create a new deployment every time. It looks up the deployment by full name and updates it if it already exists.
So the automation can also be treated declaratively:
- either create/update it on startup
- or attach triggers to deployments where appropriate
For your specific case, since you want:
- one flow served by runner
- another served flow triggered when zombie/crash condition happens
- probably filtering by flow name
I’d suggest an explicit startup function that:
- registers both deployments
- looks up/creates the automation that connects them
Recommended architecture for your case
- `main_flow`: your application flow
- `reconcile_flow`: marks your application-specific state as failed
- one startup/bootstrap script:
- runner.add_flow(main_flow, name="main")
- runner.add_flow(reconcile_flow, name="reconcile")
- upsert automation: “when zombie/main_flow occurs, mark crashed and run reconcile deployment”
- runner.run()
One important detail
For filtering by flow name, use the event trigger filter on the related flow resource name.
For triggering the reconciliation flow, use the deployment ID returned by add_flow.
Example
Below is a concrete pattern you can adapt.
```python
from datetime import timedelta
from uuid import UUID
from prefect import flow
from prefect.runner import Runner
from prefect.automations import Automation
from prefect.events.schemas.automations import EventTrigger, Posture
from prefect.events.schemas.events import ResourceSpecification
from prefect.events.actions import ChangeFlowRunState, RunDeployment
from prefect.client.schemas.objects import StateType
AUTOMATION_NAME = "zombie-main-flow-reconcile"
@flow
def main_flow(app_id: str):
print(f"Running main flow for {app_id}")
# your real work here
@flow
def reconcile_flow(flow_run_id: str, flow_name: str, app_id: str | None = None):
print(
f"Reconciling app state for flow_run_id={flow_run_id}, "
f"flow_name={flow_name}, app_id={app_id}"
)
# update your application-specific state here
def build_zombie_automation(reconcile_deployment_id: UUID, target_flow_name: str) -> Automation:
return Automation(
name=AUTOMATION_NAME,
description="Crash zombie runs of main_flow and reconcile app state",
trigger=EventTrigger(
after={"prefect.flow-run.heartbeat"},
expect={
"prefect.flow-run.heartbeat",
"prefect.flow-run.Completed",
"prefect.flow-run.Failed",
"prefect.flow-run.Cancelled",
"prefect.flow-run.Crashed",
},
posture=Posture.Proactive,
threshold=1,
within=timedelta(seconds=90),
match=ResourceSpecification(
{
"prefect.resource.id": ["prefect.flow-run.*"],
}
),
match_related=ResourceSpecification(
{
"prefect.resource.role": "flow",Marvin
04/30/2026, 3:52 PM*What this gives you*
- deployment registration is stable across restarts
- automation is created once, then updated on later restarts
- no worker required
- reconciliation flow is also served by the same runner model
*Two things to double-check in your environment*
1. *Flow name vs deployment name*
The trigger filter above uses *flow name*, not deployment name.
If your flow is declared as:
```python
@flow
def main_flow(...):
...
then the flow resource name is typically main_flow.
2. Automation read/update behavior
The pattern above assumes Automation.aread(name=...) returns either the existing automation or None. If you want, I can give you a more defensive version that uses the client directly in case your installed version behaves slightly differently.
Alternative: deployment-managed triggers
There is another pattern: define triggers= directly on the deployment created by Runner.add_flow(...). That is nice when the automation is tightly coupled to that deployment.
But for your case, because the automation:
- watches one flow
- triggers another deployment
- may include custom app-state logic
I think the explicit startup upsert approach is clearer and easier to maintain.
My recommendation
Use a single bootstrap script that always:
- registers deployments with stable names
- upserts the automation by name
- starts the runner
That preserves the “declare once, keep it synced” experience you were expecting.
If you want, I can tighten this into a fully verified Prefect 3 example for either:
- pure async startup with Runner, or
- a simpler version using flow.serve(...) instead of manually constructing Runner.Mark
05/07/2026, 2:49 PMRunDeployment action to pass the all the arguments from the original flow (main_flow in your example) to the reconcile_flow, as well as other (statically defined) arguments?Marvin
05/07/2026, 2:49 PMMarvin
05/07/2026, 2:50 PMRunDeployment to pass:
1. selected parameters from the triggering flow run, and
2. additional static values
But you cannot automatically forward the entire original parameter dict in one shot.
So this works conceptually:
python
RunDeployment(
source="selected",
deployment_id=reconcile_deployment_id,
parameters={
"name": {
"__prefect_kind": "jinja",
"template": "{{ flow_run.parameters['name'] }}",
},
"date": {
"__prefect_kind": "jinja",
"template": "{{ flow_run.parameters['date'] }}",
},
"mode": "reconcile",
"retries_allowed": 3,
},
)
That means:
- name and date come from `main_flow`'s run parameters
- mode and retries_allowed are static values you define in the automation
Important limitation: there isn't a built-in "spread all upstream parameters into this downstream deployment's parameters" feature for RunDeployment. You need to map them explicitly.
Also, if you provide parameters=..., those are the parameters sent to the target deployment — it's not doing an automatic merge with the target deployment's defaults for you in the automation config. So if the downstream flow expects parameters, you should define them explicitly.
A practical example:
python
from prefect.automations import Automation, EventTrigger, Posture, RunDeployment
from datetime import timedelta
automation = Automation(
name="Trigger reconcile flow",
trigger=EventTrigger(
expect={"prefect.flow-run.Completed"},
posture=Posture.Reactive,
threshold=1,
within=timedelta(seconds=30),
),
actions=[
RunDeployment(
source="selected",
deployment_id=reconcile_deployment_id,
parameters={
"customer_id": {
"__prefect_kind": "jinja",
"template": "{{ flow_run.parameters['customer_id'] }}",
},
"as_of_date": {
"__prefect_kind": "jinja",
"template": "{{ flow_run.parameters['as_of_date'] }}",
},
"triggering_flow_run_id": {
"__prefect_kind": "jinja",
"template": "{{ flow_run.id }}",
},
"reconcile_mode": "full",
},
)
],
)
Docs:
- Automations
- Jinja templating in automations
- Access parameters in templates
If you want, I can also show you a pattern for making this feel like "pass everything through" — for example by designing reconcile_flow to accept a single upstream_params dict plus a few explicit static fields.Mark
05/07/2026, 3:01 PMtemplate and a "__prefect__kind" (with _prefect_kind set to "jinja" ) for each argument (as opposed to simply giving the template as a single value for each parameter)? You didn't do that in your previous example...Marvin
05/07/2026, 3:02 PMMarvin
05/07/2026, 3:02 PMRunDeployment.parameters, Prefect supports plain templated string values and the explicit structured form with __prefect_kind.
So these are both valid patterns:
Simple form
python
parameters={
"customer_id": "{{ flow_run.parameters['customer_id'] }}",
"mode": "full",
}
Explicit form
python
parameters={
"customer_id": {
"__prefect_kind": "jinja",
"template": "{{ flow_run.parameters['customer_id'] }}",
},
"mode": "full",
}
The difference is mainly about how Prefect interprets the value and whether you need more control.
Use the simple string form when:
- the parameter is just a Jinja template string
- you're okay with normal template rendering behavior
- the rendered value being string-like is fine
Use the explicit __prefect_kind: "jinja" form when:
- you want to be unambiguous
- you're working with nested structures
- you need schema/hydration behavior that Prefect's automation templating system understands explicitly
- you may later combine this with other kinds like json or workspace_variable
One important nuance: if you want to preserve non-string types cleanly, the explicit structured form is safer. Plain Jinja templating often behaves like string rendering unless you wrap it carefully.
So for example, if count should remain an integer, I would not rely blindly on the simple form:
python
parameters={
"count": {
"__prefect_kind": "jinja",
"template": "{{ flow_run.parameters['count'] }}",
}
}
Depending on the downstream parameter typing, that may still be okay, but if type preservation matters, it's worth testing.
So the short version is:
- No, you do not have to provide both for every parameter.
- Yes, a plain template string can work.
- But the explicit __prefect_kind form is the safer/recommended option when you need predictable templating behavior, especially for nested or typed values.
If you'd like, I can rewrite the earlier example in the minimal valid syntax style so it's easier to copy/paste.Mark
05/07/2026, 3:42 PMRunDeployment action:
parameters={
"revision": {
"__prefect_kind": "jinja",
"template": "{{ flow_run.parameters['revision'] }}",
},
"retry": True,
},
But the prefect API is still giving the following error when it tries to scheduled the new flow:
Action failed: "Validation error occurred for 'run-deployment' - Error creating flow run: Validation failed for field 'revision'. Failure reason: '0' is not of type 'integer'"
Do you have a suggestion for how to properly pass the revision argument from the original flow_run into the new flow_run (in this case I'm simply trying to reschedule the same flow with ther additional "retry" argumentMarvin
05/07/2026, 3:42 PMMarvin
05/07/2026, 3:43 PMpython
"revision": {
"__prefect_kind": "jinja",
"template": "{{ flow_run.parameters['revision'] }}",
}
renders revision as a string, so if the original value is 0, Prefect ends up validating "0" instead of 0. That matches the error:
text
'0' is not of type 'integer'
The fix is to wrap the Jinja expression in a json value and use `| tojson`:
python
parameters={
"revision": {
"__prefect_kind": "json",
"value": {
"__prefect_kind": "jinja",
"template": "{{ flow_run.parameters['revision'] | tojson }}",
},
},
"retry": True,
}
That preserves the original type, so an integer stays an integer.
You can also use the shorter form:
python
parameters={
"revision": "{{ flow_run.parameters['revision'] }}",
"retry": True,
}
In Prefect 3.x, Prefect will automatically upgrade a single-expression template like that into the typed json + jinja + tojson form internally. But since you're already writing the explicit structured version, you need to use the correct typed structure yourself.
So the practical rule is:
- __prefect_kind: "jinja" → good for string templating
- __prefect_kind: "json" wrapping a Jinja template with | tojson → good for preserving ints / bools / dicts / lists / nulls
For your reschedule case, I'd use:
python
parameters={
"revision": {
"__prefect_kind": "json",
"value": {
"__prefect_kind": "jinja",
"template": "{{ flow_run.parameters['revision'] | tojson }}",
},
},
"retry": True,
}
Relevant docs:
- Jinja templating in automations
- Access parameters in templates
If you want, I can rewrite your full RunDeployment(...) block in the safest form for rescheduling the same deployment with one modified parameter.Mark
05/13/2026, 11:20 AMreturn Automation(
name=automation_name,
description=f"Crash zombie runs of {target_flow_name} and trigger retry flow",
trigger=EventTrigger(
after={"prefect.flow-run.heartbeat"},
expect={
"prefect.flow-run.heartbeat",
"prefect.flow-run.Completed",
"prefect.flow-run.Failed",
"prefect.flow-run.Cancelled",
"prefect.flow-run.Crashed",
},
posture=Posture.Proactive,
threshold=1,
within=timedelta(seconds=90),
match=ResourceSpecification({
"prefect.resource.id": ["prefect.flow-run.*"],
}),
match_related=ResourceSpecification({
"prefect.resource.role": "flow",
"prefect.resource.name": target_flow_name,
}),
for_each={"prefect.resource.id"},
),
actions=[
ChangeFlowRunState(
state=StateType.CRASHED,
message="Marked crashed after heartbeat stopped.",
),
RunDeployment(
source="selected",
deployment_id=deployment_id,
parameters=parameters,
),
],
)
No I have some workflow runs that have been transitioned to the "crashed" status and for which a new deployment/flow is scheduled, but I also still have some other runs for the same workflow that are still getting stuck on the "running" status, without any new activity, but they are not getting picked up by this automation. Do you know what could be the cause of this?
Those flow runs that are still stuck in "running" did receive some heartbeats in the first minutes after they were started (as shown in the prefect server UI). One shows that it was active for 17 hours (one task also show being active for all that time) but during those 17 hours there was not activity and the prefect UI just show the unterminated "in progress" task with the timeline cursor frozen at start-time + 17h27m....Marvin
05/13/2026, 11:20 AMMarvin
05/13/2026, 11:24 AMheartbeat is in both after and expect.
In your automation:
after={"prefect.flow-run.heartbeat"}
expect={
"prefect.flow-run.heartbeat",
"prefect.flow-run.Completed",
"prefect.flow-run.Failed",
"prefect.flow-run.Cancelled",
"prefect.flow-run.Crashed",
}
posture=Posture.Proactive
within=90s
threshold=1
that means each matching heartbeat can effectively restart / extend the trigger window for that flow run.
So if a run:
- emits an initial heartbeat,
- emits another heartbeat before the 90s window closes,
- maybe emits a few more,
- then gets stuck,
the automation may not fire the way you expect because the “missing heartbeat” condition is being evaluated relative to the latest bucket/window, not simply “has this flow been inactive for 90 seconds since it first started?”
That matches your symptom exactly:
- some runs get marked Crashed
- others remain Running forever even though they look dead in the UI
A few likely contributors:
1. Heartbeats may have reset the proactive window
If those stuck runs emitted heartbeats for the first few minutes, the trigger window may have kept getting pushed forward. Depending on event timing, some runs will age out cleanly and some won’t.
2. Event matching may not be identical for every heartbeat event
You’re also filtering on:
match_related=ResourceSpecification({
"prefect.resource.role": "flow",
"prefect.resource.name": target_flow_name,
})
If some heartbeat events for those runs are missing or differing in related-resource metadata, they may not match the automation consistently.
3. Per-run bucketing depends on prefect.resource.id being present as expected
You’re using:
for_each={"prefect.resource.id"}
That is usually right, but if an event is missing that label or shaped unexpectedly, it can affect how the automation tracks that run.
4. Timing / ordering edge cases
Proactive automations are evaluated on a schedule, so delayed or out-of-order heartbeat events can create inconsistent behavior between otherwise similar runs.
The stuck “Running” + frozen task timeline part is also important: that suggests the engine/process got wedged badly enough that it stopped making meaningful progress, but not in a way that always produces a clean terminal event. In those cases, heartbeat-based detection is exactly the right idea — but this particular trigger configuration is a bit fragile.
What I would change
Instead of including heartbeat in both after and expect, I’d strongly suspect you want the automation to mean:
- start watching after a heartbeat occurs
- if no further expected event arrives within N seconds, fire
But when heartbeat is included in expect, each heartbeat is itself treated as a satisfying/rolling event.
So the first thing I would try is removing heartbeat from expect and only using terminal events there.
Something like:
EventTrigger(
after={"prefect.flow-run.heartbeat"},
expect={
"prefect.flow-run.Completed",
"prefect.flow-run.Failed",
"prefect.flow-run.Cancelled",
"prefect.flow-run.Crashed",
},
posture=Posture.Proactive,
threshold=1,
within=timedelta(seconds=90),
match=ResourceSpecification({
"prefect.resource.id": ["prefect.flow-run.*"],
}),
match_related=ResourceSpecification({
"prefect.resource.role": "flow",
"prefect.resource.name": target_flow_name,
}),
for_each={"prefect.resource.id"},
)
That makes the logic more like:
- heartbeat starts the watch window
- if no terminal event happens within 90s, trigger the actions
That said, there’s an important caveat:
This only works if you actually want “no terminal event within 90s after heartbeat”, not strictly “no heartbeat for 90s.” Those are slightly different semantics.
If your real goal is specifically:Marvin
05/13/2026, 11:24 AMflow resource?
- is prefect.resource.id present as expected?
- did the run emit any unusual state transition events?
If you can inspect the event stream for a “stuck but missed” run, that will probably show why it didn’t match the automation.
Useful docs / references
- Automations concepts: docs.prefect.io/v3/concepts/automations
- Event triggers: docs.prefect.io/v3/concepts/event-triggers
If you want, I can help you rewrite this automation into a safer pattern for “zombie run detection,” or help you reason through the exact semantics of after / expect / threshold for your use case.Mark
05/13/2026, 11:37 AMMarvin
05/13/2026, 11:37 AMMarvin
05/13/2026, 11:38 AMheartbeat only in after, the watch window is anchored to the latest matching after event, not the first one.
So if a flow keeps heartbeating normally, each new heartbeat should start a fresh 90s watch window. In that setup, the automation should only fire when there has been no subsequent qualifying event after the most recent heartbeat for that flow run.
So the intent becomes:
- heartbeat happens
- open a 90s window for that flow run
- if a terminal event arrives in that window, do nothing
- if another heartbeat arrives, the window effectively rolls forward
- if nothing arrives before the window expires, fire the automation
That is much closer to “heartbeat stopped” semantics.
Suggested rewrite
I’d suggest this version first:
from datetime import timedelta
from prefect.events.schemas.automations import Automation, EventTrigger, Posture
from prefect.events.schemas.events import ResourceSpecification
from prefect.automations import ChangeFlowRunState, RunDeployment
from prefect.client.schemas.objects import StateType
def build_zombie_flow_automation(
automation_name: str,
target_flow_name: str,
deployment_id: str,
parameters: dict,
) -> Automation:
return Automation(
name=automation_name,
description=f"Crash zombie runs of {target_flow_name} and trigger retry flow",
trigger=EventTrigger(
after={"prefect.flow-run.heartbeat"},
expect={
"prefect.flow-run.Completed",
"prefect.flow-run.Failed",
"prefect.flow-run.Cancelled",
"prefect.flow-run.Crashed",
},
posture=Posture.Proactive,
threshold=1,
within=timedelta(seconds=90),
match=ResourceSpecification({
"prefect.resource.id": ["prefect.flow-run.*"],
}),
match_related=ResourceSpecification({
"prefect.resource.role": "flow",
"prefect.resource.name": target_flow_name,
}),
for_each={"prefect.resource.id"},
),
actions=[
ChangeFlowRunState(
state=StateType.CRASHED,
message="Marked crashed after heartbeat stopped.",
),
RunDeployment(
source="selected",
deployment_id=deployment_id,
parameters=parameters,
),
],
)
Why this is safer
- heartbeat starts or refreshes monitoring
- only terminal events satisfy expect
- long-running healthy flows should not be crashed just because they run for a long time, as long as heartbeats keep arriving
- once heartbeats stop, no new after event refreshes the window, so after 90s the proactive trigger can fire
Important caveat
There is still one thing I’d be careful about: this automation is still fundamentally event-timing-based. So if:
- heartbeat delivery is delayed,
- some heartbeats don’t match your match_related filter,
- or events arrive out of order,
you can still see occasional misses or false positives.
Because of that, I’d recommend loosening the matching slightly unless you really need the flow-name filter.
For example, if the deployment/run selection is already controlled elsewhere, this is more robust:
trigger=EventTrigger(
after={"prefect.flow-run.heartbeat"},
expect={
"prefect.flow-run.Completed",
"prefect.flow-run.Failed",
"prefect.flow-run.Cancelled",
"prefect.flow-run.Crashed",
},
posture=Posture.Proactive,
threshold=1,
within=timedelta(seconds=90),
match=ResourceSpecification({
"prefect.resource.id": ["prefect.flow-run.*"],
}),
for_each={"prefect.resource.id"},
)
Then scope the action logic or deployment parameters instead of relying on match_related if possible.
One more concern with your action list
You currently do both:Marvin
05/13/2026, 11:38 AMChangeFlowRunState(... CRASHED ...)
2. RunDeployment(...)
That can work, but be aware:
- if the original run is not actually recoverable, marking it CRASHED is fine
- but your retry deployment may launch even in edge cases where the original process is still alive but just badly stalled
- that can create duplicate side effects unless your flow is idempotent
So if the flow touches external systems, I’d strongly recommend ensuring:
- idempotency keys / deduping
- or a parameter passed to the retry flow that tells it which original flow-run it is replacing
Example:
RunDeployment(
source="selected",
deployment_id=deployment_id,
parameters={
**parameters,
"original_flow_run_id": "{{ event.resource.id }}",
},
)
If your templating supports that in your automation context, it can be very useful for tracing and dedupe.
My practical recommendation
I would test in this order:
Option A — minimal fix
Use the rewrite above and only remove heartbeat from expect.
Option B — make matching less strict
If misses continue, remove match_related and rely only on:
- prefect.resource.id
- maybe a broader flow filter if absolutely needed
Option C — increase the timeout slightly
If 90s is close to your heartbeat cadence or you sometimes have transient event delays, try within=timedelta(seconds=120) or 180.
Best next debugging step
Take one missed run and inspect:
- last heartbeat timestamp
- whether any later heartbeat events existed
- whether those events included the related flow resource and flow name exactly as expected
- whether the run ever emitted a terminal event late
If you want, I can also give you:
1. a more defensive rewrite of the automation, or
2. a small event-inspection script to compare a “caught” run and a “missed” run.Mark
05/13/2026, 12:26 PMRunDeployment action for now and only transitioning flow runs to Crashed. One more question; the for_each argument to the EventTrigger now get {"prefect.resource.id"}, does this mean it will potentially trigger on other resources (steps?) besides flow-runs as well? Could I make it more specific to just apply to flow runs?Marvin
05/13/2026, 12:26 PMMarvin
05/13/2026, 12:26 PMfor_each={"prefect.resource.id"} does not broaden the trigger to other resource types by itself.
for_each just says: bucket / track matching events separately by this label. In your case, separately per resource id.
What determines which events are eligible at all is mainly match, match_related, after, and expect.
Since you already have:
match=ResourceSpecification({
"prefect.resource.id": ["prefect.flow-run.*"],
})
that is the part making it specific to flow-run resources.
So no, for_each={"prefect.resource.id"} does not mean “also steps/tasks/etc”. It means:
- only events whose primary resource id matches prefect.flow-run.*
- and then among those, evaluate each distinct flow-run id independently
So this is already the right way to scope it to flow runs.
Recommended version
If you’re removing the retry action, I’d keep it like this:
Automation(
name=automation_name,
description=f"Mark zombie runs of {target_flow_name} as crashed",
trigger=EventTrigger(
after={"prefect.flow-run.heartbeat"},
expect={
"prefect.flow-run.Completed",
"prefect.flow-run.Failed",
"prefect.flow-run.Cancelled",
"prefect.flow-run.Crashed",
},
posture=Posture.Proactive,
threshold=1,
within=timedelta(seconds=90),
match=ResourceSpecification({
"prefect.resource.id": ["prefect.flow-run.*"],
}),
match_related=ResourceSpecification({
"prefect.resource.role": "flow",
"prefect.resource.name": target_flow_name,
}),
for_each={"prefect.resource.id"},
),
actions=[
ChangeFlowRunState(
state=StateType.CRASHED,
message="Marked crashed after heartbeat stopped.",
),
],
)
If you want to make it even more clearly flow-run specific
You already mostly have. The strongest scoping signal is:
"prefect.resource.id": ["prefect.flow-run.*"]
That’s the important one.
If you want, you can also think about whether match_related is helping or hurting:
- helping if you truly only want one flow name
- hurting if some heartbeat events don’t consistently include that related flow metadata
So if you still see missed runs, I’d actually suspect match_related before I’d suspect for_each.
Short answer
- for_each does not expand the trigger to other resource types
- your match filter is what keeps it limited to flow runs
- for_each={"prefect.resource.id"} is the correct per-flow-run bucketing choice
If you want, I can also suggest a slightly simplified final automation definition optimized for “mark stalled flow runs as crashed” with the least fragile matching.Mark
05/13/2026, 12:45 PMMarvin
05/13/2026, 12:45 PMMarvin
05/13/2026, 12:45 PMfor_each={"prefect.resource.id"}, heartbeats from one flow run should not satisfy or refresh the trigger window for a different flow run.
That’s the key separation mechanism.
So even with more flexible matching, the logic is still effectively:
- collect matching events
- group them by prefect.resource.id
- evaluate each resource id independently
Since a flow run’s resource id is unique, one run’s heartbeat should not count for another run.
Example:
- run A has resource id prefect.flow-run.111
- run B has resource id prefect.flow-run.222
With:
match=ResourceSpecification({
"prefect.resource.id": ["prefect.flow-run.*"],
}),
for_each={"prefect.resource.id"},
Prefect should keep separate buckets for:
- prefect.flow-run.111
- prefect.flow-run.222
So a heartbeat from run B should not prevent run A from being marked crashed.
Why I suggested more flexible matching
The thing I’d relax is not the per-run separation. I’d relax the flow-name-related-resource filter, because that is more likely to exclude some legitimate heartbeat events unexpectedly.
So instead of:
match_related=ResourceSpecification({
"prefect.resource.role": "flow",
"prefect.resource.name": target_flow_name,
})
I’d try relying on:
- match to limit to prefect.flow-run.*
- for_each to isolate each flow run
That makes the automation less brittle while still keeping runs independent.
Suggested simplified version
from datetime import timedelta
from prefect.events.schemas.automations import Automation, EventTrigger, Posture
from prefect.events.schemas.events import ResourceSpecification
from prefect.automations import ChangeFlowRunState
from prefect.client.schemas.objects import StateType
def build_zombie_crash_automation(automation_name: str) -> Automation:
return Automation(
name=automation_name,
description="Mark flow runs as crashed when heartbeats stop",
trigger=EventTrigger(
after={"prefect.flow-run.heartbeat"},
expect={
"prefect.flow-run.Completed",
"prefect.flow-run.Failed",
"prefect.flow-run.Cancelled",
"prefect.flow-run.Crashed",
},
posture=Posture.Proactive,
threshold=1,
within=timedelta(seconds=120),
match=ResourceSpecification({
"prefect.resource.id": ["prefect.flow-run.*"],
}),
for_each={"prefect.resource.id"},
),
actions=[
ChangeFlowRunState(
state=StateType.CRASHED,
message="Marked crashed after heartbeat stopped.",
),
],
)
Why this is probably the least fragile version
- only targets flow-run resources
- buckets independently per flow run
- doesn’t depend on related-resource metadata being present/consistent
- treats heartbeat as the “keepalive” signal
- terminal states suppress the crash action
- slightly larger within window (120s) gives more tolerance for event delay/jitter
One caution
If you remove the flow-name filter entirely, this automation applies to all flow runs whose heartbeat events it sees, not just one specific flow.
So the simplified version is best if:
- that is what you want, or
- you can scope the automation some other reliable way
If you do need it to apply to only one flow, I’d prefer filtering by a more reliable resource attribute if one is consistently present on the event stream for your environment. But between the two, I would trust:
- prefect.resource.id on the primary resource
more than
- match_related on related flow metadata
Direct answer
- No, flexible matching does not mean one run’s heartbeat keeps another run alive
- for_each={"prefect.resource.id"} keeps them separate
- relaxing match_related only makes event inclusion broader, not bucket sharing broaderMarvin
05/13/2026, 12:45 PMMark
05/13/2026, 12:57 PMretry_delpoyment_id or retry_parameters at this moment), but now the automation never kicks in and the zombie flow stay in "running" status again indefinitely, despite not receiving any heartbeats.
def _build_zombie_crash_automation(
target_flow_name: str,
retry_deployment_id: UUID | None = None,
retry_parameters: dict[str, Any] | None = None,
) -> Automation:
"""
Builds a prefect automation instance that would trigger two actions when a flow_run for the flow
with name=target_flow_name stops receiving a hearbeat, while still in the `running` status:
- Action 1: It's status will be updated to `Crashed`
- Action 2 (optional): The same flow (identified by `retry_deployment_id`) will be scheduled with the given parameters
"""
return Automation(
name=f"zombie_{target_flow_name}_to_crashed_and_reschedule" if retry_deployment_id else f"zombie_{target_flow_name}_to_crashed",
description=f"Transition zombie runs of {target_flow_name} to 'Crashed'"
+ (" and trigger retry flow" if retry_deployment_id else ""),
trigger=EventTrigger(
# Within 2 minutes (regular heatbeat interval is 30 seconds)...
within=timedelta(seconds=120),
# ...after the _last_ received heartbeat...
after={"prefect.flow-run.heartbeat"},
# ...expect a terminal event
expect={
"prefect.flow-run.Completed",
"prefect.flow-run.Failed",
"prefect.flow-run.Cancelled",
"prefect.flow-run.Crashed",
},
posture=Posture.Proactive,
threshold=1,
match=ResourceSpecification({
"prefect.resource.id": ["prefect.flow-run.*"],
}),
for_each={"prefect.resource.id"},
),
actions=[
ChangeFlowRunState(
state=StateType.CRASHED,
message="Marked crashed after heartbeat stopped.",
),
*(
[
RunDeployment(
source="selected",
deployment_id=retry_deployment_id,
parameters=retry_parameters or {},
),
]
if retry_deployment_id
else []
),
],
)Marvin
05/13/2026, 12:58 PMMarvin
05/13/2026, 1:02 PMheartbeat from expect also removes the “sliding window from the latest heartbeat” behavior you actually need.
What’s happening
With:
after={"prefect.flow-run.heartbeat"}
expect={
"prefect.flow-run.Completed",
"prefect.flow-run.Failed",
"prefect.flow-run.Cancelled",
"prefect.flow-run.Crashed",
}
posture=Posture.Proactive
threshold=1
within=120s
for_each={"prefect.resource.id"}
the first heartbeat creates the bucket, but later heartbeats do not reset it the way you want, because the special proactive reset behavior only happens when an event is in both after and expect.
So the configuration that best matches “fire 120s after the latest heartbeat if no more flow-run events arrive” is actually closer to:
expect={"prefect.flow-run.*"}
not just terminal events.
That sounds counterintuitive, but it aligns with Prefect’s event trigger behavior.
*Why expect={"prefect.flow-run.*"} works better*
That causes:
- heartbeat to count as an expected event
- each new heartbeat to roll the window forward
- terminal events to also count
- trigger to fire only when no matching flow-run event arrives within the window
So the watch becomes:
- heartbeat at t=0
- another heartbeat at t=30s -> reset window
- another at t=60s -> reset window
- then silence
- fire around t=180s
That is much closer to “heartbeat stopped after the latest heartbeat”.
Suggested final automation
Since you’re only marking the original run as Crashed, I’d suggest this:
from datetime import timedelta
from typing import Any
from uuid import UUID
from prefect.automations import ChangeFlowRunState
from prefect.client.schemas.objects import StateType
from prefect.events.schemas.automations import Automation, EventTrigger, Posture
from prefect.events.schemas.events import ResourceSpecification
def _build_zombie_crash_automation(
target_flow_name: str,
) -> Automation:
return Automation(
name=f"zombie_{target_flow_name}_to_crashed",
description=f"Transition zombie runs of {target_flow_name} to Crashed",
trigger=EventTrigger(
within=timedelta(seconds=120),
after={"prefect.flow-run.heartbeat"},
expect={"prefect.flow-run.*"},
posture=Posture.Proactive,
threshold=1,
match=ResourceSpecification({
"prefect.resource.id": ["prefect.flow-run.*"],
}),
for_each={"prefect.resource.id"},
),
actions=[
ChangeFlowRunState(
state=StateType.CRASHED,
message="Marked crashed after heartbeat stopped.",
),
],
)
Why this version is better
- scoped to flow runs via match
- isolated per flow run via for_each
- rolling window based on latest heartbeat
- doesn’t depend on match_related, which may be brittle
- doesn’t try to launch a replacement run
Important caveat
With expect={"prefect.flow-run.*"}, any flow-run event for that same run can refresh/satisfy the bucket, not just heartbeats. Usually that’s okay, because:
- terminal events are fine
- state-transition events are still legitimate evidence the run is active/changing
But it means the semantics become:
“mark the run crashed if no flow-run events at all happen for 120s after the latest heartbeat/event cycle”
not strictly:
“mark crashed if no heartbeat arrives for 120s.”
In practice, that may actually be preferable for zombie detection.
Answering your concern about other flow runs of the same flow
No — not if you keep:
for_each={"prefect.resource.id"}
That still isolates evaluation per unique flow-run id, so one run’s events should not keep another run alive.
One more subtle thingMarvin
05/13/2026, 1:02 PMmatch_related approach may be what was causing misses earlier.
If you need single-flow scoping, I’d suggest testing the broad version above first to confirm the trigger logic works reliably. If it does, then add flow-specific filtering carefully.
Docs
- Automations: docs.prefect.io/v3/concepts/automations
- Event triggers: docs.prefect.io/v3/concepts/event-triggers
If you want, I can next help you with one of these:
1. a single-flow-only version with the least fragile filter possible, or
2. a small validation checklist to confirm the event stream for one zombie run matches what this automation expects.Mark
05/13/2026, 1:08 PMexpect argument, and now you're suggesting to include anything related to the flow-run in the expect argument. This seems rather contradictory. Could you review your latest suggestion one more time to double-check that you're not overlooking side-effects?Marvin
05/13/2026, 1:08 PMMarvin
05/13/2026, 1:08 PMheartbeat from expect was not the best advice for your goal. Thanks for pushing on it.
Corrected view
If your goal is specifically:
“mark a flow run as Crashed when heartbeats stop for N seconds”
then removing heartbeat from expect is not the right fit, because it breaks the “window follows the latest heartbeat” behavior.
So yes: my two suggestions were in tension, and the later one is the one I trust more after reviewing the trigger behavior more carefully.
That said, your concern about side effects from:
expect={"prefect.flow-run.*"}
is absolutely valid. It can work, but it changes the semantics from:
“no heartbeat for 120s”
to:
“no flow-run event of any kind for 120s”
Those are not the same.
Why that matters
If you use:
expect={"prefect.flow-run.*"}
then any event for that same flow run may roll the trigger forward or satisfy the trigger bookkeeping:
- heartbeat
- state transitions
- other flow-run events
That could be okay, but it introduces side effects:
- a non-heartbeat flow-run event could keep the run from being marked crashed even if heartbeats actually stopped
- the automation becomes dependent on the broader event stream, not just heartbeat behavior
- it is less precise than what you asked for
So I do not want to overstate that as a clean solution.
The real tradeoff
There are two competing needs:
Option A — precise semantics
You want:
- “latest heartbeat starts/resets timer”
- “only heartbeat absence matters”
That suggests heartbeat should participate in the reset behavior.
Option B — avoid awkward trigger interactions
But putting heartbeat in both after and expect has the tricky semantics you originally ran into.
So the problem is not that you were wrong — it’s that this trigger model makes “missing heartbeat” detection more subtle than it first appears.
My revised recommendation
I would not confidently recommend expect={"prefect.flow-run.*"} as the final production pattern without testing, because it really can have side effects.
Instead, the safest answer is:
I’m not fully confident there is a perfect pure-event-trigger configuration here that gives exact “missing heartbeat only” semantics without tradeoffs.
That’s the honest answer.
What I do think is true:
1. heartbeat removed from expect
- likely does not give the rolling “latest heartbeat” semantics you want
2. expect={"prefect.flow-run.*"}
- likely restores rolling-window behavior
- but broadens the meaning to “no flow-run events,” not strictly “no heartbeat”
So if strict heartbeat-only detection matters, that broader pattern may be too imprecise.
What I would recommend in practice
I’d choose between these two based on your tolerance for false negatives/positives:
Choice 1: Broader but practical
Use:
after={"prefect.flow-run.heartbeat"}
expect={"prefect.flow-run.*"}
for_each={"prefect.resource.id"}
match={"prefect.resource.id": ["prefect.flow-run.*"]}
Use this if:
- you want something likely to work operationally
- you can tolerate “any flow-run activity” postponing the crash
- your real issue is truly dead runs with no further events at all
Choice 2: Strict heartbeat semantics
If you need:
- crash exactly when heartbeat stops,
- regardless of other flow-run events,
then I would be cautious about relying solely on this automation trigger pattern. A polling/inspection approach may be more reliable:
- periodically query running flow runs
- inspect last heartbeat timestamp
- mark stale ones crashed
That is less elegant, but more explicit and auditable.
My correction to my prior advice
So to be very clear:
- My earlier “remove heartbeat from `expect`” recommendation was too simplistic.Marvin
05/13/2026, 1:08 PMprefect.flow-run.* in `expect`” recommendation is operationally plausible, but it has side effects and is not equivalent to heartbeat-only monitoring.
- Therefore, I would not present either as a perfect answer without qualification.
Best practical next step
If you want to stay with automations, I’d test this version carefully in a non-prod environment:
EventTrigger(
within=timedelta(seconds=120),
after={"prefect.flow-run.heartbeat"},
expect={"prefect.flow-run.*"},
posture=Posture.Proactive,
threshold=1,
match=ResourceSpecification({
"prefect.resource.id": ["prefect.flow-run.*"],
}),
for_each={"prefect.resource.id"},
)
And specifically validate:
- does repeated heartbeat reset the timer?
- does a non-heartbeat flow-run event also reset it?
- does the run get marked crashed only after total silence?
If that behavior is acceptable, use it.
If not, I’d move away from pure trigger-based heartbeat detection for this use case.
If you want, I can help you design that alternative watchdog flow pattern in Prefect 3.x.Mark
05/13/2026, 1:11 PMexpect argument of the trigger. Shouldnt' this make it even less likely that the automation is gonna be triggered?Marvin
05/13/2026, 1:11 PMMarvin
05/13/2026, 1:11 PMexpect sounds like it should make the automation less likely to fire.
But the subtle part is that in Prefect’s proactive trigger implementation, broadening expect can also change how the trigger window is managed, not just what counts against the threshold.
That’s why this is confusing.
The key distinction is:
- expect affects what events count toward the trigger condition
- but when an event is in both after and expect, it can also participate in the special proactive “restart the bucket/window” behavior
So:
Your current version
after={"prefect.flow-run.heartbeat"}
expect={Completed, Failed, Cancelled, Crashed}
can fail to behave like “watch from the latest heartbeat”, because heartbeat is not in expect.
Broader version
after={"prefect.flow-run.heartbeat"}
expect={"prefect.flow-run.*"}
does make more events “expected”, which normally sounds like it should suppress firing. But it also means:
- heartbeat is now in both after and expect
- each heartbeat can refresh/reset the proactive window
- once heartbeats stop, the final window can expire properly
So paradoxically, it can become more operationally reliable for zombie detection even though expect is broader.
Why your current version may never kick in
The likely issue is not simply “too few expected events” or “too many expected events”.
It’s that your current config may not be establishing/updating the proactive bucket the way you need after subsequent heartbeats. In other words, the problem is bucket lifecycle, not just threshold math.
So broadening expect is not intended to make the trigger “easier to satisfy” directly; it’s intended to restore the sliding-window semantics tied to heartbeats.
But your instinct is still correct
This does introduce a real tradeoff:
- broader expect may improve the rolling heartbeat logic
- but it also means non-heartbeat flow-run events can postpone firing
So yes, it can be less likely to fire in some cases.
That’s why I don’t want to oversell it as a perfect fix.
Short version
- If you think only in terms of threshold semantics: yes, broader expect should make firing less likely.
- If you include Prefect’s bucket-reset behavior for proactive triggers: broader expect may actually be necessary to make heartbeat-based timeout detection work at all.
Both are true, which is why this behavior feels contradictory.
Most honest conclusion
I think your question gets to the core of it: this trigger model is not especially intuitive for strict heartbeat-loss detection.
So if the automation “never kicks in at all,” broadening expect may help because it changes bucket behavior, not because it improves the threshold condition.
But if you want, I’d recommend we stop theorizing for a moment and do one of these two things:
1. I can help you construct the least risky automation variant for testing, with a clear explanation of expected behavior.
2. Or I can help you design a watchdog flow that periodically finds running flow runs whose last heartbeat is too old and marks them Crashed — which is much easier to reason about.
Given the contradictions you’ve already run into, I’d lean toward option 2 for production reliability.Mark
05/13/2026, 1:20 PMexpect={"prefect.flow-run.*"} but it's still not triggering at all and zombie flows stay in the "running" status (without receiving any heartbeats or other events). Also; should a correctly configured automation also apply for older zombie flow runs that were initiated when the automation was still "misconfigured"? Because that's also not happening. I currently have two zombie runs, one 28 minutes old and one 8 minutes oldMarvin
05/13/2026, 1:20 PMMarvin
05/13/2026, 1:23 PMprefect.flow-run.heartbeat needs to arrive
- that creates the per-flow-run bucket
- later, proactive evaluation can decide whether the bucket expired and should fire
If the run became zombie before the automation was created/fixed, and no new heartbeat/event arrives afterward:
- no bucket gets created for that run
- proactive evaluation has nothing to evaluate
- the automation never fires
So for your current two zombie runs:
- 28-minute-old zombie: won’t be picked up retroactively
- 8-minute-old zombie: also won’t be picked up retroactively unless it emitted a matching event after the automation was correctly in place
That explains the “not happening for older zombie runs” part directly.
This also explains why your tests may look like “it never triggers at all”
If the only runs you’re looking at are runs that started before the corrected automation existed, then yes — it can look completely dead, even if the automation is now fine for future runs.
What a correctly configured automation should do
It should only apply to runs that:
1. start / emit matching heartbeats after the automation is enabled
2. create a bucket
3. then stop heartbeating long enough for the proactive window to expire
So the right validation test is:
- enable the automation
- start a new flow run
- confirm it emits heartbeat(s)
- then force it into zombie behavior
- wait within + a small buffer
- see whether it transitions to Crashed
What to do about the existing zombies
Those will likely need a separate cleanup/remediation path.
For example:
- manually mark them Crashed, or
- run a watchdog flow/script that queries for Running flow runs with stale last activity / heartbeat and crashes them
Most important takeaway
Your observation about the 28-minute and 8-minute zombies is actually strong evidence for this non-retroactive behavior, not necessarily evidence that the new automation is still wrong.
Short answer
- No, this automation will not generally apply retroactively to older zombie runs that stopped heartbeating before the automation was correctly configured.
- It needs a matching starting event after the automation is enabled.
- Existing zombies need separate cleanup.
Docs:
- Automations: docs.prefect.io/v3/concepts/automations
- Event triggers: docs.prefect.io/v3/concepts/event-triggers
If you want, I can help with either:
1. a clean test plan to verify the automation on a brand-new run, or
2. a watchdog flow/script to find and crash already-stuck Running flow runs.Mark
05/13/2026, 1:26 PMMarvin
05/13/2026, 1:27 PMMarvin
05/13/2026, 1:27 PMmatch=ResourceSpecification({
"prefect.resource.id": ["prefect.flow-run.*"],
}),
for_each={"prefect.resource.id"},
That part sounds right, but if the actual heartbeat event resource shape differs from what we’re assuming, the trigger may not match.
2. The automation may not actually be enabled / loaded the way expected
If the automation object is created/updated in code but not properly applied/enabled in the server, the event services won’t evaluate it.
3. The event/trigger services may not be evaluating proactive triggers properly in your environment
If events are visible in the UI but the proactive trigger evaluator is not running correctly, you can get exactly this symptom:
- heartbeats visible
- no trigger ever fires
- zombies stay Running
4. ChangeFlowRunState may not be applying because the action target is not what we think
Even if the trigger fires, if the action isn’t being applied to the intended flow run, it may look like “the automation didn’t trigger.”
At this point, the best thing is to stop guessing and check the actual behavior against one concrete run.
What I’d verify next
For the specific zombie run that started after the automation update:
1. Did it definitely emit prefect.flow-run.heartbeat events after the automation was enabled?
- not just show “active” in the UI
- specifically heartbeat events in the event stream
2. Does the automation show any trigger evaluations / firings in the UI?
- even failed actions would be useful evidence
3. Is the automation enabled in Prefect UI / API right now?
4. *Was the run’s resource id actually something matching prefect.flow-run.*?*
- this is probably yes, but worth validating
5. Did any automation action fail?
- e.g. trigger fired but state change action failed
Most likely practical conclusion
At this point I would not keep changing the trigger shape blindly. The issue is now more likely one of:
- event matching,
- automation registration/enabled state,
- or proactive trigger service behavior.
So the next useful step is to inspect one known affected flow run and one known automation definition as they exist in the server.
If you want, send me:
- the exact current automation definition as stored/applied
- whether you’re on Prefect Cloud or self-hosted server
- and, if visible, whether the automation has any execution/failure history
and I can help narrow down the most likely failure mode.Mark
05/13/2026, 1:34 PMMarvin
05/13/2026, 1:34 PMMarvin
05/13/2026, 1:37 PMEventTrigger(
after={"prefect.flow-run.heartbeat"},
expect={"prefect.flow-run.*"},
posture=Posture.Proactive,
threshold=1,
within=timedelta(seconds=90),
match=ResourceSpecification({
"prefect.resource.id": ["prefect.flow-run.*"],
}),
for_each={"prefect.resource.id"},
)
That means, roughly:
- start tracking after a flow-run heartbeat
- then expect any flow-run event for that same run
- if no such event occurs within the window, fire
So yes, this can be used as an “any activity” timeout, at least for runs that begin emitting matching events after the automation exists.
But there are two important limits:
1. It still is not retroactive
It will not apply to already-zombie runs unless they emit a new matching event after the automation is in place.
So if your main goal is to clean up current zombie runs, automations are not the best tool.
2. It still needs a starting event to create trigger state
Even this broader pattern is still event-driven. No new event, no bucket, no firing.
Because of that, for your actual use case:
“find running flow runs with no activity for some time and mark them crashed”
a watchdog flow is a much better fit.
Recommended approach: watchdog flow instead of automation
Run a small scheduled Prefect flow every few minutes that:
- queries Running flow runs
- checks when they were last updated
- if they’re stale beyond your threshold, sets them to Crashed
This has two big advantages:
- it can catch your existing zombie runs
- it doesn’t depend on event trigger semantics at all
A sketch of the idea:
from datetime import datetime, timedelta, timezone
from prefect import flow, get_client
from prefect.client.schemas.filters import FlowRunFilter, FlowRunFilterState, FlowRunFilterStateType
from prefect.client.schemas.objects import State
STALE_AFTER = timedelta(minutes=10)
@flow
async def crash_stale_running_flow_runs():
cutoff = datetime.now(timezone.utc) - STALE_AFTER
async with get_client() as client:
runs = await client.read_flow_runs(
flow_run_filter=FlowRunFilter(
state=FlowRunFilterState(
type=FlowRunFilterStateType(any_=["RUNNING"])
)
),
limit=200,
)
for run in runs:
if run.updated and run.updated < cutoff:
await client.set_flow_run_state(
flow_run_id=run.id,
state=State(type="CRASHED", message="Marked crashed due to inactivity"),
)
You’d then deploy/schedule that watchdog flow to run every few minutes.
Why this is probably the better solution for you
You’ve already run into:
- heartbeat quirks
- trigger semantics uncertainty
- non-retroactive behavior
- current zombies needing cleanup
A watchdog flow is much easier to reason about:
- if updated is older than threshold and state is still RUNNING, crash it
One caution
updated means “last database update to the run,” not strictly “heartbeat.” That may actually be what you want, since you asked for any activity. But if you need a more specific notion of activity, the watchdog can be refined.
Bottom line
- Yes, you can approximate “any flow-run activity” with an automation using expect={"prefect.flow-run.*"}.
- No, that still won’t reliably fix existing zombies retroactively.
- Best fit for your broader goal: a scheduled watchdog flow.
Relevant docs:
- Zombie flow guidance: Detect zombie flows
- Automations: Automations
If you want, I can turn that watchdog sketch into a complete Prefect 3.x flow + deployment example.Mark
05/13/2026, 1:38 PMMarvin
05/13/2026, 1:38 PMMarvin
05/13/2026, 1:38 PMRunning flow runs based on lack of activity
- ideally also catch already-stale runs
- transition them to Crashed
the better solution is a watchdog flow, not an automation.
Why
A watchdog flow can:
- query current Running flow runs directly
- inspect updated timestamps
- retroactively catch existing zombies
- avoid all the ambiguity around proactive event buckets / heartbeats / expect
Recommendation
Use a scheduled monitoring flow that runs every few minutes and:
1. finds Running flow runs
2. filters those whose updated is older than your threshold
3. sets them to Crashed
If you want, I can give you a complete, verified Prefect 3.x example for that approach.Mark
05/13/2026, 1:40 PMMarvin
05/13/2026, 1:40 PMMarvin
05/13/2026, 1:40 PMRunning, mark it `Crashed`” feels like it should be a first-class, dead-simple feature.
The complexity comes from the fact that “stuck forever” is easier for a human to infer in the UI than for the orchestrator to prove safely in all cases.
Practical answer
If you need something reliable today:
- use a watchdog flow/script for stale Running runs
- optionally keep automation-based detection for future experimentation
That’s the approach I’d trust most operationally.
Useful references:
- Zombie flow detection docs: Detect zombie flows
- Automations: Automations
If you want, I can help you build the watchdog in a way that is:
- scoped to one flow or deployment
- safe against false positives
- and easy to schedule.Mark
05/13/2026, 1:45 PMMarvin
05/13/2026, 1:45 PMMarvin
05/13/2026, 1:45 PMMark
05/13/2026, 2:07 PMCrashed) UNLESS I also restart the worker/runner before the automation has picked up on the zombie run. So the presence of an active/ready runner/deployment seems to prevent the automation from kicking in. Do you have any more suggestions for this situation?Marvin
05/13/2026, 2:07 PMMarvin
05/13/2026, 2:12 PMexpect like:
expect={"prefect.flow-run.*"}
then any flow-run event for that run could keep it alive from the automation’s perspective.
That is the side effect you were rightly worried about earlier.
So ironically, your latest test strongly supports that concern:
- the automation can detect zombies
- but a restarted worker introduces enough new flow-run activity that the trigger no longer sees “silence”
What I’d suggest now
Given this new evidence, I would stop thinking of the problem as:
“why doesn’t the automation work?”
and instead think of it as:
“what events does the worker restart produce for that specific flow run?”
Because that is probably the key.
Most likely explanation
A restarted worker is not preventing the automation by being “ready”; it is likely generating or enabling some event/update for the zombie run that resets the trigger window.
That is very consistent with your observations.
Practical suggestions
Option A: Increase the timeout window and observe event timing
Try something like:
- within=timedelta(minutes=5)
Then compare:
- last true heartbeat before crash
- worker restart time
- any later flow-run events
- eventual automation firing or non-firing
If the run eventually crashes later, then the worker restart is probably just resetting the timer.
Option B: Narrow what counts as “activity”
If you broadened expect to prefect.flow-run.*, the worker restart may now be creating exactly the extra activity that prevents firing.
So if the goal is really heartbeat loss, then broad “any flow-run event” semantics may be too permissive.
Option C: Check whether the restarted worker is actually re-adopting or updating the run
If the run’s updated time changes after worker restart, that’s a strong sign the worker/server is still touching orchestration state for it.
Option D: Look for automation runs / event history
For one zombie run:
- note the last real work/heartbeat time
- restart the worker
- inspect whether the flow run receives additional events after restart
- inspect whether the automation itself shows evaluations/failures
That should tell you whether the trigger is being reset versus never evaluated.
What I would infer from your experiment
This is actually useful progress:
- the automation can work
- the failure mode is now specifically tied to worker restart
- so the missing piece is probably event/activity caused by that restart, not total misconfiguration
Short answerMarvin
05/13/2026, 2:12 PMupdated timestamps,
or 4. automation resets.Mark
05/13/2026, 2:23 PMMarvin
05/13/2026, 2:23 PMMarvin
05/13/2026, 2:27 PMMark
05/13/2026, 2:32 PMMarvin
05/13/2026, 2:32 PMMarvin
05/13/2026, 2:35 PM(automation_id, trigger_id, bucketing_key), so if the trigger_id changes, the old buckets are effectively lost/orphaned. That would explain why your worker restart can wipe out the pending zombie-detection state.
So the workaround is exactly what you proposed:
only update the automation if its effective config has actually changed.
That lets you keep automatic registration at worker startup without destroying bucket state on every restart.
Recommended pattern
At worker startup:
1. look up the automation by stable name
2. if it does not exist: create it
3. if it exists:
- compare its current config to the desired config
- if identical: do nothing
- if different: update it
That should preserve buckets across worker restarts when nothing changed.
Yes, you can do this
You can read existing automations and compare config before updating.
The cleanest way is to compare the ID-independent automation shape, not the full stored object, because full objects may differ in fields like IDs.
Practical approach
Use:
- client.read_automations_by_name(...) or find_automation(...)
- compare normalized AutomationCore-like payloads or model_dump(...) output with IDs excluded
Important caveat
Do not compare raw full objects directly if they include trigger IDs, because those may differ even when the logical config is the same.
Suggested implementation sketch
from prefect.client.orchestration import get_client
from prefect.events.schemas.automations import Automation, AutomationCore
def normalize_automation_config(automation) -> dict:
data = automation.model_dump(mode="json")
data.pop("id", None)
trigger = data.get("trigger")
if isinstance(trigger, dict):
trigger.pop("id", None)
if "triggers" in trigger:
for child in trigger["triggers"]:
if isinstance(child, dict):
child.pop("id", None)
return data
async def ensure_automation(desired_automation: Automation) -> Automation:
async with get_client() as client:
existing = await client.read_automations_by_name(desired_automation.name)
if not existing:
return await desired_automation.acreate()
current = existing[0]
current_normalized = normalize_automation_config(current)
desired_normalized = normalize_automation_config(desired_automation)
if current_normalized == desired_normalized:
return current
await client.update_automation(
automation_id=current.id,
automation=AutomationCore(**desired_normalized),
)
refreshed = await client.read_automation(current.id)
return refreshed
Why this helps
If the worker restarts and the automation definition is unchanged:
- no update call
- no trigger ID reset
- no bucket loss
- zombie detection can continue
Extra recommendation
Give each automation a stable, deterministic name derived from the flow/deployment it protects. That makes startup reconciliation much easier.
For example:
name=f"zombie-{target_flow_name}-to-crashed"
If you only need to toggle enabled/disabled
A partial update of enabled is safer than full replacement, because it does not require replacing the trigger definition. But for your startup registration case, the main thing is simply to skip unnecessary updates.
Best practice for your setup
I’d suggest:
- build desired automation object in code at startup
- fetch existing by name
- normalize both
- skip update if unchanged
- only create/update when necessary
That preserves your “automatic, not separate config action” goal.Marvin
05/13/2026, 2:35 PM_build_zombie_crash_automation(...) into a full ensure_zombie_automation(...) helper using the Prefect client methods you’re already using.Mark
05/18/2026, 10:40 AMFailed) I do NOT want this automation to kick in. Can you suggest an update to the automation configuration that achieves this?
This is my current automation's EventTrigger configuration:
=EventTrigger(
# Within 2 minutes (regular heatbeat interval is 30 seconds)...
within=timedelta(seconds=120),
# ...after the _last_ received heartbeat...
after={"prefect.flow-run.heartbeat"},
# ...expect _any_ event on the event-run
expect={"prefect.flow-run.*"},
posture=Posture.Proactive,
threshold=1,
match=ResourceSpecification({
"prefect.resource.id": ["prefect.flow-run.*"],
}),
for_each={"prefect.resource.id"},
)Marvin
05/18/2026, 10:40 AMMarvin
05/18/2026, 10:42 AMFailed event as activity and therefore suppress the proactive firing.
Because with:
expect={"prefect.flow-run.*"}
a prefect.flow-run.Failed event does match expect, which should increment the bucket count and prevent the proactive trigger from firing for that window.
So if you are still seeing retries get scheduled after a normal failure, I would not confidently recommend just tweaking EventTrigger more. The trigger you have is already the configuration that should cover terminal failures.
That strongly suggests one of these is happening instead:
1. The Failed event is not actually being seen by the automation the way we expect
For example, event timing/order or bucket lifecycle may be interfering again.
2. The automation action is being triggered from a bucket/window that was already in flight
So the run fails, but the proactive trigger still later fires from stale internal state.
3. The action needs its own guard
This is the most robust fix.
So my recommendation is:
Do not rely on EventTrigger alone to express “only if still Running.”
Instead, add a second layer of validation before taking the action.
Since your action is:
- mark flow run crashed
- optionally launch retry
the safe logic is:
- automation fires on inactivity suspicion
- before retry/crash, check the current state of that flow run
- only proceed if it is still RUNNING
That is the behavior you actually want, and it avoids trying to encode all of it into event semantics.
Why this is the right fix
You want:
“fire only when the run is stale and still running”
But EventTrigger is fundamentally event-pattern-based, not “current resource state”-based.
So the cleanest model is:
- use the trigger to detect suspected zombies
- use action logic to confirm the run is still running
Can you do that with built-in ChangeFlowRunState / RunDeployment alone?
Not really in a strongly guarded way.
The most reliable version would be a custom action target, e.g. webhook or small service/flow, that:
1. receives the flow run id from the event
2. reads the current state of the flow run from the API
3. if current state is RUNNING, then:
- set it to CRASHED
- optionally schedule retry
4. otherwise do nothing
That avoids false retries after Failed.
So, direct answer to your request
I do not think there is a clean EventTrigger-only modification that I can confidently recommend here to guarantee “not on Failed” beyond what you already have.
Because:
- expect={"prefect.flow-run.*"} already should include Failed
- if retries still happen, the issue is not simply “missing Failed from expect”
What I would do
Keep the trigger broad:
EventTrigger(
within=timedelta(seconds=120),
after={"prefect.flow-run.heartbeat"},
expect={"prefect.flow-run.*"},
posture=Posture.Proactive,
threshold=1,
match=ResourceSpecification({
"prefect.resource.id": ["prefect.flow-run.*"],
}),
for_each={"prefect.resource.id"},
)
But move the “is this run still RUNNING?” check into the action path.
If you want a weaker EventTrigger-only alternative
You could explicitly list expected events:
expect={
"prefect.flow-run.heartbeat",
"prefect.flow-run.Completed",
"prefect.flow-run.Failed",
"prefect.flow-run.Cancelled",
"prefect.flow-run.Crashed",
}
But that is not clearly better than prefect.flow-run.*, and it risks missing other valid activity/state events.
So I would not recommend it as a real fix.
Bottom line
- Your current EventTrigger should already not fire after a normal Failed.
- If it still does, the issue is probably not solvable cleanly by changing expect.
- The robust fix is to validate the flow run’s current state before crashing/retrying.Marvin
05/18/2026, 10:42 AMRUNNING before acting.Mark
05/18/2026, 10:55 AMRunning , message has the same timestamp as both the Heartbeat message and the Failure status transition. Is it possible that the automation still kicks in because the failure's timestamp is the same as the hearbeat?Marvin
05/18/2026, 10:55 AMMarvin
05/18/2026, 10:55 AMHeartbeat, Running, and Failed events all share essentially the same timestamp, then the proactive trigger can run into edge-case ordering/tie behavior.
In particular, for your setup:
after={"prefect.flow-run.heartbeat"}
expect={"prefect.flow-run.*"}
posture=Posture.Proactive
threshold=1
within=120s
for_each={"prefect.resource.id"}
the trigger logic depends on:
- bucket creation/reset timing
- whether an event is considered inside the current bucket window
- and event ordering when timestamps are equal or nearly equal
So if:
- heartbeat creates/resets the bucket
- failure arrives with the same occurred timestamp
- internal ordering or bucket evaluation treats that failure as not incrementing the bucket in the expected way
then yes, you could end up with the automation still firing later even though the run failed normally.
That would fit what you observed much better than “Failed isn’t matched.”
Why equal timestamps are dangerous here
These triggers are not just checking event names; they also bucket events by time windows. When multiple relevant events land at the same logical time, subtle ordering matters.
So this could cause:
- the bucket to be started by heartbeat
- the failure not to count the way you expect
- later proactive evaluation sees an under-threshold bucket
- retry/crash action still happens
Can you fix this purely in trigger config?
Not in a way I’d trust.
Because the problem would not be “wrong event names”; it would be “same-time event ordering / bucket semantics.” Changing expect is unlikely to reliably solve that.
Most practical config-only mitigation
If you want to stay in automations and avoid an extra flow, the best mitigation I can suggest is to narrow what counts as the starting event so that ordinary terminal transitions are less likely to collide with your start/reset signal.
For example, you could try using only heartbeat-based activity semantics and not broad prefect.flow-run.*.
But as we’ve already seen, that path has its own problems.
So the honest answer is:
Yes, same timestamps could absolutely explain why a normal failure is still followed by the zombie automation firing.
And if that’s the cause, it’s more of an event-processing edge case than a clean configuration mistake.
What I’d suggest next
Since you want to avoid another flow, the most reasonable mitigation may be:
- keep the zombie automation only for ChangeFlowRunState
- remove RunDeployment from this automation
- handle retries through your normal flow/deployment retry policy instead
Why?
Because the harmful side effect is the retry scheduling on non-zombies. A mistaken crash after a terminal failure is annoying, but unintended retries are usually worse.
If you still want the automation to do both crash + retry, then I don’t think I can honestly promise a trigger-only config that is robust against this same-timestamp edge case.
Short answer
- Yes, equal timestamps between heartbeat and Failed could explain the behavior.
- That kind of race/ordering issue is consistent with what you’re seeing.
- I would not trust a pure EventTrigger tweak alone to eliminate it fully.
If you want, I can help you think through a minimal-risk split where:
1. the automation only marks suspected zombies Crashed
2. retries are handled separately in a way that won’t misfire on normal failures.Mark
05/18/2026, 11:08 AMMarvin
05/18/2026, 11:08 AMMarvin
05/18/2026, 11:08 AMRunning
- first heartbeat
- immediate Failed
So as a temporary measure for testing, that’s reasonable.
I’d just be cautious about relying on it as the real fix, because it means correctness depends on timing rather than the automation semantics being stable.
A few practical notes:
- Even a small delay like a few seconds is probably enough for testing.
- If the issue disappears consistently, that’s useful evidence that the failure mode is tied to startup-time event ordering.
- I would still treat this as a workaround, not a durable design.
What this tells you
If this works, your automation is likely “mostly correct” for true zombie detection, but fragile when:
- a run fails almost immediately after start
- heartbeat and terminal events happen at effectively the same time
That’s actually a much narrower problem than “automations don’t work at all,” which is good news.
My recommendation
Use the delay for now if it helps you validate the zombie path, but I’d keep in mind one of these longer-term directions:
1. Only use the automation to mark runs Crashed
- less risky than also scheduling retries
2. Keep automatic registration, but avoid unnecessary automation updates
- which you already identified
3. Consider documenting this as a potential Prefect bug / edge case
- especially the “immediate failure after first heartbeat can still satisfy zombie automation later” behavior
If you want, I can help you summarize the exact reproduction steps into a concise issue/discussion draft for GitHub.