Skip to content

Add Pack rendering, discovery tools, and tools.stats, clean up the engine, and split its scheduler - #172

Closed
vinniefalco wants to merge 27 commits into
cppalliance:masterfrom
vinniefalco:master
Closed

vinniefalco wants to merge 27 commits into
cppalliance:masterfrom
vinniefalco:master

Conversation

@vinniefalco

@vinniefalco vinniefalco commented Oct 26, 2026 •

Copy link
Copy Markdown
Member

Summary

This pull request carries three series. The first 12 commits add late Pack rendering, the discovery tools, and tools.stats. The next 8 commits clean up runvil-engine after a review of its code and docs, and they change the public run API and two Lua error kinds, listed under Breaking changes. The last 7 commits finish that review: the two response limits leave RunLimits, each section's sys becomes a typed value, and the scheduler is split into parts that each own their state and enforce their own rules.

Pack rendering, discovery tools, and tools.stats

  • A section entry that offers catalog tools now asks each Pack to render what the model should see before its first loop round. The render is its own effect, so the run log records what each Pack showed. A render may rename, reshape, or drop offered tools and add tools only the model can call. ModelBinding gained a public with_catalog_info builder so that render receives the catalog's description of the model.
  • Each section entry names, checks, and freezes its own Static Set and Dynamic Set at its first loop round. The run keeps one catalog that scripts and tool objects read by id. Interleaved offers group each Pack's tools at that Pack's first offer. A script tools.call reports the tool id as its alias.
  • A tool whose wire name clashes or is invalid fails only the sections that offer it. The wire-name rule is now [A-Za-z][A-Za-z0-9_-]{0,63}.
  • A section whose Dynamic Set holds a tool advertises GetDynamicTools and CallDynamicTool. One lists, searches, and describes that set. The other calls a tool in it. The Engine reserves the Pack id dynamic beside adhoc.
  • tools.calls is gone, replaced by the read-only tools.stats view. Each read computes a total, a count per Pack, and a count per tool the script can see from the single count map. Tool-call origins now name the section entry, and a model call's origin names the model of the round that requested it.
  • The Debug trait is spelled only fmt::Debug, enforced by a build-xtask check rather than clippy. One commit outside the plan, "Reject a blocked log producer only after its deadline", fixes a pre-existing Windows timing bug in gateway-logging that blocked the suite twice. After wait_timeout reports a timeout, the producer rejects its record only once Instant::now() has reached the deadline.

Engine cleanup

  • The engine's doc comments and README now say what the code does today: which work leaves the Engine as an effect, which runs inside it and is reported only as events, and which inputs a repeatable run depends on. RunErrorKind is the one place each error kind is documented.
  • Run setup consumes the caller's RunContext whole, three modules that only forwarded names from other crates are gone, and the model keyword spellings live once, in the parser crate. Two bugs are fixed along the way: a Pack listed as both usable and unavailable no longer keeps a vendor, and a section whose first tool call fails while binding its model redoes that setup on its next tool call instead of skipping it for good.
  • The scheduler keeps one effect table whose entries are awaited or orphaned, the task arena is an ordered map so an owner's tasks list in spawn order, and a chain receives its storage access when it starts. Aborting an effect the run is not waiting on no longer records an orphan, so a stray answer for that id still fails the run. Delivering an answer to a chain with no suspended block fails with an internal error at the delivery itself.
  • Prose substitution renders values through the Lua crate's item rule, so a placeholder and a fanout item render the same way. A section's place in the prompt tree uses named path and position types in place of bare integers.
  • The internal error type holds the parser's ParseError whole, has one Lua variant with an optional cause, and names its tool set errors after the sets they guard.

Response limits, typed sys, and the scheduler split

  • The Engine never read RunLimits' response cap or receive timeout; only the gateway client did, by copying them out of RunLimits::default. Both fields are gone, and GatewayBroker lets GatewayChat apply its own request limits, 16 MiB per reply and 120 seconds per receive, the same values as before. The client's field and private constant are renamed receive_timeout and DEFAULT_RECEIVE_TIMEOUT to match the guide.
  • Each section's sys is a typed SectionSys the engine builds and keeps, so entry_id can no longer fail and the first tool call no longer reads the VM's copy back to add model. The JSON a section's Lua code reads keeps its keys and value types, and index and model stay absent rather than null when unset. enrich_sys_model and current_sys leave runvil-lua.
  • A section's place is one SectionPos, and each chain walks with one never-empty Walk stack that moves only by falling through, jumping, or popping a finished child level. Jumps, calls, spawns, and the H1 pass resolve a heading into one JumpTarget. The walk visits sections in the same order, and new tests cover child levels that run out inside call and task chains, jumpers that were last in their slice, and the section a chain reports after resuming.
  • The scheduler's state now lives in four owners: EffectTable (every issued effect takes exactly one answer), ChainArena (the only source of chain indices), TaskArena (only legal lifecycle changes), and Admission (the queues, spawn records, and slot holders, reading the chain tree through a read-only ChainTree). No owner writes another, and the scheduler keeps every method that coordinates more than one, with its writes in the same order.
  • The 29 requests that touch only their own chain run in a ChainOps view that borrows that chain, the effect table, and the run context, so the compiler rules out any wider reach. Scheduler::with_chain is the only way in, and it requeues the chain after the request returns; nothing else can enter the queue in between, so the ready order is unchanged.
  • Cancelling and abandoning a task share one end_backing path, and a timer's answer names its task instead of being matched by scanning every entry.
  • Three paths that only a scheduler bug can reach now fail with an internal error: a spawn whose worker lies past its slice no longer panics, completing a task that is no longer live no longer overwrites its entry, and an admission pass that fails partway applies none of its admissions. The caller sees the same steps, events, and admission order otherwise.

Breaking changes

  • RunLimits is a plain Copy value with four public fields, max_tool_iterations, max_concurrency, lua_memory_bytes, and lua_log_events, and no setters or getters. Its response cap and request timeout are gone; the gateway client applies its own request limits with the same defaults.
  • RunContext builders take a with_ prefix, and its getters are now tools, flags, and vfs. RunContext::prepare takes the current model as an argument, in place of the context's model setter.
  • RunResult::Ok is now RunResult::Completed, RunError::cause is now originating_error, and Step::Pending::effects holds named IssuedEffect records instead of tuples.
  • RunErrorKind loses Cancelled and Substitution, and RunError::is_cancelled is gone. A caller sees cancellation only as RunResult::Cancelled, and a substitution failure is classified as Lua.
  • The Requirements lists are now unmet_models, missing_packs, and unavailable_packs.
  • ChatAnswerRecord::new and its three with_* setters are no longer public.
  • A failed model round reaches Lua as kind model_failure and a round with no model as model_required, where both used to be internal. A prompt that checked for internal to catch a model failure no longer matches.
  • Three author-facing messages change: a schema build failure names the schema refusal, a nil {{ item }} says no item is seeded into the section, and a key path after item says the item renders whole.

Guide

The guide for the tools series is on cppalliance/runvil-docs master (dd8a245..22ff9e0): cppalliance/runvil-docs@dd8a245...22ff9e0

The guide for the engine cleanup follows it (22ff9e0..c51c7f8): cppalliance/runvil-docs@22ff9e0...c51c7f8

The guide for the response limits follows that (c51c7f8..d81228d): cppalliance/runvil-docs@c51c7f8...d81228d

Test plan

The tools series ran, at its last step: workspace nextest, Workshop nextest, clippy with warnings denied, fmt, docs builds, xtask api --check, cargo deny, and the 1.95 build.

Each engine cleanup step ran workspace nextest, workspace clippy with warnings denied, fmt, the rustdoc gates, cargo test -p build-xtask, and the docs-claims scan. The two steps that changed the public API listing also ran xtask api --check. The step that reshaped the public facade also ran Workshop clippy with warnings denied. The step that trimmed the scripted test model also built the engine benchmarks without running them.

Each step of the scheduler split series ran the same set: workspace nextest, workspace clippy with warnings denied, fmt, the three rustdoc gates, cargo test -p build-xtask, and the docs-claims scan. The response limits step also ran Workshop nextest, Workshop clippy with warnings denied, and xtask api --check, plus a check that the API listing lost exactly the two RunLimits field lines. The final commit ran the full set: the gateway build, fmt, workspace and Workshop clippy with warnings denied, the headless gateway check, the rustdoc gates, workspace and Workshop nextest, cargo test -p build-xtask, and the docs-claims scan.

cargo deny and the 1.95 build were not run locally for either engine series and are left to CI on this pull request.

Packs gain a render hook that returns the tools a model sees, given the tools a section offered from the Pack. A render may rename, reshape, or drop those tools and add tools only the model can call, and by default it passes them through unchanged. The hook learns the section entry and the model of its first round from a context that holds plain data only, so a later verb of the C plugin interface can pass it serialized. A tool description can now hold the name the model calls the tool by, and descriptions logged before that name existed read back and log the same way. The plugin crate re-exports the model types a render needs, so a Plugin crate can name them through that crate alone.

- `Pack::render` is a provided trait method returning a `PackFuture`, so a Pack that does not override it passes its offered tools through, and `dyn Pack` still works. Its doc lets a render rename, reshape, or drop offered tools and add model-only tools under its own Pack id, and holds it to `call`'s effect-loop rules.
- `RenderContext` borrows the section entry, the value Lua reads as `sys.id`, and the first round's `ModelInfo` as public fields, and `#[non_exhaustive]` leaves `RenderContext::new` as the only way to build one outside the crate. Unlike `ToolContext`, it holds no Rust-only handle such as `HostServices`, so a later C verb can pass it serialized.
- `ToolInfo::wire_name` is an `Option<String>` that serde defaults when absent and skips when `None`, with a builder in the style of `survives_stop`. Its doc defines the unset default as the id with `/` and `.` turned into `_`, such as `web_fetch` for `web/fetch`.
- `crates/runvil-plugin/src/lib.rs` re-exports `RenderContext` and the types crate's `ModelId`, `ModelInfo`, and `ThinkingMode`, so a Plugin crate can build and read a render's model through the plugin crate alone. Its invariants gain `render`'s rules, including plain-data inputs and outputs.
- `RV_PACK_RENDER` (8) joins the reserved C verbs, now 3 to 8, for a render that finishes later through an `RvHost` function. Like the other reserved verbs, it has no constant.
- `a_tool_info_logged_without_survives_stop_reads_as_not_surviving` drops `"wire_name":"fetch"` from its old-line fixture, because serde now reads a `wire_name` key in a logged line instead of ignoring it. The test still pins only the missing `survives_stop`.
- `Pack::render` gets no caller in this change beyond tests, and nothing here checks its documented rule that every catalog tool it returns was offered in that set.
- `wire_name` has no reader outside tests in this change, and its builder accepts any string unchecked.

Design: extends bag-of-state @ crates/runvil-internal/types/src/tools/info.rs::ToolInfo
  boundary: pub
Design: new surface-growth @ crates/runvil-internal/types/src/tools/info.rs::ToolInfo::wire_name
  boundary: persisted
Design: extends facade @ crates/runvil-plugin/src/lib.rs
  boundary: pub
Design: new speculative-abstraction @ crates/runvil-plugin/src/pack.rs::Pack::render
  boundary: pub
Deferred: RV_PACK_RENDER has a reserved number but no constant.
Plan: vibe/2026-10-25-1-tools-kernel-redo.md
Two capitalized terms join the repository's shared definitions, so each has exactly one meaning wherever it appears. The Static Set names the tools a section renders into the model's tool list at its first model call, and it stays frozen after that. The Dynamic Set names the tools the model finds and calls through the Engine's two discovery tools.

- `AGENTS.md` lists both definitions after the Plugin entry, at the end of its Definitions list. The sentence that introduces the list now opens with "Seven terms" in place of "Five words" and is otherwise unchanged.
- `AGENTS.md` mentions neither term outside its own definition, and the usage rules that follow the list gain no entry for either.

Plan: vibe/2026-10-25-1-tools-kernel-redo.md
Each section entry now names, checks, and freezes its own tools at its first model call, instead of the run binding every catalog tool once at start. The run keeps a single catalog that scripts and tool objects read by id, and each entry merges its offers into a Static Set that every round advertises and a Dynamic Set held beside it, with each Pack's tools grouped where that Pack was first offered. Two tools that share a wire name now fail only the sections that offer both, and a tool no round can advertise fails only the sections that offer it, where before the run dropped the losing tool of a clash, or the unusable tool, from every section with only a log line. A wire name a tool sets for itself is now the name the model sees, and every wire name must start with a letter, hold only letters, digits, underscores, and hyphens, and run at most 64 characters. A script's tool call now reports the tool's id where it used to report the wire name.

