Skip to content

feat(workflow): end hooks B — afterExecution runs, kill stops it, sweeps read execution_is_idle - #761

Merged
viethqb merged 19 commits into
mainfrom
feat/workflow-end-hooks-b
Oct 27, 2026
Merged

viethqb merged 19 commits into
mainfrom
feat/workflow-end-hooks-b

Conversation

@viethqb

@viethqb viethqb commented Oct 27, 2026

Copy link
Copy Markdown
Member

Summary

Package #2b of the Kestra-parity roadmap: afterExecution: now runs, after the execution has ended (stacked on #759, which landed finally:).

  • Post-terminal phase. advance runs the afterExecution: list once the execution is terminal (and after KILLED). The execution's own state, history and ended_at never change; no retry (D11), no pause (D12). Plan indexes are append-only (the frozen layout fixture is untouched).
  • Refusals / warnings. Pause, Exit, LoopUntil/WaitFor inside afterExecution: are refused at start (naming Phase 3b-1 — Query Engine (async execution, REST polling) #9 / feat(web): Box plot + Sankey + stack totals — finish chart clean-room (17 types) #20); retry: there is warned at save/publish (Phase 3c-3 + 3c-3.2 — Charts: Table, Pivot, and multi-dimensional Pivot #13). A flow stored before this deploy that carries one of those kinds cannot write the ended execution: its after-run is FAILED with the refusal text, and the phase never passes a command that writes the execution.
  • Worker. After-runs are claimed and rendered (including SetVariables/Labels); a Subflow parent hears its child only once the child is terminated (after its after-tasks); flow-trigger events are still written once, at the terminal state.
  • Kill stops after-tasks (user decision Q3, supersedes D9). A kill of a terminal execution with live after-runs is accepted (202), stops them, and leaves the execution untouched; with none it is still 409 execution_terminated; while a root WorkingDirectory block claim is in flight it is a retryable 409 kill_not_applied. A parent's kill cascade also stops a terminal child's after-phase (recorded divergence from Kestra, which skips terminal descendants).
  • Migration 0210 workflow.execution_is_idle(id) (terminal and every run terminal) replaces "terminal" in the superseded-container liveness, the pruner / protected_set, and log_status.complete. The _wd sweep keeps reading the block's own state. Additive, one function.
  • Frontend. Execution pages follow a live after-run (executionLive), bounded to 30 minutes after the end; the Kill button and its copy handle a phase kill and kill_not_applied; the log follower is bounded without cutting a full page.
  • Oracle. Ratchet 301/60/60/0 → 301/52/52/0 (8 entries banked: after-execution-error, -finally, -flowable, flow-concurrency-queue-after-execution, flow-concurrency-queue-killed-after-execution, restart-with-after-execution, sleep-long, sleep-long-after-execution-flowable). valids/after-execution (flow outputs:, Phase 2a — Iceberg REST Catalog (egret-storage, egret-iceberg-ext, egret-catalog) #6) and valids/pause-errors-finally-after-execution (Phase 3b-1 — Query Engine (async execution, REST polling) #9) stay baselined.
  • Docs. EN + VI (spec, plan, roadmap, notes); OpenAPI text and generated client regenerated.

Known, measured, documented (not fixed here)

A non-KILLED Exit beside a flowable sibling leaves a main-list run non-terminal for ever (pre-existing; tests/exit_sibling_idle.rs pins it). Effect: such an execution is never "idle", so the pruner keeps its family, protected_set pins its readable set, and its log complete never turns true. Nothing is lost; the UI polling is bounded. Recorded in spec §6 and roadmap row #20 with the recommended fix (a terminal execution keeps closing main-list runs).

Deploy notes

  • Migration 0210 is a CREATE OR REPLACE FUNCTION; apply before the new workers start. Already-published flows with Pause/Exit/LoopUntil inside afterExecution: are now refused at every new start (schedules included); in-flight or stored ones fail that after-run instead of running it.
  • protected_set costs ~4 s per 100k executions (was 0.12 s), hourly; debt row with the cheaper form is in the debt spec.

Test plan

  • cargo test -p egret-workflow: 74 binaries ok; only the 7 sql_starrocks_live tests fail (they need a StarRocks engine)
  • oracle ratchet 301/52/52/0
  • cargo test -p egret-config -p egret-server (env allow-list, OpenAPI + generated-client drift)
  • frontend typecheck, lint, vitest workflows (76 files, 1238 tests)
  • mutation checks recorded per task in the SDD reports
  • CI

Docs: EN/VI done in-repo; egret-data/docs follow-up (Kill button on an ended execution, kill_not_applied, refusals, 30-minute follow) tracked separately.

🤖 Generated with Claude Code

viethqb and others added 19 commits October 28, 2026 01:49
advance gains the post-terminal phase (spec D8, Kestra
ExecutorService.handleAfterExecution): once the execution's last state is
terminal, after_execution::drive walks the flow's afterExecution: list one
task at a time under no parent, and never emits SetExecutionState,
ScheduleRetry or PauseRun (debug_assert + corpus test). KILLING keeps
advance_killing, so the phase starts once the execution is KILLED; a named
B3 hook marks where the kill of the phase (Q3) will stop it.

- Plan::after_execution, flattened last and then drained (D3): the A1
  prefix fixture is unchanged.
- executor::terminated / drives_after (D10).
- D11: retry::policy_for answers None for a run under afterExecution:, so
  a flowable in the phase never waits for a retry nobody schedules.

Oracle: 8 entries bank (301/60/60/0 -> 301/52/52/0); derived constants
moved with dated notes; handler-surface regenerated (ceiling 273 -> 281).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…ry there is warned

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…s child once it is terminated

The settle tail reports a terminal to a Subflow or Loop parent when
executor::terminated first becomes true (spec D10), so a child whose
afterExecution: is still running keeps its parent waiting. render_updates
also runs while the post-terminal phase does, so a SetVariables or Labels
task inside afterExecution: is computed. Flow-trigger events stay per
execution state change. New DB tests in tests/end_hooks.rs.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…tion's state alone

A kill of a terminal execution whose afterExecution: still has a live run
is accepted (user answer to Q3, 2026-10-27; a security divergence from
Kestra, which answers 409): kill_phase kills the phase's live runs through
the existing kill path, the cascade reaches only the phase's own children,
and the execution's state, history and ended_at are not written. advance
then closes the phase and creates no further after-task; terminated holds
once every run of a killed phase is terminal, so a parent hears the child.
A terminal execution with nothing of its phase live is still a 409.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
… a cascade stops a terminal child's afterExecution

A kill of a terminal execution whose only live after-run is a
WorkingDirectory block waiting on its prepare or finish claim writes
nothing and answers 409 kill_not_applied instead of an accepted kill.
An ancestor's kill cascade now reaches a terminal descendant whose
afterExecution: still has a live run and kills that phase only, leaving
the descendant's state, history and ended_at alone (a recorded divergence
from Kestra, which skips terminal children). DESCENDANTS pairs each
phase run index with its own link.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…afterExecution

Loop, Subflow and WorkingDirectory run to the end inside afterExecution.
LoopUntil is kept as an ignored test: it waits between iterations with a
PauseRun, which the post-terminal phase refuses (panic at
after_execution.rs:68).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
A LoopUntil (and its WaitFor alias) waits between iterations with PauseRun,
which the post-terminal phase cannot apply; it is refused at start with
unsupported_after_execution_task, naming roadmap package #9.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…minal (roadmap Rule 2)

Migration 0210 adds workflow.execution_is_idle(id): terminal AND no task
run live (spec D13, section 6). A terminal execution can still run work
(afterExecution:, an Exit sibling), so the readers that meant "nothing of
this execution runs" now read idle:

- run_liveness: a superseded container is judged only once its execution
  is idle, with the retention measured from the last run's end;
- the _wd sweep also requires the block itself to be terminal;
- the storage pruner's protected set and late-dependent check read
  NOT execution_is_idle, and its retention runs from the last run's end;
- the logs route's `complete` waits for idle and the settle lag after the
  last run's end.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
… Exit-sibling leak

The superseded-container clock, the pruner's retention clock and the logs
route's settle clock take each run's end as COALESCE(ended_at, updated_at),
as run_liveness's own terminal rule does: a terminal run written with no
ended_at ended no earlier than its last write.

A new test measures which Exit shapes leave a main-list run live for good
(a CREATED leaf never started, a flowable never closed) and so keep their
execution from ever being idle; the same rows stay on the commit before
package #2. The prose about a LoopUntil inside afterExecution (refused
since ff39653c) now names the live case, a LoopUntil beside an Exit.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…on runs

