9.7 KiB
Phase 1 Step 16d — Implementation Notes
Status
Done.
Plan reference
- Parent plan:
docs/implementation/PHASE-1-STEP-16-NOTES.md - Sub-phase goal: "Build
AppState::init(app_config, ...).awaitabsorbing MCP registry startup and global functions loading"
Summary
Added AppState::init() async constructor that self-initializes all
process-wide shared state from an Arc<AppConfig> and startup
context. Two new fields on AppState: mcp_registry (holds initial
MCP server Arcs alive) and functions (the global base
Functions). Changed McpRegistry::init to take &AppConfig + &Vault instead of &Config.
The constructor is dead-code-gated (#[allow(dead_code)]) until
Step 16e switches main.rs over to call it. The bridge flow
continues to populate the new fields via Config's existing
functions and mcp_registry so nothing breaks.
What was changed
src/mcp/mod.rs
McpRegistry::init signature change:
// Before
pub async fn init(
log_path: Option<PathBuf>,
start_mcp_servers: bool,
enabled_mcp_servers: Option<String>,
abort_signal: AbortSignal,
config: &Config,
) -> Result<Self>
// After
pub async fn init(
log_path: Option<PathBuf>,
start_mcp_servers: bool,
enabled_mcp_servers: Option<String>,
abort_signal: AbortSignal,
app_config: &AppConfig,
vault: &Vault,
) -> Result<Self>
The function reads two things from its config argument:
config.vaultfor secret interpolation → now takes&Vaultdirectlyconfig.mcp_server_supportfor the start-servers gate → a serde field already present onAppConfig
Both dependencies are now explicit. No Config reference anywhere in the MCP module.
Imports updated:
- Removed
use crate::config::Config - Added
use crate::config::AppConfig - Added
use crate::vault::Vault
src/config/mod.rs
Config::load_mcp_servers updated to build a temporary AppConfig
via self.to_app_config() and pass it plus &self.vault to
McpRegistry::init. This is a transitional bridge — disappears in
16e when main.rs stops calling Config::init and
AppState::init becomes the sole entry point.
src/config/app_state.rs
New fields:
pub mcp_registry: Option<Arc<McpRegistry>>,
pub functions: Functions,
New AppState::init() async constructor absorbs:
Vault::init(&config)(replaces the oldAppState { vault: cfg.vault.clone() }pattern)McpRegistry::init(...)(previously insideConfig::init)- Registers initial MCP servers with
McpFactoryviainsert_active(McpServerKey, &handle)— this is NEW behavior, see below. Functions::init(config.visible_tools)(previously insideConfig::init)functions.append_mcp_meta_functions(...)when MCP support is on and servers started- Wraps
McpRegistryinArcto keep initial server handles alive across scope transitions (see below)
Imports expanded:
McpServerKeyfromsuper::mcp_factoryFunctionsfromcrate::functionMcpRegistryfromcrate::mcpAbortSignalfromcrate::utilsVaultfromcrate::vaultanyhow::Result
src/main.rs
AppState struct literal extended to populate the two new fields
from cfg.mcp_registry and cfg.functions. This keeps the bridge
flow working unchanged. When 16e replaces this struct literal with
AppState::init(...), these field references go away entirely.
src/function/supervisor.rs
Child AppState construction extended to propagate the new
fields from parent: mcp_registry: ctx.app.mcp_registry.clone() and
functions: ctx.app.functions.clone(). This maintains parent-child
sharing of the MCP factory cache (which was already fixed earlier in
this work stream).
src/config/request_context.rs and src/config/session.rs
Test helper AppState construction extended to include the two
new fields with safe defaults (None, and cfg.functions.clone()
respectively).
New behavior: McpFactory pre-registration
AppState::init registers every initial server with McpFactory via
insert_active. This fixes a latent issue in the current bridge
flow:
- Before: initial servers were held on
Config.mcp_registry.servers; when the first scope transition (e.g.,.role coder) ranrebuild_tool_scope, it calledMcpFactory::acquire(name, spec, log_path)which saw an empty cache and spawned duplicate servers. The original servers died when the initial ToolScope was replaced. - After (via
AppState::init): the factory's Weak map is seeded with the initial server Arcs. The registry itself is wrapped in Arc and held on AppState so the Arcs stay alive. Scope transitions now hit the factory cache and reuse the same subprocesses.
This is a real improvement that shows up once main.rs switches to
AppState::init in 16e. During the 16d bridge window, nothing
reads the factory pre-registration yet.
Behavior parity (16d window)
main.rsstill callsConfig::init, still uses the bridge; all new fields populated from Config's own state.AppState::initis present but unused in production code paths.- Test helpers still use struct literals; they pass
Noneformcp_registryand clonecfg.functionswhich is the same as what the bridge was doing. - No observable runtime change for users.
Files modified
src/mcp/mod.rs—McpRegistry::initsignature; imports.src/config/mod.rs—Config::load_mcp_serversbridges to new signature.src/config/app_state.rs— added 2 fields, addedinitconstructor, expanded imports.src/main.rs— struct literal populates 2 new fields.src/function/supervisor.rs— child struct literal populates 2 new fields.src/config/request_context.rs— test helper populates 2 new fields.src/config/session.rs— test helper populates 2 new fields.
Assumptions made
-
McpFactory::insert_activewith Weak is sufficient to seed the cache. The initial ServerArcs live onAppState.mcp_registry(wrapped in Arc to enable clone across child states). Scope transitions callMcpFactory::acquirewhich doestry_get_active(key).unwrap_or_else(spawn_new). The Weak in factory upgrades because Arc is alive inmcp_registry. Verified by reading code paths; not yet verified at runtime since bridge still drives today's flow. -
functions: Functionsis Clone-safe. The struct containsVec<FunctionDeclaration>and related fields; cloning is cheap enough at startup and child-agent spawn. Inspected the definition; no references to check. -
mcp_server_supportgate still applies.Config::initused to gate the MCP meta function append with bothis_empty()andmcp_server_support;AppState::initpreserves both checks. Parity confirmed. -
Mode-specific function additions (REPL-only
append_user_interaction_functions) do NOT live on AppState. They are added per-scope inrebuild_tool_scopeand in the initialRequestContext::newpath.AppState.functionsis the mode-agnostic base. This matches the long-term design (RequestContext owns mode-aware additions). -
mcp_registry: Option<Arc<McpRegistry>>vsMcpRegistry. Went withOption<Arc<_>>so:Nonewhen no MCP config exists (can skip work)Arcso parent/child AppStates share the same registry (keeping initial server handles alive across the tree)
Open questions
-
Registry on AppState vs factory-owned lifecycle. The factory holds Weak; the registry holds Arc. Keeping the registry alive on AppState extends server lifetime to the process lifetime. This differs from the current "servers die on scope transition" behavior. In practice this is what users expect — start the servers once, keep them alive. But it means long-running REPL sessions retain all server subprocesses even if the user switches away from them. Acceptable trade-off for Phase 1.
-
Should
AppState::initreturnArc<AppState>directly? Currently returnsSelf. Caller wraps in Arc. Symmetric with other init functions; caller has full flexibility. Keep as-is. -
Unit tests for
AppState::init. Didn't add any because the function is heavily async, touches filesystem (paths), subprocess startup (MCP), and the vault. A meaningful unit test would require mocking. Integration-level validation happens in 16e when main.rs switches over. Deferred.
Verification
cargo check— clean, zero warningscargo clippy— clean, zero warningscargo test— 122 passing, zero failures- New
AppState::initgated with#[allow(dead_code)]— no warnings for being unused
Remaining work for Step 16
- 16e: Switch
main.rsto callAppState::init(app_config, log_path, start_mcp_servers, abort_signal).await?instead of the bridge pattern. Audit the 15Config::init()callers. Remove#[allow(dead_code)]fromAppConfig::from_config,AppConfig::resolve_model, andAppState::init. - 16f: Delete
Config.vault,Config.functions,Config.mcp_registry, all other#[serde(skip)]runtime fields. DeleteConfig::init,Config::load_envs,Config::load_functions,Config::load_mcp_servers,Config::setup_model,Config::set_model, etc. Deletebridge.rs.
Migration direction preserved
Before 16d:
AppState {
config, vault, mcp_factory, rag_cache, mcp_config, mcp_log_path
}
Constructed only via struct literal from Config fields via bridge.
After 16d:
AppState {
config, vault, mcp_factory, rag_cache, mcp_config, mcp_log_path,
mcp_registry, functions
}
impl AppState {
pub async fn init(config, log_path, start_mcp_servers, abort_signal)
-> Result<Self>
}
New fields present on all code paths. New self-initializing constructor ready for 16e's switchover.