- `ToolSet` keeps a clone of the run context's catalog handle and each Pack's vendor in place of tools bound at run start, and it no longer derives `PartialEq` or `Eq`. `ToolBinding` drops its vendor, and `vendor_for` derives one from a tool id's Pack, so an adhoc tool or a Pack with no reported vendor reports none.
- `merge_tool_sets`, which `prepare_effective_scope` runs between the scope validation events, lays each Pack's tools out as one block where that Pack was first offered into a set, keeps each adhoc tool in its place, drops a Pack tool whose wire name an adhoc tool of its set takes and a Dynamic Set tool whose id the Static Set holds, refuses a wire name used twice anywhere in the two sets, and builds every tool's schema. `offered_packs` hands it each Pack's offered tools unchanged as that Pack's rendered tools, and a catalog id keeps the catalog's output kind and listing flag.
- `static_set` and `dynamic_set` replace the schema list, the wire-name map, and the dynamic id list on `RenderedTools`, which the message list now shares through an `Arc` instead of copying. Each round builds its schemas from the Static Set through `tool_schema`, and `Advertised` and the chain's per-round copy of the advertised map are gone.
- `tool_route` resolves each call to its binding once, a model call by wire name in the Static Set and a script call by id among the adhoc tools or in the catalog, and `prepare_catalog_call` only issues the call under that binding. The scope gate and each call's event read the frozen sets through the parked chain's frame.
- `Error::ToolMerge` names each clashing tool with its Pack and its set in text written for the model, and it maps to Lua kind `internal` and run error kind `Binding`, as `Error::BindSchema` does.
- `prepare_dispatch` and `prepare_model_dispatch` take the tool's vendor as a parameter, which brings each to eight parameters under an expected `clippy::too_many_arguments` lint.
- `Scenario::offer` puts its tool in the run catalog with `wire_name` set to the alias, and `Scenario::offering` adds catalog entries with no tool behind them, so the test-only `with_tool_set` swap of the run's tool set is gone.
- `tracing` leaves the Engine crate's dependencies along with the run-start warning for a tool left out of the offering.
- `tool_schema_new` accepts only `[A-Za-z][A-Za-z0-9_-]{0,63}`, so a wire name with a leading digit or underscore, a `.`, or a 65th character now fails every section that offers the tool.
- `EffectRecord::ToolCall`, the `ToolCall` effect, and the `ToolResult` event of a script call name the tool by its id, such as `tools/echo` where they named `tools_echo`.
- `tools.get`, tool objects, and Pack objects cover every catalog tool, including one no round could advertise, and offering such a tool succeeds and then fails the section at its first model call.
- `global_exists` holds only for a catalog tool's own wire name, never for an adhoc tool's name or the default name of a tool that sets its own, and the `tools.offer` hint matches catalog wire names the same way before adhoc names.
- `current_tool_bindings` and `current_dynamic_set`, renamed from `current_dynamic_tools`, dedupe offers by id only and return `OfferedTool` values, so tools that share a wire name all reach the merge. A Pack offer still expands to that Pack's catalog tools in id order.
- `drop_shadowed` and the Dynamic Set's id filter drop tools without an event or a log line.
- `RenderedTools` documents that no wire name appears twice across its two sets, but its fields are public and it derives `Default`, so nothing but the merge upholds that.
- `advertise_for_test` and the batch test that used it are removed, so `parallel_tool_calls.rs` no longer drives a batched model call that binds to nothing.

Design: replaces pure-function @ crates/runvil-internal/engine/src/execute/scope.rs::merge_tool_sets deps: OfferedTool,ToolCatalog,Vec<PackSets>
  was: crates/runvil-internal/engine/src/execute/scope.rs::prepare_scoped_tools
Design: new surface-growth @ crates/runvil-internal/engine/src/execute/scope.rs::merge_tool_sets deps: OfferedTool,ToolCatalog,Vec<PackSets>
  boundary: wire
Design: new shared-parameter-cluster @ crates/runvil-internal/engine/src/execute/scope.rs::prepare_effective_scope deps: Emitter,OfferedTool,ToolCatalog,Vec<PackSets>,str
Design: new parameter-object @ crates/runvil-internal/engine/src/execute/scheduler/chat.rs::Scope
Design: extends oversized-unit @ crates/runvil-internal/engine/src/execute/scheduler/chat.rs::Scheduler::prepare_chat
Design: new surface-growth @ crates/runvil-internal/engine/src/execute/scheduler/tool_call.rs::Scheduler::tool_route
  boundary: persisted
Design: removes swallowed-exception @ crates/runvil-internal/engine/src/execute/context-bound.rs::offered_bindings deps: RunContext
Design: removes parallel-abstraction @ crates/runvil-internal/lua/src/scope.rs::Advertised
Design: new pure-function @ crates/runvil-internal/lua/src/handles.rs::wire_name_of deps: ToolInfo
Design: extends shared-parameter-cluster @ crates/runvil-internal/lua/src/dispatch.rs::prepare_model_dispatch deps: Emitter,GuardNonce,ModelReport,Option<&ToolCallCounts>,Option<&str>,Result<ToolOutput,ToolError>,ToolBinding,str
Design: replaces shared-parameter-cluster @ crates/runvil-internal/lua/src/vm/state.rs::current_dynamic_set deps: AdhocTools,Mutex<ToolRuntime>,OfferedTool,ToolSet
  was: crates/runvil-internal/lua/src/vm/state.rs::current_dynamic_tools
Design: extends encapsulated-invariant @ crates/runvil-internal/model-client/src/detail.rs::tool_schema_new deps: Into<String>,Value
  boundary: wire
Repairs: a catalog tool's wire name is its ToolInfo.wire_name when set @ crates/runvil-internal/lua/src/handles.rs::wire_name_of - the run advertised a catalog tool that set wire_name under its default name
Plan: vibe/2026-10-25-1-tools-kernel-redo.md
The Engine now reserves two Pack ids for itself: adhoc for a section's own tools and dynamic for its discovery tools. No Pack from any vendor may take either. Both ids live in one list behind one check, so the plugin registry, the prompt parser, and the run preflight refuse the same ids. The preflight report now lists each reserved id a run names, sorted and once each, in place of a single adhoc flag. Every refusal names the id it found.

- `RESERVED_PACK_IDS` holds the reserved ids as strings, since a const cannot construct a `PackId`, and `PackId::is_reserved` checks an id's one segment against it without allocating. `PackRegistry::insert`, `RunContext::prepare`, and the renamed `check_no_reserved_pack` call `is_reserved` in place of their own string comparisons.
- `Requirements::reserved` replaces the public `reserved_adhoc` flag with the reserved ids a run's tool catalog or Pack roster names, sorted and each once. `is_satisfied` needs it empty, `merge` takes the union of both reports, and `notice` writes one line per id.
- `InstallError::ReservedPackId` replaces `AdhocPackId`, and `PackListError::Reserved` replaces `Adhoc`, each carrying the reserved id it found.
- `dynamic` now fails the same three checks as `adhoc`, from any vendor: registry insertion, the `uses:` list, and a run's tool catalog or Pack roster, usable or not. Each refusal says the Engine reserves the id it names, and the `uses:` refusal names the first reserved id declared.
- `BUILTIN` now documents that no built-in may take a reserved id, since every run would then fail its preflight, and `no_builtin_takes_a_reserved_pack_id` enforces it.
- `crates/runvil/public-api.txt` lists `PackId::is_reserved` but not `RESERVED_PACK_IDS`, so the public API can test an id without exposing the list.

Design: new surface-growth @ crates/runvil-internal/types/src/packs.rs::PackId::is_reserved
  boundary: pub
Design: replaces surface-growth @ crates/runvil-internal/engine/src/execute/requirements.rs::Requirements::reserved
  boundary: pub
  was: crates/runvil-internal/engine/src/execute/requirements.rs::Requirements::reserved_adhoc
Design: replaces surface-growth @ crates/runvil-plugin/src/registry.rs::InstallError::ReservedPackId
  boundary: pub
  was: crates/runvil-plugin/src/registry.rs::InstallError::AdhocPackId
Design: replaces pure-function @ crates/runvil-internal/parser/src/contract.rs::check_no_reserved_pack deps: PackRef
  was: crates/runvil-internal/parser/src/contract.rs::check_no_adhoc_pack
Design: removes stringly-typed @ crates/runvil-internal/engine/src/execute/config.rs::RunContext::prepare
  boundary: pub
Design: removes stringly-typed @ crates/runvil-internal/parser/src/contract.rs::check_no_adhoc_pack deps: PackRef
Design: removes stringly-typed @ crates/runvil-plugin/src/registry.rs::ADHOC
Design: new pure-function @ crates/runvil-internal/engine/src/execute/requirements-tests.rs::reserved_line deps: str
Plan: vibe/2026-10-25-1-tools-kernel-redo.md
A section whose Dynamic Set holds a tool now shows the model two Engine tools at the end of its tool list: one lists, searches, and describes that set, and the other calls any tool in it by name. A call through the second takes the same path as a call to a listed tool, so that tool counts and reports it as its own under the original call's id. A malformed discovery call, or a direct call to a Dynamic Set tool, now gets plain text that says what to do next, so the model's loop keeps going where a direct call used to fail the round. Search results count as untrusted output, because their descriptions come from the catalog Packs.

- `ToolRoute::Discovery` joins the adhoc and catalog routes inside `tool_route`'s one lookup. A model call that names a discovery tool or a Dynamic Set tool takes it, and `dispatch_tool_calls` stops a batch there as it stops at an adhoc tool, so the loop sends that call alone.
- `prepare_reached` hands a `CallDynamicTool` call's inner `arguments`, under the call's own site, to `prepare_adhoc_call` or `prepare_catalog_call`, so the target counts, reports, and answers the call as its own. The result event therefore names the target's wire name and id under the `CallDynamicTool` call id.
- `merge_tool_sets` appends `GetDynamicTools` and `CallDynamicTool` to the end of the Static Set when the Dynamic Set still holds a tool after the id filter, ahead of the clash check, so dynamic offers now add exactly those two schemas to what every round advertises. A catalog tool that takes either wire name fails the merge only in such a section.
- `discovery_tools` ends the search tool's description with the Dynamic Set's wire names, sorted, the first 100 and then a count of the rest. That description varies with the Dynamic Set but stays fixed once the section entry freezes its sets.
- `static_binding` becomes `binding` on `RenderedTools`, which looks a wire name up in the Static Set and then the Dynamic Set and returns the binding with its set as an `OfferMode`. `rendered_target` on `MessageList` and `SectionVm` passes that pair through.
- `OfferMode` now also names the frozen set a binding sits in, the role the Engine's `FrozenSet` already plays in merge errors. `tool_route` and the discovery route branch on `OfferMode`, while the merge still reports clashes by `FrozenSet`.
- `DiscoveryTool` fixes both tools' ids, `dynamic/search` and `dynamic/call`, their wire names, and the vendor `runvil`, all as plain strings. `DiscoveryTool::of`, which `vendor_for` runs on every lookup, formats the `ToolId` it checks, and `discovery_tools` parses each id back into a `ToolId` behind an internal error.
- `crates/runvil-internal/lua/src/handles.rs` now imports `DiscoveryTool` from the `scope` module, which already imports `ToolBinding` from `handles`, so the two modules depend on each other.
- `regex` joins the workspace dependencies and the Engine crate for the pattern search. The lockfile gains only the new edge, because the crate was already in the tree.
- `tool_calls` on `ArrivedRound` passes any name either frozen set holds, so a direct model call to a Dynamic Set tool, alone or in a batch, now gets text that points to `CallDynamicTool` and issues no call. A name outside both sets still fails the round, and its in-scope list names only the Static Set, discovery tools included.
- `GetDynamicTools` answers a `toolName` with that tool's description and input schema, else lists each Dynamic Set tool whose wire name the `pattern` regex matches, or every one, in Dynamic Set order with descriptions over 200 characters cut to their first 200. `toolName` wins over `pattern`, a pattern may run 256 characters, and the tool ignores other keys such as `namespace`.
- `CallDynamicTool` requires `toolName` and passes an empty object when `arguments` is absent. It refuses a Static Set name, its own included, with text that says to call that tool directly.
- `OutputTrust::Untrusted` marks a listing, which the route wraps with the run's nonce and reports as a succeeded call. The route decides every refusal before any preparer runs, so a refusal issues no call; it answers with trusted Engine text and reports a failed call under the binding the model named.
- `vendor_for` returns `runvil` for both discovery ids, so the round's tool-call event and each discovery result carry that vendor.
- `tools.calls` counts every `GetDynamicTools` call under `dynamic/search`, whatever its outcome, and every other refusal, a direct Dynamic Set call included, under `dynamic/call`. A call that reaches its target counts only under that target, and both discovery ids start at zero once rendered.
- `tools.adhoc` refuses the exact names `GetDynamicTools` and `CallDynamicTool`, so a script may still define `getdynamictools`.
- `tools.call` with a discovery id fails as an unknown id, so only the model reaches the discovery tools.
- `search` puts no cap on a listing: a call with neither `toolName` nor `pattern` returns every Dynamic Set tool, each description up to 200 characters plus the truncation marker.

Design: extends surface-growth @ crates/runvil-internal/engine/src/execute/scope.rs::merge_tool_sets deps: OfferedTool,ToolCatalog,Vec<PackSets>
  boundary: wire
Design: new surface-growth @ crates/runvil-internal/lua/src/scope.rs::DiscoveryTool
  boundary: pub
Design: new stringly-typed @ crates/runvil-internal/lua/src/scope.rs::DiscoveryTool::id
  boundary: pub
Design: replaces surface-growth @ crates/runvil-internal/lua/src/scope.rs::RenderedTools::binding
  boundary: pub
  was: crates/runvil-internal/lua/src/scope.rs::RenderedTools::static_binding
Design: extends surface-growth @ crates/runvil-internal/lua/src/vm/state.rs::SectionVm::rendered_target
  boundary: pub
Design: extends surface-growth @ crates/runvil-internal/lua/src/handles.rs::ToolSet::vendor_for
  boundary: pub
Design: new surface-growth @ crates/runvil-internal/engine/src/execute/scheduler/tool_call-discovery.rs::Listed
  boundary: wire