Terminal execution with a live run (an afterExecution run) stays live for
AFTER_RUN_FOLLOW_MS (30 min) after ended_at: polling, topology, Gantt and
Kill follow it. Kill of such an execution says the afterExecution tasks are
being stopped; a 409 kill_not_applied is a retryable message.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…rExecution runs

ChildExecutions polls while executionLive, so a child an after-run starts
appears; the Gantt clock stops once the follow window has passed; Kill
reuses the frame's clock.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The end-hooks spec gets a #2b status line and dated as-built notes under
D9 (superseded by Q3: a kill stops live afterExecution runs, 409
execution_terminated without one, 409 kill_not_applied when it cannot
apply yet, the cascade reaches a terminal child's phase), D10, D11, D12
(LoopUntil/WaitFor refused too), D15, 4.2, 5, 6 (with the Exit-sibling
leak and protected_set cost debts), 7.1 (301/52/52/0, all 8 banked),
8, 9, 10, 11 and the answers, plus a #2b deploy note. The roadmap marks
row #2 done with measured banks, records both kill divergences in 3.1
as built, and adds the Exit-in-afterExecution refusal and the
Exit-sibling stuck run to row #20. The kill-and-exit spec, the debt
spec 4.7, the programme spec 9 and the plan's B8 docs list get dated
notes. The oracle README gains the #2b as-built section; the ninth
MISMATCH_ON_ORDER entry is verified as valids/after-execution.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…haviour

The kill route answers 202 for a terminal execution whose afterExecution
runs are live, 409 kill_not_applied while a block claim is in flight, and
409 execution_terminated otherwise. Log complete now means idle: terminal
and every run terminal. OpenAPI and the generated client regenerated.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
… the last run's end

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The log follower stops once the execution has been terminal past
AFTER_RUN_FOLLOW_MS even when complete stays false; the kill dialog for an
ended execution says it stops the afterExecution tasks.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…ed execution

A flow published before D12 can hold Pause, Exit or LoopUntil/WaitFor in
afterExecution:. Executions running at deploy and CREATE_NEW_EXECUTION
replacements are planned with plan_stored, which skips the start-time
refusal, and the phase guard was only a debug_assert: in release an Exit
rewrote the ended execution, and a Pause/LoopUntil left an after-run
PAUSED for ever.

- spec::validate::after_execution_refusal: B2's predicate, extracted; one
  text for the start refusal and the stored plan.
- Plan::after_execution_refused (no index moves; A1 fixture untouched).
- after_execution::drive, layer 1: a refused run is FAILED before it
  starts; the worker writes the refusal as its message.
- Layer 2, release-safe: a batch that would write the execution
  (SetExecutionState, PauseRun, ScheduleRetry) is dropped and the run it
  was about FAILED.
- Deploy note in the EN spec and its VI mirror.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Layer 2 of the afterExecution guard kept a named run that was already
terminal, so it could drop a batch with no replacement and stall the
phase. It now keeps only runs that can still fail, falls back to the live
leaves of the phase, and with no live phase run returns the batch with its
execution writes stripped, so the phase moves on and terminated becomes
true.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@viethqb
viethqb merged commit ff25210 into main Oct 27, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant