Skip to content

async: Task.scope/Task.start over asyncio.TaskGroup, a trailing unit expression ends a block, and the mailbox agent in the cookbook - #115

Merged
simontreanor merged 1 commit into
mainfrom
task-scope
Aug 30, 2026
Merged

async: Task.scope/Task.start over asyncio.TaskGroup, a trailing unit expression ends a block, and the mailbox agent in the cookbook#115
simontreanor merged 1 commit into
mainfrom
task-scope

Conversation

@simontreanor

Copy link
Copy Markdown
Owner

Closes #109 and the Agent half of #108 (as a cookbook example, per ROADMAP item 18).

Structured concurrency, the library form

Every task is owned by a scope, the scope does not exit until its children finish, one failure cancels the siblings, and leaving the scope cancels everything. Two prelude members type the discipline over asyncio.TaskGroup:

member type
Task.scope (Scope ->{e} Async a) ->{e} Async a
Task.start Scope -> Async unit ->{io} unit

Scope is an opaque built-in handle only Task.scope hands out, so a start outside a scope is a missing argument at compile time (expected Scope, found Async unit), and there is no cancel to forget. The Python IR gains PyStmt::AsyncWith; the helpers are:

async def _pf_task_scope(body):
    async with asyncio.TaskGroup() as tg:
        return await body(tg)
def _pf_task_start(tg, c):
    tg.create_task(c)
    return None

Decided: no task { } spelling. The user-builder route was tried on paper first, as agreed: a builder cannot open the scope around the whole block because the protocol has no run member, and Task.scope (fun scope -> async { … }) reads well enough that adding run waits for demand. §8.1 stays at four built-ins, untouched. The ExceptionGroup typing (Result a (List Exception)) stays open until a program needs the members; today Async.catch reports it as one Exception. All of this is in DESIGN.md §6.

A trailing unit expression ends a block

The cookbook producer ends in post inbox Quit, a plain call, and F#'s implicit Zero makes that the block's end. A trailing unit expression (parsed as let _ = e) now ends a result/option/async block with (): async falls off the async def, result/option emit return Ok(None) / Some(None). The checker pins the last step to unit.

The mailbox agent (#108)

examples/interop/structured_concurrency.pyfun: an asyncio.Queue behind two externs (post = .put_nowait, take = .get() typed Async a), a recursive async loop that is a match over a Msg ADT, and a producer task started with Task.start inside a Task.scope, so the agent dies with the session. It prints agent heard hello, agent heard world, agent handled 2 messages, and the run_example harness pins that. Lesson 18 gets a structured-concurrency section with a two-worker example; the cookbook README gets the row.

Tests

Typecheck: the scope/start types, the missing-Scope error, let pure rejected over Task.start, and the trailing unit expression in all three monads (with option { print 1 } : Option unit on hover). E2E: the cookbook example, and the trailing-expression lowering in result/option including the short-circuit path. cargo test, cargo clippy --all-targets, cargo fmt --check and docs/verify_lessons.py are clean.

…expression ends a block, and the mailbox agent in the cookbook (#108, #109)

Structured concurrency as a library: Task.scope runs an async body
inside async with asyncio.TaskGroup() as tg (a new PyStmt::AsyncWith IR
node) and hands it the opaque Scope; Task.start needs that Scope, so a
start outside a scope is a missing argument at compile time. No task { }
spelling: a user builder cannot open the scope around the whole block
and the library form reads well enough. A trailing unit expression now
ends a result/option/async block with () (F#'s implicit Zero). The agent
lives in examples/interop/structured_concurrency.pyfun as an
asyncio.Queue behind two externs and a recursive async loop over a Msg
ADT, started inside a scope so it dies with the session. Lesson 18 gets
a structured-concurrency section; DESIGN §6 and §8.1, the cookbook
README and ROADMAP items 17 and 18 are updated.
@simontreanor
simontreanor merged commit 9c29299 into main Aug 30, 2026
16 checks passed
@simontreanor
simontreanor deleted the task-scope branch August 30, 2026 18:02
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.

Structured concurrency: task { } over asyncio.TaskGroup, a fifth built-in by the §8.1 rule

1 participant