Design: new dispatch-on-tag @ crates/runvil-internal/engine/src/execute/scheduler/tool_call-discovery.rs::Scheduler::prepare_discovery_call
  boundary: wire
  instead-of: parallel-abstraction: a separate discovery dispatch and continuation type
Design: extends dispatch-on-tag @ crates/runvil-internal/engine/src/execute/scheduler/tool_call.rs::Scheduler::tool_route
Design: new cyclic-dependency @ crates/runvil-internal/lua/src/handles.rs
Design: extends parallel-abstraction @ crates/runvil-internal/lua/src/scope.rs::OfferMode
  boundary: pub
Design: new shared-parameter-cluster @ crates/runvil-internal/engine/src/execute/scheduler/tool_call-discovery.rs::Scheduler::prepare_discovery_call
Design: new shared-parameter-cluster @ crates/runvil-internal/engine/src/execute/scheduler/tool_call-discovery.rs::Scheduler::prepare_reached
Design: extends facade @ crates/runvil-internal/lua/src/lib.rs
  boundary: pub
Design: extends facade @ crates/runvil-internal/engine/src/lua.rs
Design: new pure-function @ crates/runvil-internal/engine/src/execute/scope.rs::discovery_tools deps: Laid
Design: new pure-function @ crates/runvil-internal/engine/src/execute/scheduler/tool_call-discovery.rs::search deps: RenderedTools,Value
Design: new pure-function @ crates/runvil-internal/engine/src/execute/scheduler/tool_call-discovery.rs::reach deps: RenderedTools,Value
Design: new pure-function @ crates/runvil-internal/engine/src/execute/scheduler/tool_call-discovery.rs::text_arg deps: Value,str
Design: new pure-function @ crates/runvil-internal/engine/src/execute/scheduler/tool_call-discovery.rs::dynamic_target deps: RenderedTools,str
Design: new pure-function @ crates/runvil-internal/engine/src/execute/scheduler/tool_call-discovery.rs::compiled deps: str
Design: new pure-function @ crates/runvil-internal/engine/src/execute/scheduler/tool_call-discovery.rs::cut deps: str
Design: new pure-function @ crates/runvil-internal/engine/src/execute/scheduler/tool_call-discovery.rs::listing deps: Serialize
Design: new pure-function @ crates/runvil-internal/engine/src/execute/scheduler/tool_call-discovery.rs::json_kind deps: Value
Design: new pure-function @ crates/runvil-internal/engine/src/execute/tests/discovery.rs::model_call deps: str
Design: new pure-function @ crates/runvil-internal/engine/src/execute/tests/discovery-refusals.rs::refusals deps: str
Plan: vibe/2026-10-25-1-tools-kernel-redo.md
Scripts now read a section's tool call counts through one read-only table that reports a total, a count per Pack, and a count per tool the script can see. Each read computes its answer from the single count map keyed by tool id, so no stored total or per-Pack counter can drift from it. Searches and refusals by the discovery tools, and calls to tools only the model sees, count toward the total and their Pack but stay out of the per-tool table, so a script can judge progress without knowing those names. Tools offered dynamically now start at zero like static ones, where before they had no starting entry. The old per-tool counter table is gone.

- `Stats` holds the count map, the run catalog's ids, and the adhoc tools, and each read of `total`, `packs`, or `tools` computes from one fresh `snapshot` of the map. Nothing stores a per-Pack or total counter.
- `View` tags the `packs` and `tools` tables, so one proxy builder, one ordered walk, and one unknown-key error serve both.
- `snapshot` replaces the `get` and `names` readers on `ToolCallCounts` and clones the whole map under one lock.
- `rendered_ids` now lists the Dynamic Set's ids after the Static Set's. `section_call_counts` seeds the discovery, run catalog, and adhoc ids among them, or the live static and dynamic offers before the freeze, so a dynamically offered tool starts at zero.
- `tools.stats.tools` holds only run catalog and adhoc ids and checks the adhoc tools at each read, so discovery and model-only ids stay out of it, its walk, and its unknown-key error's list.
- `tools.stats.packs` takes an id's Pack as the text before its first slash, so both discovery ids sum under `dynamic`.
- `pairs` walks `tools.stats` and both of its tables in key order, and each step of a table's walk recomputes the table from a fresh snapshot.
- `__metatable` protects all three proxies with the text `<path> is read-only`, which every write raises. `tools.stats` rejects any key but `total`, `packs`, and `tools`, and each table's unknown-key error opens with its own path.
- `tools.calls` is no longer installed, and this change keeps no alias for the old name.

Design: replaces surface-growth @ crates/runvil-internal/lua/src/tools/stats.rs::install_tool_call_counts deps: AdhocTools,Lua,ToolId,ToolSet
  boundary: pub
  was: crates/runvil-internal/lua/src/tools.rs::install_tool_call_counts
Design: new parameter-object @ crates/runvil-internal/lua/src/tools/stats.rs::Stats
Design: replaces stringly-typed @ crates/runvil-internal/lua/src/tools/stats.rs::Stats
  was: crates/runvil-internal/lua/src/tools.rs::install_lua_tool_calls
Design: new dispatch-on-tag @ crates/runvil-internal/lua/src/tools/stats.rs::Stats::view
Plan: vibe/2026-10-25-1-tools-kernel-redo.md
The protected producer timeout test sometimes failed on Windows: the producer returned after 29.3 ms of its 30 ms budget. This occurred because `wait_timeout` can report a timeout before `deadline`, and the producer then rejected its record at once. After a timeout, the producer now rejects its record only when `Instant::now() >= deadline`, and otherwise tries again for the remaining budget.

- A timeout after the budget is spent goes to the same rejection path as before and does not check for free space again.
- The diff adds no test, because nothing in `admit.rs` can force `wait_timeout` to report an early timeout.

Plan: vibe/2026-10-25-1-tools-kernel-redo.md
Every tool call's origin now names the section entry that made the call, and a model call's origin also names the model of the round that requested it. Two entries of one section share its name, so the entry id tells their calls apart, as with a re-entered section or two fan-out arms. The engine records the model name on the section's message list when it sends a round, and reads it back when it issues that round's calls. Both new fields are optional in the logged shape, so run logs written before them still read. The change also corrects stale comments about which name a tool call reports and what a section reads as its model.

- `ToolCallOrigin` gains optional `entry` and `model` fields, with `with_entry` and `with_model` builders in the facade. Serialization leaves each field out when it is absent, and an origin logged without them reads both as `None`.
- `MessageList` keeps the model name beside its last send: `commit` takes the name part of the round binding's id, and `last_model` reads it back. `prepare_catalog_call` relies on a section running one loop at a time, so no other round commits to the list between a round's answer and the issue of its calls.
- `SectionVm` now exposes `messages` as a public method outside tests, so the scheduler can read a frame's list through its VM.
- `entry_id` on `SectionContext` reads the entry id from the frame's `sys` JSON by its `id` key, and returns an internal error when `sys` holds no string id.
- `prepare_catalog_call` reads the frame, its entry id, and for a model call the VM's list after it counts the attempt, so an internal error from any of these returns with the count already taken.
- `Effect::ToolCall`, `EffectRecord::ToolCall`, and `ToolPerformer` docs now say that `alias` is the wire name for a model call and the tool's id for a script call. The `sys.model` docs and test names now call its value the bound model's name, without the server namespace.

Design: extends surface-growth @ crates/runvil-internal/types/src/tools/origin.rs::ToolCallOrigin
  boundary: persisted
Design: new stringly-typed @ crates/runvil-internal/engine/src/execute/section_context.rs::SectionContext::entry_id
Design: extends shared-mutable-state @ crates/runvil-internal/lua/src/messages.rs::MessageList
  boundary: pub
Design: new surface-growth @ crates/runvil-internal/lua/src/vm.rs::SectionVm::messages
  boundary: pub
Design: extends temporal-coupling @ crates/runvil-internal/engine/src/execute/scheduler/tool_call.rs::Scheduler::prepare_catalog_call
  instead-of: shotgun-surgery: threading the model name through the loop and the call site
Design: extends pure-function @ crates/harness-internal/runner/tests/it/prepare-prelude.rs::script_call deps: str
Design: new pure-function @ crates/runvil-internal/engine/src/execute/tests/discovery.rs::model_origin
Design: extends pure-function @ crates/runvil-internal/engine/src/execute/tests/effects.rs::origin deps: ToolCaller,str
Design: new pure-function @ crates/runvil-internal/engine/src/execute/tests/models_loop.rs::loop_model_name deps: str
Design: new pure-function @ crates/runvil-internal/engine/src/execute/tests/offering.rs::model_origin deps: str
Design: new pure-function @ crates/runvil-internal/engine/src/execute/tests/tool_call_origins.rs::grab_then_echo
Design: new pure-function @ crates/runvil-internal/engine/src/execute/tests/tool_call_origins.rs::grab_then_loop deps: str
Design: new pure-function @ crates/runvil-internal/engine/src/execute/tests/tool_call_origins.rs::models deps: RigTrace
Design: new pure-function @ crates/runvil-internal/engine/src/execute/tests/tool_call_origins.rs::origins deps: RigTrace
Design: new pure-function @ crates/runvil-internal/engine/src/execute/tests/tool_call_origins.rs::request_models deps: ScriptedChat
Design: new pure-function @ crates/runvil-internal/engine/src/execute/tests/tool_call_origins.rs::script_call deps: str
Plan: vibe/2026-10-25-1-tools-kernel-redo.md
A section entry's first model round that offers catalog tools now asks its caller to render them before the round is sent. The render is its own effect, so the Engine stays pure and the run log records exactly what each Pack showed the model. The Engine checks the answer against what it asked and merges it with the section's own tools into the entry's frozen sets, which every later round of the entry reuses. The Harness and the test drivers answer by passing the offered tools through unchanged, so models see the tools they saw before. Model bindings now hold the catalog's description of their model, which a render receives.

- `Effect::Render` holds the section entry, the binding of the entry's first round, and each offered catalog Pack once with the tools offered from it into each set, and `EffectAnswer::Render` returns each Pack's rendered tools in the order asked. Both enums stay closed, so a match over them must name the render.
- `PackToolSets` replaces the merge's internal `PackSets` with a public generic that the facade's `effect` module re-exports: the effect and its answer hold `ToolInfo`, and the effect's record holds `ToolId`.
- `effect-record.rs` takes the record types out of the effect module into a private child module that the effect module re-exports, so every existing path holds.
- `Continuation::Render` holds only the round's binding, because a handle round's binding exists nowhere in VM state and resolving the section's model again could pick a default another chain set meanwhile. `merge_render` reads the offers and the message list back from the parked frame, whose VM runs no Lua while the chain is parked.
- `SectionOffers` bundles a section's static offers, its dynamic offers, and its catalog Packs in the order the section first offered one of their tools, into either set, in place of the two offer slices that `offered_packs`, `prepare_effective_scope`, and `merge_tool_sets` each took. `current_catalog_packs` reads that order through the same expansion both sets read, and a render asks its Packs in it.
- `prepare_chat` issues the render after the emptiness check, the binding, and the offer expansion but before the projection and the precheck, and the render's answer dispatches the round again, so the round is projected, prechecked, numbered, and recorded once. A section whose offers name no catalog tool merges at once and issues no render.
- `check_render` refuses a render answer whose Packs differ from the ones asked, in order, and `check_set` refuses a tool in a set its Pack was not offered into, under another Pack's id, or from the run catalog but not offered in that set, keeping the first copy of a repeated tool. A refused answer fails the round with the merge error and fixes no tools, so the next round renders again.
- `dropped_answer` maps a dropped render to the round's cancelled chat answer, so Lua sees the cancellation error, no tools are fixed, and the next round renders again.
- `EffectRecord::Render` records the slot's alias, the entry, and the offered tool ids, and `AnswerRecord::Render` keeps the rendered tools whole because the `Chat` record keeps only tool names. A logged render answer fed to a fresh run rebuilds the same round tools byte for byte, and records written before the render existed read unchanged.
- `render_info` returns the model info a render receives: the catalog's, which `bound_model_set` now attaches to each filled role through the new public `with_catalog_info`, or else one built from the binding's id, description, context window, and provider that claims `ThinkingMode::Never`. A raw gateway model id takes that fallback, and `with_invocation` keeps the catalog's.
- `effect_loop.rs` answers a render with the offered tools unchanged, as a future outside the tool-call cap, and the engine rigs, the facade's suite driver, and the greeter example answer it the same way.
- `render_info` is called only from tests in this change, and every driver it updates ignores the render's entry and binding.

Design: new surface-growth @ crates/runvil-internal/engine/src/execute/run/effect.rs::Effect::Render
  boundary: pub
Design: new surface-growth @ crates/runvil-internal/engine/src/execute/run/effect.rs::EffectAnswer::Render
  boundary: pub
Design: new surface-growth @ crates/runvil-internal/engine/src/execute/run/effect-record.rs::EffectRecord::Render
  boundary: persisted
Design: new surface-growth @ crates/runvil-internal/engine/src/execute/run/effect-record.rs::AnswerRecord::Render
  boundary: persisted
Design: new surface-growth @ crates/runvil-internal/engine/src/execute/run/effect-record.rs::PackToolSets
  boundary: persisted
Design: new surface-growth @ crates/runvil-internal/model-client/src/model/options.rs::ModelBinding::render_info
  boundary: pub
