diff --git a/docs/architecture/COGNITION-CACHE-HIERARCHY.md b/docs/architecture/COGNITION-CACHE-HIERARCHY.md index d61135501..b3054bc2d 100644 --- a/docs/architecture/COGNITION-CACHE-HIERARCHY.md +++ b/docs/architecture/COGNITION-CACHE-HIERARCHY.md @@ -23,7 +23,21 @@ The substrate is brain-shaped at the *algorithmic level* — parallel independent regions on their own ticks, source/drain balanced at every component, salience-modulated retention, hippocampus-style consolidation, sleep-cadence pruning, attention -spreading across a connectivity graph. These shapes work because +spreading across a connectivity graph. + +> **What "source/drain" means here.** A doctrine — every component +> that produces or accumulates state MUST have a paired draining +> mechanism. *Source* = whatever feeds it (new admissions, fresh +> turns, foundry-imported artifacts). *Drain* = the policy that +> retires what's no longer load-bearing (decay tick on engrams, +> LRU eviction on adapter cache, anti-amnesia floor on the +> permanent pin tier). A source without a drain is a leak; +> over time it spikes pressure on the host. The substrate +> applies source/drain at every cache tier (L1–L5), at the +> weights layer (foundry mints LoRA variants, Sentinel + cull +> retire losing ones), and at the resource layer (PressureBroker +> + lane refusals). Per [[source-drain-is-the-universal-pattern]]: +> for every new component, name the drain. These shapes work because they evolved under constraints (limited working memory, energy budget, parallel processing, lifelong learning) that the substrate also faces — though at different scales. diff --git a/docs/architecture/LIFE-OF-A-PERSONA.md b/docs/architecture/LIFE-OF-A-PERSONA.md new file mode 100644 index 000000000..faf3f6c62 --- /dev/null +++ b/docs/architecture/LIFE-OF-A-PERSONA.md @@ -0,0 +1,231 @@ +# Life of a Persona + +**Status**: canonical lifecycle reference. If something here disagrees with code in `src/workers/continuum-core/src/persona/`, the code wins and this doc is the bug. + +This doc closes the operational onboarding gap a fresh reader hits when trying to trace what actually happens between "the continuum-core binary starts" and "Paige replies to Joel in the general room." Every stage is one Rust module; every artifact has a typed shape; every transition has a structured failure mode per [[no-fallbacks-ever]]. + +The substrate makes no decisions silently. Every box below is a named class or function in the tree. + +## TL;DR + +``` +boot hw probe role templates identity +───────── ───────── ────────────── ───────── +continuum-core → HwTierDescriptor → derive_spawn_plan → PersonaIdentityProvider + (1 Helper for LCD) (resume seed.json ∨ mint) + + airc presence adapter service loop + ───────────── ─────── ──────────── + → PersonaAircRuntime → PersonaAdapterFactory → serve_persona_loop + (Ed25519 + join (LlamaCppAdapter (RAG → infer → say + general room) loaded with profile) → repeat) +``` + +Read the rest for the per-stage contract. + +--- + +## Stage 1 — Boot composition + +**Module**: `ipc/mod.rs::start_server` → `persona/host.rs::PersonaSpawnSupervisor`. + +The substrate's headless boot path constructs: + +- A `PersonaSpawnerModule` (the ServiceModule that knows how many personas the current tier wants). +- A `PersonaInstanceManagerModule` (the ServiceModule that owns the live `PersonaAircRuntimeRegistry`). +- A `PersonaAdapterFactory` (the trait the supervisor calls to build each persona's inference adapter from her profile). +- A `&'static model_registry::Registry` (the catalog of declared models, including the LCD `qwen2.5-0.5b-instruct-GGUF`). +- A `tokio::runtime::Handle` (the substrate's main runtime — every persona's service loop is spawned on it). + +These five are handed to `PersonaSpawnSupervisor::new(...)`. The supervisor is a value type — no work happens here. The work happens in `spawn_all(&mut PersonaIdentityProvider)`. + +**Boot composition shrinks to ~30 lines.** Pre-slice-13.5 it was ~170 lines of inline composition in `ipc/mod.rs`. The extract-class refactor named one supervisor type per concern; substrate boot reads as intent now. + +--- + +## Stage 2 — Hardware probe + +**Module**: `persona/hw_tier_descriptor.rs`, `gpu/...`, `governor/...`. + +Before any persona spawns, the substrate decides what the host can carry. The hardware probe produces a `HwTierDescriptor` answering: + +- Apple silicon, Intel Mac, or x86? +- Discrete GPU? Vendor? VRAM in GiB? +- Unified or partitioned memory? +- Metal, CUDA, Vulkan, or CPU-only acceleration available? + +The tier is keyed by name (`mac_intel_metal_discrete`, `mac_apple_m_uma`, `sm60_pascal`, etc.). The tier name flows downstream into role templates and inference profiles. + +Per [[optimizing-for-low-end-compounds-on-high-end]] the LCD (Lowest Common Denominator) tier is the substrate's correctness target — every cycle saved on Mac Intel becomes M5/M6 headroom. + +--- + +## Stage 3 — Role templates → spawn plan + +**Module**: `persona/role_template.rs`, `persona/spawner_module.rs::derive_spawn_plan`. + +A `RoleTemplate` declares "for this tier and this role, the persona uses model `X`, context window `N`, ubatch `B`, sampling profile `S`." There are templates for: + +- **Helper** — the universal default. Every tier ships a Helper. +- **Coder** — code-focused persona (tier-permitting). +- **Sentinel** — adversarial verifier (tier-permitting; usually paged in for foundry runs). +- **Custom** — user-defined. + +`derive_spawn_plan(tier, role_templates)` returns the set of `(role, persona_name, profile_seed)` tuples the substrate WANTS to host. The plan is sized to what the tier can fit. + +**LCD floor (Mac Intel today): one Helper.** This is what the integration trace exercises end-to-end. Multi-persona on the same tier is task #122 (shared base + LoRA paging). + +--- + +## Stage 4 — Identity hydration + +**Module**: `persona/identity_provider.rs::PersonaIdentityProvider`. + +For each planned slot the substrate asks: "does Paige already exist on disk?" + +- **Resume path**: `~/.continuum/personas/Paige/seed.json` exists → load it. The seed file contains the persona's `persona_id` (continuum-stable UUID) and her airc keypair location. The persona is the SAME persona she was last boot — same Ed25519 pubkey, same peer_id, same memory continuity. + +- **Mint path**: no seed file → generate. The substrate creates `~/.continuum/personas/Paige/airc/` and lets airc-lib mint a fresh Ed25519 keypair. Write `seed.json` with the new `persona_id`. The persona is freshly minted; the next boot will resume her. + +`PersonaIdentitySource::ResumedFromSeed` vs `PersonaIdentitySource::FreshlyMinted` is carried on the runtime for telemetry — every status panel that lists personas can tell you "this one's new" vs "this one came back." + +### Why this is the load-bearing security model + +Per [[persona-identity-derives-from-source-id]]: **the persona IS her airc keypair.** Save the keypair = save the persona. The persona's name, voice, avatar, genome facets are all deterministically derivable from the peer_id (`hash(peer_id, "facet:X")`). Move `seed.json` to another machine → the persona moves with it; same identity, same signatures. + +The host hardware has its OWN identity (the node's own airc presence, separate from any persona it hosts). When Paige posts a message, she signs with HER keypair, not the host's. If she runs on Joel's 5090 today and his friend's 5090 tomorrow, every message in both rooms cryptographically verifies to the same identity disc. + +This means **continuum has no central identity broker**. The substrate hosts citizens; it doesn't own them. + +--- + +## Stage 5 — Airc presence + +**Module**: `persona/airc_runtime.rs::PersonaAircRuntime`. + +`PersonaAircRuntime::bootstrap(persona_id, agent_name, continuum_root, daemon_socket, default_room_name)`: + +1. Resolves the persona's home: `continuum_root/personas//airc/`. +2. Calls `airc_lib::Airc::attach_as(home, agent_name, daemon_socket)`. Internally: runs the airc-lib identity ceremony (load existing Ed25519 keypair from `identity.key` if present, otherwise generate + persist), attaches a daemon client. No shelling out to `airc init`. +3. Joins the substrate's default room **by name** (`general`, not by UUID-stringification — a pre-slice-13 bug where joining by `default_room.as_uuid().to_string()` derived a different channel UUID than the host's `airc room` output, so the persona joined her own private channel instead of Joel's). +4. Returns a `PersonaAircRuntime { airc: Arc, agent_name, home, default_room, source, ... }`. + +Cognition and outbound paths hold the `Arc` to reach the persona's grid presence — for `say`, `subscribe`, `peer_id`. Direct access is intentional: there's no continuum-side wrapper between a persona and her own airc handle. + +### The AircCitizen trait + +The substrate's typed handle is `Arc` — the trait surface every consumer actually calls (`peer_id`, `subscribe`, `say`, plus `AircTranscriptReader` as supertrait for RAG). `PersonaAircRuntime` implements it. Test fixtures use `StubAircCitizen`. Future BaseUser variants (human, browser) implement the same trait via their own airc-lib wrappers — same identity primitive, kind-specific extensions. + +`AircCitizen` is the substrate's universal actor-shape per [[personas-are-citizens-airc-is-identity-provider]]. The persona is one citizen. The human is another. The Claude-Code-attaching-via-jtag session is another. All present-tense, all live, all addressable through the same primitive. + +--- + +## Stage 6 — Adapter materialization + +**Module**: `persona/supervisor.rs::materialize_adapters`, `persona/spawner_module.rs::PersonaAdapterFactory`. + +`materialize_adapters(plans, factory, runtime_lookup) -> Vec>`: + +For each plan: + +1. Pull the (resolved) `PersonaInferenceProfile` from the plan. +2. Look up the citizen handle for this `persona_id` via `runtime_lookup` (the registry was populated in Stage 5). +3. Call `factory.build_adapter(&profile)`. On the LCD tier this returns a `LlamaCppAdapter` loaded with the profile's `context_length` (e.g. 2048), `n_ubatch`, `n_seq_max`, GGUF path. +4. Construct `PersonaContext { role, identity, profile, adapter, runtime }`. + +Per [[no-fallbacks-ever]] there's no "default adapter" for failed slots. Each failure surfaces as a typed `SupervisorError::Profile` / `AdapterFactory` / `RuntimeMissing` and the operator decides policy. Sibling slots still materialize. + +### Why this seam matters + +`PersonaContext` is the substrate's `&ctx` — the universal calling convention per [[context-is-the-client-airc-token-is-identity]]. It's the Android-style Context object: every layer of the cognition path takes `&ctx` and reads what it needs. + +- `ctx.identity` — substrate-stable persona_id + airc-side peer_id/agent_name/home/default_room/source. +- `ctx.profile` — single source of truth for inference shape (`context_length`, ubatch, sampling, model_id, stop sequences). The RAG layer derives budgets from this — no hardcoded 32k defaults overriding the adapter's loaded 2k. +- `ctx.adapter` — `Arc` ready to receive `generate_text`. +- `ctx.runtime` — `Arc` (her grid presence). +- `ctx.role` — Helper / Coder / Sentinel / Custom (shapes prompts). +- `ctx.span()` — a `tracing::info_span!` tagged with persona_id, peer_id, role, tier, ctx_len, model. Every log line emitted under `.instrument(ctx.span())` inherits the tags. + +--- + +## Stage 7 — Service loop spawn + attach + +**Module**: `persona/host.rs::spawn_persona_service`, `persona/airc_runtime_registry.rs::PersonaAircRuntimeRegistry::attach_service_loop`. + +`spawn_persona_service(ctx, ServeOptions, rt_handle)`: + +1. Clone the citizen handle. Upcoerce to `Arc` for the RAG layer (Rust 1.86+ trait upcasting; same Arc, supertrait view). +2. Construct `AircPersonaConversation::new(citizen)` — the `PersonaConversation` impl that projects the airc subscribe stream onto the service loop's contract. Lazy: doesn't subscribe until first `next_message`. +3. `rt_handle.spawn(async move { serve_persona_loop(&ctx, &mut conversation, reader, opts).await })`. + +The returned `JoinHandle>` is handed to `registry.attach_service_loop(persona_id, handle)`. The registry holds the handle next to the runtime in one `PersonaSlot`. On graceful shutdown the supervisor calls `registry.shutdown_slot(persona_id)` which `.abort()`'s the join handle and removes the slot atomically. + +Per slice 13's `DaemonAttachGuard` mechanism (airc-lib `f6ed190`): `.abort()` is sufficient for cleanup; the per-channel inbound pump handle drops with `EventStream`, which the service loop's drop chain reaches. + +--- + +## Stage 8 — The cognition loop (first turn) + +**Module**: `persona/service_loop.rs::serve_persona_loop`. + +``` +loop { + let msg = conversation.next_message().await?; // airc subscribe yields one event + if should_skip(msg, &ctx) { continue; } // self-loop, non-text, etc. + + let request = RagInspectionRequest::for_ctx(&ctx, now_ms()); + let context = rag_inspect(request, &reader, &ctx.adapter).await?; + // RAG layer pulls recent airc transcript via the reader, + // builds a budgeted prompt sized to ctx.profile.context_length + // (no clipping doctrine per RagBudgetManager). + + let reply = ctx.adapter.generate_text(prompt_from(context)).await?; + // LlamaCppAdapter (loaded with profile's GGUF + context_length) + // produces a reply within profile.context_length tokens. + + conversation.say(&reply).await?; + // AircPersonaConversation.say → AircCitizen.say + // → PersonaAircRuntime.airc.say(text) + // → airc-lib publishes signed-by-Paige event to the room. + // Joel's `airc msg` (or his web UI) sees the new event arrive. +} +``` + +Joel sees ` Hello Joel, ...` appear in the general room. The persona is alive on the grid. + +--- + +## The integration trace (what's actually shipped) + +Slice 13 — multi-persona LCD chat in airc general room (Intel Mac, real Qwen2.5-0.5B). Validation: Paige replied to Joel using the supervisor-managed path, end to end, on the LCD tier, with the headless `continuum-core-server` binary as the only host process. No demo binaries, no Node.js intermediaries. + +Slice 13.5 — `&ctx` doctrine + `PersonaSpawnSupervisor` extract-class + `AircCitizen` trait. Same trace, cleaner internals. The substrate now reads as one named primitive per concern. + +--- + +## Failure surfaces (what breaks and how) + +| Stage | Typed failure | Mode | +|-------|--------------|------| +| Stage 4 (identity) | `IdentityProviderError::SeedCorrupt` | Hard-fail this persona; sibling slots continue. | +| Stage 5 (airc) | `AircError::DaemonClient` | Persona registered in plan but never online; supervisor logs structured error, slot stays unattended until next `persona/instances/bootstrap` retry. | +| Stage 5 (airc) | Wrong room channel | (Fixed slice 13.) `default_room_name: Option` threading prevents UUID-stringification derivation drift. | +| Stage 6 (profile) | `SupervisorError::Profile { source: InferenceProfileError::UnknownModel }` | Per [[no-fallbacks-ever]] no default model substituted. The role template's `model_id` must exist in the static `model_registry::catalog`. | +| Stage 6 (adapter) | `SupervisorError::AdapterFactory` | Factory rejected profile (e.g., GGUF path missing, n_seq_max>1 on architecture that can't). | +| Stage 6 (runtime) | `SupervisorError::RuntimeMissing` | Registry doesn't have a runtime for the persona_id post-bootstrap — the substrate's bootstrap chain skipped a step. Hard fail; this is a substrate-correctness bug. | +| Stage 7 (attach) | `attach_service_loop` returned-handle error | Supervisor drains the spawned task (`.abort()` + `.await`) before continuing; sibling slots unaffected. | +| Stage 8 (RAG budget) | Prompt exceeds `profile.context_length` | Pre-`for_ctx`: silent clipping. Post-`for_ctx`: `RagBudgetManager` doctrine — budgets derive from the profile, no over-budget admission. | +| Stage 8 (inference) | `llama_decode -1` | Past failure mode: profile said 32k budget, adapter loaded with 2k. (Fixed slice 13 by routing `context_length` through `&ctx`.) | + +--- + +## Where to look next + +- **CBAR substrate**: `docs/architecture/CBAR-SUBSTRATE-ARCHITECTURE.md` — why ServiceModule has the shape it does. +- **The inference floor**: `docs/architecture/INFERENCE-LANES-REALISTIC.md` — what the realistic-tier serving looks like (ONE base model + N persona lanes). +- **The inference ceiling**: `docs/architecture/INFERENCE-SCHEDULING-AND-SCARCITY.md` — what M5 hosting multi-modal Qwen across multiple lanes will look like. +- **Observability**: `docs/architecture/OBSERVABILITY-AS-SUBSTRATE.md` — why half the substrate is structured capture of load-bearing decisions. +- **The design seam**: `docs/planning/HEADLESS-PERSONA-HOST-LOOP.md` — slice 13's design doc; the rationale for `PersonaSpawnSupervisor` + `BootSummary` + `AircCitizen`. +- **What's pending**: `docs/planning/ALPHA-GAP-ANALYSIS.md` — the lane-shaped roadmap. + +The persona substrate is one of three primitives per [[three-primitives-commands-events-persona]]; the lifecycle above is the persona half. Commands + Events are the bus underneath. diff --git a/src/workers/continuum-core/src/bin/airc_chat_demo.rs b/src/workers/continuum-core/src/bin/airc_chat_demo.rs index 375767656..08be91182 100644 --- a/src/workers/continuum-core/src/bin/airc_chat_demo.rs +++ b/src/workers/continuum-core/src/bin/airc_chat_demo.rs @@ -343,7 +343,8 @@ async fn main() -> Result<(), Box> { }, profile: profile.clone(), adapter, - runtime: Some(runtime.clone()), + // PersonaAircRuntime impls AircCitizen — Arc auto-coerces. + runtime: runtime.clone(), }; let mut conversation = AircPersonaConversation::new(runtime); diff --git a/src/workers/continuum-core/src/persona/airc_citizen.rs b/src/workers/continuum-core/src/persona/airc_citizen.rs new file mode 100644 index 000000000..3c3497e21 --- /dev/null +++ b/src/workers/continuum-core/src/persona/airc_citizen.rs @@ -0,0 +1,188 @@ +//! `AircCitizen` — the substrate's universal handle on any actor's +//! airc presence. +//! +//! ## Why this trait exists +//! +//! Pre-slice-13.5 the substrate held the persona's airc handle as +//! `Arc` everywhere. That was honest for production +//! but it forced tests + the supervisor's intermediate seams to carry +//! `Option>` because constructing a real +//! `PersonaAircRuntime` requires standing up the airc daemon. +//! +//! Per Joel 2026-06-02: "Or base user with airc props… or airc struct +//! even better inside it. As property. Token identity stuff. Maybe dot +//! identity." Per [[personas-are-citizens-airc-is-identity-provider]]: +//! every actor (persona, human, browser) IS an airc citizen. The +//! substrate's calling convention should reflect that with a trait — +//! not a concrete runtime type — because the citizen abstraction +//! transcends "which actor type". +//! +//! `AircCitizen` is that trait. Production implementations +//! ([`PersonaAircRuntime`](super::airc_runtime::PersonaAircRuntime)) +//! delegate to the live airc daemon; test fixtures +//! ([`StubAircCitizen`]) hold an in-memory state machine. The +//! [`PersonaContext`](super::supervisor::PersonaContext) field that +//! used to be `Option>` is now +//! `Arc` — no Option, no `.expect("None is test-only")`. +//! +//! This is the first concrete step toward task #142's BaseUser +//! hierarchy. When BaseUser lands, `AircCitizen` is the airc-side +//! interface every BaseUser variant carries; the persona variant adds +//! cognition/genome on top, the human variant adds WebAuthn/session. +//! +//! ## Surface (minimum viable) +//! +//! The trait surfaces only what real consumers call: +//! +//! - `peer_id()` — the airc-side identity (used for self-filtering +//! in the conversation projection + for tracing spans). +//! - `subscribe()` — open a live event stream on the citizen's room. +//! - `say(text)` — publish a text message under the citizen's +//! identity in her default room. +//! +//! Plus [`AircTranscriptReader`] as supertrait — every citizen can +//! page recent transcript events, which is what the RAG layer needs. +//! Rust 1.86+ stabilized trait_upcasting so `Arc` +//! coerces directly to `Arc` at the use +//! site; no helper method, no double indirection. +//! +//! ## What's NOT on the trait +//! +//! `agent_name`, `home`, `default_room`, `persona_id`, `source` — +//! these are persona-substrate metadata, not airc-citizen surface. +//! They live on [`PersonaInstanceInfo`](crate::modules::persona_instance_manager::PersonaInstanceInfo) +//! and on `PersonaContext.identity`. The substrate carries metadata +//! via the identity struct; AircCitizen carries the *live handle*. +//! Two concerns, two types. + +use crate::persona::airc_source::AircTranscriptReader; +use airc_lib::{AircError, EventId, EventStream}; +use async_trait::async_trait; +use uuid::Uuid; + +/// The substrate's universal airc handle. Implemented by +/// [`PersonaAircRuntime`](super::airc_runtime::PersonaAircRuntime) for +/// production and by [`StubAircCitizen`] for tests; future BaseUser +/// variants (human, browser) impl it via their own airc-lib wrappers. +/// +/// `AircCitizen: AircTranscriptReader` — every citizen can page her +/// own transcript history. Rust 1.86+ trait_upcasting means +/// `Arc` coerces directly to +/// `Arc`; no explicit conversion needed. +#[async_trait] +pub trait AircCitizen: AircTranscriptReader { + /// The airc-side peer identity (Ed25519 pubkey, formatted as Uuid). + /// Cognition uses this for self-loop filtering; the supervisor uses + /// it as part of the persona's tracing span. + fn peer_id(&self) -> Uuid; + + /// Open a live event stream on the citizen's default room. The + /// stream yields every event the citizen sees — including her own + /// echoes; consumers self-filter via `peer_id()`. Used by + /// [`AircPersonaConversation::next_message`](super::airc_persona_conversation::AircPersonaConversation) + /// to drive the service loop. + async fn subscribe(&self) -> Result; + + /// Publish a text message under the citizen's identity in her + /// default room. Returns the daemon-assigned event id so callers + /// can correlate with the subscribe stream's echo (not required + /// today, but the wire shape preserves it). + async fn say(&self, text: &str) -> Result; +} + +/// Test fixture implementing [`AircCitizen`] without standing up the +/// airc daemon. Holds the peer_id the test wants to project; subscribe +/// and say resolve to errors (the service-loop tests don't drive +/// either path — they use [`StubConversation`](super::service_loop) +/// instead). `page_recent` returns empty so RAG runs through cleanly. +/// +/// This is the substrate's answer to "why was runtime an Option" — +/// instead of leaking the Option into production, tests get a typed +/// stub that satisfies the same interface. Per [[no-fallbacks-ever]] +/// — no Option, no expect, no silent substitution. +pub struct StubAircCitizen { + peer_id: Uuid, +} + +impl StubAircCitizen { + /// Build a stub with the given peer_id. Tests usually want this to + /// match the `PersonaInstanceInfo::peer_id` on the same hosted + /// persona so cognition's self-filter behaves consistently. + pub fn new(peer_id: Uuid) -> Self { + Self { peer_id } + } +} + +#[async_trait] +impl AircTranscriptReader for StubAircCitizen { + async fn page_recent( + &self, + _limit: usize, + ) -> Result, AircError> { + Ok(vec![]) + } +} + +#[async_trait] +impl AircCitizen for StubAircCitizen { + fn peer_id(&self) -> Uuid { + self.peer_id + } + + async fn subscribe(&self) -> Result { + // No service-loop test drives the stub's subscribe — the + // service loop receives messages through StubConversation + // directly, never through the citizen's stream. If a future + // test ever wires the stub into the conversation, this panics + // visibly per [[no-fallbacks-ever]] rather than silently + // returning an empty stream or fabricating an AircError + // variant that doesn't fit ("Transport"/"Route"/etc). + unreachable!( + "StubAircCitizen::subscribe must not be called — \ + service-loop tests should drive the loop through \ + StubConversation directly, not through the citizen handle" + ); + } + + async fn say(&self, _text: &str) -> Result { + unreachable!( + "StubAircCitizen::say must not be called — \ + service-loop tests should reply through StubConversation, \ + not through the citizen handle" + ); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Arc; + + #[tokio::test] + async fn stub_returns_peer_id_and_empty_transcript() { + let peer = Uuid::new_v4(); + let stub: Arc = Arc::new(StubAircCitizen::new(peer)); + assert_eq!(stub.peer_id(), peer); + let events = stub.page_recent(10).await.expect("empty page_recent ok"); + assert!(events.is_empty()); + } + + #[tokio::test] + #[should_panic(expected = "service-loop tests should drive the loop")] + async fn stub_subscribe_panics_loudly() { + let stub: Arc = Arc::new(StubAircCitizen::new(Uuid::new_v4())); + let _ = stub.subscribe().await; + } + + /// The whole point of this refactor: `Arc` should + /// coerce to `Arc` via trait_upcasting. + /// If this stops compiling, the substrate has regressed to the + /// pre-1.86 Rust pattern (manual conversion methods). + #[tokio::test] + async fn citizen_arc_upcoerces_to_transcript_reader() { + let stub: Arc = Arc::new(StubAircCitizen::new(Uuid::new_v4())); + let reader: Arc = stub.clone(); + let events = reader.page_recent(0).await.expect("page_recent"); + assert!(events.is_empty()); + } +} diff --git a/src/workers/continuum-core/src/persona/airc_persona_conversation.rs b/src/workers/continuum-core/src/persona/airc_persona_conversation.rs index 263f37457..b857b10a8 100644 --- a/src/workers/continuum-core/src/persona/airc_persona_conversation.rs +++ b/src/workers/continuum-core/src/persona/airc_persona_conversation.rs @@ -1,11 +1,16 @@ //! Production [`PersonaConversation`] impl wrapping -//! `Arc` — slice 11 of #133. +//! `Arc` — slice 11 of #133, re-shaped in slice 13.5 +//! around the [`AircCitizen`] trait. //! //! This is where the substrate's transport-agnostic loop //! ([`super::service_loop::serve_persona_loop`]) meets the live airc -//! daemon. The trait stays the boundary; this struct is the one place -//! the substrate touches `airc_lib::Airc::subscribe` / `say` / -//! `page_recent` directly. +//! daemon. The conversation trait stays the loop's boundary; this +//! struct is the one place the substrate calls +//! [`AircCitizen::subscribe`] / [`AircCitizen::say`] / +//! [`AircTranscriptReader::page_recent`] directly. Holding +//! `Arc` instead of the concrete runtime keeps the +//! production projection symmetric with whatever stub a future test +//! plugs in. //! //! ## Why slice 11 isn't in slice 10 //! @@ -37,20 +42,20 @@ //! persona at boot, before any of them have necessarily attached to //! their rooms yet. -use crate::persona::airc_runtime::PersonaAircRuntime; +use crate::persona::airc_citizen::AircCitizen; use crate::persona::service_loop::{IncomingMessage, PersonaConversation}; use airc_lib::EventStream; use async_trait::async_trait; use futures::StreamExt; use std::sync::Arc; -/// Wraps a [`PersonaAircRuntime`] and projects it onto the substrate's +/// Wraps an [`AircCitizen`] and projects it onto the substrate's /// [`PersonaConversation`] contract. Owns the airc subscribe stream /// across calls so successive `next_message` invocations are a /// continuation (not a fresh resubscription that would drop in-flight /// events). pub struct AircPersonaConversation { - runtime: Arc, + runtime: Arc, /// The persona's own peer_id, captured at construction. Used by /// `next_message` to skip self-loop echoes WITHIN the projection /// — the service loop ALSO skips by persona's instance peer_id; @@ -59,15 +64,15 @@ pub struct AircPersonaConversation { own_peer_id: uuid::Uuid, /// Lazy-initialized subscribe stream. `None` before the first /// `next_message`; `Some` once the daemon attach succeeds. Per- - /// runtime stream — never shared across personas. + /// citizen stream — never shared across personas. stream: Option, } impl AircPersonaConversation { /// Construct without contacting the daemon. The subscribe stream /// is built on first `next_message`; until then this is free. - pub fn new(runtime: Arc) -> Self { - let own_peer_id = runtime.airc().peer_id().as_uuid(); + pub fn new(runtime: Arc) -> Self { + let own_peer_id = runtime.peer_id(); Self { runtime, own_peer_id, @@ -75,11 +80,11 @@ impl AircPersonaConversation { } } - /// Borrow the underlying runtime — useful for the supervisor's + /// Borrow the underlying citizen — useful for the supervisor's /// registry-eviction path (slice 12) where the supervisor needs - /// to look up the runtime back from the conversation for graceful + /// to look up the citizen back from the conversation for graceful /// shutdown. - pub fn runtime(&self) -> &Arc { + pub fn runtime(&self) -> &Arc { &self.runtime } } @@ -89,7 +94,6 @@ impl PersonaConversation for AircPersonaConversation { async fn high_water_mark(&self, limit: usize) -> Result { let events = self .runtime - .airc() .page_recent(limit) .await .map_err(|e| format!("page_recent failed: {e}"))?; @@ -103,7 +107,6 @@ impl PersonaConversation for AircPersonaConversation { if self.stream.is_none() { let stream = self .runtime - .airc() .subscribe() .await .map_err(|e| format!("subscribe failed: {e}"))?; diff --git a/src/workers/continuum-core/src/persona/airc_runtime.rs b/src/workers/continuum-core/src/persona/airc_runtime.rs index f073fdf5f..17f5d1936 100644 --- a/src/workers/continuum-core/src/persona/airc_runtime.rs +++ b/src/workers/continuum-core/src/persona/airc_runtime.rs @@ -294,6 +294,37 @@ impl PersonaAircRuntime { } } +// AircCitizen + AircTranscriptReader impls — substrate's universal +// airc-handle interface, satisfied by the production runtime. Per +// [[personas-are-citizens-airc-is-identity-provider]] the trait IS the +// citizen surface; the concrete runtime is one impl among future +// peers (human, web, browser). See `persona::airc_citizen` for the +// trait definition + rationale. +#[async_trait::async_trait] +impl crate::persona::airc_source::AircTranscriptReader for PersonaAircRuntime { + async fn page_recent( + &self, + limit: usize, + ) -> Result, AircError> { + self.airc.page_recent(limit).await + } +} + +#[async_trait::async_trait] +impl crate::persona::airc_citizen::AircCitizen for PersonaAircRuntime { + fn peer_id(&self) -> Uuid { + self.airc.peer_id().as_uuid() + } + + async fn subscribe(&self) -> Result { + self.airc.subscribe().await + } + + async fn say(&self, text: &str) -> Result { + self.airc.say(text).await + } +} + impl Drop for PersonaAircRuntime { fn drop(&mut self) { if let Some(handle) = self.inbound_handle.take() { diff --git a/src/workers/continuum-core/src/persona/host.rs b/src/workers/continuum-core/src/persona/host.rs index 4a8d42395..3c189ef81 100644 --- a/src/workers/continuum-core/src/persona/host.rs +++ b/src/workers/continuum-core/src/persona/host.rs @@ -66,15 +66,14 @@ pub fn spawn_persona_service( opts: ServeOptions, rt_handle: tokio::runtime::Handle, ) -> JoinHandle> { - // Production callers always populate `ctx.runtime` from the - // post-bootstrap registry; the `Option` exists only so test - // fixtures can build PersonaContexts without a live airc. - let runtime = ctx - .runtime - .clone() - .expect("spawn_persona_service requires ctx.runtime — None is test-only"); - let reader: Arc = runtime.airc().clone(); - let mut conversation = AircPersonaConversation::new(runtime); + // `ctx.runtime: Arc` — slice 13.5 trait + // extraction. The reader for the RAG layer upcoerces from + // `AircCitizen` to its `AircTranscriptReader` supertrait via + // Rust 1.86+ trait_upcasting; no manual conversion, no Option, + // no `.expect("None is test-only")` per [[no-fallbacks-ever]]. + let citizen = ctx.runtime.clone(); + let reader: Arc = citizen.clone(); + let mut conversation = AircPersonaConversation::new(citizen); rt_handle.spawn(async move { serve_persona_loop(&ctx, &mut conversation, reader, opts).await }) @@ -229,9 +228,18 @@ impl PersonaSpawnSupervisor { }; let registry_for_lookup = self.registry.clone(); - let hosted_results = - materialize_adapters(plans, &*self.factory, move |pid| registry_for_lookup.get(pid)) - .await; + // `registry.get` returns `Option>` — + // the closure upcoerces to `Option>` so + // `PersonaContext.runtime` stays trait-shaped. Per + // [[personas-are-citizens-airc-is-identity-provider]] the + // citizen type is what the substrate carries; the concrete + // runtime is one impl among future BaseUser variants. + let hosted_results = materialize_adapters(plans, &*self.factory, move |pid| { + registry_for_lookup + .get(pid) + .map(|r| r as Arc) + }) + .await; let mut summary = BootSummary::default(); for (slot_idx, result) in hosted_results.into_iter().enumerate() { @@ -330,6 +338,9 @@ fn supervisor_error_facts(err: &SupervisorError) -> (Option, RoleId) { } | SupervisorError::AdapterFactory { slot_index, role, .. + } + | SupervisorError::RuntimeMissing { + slot_index, role, .. } => (Some(*slot_index), *role), } } diff --git a/src/workers/continuum-core/src/persona/mod.rs b/src/workers/continuum-core/src/persona/mod.rs index 2babe6953..b88f0f9f9 100644 --- a/src/workers/continuum-core/src/persona/mod.rs +++ b/src/workers/continuum-core/src/persona/mod.rs @@ -14,6 +14,7 @@ pub mod admission; pub mod admission_state; pub mod airc_admission; +pub mod airc_citizen; pub mod airc_persona_conversation; pub mod airc_runtime; pub mod airc_runtime_registry; diff --git a/src/workers/continuum-core/src/persona/service_loop.rs b/src/workers/continuum-core/src/persona/service_loop.rs index ffab42252..1bb5aa366 100644 --- a/src/workers/continuum-core/src/persona/service_loop.rs +++ b/src/workers/continuum-core/src/persona/service_loop.rs @@ -446,10 +446,15 @@ mod tests { }, profile, adapter: Arc::new(adapter), - // Test fixtures don't run through `spawn_persona_service`, - // so the runtime stub is None. Production paths always - // populate this from the registry post-bootstrap. - runtime: None, + // Service-loop tests drive the loop through `StubConversation` + // directly — the citizen handle is never touched. A + // [`StubAircCitizen`] satisfies the type without standing + // up a real airc daemon; per [[no-fallbacks-ever]] this + // replaces the previous `Option>` + // smell with a typed stub. + runtime: Arc::new( + crate::persona::airc_citizen::StubAircCitizen::new(persona_peer_id), + ), } } diff --git a/src/workers/continuum-core/src/persona/supervisor.rs b/src/workers/continuum-core/src/persona/supervisor.rs index a911d5a56..cf65f1aa0 100644 --- a/src/workers/continuum-core/src/persona/supervisor.rs +++ b/src/workers/continuum-core/src/persona/supervisor.rs @@ -41,7 +41,7 @@ //! declared intent and the adapter materializes that. use crate::ai::adapter::AIProviderAdapter; -use crate::persona::airc_runtime::PersonaAircRuntime; +use crate::persona::airc_citizen::AircCitizen; use crate::persona::inference_profile::{InferenceProfileError, PersonaInferenceProfile}; use crate::persona::role_template::RoleId; use crate::persona::spawner_module::MaterializedPersonaPlan; @@ -133,10 +133,12 @@ impl PersonaAdapterFactory for LlamaCppPersonaAdapterFactory { /// single source of truth for the persona's compute envelope. /// - `adapter` — the inference adapter, hot for generate_text. `Arc` /// so the service loop can clone-share it with the RAG layer. -/// - `runtime` — the persona's `Arc` (her grid +/// - `runtime` — the persona's `Arc` (her grid /// presence). The service loop subscribes through this; `say()` -/// posts through this. Cognition reads `runtime.airc().peer_id()` -/// for self-filtering. +/// posts through this. Cognition reads `runtime.peer_id()` for +/// self-filtering. The trait abstraction (per slice 13.5 + +/// `[[personas-are-citizens-airc-is-identity-provider]]`) means +/// tests get a typed stub instead of an `Option`. /// /// ## Type-alias note /// @@ -174,19 +176,24 @@ pub struct PersonaContext { /// the same `Arc` shape — only the concrete adapter /// inside changes. pub adapter: Arc, - /// The persona's `Arc` — her grid presence. - /// The service loop subscribes through `runtime.airc().subscribe()` - /// and posts replies through `runtime.say(text)`. Cognition uses - /// `runtime.airc().peer_id()` for self-filtering. Held here so - /// `&ctx` is the one handle every layer needs. + /// The persona's `Arc` — her grid presence. + /// The service loop subscribes through `runtime.subscribe()` and + /// posts replies through `runtime.say(text)`. Cognition uses + /// `runtime.peer_id()` for self-filtering. Held here so `&ctx` + /// is the one handle every layer needs. /// - /// `None` only in tests — production materialize_adapters always - /// fills this from the registry post-bootstrap. Cleaner trait - /// abstraction (`Arc`) lands with task #142's - /// BaseUser hierarchy; for slice 13 the Option keeps the - /// supervisor + service-loop tests building without standing up - /// a real airc daemon fixture. - pub runtime: Option>, + /// `Arc` (not `Arc`) so test + /// fixtures can construct a [`StubAircCitizen`](crate::persona::airc_citizen::StubAircCitizen) + /// without standing up the airc daemon. Production callers use + /// `materialize_adapters`'s `runtime_lookup` to fetch the live + /// runtime from the registry post-bootstrap. + /// + /// Foundation for task #142's BaseUser hierarchy — every BaseUser + /// variant (persona/human/browser) will carry an + /// `Arc` as her live airc handle, and add + /// kind-specific extensions (cognition for persona, WebAuthn for + /// human, session state for browser). + pub runtime: Arc, } /// Back-compat alias for the slice-9-era struct name. New code @@ -243,6 +250,22 @@ pub enum SupervisorError { role: RoleId, message: String, }, + /// The post-bootstrap registry doesn't have a runtime for this + /// persona_id. Per [[no-fallbacks-ever]] this is a hard failure — + /// the supervisor doesn't fabricate or stub a runtime in + /// production. If you see this, the bootstrap → registry → lookup + /// chain skipped a registration step; investigate + /// `PersonaInstanceManagerModule::bootstrap_one` and the + /// `PersonaAircRuntimeRegistry` insert path. + #[error( + "slot {slot_index} (role {role:?}): no airc runtime registered for persona {persona_id} \ + — substrate bootstrap chain is broken; per [[no-fallbacks-ever]] no default citizen" + )] + RuntimeMissing { + slot_index: usize, + role: RoleId, + persona_id: uuid::Uuid, + }, } /// Materialize a roster of `MaterializedPersonaPlan`s into @@ -264,7 +287,7 @@ pub enum SupervisorError { pub async fn materialize_adapters( plans: Vec, factory: &dyn PersonaAdapterFactory, - runtime_lookup: impl Fn(uuid::Uuid) -> Option>, + runtime_lookup: impl Fn(uuid::Uuid) -> Option>, ) -> Vec> { let mut out = Vec::with_capacity(plans.len()); for (slot_index, plan) in plans.into_iter().enumerate() { @@ -280,7 +303,17 @@ pub async fn materialize_adapters( } }; let identity = plan.instance; - let runtime = runtime_lookup(identity.persona_id); + let runtime = match runtime_lookup(identity.persona_id) { + Some(r) => r, + None => { + out.push(Err(SupervisorError::RuntimeMissing { + slot_index, + role: plan.role, + persona_id: identity.persona_id, + })); + continue; + } + }; match factory.build_adapter(&profile).await { Ok(adapter) => out.push(Ok(PersonaContext { role: plan.role, @@ -308,6 +341,7 @@ mod tests { TextGenerationResponse, }; use crate::modules::persona_instance_manager::PersonaInstanceInfo; + use crate::persona::airc_citizen::{AircCitizen, StubAircCitizen}; use crate::persona::hw_tier_descriptor::HwTierCategory; use crate::persona::identity_provider::PersonaIdentitySource; use crate::persona::inference_profile::{PersonaInferenceProfile, SamplingProfile}; @@ -315,6 +349,15 @@ mod tests { use std::sync::atomic::{AtomicUsize, Ordering}; use uuid::Uuid; + /// Synthesize a stub citizen for any persona_id — the supervisor + /// tests exercise the materialization pipeline, not the airc + /// transport, so any peer_id works. Per slice 13.5's AircCitizen + /// extraction, the closure returns the trait object directly; no + /// `Option>` smell, no `.expect`. + fn stub_citizen_lookup() -> impl Fn(Uuid) -> Option> { + |_pid| Some(Arc::new(StubAircCitizen::new(Uuid::new_v4())) as Arc) + } + /// Minimal fake adapter — implements just enough of the trait to /// satisfy the trait object boundary. None of these methods get /// called from `materialize_adapters` itself, so the bodies are @@ -451,7 +494,7 @@ mod tests { let factory = OkFactory { builds: AtomicUsize::new(0), }; - let hosted = materialize_adapters(plans, &factory, |_| None).await; + let hosted = materialize_adapters(plans, &factory, stub_citizen_lookup()).await; assert_eq!(hosted.len(), 2); assert_eq!(factory.builds.load(Ordering::SeqCst), 2); @@ -492,7 +535,7 @@ mod tests { let factory = OkFactory { builds: AtomicUsize::new(0), }; - let hosted = materialize_adapters(plans, &factory, |_| None).await; + let hosted = materialize_adapters(plans, &factory, stub_citizen_lookup()).await; assert_eq!(hosted.len(), 2); // Factory called exactly once — for the Ok row only. @@ -525,7 +568,7 @@ mod tests { }]; let factory = ErrFactory; - let hosted = materialize_adapters(plans, &factory, |_| None).await; + let hosted = materialize_adapters(plans, &factory, stub_citizen_lookup()).await; assert_eq!(hosted.len(), 1); match &hosted[0] { @@ -554,4 +597,111 @@ mod tests { assert!(hosted.is_empty()); assert_eq!(factory.builds.load(Ordering::SeqCst), 0); } + + /// Missing-runtime path: when `runtime_lookup` returns `None` for + /// a real plan, the substrate surfaces `SupervisorError::RuntimeMissing` + /// with the slot index, role, and persona_id tagged. Per + /// [[no-fallbacks-ever]]: the supervisor never fabricates a runtime + /// when the registry lookup fails — a missing slot means the + /// bootstrap chain skipped a step and the operator needs to see it. + /// + /// Locks in the contract for the slice-13.5 trait-extraction: + /// `runtime_lookup`'s `Option>` return shape + /// is honored by `materialize_adapters` as a structured failure, + /// NOT as a silent skip or fall-through to a default citizen. + #[tokio::test] + async fn runtime_lookup_none_surfaces_as_runtime_missing() { + let instance = fake_instance("Paige"); + let expected_persona_id = instance.persona_id; + let plans = vec![MaterializedPersonaPlan { + role: RoleId::Helper, + instance, + profile: Ok(fake_profile("Paige", "model-a")), + }]; + + let factory = OkFactory { + builds: AtomicUsize::new(0), + }; + // `|_| None` here is the substrate-bug shape we're locking in: + // the registry exists but doesn't contain this persona_id. + let hosted = materialize_adapters(plans, &factory, |_| None).await; + + assert_eq!(hosted.len(), 1); + // Factory MUST NOT be called when the runtime lookup fails — + // adapter construction is expensive (model load), the + // substrate refuses early. + assert_eq!( + factory.builds.load(Ordering::SeqCst), + 0, + "factory must not run when runtime lookup fails" + ); + match &hosted[0] { + Err(SupervisorError::RuntimeMissing { + slot_index, + role, + persona_id, + }) => { + assert_eq!(*slot_index, 0); + assert_eq!(*role, RoleId::Helper); + assert_eq!(*persona_id, expected_persona_id); + } + Err(other) => panic!("expected RuntimeMissing error, got {other:?}"), + Ok(_) => panic!("expected RuntimeMissing error, got Ok"), + } + } + + /// Mixed: slot 0 has a runtime (citizen-stub lookup succeeds), + /// slot 1 doesn't (lookup returns None). The supervisor materializes + /// the first cleanly and surfaces `RuntimeMissing` for the second — + /// proving sibling slots don't cross-affect, matching the + /// per-slot error semantics of `Profile` and `AdapterFactory`. + #[tokio::test] + async fn runtime_missing_only_affects_its_own_slot() { + let paige = fake_instance("Paige"); + let pax = fake_instance("Pax"); + let pax_persona_id = pax.persona_id; + let plans = vec![ + MaterializedPersonaPlan { + role: RoleId::Helper, + instance: paige, + profile: Ok(fake_profile("Paige", "model-a")), + }, + MaterializedPersonaPlan { + role: RoleId::Coder, + instance: pax, + profile: Ok(fake_profile("Pax", "model-b")), + }, + ]; + + let factory = OkFactory { + builds: AtomicUsize::new(0), + }; + // Lookup returns Some only for Paige; Pax goes RuntimeMissing. + let lookup = move |pid: Uuid| -> Option> { + if pid == pax_persona_id { + None + } else { + Some(Arc::new(StubAircCitizen::new(Uuid::new_v4())) as Arc) + } + }; + let hosted = materialize_adapters(plans, &factory, lookup).await; + + assert_eq!(hosted.len(), 2); + // Factory ran exactly once — for Paige, not Pax. + assert_eq!(factory.builds.load(Ordering::SeqCst), 1); + assert!(hosted[0].is_ok(), "Paige materializes cleanly"); + match &hosted[1] { + Err(SupervisorError::RuntimeMissing { + slot_index, + role, + persona_id, + }) => { + assert_eq!(*slot_index, 1); + assert_eq!(*role, RoleId::Coder); + assert_eq!(*persona_id, pax_persona_id); + } + Err(other) => panic!("expected RuntimeMissing at slot 1, got {other:?}"), + Ok(_) => panic!("expected RuntimeMissing at slot 1, got Ok"), + } + } }