Lloyaldocs
Understand

Structured concurrency

Every Lloyal harness runs on Effection's structured concurrency. If you write async/await, this page is the translation — and what it buys you.

On this page

Lloyal does not have its own concurrency model. Every harness runs on Effection, the structured concurrency library for JavaScript made by Frontside, and the ideas on this page — generators as operations, yield* in place of await, scopes that own every lifetime — are Effection's. Lloyal builds on them, and adds the few pieces that live model state needs.

What Effection guarantees

From Frontside's Thinking in Effection:

  • No operation runs longer than its parent. When a parent completes or is halted, Effection tears down its children — the way memory is released when nothing refers to it.
  • Every operation exits fully. Cleanup runs, whether the work returned, threw or was stopped.
  • It's just JavaScript. let, const, if, for, try/catch/finally all work as you expect; generators take the place of async/await, because async/await cannot model structured concurrency.

That is why a Stop in a Lloyal app works in the middle of anything, and why there is almost no teardown code to write: whatever a piece of work starts — a tool call, a pool of agents, a fork of the model's state — is finished or cleaned up when that work ends, however it ends.

The rules below are few, and they matter: code that breaks them usually compiles, runs, and leaks. Thinking in Lloyal is how the same ownership extends to the model's live state.

From async/await to Effection

If you know how to do it in JavaScript, you know how to do it in Effection. These rows follow Frontside's Async Rosetta Stone, with what each one means for ownership:

JavaScript Effection Ownership meaning
async function function*(): Operation<T> A composable scoped program
await work() yield* work() Perform work under the current owner
Promise<T> Operation<T> Work waiting to be placed in a scope
Promise.all(...) yield* all(...) Run and join an owned cohort
Promise.race(...) yield* race(...) Race owned alternatives and halt the losers
fire-and-forget Promise yield* spawn(...) Start a child that cannot outlive this scope
call an async API yield* call(() => ...) Cross deliberately into Promise code
finally cleanup ensure() or a resource Bind cleanup to scope exit
for await for (... of yield* each(stream)) Consume a scoped subscription
global dependency Effection Context Inherit a capability inside a scope

What Lloyal adds

Two forms are Lloyal's, because live model state has lifetimes of its own:

Situation Lloyal form Ownership meaning
write into the model — commitTurn, commit, prefill, promote yield* waitUntilSettled(session.commitTurn(...)) Finish the native write, even when halted
a temporary workspace for agents yield* withSpine(options, body) Borrow live inference state and reclaim it when the body ends

Both are built from Effection's own primitives — scoped, ensure, until — and they are in @lloyal-labs/lloyal-agents.

Four lines that carry the model

TypeScript
const value = yield* operation;

Perform owned work and resume with its result.

TypeScript
const task = yield* spawn(operation);

Start concurrent work without detaching it from the current scope.

TypeScript
const findings = yield* withSpine(options, body);

Borrow live inference state, return durable data, and reclaim the temporary subtree.

TypeScript
yield* waitUntilSettled(session.commitTurn(query, answer));

Make the result durable, explicitly — and let the write finish even if the owner is halted.

Operator ownership reference

yield* performs the Operation it receives. Different Operations complete at different moments.

Code Meaning
yield* operation Perform scoped work and receive its result
yield* spawn(operation) Attach and start a concurrent child
yield* all(operations) Run and join an owned cohort
yield* race(operations) Race owned alternatives and halt losers
yield* call(() => promise) Cross deliberately into Promise-based code
yield* waitUntilSettled(promise) Await a native write into the model; exit only once it has settled, even on halt
yield* ensure(cleanup) Register cleanup for scope exit
for (... of yield* each(stream)) Consume a subscription owned by the scope
yield* context.expect() Read an inherited scoped capability
yield* resourceOperation Acquire a live capability whose provider remains beneath you

This matters most with spawn and resources: yield* does not always mean “wait for every activity underneath this call to finish”.

Follow the return type

Two APIs may share a method name but have different semantics.

TypeScript
events.send(event);

may be synchronous.

TypeScript
yield* channel.send(event);

may return Operation<void> and require yield*.

Do not infer from names such as send, read, or close.

Let TypeScript tell you whether an API is:

  • synchronous;
  • Promise-returning;
  • or Operation-returning.

A floating-Operation lint rule would be valuable for harness projects.

Common mistakes

Converting an Operation to async

Avoid:

TypeScript
async function runResearch() {
  // ...
}

when the function must compose Lloyal Operations.

Use:

TypeScript
function* runResearch(): Operation<Result> {
  // ...
}

Calling an Operation without performing it

Avoid:

TypeScript
ctx.spawn(task);

Use:

TypeScript
const agent = yield* ctx.spawn(task);

Treating an Agent as a Task

Avoid:

TypeScript
yield* agent;

Use:

TypeScript
yield* ctx.waitFor(agent);

Returning a scoped branch

Avoid relying on:

TypeScript
const spine = yield* withSpine(options, function* (spine) {
  return spine;
});

Return findings or another explicitly durable value.

Detached background loops

Avoid:

TypeScript
void listenForever();

Use:

TypeScript
yield* spawn(listenForever);

Wrapping a native write in call()

Avoid:

TypeScript
yield* call(() => session.commitTurn(query, answer));

Use:

TypeScript
import { waitUntilSettled } from "@lloyal-labs/lloyal-agents";

yield* waitUntilSettled(session.commitTurn(query, answer));

A native decode is queued on a worker thread and cannot be recalled. If the owner is halted — the reader presses Stop — call abandons the promise while the decode keeps writing the model's memory, and whatever cleans up next touches the same state: a corrupted branch, or a crash. waitUntilSettled waits for that one call to finish before the scope exits. It applies to every call that writes into the model: commitTurn, commit, the prefill family, promote and retainOnly.

Assuming call() cancels every Promise

Bind provider cancellation into the scope where available.

Marking a native Tool as off-loop

Do not set Tool.fanout = true when the Tool may touch the main SessionContext, a branch, or a nested Agent runtime.

Repository invariants

Markdown
## Lloyal structured-concurrency invariants

- A harness is a long-lived Effection scope.
- Read `yield*` as “perform this operation here, under this owner.”
- Do not convert Operation generators into async functions.
- Do not call and ignore a value of type `Operation<T>`.
- Perform Operations with `yield*`, `all`, `race`, `spawn`, or return them.
- Use `spawn` only for concurrent children owned by the current scope.
- Use `call()` at Promise or async-library boundaries.
- Await a native write into the model (`commitTurn`, `commit`, `prefill*`, `promote`) with `waitUntilSettled`, never `call()`.
- An Agent is not an Effection Task; the pool advances Agents.
- Agent concurrency is executed by the pool over BranchStore.
- A Branch or spine normally cannot outlive the scope that created it.
- Return durable findings from `withSpine`, not live branch handles.
- Effection Context values are scoped capabilities, not globals.
- Follow TypeScript return types: some `send()` methods are synchronous,
  while Channels return Operations that must be yielded.
- Do not mark a Tool as `fanout` if it may touch the main SessionContext.

Learn Effection

Effection's guides are the reasoning behind everything on this page, and the contract Lloyal's own code is held to:

Next

↑
Search