Design: new surface-growth @ crates/runvil-internal/model-client/src/model/options.rs::ModelBinding::with_catalog_info
  boundary: pub
Design: extends dispatch-on-tag @ crates/runvil-internal/engine/src/execute/scheduler/apply.rs::Scheduler::apply_answer
Design: new oversized-unit @ crates/runvil-internal/engine/src/execute/scheduler/apply.rs::Scheduler::apply_answer
Design: extends dispatch-on-tag @ crates/harness-internal/runner/src/effect_loop.rs::Driver::perform
Design: extends oversized-unit @ crates/runvil-internal/engine/src/execute/scheduler/chat.rs::Scheduler::prepare_chat
Design: new temporal-coupling @ crates/runvil-internal/engine/src/execute/scheduler/chat.rs::Scheduler::merge_render
Design: new parameter-object @ crates/runvil-internal/engine/src/execute/scope.rs::SectionOffers
Design: new pure-function @ crates/runvil-internal/engine/src/execute/scope.rs::check_render deps: PackToolSets,ToolCatalog
Design: new pure-function @ crates/runvil-internal/engine/src/execute/scope.rs::check_set deps: FrozenSet,PackId,ToolCatalog,ToolInfo
Design: new pure-function @ crates/runvil-internal/engine/src/execute/scope.rs::set_of deps: FrozenSet,PackToolSets
Design: new pure-function @ crates/runvil-internal/engine/src/execute/run/effect.rs::offered_ids deps: PackToolSets
Design: new pure-function @ crates/runvil-internal/engine/src/error/merge.rs::pack_list deps: PackId
Design: extends shared-parameter-cluster @ crates/runvil-internal/lua/src/vm/state.rs::current_catalog_packs deps: AdhocTools,ToolRuntime,ToolSet
Plan: vibe/2026-10-25-1-tools-kernel-redo.md
The Harness now answers a section entry's render by asking each Pack the section offered tools from what the model should see, once for each set the Pack was offered into, with the section entry and the model of the entry's first round. A render may rename a Pack's tools for that model and add tools only the model sees, and every tool call now goes to the Pack its tool id names, so the model can call those added tools although no catalog lists them. Each tool call now holds its own stop flag, taken from the section's binding of the tool: a catalog tool keeps the catalog's flag whatever the render sets, and a model-only tool takes the flag its render gave it. Two sections on different models can therefore show one Pack's tools under different names, and a stop spares only the calls whose flag says they survive it.

- `ToolPerformer` gains `render`, whose default passes every offered tool through, and loses `survives_stop`, so the eight test doubles only drop that method while `HostRunContext` overrides `render`.
- `Effect::Render` now goes to the tool performer inside the effect's own future, with `binding.render_info()` as the model, so a panicking render answers `Dropped` and fails its round cancelled, as a panicking tool call does.
- `Effect::ToolCall` gains a public `survives_stop` field that the scheduler fills from the binding the call resolved to, and the effect loop reads it when it queues the call instead of asking the performer.
- `ToolBinding` gains the stop flag, and `bind` copies the catalog's flag onto a rendered catalog tool beside its output kind and listing flag, so only a model-only tool keeps the stop flag its render returned.
- `HostRunContext` finds the Pack for every call by the tool id's Pack segment and for every render by the offered Pack's id, through `usable` in place of the catalog lookup in `offering`, so a call reaches a tool only a render added the same way it reaches a catalog tool.
- `RenderContext` gives each Pack's render the section entry and the model of the entry's first round. The Harness asks one Pack at a time in the order offered, once per non-empty set with the Static Set first, and answers every Pack asked in that order.
- `usable` returns nothing for a Pack the run cannot use. A call to such a Pack still fails with a tool error, but a render that names one logs an error and passes that Pack's tools through unchanged instead of failing the round.
- `EffectRecord` still leaves the stop flag out of a tool call's record, as it leaves out the access handle, because the run catalog and the render's logged answer already give it.
- `crates/harness-internal/runner/tests/it/render.rs` runs a test Pack under the Harness whose render renames each offered tool after the round's model and adds a model-only tool. Its tests pin that the model can call both tools, the model-only one also through `CallDynamicTool`, while Lua's `tools.call` refuses the model-only id and `tools.stats` counts its calls under its Pack but never under its own id.
- `prompts/research-person.md` names its tools by id, `web/search` and `web/fetch`, instead of by their default wire names, because a render may rename a tool for each section's model.
- `BUILTIN`'s comment now rests its unreachable `call` on the Engine never issuing a call under a built-in's id: a built-in lists no tools, so no section offers one and no render adds one.
- `HostRunContext` does not check what a Pack's render returns; the Engine checks each answer against the Packs it asked and the sets it offered, and fails the round when an answer breaks a rule.

Design: extends surface-growth @ crates/runvil-internal/engine/src/execute/run/effect.rs::Effect::ToolCall
  boundary: pub
Design: extends strategy @ crates/harness-internal/runner/src/effect_loop.rs::Driver::perform
Design: extends registry @ crates/harness-internal/runner/src/host-run.rs::HostRunContext
Design: new surface-growth @ crates/harness-internal/runner/src/host-run.rs::HostRunContext::render
  boundary: pub
Design: new swallowed-exception @ crates/harness-internal/runner/src/host-run.rs::HostRunContext::render
Design: extends value-object @ crates/runvil-internal/lua/src/handles.rs::ToolBinding
Design: extends pure-function @ crates/runvil-internal/engine/src/execute/scope.rs::bind deps: ToolCatalog,ToolInfo
Design: new pure-function @ crates/harness-internal/runner/src/host-tests.rs::listed_tool deps: str
Design: new value-object @ crates/harness-internal/runner/src/host-tests-render.rs::Rendered
Design: new shared-mutable-state @ crates/harness-internal/runner/src/host-tests-render.rs::Rendering
Design: new pure-function @ crates/harness-internal/runner/src/host-tests-render.rs::model
Design: new pure-function @ crates/harness-internal/runner/src/host-tests-render.rs::sets deps: str
Design: new pure-function @ crates/harness-internal/runner/src/host-tests-render.rs::owned deps: str
Design: new pure-function @ crates/harness-internal/runner/src/host-tests-render.rs::ids deps: PackToolSets
Design: new pure-function @ crates/harness-internal/runner/tests/it/scripted.rs::scripted_model
Design: new value-object @ crates/harness-internal/runner/tests/it/render.rs::Rendered
Design: new value-object @ crates/harness-internal/runner/tests/it/render.rs::Called
Design: new shared-mutable-state @ crates/harness-internal/runner/tests/it/render.rs::Seen
Design: new pure-function @ crates/harness-internal/runner/tests/it/render.rs::construct deps: HostServices,PackId,Value
Design: new pure-function @ crates/harness-internal/runner/tests/it/render.rs::tool deps: str
Design: new pure-function @ crates/harness-internal/runner/tests/it/render.rs::echo
Design: new pure-function @ crates/harness-internal/runner/tests/it/render.rs::shown deps: ToolInfo,bool,str
Design: new pure-function @ crates/harness-internal/runner/tests/it/render.rs::prompt deps: str
Design: new pure-function @ crates/harness-internal/runner/tests/it/render.rs::call deps: Value,str
Design: new pure-function @ crates/harness-internal/runner/tests/it/render.rs::calling deps: ToolCall,str
Design: new pure-function @ crates/harness-internal/runner/tests/it/render.rs::answered deps: Record,str
Design: new pure-function @ crates/harness-internal/runner/tests/it/render.rs::outcome_of deps: Value
Design: new pure-function @ crates/harness-internal/runner/tests/it/render.rs::events deps: Record,str
Design: new shared-parameter-cluster @ crates/harness-internal/runner/tests/it/render.rs::run deps: ScriptedBroker,Seen,Value,str
Design: new oversized-unit @ crates/harness-internal/runner/tests/it/render-calls.rs::two_models_get_their_own_renders_and_a_stop_spares_only_the_surviving_call
Plan: vibe/2026-10-25-1-tools-kernel-redo.md
Files that named the standard debug-formatting trait by its full standard-library path, or brought in its bare name through a group import, now name it through the formatting module, which each of them imports. A new build-tooling check reads every Rust source file in every workspace crate and refuses both forms, naming the file, the line, and the fix, so the trait keeps one spelling. No lint can tell the spellings apart, because both resolve to the same trait, so the check reads source text, and comments, doc comments, string literals, and macro bodies count the same as code. The respelling and the check land together because the check's workspace test fails until the respelling is done.

- `debug_spelling_violations` joins `all_violations` after the doctest check and has its own workspace test, so both `cargo test -p build-xtask` and `cargo xtask tidy` apply the rule.
- `DEBUG_PATH` joins two string halves with `concat!` so the checker's own source never spells the path it refuses, and the tests write those spellings into their fixtures through it and `BARE` with `format!`.
- `slash_path` becomes `pub(crate)` so the new check names each file by its workspace-relative path with `/` separators, which reads the same on every platform.
- `crates/gateway/app/src/commands.rs` takes its `use std::fmt;` under `#[cfg(test)]`, because its one use is the test-only `ExecutorOverride` impl and an unconditional import would go unused in a normal build. `crates/gateway/stt/whisper-ffi/src/lib.rs` takes the import inside its inline test module for the same reason.
- `use std::fmt::{self, Display};` replaces `use std::fmt::{Debug, Display};` in `crates/runvil-internal/engine/src/test_support/seeded.rs` and `crates/runvil-internal/types/src/test_support.rs`, and `crates/gateway/stt/engine/src/decoder.rs` narrows `use std::fmt::{self, Debug};` to `use std::fmt;`, so none of the three imports the bare trait name. Their bare `Debug` bounds, the `RequestSamples` impl header, and the `ModelFactory` supertrait now read `fmt::Debug`.
- `debug_spelling_violations` also scans the crates whose manifest the shared walk could not read, reports an unreadable file as a violation instead of skipping it, and fails with one violation when it finds no `.rs` file, so a scan that read nothing cannot pass.
- `holds_debug_path` matches the long path only when no identifier character touches either end, so it catches a `::`-rooted path but passes the `Debug`-prefixed builders and lookalikes such as a `mystd` crate. `source_violations` reports a line once however many refused forms it holds.
- `group_debug_lines` refuses a `std::fmt` group import that lists `Debug` at its top level, bare or renamed with `as`, on one line or across several, and reports the line that holds the item, while `self`, `Display`, and the `Debug`-prefixed builders in a group pass.
- `a_re_export_rooted_outside_the_facade_dependencies_is_rejected` now feeds `pub use std::sync::Arc;` as its `std`-rooted case, which keeps the case and drops the refused spelling from a string literal that the new check reads.
- `std::fmt::Formatter` and `std::fmt::Result` stay as written beside every respelled impl header, as does `use std::fmt::Write as _;`, because only the trait takes the short spelling.

Design: new pure-function @ crates/build-xtask/src/debug_spelling-tests.rs::violation_at deps: str,usize
Design: new pure-function @ crates/build-xtask/src/debug_spelling.rs::source_violations deps: str
Design: new pure-function @ crates/build-xtask/src/debug_spelling.rs::holds_debug_path deps: str
Design: new pure-function @ crates/build-xtask/src/debug_spelling.rs::group_debug_lines deps: str
Design: new pure-function @ crates/build-xtask/src/debug_spelling.rs::debug_item_line deps: str,usize
Design: new pure-function @ crates/build-xtask/src/debug_spelling.rs::is_boundary deps: Option<char>
Design: extends facade @ crates/build-xtask/src/tidy.rs::all_violations deps: Path
Plan: vibe/2026-10-25-1-tools-kernel-redo.md
Plan: vibe/2026-10-25-1-tools-kernel-redo.md
Many of the engine's doc comments and its overview had drifted from the code, so this rewrites them to say what the code does today. The overview now separates the work that leaves the engine as an effect from the work that runs inside it, and names the inputs a repeatable run depends on. The scheduler docs now treat the H1 pass and the walk after it as two records under one lineage, and count the wait timers among the users of a chain's child ids. The crate-private error type also drops markers that only matter to other crates, and its conversion back from the public run error. No error message, display text, or runtime behavior changes.

