Skip to content

✨ feat: give every component invocation its own resource scope - #210

Merged
taras merged 4 commits into
mainfrom
feat/component-invocation-scope
Jul 29, 2026
Merged

✨ feat: give every component invocation its own resource scope#210
taras merged 4 commits into
mainfrom
feat/component-invocation-scope

Conversation

@taras

@taras taras commented Jul 29, 2026

Copy link
Copy Markdown
Owner

Why

Component invocations had no resource lifetime of their own. Each created its eval scope with parentEvalScope.eval(() => useEvalScope()), which parents the child to the parent's loop task, and EvalScope exposes no way to destroy one — so nothing was ever halted. Daemons and persist eval resources lived until the document ended.

The spec rows that said otherwise — L3, Q5, S7 — were unimplemented and untested: every lifetime test asserted only "execute completed without hanging", which document-lifetime teardown satisfies equally well.

This is the lifecycle #189 (<TempDir>) is blocked on: a watcher projected into a component must stop before that component's cleanup runs.

What changes

Before:

  • A daemon started inside a component was still alive after that component finished.
  • A resource a TypeScript component acquired was released with no relationship to when its projected content stopped.
  • FunctionComponent was declared Workflow<string>, so a .ts component's only bridge to Effection was ephemeral().

After:

  • Every Markdown and TypeScript component invocation is a resource scope, torn down when the invocation completes.
  • Content the component projects stops before the component releases anything of its own.
  • A component acquires resources with ordinary operations and needs no wrapper.

How it works

withInvocation() → scoped invocation frame → evalHost → body task → content scope

An invocation creates its eval scope on its own expansion frame and runs its body inside a task that scope owns. That collapses the engine's two parallel context chains into one, which is what makes middleware installed by a component visible to its projected content — including persistent work created there — while ancestor persistent middleware stays visible to nested invocations.

Leaving the invocation runs one destructor with three ordered stages:

  1. halt the content scope — everything projected content created;
  2. halt the body — the resources the component itself acquired;
  3. halt the invocation scope — whatever persist and daemon retained.

Each stage finishes before the next begins, so the ordering never depends on the order a component happened to acquire things in. Every stage is attempted even when an earlier one fails, and failures are reported together.

Review guide

Start with: packages/core/src/invocation.ts

Then review:

  1. specs/executable-mdx-spec.md §4.4 — the three nested eval scopes, the boundary, and teardown.
  2. packages/core/src/invocation.ts — the two-phase handshake, single-flight content scope, ordered teardown.
  3. packages/core/src/types.ts — the ComponentExecution contract.
  4. packages/core/src/errors.ts + component-api.ts — reporting vs settling.
  5. packages/core/src/expand.ts — both component paths through withInvocation.

Look carefully at:

  • Providers are installed on the invocation's body task, not a nested scoped(). A nested scope releases author resources before teardown runs — that inversion is what O5 caught.
  • Function-component projections leave the ambient environment in place. Capturing the caller's env broke AgentHarness, which publishes {harness.*} by installing an env for its own content.

What must stay true

  • Workflow<T> stays assignable to Operation<T> — enforced by deno task check across every existing ephemeral-using component.
  • A <Test>'s bindings stay invisible to the next test — checked by testing-mode.test.ts:178.
  • Durable effects a component yields are still journaled and replayed — checked by O22.

How to verify it

  • Q5 places a kill -0 probe in a block after the component while the document is still running, and gets STOPPED. Fails if a daemon keeps document lifetime.
  • L3 does the same for a persist eval resource.
  • O5/O6 assert the exact teardown timeline for a TypeScript component with no ephemeral(), scoped() or wrapper, with the resource acquired both before and after the first projection.
  • O9/O10 make a teardown stage throw and prove the later stages still run.
  • O22 runs a component that combines a durable effect with a directly acquired resource, then replays a stream with the root Close removed: the executor ran once, output is identical, the resource was re-established per execution.
  • O23/O24 prove a persist eval block's projection settles under its own block's policy — throwing in documentation, collecting inside <Output>.

Scope

Included

  • The invocation boundary, the projection foundation, and the error-policy split.
  • ComponentExecution as the public component contract.
  • <Test> composing the shared boundary instead of a private lease.
  • useContent as an injected binding rather than a standard import, so a persistent evaluation can bind it to its source policy.

Intentionally unchanged

  • Markdown <Content /> is still substitution-based; §4.4 states in the present tense where each projection path anchors.
  • No Workflow or ephemeral() contract in durable-streams, document execution, modifiers or output handling.
  • provider-integration.test.ts S7 still asserts rendered text rather than teardown order. Making it real needs two nested providers' daemons to log on SIGTERM; L3 and Q5 now prove the same ordering deterministically, and the subprocess timing was not worth the flake risk.

Risks and limitations

  • Running the body inside its eval scope's task is the largest structural change: every provider previously read from the expansion frame now arrives through one longer chain. provider-integration.test.ts S1–S15, sample-component.test.ts and the Sampling smoke chapter are the regression net.
  • Every invocation now waits for its projected content and its own daemons before returning, so daemon-heavy documents serialize more.

Scope confirmation

  • Every changed file supports the purpose described above.
  • Unrelated cleanup and formatting changes are excluded.
  • Generated or mechanical changes are clearly identified.
  • The description matches the final diff and test results.

@taras
taras force-pushed the feat/component-invocation-scope branch 2 times, most recently from 0923e59 to f3be157 Compare July 29, 2026 01:08
@github-actions

github-actions Bot commented Jul 29, 2026

Copy link
Copy Markdown

PR #210: ✨ feat: give every component invocation its own resource scope

23 files, +1819 / -235

