diff --git a/.semgrep.yaml b/.semgrep.yaml index bc079054..7d6a90da 100644 --- a/.semgrep.yaml +++ b/.semgrep.yaml @@ -4087,3 +4087,18 @@ rules: include: - /packages/doeff-conductor/src/** - /packages/doeff-agents/src/** + + - id: doeff-scheduler-spawn-must-capture-boundaries + languages: [generic] + severity: ERROR + message: > + Issue scheduler-spawn-drops-observer-boundary / ADR-DOE-CORE-001: the + scheduler must capture the FULL boundary stack at the spawn site via + get_inner_boundaries (handlers AND WithObserve observers, nesting order + preserved). get_inner_handlers drops observer boundaries, silently + breaking subtree observation (e.g. OpenTelemetry effect->span + instrumentation) inside Spawn'd tasks. + pattern-regex: 'get_inner_handlers' + paths: + include: + - /packages/doeff-core-effects/doeff_core_effects/scheduler.py diff --git a/docs/adr/defadr_doeff_core_001_spawn_boundary_inheritance.hy b/docs/adr/defadr_doeff_core_001_spawn_boundary_inheritance.hy new file mode 100644 index 00000000..3ee666b4 --- /dev/null +++ b/docs/adr/defadr_doeff_core_001_spawn_boundary_inheritance.hy @@ -0,0 +1,52 @@ +;;; Executable ADR: Spawn は spawn-site の boundary stack(handler+observer)を継承する。 + +(require doeff-adr.macros [defadr defsemgrep rule law]) +(import doeff-adr.macros [fact interpretation counterexample]) + + +(defadr ADR-DOE-CORE-001 + :title "Spawn は handler と同様に WithObserve 境界も捕捉・再インストールする(interleave 順序保存)" + :status "accepted" + :scope ["doeff-core-effects" "scheduler.Spawn" "doeff-vm.GetBoundaries" "WithObserve"] + :problem + [(fact + "scheduled() の内側に置いた WithObserve は Spawn した子タスクが perform する効果を一切観測しない。同じ位置の handler(WithHandler)は子に継承されるのに、observer だけが脱落する(silent drop — fail-fast 原則違反)。" + :evidence "issues/scheduler-spawn-drops-observer-boundary.md 再現 @ cacc4b85; tests/test_spawn_observer_inheritance.py は修正前 HEAD で 5 件 fail") + (fact + "機構: Spawn は get_inner_handlers(= GetHandlers(k))で prompt boundary のみを捕捉して子タスクに再インストールする。intercept(observer)boundary は捕捉対象外。observer 呼び出しは fiber 親チェーン走査であり、Spawn 子 fiber は scheduler の評価文脈を親に持つため、spawn-site〜scheduler 間の observer boundary はチェーン上に存在しない。" + :evidence "packages/doeff-core-effects/doeff_core_effects/scheduler.py Spawn 分岐; doeff/handler_utils.py; packages/doeff-vm-core/src/vm/step.rs call_all_observers") + (fact + "実務影響: doeff-traverse parallel worker 内の slog が observer ベース出力に出ない(proboscis-ema, 2026-04)。OpenTelemetry の effect→span 自動計装をサブツリー限定で書くと、Spawn を含む区間で計装が『付いているのに見えない』。" + :evidence "issue 動機セクション")] + :context + [(interpretation + "観測の期待値として handler と observer の継承規則は対称であるべき。非対称を正当化する仕様文書は存在しなかった(issue 論点 1)。") + (interpretation + "相対順序も意味論の一部: handler(WithObserve(obs, body)) の配置では handler 本体が emit する効果は obs に不可視。フラットな『observer を最外へ積む』再インストールは過剰観測(重複 span 等)を生む。よって捕捉・再インストールは interleaved(innermost-first)で順序を保存する。") + (interpretation + "primitive は opt-in クエリ GetBoundaries(k)。GetHandlers(k) と対称で、継続を消費しない(multi-shot 安全)。既存 GetHandlers とその多数の利用箇所(try_handler, doeff-traverse 等)は不変(issue 論点 2 の opt-in 案を採用しつつ、scheduler は既定で継承する)。")] + :decision + [(rule R1 "doeff-vm bridge に GetBoundaries(k) を追加する: spawn-site から catching handler までの [\"handler\"|\"observer\", callable] を innermost-first で返す。catching handler の prompt boundary を最終エントリとして含む(GetHandlers と対称)。") + (rule R2 "doeff.handler_utils.get_inner_boundaries(k) は GetBoundaries(k) の最終エントリ(呼び手自身)を落として返す。最終エントリが handler 種でなければ RuntimeError(fail-fast)。") + (rule R3 "scheduler の Spawn は get_inner_boundaries で boundary stack を捕捉し、子タスク再構築時に kind に応じて WithHandler / WithObserve を innermost-first で再インストールする。handler と observer の相対 nesting 順序を保存する。") + (rule R4 "再インストールされた boundary は子タスクの program が所有し、タスク終端(完了・失敗・キャンセル)で _release_task_refs により解放される。scheduler 内部効果(wrap_task の TaskCompleted 等)は boundary の外側で perform され、サブツリー observer から観測されない(issue 論点 3)。") + (rule R5 "Mask boundary は本 ADR の対象外(従来どおり Spawn 非継承)。継承が必要になった場合は GetBoundaries の kind 追加として本 ADR を改訂する。")] + :laws + [(law spawn-observer-inheritance + :statement "spawn_site_boundary(observer or handler) => reinstalled_on_child preserving_relative_order" + :counterexamples + [(counterexample "scheduled(WithObserve(obs, h(main))) で子タスクの効果が observed に含まれない") + (counterexample "observer をフラットに最外へ積み、handler 本体が emit する効果まで観測してしまう") + (counterexample "Spawn 経路が get_inner_handlers に退行し observer だけ脱落する")])] + :enforcement + [(defsemgrep scheduler-spawn-boundary-capture + "doeff-scheduler-spawn-must-capture-boundaries" + [{"relative-path" "packages/doeff-core-effects/doeff_core_effects/scheduler.py" + "source" "inner_handlers = yield get_inner_handlers(k)\n"}] + [{"relative-path" "packages/doeff-core-effects/doeff_core_effects/handlers.py" + "source" "inner_hs = yield get_inner_handlers(k)\n"}])] + :plans ["docs/adr/defadr_doeff_core_001_spawn_boundary_inheritance.hy" + "tests/test_spawn_observer_inheritance.py" + "packages/doeff-vm-core/src/continuation.rs (boundary_callables)" + "packages/doeff-vm/src/do_expr.rs (GetBoundaries pyclass)" + "packages/doeff-core-effects/doeff_core_effects/scheduler.py (Spawn boundary capture)"]) diff --git a/doeff/__init__.py b/doeff/__init__.py index 2ccb9ac8..83dc2b87 100644 --- a/doeff/__init__.py +++ b/doeff/__init__.py @@ -21,6 +21,7 @@ from doeff.mcp import McpToolDef as McpToolDef from doeff.program import Apply as Apply from doeff.program import Expand as Expand +from doeff.program import GetBoundaries as GetBoundaries from doeff.program import GetExecutionContext as GetExecutionContext from doeff.program import GetHandlers as GetHandlers from doeff.program import GetOuterHandlers as GetOuterHandlers @@ -103,7 +104,7 @@ def WithObserve(observer, body): # noqa: N802 - public compatibility constructo _DOEXPR_TYPES = ( Pure, Perform, Resume, Transfer, Apply, Expand, Pass, WithHandlerType, WithObserveRaw, ResumeThrow, TransferThrow, - GetTraceback, GetExecutionContext, GetHandlers, GetOuterHandlers, + GetTraceback, GetExecutionContext, GetHandlers, GetBoundaries, GetOuterHandlers, ) diff --git a/doeff/handler_utils.py b/doeff/handler_utils.py index 2ac9613c..1e4ead06 100644 --- a/doeff/handler_utils.py +++ b/doeff/handler_utils.py @@ -3,7 +3,7 @@ """ from doeff.do import do -from doeff.program import GetHandlers +from doeff.program import GetBoundaries, GetHandlers @do @@ -14,6 +14,10 @@ def get_inner_handlers(k): the effect — i.e., the caller). Returns the remaining inner handlers (innermost first). + NOTE: this captures prompt (WithHandler) boundaries only — WithObserve + observer boundaries are NOT included. Use get_inner_boundaries when the + reinstalled program must keep observers attached (e.g. Spawn). + Usage in a handler: inner_hs = yield get_inner_handlers(k) for h in inner_hs: @@ -23,3 +27,36 @@ def get_inner_handlers(k): if all_hs: return all_hs[:-1] return [] + + +@do +def get_inner_boundaries(k): + """Get the interleaved boundary stack from a continuation, excluding the caller. + + Yields GetBoundaries(k) — every handler (WithHandler) and observer + (WithObserve) boundary between the yield site and the handler that caught + the effect, innermost first — and drops the last entry (the catching + handler itself, i.e. the caller). Returns a list of + ("handler" | "observer", callable) tuples. + + Raises RuntimeError if the captured chain does not terminate at a handler + boundary — the catching handler must always be the outermost entry. + + Usage in a handler (reinstall preserving nesting order): + boundaries = yield get_inner_boundaries(k) + for kind, boundary_callable in boundaries: + if kind == "handler": + prog = handler(boundary_callable)(prog) + else: + prog = WithObserve(boundary_callable, prog) + """ + all_boundaries = yield GetBoundaries(k) + if not all_boundaries: + return [] + last_kind, _last_callable = all_boundaries[-1] + if last_kind != "handler": + raise RuntimeError( + "get_inner_boundaries: expected the catching handler as the last " + f"chain entry, got kind={last_kind!r}" + ) + return [(kind, boundary_callable) for kind, boundary_callable in all_boundaries[:-1]] diff --git a/doeff/program.py b/doeff/program.py index 33726323..bed721a1 100644 --- a/doeff/program.py +++ b/doeff/program.py @@ -9,6 +9,7 @@ from doeff_vm import Apply as Apply from doeff_vm import Expand as Expand +from doeff_vm import GetBoundaries as GetBoundaries from doeff_vm import GetExecutionContext as GetExecutionContext from doeff_vm import GetHandlers as GetHandlers from doeff_vm import GetOuterHandlers as GetOuterHandlers diff --git a/packages/doeff-core-effects/doeff_core_effects/scheduler.py b/packages/doeff-core-effects/doeff_core_effects/scheduler.py index 532022c4..e82532a8 100644 --- a/packages/doeff-core-effects/doeff_core_effects/scheduler.py +++ b/packages/doeff-core-effects/doeff_core_effects/scheduler.py @@ -21,20 +21,31 @@ def main(): run(scheduled(main())) """ +from doeff_vm import Callable as _VmCallable from doeff_vm import EffectBase, Err, Ok, TailEval +from doeff_vm import WithObserve as _WithObserveRaw from doeff.do import do -from doeff.handler_utils import get_inner_handlers +from doeff.handler_utils import get_inner_boundaries from doeff.program import Pass, Perform, Pure, Resume, Transfer from doeff.program import handler as _program_handler +def _reinstall_boundary(prog, kind, boundary_callable): + """Re-wrap prog with one boundary captured at the spawn site.""" + if kind == "handler": + return _program_handler(boundary_callable)(prog) + if kind == "observer": + return _WithObserveRaw(_VmCallable(boundary_callable), prog) + raise RuntimeError(f"unknown boundary kind: {kind!r}") + + def _enrich_exception_traceback(exc, task_meta=None, vm_ctx=None): """Build doeff traceback from VM execution context + task metadata. vm_ctx: from GetExecutionContext — fiber chain at error site (before unwinding). Contains ["frame", ...] and ["handler", ...] entries from the live fiber chain. - task_meta: scheduler task metadata with inner_handlers. + task_meta: scheduler task metadata with inner_boundaries. """ entries = [] @@ -302,12 +313,12 @@ def enqueue_resume(owner_tid, cont, value, priority=PRIORITY_NORMAL): def enqueue_raise(owner_tid, cont, error, priority=PRIORITY_NORMAL): enqueue(("raise", owner_tid, cont, error), priority) - def alloc_task(program, priority=PRIORITY_NORMAL, inner_handlers=None): + def alloc_task(program, priority=PRIORITY_NORMAL, inner_boundaries=None): tid = fresh_id() tasks[tid] = { "status": "pending", "result": None, "program": program, "priority": priority, - "inner_handlers": inner_handlers or [], + "inner_boundaries": inner_boundaries or [], } return tid @@ -389,9 +400,11 @@ def pick_next(): # noqa: PLR0912 - scheduler dispatch loop has one branch per r continue # skip cancelled tasks tasks[tid]["status"] = "running" prog = tasks[tid].pop("program") - # Re-wrap task with inner handlers captured at spawn site - for h in tasks[tid].pop("inner_handlers", []): - prog = _program_handler(h)(prog) + # Re-wrap task with the boundary stack (handlers AND + # observers) captured at the spawn site, innermost first — + # preserves handler/observer nesting order. + for kind, boundary_callable in tasks[tid].pop("inner_boundaries", []): + prog = _reinstall_boundary(prog, kind, boundary_callable) return make_handler(tid)(wrap_task(tid, prog)) if entry[0] == "resume": _, owner_tid, cont, value = entry @@ -434,14 +447,14 @@ def _release_task_refs(tid): """Drop heavy references from a terminal task. Keeps status/result (needed by Wait/Gather) but releases the - program, inner_handlers, and spawn_site closures that pin large + program, inner_boundaries, and spawn_site closures that pin large Python object graphs into memory. """ t = tasks.get(tid) if t is None: return t.pop("program", None) - t.pop("inner_handlers", None) + t.pop("inner_boundaries", None) t.pop("spawn_site", None) def resume_with_waitable_result(owner_tid, waiter_k, key): @@ -573,8 +586,10 @@ def raw_handler(effect, k): def handle_scheduler_effect(current_tid, effect, k): # noqa: PLR0911, PLR0912, PLR0915 - baseline cleanup keeps existing control flow unchanged drain() if isinstance(effect, Spawn): - # Capture inner handlers from continuation (between yield site and scheduler). - inner_handlers = yield get_inner_handlers(k) + # Capture the boundary stack (handlers AND WithObserve observers) + # from the continuation (between yield site and scheduler) so the + # spawned task keeps both — with nesting order preserved. + inner_boundaries = yield get_inner_boundaries(k) # Capture spawn site from continuation's traceback from doeff.program import GetTraceback @@ -585,7 +600,7 @@ def handle_scheduler_effect(current_tid, effect, k): # noqa: PLR0911, PLR0912, if isinstance(f, (list, tuple)) and len(f) >= 3: spawn_site = f"{f[0]} {f[1]}:{f[2]}" - tid = alloc_task(effect.program, effect.priority, inner_handlers=inner_handlers) + tid = alloc_task(effect.program, effect.priority, inner_boundaries=inner_boundaries) tasks[tid]["spawn_site"] = spawn_site enqueue(("new", tid), effect.priority) enqueue_resume(current_tid, k, Task(tid)) # spawner resumes at normal priority diff --git a/packages/doeff-vm-core/src/continuation.rs b/packages/doeff-vm-core/src/continuation.rs index 02af881b..568b4d1f 100644 --- a/packages/doeff-vm-core/src/continuation.rs +++ b/packages/doeff-vm-core/src/continuation.rs @@ -39,6 +39,16 @@ pub struct DetachedFiber { pub fiber: Fiber, } +/// Kind of callable boundary found while walking a detached fiber chain. +/// `Handler` = prompt boundary (WithHandler), `Observer` = intercept +/// boundary (WithObserve). Mask boundaries carry no callable and are +/// not reported. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BoundaryKind { + Handler, + Observer, +} + /// A body-through-boundary fiber chain owned by `Continuation`. #[derive(Debug)] pub struct DetachedFiberChain { @@ -126,6 +136,30 @@ impl DetachedFiberChain { handlers } + /// Collect the interleaved handler/observer boundary stack, innermost + /// first (chain head toward parents). The catching handler's prompt + /// boundary terminates the chain and is included as the last entry, + /// symmetric with `handler_callables`. + pub fn boundary_callables(&self) -> Vec<(BoundaryKind, CallableRef)> { + let mut boundaries = Vec::new(); + let mut cursor = Some(self.head); + + while let Some(fid) = cursor { + let Some(fiber) = self.fiber(fid) else { break }; + if let Some(handler) = &fiber.handler { + if let Some(prompt) = handler.prompt_boundary() { + boundaries.push((BoundaryKind::Handler, prompt.handler.clone())); + } + if let Some(intercept) = handler.intercept_boundary() { + boundaries.push((BoundaryKind::Observer, intercept.interceptor.clone())); + } + } + cursor = fiber.parent; + } + + boundaries + } + pub fn collect_rich_context(&self) -> Vec { let mut raw: Vec<(String, String, u32)> = Vec::new(); let mut first_boundary: Option = None; @@ -319,6 +353,12 @@ impl Continuation { self.chain.as_ref().map(DetachedFiberChain::handler_callables) } + pub fn boundary_callables(&self) -> Option> { + self.chain + .as_ref() + .map(DetachedFiberChain::boundary_callables) + } + pub fn collect_rich_context(&self) -> Option> { self.chain.as_ref().map(DetachedFiberChain::collect_rich_context) } diff --git a/packages/doeff-vm-core/src/lib.rs b/packages/doeff-vm-core/src/lib.rs index cf0e393f..94949686 100644 --- a/packages/doeff-vm-core/src/lib.rs +++ b/packages/doeff-vm-core/src/lib.rs @@ -49,7 +49,9 @@ mod vm_tests; #[cfg(feature = "python_bridge")] pub use arena::FiberArena; #[cfg(feature = "python_bridge")] -pub use continuation::{Continuation, OwnedControlContinuation, PendingContinuation, PyK}; +pub use continuation::{ + BoundaryKind, Continuation, OwnedControlContinuation, PendingContinuation, PyK, +}; #[cfg(feature = "python_bridge")] pub use do_ctrl::DoCtrl; #[cfg(feature = "python_bridge")] diff --git a/packages/doeff-vm/doeff_vm/__init__.py b/packages/doeff-vm/doeff_vm/__init__.py index 7fba1237..d9172205 100644 --- a/packages/doeff-vm/doeff_vm/__init__.py +++ b/packages/doeff-vm/doeff_vm/__init__.py @@ -28,6 +28,7 @@ GetTraceback = _ext.GetTraceback GetExecutionContext = _ext.GetExecutionContext GetHandlers = _ext.GetHandlers +GetBoundaries = _ext.GetBoundaries GetOuterHandlers = _ext.GetOuterHandlers TailEval = _ext.TailEval diff --git a/packages/doeff-vm/doeff_vm/__init__.pyi b/packages/doeff-vm/doeff_vm/__init__.pyi index 9943d067..e4005d42 100644 --- a/packages/doeff-vm/doeff_vm/__init__.pyi +++ b/packages/doeff-vm/doeff_vm/__init__.pyi @@ -130,6 +130,11 @@ class GetHandlers: def __init__(self, k: K) -> None: ... def __repr__(self) -> str: ... +class GetBoundaries: + continuation: K + def __init__(self, k: K) -> None: ... + def __repr__(self) -> str: ... + class GetOuterHandlers: def __init__(self) -> None: ... def __repr__(self) -> str: ... diff --git a/packages/doeff-vm/src/do_expr.rs b/packages/doeff-vm/src/do_expr.rs index dbd33e59..cf08b0f3 100644 --- a/packages/doeff-vm/src/do_expr.rs +++ b/packages/doeff-vm/src/do_expr.rs @@ -356,6 +356,32 @@ impl PyGetHandlers { } } +/// GetBoundaries(k) — extract the interleaved handler/observer boundary +/// stack from the continuation's fiber chain, innermost first. Each entry is +/// a `["handler" | "observer", callable]` pair. The catching handler is +/// included as the last entry, symmetric with GetHandlers(k). +/// +/// Used by the scheduler to reinstall the full spawn-site boundary stack — +/// handlers AND WithObserve observers, preserving their relative nesting +/// order — on Spawn'd tasks (issue scheduler-spawn-drops-observer-boundary). +#[pyclass(name = "GetBoundaries", frozen, dict, module = "doeff_vm.doeff_vm")] +pub struct PyGetBoundaries { + #[pyo3(get)] + pub continuation: Py, +} + +#[pymethods] +impl PyGetBoundaries { + #[new] + fn new(k: Py) -> Self { + Self { continuation: k } + } + + fn __repr__(&self) -> &'static str { + "GetBoundaries(k)" + } +} + /// GetOuterHandlers — extract handlers installed ABOVE the current handler. /// /// When a handler catches an effect, its segment's parent is detached from the diff --git a/packages/doeff-vm/src/python_generator_stream.rs b/packages/doeff-vm/src/python_generator_stream.rs index 85372786..fbfd34fc 100644 --- a/packages/doeff-vm/src/python_generator_stream.rs +++ b/packages/doeff-vm/src/python_generator_stream.rs @@ -490,6 +490,38 @@ fn continuation_handlers_value( Ok(Value::List(handlers)) } +fn continuation_boundaries_value( + py: Python<'_>, + k: &Py, + label: &str, +) -> Result { + let k_ref = k.bind(py); + let k_borrowed = k_ref.borrow(); + let Some(doeff_vm_core::OwnedControlContinuation::Started(k)) = k_borrowed.continuation_ref() + else { + return Err(format!( + "{}: continuation has no detached fiber chain", + label + )); + }; + let boundaries = k + .boundary_callables() + .ok_or_else(|| format!("{}: continuation has no detached fiber chain", label))? + .into_iter() + .map(|(kind, callable)| { + let kind_name = match kind { + doeff_vm_core::BoundaryKind::Handler => "handler", + doeff_vm_core::BoundaryKind::Observer => "observer", + }; + Value::List(vec![ + Value::String(kind_name.to_string()), + Value::Callable(callable), + ]) + }) + .collect(); + Ok(Value::List(boundaries)) +} + /// Wrap a handler callable as Value::Callable. fn wrap_handler(py: Python<'_>, handler: &Py) -> Value { let callable = PythonCallable::new(handler.clone_ref(py)); @@ -602,6 +634,10 @@ pub fn classify_python_object(py: Python<'_>, obj: &Bound<'_, PyAny>) -> Result< let value = continuation_handlers_value(py, &gh.get().continuation, "GetHandlers")?; return Ok(DoCtrl::Pure { value }); } + if let Ok(gb) = obj.downcast::() { + let value = continuation_boundaries_value(py, &gb.get().continuation, "GetBoundaries")?; + return Ok(DoCtrl::Pure { value }); + } if obj.downcast::().is_ok() { return Ok(DoCtrl::GetOuterHandlers); } diff --git a/packages/doeff-vm/src/pyvm.rs b/packages/doeff-vm/src/pyvm.rs index 16153785..2a5161ee 100644 --- a/packages/doeff-vm/src/pyvm.rs +++ b/packages/doeff-vm/src/pyvm.rs @@ -394,6 +394,7 @@ pub fn register_pyvm(m: &Bound<'_, pyo3::types::PyModule>) -> PyResult<()> { m.add_class::()?; m.add_class::()?; m.add_class::()?; + m.add_class::()?; m.add_class::()?; m.add_class::()?; Ok(()) diff --git a/tests/test_spawn_observer_inheritance.py b/tests/test_spawn_observer_inheritance.py new file mode 100644 index 00000000..c35f5b0f --- /dev/null +++ b/tests/test_spawn_observer_inheritance.py @@ -0,0 +1,241 @@ +"""Spawn must inherit WithObserve (observer) boundaries like handlers. + +Issue: scheduler-spawn-drops-observer-boundary + +Before this fix, Spawn captured inner *handlers* from the spawn-site +continuation (get_inner_handlers) and reinstalled them on the child task, +but observer boundaries (WithObserve) between the spawn site and the +scheduler were dropped: observers inside scheduled() never saw effects +performed by spawned tasks (silent drop — fail-fast violation for +observer-based instrumentation such as OpenTelemetry spans). + +The fix captures the full interleaved boundary stack (handlers AND +observers, innermost-first) via get_inner_boundaries / GetBoundaries(k) +and reinstalls both kinds on the child, preserving their relative +nesting order. +""" +import dataclasses + +from doeff_core_effects import Gather, Spawn, Wait, scheduled + +from doeff import EffectBase, Pass, Resume, WithObserve, do, handler, run + + +@dataclasses.dataclass(frozen=True) +class Mark(EffectBase): + label: str + + +@dataclasses.dataclass(frozen=True) +class HandlerNoise(EffectBase): + """Effect emitted from a handler *body* (not from user code).""" + + label: str + + +@handler +@do +def mark_handler(effect, k): + if isinstance(effect, Mark): + return (yield Resume(k, None)) + return (yield Pass(effect, k)) + + +@handler +@do +def noisy_mark_handler(effect, k): + """Handles Mark and emits HandlerNoise from its own body.""" + if isinstance(effect, Mark): + yield HandlerNoise(f"noise:{effect.label}") + return (yield Resume(k, None)) + return (yield Pass(effect, k)) + + +@handler +@do +def noise_sink_handler(effect, k): + if isinstance(effect, HandlerNoise): + return (yield Resume(k, None)) + return (yield Pass(effect, k)) + + +def make_observer(sink): + def observer(effect): + if isinstance(effect, (Mark, HandlerNoise)): + sink.append((type(effect).__name__, effect.label)) + + return observer + + +def observed_labels(sink, effect_name): + return [label for name, label in sink if name == effect_name] + + +@do +def child(): + yield Mark("child-effect") + return 1 + + +@do +def spawning_main(): + yield Mark("main-effect") + task = yield Spawn(child()) + result = yield Wait(task) + return result + + +def test_observer_inside_scheduler_sees_spawned_child_effects(): + """Repro case A from the issue: observer inside scheduled().""" + seen = [] + program = scheduled(WithObserve(make_observer(seen), mark_handler(spawning_main()))) + result = run(program) + assert result == 1 + marks = observed_labels(seen, "Mark") + assert "main-effect" in marks + assert "child-effect" in marks, ( + f"Spawn dropped the WithObserve boundary — observed only {marks}" + ) + + +def test_observer_outside_scheduler_still_sees_all(): + """Repro case B from the issue (worked before the fix): regression guard.""" + seen = [] + program = WithObserve(make_observer(seen), scheduled(mark_handler(spawning_main()))) + result = run(program) + assert result == 1 + marks = observed_labels(seen, "Mark") + assert "main-effect" in marks + assert "child-effect" in marks + + +def test_spawned_observer_does_not_see_handler_internal_effects(): + """Inheritance preserves the observer's nesting relative to handlers. + + Original nesting: noise_sink(noisy_mark(WithObserve(obs, main))) — the + observer sits BELOW noisy_mark_handler, so effects performed by that + handler's own body (HandlerNoise) are dispatched above the observer + boundary and must NOT be observed. The spawned child must reproduce the + same nesting: obs sees the child's Mark but not the handler's noise. + A flat "observers outermost" reinstall would over-observe here. + """ + seen = [] + program = scheduled( + noise_sink_handler( + noisy_mark_handler(WithObserve(make_observer(seen), spawning_main())) + ) + ) + result = run(program) + assert result == 1 + marks = observed_labels(seen, "Mark") + assert "main-effect" in marks + assert "child-effect" in marks + noise = observed_labels(seen, "HandlerNoise") + assert noise == [], ( + f"Observer must keep its position below noisy_mark_handler in the " + f"spawned child; it observed handler-internal effects: {noise}" + ) + + +@do +def labeled_child(label): + yield Mark(label) + return label + + +@do +def gather_main(): + task_1 = yield Spawn(labeled_child("child-1")) + task_2 = yield Spawn(labeled_child("child-2")) + results = yield Gather(task_1, task_2) + return list(results) + + +def test_gather_multiple_children_all_observed(): + seen = [] + program = scheduled(WithObserve(make_observer(seen), mark_handler(gather_main()))) + result = run(program) + assert result == ["child-1", "child-2"] + marks = observed_labels(seen, "Mark") + assert "child-1" in marks + assert "child-2" in marks + + +@do +def grandchild(): + yield Mark("grandchild-effect") + return 2 + + +@do +def spawning_child(): + yield Mark("child-effect") + task = yield Spawn(grandchild()) + result = yield Wait(task) + return result + + +@do +def nested_spawn_main(): + task = yield Spawn(spawning_child()) + result = yield Wait(task) + return result + + +def test_nested_spawn_inherits_observer_transitively(): + seen = [] + program = scheduled( + WithObserve(make_observer(seen), mark_handler(nested_spawn_main())) + ) + result = run(program) + assert result == 2 + marks = observed_labels(seen, "Mark") + assert "child-effect" in marks + assert "grandchild-effect" in marks + + +# --------------------------------------------------------------------------- +# Primitive: get_inner_boundaries / GetBoundaries(k) +# --------------------------------------------------------------------------- + +@dataclasses.dataclass(frozen=True) +class Probe(EffectBase): + pass + + +def make_probe_handler(captured): + from doeff.handler_utils import get_inner_boundaries + + @handler + @do + def probe_handler(effect, k): + if isinstance(effect, Probe): + boundaries = yield get_inner_boundaries(k) + captured.append(boundaries) + return (yield Resume(k, None)) + return (yield Pass(effect, k)) + + return probe_handler + + +def test_get_inner_boundaries_shape(): + """get_inner_boundaries returns the interleaved boundary stack + (innermost-first), tagged by kind, excluding the calling handler.""" + captured = [] + seen = [] + + @do + def probing_body(): + yield Probe() + return "done" + + program = make_probe_handler(captured)( + mark_handler(WithObserve(make_observer(seen), probing_body())) + ) + result = run(program) + assert result == "done" + assert len(captured) == 1 + boundaries = captured[0] + kinds = [kind for kind, _ in boundaries] + assert kinds == ["observer", "handler"], f"got {kinds}" + assert all(callable(entry) for _, entry in boundaries)