- `Error` drops `#[non_exhaustive]` from the enum and its 13 variants, along with the doc sentence that justified it. The type is `pub(crate)`, and the attribute has no effect inside its defining crate.
- `From<RunError> for Error` is deleted. `From<Error> for RunError` remains, so conversion runs one way, from the internal error to the public one.
- `admit_chain` binds the task chain's owner as `owner` instead of `spawner`. Its doc says the start event fires on the owner's sequence, which is the root walk for a task the H1 pass spawned.
- `crates/runvil-internal/engine/README.md` and the crate doc say a model round, Pack render, catalog tool call, `store` operation, or timer leaves as an effect. The README adds that adhoc tool calls, `GetDynamicTools` calls, refused discovery calls, and Pack prelude store calls run inside the Engine as events only, and that a repeatable run also needs the same `RunContext` inputs, seed and start time included.
- `RunErrorKind` is the one place each kind is documented, and the `RunResult` doc points to it instead of keeping its own list. `RequirementsUnmet` names its four causes, `Determinism` says Lua cannot catch it, and `Quota` and `LuaQuota` cover only the log-event and log-byte quotas.
- `RunResult::Cancelled` also covers a dropped effect whose cancelled error no `pcall` caught, and `EffectAnswer::Dropped` says the same.
- `ModelRequired` is documented as a `models.infer` or `models.loop` round with no `models.use` selection and no `models.default`, and its `section` field as the H1 title for a round in the H1 pass. The Lua variant's doc now describes an author-fixable condition with no `mlua` cause and sends Engine misuse to `Error::Internal`.
- `ChainKind::Task` and `TaskSlot` name the owner as the spawner, or the root walk for a task the H1 pass spawned, because the pass and the walk share root lineage `0` as two arena records. That wording replaces "one chain" across the scheduler modules.
- `next_child` is documented as shared by `call` children, spawned tasks, and the wait shims' timers. The `TaskSlot` and `TaskState` docs describe a timer's slot: the `timer` placeholder target, an empty outcome, and an `ok` that is always `true`.
- `resuming` is documented as holding parked chains that gave their admission slots back, call chains included, each keeping its Lua VM and admitted ahead of every fresh spawn.
- `MAX_CALL_DEPTH` is documented as counting spawned tasks and fanout arms as well as `call` children.
- `setup_section_vm` lists the full setup sequence, now including the `ui()` global and the section's `models.loop` shim, and the other copies of that list point to it. Its callers are named frame constructors, one for a walked section and one for the H1 pass, instead of the walk and the fanout arm.
- `report_model_turn` is the one place a round's event sequence is spelled out. The `chat` module, `accept_chat`, `ArrivedRound::served`, and `accept_infer_completion` docs point to it.
- `RunErrorKind::Cancelled`, `RunErrorKind::Substitution`, and `RunError::is_cancelled` keep their existing docs.

Deferred: RunErrorKind::Cancelled, RunErrorKind::Substitution, and RunError::is_cancelled keep their old docs.
Plan: vibe/2026-10-26-1-engine-slop-cleanup.md
The run now builds its shared state by consuming the caller's run context whole, instead of borrowing it after moving a few inputs out, and each chain's event emitter and turn counter travel together as one value through the run state, the section frame, and each arrived model round. The engine drops three modules that only forwarded names from other crates, and the model keyword spellings move to the parser crate so one copy remains. A Pack listed as both usable and unavailable no longer keeps a vendor, and a section whose first tool call fails while binding its model now redoes that setup on its next tool call instead of skipping it for good. Inside the crate the run limits are read as fields rather than through private getters, and several docs shrink to one line that names the setter or crate responsible.

- `RunState::new` takes the `RunContext` by value and destructures it, and `prepare_state` and `Run::new` pass it through by value, so `OwnedInputs` and its move-out step are gone. `Run::new` and `Run::new_for_test` clone the cancel handle first, so a stillborn run still holds it.
- `ReportingHandles` now lives beside `RunState` and replaces the separate `emitter` and `turns` fields of `RunState`, `SectionContext`, and `ArrivedRound`. `with_task` builds a fresh one, and `reporting_handles` returns a reference that the chat path clones.
- `crate::lua` re-exports the 15 coroutine protocol names, and every import that went through `crate::execute::protocol` now goes through it. `protocol.rs`, `tools.rs`, and `untrusted.rs` are deleted, and `ToolCatalog` and `GuardNonce` are imported from `runvil_types` where they are used.
- `runvil_parser::detail` gains `keyword_name` as a public function with a total match, and both the model set and the fill call it. The enum is defined in that crate, so a new keyword fails to compile there until it has a spelling, where the engine's copy reported `unknown`.
- `RunLimits` makes its six fields `pub(crate)`, renames `concurrency` to `max_concurrency`, and drops the four crate-private getters, so engine code reads the fields directly. Outside the crate the fields stay private, and the builders, `response_bytes`, and `timeout` are unchanged.
- `NonZero::new` in a `const` block replaces the `nz!` macro and its three generated constructors, so a zero default still fails at compile time.
- `prompt_arc`, `args_shared`, and `argv_shared` give way to `prompt`, `args`, and `argv`, which return `&Arc<Prompt>`, `&Arc<str>`, and `Option<&Arc<serde_json::Value>>`, and callers clone when they need ownership. `RunState` also drops its `models` view field, and `models()` reads `model_set` directly.
- `bound_tool_set` and `bound_model_set` take the catalog, roster, vendors, and bindings they read instead of the whole context.
- `install_first_call_state`, renamed from `install_section_scope`, returns the counts instead of writing the slot, and `tool_call_counts`, renamed from `script_call_counts`, stores them only after the install succeeds.
- `RunContext::packs` builds the roster first and keeps a vendor only for a name the roster maps to no reason. A Pack in both the usable and the unavailable list counts as unavailable and has no vendor, which `a_pack_in_both_roster_lists_records_no_vendor` pins.
- `tool_call_counts` leaves the counts slot empty when the model step or the `sys` re-seal fails, so the section's next tool call runs the whole install again under the selection current at that call. `a_first_tool_call_whose_model_step_fails_leaves_the_install_to_the_next_call` pins this through `sys.model`.
- `fill_model_bindings` reports the same words as before: `required` comes from `keyword_name`, and `actual` is `Never` or `Always`, written in the failing arm, so `thinking_name` goes.
- `Run::new` names its context parameter `config`, which changes its line in `crates/runvil/public-api.txt`; its types are unchanged.
- `max_tool_iterations` docs now describe the cap per `models.loop` or model handle `loop` call rather than per section, and say `models.infer` rounds do not count. No test in this change pins that per-call cap.

Design: removes shim @ crates/runvil-internal/engine/src/execute/protocol.rs
Design: removes facade @ crates/runvil-internal/engine/src/tools.rs
Design: removes facade @ crates/runvil-internal/engine/src/untrusted.rs
Design: extends facade @ crates/runvil-internal/engine/src/lua.rs
Design: new bag-of-state @ crates/runvil-internal/engine/src/execute/config-limits.rs::RunLimits
  boundary: pub
Design: removes pure-function @ crates/runvil-internal/engine/src/execute/config-limits.rs::nz_u32 deps: u32
Design: removes pure-function @ crates/runvil-internal/engine/src/execute/config-limits.rs::nz_u64 deps: u64
Design: removes pure-function @ crates/runvil-internal/engine/src/execute/config-limits.rs::nz_usize deps: usize
Design: removes parameter-object @ crates/runvil-internal/engine/src/execute/context.rs::OwnedInputs
Design: replaces parameter-object @ crates/runvil-internal/engine/src/execute/context.rs::ReportingHandles
  was: crates/runvil-internal/engine/src/execute/section_context.rs::ReportingHandles
Design: extends shared-parameter-cluster @ crates/runvil-internal/engine/src/execute/run.rs::prepare_state deps: Arc<Prompt>,RunContext,str
Design: extends pure-function @ crates/runvil-internal/engine/src/execute/context-bound.rs::bound_tool_set deps: BTreeMap,Prompt,ToolCatalog
Design: extends pure-function @ crates/runvil-internal/engine/src/execute/context-bound.rs::bound_model_set deps: ModelBindings,Prompt
Design: replaces pure-function @ crates/runvil-internal/parser/src/detail.rs::keyword_name deps: ModelKeyword
  was: crates/runvil-internal/engine/src/execute/context-bound.rs::keyword_name
Design: removes pure-function @ crates/runvil-internal/engine/src/execute/fill.rs::thinking_name deps: ThinkingMode
Design: replaces temporal-coupling @ crates/runvil-internal/engine/src/execute/section_context.rs::SectionContext::tool_call_counts
  was: crates/runvil-internal/engine/src/execute/section_context.rs::SectionContext::script_call_counts
Repairs: an unavailable Pack has no vendor @ crates/runvil-internal/engine/src/execute/config.rs::RunContext::packs - a name in both lists kept the usable entry's vendor
Repairs: the counts slot means a fully installed first call @ crates/runvil-internal/engine/src/execute/section_context.rs::SectionContext::tool_call_counts - after a failed first model step, `sys.model` never took the bound model
Plan: vibe/2026-10-26-1-engine-slop-cleanup.md
The scheduler kept two collections for effects the caller still owed an answer, a pending map and an orphaned set, and now keeps one effect table whose entries are either parked or orphaned. The task arena becomes an ordered map so one owner's tasks list in spawn order, and a task's owner, outcome, and progress note now live only on its slot. Chat and tool-call dispatch share one result type, and a store continuation carries just the operation. Aborting an effect the run is not waiting on no longer records an orphan, so a stray answer for that id still fails the run.

- `Outstanding` is the effect table's entry: `Parked` holds the chain and its continuation, and `Orphaned` marks an answer still owed but discarded on arrival. `resume` removes the entry once and hands a parked entry to `apply_answer`, which no longer looks it up.
- `tasks: BTreeMap<TaskId, TaskSlot>` orders the arena by task id, so one owner's slots iterate in spawn order and the sorts in `live_tasks_of` and `abandon_owned_tasks` are gone.
- `ChainKind::Task` no longer records the owner. The owner lives only on `TaskSlot`, `enclosing` reads it from there for admission's ancestor walk, and `ChainKind::owner` and `ChainKind::enclosing` are gone.
- `TaskBacking::Chain` carries the backing chain and its `exec` identity in place of the slot's optional `exec`, so a timer slot has no identity field to read.
- `SlotState` holds a finished task's outcome inside `Done` in place of a separate outcome field, and `SlotState::view` derives the copyable `TaskState` that status reporting and the test hooks read. The two enums mirror each other variant for variant.
- `Dispatched` replaces the identical `ChatDispatch` and `ToolCallDispatch` enums as the one result of a leaf dispatch's preparation.
- `Continuation::Vfs` carries the bare `VfsOp`, and the success and failure observations are derived when the answer is applied rather than stored with the continuation.
- `MAX_CHAINS` is the chain-count bound, and only test builds keep the `max_chains` field a test shrinks.
- `vfs_observations` and `classify_vfs_failure` move into `apply.rs` as private functions, and `unbound_tool_call` moves into `tool_call.rs` the same way.
- `prepare_infer` is the new name of `issue_infer`, matching the other preparers.
- `abort_effect` does nothing for an id with no entry, so an answer for an effect the run never issued, or already settled, still ends the run with an internal error instead of being discarded as an orphan's.
- `Outstanding::Parked` entries are what the stall check looks for: it scans the whole table each step instead of testing a map for emptiness, since orphaned entries are not in-flight work.
- `enclosing` reports no enclosing chain for a task chain whose slot is missing, so its admission counts against no ancestor until the slot exists. `wedge_admission_for_test` now inserts a slot for each chain it wedges.
- `dispatch_note` records the note on the slot of the task the chain runs inside, so a `call` child's note is its task's. A note from the main walk or its `call` children now records nothing.
- `SlotState::Delivered` is swapped in for every state during delivery, and every state but `Done` is put back as it was, so only a finished slot ends delivered and the errors match the old ones.
- `start_chain` compares against `MAX_CHAINS` only outside test builds, so no test exercises the shipped bound check.
- `aborting_an_effect_that_is_not_outstanding_records_no_orphan` covers only a never-issued id. No test in this change aborts an effect whose answer already arrived.

Design: new parallel-abstraction @ crates/runvil-internal/engine/src/execute/scheduler/tasks.rs::SlotState
Design: removes parallel-abstraction @ crates/runvil-internal/engine/src/execute/scheduler/chat.rs::ChatDispatch
Design: removes parallel-abstraction @ crates/runvil-internal/engine/src/execute/scheduler/tool_call.rs::ToolCallDispatch
Design: new feature-flag @ crates/runvil-internal/engine/src/execute/scheduler.rs::Scheduler::max_chains
Design: replaces pure-function @ crates/runvil-internal/engine/src/execute/scheduler/apply.rs::vfs_observations deps: VfsOp
  was: crates/runvil-internal/engine/src/execute/scheduler/dispatch.rs::vfs_observations
Design: replaces pure-function @ crates/runvil-internal/engine/src/execute/scheduler/apply.rs::classify_vfs_failure deps: VfsError,VfsOp
  was: crates/runvil-internal/engine/src/execute/scheduler/dispatch.rs::classify_vfs_failure
Design: replaces pure-function @ crates/runvil-internal/engine/src/execute/scheduler/tool_call.rs::unbound_tool_call deps: ToolSet,str
  was: crates/runvil-internal/engine/src/execute/scheduler/dispatch.rs::unbound_tool_call
Repairs: an abort orphans only an outstanding effect @ crates/runvil-internal/engine/src/execute/scheduler/chain.rs::Scheduler::abort_effect - an answer for a never-issued effect id after its abort was discarded instead of failing the run
Plan: vibe/2026-10-26-1-engine-slop-cleanup.md
A chain now receives its storage access capability when it starts, so a live chain holds one from its first step and the separate install after start is gone. What a suspended chain is parked on is now one value that carries its join set or its batch answers, and task status derives its report from that value without changing what it shows. Everything tied to an entered section, from the frame and block position to the pending prose and the running block with its delivered answer, now lives in one value built at entry and dropped when the chain leaves the section. Delivering an answer to a chain with no suspended block now fails with an internal error at the delivery itself, and every dispatch and wake path returns that error.

- `ChainStart` carries the chain's `access` capability into `start_chain`. The H1 pass, the walk after it, `call` children, and spawned tasks pass the capability they already hold, and the root walk acquires its own before it starts the chain.
- `Parked` replaces the separate `blocked`, `waiting_on`, and `batch` fields on `Chain`, and its `Tasks` and `Batch` variants carry the join set and the batch slots. `blocked()` projects it onto `Blocked` for `tasks.status`.
- `Entered` holds the frame, the block index, the pending prose, and an optional `Running` with the coroutine and its delivered answer. It declares `running` before `frame`, so dropping an entered section drops a suspended coroutine before its frame.
- `settle_block` settles one coroutine step under the borrow of the entered section and returns a `Settled` outcome that `apply_settled` acts on. Both take the H1 flag as a `bool` parameter in place of reading the chain kind.
- `frame` and `frame_mut` keep their internal-error path for callers that reach the chain by its arena index, such as the dispatch arms. The coroutine step works on the entered section directly.
- `seed` is a plain `TaskSeed` that the first section entry takes with `std::mem::take`, so admission reads `item` and `index` without unwrapping.
- `deliver` returns `Error::Internal` when the chain has no suspended block, and `answer_inline`, `wake_from_wait`, `wake_waiter`, `drop_timer`, and every dispatch arm now propagate it. A failed delivery of a `call` child's outcome to its parent ends the run with that error.
- `wake_from_wait` sets `Parked::Queued` when it requeues a chain and otherwise leaves the emptied wait in place, so `tasks.status` reports a requeued chain as queued and an inline wake as still waiting on tasks until the resume, as before.
- `blocks` returns an empty sequence once the chain's index is past its slice, because `step_chain` reads it before checking for an entered section.
- `prepare_call` hands the child `Arc::clone(chain.access()?)`, so a caller with no capability gets an internal error as the call's answer instead of a child starting without one.
- `answer_unsuspended_for_test` starts a chain with no entered section and answers it through the inline path and a wait's wake. The new failure test asserts that both deliveries return the internal error.
- `install_root_slots`, `resume_block`, `start_lua`, `handle_coro_result`, and `current_block` are gone. No per-section field stays on `Chain` outside `Entered`.

Design: temporal-coupling -> constructor-injection @ crates/runvil-internal/engine/src/execute/scheduler/chain.rs::ChainStart
Design: new parallel-abstraction @ crates/runvil-internal/engine/src/execute/scheduler/chain_record.rs::Parked
Design: extends bag-of-state @ crates/runvil-internal/engine/src/execute/scheduler/chain_record.rs::Chain
Design: replaces pure-function @ crates/runvil-internal/engine/src/execute/scheduler/dispatch.rs::parked_on deps: Request
  was: crates/runvil-internal/engine/src/execute/scheduler/dispatch.rs::blocked_on
Design: replaces oversized-unit @ crates/runvil-internal/engine/src/execute/scheduler/step.rs::settle_block deps: Emitter,Entered,Result<CoroStep>,bool,str
  was: crates/runvil-internal/engine/src/execute/scheduler/step.rs::handle_coro_result
Design: new flag-parameter @ crates/runvil-internal/engine/src/execute/scheduler/step.rs::settle_block deps: Emitter,Entered,Result<CoroStep>,bool,str
Design: new flag-parameter @ crates/runvil-internal/engine/src/execute/scheduler/step.rs::apply_settled
Design: new oversized-unit @ crates/runvil-internal/engine/src/execute/scheduler/step.rs::step_chain
Plan: vibe/2026-10-26-1-engine-slop-cleanup.md
Prose substitution now renders every value through the item rendering rule the Lua crate defines, so a placeholder and a fanout item render the same way and substitution keeps no rule of its own. The two types that name a section's place in the prompt tree, the path to its sibling slice and its position in that slice, move down into the module that resolves headings, and the walk, jump resolution, require origins, and reach views use them in place of bare integers. The scripted test model loses its simulated delay and timeout and counts rounds from the requests it records. Task arena records are now called entries, the H1 pass drops the word live from its names and docs, and the run's start state no longer shares the scheduler's phase name.

- `SlicePath` and `SectionIndex` move into `heading_address` from `walk_target` and `chain_record`, widening from `pub(super)` to `pub(crate)`. `Reachable` and `Reached` hold a `SlicePath` instead of a raw index list, and `Reachable::top` borrows a `static TOP` empty path.
- `SectionIndex` replaces bare `usize` positions in `Visible::of`, `Reachable::of`, `Reached::index`, `RequireOrigin::Section`, `library_key`, `section_position`, `resolve_jump_target`, `JumpTarget`, and `SectionContext::new`.
- `ChainTarget` holds a `JumpTarget` and a `SlicePath` in place of an index and a `child` flag, and its `index` method reads the position out of the jump. `resolve_chain_target` builds it once from `resolve_jump_target`'s result instead of once per jump kind.
- `Visible` no longer stores the caller's children; `Visible::iter` looks them up from `home` and the caller's index.
- `render` checks for null first, keeping `NullValue` and its message, and otherwise returns `crate::lua::render_item`'s output. `render_scalar` and the `subst::render_item` forwarder are gone, and the item arm calls `crate::lua::render_item` directly.
- `TaskEntry` replaces `TaskSlot`, and `owned_entry` and `visible_entry` replace `owned_slot` and `visible_slot`. Docs say entry for the arena record and keep slot for admission and batch slots.
- `start_h1`, `end_h1`, `end_h1_at_jump`, and `SectionContext::new_h1` replace the `live_h1` names, and the test module, test names, docs, and comments say the H1 pass.
- `Start` replaces `run::Phase` and the `start` field replaces `phase` on `Run`, keeping the variants, so the run's start state no longer shares a name with the scheduler's `Phase`. The scheduler's `phase` field doc lists where the step loop stands.
- `dispatch_*` arms rely on one module-doc rule: each arm with a fallible half resumes that half's error into the calling chain as the call's answer, `tool_calls` into the failing call's batch slot, and an `Error::Internal` from a scheduler method marks a scheduler or shim bug. The per-arm docs and `# Errors` clauses keep only their own failures.
- `delayed`, `ScriptedReply::Delayed`, and `Answers::timeout` are gone from the public test-support surface, and the bench and the scenario driver call `complete_now` without a timeout.
- `complete_now` answers every round with its scripted reply, recording the request and picking the reply index under one lock, and `call_count` returns the number of recorded requests. A test that needs a timed-out round scripts `fail` with `CompletionErrorKind::Timeout`.
- `cancellation_while_suspended_on_infer_interrupts_the_run` and `cancelled_nested_infer_does_not_report_model_turn_failed` script a plain `text("too late")` reply in place of a 30-second delayed one, and the driving test's comment says the held round keeps the reply from arriving.
- `SubstErrorKind::Serialize` now covers only a bare global that is not JSON data, since rendering an array or object no longer returns an error.
- `render_item_renders_by_type` in the Lua crate now asserts that a null renders as `null`, and the engine's test of the removed forwarder is gone.
- `Sources` and the module doc describe `item` as the item seeded into the section, a fanout arm's or a `tasks.spawn` task's first entry, and `substitute`'s `# Errors` adds a bare `{{ var }}` or `{{ sys }}` and a key path after `args` or `item`.
- `heading_address`'s module doc keeps only the heading lookup contract and the position types, dropping its account of fanout member order, the shim, and admission.
- `fail` documents that it builds the kind's phrase, status, and body for every kind, skipping the gateway client's classification, and the module doc says the empty-reply wording copies the gateway client's reader and changes with it.
- `SlotState` keeps its name, and local bindings named `slot` still hold task entries.
- `NilItem` still reports "{{ item }} is nil (not inside a fanout arm)", naming only the fanout case of the seeded item the docs now describe.

Design: replaces newtype @ crates/runvil-internal/engine/src/heading_address.rs::SlicePath
  was: crates/runvil-internal/engine/src/execute/walk_target.rs::SlicePath
Design: replaces newtype @ crates/runvil-internal/engine/src/heading_address.rs::SectionIndex
  was: crates/runvil-internal/engine/src/execute/scheduler/chain_record.rs::SectionIndex
Design: extends pure-function @ crates/runvil-internal/engine/src/execute/walk_target.rs::resolve_jump_target deps: Section,SectionIndex,str
Design: extends pure-function @ crates/runvil-internal/engine/src/execute/walk_target.rs::section_position deps: Section
Design: extends pure-function @ crates/runvil-internal/engine/src/execute/walk_target.rs::library_key deps: SectionIndex,SlicePath
Design: extends pure-function @ crates/runvil-internal/engine/src/subst.rs::render deps: Value,str,usize
Design: removes pure-function @ crates/runvil-internal/engine/src/subst.rs::render_scalar deps: Value
Design: removes shim @ crates/runvil-internal/engine/src/subst.rs::render_item deps: Value
Design: removes pure-function @ crates/runvil-internal/engine/src/test_support/scripted-chat.rs::delayed deps: Duration,ScriptedReply
  boundary: pub
Design: removes pure-function @ crates/runvil-internal/engine/src/test_support/scripted-chat.rs::timed_out
Design: new pure-function @ crates/runvil-internal/engine/src/execute/walk_target-tests.rs::path deps: usize
Design: new pure-function @ crates/runvil-internal/engine/src/heading_address-tests.rs::self_home
Design: extends pure-function @ crates/runvil-internal/engine/src/heading_address-tests.rs::from_self deps: Section,SlicePath
Plan: vibe/2026-10-26-1-engine-slop-cleanup.md
This reshapes the public run API so each name says what it holds and each input arrives where it is used. Run limits become a plain value with public fields in place of setters and getters, the run context's builders take a with prefix so its getters can use the plain names, and the caller now hands the current model to the prepare call instead of setting it on the context first. Each effect a step issues is a named record rather than a tuple, a finished run is reported as completed rather than ok, and the requirements report names its lists after what is missing. Cancellation and substitution lose their own failure kinds, because a cancelled run ends as cancelled rather than as a failure, and a failed substitution is a fault in the author's program like any other Lua fault.

- `RunLimits` is a plain `Copy` value whose six fields are public under `#[non_exhaustive]`, with no `new`, setters, or getters, and its defaults are listed on the `Default` impl. The field `request_timeout` is now `receive_timeout`, and its doc says the wait restarts with each part of the response, so it is not a whole-request deadline.
- `IssuedEffect` replaces the `(EffectId, Provenance, Effect)` tuple in `Step::Pending::effects` and the scheduler's issued list, so callers destructure `id`, `provenance`, and `effect` by name. The runner's `commit_effect` takes one `&IssuedEffect` in place of three parameters.
- `RunContext::prepare` takes the current model as `model: Option<ModelInfo>` after the prompt, and the context's `model` setter and field are gone, so binding no longer depends on a builder call made earlier. With `None` it skips the fill and the checks, and selecting a declared role at run time fails.
- `RunContext` builders take a `with_` prefix, such as `with_cancel`, `with_tools`, and `with_vfs`, and the getters `catalog`, `run_flags`, and `vfs_handle` become `tools`, `flags`, and `vfs`.
- `RunResult::Completed` replaces `RunResult::Ok`, so the variant no longer shares a name with `Result::Ok`.
- `originating_error` replaces `cause` on both `RunError` and the internal `Error`, along with every doc that named it.
- `Requirements` renames `unmet_requirements`, `missing_required`, and `unavailable` to `unmet_models`, `missing_packs`, and `unavailable_packs`. Its `reserved` field is a `BTreeSet<PackId>`, so `merge` no longer sorts and dedups it by hand.
- `ChatAnswerRecord::new` and its three `with_*` setters leave the public API and exist only in test builds as `pub(crate)`.
- `RunErrorKind` loses `Cancelled` and `Substitution`: `kind` classifies `Error::Interrupted` as `Internal` and `Error::Substitution` as `Lua`. A new test pins both classifications.
- `RunError::is_cancelled` is gone, and a caller sees cancellation only as `RunResult::Cancelled`.

Design: new bag-of-state @ crates/runvil-internal/engine/src/execute/config-limits.rs::RunLimits
  boundary: pub
Design: new parameter-object @ crates/runvil-internal/engine/src/execute/run.rs::IssuedEffect
  boundary: pub
Design: removes temporal-coupling @ crates/runvil-internal/engine/src/execute/config.rs::RunContext::prepare
  boundary: pub
Deferred: The runvil-docs guide edits for this change land in a separate runvil-docs commit.
Plan: vibe/2026-10-26-1-engine-slop-cleanup.md
The engine's internal error type now holds the parser's parse failure whole instead of copying its fields into two variants of its own, merges its two Lua failure variants into one whose originating error is optional, and names the tool set errors after the sets they guard. A failed model round and a round with no model now reach Lua as kinds of their own rather than as the internal kind, so a prompt can tell a backend fault from an engine fault, and a prompt that checked for internal to catch a model failure no longer matches. Three author-facing messages change: a schema build failure names the schema refusal, a nil item says no item is seeded into the section, and a key path after item says the item renders whole. The rulebook now says a section entry's Static Set freezes at its first loop round, and the engine docs and tests use that phrase. Other messages, parse locations, and run error kinds stay as they were.