Scope

🔴 PR has 2054 lines changed. Split into focused PRs.

🟡 2054 lines changed. PRs under 400 receive more thorough review.

🟡 23 files changed. Are all changes related?

Structural

✅ No structural bloat detected.

Slop

✅ Slop indicators look low.

Static Analysis

✅ Oxlint found no issues.

Correctness

No extraneous code patterns detected.

Component invocations had no resource lifetime of their own. Each one
created an eval scope via `parentEvalScope.eval(() => useEvalScope())`,
which parents the child to the *parent's* loop task, and `EvalScope`
exposes no way to destroy it — so nothing was ever halted. Daemons and
`persist eval` resources lived until the document ended, and the spec
rows that said otherwise (L3, Q5, S7) were unimplemented and untested:
every lifetime test asserted only "execute completed without hanging".

An invocation now creates its eval scope on its own expansion frame and
runs its body inside a task that scope owns, which collapses the engine's
two parallel context chains into one. Leaving it runs a single destructor
with three ordered stages — halt the content scope, halt the body, halt
the invocation scope — each finishing before the next, all three attempted
even when one fails, on success, error and cancellation alike.

`FunctionComponent` now returns `ComponentExecution<string>` (an
`Operation`) rather than `Workflow`. A `.ts` component's only bridge to
Effection was `ephemeral()`, whose task settles on return and destroys
what it acquired — before teardown could stop projected content. The
widening is backward compatible and matches what the spec always declared,
so a component holds resources for its children with no wrapper:

    export default function*() {
      const directory = yield* useTempDir();
      return yield* useContent();
    }

Reporting an error and settling it are now separate: the `Component.raise`
middleware chain observes each segment once, and its default implementation
settles under `AmbientErrorPolicy`, which documentation and `<Output>` set
as a value. A persistent evaluation carries its source block's policy in a
per-evaluation `env` facade, since it runs on a loop task that predates it.

`<Test>` composes the shared boundary instead of its own private lease.
@taras
taras force-pushed the feat/component-invocation-scope branch from f3be157 to b318c32 Compare July 29, 2026 02:49
taras added 3 commits July 28, 2026 23:09
The invocation boundary landed with a claim it did not honour for
Markdown. `<Content />` spliced the caller's children into the body, so
projected content anchored in the invocation's eval scope alongside the
component's own resources, and teardown order fell out of LIFO
acquisition order rather than the boundary. A provider that retains a
resource *after* projecting released it first — the inversion of the
contract issue #203 states for both `<Content />` and `useContent()`.

O7 pins it: a Markdown provider projecting `<Content />`, the projected
content acquiring a watcher, the provider's own resource acquired after
the projection. It failed before this change with `stop:own` first.

Slot resolution is unchanged — partitioning, validation and once-only
slot errors still happen during substitution. What changes is that the
resolved segments ride on the `<Content />` element the invocation
claims, and expansion runs them in the content scope. Identity is the
boundary: a `<Content />` the engine did not claim keeps its old
behaviour. The binding environment, meta/props, hide set and block
counter stay the body's, so caller lexical bindings, expression props,
`<Output>`/`<Return>` placement, cycle detection and capture are
untouched.

Lifecycle coverage is now deterministic for both component forms across
success, propagated body error, cancellation, and nesting with sibling
isolation.

Q7 previously ended in `expect(true).toBe(true)`. It now asserts that
cancelling the root resolves rather than waiting on the daemon. It
deliberately does not assert that the signalled subprocess is reaped:
that probe fails identically on main, so the gap is pre-existing and
out of scope here.
The per-evaluation environment was a Proxy over the shared bindings
record. It worked, but it made every read, write, deletion and
enumeration an interception point, and it left the block holding a live
view of changes later blocks made.

A block now receives a plain snapshot of the bindings as they stand when
it starts, with `renderChildren`, `render` and `useContent` replaced by
ordinary closures bound to the policy where the block sits. When it
completes, its declared exports are committed to the shared record —
explicitly, so a function or a live object still reaches later blocks
even though the journal carries only the serializable subset.

The semantics this fixes in place rather than by interception:

- a block sees the bindings available when it starts;
- its declared exports become shared when it completes;
- persistent work retains the values and policy-bound capabilities it
  captured, and a later block rebinding a name cannot reach them;
- an explicit `import { useContent }` still shadows the injected binding
  without a duplicate declaration.
A claimed `<Content />` entered the content scope with a hardcoded
"collect" policy, and its children had not passed through the ambient
policy — they are expanded inside the content task. A missing component
projected into a documentation region was therefore collected into an
ErrorSegment and then discarded with the region's rendered output,
instead of stopping the body.

The policy is now captured at the `<Content />` expansion site and
carried into the content task, as the other projection paths already do.

`runDocumentation` for value components installed throwing
`Component.raise` middleware, which a task launched from the content
scope never inherits. It sets `AmbientErrorPolicy` instead, so ordinary
documentation, value-component documentation and output regions all
carry policy the same way.

A projection failure is also no longer raised into the content scope.
Doing so poisoned that scope, and the invocation's teardown then
re-reported it as an InvocationTeardownError, replacing the
DocumentationError the caller is meant to see. The failure travels back
through the caller instead, and is reported exactly once.

Two existing rows were claiming more than they proved: O11 had no
provider-owned resource, so it could not show ordering on the error
path, and O25's `{ id: 7 }` was JSON, so it did not exercise the commit
of values the journal cannot carry. Both now do.
@taras
taras merged commit 20f5906 into main Jul 29, 2026
9 checks passed
@taras
taras deleted the feat/component-invocation-scope branch July 29, 2026 03:43
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