Skip to content

feat(spawn): spawn options — with baml.spawn.options(...), CancelToken, detach, TaskGroup (BEP-034) - #3596

Closed
antoniosarosi wants to merge 4 commits into
canaryfrom
antoniosarosi/Spawn-Options
Closed

antoniosarosi wants to merge 4 commits into
canaryfrom
antoniosarosi/Spawn-Options

Conversation

@antoniosarosi

@antoniosarosi antoniosarosi commented May 28, 2026 •

Copy link
Copy Markdown
Contributor

Implements the spawn-options core of BEP-034 — the with clause on spawn and the primitives it configures. Builds on the merged spawn/await/Future<T,E> foundation.

let tok = baml.spawn.CancelToken.new();
let group = baml.spawn.TaskGroup.new(limit = 5);
let f = spawn "fetch" with baml.spawn.options(group = group, cancel = tok, detach = false) {
    baml.http.fetch(url)
};
await f

What's included (4 commits)

  1. with baml.spawn.options(cancel = ...) + CancelToken — the with clause end-to-end (parser → AST → TIR → MIR → emit → VM → engine), the baml.spawn namespace, and CancelToken (new/any/cancel/is_cancelled). A user token links into a spawn's effective cancel token via a self-terminating watcher; firing it cancels the child and await re-throws Cancelled (catchable).
  2. detach = true + OmittedArg codegen fix — a detached spawn gets a fresh effective token (parent cascade/throw doesn't reach it) and routes unhandled fire-and-forget errors to the root task. Also fixes the codegen so optional builtin params (incl. primitives like bool?) accept an omitted argument (general improvement).
  3. TaskGroup rate limiting — TaskGroup.new(limit, name?), cancel(pending?, active?), set_limit, limit, name, active_count, queued_count, and options(group = ...). Members register synchronously at spawn (so cancel()/counts are exact immediately) and park asynchronously for a slot (FIFO, without holding the heap permit), releasing on settle. Admission is cancel-biased so a group.cancel()'d member never runs.
  4. Strict with validation + spawn_parallel fix — TIR rejects with expressions that aren't a baml.spawn.options(...) config (type-based, so let c = options(...); spawn with c works too). Also fixes spawn_parallel.rs, which was a false-positive (it used sys_ops, whose sleep is Unsupported, so its "parallel sleeps" were no-ops) — it now uses sys_native real sleeps and genuinely verifies concurrency.

Notable decisions / findings

  • baml.spawn collided with the spawn keyword → spawn/await are now accepted as member-path segments after . (mirrors the existing KW_CLIENT precedent), so baml.spawn.options(...) parses exactly as the BEP writes it.
  • Spawns do run concurrently for real IO — verified with execution-only timing: limit=3 runs three 200ms sleeps in ~200ms; limit=1 serializes them (~600ms).
  • Static methods (TaskGroup.new, CancelToken.new/any) use the existing non-self builtin-fn mechanism (like int.parse).

Testing

17 engine cancellation tests, 7 TaskGroup tests (+2 admission unit tests), ns_cancel_token/ns_task_group BAML fixtures, baml_src (1500+ BAML tests + bytecode/CLI targets), baml_tests --lib, plus parser/AST/MIR/emit/codegen suites — all green; clippy clean; snapshots regenerated.

Deferred (out of this scope)

Combinators (race/any/all/all_complete/all_settled) + Future.then(), the full user-middleware system (SpawnParams transformers), await-over-unions, shielding, Duration/CancelToken.after, and TaskGroup.on_event.

Branch is currently a few commits behind canary; will rebase before merge.

Summary by CodeRabbit

Release Notes

  • New Features

    • Added spawn ... with baml.spawn.options(...) syntax to configure spawned tasks
    • Introduced CancelToken for canceling spawned operations
    • Introduced TaskGroup for rate-limiting concurrent spawns with configurable concurrency caps
    • Added detach flag to allow spawned tasks to complete independently without blocking
  • Tests

    • Added spawn cancellation behavior validation
    • Added task group rate-limiting verification tests

Review Change Stack

…+ CancelToken (BEP-034)

First slice of BEP-034 "spawn options": the `with` clause on `spawn`, a new
`baml.spawn` namespace with a BAML-surface `CancelToken`
(`new`/`any`/`cancel`/`is_cancelled`), and `baml.spawn.options(group?, cancel?)`
producing an opaque `SpawnConfig`. A user cancel token links into a spawned
task's effective token via a self-terminating watcher; firing it (or an
`any`-composed token) cancels the child, settles its future `Cancelled`, and
`await` re-throws it (catchable).

- Surface: ns_spawn/spawn.baml (CancelToken, SpawnConfig, TaskGroup stub, options)
- Runtime: SpawnConfigData + UnscheduledFuture.config; engine reads the config's
  cancel token and links a watcher in spawn_thread; GC traces the config ptr.
- Compiler: parse `with expr (, expr)*`; Expr::Spawn.with_exprs; thread an
  optional config operand through TIR/MIR/emit and the Spawn bytecode (now pops
  [closure, name, config]).
- `spawn`/`await` are now valid as path segments after `.` so `baml.spawn.X`
  parses (mirrors the existing KW_CLIENT precedent).

Deferred: `detach` (needs an OmittedArg-for-primitive codegen fix) and strict
`with`-clause validation.

Tests: 4 engine cancellation tests + ns_cancel_token fixture; snapshots regenerated.
Adds `detach = true` to `baml.spawn.options(...)` and the codegen fix it
depends on.

- Codegen: `Value::is_omitted()`; the generated optional-arg extraction now
  treats an omitted argument (the `OmittedArg` sentinel) the same as `null`
  (→ `None`), so optional builtin params — including primitives like `bool?` —
  accept being left out. Unblocks `options(group?, cancel?, detach?)`.
- detach: `SpawnConfigData.detach`; a detached spawn gets a fresh effective
  cancel token (`CancellationToken::new()` instead of `parent.child_token()`),
  derived before the user-token watcher links, so the parent cascade / unhandled
  throw does not reach it. An explicit `cancel` token still applies.
- Root-error routing: `BexThread.root_pending_errors` is propagated unchanged
  down the spawn tree; a detached spawn routes its unhandled fire-and-forget
  error to the root task's queue (surfaces at the root's next `await`) rather
  than the spawner's.

Tests: 2 detach engine tests + a detach BAML fixture test; snapshots regenerated.
Adds `baml.spawn.TaskGroup` and `spawn ... with baml.spawn.options(group = g)`.
A group caps how many spawns referencing it run concurrently; excess spawns
queue FIFO and start as earlier ones settle (the `spawn` still returns its
`Future` immediately).

- Surface: `TaskGroup.new(limit, name?)`, `cancel(pending?, active?) -> int`,
  `set_limit`, `limit`, `name`, `active_count`, `queued_count`; `options(group=)`.
- Runtime: `bex_vm_types::TaskGroupInner` admission state (std `Mutex` + a
  per-waiter `oneshot`). Members register SYNCHRONOUSLY at spawn time (so
  `cancel()`/`active_count()`/`queued_count()` right after `spawn` are exact);
  the body task parks on a `TaskGroupTicket` for a slot — without the heap
  permit, so a queued task doesn't block GC — and releases on settle, waking
  the next FIFO waiter. The ticket's select is cancel-biased so a member
  cancelled via `group.cancel()` never runs. `set_limit` grows (drains
  waiters) / shrinks (no preempt) / `0` (pauses).
- Engine: `SpawnConfigData.group` threaded through `spawn_thread`; admission
  runs before `run_thread_event_loop`.

Verified spawns actually parallelize for real IO (execution-only timing):
limit=3 runs three 200ms sleeps in ~200ms; limit=1 serializes them (~600ms).

Tests: 7 engine tests (deterministic counts/cancel/set_limit + parallel/serial
timing) + 2 admission unit tests + a TaskGroup BAML fixture; snapshots regenerated.
…tive (BEP-034)

Completes the spawn-options core.

- TIR now requires each `spawn ... with` expression to produce a
  `baml.spawn.SpawnConfig` — i.e. a `baml.spawn.options(...)` call, or a
  variable bound to one (type-based, so it's forward-compatible) — and rejects
  more than one config. New `TirTypeError::SpawnWithMustBeSpawnConfig` /
  `SpawnWithTooManyConfigs`, mapped through the LSP diagnostic id table.
- Fix `baml_tests/tests/spawn_parallel.rs`: it used `sys_ops` whose `sleep` is
  `Unsupported`, so its "parallel sleeps" were no-ops caught by a `catch` — a
  false positive. It now uses `sys_native` (real `tokio::time::sleep`) and
  genuinely verifies three concurrent 200ms sleeps finish in ~200ms.

Tests: a negative `spawn with <non-options>` compile-rejection test; the fixed
spawn_parallel now exercises real concurrency.
@vercel

vercel Bot commented May 28, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
beps Ready Ready Preview, Comment May 28, 2026 11:34pm
promptfiddle Ready Ready Preview, Comment May 28, 2026 11:34pm
promptfiddle2 Ready Ready Preview, Comment May 28, 2026 11:34pm

Request Review

@coderabbitai

coderabbitai Bot commented May 28, 2026 •

Copy link
Copy Markdown
Contributor
📝 Walkthrough

Walkthrough

This PR implements BEP-034 spawn configuration, extending the spawn expression with an optional with clause to support spawn ... with baml.spawn.options(cancel: token?, group: group?, detach: bool?) { body }. The feature threads a SpawnConfig operand through the compiler pipeline and engine execution, enabling cancellation tokens, FIFO task-group rate limiting, and detached spawn semantics. New builtin types and VM-side implementations provide the user-facing API and runtime behavior.

Changes

Spawn Options Configuration

Layer / File(s) Summary
Parser & AST: spawn ... with syntax
baml_language/crates/baml_compiler_parser/src/parser.rs, baml_language/crates/baml_compiler2_ast/src/ast.rs, baml_language/crates/baml_compiler2_ast/src/lower_expr_body.rs
Parser recognizes optional with clause and comma-separated option expressions; Expr::Spawn AST variant adds with_exprs: Vec<ExprId> field; path/ident parsing allows spawn/await as dotted segments (e.g., baml.spawn.options).
Builtin API: SpawnConfig, CancelToken, TaskGroup
baml_language/crates/baml_builtins2/baml_std/baml/ns_spawn/spawn.baml, baml_language/crates/baml_builtins2/src/lib.rs
New BAML builtin file defines opaque SpawnConfig type, CancelToken class with creation/cancellation/composition via any(), and TaskGroup class with rate-limit control and cancellation; options(group?, cancel?, detach?) function assembles these into SpawnConfig.
Type checking: spawn config validation
baml_language/crates/baml_compiler2_tir/src/builder.rs, baml_language/crates/baml_compiler2_tir/src/infer_context.rs, baml_language/crates/baml_compiler2_tir/src/throws_analysis.rs
TIR builder validates that with clause contains exactly one baml.spawn.SpawnConfig expression, emitting SpawnWithTooManyConfigs and SpawnWithMustBeSpawnConfig errors for violations; throws-set analysis treats config/name as eagerly evaluated while spawn body remains non-escaping.
Compilation: AST → MIR with config operand
baml_language/crates/baml_compiler2_hir/src/builder.rs, baml_language/crates/baml_compiler2_mir/src/lower.rs, baml_language/crates/baml_compiler2_mir/src/ir.rs, baml_language/crates/baml_compiler2_mir/src/builder.rs, baml_language/crates/baml_compiler2_mir/src/optimize.rs, baml_language/crates/baml_compiler2_mir/src/pretty.rs
HIR traversal walks with_exprs; MIR lowering extracts first with_expr into config_op operand; Terminator::Spawn variant gains optional config: Option<Operand> field; optimization passes (collection, counting, propagation, dead-local elimination, verification) handle config operand.
Bytecode & stack: emit/simulate config
baml_language/crates/baml_compiler2_emit/src/emit.rs, baml_language/crates/baml_compiler2_emit/src/stack_carry.rs, baml_language/crates/baml_compiler2_emit/src/analysis.rs, baml_language/crates/baml_builtins2_codegen/src/codegen.rs
Emitter pushes three operands (closure, name, config) in order; stack-carry simulation models three-value pop; def-use tracking records config uses; optional-arg extraction treats OmittedArg sentinel as None alongside null.
VM types: UnscheduledFuture and config storage
baml_language/crates/bex_vm_types/src/types.rs, baml_language/crates/bex_heap/src/gc.rs, baml_language/crates/bex_vm/src/package_baml/root.rs
Value::is_omitted() predicate added; UnscheduledFuture extends with optional config: Option<HeapPtr> GC pointer; SpawnConfigData struct carries cancel token, detach flag, and task group; GC tracing/fixup updated to follow config pointer.
VM opcode: Spawn instruction pop & construct
baml_language/crates/bex_vm/src/vm.rs
OpCode::Spawn pops config value (null or Instance), type-checks, extracts config_ptr, and passes it into UnscheduledFuture during construction.
Task group runtime: FIFO concurrency limiter
baml_language/crates/bex_vm_types/src/task_group.rs, baml_language/crates/bex_vm_types/src/lib.rs
New TaskGroupInner implements concurrency cap with FIFO queuing: register() admits immediately or parks waiter; TaskGroupTicket::acquire() awaits grant with cancellation-biased unqueuing; set_limit() updates cap and promotes; cancel() signals members; dropping TaskGroupPermit promotes next waiter.
Spawn option builtins: CancelToken, TaskGroup, options()
baml_language/crates/bex_vm/src/package_baml/spawn.rs, baml_language/crates/bex_vm/Cargo.toml, baml_language/crates/bex_vm/src/package_baml/mod.rs
Native module implements CancelToken.new(), any(), cancel(), is_cancelled(); TaskGroup.new(), cancel(), set_limit(), accessors; options() builds SpawnConfig from group/cancel/detach; handles omitted args and target-dependent watcher spawning (tokio vs wasm).
Engine: root error queue for detached spawns
baml_language/crates/bex_engine/src/thread.rs
BexThread gains root_pending_errors field and new_child parameter; accessor vm_thread_root_errors_arc() exposes root queue for detached spawn error routing.
Engine: VmExecState::Spawn with options
baml_language/crates/bex_engine/src/lib.rs
VmExecState::Spawn reads config via read_spawn_config(), derives detach/cancel/group; selects error queues based on detach; refactored spawn_thread() accepts options, creates child token for detach, watcher-spawns user cancel token, registers with group, handles cancellation-while-queued.
BAML tests: cancel token and task group
baml_language/crates/baml_tests/baml_src/ns_cancel_token/cancel_token.baml, baml_language/crates/baml_tests/baml_src/ns_task_group/task_group.baml
Test helpers verify token-cancelled completion, catch handling, uncancelled normal flow, is_cancelled() reflection, detach compatibility; task-group tests assert cap admission, FIFO queuing, group-level cancellation, selective active/pending cancellation, limit updates.
Rust integration tests: engine-level behavior
baml_language/crates/bex_engine/tests/cancellation.rs, baml_language/crates/bex_engine/tests/task_group.rs
Comprehensive tests covering token cancellation before/during/never, Cancelled catch handling, CancelToken.any() composition, detach semantics, compile-time config-type validation; task-group tests validate admission timing, FIFO promotion, cancellation targeting, limit round-tripping, concurrency wall-clock measurements.
Test harness fix: spawn_parallel
baml_language/crates/baml_tests/tests/spawn_parallel.rs
Uses native sys-ops to ensure real sleep() and removes error-catching wrapper for accurate concurrency timing.
Diagnostics: error mapping and renderer
baml_language/crates/baml_lsp2_actions/src/check.rs, baml_language/crates/tools_onionskin/src/compiler.rs
LSP maps new spawn-config errors to TypeMismatch diagnostic; onionskin renderer updates Expr::Spawn pattern to include with_exprs.

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly Related PRs

  • BoundaryML/baml#3135: Overlaps the Terminator::Spawn stack-carry and emit refactoring machinery that this PR extends with the config operand.
  • BoundaryML/baml#3427: Introduces global futures scheduling that this PR extends with spawn option config threading through the cancellation/detach flow.
  • BoundaryML/baml#3520: Lays the config-free BEP-034 spawn/await foundation that this PR extends with with clause configuration.

🐰 A rabbit's spawn of cheer,
With tokens and groups, no fear!
Rate limits and grace,
At a measured pace—
Concurrency blooms this year! 🌸

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main feature being added: spawn options with a with clause supporting CancelToken, detach flag, and TaskGroup rate limiting as part of BEP-034.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch antoniosarosi/Spawn-Options
⚔️ Resolve merge conflicts
  • Resolve merge conflict in branch antoniosarosi/Spawn-Options

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@vercel
vercel Bot temporarily deployed to Preview – beps May 28, 2026 23:15 Inactive

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 7

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
baml_language/crates/baml_compiler_parser/src/parser.rs (1)

5089-5112: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

This widens dotted keywords only in expression paths, not in type paths.

parse_path_or_ident() now accepts .spawn / .await, but parse_type_primary() still requires TokenKind::Word after .. That means types introduced by this PR, like baml.spawn.CancelToken or baml.spawn.TaskGroup, will still fail in annotations even though the expression form parses.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@baml_language/crates/baml_compiler_parser/src/parser.rs` around lines 5089 -
5112, parse_type_primary currently only accepts TokenKind::Word after a dot, so
type paths like baml.spawn.CancelToken fail; update parse_type_primary to mirror
the same segment logic used in parse_path_or_ident (or extract the segment
predicate into a shared helper) to accept TokenKind::Client, TokenKind::Spawn,
and TokenKind::Await as valid path segments after a Dot, ensuring type path
parsing (e.g. in parse_type_primary) uses the same check as the segment closure
used when building SyntaxKind::PATH_EXPR.
🧹 Nitpick comments (4)
baml_language/crates/baml_compiler_parser/src/parser.rs (1)

4515-4551: ⚡ Quick win

Add parser unit coverage for the new spawn grammar.

Please add unit tests in this file for at least: a valid spawn ... with baml.spawn.options(...) { ... }, trailing-comma recovery before the body brace, missing-expression recovery after with, and dotted keyword segments like baml.spawn.CancelToken. These are parser-local behaviors and are best locked down here. As per coding guidelines: **/*.rs: Prefer writing Rust unit tests over integration tests where possible; Always run cargo test --lib if you changed any Rust code.

Also applies to: 5089-5112

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@baml_language/crates/baml_compiler_parser/src/parser.rs` around lines 4515 -
4551, Add unit tests covering the new spawn grammar in this parser module:
create tests that exercise parse_spawn_expr including a valid "spawn ... with
baml.spawn.options(...) { ... }" case, a case with a trailing comma before the
body brace to verify recovery, a missing-expression-after-`with` recovery case,
and dotted keyword segments like "baml.spawn.CancelToken" (these tests should
invoke the parser on snippet strings and assert the produced syntax kind is
SPAWN_EXPR and that error/recovery nodes are present as expected). Place tests
in the same Rust file as parse_spawn_expr (using #[cfg(test)] mod tests) and
ensure they call the parser entry used by parse_spawn_expr (so the code paths
using suppress_object_literal_depth, bump_contextual_with, and parse_expr_bp are
hit); run cargo test --lib to verify.
baml_language/crates/baml_builtins2/baml_std/baml/ns_spawn/spawn.baml (1)

104-112: 💤 Low value

Stale doc comment: TaskGroup is fully implemented in this PR.

The comment states group is "accepted for forward compatibility and wired in a follow-up," but according to the PR description, TaskGroup rate limiting is fully implemented in this changeset (Layer 8 implements the FIFO rate limiter, Layer 9 wires the VM builtins). Consider updating the comment to reflect the actual implementation status.

 /// Assemble a `SpawnConfig` for a `spawn ... with` clause. `cancel` links a
 /// cancel token into the spawn's effective token; `detach = true` opts the
 /// spawn out of the parent→child cancel cascade and routes its unhandled
-/// errors to the root task instead of the spawner. (`group` is accepted for
-/// forward compatibility and wired in a follow-up.)
+/// errors to the root task instead of the spawner. `group` registers the spawn
+/// with a `TaskGroup` for FIFO rate limiting.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@baml_language/crates/baml_builtins2/baml_std/baml/ns_spawn/spawn.baml` around
lines 104 - 112, The doc comment on the options function is stale about
TaskGroup not yet wired; update the comment for function options(group:
TaskGroup? = null, cancel: CancelToken? = null, detach: bool? = null) ->
SpawnConfig to reflect that TaskGroup is implemented and wired in this PR
(remove the "forward compatibility" note and state that group support and rate
limiting are active, and optionally mention FIFO rate limiter/Layers 8–9 as
implemented); keep the rest of the description about cancel and detach intact.
baml_language/crates/bex_heap/src/gc.rs (1)

1904-1908: ⚡ Quick win

Add one GC test that uses UnscheduledFuture.config = Some(...).

Current updated tests only use config: None, so they don’t directly prove tracing/fixup of non-null config pointers.

Proposed test adjustment
@@
         let closure = tlab.alloc_string("closure-stand-in".to_string());
         let name = tlab.alloc_string("spawn-name".to_string());
+        let config_obj = tlab.alloc(Object::RustData(Arc::new(
+            bex_vm_types::types::SpawnConfigData::default(),
+        )));
         let future_ptr = tlab.alloc(Object::UnscheduledFuture(UnscheduledFuture {
             closure,
             name: Some(name),
-            config: None,
+            config: Some(config_obj),
         }));
@@
-        // future + closure + name
-        assert_eq!(stats.live_count, 3);
+        // future + closure + name + config
+        assert_eq!(stats.live_count, 4);

Also applies to: 2421-2425

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@baml_language/crates/bex_heap/src/gc.rs` around lines 1904 - 1908, The tests
currently allocate UnscheduledFuture instances with UnscheduledFuture.config =
None, so they don't exercise pointer-tracing/fixup for non-null config fields;
update or add a GC test that allocates via tlab.alloc an
Object::UnscheduledFuture where UnscheduledFuture.config =
Some(some_heap_object) (use the same helper objects used elsewhere in the
tests), hold a reference to that config across a GC, trigger tracing/fixup, and
assert the config pointer is preserved/updated after collection; update both
occurrences around the future_ptr allocation (the block using tlab.alloc for
future_ptr and the similar block at the later 2421-2425 region) to include a
non-None config variant so tracing code for UnscheduledFuture.config is covered.
baml_language/crates/bex_vm/Cargo.toml (1)

35-45: Tokio select! on wasm32 is covered by features = ["macros"] (no rt needed); native still uses tokio::spawn. In bex_vm/src/package_baml/spawn.rs, the shared watcher uses tokio::select!, which only depends on the macros feature, while the wasm32 watcher is spawned via wasm_bindgen_futures::spawn_local (no tokio runtime usage). The remaining nit is redundancy: the native target adds ["rt", "macros"] even though macros is already enabled in the base tokio dependency.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@baml_language/crates/bex_vm/Cargo.toml` around lines 35 - 45, The Cargo.toml
currently enables tokio macros globally and also repeats "macros" in the native
target-specific tokio dependency; remove the redundant "macros" from the native
target entry so that the base tokio = { workspace = true, features = ["macros"]
} stays as-is and the target.'cfg(not(target_arch = "wasm32"))'.dependencies
tokio entry becomes tokio = { workspace = true, features = ["rt"] } (leave
wasm-bindgen-futures and the wasm target block unchanged).
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@baml_language/crates/baml_compiler_parser/src/parser.rs`:
- Around line 4538-4551: The parser currently always calls p.parse_expr_bp(1)
after bump_contextual_with(), which lets a following `{` be consumed as the
config expression and eat the spawn body; instead, guard the config parse so it
does not run when the next token is a left brace: after p.bump_contextual_with()
check p.at(TokenKind::LBrace) and, if true, report a missing/empty config (or
recover) and skip parsing the expression so the body remains intact; only adjust
p.suppress_object_literal_depth and call p.parse_expr_bp(1) when the next token
is not TokenKind::LBrace, keeping the rest of the while p.eat(TokenKind::Comma)
logic unchanged to preserve comma handling for real config expressions in
SPAWN_EXPR.

In `@baml_language/crates/baml_compiler2_tir/src/builder.rs`:
- Around line 3226-3239: The check for SpawnConfig uses cfg_ty directly and
fails for type aliases; before computing is_spawn_config you should expand type
aliases on cfg_ty (e.g., resolve through whatever alias-expansion helper or call
you have) and then match the expanded type against Ty::Class to detect
baml.spawn.SpawnConfig; update the code that computes is_spawn_config (and keep
unresolved logic) to use the expanded type so
report_simple(TirTypeError::SpawnWithMustBeSpawnConfig, *with_id) is only
emitted when the underlying (expanded) type is not SpawnConfig.

In `@baml_language/crates/bex_engine/tests/cancellation.rs`:
- Around line 933-936: The test mutates the global panic hook
(take_hook/set_hook) which can interfere with other concurrent tokio tests;
remove the hook mutation and instead rely on std::panic::catch_unwind around the
call to compile_for_engine(bad) (or run the panic-producing call in an isolated
subprocess if you must suppress diagnostics), so do not call
std::panic::take_hook or std::panic::set_hook in cancellation.rs and keep only
the catch_unwind wrapping compile_for_engine to observe the panic payload.

In `@baml_language/crates/bex_vm_types/src/task_group.rs`:
- Around line 68-78: TaskGroupTicket currently leaks group state when dropped;
implement ownership cleanup by adding a Drop for TaskGroupTicket that
unregisters the ticket from TaskGroupInner unless the ticket has already been
consumed by a successful acquire(); modify TaskGroupTicket.acquire() to
atomically mark the ticket as consumed (e.g., set rx to None or flip a
bool/AtomicBool) and return the permit/guard that becomes responsible for
decrementing active on its Drop, and add an internal
TaskGroupInner::unregister(id) (or call the existing decrement path) used by
Drop to decrement active/cancel queued state for tickets that were never
acquired; ensure both the parked (rx/ cancel) and immediately-admitted paths set
the consumed flag so Drop does not double-unregister.
- Around line 156-163: set_limit currently calls Self::promote(&mut st) which
only grants one waiter because promote returns after the first success; update
promote (or add a new loop in set_limit) to repeatedly promote waiters until
either st.limit == st.active or no more waiters remain so widening the cap from
1 to N fills all spare slots immediately; modify the logic in promote
(referencing the promote function) to not break after the first grant (or call
promote in a loop from set_limit) and ensure it respects paused (limit==0)
behavior. Also add a unit test that constructs a TaskGroup, sets limit=1, parks
enough waiters to exceed 1, then calls set_limit to a value > active and asserts
that the group’s active count increases to the new limit (and all eligible
waiters are admitted) right after set_limit returns.

In `@baml_language/crates/bex_vm/src/package_baml/spawn.rs`:
- Around line 84-109: The watchers can leak because
CancellationToken::cancelled() doesn't fire when the last clone is dropped; fix
by creating a CancellationTokenDropGuard (via CancellationToken::drop_guard())
for the composite token and store that guard with the returned VM token so
dropping the VM handle triggers cancel(); specifically, in any() create let
guard = composite.drop_guard() (or CancellationToken::drop_guard(&composite))
and pass/store that guard into alloc_cancel_token (or adjust alloc_cancel_token
to accept and retain a CancellationTokenDropGuard alongside the
CancellationToken), leaving watcher logic unchanged so out.cancel() still
cancels all watchers when the guard is dropped.

In `@baml_language/crates/bex_vm/src/vm.rs`:
- Around line 3934-3944: The code currently accepts any heap object for
config_value via config_value.as_object_ptr() even when the expected type is an
Instance; update the branch that sets config_ptr (in vm.rs around the
config_value handling) to check the actual heap object's kind is
ObjectType::Instance before returning Some(ptr), and if it is not an Instance
return Err(VmInternalError::TypeError { expected:
Type::Object(ObjectType::Instance), got: self.type_of(&config_value) }.into());
ensure you use the VM/heap API that retrieves the object kind by pointer (the
same mechanism used elsewhere for type checks) so malformed bytecode cannot pass
non-Instance objects into UnscheduledFuture.config.

---

Outside diff comments:
In `@baml_language/crates/baml_compiler_parser/src/parser.rs`:
- Around line 5089-5112: parse_type_primary currently only accepts
TokenKind::Word after a dot, so type paths like baml.spawn.CancelToken fail;
update parse_type_primary to mirror the same segment logic used in
parse_path_or_ident (or extract the segment predicate into a shared helper) to
accept TokenKind::Client, TokenKind::Spawn, and TokenKind::Await as valid path
segments after a Dot, ensuring type path parsing (e.g. in parse_type_primary)
uses the same check as the segment closure used when building
SyntaxKind::PATH_EXPR.

---

Nitpick comments:
In `@baml_language/crates/baml_builtins2/baml_std/baml/ns_spawn/spawn.baml`:
- Around line 104-112: The doc comment on the options function is stale about
TaskGroup not yet wired; update the comment for function options(group:
TaskGroup? = null, cancel: CancelToken? = null, detach: bool? = null) ->
SpawnConfig to reflect that TaskGroup is implemented and wired in this PR
(remove the "forward compatibility" note and state that group support and rate
limiting are active, and optionally mention FIFO rate limiter/Layers 8–9 as
implemented); keep the rest of the description about cancel and detach intact.

In `@baml_language/crates/baml_compiler_parser/src/parser.rs`:
- Around line 4515-4551: Add unit tests covering the new spawn grammar in this
parser module: create tests that exercise parse_spawn_expr including a valid
"spawn ... with baml.spawn.options(...) { ... }" case, a case with a trailing
comma before the body brace to verify recovery, a
missing-expression-after-`with` recovery case, and dotted keyword segments like
"baml.spawn.CancelToken" (these tests should invoke the parser on snippet
strings and assert the produced syntax kind is SPAWN_EXPR and that
error/recovery nodes are present as expected). Place tests in the same Rust file
as parse_spawn_expr (using #[cfg(test)] mod tests) and ensure they call the
parser entry used by parse_spawn_expr (so the code paths using
suppress_object_literal_depth, bump_contextual_with, and parse_expr_bp are hit);
run cargo test --lib to verify.

In `@baml_language/crates/bex_heap/src/gc.rs`:
- Around line 1904-1908: The tests currently allocate UnscheduledFuture
instances with UnscheduledFuture.config = None, so they don't exercise
pointer-tracing/fixup for non-null config fields; update or add a GC test that
allocates via tlab.alloc an Object::UnscheduledFuture where
UnscheduledFuture.config = Some(some_heap_object) (use the same helper objects
used elsewhere in the tests), hold a reference to that config across a GC,
trigger tracing/fixup, and assert the config pointer is preserved/updated after
collection; update both occurrences around the future_ptr allocation (the block
using tlab.alloc for future_ptr and the similar block at the later 2421-2425
region) to include a non-None config variant so tracing code for
UnscheduledFuture.config is covered.

In `@baml_language/crates/bex_vm/Cargo.toml`:
- Around line 35-45: The Cargo.toml currently enables tokio macros globally and
also repeats "macros" in the native target-specific tokio dependency; remove the
redundant "macros" from the native target entry so that the base tokio = {
workspace = true, features = ["macros"] } stays as-is and the
target.'cfg(not(target_arch = "wasm32"))'.dependencies tokio entry becomes tokio
= { workspace = true, features = ["rt"] } (leave wasm-bindgen-futures and the
wasm target block unchanged).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: bfb670ed-4f24-4799-8d68-3d119bf7ef73

📥 Commits

Reviewing files that changed from the base of the PR and between 4bcecdb and 469640c.

⛔ Files ignored due to path filters (114)
  • baml_language/Cargo.lock is excluded by !**/*.lock
  • baml_language/crates/baml_tests/snapshots/baml_src/_root.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/baml_src/arrays.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/baml_src/assignments.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/baml_src/bigints.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/baml_src/builtins.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/baml_src/byte_strings.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/baml_src/cancel_cascade.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/baml_src/cancel_token.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/baml_src/class_type_args_at_runtime.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/baml_src/classes.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/baml_src/closures.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/baml_src/deep_copy.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/baml_src/enums.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/baml_src/env.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/baml_src/exceptions.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/baml_src/floats.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/baml_src/for_loops.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/baml_src/fs.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/baml_src/functions.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/baml_src/future_methods.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/baml_src/glob.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/baml_src/if_else.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/baml_src/ints.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/baml_src/is_operator.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/baml_src/json_alias.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/baml_src/json_auto_derive.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/baml_src/json_parse_stringify.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/baml_src/json_to_from_string.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/baml_src/lambdas.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/baml_src/lexical_scoping.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/baml_src/maps.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/baml_src/match_basics.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/baml_src/match_optimization.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/baml_src/match_types.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/baml_src/null_handling.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/baml_src/operators.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/baml_src/optional_function_parameters.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/baml_src/patterns_new_runtime.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/baml_src/reflect_type_of.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/baml_src/reflect_type_of_generic.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/baml_src/shell.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/baml_src/soundness.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/baml_src/spawn_basic.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/baml_src/spawn_name_object.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/baml_src/spawn_semantics.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/baml_src/spawn_throws.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/baml_src/strings.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/baml_src/task_group.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/baml_src/type_error_repro.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/baml_src/type_reflection.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/baml_src/typed_inputs.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/baml_src/typed_outputs.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/baml_src/watch.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/baml_src/while_loops.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/__baml_std__/baml_tests__compiles____baml_std____03_hir.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/__baml_std__/baml_tests__compiles____baml_std____04_5_mir.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/__baml_std__/baml_tests__compiles____baml_std____04_tir.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/__baml_std__/baml_tests__compiles____baml_std____06_codegen.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/test_expr_basic/baml_tests__compiles__test_expr_basic__03_hir.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/test_expr_basic/baml_tests__compiles__test_expr_basic__04_5_mir.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/test_expr_basic/baml_tests__compiles__test_expr_basic__04_tir.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/test_expr_basic/baml_tests__compiles__test_expr_basic__06_codegen.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/test_expr_name_concat/baml_tests__compiles__test_expr_name_concat__03_hir.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/test_expr_name_concat/baml_tests__compiles__test_expr_name_concat__04_5_mir.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/test_expr_name_concat/baml_tests__compiles__test_expr_name_concat__04_tir.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/test_expr_name_concat/baml_tests__compiles__test_expr_name_concat__06_codegen.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/test_expr_throwing_body/baml_tests__compiles__test_expr_throwing_body__03_hir.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/test_expr_throwing_body/baml_tests__compiles__test_expr_throwing_body__04_5_mir.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/test_expr_throwing_body/baml_tests__compiles__test_expr_throwing_body__04_tir.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/test_expr_throwing_body/baml_tests__compiles__test_expr_throwing_body__06_codegen.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/test_old_and_new/baml_tests__compiles__test_old_and_new__03_hir.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/test_old_and_new/baml_tests__compiles__test_old_and_new__04_5_mir.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/test_old_and_new/baml_tests__compiles__test_old_and_new__04_tir.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/test_old_and_new/baml_tests__compiles__test_old_and_new__06_codegen.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/test_raw_string_name/baml_tests__compiles__test_raw_string_name__03_hir.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/test_raw_string_name/baml_tests__compiles__test_raw_string_name__04_5_mir.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/test_raw_string_name/baml_tests__compiles__test_raw_string_name__04_tir.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/test_raw_string_name/baml_tests__compiles__test_raw_string_name__06_codegen.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/test_with_not_keyword/baml_tests__compiles__test_with_not_keyword__03_hir.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/test_with_not_keyword/baml_tests__compiles__test_with_not_keyword__04_5_mir.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/test_with_not_keyword/baml_tests__compiles__test_with_not_keyword__04_tir.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/test_with_not_keyword/baml_tests__compiles__test_with_not_keyword__06_codegen.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/testset_basic/baml_tests__compiles__testset_basic__03_hir.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/testset_basic/baml_tests__compiles__testset_basic__04_5_mir.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/testset_basic/baml_tests__compiles__testset_basic__04_tir.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/testset_basic/baml_tests__compiles__testset_basic__06_codegen.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/testset_dynamic/baml_tests__compiles__testset_dynamic__03_hir.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/testset_dynamic/baml_tests__compiles__testset_dynamic__04_5_mir.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/testset_dynamic/baml_tests__compiles__testset_dynamic__04_tir.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/testset_dynamic/baml_tests__compiles__testset_dynamic__06_codegen.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/testset_nested/baml_tests__compiles__testset_nested__03_hir.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/testset_nested/baml_tests__compiles__testset_nested__04_5_mir.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/testset_nested/baml_tests__compiles__testset_nested__04_tir.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/testset_nested/baml_tests__compiles__testset_nested__06_codegen.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/testset_vibes_nested/baml_tests__compiles__testset_vibes_nested__03_hir.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/testset_vibes_nested/baml_tests__compiles__testset_vibes_nested__04_5_mir.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/testset_vibes_nested/baml_tests__compiles__testset_vibes_nested__04_tir.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/testset_vibes_nested/baml_tests__compiles__testset_vibes_nested__06_codegen.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/testset_with_setup/baml_tests__compiles__testset_with_setup__03_hir.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/testset_with_setup/baml_tests__compiles__testset_with_setup__04_5_mir.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/testset_with_setup/baml_tests__compiles__testset_with_setup__04_tir.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/testset_with_setup/baml_tests__compiles__testset_with_setup__06_codegen.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/diagnostic_errors/test_expr_name_type_error/baml_tests__diagnostic_errors__test_expr_name_type_error__03_hir.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/diagnostic_errors/test_expr_name_type_error/baml_tests__diagnostic_errors__test_expr_name_type_error__04_tir.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/diagnostic_errors/test_expr_with_runner/baml_tests__diagnostic_errors__test_expr_with_runner__03_hir.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/diagnostic_errors/test_expr_with_runner/baml_tests__diagnostic_errors__test_expr_with_runner__04_tir.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/diagnostic_errors/test_expr_wrong_runner/baml_tests__diagnostic_errors__test_expr_wrong_runner__03_hir.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/diagnostic_errors/test_expr_wrong_runner/baml_tests__diagnostic_errors__test_expr_wrong_runner__04_tir.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/diagnostic_errors/test_with_runner_ambiguity/baml_tests__diagnostic_errors__test_with_runner_ambiguity__03_hir.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/diagnostic_errors/test_with_runner_ambiguity/baml_tests__diagnostic_errors__test_with_runner_ambiguity__04_tir.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/src/compiler2_tir/snapshots/baml_tests__compiler2_tir__phase5__snapshot_baml_package_items.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/tests/bytecode_format/snapshots/bytecode_format__bytecode_display_expanded.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/tests/bytecode_format/snapshots/bytecode_format__bytecode_display_expanded_unoptimized.snap is excluded by !**/*.snap
📒 Files selected for processing (36)
  • baml_language/crates/baml_builtins2/baml_std/baml/ns_spawn/spawn.baml
  • baml_language/crates/baml_builtins2/src/lib.rs
  • baml_language/crates/baml_builtins2_codegen/src/codegen.rs
  • baml_language/crates/baml_compiler2_ast/src/ast.rs
  • baml_language/crates/baml_compiler2_ast/src/lower_expr_body.rs
  • baml_language/crates/baml_compiler2_emit/src/analysis.rs
  • baml_language/crates/baml_compiler2_emit/src/emit.rs
  • baml_language/crates/baml_compiler2_emit/src/stack_carry.rs
  • baml_language/crates/baml_compiler2_hir/src/builder.rs
  • baml_language/crates/baml_compiler2_mir/src/builder.rs
  • baml_language/crates/baml_compiler2_mir/src/ir.rs
  • baml_language/crates/baml_compiler2_mir/src/lower.rs
  • baml_language/crates/baml_compiler2_mir/src/optimize.rs
  • baml_language/crates/baml_compiler2_mir/src/pretty.rs
  • baml_language/crates/baml_compiler2_tir/src/builder.rs
  • baml_language/crates/baml_compiler2_tir/src/infer_context.rs
  • baml_language/crates/baml_compiler2_tir/src/throws_analysis.rs
  • baml_language/crates/baml_compiler_parser/src/parser.rs
  • baml_language/crates/baml_lsp2_actions/src/check.rs
  • baml_language/crates/baml_tests/baml_src/ns_cancel_token/cancel_token.baml
  • baml_language/crates/baml_tests/baml_src/ns_task_group/task_group.baml
  • baml_language/crates/baml_tests/tests/spawn_parallel.rs
  • baml_language/crates/bex_engine/src/lib.rs
  • baml_language/crates/bex_engine/src/thread.rs
  • baml_language/crates/bex_engine/tests/cancellation.rs
  • baml_language/crates/bex_engine/tests/task_group.rs
  • baml_language/crates/bex_heap/src/gc.rs
  • baml_language/crates/bex_vm/Cargo.toml
  • baml_language/crates/bex_vm/src/package_baml/mod.rs
  • baml_language/crates/bex_vm/src/package_baml/root.rs
  • baml_language/crates/bex_vm/src/package_baml/spawn.rs
  • baml_language/crates/bex_vm/src/vm.rs
  • baml_language/crates/bex_vm_types/src/lib.rs
  • baml_language/crates/bex_vm_types/src/task_group.rs
  • baml_language/crates/bex_vm_types/src/types.rs
  • baml_language/crates/tools_onionskin/src/compiler.rs

Comment on lines +4538 to +4551
// Optional `with expr (, expr)*` clause. Suppress object literals
// for the same reason as the name: keep the body brace available.
if p.at_contextual_kw("with") {
p.bump_contextual_with();
p.suppress_object_literal_depth += 1;
p.parse_expr_bp(1);
while p.eat(TokenKind::Comma) {
// Tolerate a trailing comma before the body brace.
if p.at(TokenKind::LBrace) {
break;
}
p.parse_expr_bp(1);
}
p.suppress_object_literal_depth -= 1;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Don't consume the spawn body when with has no config expression.

If the next token after with is {, parse_expr_bp(1) will parse the body block as the config expression. That turns a simple syntax error into a malformed SPAWN_EXPR and loses recovery for the real body.

Suggested fix
             if p.at_contextual_kw("with") {
                 p.bump_contextual_with();
-                p.suppress_object_literal_depth += 1;
-                p.parse_expr_bp(1);
-                while p.eat(TokenKind::Comma) {
-                    // Tolerate a trailing comma before the body brace.
-                    if p.at(TokenKind::LBrace) {
-                        break;
-                    }
-                    p.parse_expr_bp(1);
-                }
-                p.suppress_object_literal_depth -= 1;
+                if p.at(TokenKind::LBrace) {
+                    p.error_unexpected_token("spawn config expression after 'with'".to_string());
+                } else {
+                    p.suppress_object_literal_depth += 1;
+                    p.parse_expr_bp(1);
+                    while p.eat(TokenKind::Comma) {
+                        // Tolerate a trailing comma before the body brace.
+                        if p.at(TokenKind::LBrace) {
+                            break;
+                        }
+                        p.parse_expr_bp(1);
+                    }
+                    p.suppress_object_literal_depth -= 1;
+                }
             }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@baml_language/crates/baml_compiler_parser/src/parser.rs` around lines 4538 -
4551, The parser currently always calls p.parse_expr_bp(1) after
bump_contextual_with(), which lets a following `{` be consumed as the config
expression and eat the spawn body; instead, guard the config parse so it does
not run when the next token is a left brace: after p.bump_contextual_with()
check p.at(TokenKind::LBrace) and, if true, report a missing/empty config (or
recover) and skip parsing the expression so the body remains intact; only adjust
p.suppress_object_literal_depth and call p.parse_expr_bp(1) when the next token
is not TokenKind::LBrace, keeping the rest of the while p.eat(TokenKind::Comma)
logic unchanged to preserve comma handling for real config expressions in
SPAWN_EXPR.

Comment on lines +3226 to +3239
let is_spawn_config = matches!(
&cfg_ty,
Ty::Class(qn, _, _)
if qn.package().as_str() == "baml"
&& qn.namespace().len() == 1
&& qn.namespace()[0].as_str() == "spawn"
&& qn.name().as_str() == "SpawnConfig"
);
// Don't double-report on an expression that already failed
// to type-check.
let unresolved = matches!(cfg_ty, Ty::Unknown { .. } | Ty::Error { .. });
if !is_spawn_config && !unresolved {
self.context
.report_simple(TirTypeError::SpawnWithMustBeSpawnConfig, *with_id);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Expand aliases before SpawnConfig validation.

Line 3226 currently checks only a direct Ty::Class(...) shape. A with value typed via a type alias to baml.spawn.SpawnConfig can be incorrectly rejected with SpawnWithMustBeSpawnConfig.

💡 Suggested fix
                 for (idx, with_id) in with_exprs.iter().enumerate() {
                     let cfg_ty = self.infer_expr(*with_id, body);
                     if idx >= 1 {
                         self.context
                             .report_simple(TirTypeError::SpawnWithTooManyConfigs, *with_id);
                         continue;
                     }
+                    let cfg_ty = self.expand_alias_chains(cfg_ty);
                     let is_spawn_config = matches!(
                         &cfg_ty,
                         Ty::Class(qn, _, _)
                             if qn.package().as_str() == "baml"
                                 && qn.namespace().len() == 1
                                 && qn.namespace()[0].as_str() == "spawn"
                                 && qn.name().as_str() == "SpawnConfig"
                     );
                     // Don't double-report on an expression that already failed
                     // to type-check.
                     let unresolved = matches!(cfg_ty, Ty::Unknown { .. } | Ty::Error { .. });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
let is_spawn_config = matches!(
&cfg_ty,
Ty::Class(qn, _, _)
if qn.package().as_str() == "baml"
&& qn.namespace().len() == 1
&& qn.namespace()[0].as_str() == "spawn"
&& qn.name().as_str() == "SpawnConfig"
);
// Don't double-report on an expression that already failed
// to type-check.
let unresolved = matches!(cfg_ty, Ty::Unknown { .. } | Ty::Error { .. });
if !is_spawn_config && !unresolved {
self.context
.report_simple(TirTypeError::SpawnWithMustBeSpawnConfig, *with_id);
let cfg_ty = self.expand_alias_chains(cfg_ty);
let is_spawn_config = matches!(
&cfg_ty,
Ty::Class(qn, _, _)
if qn.package().as_str() == "baml"
&& qn.namespace().len() == 1
&& qn.namespace()[0].as_str() == "spawn"
&& qn.name().as_str() == "SpawnConfig"
);
// Don't double-report on an expression that already failed
// to type-check.
let unresolved = matches!(cfg_ty, Ty::Unknown { .. } | Ty::Error { .. });
if !is_spawn_config && !unresolved {
self.context
.report_simple(TirTypeError::SpawnWithMustBeSpawnConfig, *with_id);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@baml_language/crates/baml_compiler2_tir/src/builder.rs` around lines 3226 -
3239, The check for SpawnConfig uses cfg_ty directly and fails for type aliases;
before computing is_spawn_config you should expand type aliases on cfg_ty (e.g.,
resolve through whatever alias-expansion helper or call you have) and then match
the expanded type against Ty::Class to detect baml.spawn.SpawnConfig; update the
code that computes is_spawn_config (and keep unresolved logic) to use the
expanded type so report_simple(TirTypeError::SpawnWithMustBeSpawnConfig,
*with_id) is only emitted when the underlying (expanded) type is not
SpawnConfig.

Comment on lines +933 to +936
let prev = std::panic::take_hook();
std::panic::set_hook(Box::new(|_| {})); // suppress the diagnostic backtrace
let compiled = std::panic::catch_unwind(|| compile_for_engine(bad));
std::panic::set_hook(prev);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify all global panic-hook mutations in this test area.
rg -n -C2 'std::panic::(take_hook|set_hook|catch_unwind)' baml_language/crates/bex_engine/tests/cancellation.rs

Repository: BoundaryML/baml

Length of output: 375


🏁 Script executed:

#!/bin/bash
set -euo pipefail
FILE="baml_language/crates/bex_engine/tests/cancellation.rs"

echo "== hook mutations =="
rg -n 'std::panic::(take_hook|set_hook|catch_unwind)' "$FILE" -C1

echo
echo "== test declarations =="
rg -n '#\[(test|tokio::test)\]' "$FILE"

echo
echo "== serial_test usage =="
rg -n 'serial_test|#[[:space:]]*serial' "$FILE" || true

echo
echo "== context around lines 920-950 =="
nl -ba "$FILE" | sed -n '900,960p'

Repository: BoundaryML/baml

Length of output: 813


🏁 Script executed:

#!/bin/bash
set -euo pipefail
FILE="baml_language/crates/bex_engine/tests/cancellation.rs"

echo "== hook mutations =="
rg -n 'std::panic::(take_hook|set_hook|catch_unwind)' "$FILE" -C1

echo
echo "== test declarations =="
rg -n '#\[(test|tokio::test)\]' "$FILE"

echo
echo "== serial_test usage =="
rg -n 'serial_test|#[[:space:]]*serial' "$FILE" || true

echo
echo "== context around lines 920-950 =="
nl -ba "$FILE" | sed -n '900,960p'

Repository: BoundaryML/baml

Length of output: 813


🏁 Script executed:

#!/bin/bash
set -euo pipefail
FILE="baml_language/crates/bex_engine/tests/cancellation.rs"

echo "== hook mutations =="
rg -n 'std::panic::(take_hook|set_hook|catch_unwind)' "$FILE" -C1

echo
echo "== test declarations =="
rg -n '#\[(test|tokio::test)\]' "$FILE"

echo
echo "== serial_test usage =="
rg -n 'serial_test|#[[:space:]]*serial' "$FILE" || true

echo
echo "== context around lines 920-950 =="
nl -ba "$FILE" | sed -n '900,960p'

Repository: BoundaryML/baml

Length of output: 813


🏁 Script executed:

#!/bin/bash
set -euo pipefail
FILE="baml_language/crates/bex_engine/tests/cancellation.rs"

echo "== hook mutations =="
rg -n 'std::panic::(take_hook|set_hook|catch_unwind)' "$FILE" -C1

echo
echo "== test declarations =="
rg -n '#\[(test|tokio::test)\]' "$FILE"

echo
echo "== serial_test usage =="
rg -n 'serial_test|#[[:space:]]*serial' "$FILE" || true

echo
echo "== context around lines 900-960 =="
nl -ba "$FILE" | sed -n '900,960p'

Repository: BoundaryML/baml

Length of output: 813


🏁 Script executed:

#!/bin/bash
set -euo pipefail
FILE="baml_language/crates/bex_engine/tests/cancellation.rs"

echo "== context around hook mutation (lines ~910-945) =="
awk 'NR>=900 && NR<=950 {printf "%d:%s\n", NR, $0}' "$FILE"

echo
echo "== serial_test usage in file =="
rg -n 'serial_test|#[[:space:]]*serial' "$FILE" || true

echo
echo "== panic hook mutations in this crate tests =="
rg -n 'std::panic::(take_hook|set_hook)' baml_language/crates/bex_engine || true

echo
echo "== panic hook mutations repo-wide (scoped to baml_language) =="
rg -n 'std::panic::(take_hook|set_hook)' baml_language || true

Repository: BoundaryML/baml

Length of output: 2662


Avoid mutating the global std::panic hook in concurrent tests (cancellation.rs).
In baml_language/crates/bex_engine/tests/cancellation.rs (lines 933–936), the test swaps the process-wide panic hook via std::panic::take_hook / std::panic::set_hook to suppress diagnostics. The file contains many #[tokio::test] cases and no serialization, so this can interfere with other concurrently running tests and change/suppress their panic output.

Suggested fix
-    let prev = std::panic::take_hook();
-    std::panic::set_hook(Box::new(|_| {})); // suppress the diagnostic backtrace
-    let compiled = std::panic::catch_unwind(|| compile_for_engine(bad));
-    std::panic::set_hook(prev);
+    let compiled = std::panic::catch_unwind(|| compile_for_engine(bad));
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
let prev = std::panic::take_hook();
std::panic::set_hook(Box::new(|_| {})); // suppress the diagnostic backtrace
let compiled = std::panic::catch_unwind(|| compile_for_engine(bad));
std::panic::set_hook(prev);
let compiled = std::panic::catch_unwind(|| compile_for_engine(bad));
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@baml_language/crates/bex_engine/tests/cancellation.rs` around lines 933 -
936, The test mutates the global panic hook (take_hook/set_hook) which can
interfere with other concurrent tokio tests; remove the hook mutation and
instead rely on std::panic::catch_unwind around the call to
compile_for_engine(bad) (or run the panic-producing call in an isolated
subprocess if you must suppress diagnostics), so do not call
std::panic::take_hook or std::panic::set_hook in cancellation.rs and keep only
the catch_unwind wrapping compile_for_engine to observe the panic payload.

Comment on lines +68 to +78
/// A member registered with a group (synchronously, at spawn time) that has not
/// yet been admitted. The spawned body task [`acquire`](TaskGroupTicket::acquire)s
/// it to await a concurrency slot.
pub struct TaskGroupTicket {
group: Arc<TaskGroupInner>,
id: u64,
/// `None` when admitted at registration; `Some` while parked.
rx: Option<oneshot::Receiver<()>>,
/// The member's effective token (only consulted on the parked path).
cancel: CancellationToken,
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Dropping TaskGroupTicket leaks group state.

register() consumes capacity before the caller awaits acquire(), but the ticket itself has no cleanup path. If an immediately-admitted ticket is dropped before acquire(), active stays permanently inflated; if a queued ticket is dropped, it remains counted until some unrelated promotion/cancel path cleans it up. Because this type is publicly re-exported, that footgun is reachable outside the engine too.

Either make the ticket internal, or teach it to unregister itself on drop and mark the successful acquire() path as consumed so the permit owns cleanup from that point onward.

Also applies to: 193-231

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@baml_language/crates/bex_vm_types/src/task_group.rs` around lines 68 - 78,
TaskGroupTicket currently leaks group state when dropped; implement ownership
cleanup by adding a Drop for TaskGroupTicket that unregisters the ticket from
TaskGroupInner unless the ticket has already been consumed by a successful
acquire(); modify TaskGroupTicket.acquire() to atomically mark the ticket as
consumed (e.g., set rx to None or flip a bool/AtomicBool) and return the
permit/guard that becomes responsible for decrementing active on its Drop, and
add an internal TaskGroupInner::unregister(id) (or call the existing decrement
path) used by Drop to decrement active/cancel queued state for tickets that were
never acquired; ensure both the parked (rx/ cancel) and immediately-admitted
paths set the consumed flag so Drop does not double-unregister.

Comment on lines +156 to +163
/// Change the concurrency cap. Increasing admits parked waiters up to the
/// new cap immediately; decreasing never preempts running members (new
/// admissions simply wait); `0` pauses the group.
pub fn set_limit(&self, limit: usize) {
let mut st = self.lock();
st.limit = limit;
Self::promote(&mut st);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

set_limit() only promotes one waiter today.

promote() breaks after the first successful grant, so widening the cap from 1 to N leaves spare capacity idle until some later drop/cancel path runs. That contradicts the Line 156-158 contract and underutilizes the group after set_limit().

Suggested fix
-            break;

Please also add a unit test that raises the limit above the current active count and asserts that admission catches up immediately.

Also applies to: 251-274

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@baml_language/crates/bex_vm_types/src/task_group.rs` around lines 156 - 163,
set_limit currently calls Self::promote(&mut st) which only grants one waiter
because promote returns after the first success; update promote (or add a new
loop in set_limit) to repeatedly promote waiters until either st.limit ==
st.active or no more waiters remain so widening the cap from 1 to N fills all
spare slots immediately; modify the logic in promote (referencing the promote
function) to not break after the first grant (or call promote in a loop from
set_limit) and ensure it respects paused (limit==0) behavior. Also add a unit
test that constructs a TaskGroup, sets limit=1, parks enough waiters to exceed
1, then calls set_limit to a value > active and asserts that the group’s active
count increases to the new limit (and all eligible waiters are admitted) right
after set_limit returns.

Comment on lines +84 to +109
fn any(vm: &mut BexVm, tokens: &[Value]) -> Value {
// A fresh composite token that fires when any input fires. One watcher
// task per input links `input.cancelled() -> composite.cancel()`; each
// watcher self-terminates when the composite is cancelled (directly or
// by a sibling), so they never outlive the composite. Cancelling the
// composite does not propagate back to the inputs (one-directional).
let composite = CancellationToken::new();
for &token in tokens {
let Some(input) = as_cancellation_token(vm, token) else {
continue;
};
let out = composite.clone();
let watcher = async move {
tokio::select! {
biased;
() = input.cancelled() => out.cancel(),
() = out.cancelled() => {}
}
};
#[cfg(not(target_arch = "wasm32"))]
tokio::spawn(watcher);
#[cfg(target_arch = "wasm32")]
wasm_bindgen_futures::spawn_local(watcher);
}
alloc_cancel_token(vm, composite)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🌐 Web query:

Does tokio_util::sync::CancellationToken::cancelled()resolve when the last clone is dropped, or only after an explicitcancel()? Is CancellationTokenDropGuard the intended way to make watcher tasks stop on drop?

💡 Result:

No, tokio_util::sync::CancellationToken::cancelled does not resolve when the last clone is dropped; it only resolves when the token is explicitly cancelled via the cancel method (or when it is already in a cancelled state) [1][2]. The CancellationToken::drop_guard method is the intended way to trigger cancellation automatically when a specific scope is exited (i.e., when the guard is dropped) [1][3]. To implement this pattern, you call.drop_guard on your CancellationToken to obtain a DropGuard. As long as this guard exists, the token remains uncancelled (unless cancelled by other means). When the guard goes out of scope and is dropped, it automatically calls cancel on the associated token [1][3]. If you wish to prevent this automatic cancellation (e.g., because the task completed successfully), you can use the disarm method on the guard to consume it without triggering the cancellation [3].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

FILE="baml_language/crates/bex_vm/src/package_baml/spawn.rs"

echo "== File top =="
wc -l "$FILE"
rg -n "fn any\\(" "$FILE"
rg -n "alloc_cancel_token\\(|as_cancellation_token\\(|struct .*Cancel|DropGuard|drop_guard|disarm" "$FILE"
echo
echo "== any() implementation region =="
sed -n '60,140p' "$FILE"
echo
echo "== alloc_cancel_token / related definitions =="
rg -n "fn alloc_cancel_token|alloc_cancel_token\\(" "$FILE"
# Print some nearby context around each hit
for m in $(rg -n "alloc_cancel_token\\(|as_cancellation_token\\(" "$FILE" | cut -d: -f1 | sort -u); do
  echo "-- around line $m --"
  sed -n "$((m-25)),$((m+25))p" "$FILE"
done

Repository: BoundaryML/baml

Length of output: 20902


🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n "pub (type|use) CancellationToken|struct CancellationToken|tokio_util::sync::CancellationToken" -S baml_language/crates/bex_vm_types 2>/dev/null || true
rg -n "drop_guard|CancellationTokenDropGuard|disarm\\(" -S baml_language/crates/bex_vm/src/package_baml/spawn.rs baml_language/crates/bex_vm_types 2>/dev/null || true

Repository: BoundaryML/baml

Length of output: 250


🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n "CancellationToken" baml_language/crates/bex_vm_types -S | head -n 50

Repository: BoundaryML/baml

Length of output: 1903


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Confirm what CancellationToken actually is in this repo
rg -n "tokio_util::sync::CancellationToken|CancellationTokenDropGuard|drop_guard\\(|disarm\\(" -S baml_language/crates/bex_vm_types baml_language/crates/bex_vm/src/package_baml/spawn.rs

# Show a few key lines where CancellationToken is defined/re-exported
rg -n "CancellationToken" -S baml_language/crates/bex_vm_types | head -n 80

Repository: BoundaryML/baml

Length of output: 2094


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Confirm what CancellationToken actually is in this repo
rg -n "tokio_util::sync::CancellationToken|CancellationTokenDropGuard|drop_guard\\(|disarm\\(" -S baml_language/crates/bex_vm_types baml_language/crates/bex_vm/src/package_baml/spawn.rs

# Show a few key lines where CancellationToken is defined/re-exported
rg -n "CancellationToken" -S baml_language/crates/bex_vm_types | head -n 80

Repository: BoundaryML/baml

Length of output: 2094


🏁 Script executed:

#!/bin/bash
set -euo pipefail

FILE="baml_language/crates/bex_vm/src/package_baml/spawn.rs"

echo "== Search for drop-guard patterns in spawn.rs =="
rg -n "drop_guard|CancellationTokenDropGuard|disarm\\(" "$FILE" || true

echo
echo "== Inspect alloc_cancel_token definition (ownership) =="
sed -n '1,80p' "$FILE"

echo
echo "== Inspect any() watcher lifetime details =="
sed -n '80,130p' "$FILE"

Repository: BoundaryML/baml

Length of output: 5820


Fix potential watcher task leak in baml.spawn.CancelToken.any when the composite token is dropped

  • In baml_language/crates/bex_vm/src/package_baml/spawn.rs (lines 84-109), each spawned watcher captures out = composite.clone() and waits on input.cancelled() or out.cancelled(). Dropping the returned composite CancelToken only drops one clone; tokio_util::sync::CancellationToken::cancelled() does not resolve on last-clone drop, so watchers can remain pending forever and accumulate.
  • Allocate the heap CancelToken handle so it owns a drop-triggered cancellation (e.g., store a CancellationTokenDropGuard from CancellationToken::drop_guard() alongside the token) to cancel the composite when the VM handle is dropped.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@baml_language/crates/bex_vm/src/package_baml/spawn.rs` around lines 84 - 109,
The watchers can leak because CancellationToken::cancelled() doesn't fire when
the last clone is dropped; fix by creating a CancellationTokenDropGuard (via
CancellationToken::drop_guard()) for the composite token and store that guard
with the returned VM token so dropping the VM handle triggers cancel();
specifically, in any() create let guard = composite.drop_guard() (or
CancellationToken::drop_guard(&composite)) and pass/store that guard into
alloc_cancel_token (or adjust alloc_cancel_token to accept and retain a
CancellationTokenDropGuard alongside the CancellationToken), leaving watcher
logic unchanged so out.cancel() still cancels all watchers when the guard is
dropped.

Comment on lines +3934 to +3944
let config_ptr = if config_value.is_null() {
None
} else if let Some(ptr) = config_value.as_object_ptr() {
Some(ptr)
} else {
return Err(VmInternalError::TypeError {
expected: Type::Object(ObjectType::Instance),
got: self.type_of(&config_value),
}
.into());
};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Reject non-Instance heap objects here.

This branch currently accepts any heap object as config, even though the error type says ObjectType::Instance. That means malformed bytecode can smuggle a string/array/function into UnscheduledFuture.config and only fail later in the engine.

Suggested fix
-                    let config_ptr = if config_value.is_null() {
-                        None
-                    } else if let Some(ptr) = config_value.as_object_ptr() {
-                        Some(ptr)
-                    } else {
+                    let config_ptr = if config_value.is_null() {
+                        None
+                    } else if let Some(ptr) = config_value.as_object_ptr() {
+                        match self.get_object(ptr) {
+                            Object::Instance(_) => Some(ptr),
+                            _ => {
+                                return Err(VmInternalError::TypeError {
+                                    expected: Type::Object(ObjectType::Instance),
+                                    got: self.type_of(&config_value),
+                                }
+                                .into());
+                            }
+                        }
+                    } else {
                         return Err(VmInternalError::TypeError {
                             expected: Type::Object(ObjectType::Instance),
                             got: self.type_of(&config_value),
                         }
                         .into());
                     };
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
let config_ptr = if config_value.is_null() {
None
} else if let Some(ptr) = config_value.as_object_ptr() {
Some(ptr)
} else {
return Err(VmInternalError::TypeError {
expected: Type::Object(ObjectType::Instance),
got: self.type_of(&config_value),
}
.into());
};
let config_ptr = if config_value.is_null() {
None
} else if let Some(ptr) = config_value.as_object_ptr() {
match self.get_object(ptr) {
Object::Instance(_) => Some(ptr),
_ => {
return Err(VmInternalError::TypeError {
expected: Type::Object(ObjectType::Instance),
got: self.type_of(&config_value),
}
.into());
}
}
} else {
return Err(VmInternalError::TypeError {
expected: Type::Object(ObjectType::Instance),
got: self.type_of(&config_value),
}
.into());
};
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@baml_language/crates/bex_vm/src/vm.rs` around lines 3934 - 3944, The code
currently accepts any heap object for config_value via
config_value.as_object_ptr() even when the expected type is an Instance; update
the branch that sets config_ptr (in vm.rs around the config_value handling) to
check the actual heap object's kind is ObjectType::Instance before returning
Some(ptr), and if it is not an Instance return Err(VmInternalError::TypeError {
expected: Type::Object(ObjectType::Instance), got: self.type_of(&config_value)
}.into()); ensure you use the VM/heap API that retrieves the object kind by
pointer (the same mechanism used elsewhere for type checks) so malformed
bytecode cannot pass non-Instance objects into UnscheduledFuture.config.

@vercel
vercel Bot temporarily deployed to Preview – promptfiddle2 May 28, 2026 23:33 Inactive
@vercel
vercel Bot temporarily deployed to Preview – promptfiddle May 28, 2026 23:34 Inactive
@antoniosarosi

Copy link
Copy Markdown
Contributor Author

Superseded by #3625, which folds the spawn-options work and the BEP-034 future combinators into a single PR.

pull Bot pushed a commit to justinlietz93/baml that referenced this pull request Jun 8, 2026
BEP-034 — concurrency via `spawn` / `await`. Single PR covering the
spawn-options core and the future combinators (supersedes BoundaryML#3596).

## Public BAML API (BEP-034 concurrency)

This is the complete user-facing surface this PR adds. Everything below
is callable from `.baml` source.

### Syntax

```baml
spawn { body }                       // -> Future<T, E>; runs body concurrently
await f                              // -> T; blocks until f settles, re-throws its error
spawn with t1 with t2 { body }       // middleware: applies transformers left-to-right
```

- `spawn { e }` evaluates to `Future<T, E>` where `T` is the body's
value type and `E` its throws type. The body starts running immediately;
the future is the handle.
- `await f` suspends until `f` settles, then yields its value or
re-throws its error. `await` **distributes over a union of futures**:
`await (Future<A,E1> | Future<B,E2>)` yields `A | B` (the error side is
contributed to the awaiter's throws surface).
- `spawn ... with <transformer> ...` runs each transformer over an
implicit `SpawnParams` before the runtime creates the future (see
middleware below).

### `baml.future` — futures & combinators

```baml
class Future<T, E> {
  fn cancel(self) -> bool throws never        // request cancel; false if already settled
  fn is_settled(self) -> bool throws never     // reached Ready | Error | Cancelled
  fn is_cancelled(self) -> bool throws never
  fn is_result(self) -> bool throws never      // settled with a value
  fn is_error(self) -> bool throws never        // settled with an error
  fn state(self) -> FutureState throws never
}

enum FutureState { Pending  Ready  Error  Cancelled }

// Combinators over a homogeneous Future<T,E>[] — each returns a new Future, so they compose.
fn all_complete<T, E>(futures: Future<T, E>[]) -> Future<T[], E>
//   all values in input order; losers KEEP RUNNING on failure; throws first error (input order)
fn all<T, E>(futures: Future<T, E>[]) -> Future<T[], E>
//   all values in input order; on first failure the rest are CANCELLED, then error re-thrown (JS Promise.all)
fn race<T, E>(futures: Future<T, E>[]) -> Future<T, E>
//   first to settle (success OR failure) wins; losers cancelled (JS Promise.race); empty array never settles
fn any<T, Err>(futures: Future<T, Err>[]) -> Future<T, AllFailed<Err>>
//   first to SUCCEED wins; losers cancelled; if all fail, throws AllFailed<Err> (JS Promise.any)

class AllFailed<E> { errors E[] }   // thrown by `any` when every input failed; errors in input order
```

### `baml.spawn` — spawn options & `with` middleware

```baml
// Every `spawn` implicitly builds a SpawnParams; the `with` pipeline transforms it.
// A transformer is any function (SpawnParams<T,E>) -> SpawnParams<U,F>.
class SpawnParams<T, E> {
  body   () -> T throws E
  name   string?
  group  TaskGroup?
  cancel CancelToken?
  detach bool
}

// The built-in config transformer. `cancel` links a token; `detach = true` opts out of the
// parent→child cancel cascade and routes unhandled errors to the root task; `group` enrolls
// the spawn in a TaskGroup (parks FIFO until admitted). Leaves `body`/types unchanged.
fn options<T, E>(group: TaskGroup? = null, cancel: CancelToken? = null, detach: bool? = null)
  -> (SpawnParams<T, E>) -> SpawnParams<T, E> throws never

class TaskGroup {                                  // caps concurrency; excess spawns queue FIFO
  fn new(limit: int, name: string? = null) -> TaskGroup throws never
  fn cancel(self, pending: bool? = true, active: bool? = true) -> int throws never  // returns # signalled
  fn set_limit(self, limit: int) -> void throws never   // raise admits queued; lower never preempts; 0 pauses
  fn limit(self) -> int throws never
  fn name(self) -> string? throws never
  fn active_count(self) -> int throws never
  fn queued_count(self) -> int throws never
}

class CancelToken {                                // cooperative, one-shot; fires at next await
  fn new() -> CancelToken throws never
  fn any(tokens: CancelToken[]) -> CancelToken throws never   // fires when ANY input fires (one-directional)
  fn cancel(self) -> int throws never              // 1 if this call transitioned it, else 0
  fn is_cancelled(self) -> bool throws never
}
```

A cancelled task throws `baml.panics.Cancelled` at its next `await`.

### Writing your own middleware

A transformer is just a function returning `(SpawnParams<T,E>) ->
SpawnParams<U,F>`. It can set config
fields, wrap `body` (retry/timing), or replace it entirely (a
type-changing transformer — e.g. a
fallback that catches and erases the error type to `null`):

```baml
function withFallback<T, E>(default_value: T) -> (SpawnParams<T, E>) -> SpawnParams<T, null> throws never {
  (params) -> {
    let original = params.body;
    SpawnParams {
      body: () -> { (original()) catch (e) { let e => default_value } },
      name: params.name, group: params.group, cancel: params.cancel, detach: params.detach,
    }
  }
}

// usage — `f : Future<int, null>`, so awaiting needs no catch:
let f = spawn with withFallback(99) { flaky() };
```

Transformers run **eagerly at the spawn site**, so a throwing
transformer throws there (and is part of
the caller's throws surface). Wrong-shaped transformers are rejected at
compile time with a concrete
"must return a SpawnParams" diagnostic.

## Spawn options
- `spawn ... with baml.spawn.options(group?, cancel?, detach?)` —
config-only `with` clause
- `CancelToken` (`new` / `any` / `cancel` / `is_cancelled`) +
parent→child cancel cascade
- `TaskGroup` rate limiting (FIFO admission, exact counts)
- `detach` (fresh cancel token; unhandled errors routed to the root
task)
- strict `with` validation (must be a `baml.spawn.SpawnConfig`)

## Future combinators (`baml.future`)
- `race` / `any` / `all` / `all_complete` — pure-BAML stdlib over a new
1-byte `AwaitAny` engine primitive (`__await_any: Future<T,E>[] -> int
throws never`, suspends until the first input settles)
- `AllFailed<E>` aggregate error class for `any`
- losers cancelled (`race`/`any`/`all`) or left running (`all_complete`)
per the BEP table

## Engine/runtime fixes found along the way
- **fire-and-forget errors (bug 2):** a child's unhandled throw is now
*deferred* (GC-rooted stash; future stays Pending with its wake signal
fired) and consumed at the single observation point (`future_ready`, now
`&mut`). The spawner's post-await drain surfaces only never-observed
errors — a sibling handling a child's error no longer races the spawner
into a spurious `UnhandledThrow`. (`tests/fire_and_forget.rs`)
- **AwaitAny index desync (bug 3):** global-index bookkeeping fix that
broke `any`
- **bare-bind catch arms (bug 4):** `let e =>` in a generic fn emitted a
constant-false `IsType` (TypeVar→Void erasure) and silently rethrew;
bare binds are irrefutable — no runtime test
- **copy propagation** substitutes `Terminator::Await` operands (`await
<param>` inside `.map` closures)
- unannotated lambda params infer from generic array elements mentioning
only the enclosing fn's rigid generics

## Generics: inferred type args at runtime
- TIR records call-site inferred bindings (`call_type_instantiations`);
MIR threads them to `frame.type_args` (`LoadType`/`TypeArgRef`) —
instances built inside generic fns carry real `class_type_args`, and
typed arms like `let e: AllFailed<string>` match
(`tests/generics_runtime.rs`)
- typed arms naming enclosing TypeVars (`let e: AllFailed<E>`) lower via
type-arg templates instead of erasing

## Canary merge (f5df38a)
Absorbs the spawn/GC permit deadlock fix (full `baml_tests` now
completes on this branch), BEP-044/BEP-57, instantiation expressions,
bounds, and interface-dispatch type-arg threading. Canary's strict
rigid-var checking exposed pre-existing inference gaps, fixed here:
- `await f` contributes the future's `E` to escaping throws
- callee generics matched only against rigid caller TypeVars now bind
(`map`'s `U:=T`)
- lambda *effective* throws feed callee throws-generics — lifts the
documented "current limitation" (covered callback throws no longer
false-positive) and violations name concrete types
- **miscompile fix:** `ShortCircuit` emit assumed a stack-carried
(PhiLike) destination; with `LoadType` before call args the carry is
validly rejected, so `a || b` as an inferred-generic call argument read
an uninitialized local. Emit now materializes the destination on the
short-circuit edge (no-op for PhiLike).
- std `any<T, E>` → `any<T, Err>` (works around generic-param name
capture vs `map<U, E>`; proper fix tracked separately)

## `with` middleware (c37027a)
Full BEP middleware: `spawn ... with a, b { body }` builds a
`SpawnParams<T,E>` and applies each transformer left-to-right — any
plain BAML function `(SpawnParams<T,E>) -> SpawnParams<U,F>`
(wrap/replace body, set config, change types). `baml.spawn.options` is
now a pure-BAML transformer (native SpawnConfig deleted). Flushed out +
fixed three language gaps: keyword segments in type paths, lambdas as
block tail expressions, and unannotated lambdas now INFER their throws
surface from the body (two tests asserting the old throws-never default
updated). Tests: `bex_engine/tests/middleware.rs` incl. the BEP's
`withRetry`.

## Complete bug ledger (every bug found & fixed on this branch)

**Engine / runtime**
1. **Fire-and-forget errors bypassed consumers** — a spawned child's
unhandled throw settled its Future eagerly AND queued on the spawner; a
sibling that awaited + handled the error was invisible to the engine, so
the spawner re-surfaced an already-handled error as `UnhandledThrow`.
Fixed with the deferred-error model: the error parks in a GC-rooted
stash, the future stays Pending (wake signal fired), `future_ready` is
the single observe-and-consume point, and the spawner's post-await drain
surfaces only never-observed errors.
2. **`AwaitAny` index desync** — global-index bookkeeping desync broke
`any` (wrong winner index reported).
3. **Copy propagation didn't substitute `Terminator::Await` operands** —
`await <param>` inside a `.map` closure read an uninitialized slot.
4. **`ShortCircuit` emit miscompile** — the emitter assumed the
destination is always stack-carried (PhiLike); when inferred-generic
calls push `LoadType` before args the carry is validly rejected, so `a
|| b` as a call argument left the value on the stack and read an
uninitialized local (`true || false` evaluated false). Emit now
materializes the destination on the short-circuit edge; MIR normalizes
projection destinations. *Latent before the canary merge — the suite
deadlocked before reaching the `||` fixtures.*

**Type system / generics**
5. **Bare-bind catch arms in generic fns always rethrew** — `let e =>`
emitted an `IsType` from its inferred type; a rigid TypeVar erases to
`Ty::Void` in MIR making the test constant-false. Bare binds are
irrefutable — no runtime test emitted (also drops tautological field
tests in destructures).
6. **Inferred call-site type args were discarded** — TIR ran inference,
substituted the return type, and threw the bindings away; MIR emitted
frame type args only for explicit `<...>` call sites. Instances built
inside generic fns carried empty `class_type_args`; typed arms like `let
e: AllFailed<string>` never matched. Bindings now recorded
(`call_type_instantiations`) and threaded to `frame.type_args`.
7. **Typed arms naming enclosing TypeVars compiled to constant-false** —
`let e: AllFailed<E>` inside `any<T, E>` erased E→Void; now lowers via
`TypeArgRef` templates resolved against the frame.
8. **Recorded instantiations poisoned by fresh literal types** —
`mk("hi")` recorded `T = Literal("hi")` so `is Box<string>` compared
unequal; recordings now `widen_fresh()`. Plus generic→generic chains
recorded `Unknown` (TypeVar actuals refused); fixed with an
allow-typevars backfill.
9. **Unannotated lambda params didn't infer from generic array
elements** whose type only mentions the enclosing fn's rigid generics.

**Throws tracking** *(pre-existing gaps exposed by canary's strict
rigid-var checking)*
10. **`await f` contributed nothing to escaping throws** — the future's
`E` is now part of the awaiting body's throw set.
11. **Callee generics matched only against rigid caller TypeVars never
bound** — `map`'s `U:=T` in `futures.map((f) -> { await f })` was
skipped as a TypeVar actual, leaving `U[]` in result types.
12. **Rigid TypeVar throw-facts collapsed lambda surfaces to `Never`** —
only genuinely open facts (fresh effect slots, Unknown) collapse now.
13. **Callee throws-generics fed from lambda SURFACE instead of
effective throws** — an omitted-throws lambda's open surface left
`map`'s `E` unbound (leaking a raw TypeVar) or symbolic where the body
demonstrably throws. Now fed from the side-table effective set: a
non-throwing callback binds `E:=never`, a throwing one binds its
concrete type. **Lifts a documented "current limitation"**: a callback
throw covered by the caller's declared throws no longer false-positives,
and violations name concrete types instead of symbolic `E`.

**Parser / lowering** *(flushed out by the middleware work)*
14. **`spawn`/`await` keywords rejected as path segments** in type
annotations (`baml.spawn.SpawnParams`) — parser type-path loop + CST
dotted-name extractor.
15. **A lambda as a block's tail expression was silently dropped** from
block elements — the block typed as void ("missing return value"); any
function returning a transformer hit it (the std's `json` code had been
dodging it with explicit `return`).
16. **Unannotated lambdas defaulted to `throws never` with a local
violation** — they now infer their throws surface from the body
(required for body wraps like `() -> { original() }` whose throws is a
generic `E`). Omitted throws on callback *params* remains
effect-polymorphic; explicit `throws` contracts remain enforced
(verified: E0096 fires through the forwarding chain — an enforcement
that itself depends on fixes 10–13).

**Known issues (documented, not fixed here)**
- Generic-param name capture: a callee's generic (`map<U, E>`) sharing a
name with a caller's generic false-binds through the
receiver-substituted signature. Worked around (`any<T, E>` → `any<T,
Err>`); proper fix is alpha-renaming on receiver substitution.
- `await` does not yet distribute over unions of futures (BEP
§await-distributes).

## `await` over unions (94d2b46)
Awaiting `Future<A, E1> | Future<B, E2>` types as `A | B` with both
error types tracked (BEP §await-distributes). Tests:
`bex_engine/tests/await_union.rs`.

## Review hardening (94d2b46)
CodeRabbit batch: `any` errors in INPUT order (+test) · explicit lambda
throws contracts preserved · `with`-transformer throws contributed at
spawn · copy-prop protects `await xs[_i]` · `TaskGroup.set_limit(+N)`
admits up to the cap (+test) · deferred-error hygiene on cancel/fulfill
· VM Spawn config validation · unified/completed generics walkers. Plus
the CI fix: `Object` enum exceeded its 64-byte budget under `heap_debug`
(`UnscheduledFuture` boxed) — the cause of the all-legs CI failure.

## Canary merge round 4 (8b2c403)
Canary's `c957417e7` ([codex] BoundaryML#3702) was a third independent
implementation of inferred-generic runtime type args; this merge
converges on it and fixes what the convergence broke:

1. **Adopted canary's CallPlan pipeline for inferred type args** —
removed our parallel `call_type_instantiations` TIR→MIR fallback (dead
code after the switch). Canary's ShortCircuit emit fix
(`store_on_taken_path`) is the same insight as ours; took theirs.
2. **`runtime_call_type_args` lost literal widening** — canary's
recording threads `T = literal "hi"` for `mk("hi")`, so an escaped
instance never matches `is Box<string>` (runtime class-arg comparison is
invariant). Restored `widen_fresh` on inferred bindings.
3. **Canary's stricter call-result recheck false-positived on `unknown`
holes** — `spawn with` middleware checks transformers against
`(SpawnParams<T,E>) -> SpawnParams<unknown, unknown>`; the normalizer
implements class-arg invariance as structural equality, so the holes
(and `BuiltinUnknown` from erased unresolved typevars, e.g. a
variable-bound transformer; and equivalent spellings like `T = int | 99`
from fallback-arg joins) all failed E0001. `is_subtype` now compares
same-class args pairwise: holes match anything, concrete args must be
**mutual** subtypes (invariance as equivalence). Concrete mismatches —
including canary's new iterator-stdlib diagnostics — still report.
4. **`heap_debug` feature gap** — `FutureRead::ErrorPending` arm added
to the heap debugger's validity walk.
5. **`task_group_cancel_cancels_members` timing** — the promptness bound
times `run_main` *including compilation*; the stdlib grew (ns_iter). 2s
→ 5s, still far under the 10s sleep it guards.

Closes the deferred interface-dispatch inferred-type-args item (canary's
BoundaryML#3702 ships it with runtime regressions).

Verified: `cargo test --workspace --all-features` — 185 suites / 0
failures (sdk_test_* excluded as in CI; they require cargo-nextest setup
scripts) + prek clean, rustc 1.93.0.

## Deferred (follow-up branch)
- `Result<T, E>` type design + `all_settled`, `.then()`
- cancellation-test panic-hook hygiene

Tests: `bex_engine` 227/0 (incl. `combinators`, `fire_and_forget`,
`generics_runtime`, `cancellation`, `task_group`); `baml_tests`
diagnostics 418/418, runtime fixtures 1650/1650 (one timing-sensitive
fixture flakes only under full parallel suite load); snapshots
regenerated.





<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* spawn ... with middleware and options(...) for spawn configuration;
CancelToken and TaskGroup APIs; future combinators all_complete, all,
race, any; await-any (returns winning input index)
* **Tests**
* Comprehensive BEP-034 tests for spawn options, TaskGroup, CancelToken,
combinators, middleware, fire-and-forget, and runtime behavior
* **Bug Fixes**
* Improved child-task error propagation and deferred fire-and-forget
error semantics across await/catch boundaries
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

This branch was previously deployed

3 inactive deployments
Preview – promptfiddle — 469640c7 Deployed May 28, 2026 by vercel[bot]
Preview – promptfiddle2 — 469640c7 Deployed May 28, 2026 by vercel[bot]
Preview – beps — 469640c7 Deployed May 28, 2026 by vercel[bot]
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