fix: dispatch execution_end hook on failed crew and flow executions (#6607)
Some checks are pending
CodeQL Advanced / Analyze (actions) (push) Waiting to run
CodeQL Advanced / Analyze (python) (push) Waiting to run
Check Documentation Broken Links / Check broken links (push) Waiting to run
Vulnerability Scan / pip-audit (push) Waiting to run

* fix: dispatch execution_end hook on failed crew and flow executions

The `execution_end` interception point only fired after a successful
kickoff, so consumers never learned about failed runs. Crew kickoff
paths (`kickoff`/`akickoff`) and the flow runtime (`kickoff_async`,
`resume_async`) now dispatch it on the failure path too, with new
additive `status` ("completed"/"failed") and `error` fields on
`ExecutionEndContext`. Pairing flags guarantee exactly-once dispatch,
keep the start/end pairing invariant, and the original exception
propagates unchanged.

* fix: track execution_end pairing per invocation for reentrant flows

Reentrant kickoffs on the same Flow instance are supported (usage
aggregation already accommodates them), but the instance-level pairing
booleans let an inner kickoff's completion mark the outer execution as
ended, skipping the outer failure's `execution_end`. The pairing state
now lives in each `kickoff_async` invocation's locals, and the resume
path passes a per-invocation holder into `_resume_async_body`. Crew
keeps its instance flags since crew kickoffs are not reentrant on the
same instance (`kickoff_for_each` copies the crew).
This commit is contained in:
Lucas Gomide
2026-07-21 16:25:06 -03:00
committed by GitHub
parent 6d496f799b
commit 3bb87532da
6 changed files with 296 additions and 6 deletions

View File

@@ -18,7 +18,7 @@ Four interception points cover the boundaries:
| `EXECUTION_START` | A crew or flow is about to begin | inputs `dict` |
| `INPUT` | Resolved inputs for the execution | inputs `dict` |
| `OUTPUT` | The final result is ready | the output object |
| `EXECUTION_END` | The execution has finished | the output object |
| `EXECUTION_END` | The execution has finished (success or failure) | the output object, or `None` on failure |
For a crew, the output payload is a `CrewOutput`. For a flow, it is the final
flow-method result.
@@ -68,7 +68,9 @@ class OutputContext(InterceptionContext):
output: Any # The output object
class ExecutionEndContext(InterceptionContext):
output: Any # The output object
output: Any # The output object (None when status == "failed")
status: str # "completed" or "failed"
error: BaseException | None # The exception when status == "failed"
```
<Note>
@@ -136,6 +138,28 @@ def redact_emails(ctx):
payload from earlier hooks; the final rewritten value is what `kickoff()`
returns.
### Observing Failures
`EXECUTION_END` fires exactly once per execution, on success and on failure
alike. When the run raises — a task error, a flow-method exception, or a
`HookAborted` from an earlier point — the hook receives `status="failed"` with
the exception in `ctx.error`, and the original exception still propagates out
of `kickoff()` unchanged:
```python
@on(InterceptionPoint.EXECUTION_END)
def report_outcome(ctx):
if ctx.status == "failed":
notify_policy_engine(status="failed", error=repr(ctx.error))
else:
notify_policy_engine(status="completed")
```
Two caveats: `EXECUTION_END` does not fire when `EXECUTION_START` never
dispatched (an abort at start counts as the execution never beginning), and
raising `HookAborted` from a failure-path `EXECUTION_END` dispatch is ignored —
there is nothing left to abort, and the original error wins.
## Ordering
For a crew run the boundary order is: