✨ feat: give every component invocation its own resource scope - #210
Merged
Conversation
taras
force-pushed
the
feat/component-invocation-scope
branch
2 times, most recently
from
July 29, 2026 01:08
0923e59 to
f3be157
Compare
PR #210: ✨ feat: give every component invocation its own resource scope23 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. CorrectnessNo 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
force-pushed
the
feat/component-invocation-scope
branch
from
July 29, 2026 02:49
f3be157 to
b318c32
Compare
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.
This was referenced Jul 29, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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, andEvalScopeexposes no way to destroy one — so nothing was ever halted. Daemons andpersist evalresources 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:
FunctionComponentwas declaredWorkflow<string>, so a.tscomponent's only bridge to Effection wasephemeral().After:
How it works
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:
persistanddaemonretained.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.tsThen review:
specs/executable-mdx-spec.md§4.4 — the three nested eval scopes, the boundary, and teardown.packages/core/src/invocation.ts— the two-phase handshake, single-flight content scope, ordered teardown.packages/core/src/types.ts— theComponentExecutioncontract.packages/core/src/errors.ts+component-api.ts— reporting vs settling.packages/core/src/expand.ts— both component paths throughwithInvocation.Look carefully at:
scoped(). A nested scope releases author resources before teardown runs — that inversion is what O5 caught.AgentHarness, which publishes{harness.*}by installing an env for its own content.What must stay true
Workflow<T>stays assignable toOperation<T>— enforced bydeno task checkacross every existingephemeral-using component.<Test>'s bindings stay invisible to the next test — checked bytesting-mode.test.ts:178.How to verify it
kill -0probe in a block after the component while the document is still running, and getsSTOPPED. Fails if a daemon keeps document lifetime.persist evalresource.ephemeral(),scoped()or wrapper, with the resource acquired both before and after the first projection.Closeremoved: the executor ran once, output is identical, the resource was re-established per execution.persist evalblock's projection settles under its own block's policy — throwing in documentation, collecting inside<Output>.Scope
Included
ComponentExecutionas the public component contract.<Test>composing the shared boundary instead of a private lease.useContentas an injected binding rather than a standard import, so a persistent evaluation can bind it to its source policy.Intentionally unchanged
<Content />is still substitution-based; §4.4 states in the present tense where each projection path anchors.Workfloworephemeral()contract indurable-streams, document execution, modifiers or output handling.provider-integration.test.tsS7 still asserts rendered text rather than teardown order. Making it real needs two nested providers' daemons to log onSIGTERM; L3 and Q5 now prove the same ordering deterministically, and the subprocess timing was not worth the flake risk.Risks and limitations
provider-integration.test.tsS1–S15,sample-component.test.tsand the Sampling smoke chapter are the regression net.Scope confirmation