- `Error::Parse` holds the parser's `ParseError` and replaces `ParseFrontmatter` and `ParseStructured`, whose fields mirrored the parser's own and were copied across field by field. `RunError::location` and `originating_error` now read the name, line, column, span, and cause from the held `ParseError`.
- `Error::Lua` carries a `message` and an optional `cause`, replacing the message-only `Lua` variant and `Error::LuaRuntime`. `Error::lua_message` builds the cause-less form for the scheduler, the heading lookup, and raised tables.
- `internal_owned` is folded into `Error::internal` and `Error::internal_with_cause`, which take `impl Into<Cow<'static, str>>` and keep `#[track_caller]`, so one constructor takes both fixed and formatted messages.
- `FrozenSet` and its `Display` move to `execute::scope`, which becomes `pub(crate)` so the error module can import it from there. `execute::scope` still imports `SetRule` and `SetTool` from the error module, so the two modules now depend on each other.
- `SetRule`, `SetTool`, `Error::ToolSets`, and `sets_message` replace `MergeRule`, `MergeTool`, `Error::ToolMerge`, and `merge_message`, and the module file moves from `error/merge.rs` to `error/sets.rs`.
- `runvil_parser::detail` gains `parse_error`, `parse_error_from_inner`, and `with_prompt_name`, through which the engine builds, rewraps, and names parse failures.
- `ErrorKind` in the Lua crate gains `ModelFailure` and `ModelRequired`, spelled `model_failure` and `model_required`. The enum is not `#[non_exhaustive]`, so every exhaustive match on it, the engine's raised-table mapping among them, now names both.
- `Error::Completion` now renders to Lua as `model_failure`, or as `empty_model_reply` for an empty reply as before, and `Error::ModelRequired` as `model_required`, where both rendered as `internal`. `internal` keeps the other failures outside the prompt, such as a failed tool freeze.
- `from_raised` maps a Lua-built table of either new kind to a cause-less Lua failure that keeps its message, as it does for `internal`, and the Lua VM's read-back passes both kinds through as raised tables.
- `a_caught_model_failure_reads_as_the_model_failure_kind` and `a_caught_missing_model_reads_as_the_model_required_kind` pin both kinds through `pcall(models.loop)`, and the raised-table test pins that both map back to cause-less Lua failures.
- `BindSchema` renders the schema refusal after the alias, such as `tool "shape" parameters schema must be a JSON object`, and keeps the refusal as a plain `cause` field instead of `#[source]`. `RunError::originating_error` returns it, and the variant's `source()` no longer does.
- `NilItem` now reads "{{ item }} is nil (no item is seeded into this section)", and a key path after `item` reports "item takes no key path; {{ item }} renders the whole item". A key path after `args` keeps "args is a string, not a table".
- `with_prompt_name` stamps the prompt name only onto a parse failure with no span and passes a spanned one through unchanged, because locating a span needs the prompt body. The engine's old version stamped the name on any structured failure.
- `AGENTS.md` defines the Static Set as the tools a section entry renders at its first loop round, the first round a `models.loop` or a model handle's `loop` sends, and engine docs and test strings say "first loop round" where they said "first model call" or "first model round". Its Engine entry now names a catalog tool's result and a file outside a prelude's install.
- `merge_tool_sets` keeps its name because it does merge the offers, and the rule messages `sets_message` renders keep their wording.

Design: removes parallel-abstraction @ crates/runvil-internal/engine/src/error.rs::Error
Design: new cyclic-dependency @ crates/runvil-internal/engine/src/error/sets.rs
Design: replaces pure-function @ crates/runvil-internal/engine/src/error/sets.rs::sets_message deps: SetRule,SetTool
  was: crates/runvil-internal/engine/src/error/merge.rs::merge_message
Design: new surface-growth @ crates/runvil-internal/engine/src/error.rs::Error::BindSchema
  boundary: pub
Design: new surface-growth @ crates/runvil-internal/lua/src/error-value.rs::ErrorKind
  boundary: pub
Design: new pure-function @ crates/runvil-internal/parser/src/detail.rs::parse_error deps: Into<String>,ParseErrorKind
Design: new pure-function @ crates/runvil-internal/parser/src/detail.rs::parse_error_from_inner deps: Error
Design: new pure-function @ crates/runvil-internal/parser/src/detail.rs::with_prompt_name deps: ParseError,str
Deferred: The runvil-docs guide's quotes of the three changed messages and its Lua error kind tables land in a separate runvil-docs commit.
Plan: vibe/2026-10-26-1-engine-slop-cleanup.md
Plan: vibe/2026-10-26-1-engine-slop-cleanup.md
@vinniefalco vinniefalco changed the title Add late Pack rendering, discovery tools, and tools.stats Add Pack rendering, discovery tools, and tools.stats, and clean up the engine Oct 26, 2026
The run limits drop the response size cap and the receive timeout. The gateway client already holds both with the same defaults, so the broker now applies the client's own limits instead of copying them from the run limits. Each section's system values become a typed record that the engine builds and keeps itself, so reading a section's entry id can no longer fail and recording the bound model no longer reads the virtual machine's copy back. The JSON a section's Lua code reads keeps the same keys and value types.

- `RunLimits` drops its public fields `max_response_bytes` and `receive_timeout`, leaving four limits whose defaults do not change. `crates/runvil/public-api.txt` loses exactly those two lines.
- `SectionSys` replaces the JSON value `SectionContext` stored as `sys`, and its `section_name` replaces the frame's separate `name` field. Its eight fields are open to the `execute` module, and `section_sys` on `RunState` builds it where `sys_json` built the JSON.
- `new_h1` takes the entering chain's `task_id`, as `new` already does, instead of building the root task itself. The walk passes `&chain.task`.
- `GatewayBroker` builds its `GatewayChat` without calling `with_request_limits`, so the client's own defaults apply instead of values copied out of `RunLimits::default`. The client's field and constant are renamed `receive_timeout` and `DEFAULT_RECEIVE_TIMEOUT`.
- `enrich_sys_model`, its helper `enrich_sys_field`, its re-export, and `current_sys` leave runvil-lua, along with the test-only `sys_live_handle` and the `current_sys` test.
- `entry_id` returns `&str` and can no longer fail, so the render effect and tool-call origin sites drop their `?`.
- `install_first_call_state` sets `model` on a clone of the section's `SectionSys`, re-seals the VM from its `to_json`, and keeps the clone only once the re-seal succeeds. It reads nothing back from the VM.
- `to_json` leaves `index` and `model` out when unset instead of writing null, because Lua raises on an absent `sys` field but reads a null one as nil. The new tests pin its keys, value types, and omissions.
- `DEFAULT_RECEIVE_TIMEOUT` and `DEFAULT_MAX_RESPONSE_BYTES` hold 120 seconds and 16 MiB, the values `RunLimits::default` supplied, so every gateway round keeps the same limits.
- `with_request_limits` stays on `GatewayChat`, but `GatewayBroker` no longer calls it and has no setter of its own, so a Host using the broker cannot change the request limits.

Design: removes bag-of-state @ crates/runvil-internal/engine/src/execute/config-limits.rs::RunLimits
  boundary: pub
  instead-of: surface-growth: a broker setter for the request limits
Design: stringly-typed -> bag-of-state @ crates/runvil-internal/engine/src/execute/section_context.rs::SectionSys
  instead-of: newtype: a typed entry id
Design: extends shared-parameter-cluster @ crates/runvil-internal/engine/src/execute/section_context-construct.rs::SectionContext::new_h1
Design: removes pure-function @ crates/runvil-internal/lua/src/sys.rs::enrich_sys_model deps: Json,ModelBinding
  boundary: pub
Design: removes pure-function @ crates/runvil-internal/lua/src/sys.rs::enrich_sys_field deps: Json,str
Deferred: The guide's run-limit pages change in a separate runvil-docs commit.
Plan: vibe/2026-10-26-2-engine-scheduler-split.md
A chain tracked its place in the prompt tree with three loose fields, and a resolved jump had two different shapes. The engine now names a section's place with one position type, and each chain walks with one stack that moves only by falling through, jumping, or popping a finished child level. Jumps, calls, spawns, and the H1 pass all resolve a heading into the same target. The walk visits sections in the same order as before, but a spawn whose worker lies past its slice now fails with an internal error instead of panicking. New tests cover child levels that run out inside call and task chains, jumpers that were last in their slice, and the section a chain reports after resuming.

- `SectionPos` names a section by its sibling slice's path and its index there, and replaces the separate slice and index in `ChainStart`, `SectionContext::new`, `Reachable`, `Reached`, `RequireOrigin`, and `library_key`. Its `name` takes over from `name_at`, and the `TOP` static goes because `Reachable` marks the H1 reach with `None`.
- `Walk` holds a chain's current position on top of the jumpers of its suspended parent levels, so it is never empty, and it replaces the chain's `slice`, `index`, and `positions` fields. Its `advance`, `jump`, and `pop` are the only ways a chain's position moves.
- `JumpTarget` is now a struct of the target's position and a `descends` flag, replacing the sibling-or-child enum and the scheduler's `ChainTarget` wrapper. `end_h1_at_jump` resolves through the H1 branch of `resolve_chain_target` instead of repeating it.
- `scheduler/tasks.rs` reads a spawn's worker through the resolved position and returns an internal error when the worker lies past its slice, where it would panic on an out-of-range index before.
- `child_levels.rs` adds scenario tests for a child level that runs out inside a `call` chain and inside a task chain, for a jumper that was last in its slice or last at the root, and for the section name a chain reports after a pop. `chain_record-tests.rs` covers each `Walk` rule with one unit test.
- `duplicate_top_level_section_names_error_loudly` now resolves against a hand-built visible set through `resolve_visible`, because `resolve_jump_target` reads a parsed prompt and the parser refuses duplicate names; the test no longer exercises the ambiguity path through `resolve_jump_target` itself.
- `ChainKind::H1` chains still get a `Walk` at the first top-level section that nothing reads; the walk after the pass is a separate root chain.

Design: new parameter-object @ crates/runvil-internal/engine/src/heading_address.rs::SectionPos
Design: removes parallel-abstraction @ crates/runvil-internal/engine/src/execute/scheduler/walk.rs::ChainTarget
Design: extends pure-function @ crates/runvil-internal/engine/src/execute/walk_target.rs::resolve_jump_target deps: Prompt,SectionPos,str
Design: extends pure-function @ crates/runvil-internal/engine/src/execute/walk_target.rs::library_key deps: SectionPos
Design: new pure-function @ crates/runvil-internal/engine/src/execute/scheduler/chain_record-tests.rs::top deps: usize
Design: new pure-function @ crates/runvil-internal/engine/src/execute/scheduler/chain_record-tests.rs::descend deps: SectionPos,usize
Design: replaces pure-function @ crates/runvil-internal/engine/src/execute/walk_target-tests.rs::pos deps: usize
  was: crates/runvil-internal/engine/src/execute/walk_target-tests.rs::path
Design: replaces pure-function @ crates/runvil-internal/engine/src/heading_address-tests.rs::self_pos
  was: crates/runvil-internal/engine/src/heading_address-tests.rs::self_home
Design: extends pure-function @ crates/runvil-internal/engine/src/heading_address-tests.rs::from_self deps: Section,SectionPos
Plan: vibe/2026-10-26-2-engine-scheduler-split.md
The scheduler kept its effect bookkeeping and its chain storage as loose fields, so the rules for each were spread across its methods. This change moves them into two owned parts that hold their own state and enforce their own rules, and neither writes the other. The effect table makes sure every issued effect takes exactly one answer, drops answers for effects whose chain stopped waiting, and reports unknown or repeated answers so the run fails loudly. The chain arena is the one place chain indices come from, so every index it hands out names a chain and the chain-count limit is checked in one spot. What the caller sees does not change.

- `EffectTable` owns the outstanding entries, the step's issued and withdrawn queues, and the run-wide effect and round counters. `Scheduler::issue` and `Scheduler::number_round` stay as thin wrappers, and `issue` still stamps the chain's provenance before it hands the effect over.
- `ChainArena` owns the chain list and the test-only chain bound. Only its module can build a `ChainIndex`, and `push` checks the bound before it adds a chain, leaving the arena unchanged when it fails.
- `Outstanding::Parked` becomes `Outstanding::Awaited` and is private to the table, which reports each answer as `Arrival::Awaited`, `Arrival::Orphaned`, or `Arrival::Unknown`. A timer's entry sits under an owner that need not be suspended, which the new name allows.
- `next_child_id` and `next_entry_id` move onto `Chain` with the `Counters` they advance, replacing two scheduler functions that touched only one chain.
- `chain_record` gains a submodule directory holding `anchor.rs`, `arena.rs`, `kind.rs`, and `tests.rs`, wired without `#[path]` attributes, while `effects.rs` wires a flat `effects-tests.rs` sibling.
- `orphan_all` orphans every awaited effect at teardown without listing withdrawals or returning call identities, while `orphan` lists a tool call as withdrawn and returns its chain and identity so the call can be ended.
- `take_step` returns the step's issued effects in issue order, including any the run's end orphaned in the same step, and the withdrawn tool calls sorted by id.
- `abort_effect` now records the withdrawal before it ends the tool call's identity. `end_tool_call` reads no effect-table state and the list is sorted when the step takes it, so the result is the same.
- `ChainIndex::for_test` builds an index outside the arena in test builds only, so the effect table's tests can use indices without building chains.
- `effects-tests.rs` and the new tests in `chain_record/tests.rs` cover the two owners and the id counters directly; no scheduler suite changes.

Design: new encapsulated-invariant @ crates/runvil-internal/engine/src/execute/scheduler/chain_record/arena.rs::ChainArena
Design: replaces newtype @ crates/runvil-internal/engine/src/execute/scheduler/chain_record/arena.rs::ChainIndex
  was: crates/runvil-internal/engine/src/execute/scheduler.rs::ChainIndex
