From bceb0799f808198db75c21d22437fe392f1570c3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 21 Jun 2026 13:43:31 +0000 Subject: [PATCH] =?UTF-8?q?feat(supervisor):=20S4=20owner-advance=20?= =?UTF-8?q?=E2=80=94=20KanbanActor=20(ractor=20mailbox-as-owner)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First real OUT-leg wire (enabled by the cont.30 ractor unblock). New lance-graph-supervisor::kanban_actor (feature = "supervisor"): - KanbanActor: a ractor actor whose State IS the owner (mailbox-as-owner — "every SoA is ractor-owned", operator). On KanbanMsg::Advance the owner advances ITSELF via the contract's checked try_advance_phase; illegal Rubicon edge -> typed RubiconTransitionError, no mutation. KanbanMsg::Phase reads back. - Single-writer by construction: ractor serializes one message at a time, so &mut state can't alias = the E-CE64-MB-4 no-race guarantee via &mut + mailbox, no lock. No bridge-held owner, no no-owner case. 2 tests green (advance + illegal-edge-no-mutation) under --features supervisor; clippy + fmt clean; light build (no lance/datafusion, no disk/symbiont gate). This is the owner-advance HALF of capstone S4. Remaining: the delivery edge (kanban.* -> where_is -> cast(Advance)) + S2/S3 drivers, which compose on top. Plan S4 row annotated; AGENT_LOG cont.31. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01CcpLeEC3XK8Eye53GKBVvi --- .claude/board/AGENT_LOG.md | 3 + .claude/plans/capstone-out-leg-wiring-v1.md | 12 +- .../src/kanban_actor.rs | 223 ++++++++++++++++++ crates/lance-graph-supervisor/src/lib.rs | 8 + 4 files changed, 245 insertions(+), 1 deletion(-) create mode 100644 crates/lance-graph-supervisor/src/kanban_actor.rs diff --git a/.claude/board/AGENT_LOG.md b/.claude/board/AGENT_LOG.md index 3d85206b0..1d84efb04 100644 --- a/.claude/board/AGENT_LOG.md +++ b/.claude/board/AGENT_LOG.md @@ -1,3 +1,6 @@ +## 2026-06-21 (cont.³¹) — S4 owner-advance SHIPPED: real ractor KanbanActor (first true OUT-leg wire) + +**Main thread (Opus), self-directed ("go").** First REAL OUT-leg code (not docs/stubs), enabled by the cont.³⁰ unblock. New `lance-graph-supervisor::kanban_actor` (gated `supervisor`): `KanbanActor` — a ractor actor whose `State` IS the owner (mailbox-as-owner, per operator "every SoA is ractor-owned"). On `KanbanMsg::Advance { to, reply }` the owner advances ITSELF via the contract's checked `try_advance_phase` (single-writer by construction — ractor serializes one message at a time, so `&mut state` can't alias = the E-CE64-MB-4 no-race guarantee, realized by `&mut` + mailbox, no lock); illegal Rubicon edge → typed `RubiconTransitionError`, no mutation. `KanbanMsg::Phase` reads back. **2 tests green** (`actor_advances_its_own_phase_on_message`, `illegal_edge_is_a_typed_error_no_mutation`) under `--features supervisor`; clippy clean, fmt clean; light build (no lance/datafusion, no disk/symbiont gate). This is the owner-advance HALF of S4 (the mechanism the operator named). **Remaining S4:** the delivery edge (`kanban.*` → `where_is` → `cast(Advance)`) + the S2/S3 drivers that send `Advance` (those compose on top). Plan S4 row annotated "owner-advance HALF shipped". Rides a PR on jirak. ## 2026-06-21 (cont.³⁰) — UNBLOCKED the ractor-actor path (supervisor) — stale "BLOCKED" was a Cargo.lock pin, not a fork bug **Main thread (Opus), self-directed ("continue").** Chasing the cheapest REAL OUT-leg progress, found `lance-graph-supervisor/Cargo.toml` documented a hard `BLOCKED (2026-06-14)`: "ractor fork ~2 commits behind upstream, non-exhaustive `MessagingErr::Saturated` match, `--features supervisor` will NOT compile." **That note is STALE — verified + fixed:** (1) `/home/user/ractor` (= origin/main, 0 ahead) already has `2bc7819 fix: handle MessagingErr::Saturated at all three match sites`; the enum is NOT `#[non_exhaustive]` and `derived_actor.rs` handles all 4 variants; `cargo check -p ractor --no-default-features --features tokio_runtime` is green. (2) The supervisor build still failed only because `Cargo.lock` pinned a PRE-fix git rev (`3f86d0a`, the merge commit before the all-sites fix). `cargo update -p ractor` advanced it `3f86d0a → f4c474f4` (fixed main); **`cargo check -p lance-graph-supervisor --features supervisor` now compiles clean** (verified, warnings only — callcenter's lance/datafusion are optional + off, so the build is light, no disk blowup). Net: the ractor-actor path (the **mailbox-as-owner substrate the OUT-leg S4/run-NaN depend on**) is OPEN. Committed: Cargo.lock (ractor rev), supervisor Cargo.toml (BLOCKED→RESOLVED comment). This is the first real unblock of the OUT-leg without needing the heavy planner build or symbiont. Rides a PR on jirak. diff --git a/.claude/plans/capstone-out-leg-wiring-v1.md b/.claude/plans/capstone-out-leg-wiring-v1.md index 8a174ded5..8dddc5dfa 100644 --- a/.claude/plans/capstone-out-leg-wiring-v1.md +++ b/.claude/plans/capstone-out-leg-wiring-v1.md @@ -88,7 +88,17 @@ is consumption only.) ## S4 — envelope route gets a `Kanban` handler (resolve-then-reject → accept) -**Census state:** GAP. `kanban.*` resolves to `StepDomain::Kanban` then every +**Status (2026-06-21): owner-advance HALF shipped.** `lance-graph-supervisor:: +kanban_actor::KanbanActor` is a real ractor actor whose +`State` IS the owner; on `KanbanMsg::Advance` it calls `try_advance_phase` +(owner advances itself; illegal edge → typed `RubiconTransitionError`, no +mutation). 2 tests green under `--features supervisor` (light build, no +disk/symbiont gate). **Remaining:** the *delivery edge* — `kanban.*` step → +mailbox id → `ractor::registry::where_is` → `cast(Advance)` — plus the S2/S3 +drivers that send `Advance`. The mechanism the operator described ("every SoA +is ractor-owned, the owner advances itself") is now real code. + +**Census state (original):** GAP. `kanban.*` resolves to `StepDomain::Kanban` then every bridge impl returns `DomainUnavailable` (`orchestration_impl.rs:55-57`, `codec_bridge.rs:38-40`). No handler. diff --git a/crates/lance-graph-supervisor/src/kanban_actor.rs b/crates/lance-graph-supervisor/src/kanban_actor.rs new file mode 100644 index 000000000..ff5cafbfa --- /dev/null +++ b/crates/lance-graph-supervisor/src/kanban_actor.rs @@ -0,0 +1,223 @@ +//! S4 — the kanban-advance ractor actor (the smallest *true* OUT-leg wire). +//! +//! Per the operator ownership model ("every SoA is owned by its ractor actor — +//! mailbox-as-owner"), the Rubicon phase of a per-mailbox SoA is advanced by the +//! actor that OWNS it, in reaction to a message. There is no owner-registry held +//! by a bridge and no "absent owner" case: the actor's `State` IS the owner +//! ([`MailboxSoaOwner`]). +//! +//! ## Why this is the safe substrate +//! +//! A ractor actor processes ONE message at a time, so the owner sees a strict +//! single-writer: `&mut state` during `handle` cannot alias another writer. That +//! is the compile-time "no aliasing / no data race / no use-after-free" guarantee +//! the canon attributes to mailbox-as-owner (E-CE64-MB-4) — realized here by +//! Rust's `&mut` + ractor's serialized mailbox, not by a lock. +//! +//! ## What it does NOT do (kept honest) +//! +//! This is the OWNER-advance mechanism only. It does NOT resolve a `kanban.*` +//! `UnifiedStep` to a mailbox (that delivery edge is `step_type` → mailbox id → +//! `ractor::registry::where_is` → `cast`, a separate seam) and it does NOT drive +//! the advance from a MUL gate (S2) or a Lance version tick (S3) — those compose +//! ON TOP by sending [`KanbanMsg::Advance`]. The owner advances **itself** via +//! the contract's checked [`MailboxSoaOwner::try_advance_phase`]; an illegal +//! Rubicon edge is a typed [`RubiconTransitionError`], never silent corruption. + +use lance_graph_contract::kanban::{KanbanColumn, KanbanMove, RubiconTransitionError}; +use lance_graph_contract::soa_view::MailboxSoaOwner; +use ractor::{Actor, ActorProcessingErr, ActorRef, RpcReplyPort}; + +/// Messages the kanban actor accepts. +pub enum KanbanMsg { + /// Advance the owned mailbox's Rubicon phase to `to` (checked against the + /// lifecycle DAG). Replies with the emitted [`KanbanMove`] on a legal edge, + /// or a [`RubiconTransitionError`] on an illegal one (no mutation occurs). + Advance { + to: KanbanColumn, + reply: RpcReplyPort>, + }, + /// Read the owned mailbox's current Rubicon phase (no mutation). + Phase { reply: RpcReplyPort }, +} + +/// A ractor actor whose `State` IS a [`MailboxSoaOwner`] — the SoA mailbox and +/// its owning actor are the same thing (mailbox-as-owner). On +/// [`KanbanMsg::Advance`] the owner advances its own phase via +/// [`MailboxSoaOwner::try_advance_phase`]. +pub struct KanbanActor { + _marker: core::marker::PhantomData, +} + +impl Default for KanbanActor { + fn default() -> Self { + Self { + _marker: core::marker::PhantomData, + } + } +} + +impl Actor for KanbanActor +where + O: MailboxSoaOwner + Send + Sync + 'static, +{ + type Msg = KanbanMsg; + type State = O; + type Arguments = O; + + async fn pre_start( + &self, + _myself: ActorRef, + owner: Self::Arguments, + ) -> Result { + // The actor takes ownership of the SoA mailbox at spawn. From here on the + // ONLY mutator of this owner is this actor's serialized message loop. + Ok(owner) + } + + async fn handle( + &self, + _myself: ActorRef, + msg: Self::Msg, + state: &mut Self::State, + ) -> Result<(), ActorProcessingErr> { + match msg { + KanbanMsg::Advance { to, reply } => { + // Single-writer by construction: one message at a time. The owner + // advances ITSELF; nothing else holds it. + let result = state.try_advance_phase(to); + let _ = reply.send(result); + } + KanbanMsg::Phase { reply } => { + let _ = reply.send(state.phase()); + } + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use lance_graph_contract::collapse_gate::MailboxId; + use lance_graph_contract::kanban::ExecTarget; + use lance_graph_contract::soa_view::MailboxSoaView; + + /// Minimal in-RAM owner (mirrors the contract's `FakeSoa`) — proves the actor + /// owns and advances a real `MailboxSoaOwner` without any heavy SoA backing. + struct TestBoard { + id: MailboxId, + phase: KanbanColumn, + cycle: u32, + } + + impl MailboxSoaView for TestBoard { + fn mailbox_id(&self) -> MailboxId { + self.id + } + fn n_rows(&self) -> usize { + 0 + } + fn w_slot(&self) -> u8 { + (self.id & 0x3F) as u8 + } + fn current_cycle(&self) -> u32 { + self.cycle + } + fn phase(&self) -> KanbanColumn { + self.phase + } + fn energy(&self) -> &[f32] { + &[] + } + fn edges_raw(&self) -> &[u64] { + &[] + } + fn meta_raw(&self) -> &[u32] { + &[] + } + fn entity_type(&self) -> &[u16] { + &[] + } + } + + impl MailboxSoaOwner for TestBoard { + fn advance_phase(&mut self, to: KanbanColumn) -> KanbanMove { + let from = self.phase; + self.phase = to; + self.cycle = self.cycle.wrapping_add(1); + KanbanMove { + mailbox: self.id, + from, + to, + witness_chain_position: self.cycle, + libet_offset_us: 0, + exec: ExecTarget::Native, + } + } + } + + fn board(phase: KanbanColumn) -> TestBoard { + TestBoard { + id: 42, + phase, + cycle: 0, + } + } + + #[tokio::test] + async fn actor_advances_its_own_phase_on_message() { + let (actor, handle) = Actor::spawn( + None, + KanbanActor::::default(), + board(KanbanColumn::Planning), + ) + .await + .expect("spawn kanban actor"); + + // Legal forward arc Planning -> CognitiveWork: the owner advances itself. + let mv = ractor::call!(actor, |reply| KanbanMsg::Advance { + to: KanbanColumn::CognitiveWork, + reply + }) + .expect("rpc") + .expect("legal Rubicon edge"); + assert_eq!(mv.from, KanbanColumn::Planning); + assert_eq!(mv.to, KanbanColumn::CognitiveWork); + + // The advance persisted in the owned SoA. + let phase = ractor::call!(actor, |reply| KanbanMsg::Phase { reply }).expect("rpc"); + assert_eq!(phase, KanbanColumn::CognitiveWork); + + actor.stop(None); + handle.await.expect("actor join"); + } + + #[tokio::test] + async fn illegal_edge_is_a_typed_error_no_mutation() { + let (actor, handle) = Actor::spawn( + None, + KanbanActor::::default(), + board(KanbanColumn::Planning), + ) + .await + .expect("spawn kanban actor"); + + // Planning -> Commit is NOT a legal Rubicon edge: typed error, no mutation. + let err = ractor::call!(actor, |reply| KanbanMsg::Advance { + to: KanbanColumn::Commit, + reply + }) + .expect("rpc") + .expect_err("illegal edge must be rejected"); + assert_eq!(err.from, KanbanColumn::Planning); + assert_eq!(err.to, KanbanColumn::Commit); + + // Phase unchanged — the owner did not mutate on the rejected edge. + let phase = ractor::call!(actor, |reply| KanbanMsg::Phase { reply }).expect("rpc"); + assert_eq!(phase, KanbanColumn::Planning); + + actor.stop(None); + handle.await.expect("actor join"); + } +} diff --git a/crates/lance-graph-supervisor/src/lib.rs b/crates/lance-graph-supervisor/src/lib.rs index 2edb3f084..b18a3152a 100644 --- a/crates/lance-graph-supervisor/src/lib.rs +++ b/crates/lance-graph-supervisor/src/lib.rs @@ -58,6 +58,14 @@ pub mod supervisor; #[cfg(feature = "supervisor")] pub mod actors; +/// S4 OUT-leg: the kanban-advance actor (mailbox-as-owner; the owner advances +/// its own Rubicon phase on a message). +#[cfg(feature = "supervisor")] +pub mod kanban_actor; + +#[cfg(feature = "supervisor")] +pub use kanban_actor::{KanbanActor, KanbanMsg}; + #[cfg(feature = "supervisor")] pub use supervisor::{ CallcenterSupervisor, ChildSummary, ConsumerSlot, ModuleEntry, StubConsumerActor,