Skip to content

workflow: align durable hook lifecycles - #410

Draft
fantix wants to merge 2 commits into
mainfrom
fantix/workflow-hook-lifecycle
Draft

fantix wants to merge 2 commits into
mainfrom
fantix/workflow-hook-lifecycle

Conversation

@fantix

@fantix fantix commented Sep 23, 2026 •

Copy link
Copy Markdown
Member

Summary

Make hook registration and explicit disposal durable even when a workflow never awaits the hook or ends without another suspension. Protect disposed hook identities in LocalWorld so overlapping invocations cannot recreate a released hook or reclaim its successor's token.

Committing explicit disposal before termination supports the retention follow-up's rule that disposal releases a token immediately, even when the run would otherwise retain it. This PR adds no retention API or deadline-based token cleanup.

The old runtime flushed hook operations only on suspension, with all disposals before all creations. It also conflated recorded disposal with a new disposal by user code. World cleanup at run termination hid some missing lifecycle events, but was not equivalent to recording explicit disposal.

User-visible behavior and TS comparison

Compared with the TS SDK at fixed revision 5093edeb, using its implementation and regression tests:

Scenario Python before Python after TS comparison
SomeHook.wait(), then return or raise without awaiting/iterating the hook or calling get_conflict() Pending registration was omitted at run termination. Attempt registration before the terminal event: hook_created or hook_conflict. Matches the final drain on success/failure.
Create and immediately dispose(), including an immediately exited async with Creation could be omitted; suspension could attempt disposal before the hook existed. Record create → dispose. If creation conflicts, record only the conflict and leave the owner untouched. Matches the create/dispose and conflict tests.
Exit async with after registration, then immediately finish/fail World terminal cleanup removed the hook, but explicit disposal could be absent from history. Commit hook_disposed before the terminal event. Merely suspending inside the scope is not scope exit. TS routes using through disposal and drains before termination.
Same-token create/dispose/reuse chains and retries Separate disposal/creation phases could omit intermediate lifecycles; disposed hooks lost their replay subscription, and terminal exits could skip remaining history. Commit each lifecycle before the next claim. Reconcile committed facts on retry so released hooks are not recreated and a successor's token is not disturbed. Matches per-token ordering and recorded-disposal handling. Python's terminal reconciliation is described below.
Replay hook_disposed before earlier buffered payloads are claimed Replaying disposal called user disposal, ending consumption of those payloads. Recorded disposal closes the historical subscription, but buffered payloads remain consumable until user code disposes. Matches TS's buffered-payload regression test.
Replay a changed hook token, a replaced operation, or a missing registration Hook tokens were not validated; terminal replay could miss divergence; missing registrations surfaced as generic errors. Fail as REPLAY_DIVERGENCE during active or terminal replay, including disposed hooks, without flushing new user-hook operations. TS validates hook tokens and bypasses final drain on replay divergence. Same failure policy, not identical replay scheduling.

Final draining is best-effort: storage errors are logged without replacing the body's result or original exception, matching TS. Suspension flush errors still propagate for retry. Independent token groups run concurrently, and all groups settle before a flush returns or raises, as in TS's settled hook phase.

Compatibility and operational impact

  • Terminal reconciliation also validates remaining step, wait, and attribute history. A body that returns or raises before active replay reaches a changed step input, changed workflow attributes, or a missing/replaced operation now fails with REPLAY_DIVERGENCE. This can expose inconsistencies that earlier terminal exits skipped.
  • Running workflows whose histories omitted hooks created and disposed before suspension can continue through the normal flush path, with no legacy-version branch or migration. On a subsequent invocation, missing lifecycle writes are attempted against current token ownership: creation followed by disposal, or a conflict. Existing events are not rewritten; a regression test covers continuation from such a history.
  • Previously omitted hooks now attempt real token claims, including hooks disposed immediately. These claims can conflict, and successful ephemeral lifecycles add creation and disposal writes. Different token groups remain concurrent; there is no performance benchmark in this PR.

Implementation and boundaries

  • Share one ordered event handler between active replay and terminal reconciliation. Active WorkflowOrchestratorContext.resume() still delivers one historical event at a time. After isolated task/generator cleanup and result serialization, terminal reconciliation applies remaining history without waking user code or hydrating unused results.
  • Keep the full hook registry separate from live payload subscriptions. Historical creation/disposal/conflict facts survive explicit disposal and guide subsequent writes.
  • Do not advance replay eagerly from create_hook(). TS uses subscribed event consumers; this PR preserves Python's scheduling and replay-clock model instead of porting TS's scheduler.
  • Unawaited hooks already registered at the next suspension on main. The new coverage is terminal exits, complete ephemeral lifecycles, and the replay protections needed to support them.
  • LocalWorld serializes creation and disposal for each hook across worker processes and checks the existing durable disposal marker before touching its token claim. A stale invocation cannot resurrect that hook after release, even when another hook has acquired the token.
  • Ordinary World terminal cleanup is unchanged. Live user hooks do not gain implicit disposal events or retention after completion. No public API, World protocol, or capability changes. Final writes added by this PR cover user hooks; TS also drains other pending operation types, while Python's step/wait/attribute final-write behavior remains unchanged.
  • This is not full hook API parity: Python's existing await after explicit disposal raises HookDisposedError; TS's empty-history case suspends. That difference is unchanged. Python's new token check covers creation/conflict events, while TS checks token-bearing hook events more broadly.

Tests extend the existing registration, conflict, disposal, and determinism modules. Coverage includes partial writes, terminal-write retries, legacy histories, same-run and other-run token successors, and two independent LocalWorld worker processes starting from the same stale history.

Validation

  • uv run poe qa vercel-workflow -q — passed on Python 3.14.2: 1,540 collected (1,527 passed, 11 skipped, 2 xfailed); lint and type checks passed.
  • Python 3.10.20: registration, conflict, disposal, and determinism modules — all 104 tests passed directly in the tox environment with tox exec -e py310 -- python -m pytest ....
  • uv run poe check-news-fragments — passed.
  • git diff --check 0c72a39f...HEAD — passed.

The multiprocess storage regression was exercised against LocalWorld on macOS. Windows file locking and live Vercel backend concurrency were not exercised. TS comparisons are based on the linked implementation and regression tests; the TS suite was not run.

Commit hook creation and disposal in per-token order, even when the
workflow never awaits the hook. Reconcile remaining history before a
terminal outcome without delivering results into a closed workflow loop.

Keep recorded disposal separate from explicit disposal so replay does
not discard buffered payloads. Cover lifecycle retries and terminal
replay across hooks, steps, waits, and attributes.
Keep LocalWorld hook disposal durable across overlapping invocations.
Serialize hook creation and disposal across processes and reject
stale creation requests before they can reclaim a released token.

Cover token reuse and concurrent handlers with regression tests.
fantix added a commit that referenced this pull request Sep 29, 2026
Catch replay mismatches before a run ends, preparing for hook
lifecycle writes in #410.

This branch was successfully deployed

1 active deployment
ci — 9bbf759e Deployed Sep 23, 2026 by fantix via Test (Linux, py3.11) #1568
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