Design: new shared-parameter-cluster @ crates/runvil-internal/engine/src/execute/scheduler/effects.rs::EffectTable::issue
Plan: vibe/2026-10-26-2-engine-scheduler-split.md
Requests that touch only the chain that made them ran as scheduler methods, which can reach every chain, the task arena, admission, and the ready queue. They now run in a view that borrows just that chain, the effect table, and the run context, so the compiler rules out any wider reach. The scheduler keeps the requests that coordinate more than one owner, and every leaf effect is issued through the view. A view cannot touch the ready queue, so an answer it delivers is recorded and the chain is requeued once the request returns; nothing else enters the queue in between, so the ready order and what the caller sees stay the same.

- `ChainOps` borrows one chain and the effect table mutably and the run context shared, and owns nothing, so a request running in it cannot reach another chain, the task arena, admission, or the ready queue.
- `Scheduler::with_chain` is the only way into the view. It builds the view from the scheduler's own fields, runs the request, then pushes the chain onto the ready queue once for each answer the view delivered.
- `impl ChainOps<'_>` blocks in the chat, dispatch, apply, walk, and three tool call files hold the 29 requests that touch only their own chain. Each drops its chain index parameter and reads `self.chain` instead of indexing the arena.
- `issue` and `number_round` move from the scheduler onto the view, so every leaf effect, the timer's included, is stamped with its chain's provenance and issued through one path.
- `answer_inline` stays on the scheduler for the requests it keeps and for a chain end answering another chain. A request in the view answers through the view, which delivers to the chain and records the requeue.
- `advance_entry` stays on the scheduler because it can finish the chain. It enters the next section or pops a walk level through the view, then requeues the chain itself.
- `with_chain` requeues after the request returns, where the scheduler path requeued mid-request. The view can push nothing else, so no other chain enters the queue in between and the ready order is unchanged.
- `ChainOps` gets no tests of its own; the borrow checker enforces what it can reach.

Design: removes shared-parameter-cluster @ crates/runvil-internal/engine/src/execute/scheduler.rs::Scheduler::issue
Design: extends oversized-unit @ crates/runvil-internal/engine/src/execute/scheduler/apply.rs::Scheduler::apply_answer
Design: replaces oversized-unit @ crates/runvil-internal/engine/src/execute/scheduler/chat.rs::ChainOps::prepare_chat
  was: crates/runvil-internal/engine/src/execute/scheduler/chat.rs::Scheduler::prepare_chat
Design: replaces temporal-coupling @ crates/runvil-internal/engine/src/execute/scheduler/chat.rs::ChainOps::merge_render
  was: crates/runvil-internal/engine/src/execute/scheduler/chat.rs::Scheduler::merge_render
Design: replaces shared-parameter-cluster @ crates/runvil-internal/engine/src/execute/scheduler/tool_call-adhoc.rs::ChainOps::prepare_adhoc_call
  was: crates/runvil-internal/engine/src/execute/scheduler/tool_call-adhoc.rs::Scheduler::prepare_adhoc_call
Design: replaces temporal-coupling @ crates/runvil-internal/engine/src/execute/scheduler/tool_call-adhoc.rs::ChainOps::dispatch_adhoc_tool_done
  was: crates/runvil-internal/engine/src/execute/scheduler/tool_call-adhoc.rs::Scheduler::dispatch_adhoc_tool_done
Design: replaces dispatch-on-tag @ crates/runvil-internal/engine/src/execute/scheduler/tool_call-discovery.rs::ChainOps::prepare_discovery_call
  was: crates/runvil-internal/engine/src/execute/scheduler/tool_call-discovery.rs::Scheduler::prepare_discovery_call
Design: replaces shared-parameter-cluster @ crates/runvil-internal/engine/src/execute/scheduler/tool_call-discovery.rs::ChainOps::prepare_discovery_call
  was: crates/runvil-internal/engine/src/execute/scheduler/tool_call-discovery.rs::Scheduler::prepare_discovery_call
Design: replaces shared-parameter-cluster @ crates/runvil-internal/engine/src/execute/scheduler/tool_call-discovery.rs::ChainOps::prepare_reached
  was: crates/runvil-internal/engine/src/execute/scheduler/tool_call-discovery.rs::Scheduler::prepare_reached
Design: replaces temporal-coupling @ crates/runvil-internal/engine/src/execute/scheduler/tool_call.rs::ChainOps::prepare_catalog_call
  was: crates/runvil-internal/engine/src/execute/scheduler/tool_call.rs::Scheduler::prepare_catalog_call
Design: replaces shared-parameter-cluster @ crates/runvil-internal/engine/src/execute/scheduler/tool_call.rs::ChainOps::prepare_catalog_call
  was: crates/runvil-internal/engine/src/execute/scheduler/tool_call.rs::Scheduler::prepare_catalog_call
Plan: vibe/2026-10-26-2-engine-scheduler-split.md
The scheduler kept every task's entry in a bare map, so the task lifecycle rules were spread across the methods that read it, and cancelling and abandoning a task each carried its own copy of the code that ends the task's work. The entries now live in a task arena that owns them and allows only the legal state changes: a running task finishes, is cancelled, or is abandoned, and a finished task is delivered once. Cancelling and abandoning share one path that ends a stopped task's work and reports its last event, and a timer's answer now names its task instead of being matched by searching every entry. Every path keeps its order of writes.

- `TaskArena` owns every task entry, keyed by task id, and hands entries out only by shared reference, so a running task can only finish, be cancelled, or be abandoned, and only a finished task can be delivered. It also runs the ownership check, the check that admits a chain running inside the task, and the spawn-order listings that leave out internal timers, with the scheduler passing in the caller's task id because the arena reads no chain.
- `Stop` names the two ways a live task ends early. The arena's `stop` moves the entry and returns its backing and target only when the task was live, so a late cancel or abandonment changes nothing.
- `end_backing` is the one path cancel and abandonment share to end a stopped task's work: a timer's effect is aborted and reports nothing, and a chain is aborted with everything it owns before its terminal event fires, only if it was ever admitted. It returns whether the backing was a chain so abandonment keeps timers off the leaked list, and it never touches the entry, so the caller must stop the entry first.
- `Continuation::Timer` now carries the timer's task id, so firing or dropping a timer looks its entry up directly instead of scanning every entry for its effect, and `apply_answer` no longer takes the effect id.
- `tasks-spawn.rs` holds the spawn arm as a child module of `tasks.rs`, moved with its docs; only its visibility and its use of the arena's `insert` change.
- `EntryState` is the new name for an entry's lifecycle state, and `is_internal` is now private to the arena.
- `complete_task` now returns an internal error when the task's entry is no longer live, where it overwrote the entry with the outcome. A stopped task's chain is aborted before it can end, so the error marks a scheduler bug.
- `deliver` still joins the task before the arena takes its outcome, and `cancel_task` and `abandon_owned_tasks` still stop the entry before ending its backing, so the order of writes and events is unchanged.
- `drop_timer` still returns quietly when the timer's entry is missing or no longer live, and `fire_timer` still returns an internal error in that case.
- `insert` replaces any entry already under the same id without a check, so the rule that entries are never removed rests on task ids being unique, which the spawner's child-id counter provides.
- `tasks-tests.rs` covers each arena rule with one unit test; no scheduler suite changes.

Design: new value-object @ crates/runvil-internal/engine/src/execute/scheduler/tasks.rs::Stop
Design: new temporal-coupling @ crates/runvil-internal/engine/src/execute/scheduler/task_end.rs::Scheduler::end_backing
Design: removes clone-block @ crates/runvil-internal/engine/src/execute/scheduler/waits.rs::Scheduler::cancel_task
Design: removes clone-block @ crates/runvil-internal/engine/src/execute/scheduler/task_end.rs::Scheduler::abandon_owned_tasks
Design: replaces shared-parameter-cluster @ crates/runvil-internal/engine/src/execute/scheduler/tasks-spawn.rs::Scheduler::dispatch_spawn
  was: crates/runvil-internal/engine/src/execute/scheduler/tasks.rs::Scheduler::dispatch_spawn
Design: replaces shared-parameter-cluster @ crates/runvil-internal/engine/src/execute/scheduler/tasks-spawn.rs::Scheduler::prepare_spawn
  was: crates/runvil-internal/engine/src/execute/scheduler/tasks.rs::Scheduler::prepare_spawn
Design: new pure-function @ crates/runvil-internal/engine/src/execute/scheduler/tasks-tests.rs::chain deps: u32
Design: new pure-function @ crates/runvil-internal/engine/src/execute/scheduler/tasks-tests.rs::task deps: u32
Design: new shared-parameter-cluster @ crates/runvil-internal/engine/src/execute/scheduler/tasks-tests.rs::spawn deps: ChainIndex,TaskArena,u32
Design: new shared-parameter-cluster @ crates/runvil-internal/engine/src/execute/scheduler/tasks-tests.rs::timer deps: ChainIndex,TaskArena,u32
Plan: vibe/2026-10-26-2-engine-scheduler-split.md
The admission state was split between the scheduler, which held the resume and spawn queues, and each chain's kind, which held a queued task's spawn record, whether a task held its slots, and, for a parked call chain, the ancestor whose slots it gave back. One admission owner now holds all of it and enforces the slot rules in one place, reading the chain tree only through a read-only view and writing neither arena. That owner plans each admission pass and the scheduler applies it, moving each chain to the ready queue and firing each start event in the same order as before. A chain's kind now records only where the chain sits in the tree. Apart from what happens after an internal error, the caller sees the same steps, events, and admission order.

- `Admission` owns the resume queue, the spawn queue with each spawn's record, the set of task chains holding slots, and a map from each parked call chain to the ancestor whose slots it gave back. It sums slot counts from the holders on every pass and never stores them.
- `ChainTree` is the read-only view admission reads: each chain's kind, limit, and call parent from the chain arena, and each task chain's owner from the task arena. The scheduler builds it inline from its two fields so admission can change while they are borrowed, and `Scheduler::enclosing` moves onto it, so `dispatch_concurrency` reads the parent's limit through it too.
- `Admission::plan` returns each pass as `Admit::Resumed` or `Admit::Started` with the spawn record, and marks every admitted chain's slot holder holding before it returns, so the scheduler must apply every entry. `Scheduler::admit` applies them in order: it clears a resumed chain's parked state and requeues it, and it starts a spawn and fires its start event.
- `park` takes whether the wait holds a chain-backed task as a bool the join computes, so admission never reads task backings. `wake` reports whether the chain was queued to take its slots back, and the scheduler marks it `Parked::Queued` when it was.
- `ChainKind` is now `Root`, `H1`, `Call { parent }`, or `Task`, and the admission enum it held goes with its `admitted`, `holding`, and `stop_holding` queries. A task chain counts as admitted when `is_queued` does not report it.
- `SpawnRecord` moves into the admission module, and `prepare_spawn` returns it beside the task id and chain index instead of storing it in the chain's kind.
- `count_slot` and `can_admit` become free functions over the slot counts, the view, and a chain index. `can_admit` reads nothing but its arguments.
- `Chain::for_test` builds a chain without a run in test builds only, and the chain record tests and the admission tests share it.
- `holding` is a hash set and the given-back map is a hash map, but slot counts are sums over the holders, so iteration order cannot change which chains a pass admits. A pass still admits resumptions first, then spawns in spawn order, and counts each admission against the next.
- `plan` runs the whole pass before the scheduler applies any of it. Applying changes no slot count or limit the pass reads, so the ready queue and start events keep their order. A plan that fails on an internal error applies nothing from its pass, where the old path had already started the chains admitted ahead of the failure.
- `enqueue_spawn` now runs before the spawner is answered, where the child was queued after the answer. The answer only delivers to the spawner and requeues it, so the order is unchanged unless the answer fails, which now leaves the child queued.
- `Admit::Started` takes the spawn record out of the queue entry, so the old internal error for a queued task missing its record can no longer arise. `plan` keeps the errors for a resumed chain whose slots belong to no admitted task and for a queued spawn that is not a task chain.
- `is_queued` scans the spawn queue where the old check read the chain's kind, and `end_backing` still asks before the abort removes the chain from the queue.
- `forget` drops an aborted chain from both queues, the holders, and the given-back map, and `clear` empties all four at teardown.
- `wait_set` is gone from `Chain`. The join computes its bool from the wait's own task list before it parks.
- `admission-tests.rs` covers each admission rule with one unit test over a small chain tree, plus the internal error for a resumption whose slots belong to no admitted task; no scheduler suite changes.

Design: new facade @ crates/runvil-internal/engine/src/execute/scheduler/admission.rs::ChainTree
Design: new flag-parameter @ crates/runvil-internal/engine/src/execute/scheduler/admission.rs::Admission::park
Design: new shared-parameter-cluster @ crates/runvil-internal/engine/src/execute/scheduler/admission.rs::count_slot deps: ChainIndex,ChainTree,usize
Design: new shared-parameter-cluster @ crates/runvil-internal/engine/src/execute/scheduler/admission.rs::can_admit deps: ChainIndex,ChainTree,usize
Design: new pure-function @ crates/runvil-internal/engine/src/execute/scheduler/admission.rs::can_admit deps: ChainIndex,ChainTree,usize
Design: new parallel-abstraction @ crates/runvil-internal/engine/src/execute/scheduler/admission-tests.rs::Admitted
Plan: vibe/2026-10-26-2-engine-scheduler-split.md
Plan: vibe/2026-10-26-2-engine-scheduler-split.md
@vinniefalco vinniefalco changed the title Add Pack rendering, discovery tools, and tools.stats, and clean up the engine Add Pack rendering, discovery tools, and tools.stats, clean up the engine, and split its scheduler Oct 27, 2026
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