Skip to content

Commit 55d3844

Browse files
lbliiiclaude
andauthored
Add Race, All, Take, Debounce saga effects and gateway tests (#29)
* Add Race, All, Take, Debounce saga effects, fix pipeline bug, add gateway tests (#29) Expand saga effects from 8 to 12 with Race (first-wins), All (wait-all), Take (action-waiting), and Debounce (timer-restart). Fix Python 2 exception syntax in pipeline.py:401. Add 28 gateway tests (previously 0 coverage). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Fix CI: ruff format, revert pipeline.py to valid 3.14 syntax, add changelog fragment Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Fix flaky debounce test: increase sleep margins for CI Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Fix Race effect TOCTOU race on free-threaded Python On Python 3.14t (no GIL), a child saga could finish between the per-child done.is_set() for-loop and the all-done check, causing _execute_race to hit the fallback `return None` path instead of returning the child's result. Re-check results in the all-done branch. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Address PR review: Take cancel-awareness, waiter cleanup, composable child sagas - Take effect now polls in short intervals (0.1s) so saga cancellation can interrupt even indefinite waits, instead of blocking on a single waiter_event.wait() call. - Take waiter cleanup now deletes the dict key when the per-action list becomes empty, preventing unbounded growth of _action_waiters. - Replaced duplicated effect-stepping logic in _run_saga_capturing with a yield-from wrapper that delegates to _run_saga, making child sagas in Race/All fully composable with all effect types (Take, Debounce, nested Race/All, etc.). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 19a322e commit 55d3844

7 files changed

Lines changed: 1336 additions & 3 deletions

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Added Race (first-wins with loser cancellation), All (wait-all with fail-fast), Take (pause until action dispatched), and Debounce (cancel-and-restart timer) saga effects. Fixed Python 2 exception syntax bug in pipeline handler introspection. Added gateway test suite covering namespacing, routing, proxying, idle reaping, and error handling.

src/milo/__init__.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,10 @@ def __getattr__(name: str):
2828
"Retry": "_types",
2929
"Timeout": "_types",
3030
"TryCall": "_types",
31+
"Race": "_types",
32+
"All": "_types",
33+
"Take": "_types",
34+
"Debounce": "_types",
3135
"Cmd": "_types",
3236
"Batch": "_types",
3337
"Sequence": "_types",
@@ -154,6 +158,7 @@ def _Py_mod_gil() -> int: # noqa: N802
154158
"CLI",
155159
"DEFAULT_THEME",
156160
"Action",
161+
"All",
157162
"App",
158163
"AppError",
159164
"AppStatus",
@@ -168,6 +173,7 @@ def _Py_mod_gil() -> int: # noqa: N802
168173
"ConfigSpec",
169174
"Context",
170175
"CycleError",
176+
"Debounce",
171177
"Delay",
172178
"Description",
173179
"DevServer",
@@ -213,6 +219,7 @@ def _Py_mod_gil() -> int: # noqa: N802
213219
"PromptDef",
214220
"Put",
215221
"Quit",
222+
"Race",
216223
"ReducerResult",
217224
"RenderTarget",
218225
"RequestLog",
@@ -225,6 +232,7 @@ def _Py_mod_gil() -> int: # noqa: N802
225232
"SpecialKey",
226233
"StateError",
227234
"Store",
235+
"Take",
228236
"ThemeProxy",
229237
"ThemeStyle",
230238
"TickCmd",

src/milo/_types.py

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,78 @@ class TryCall:
269269
kwargs: dict = field(default_factory=dict)
270270

271271

272+
@dataclass(frozen=True, slots=True)
273+
class Race:
274+
"""Run multiple sagas concurrently, return the first result.
275+
276+
Losers are cancelled via their cancel events as soon as a winner
277+
completes. If all racers fail, the first error is thrown into
278+
the parent saga::
279+
280+
winner = yield Race(sagas=(fetch_primary(), fetch_fallback()))
281+
282+
Raises ``StateError`` if *sagas* is empty.
283+
"""
284+
285+
sagas: tuple
286+
287+
288+
@dataclass(frozen=True, slots=True)
289+
class All:
290+
"""Run multiple sagas concurrently, wait for all to complete.
291+
292+
Returns a tuple of results in the same order as the input sagas.
293+
Fail-fast: if any saga raises, remaining sagas are cancelled and
294+
the error is thrown into the parent::
295+
296+
a, b = yield All(sagas=(fetch_users(), fetch_roles()))
297+
298+
An empty tuple returns ``()`` immediately.
299+
"""
300+
301+
sagas: tuple
302+
303+
304+
@dataclass(frozen=True, slots=True)
305+
class Take:
306+
"""Pause the saga until a matching action is dispatched.
307+
308+
Waits for *future* actions only — actions dispatched before the
309+
Take is yielded are not matched. Returns the full ``Action``
310+
object so the saga can inspect both type and payload::
311+
312+
action = yield Take("USER_CONFIRMED")
313+
name = action.payload["name"]
314+
315+
An optional *timeout* (in seconds) raises ``TimeoutError`` if the
316+
action is not dispatched in time.
317+
"""
318+
319+
action_type: str
320+
timeout: float | None = None
321+
322+
323+
@dataclass(frozen=True, slots=True)
324+
class Debounce:
325+
"""Delay-then-fork: start a timer, fork *saga* when it expires.
326+
327+
If the parent saga yields another ``Debounce`` before the timer
328+
fires, the previous timer is cancelled and restarted. The parent
329+
continues immediately (non-blocking)::
330+
331+
# In a keystroke handler saga:
332+
while True:
333+
key = yield Take("@@KEY")
334+
yield Debounce(seconds=0.3, saga=search_saga)
335+
336+
The debounced saga runs independently; use ``Take`` if the parent
337+
needs the result.
338+
"""
339+
340+
seconds: float
341+
saga: Callable
342+
343+
272344
# ---------------------------------------------------------------------------
273345
# Commands (lightweight alternative to sagas)
274346
# ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)