diff --git a/.claude/board/AGENT_LOG.md b/.claude/board/AGENT_LOG.md index 1d84efb0..fd9dfa3f 100644 --- a/.claude/board/AGENT_LOG.md +++ b/.claude/board/AGENT_LOG.md @@ -1,3 +1,6 @@ +## 2026-06-21 (cont.³²) — S4 delivery edge SHIPPED — S4 mechanism now COMPLETE end-to-end + +**Main thread (Opus), self-directed (da-capo).** Completed S4 on the same light crate: `deliver_kanban_step("kanban..")` in `lance-graph-supervisor::kanban_actor` — `parse_kanban_step` (snake_case phase vocab) → `ractor::registry::where_is(mailbox)` → `cast(KanbanMsg::Advance{to})` → relays the owner's `try_advance_phase` result. Address source = the step's existing string + the actor system's OWN registry (NOT a bespoke bridge registry, NOT a `UnifiedStep` field — exactly the codex-#574-corrected design). `KanbanRouteError`: `BadStepType` / `NoMailbox` (routing miss, NOT a no-owner case — a live mailbox is always owned) / `Illegal` (relayed RubiconTransitionError) / `Rpc`. **+2 tests (4 total green):** `parse_kanban_step_shapes`, `delivery_edge_resolves_via_registry_then_advances` (legal advance via where_is; unknown mailbox → graceful NoMailbox; illegal edge → Illegal; malformed → BadStepType). clippy clean (fixed an `unnecessary_to_owned` on the where_is arg) + fmt; light build, no disk/symbiont gate. **S4 mechanism is now COMPLETE end-to-end** (owner-advance #576 + delivery edge); only the S2/S3 *drivers* that SEND Advance remain, composing on top. Plan S4 status → "COMPLETE". Rides a PR on jirak. ## 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. diff --git a/.claude/plans/capstone-out-leg-wiring-v1.md b/.claude/plans/capstone-out-leg-wiring-v1.md index 8dddc5df..5be12849 100644 --- a/.claude/plans/capstone-out-leg-wiring-v1.md +++ b/.claude/plans/capstone-out-leg-wiring-v1.md @@ -88,15 +88,21 @@ is consumption only.) ## S4 — envelope route gets a `Kanban` handler (resolve-then-reject → accept) -**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. +**Status (2026-06-21): S4 mechanism COMPLETE (owner-advance + delivery edge).** +`lance-graph-supervisor::kanban_actor` (feature `supervisor`): +- `KanbanActor` — ractor actor whose `State` IS the owner; + `KanbanMsg::Advance` → `try_advance_phase` (owner advances itself; illegal + edge → typed `RubiconTransitionError`, no mutation). +- `deliver_kanban_step("kanban..")` — the delivery edge: + `parse_kanban_step` → `ractor::registry::where_is(mailbox)` → `cast(Advance)`; + unknown mailbox → graceful `KanbanRouteError::NoMailbox`, illegal edge → + `Illegal`, malformed → `BadStepType`. Address source = the step string + the + actor system's OWN registry (no bespoke registry, no `UnifiedStep` field). +4 tests green under `--features supervisor`; clippy + fmt clean; light build (no +disk/symbiont gate). **Remaining:** only the S2/S3 *drivers* that SEND +`KanbanMsg::Advance` (the MUL gate and the live version tick) — both compose ON +TOP of this complete mechanism. The operator's model ("every SoA is +ractor-owned, the owner advances itself") is now real, tested code end-to-end. **Census state (original):** GAP. `kanban.*` resolves to `StepDomain::Kanban` then every bridge impl returns `DomainUnavailable` (`orchestration_impl.rs:55-57`, diff --git a/crates/lance-graph-supervisor/src/kanban_actor.rs b/crates/lance-graph-supervisor/src/kanban_actor.rs index ff5cafbf..8ddf756d 100644 --- a/crates/lance-graph-supervisor/src/kanban_actor.rs +++ b/crates/lance-graph-supervisor/src/kanban_actor.rs @@ -96,6 +96,78 @@ where } } +// ─── S4 delivery edge: `kanban..` → where_is → cast(Advance) ─── + +/// Error from delivering a `kanban.*` step to its owning actor. +#[derive(Debug, thiserror::Error)] +pub enum KanbanRouteError { + /// `step_type` was not a well-formed `kanban..`. + #[error("malformed kanban step_type: {0}")] + BadStepType(String), + /// No live actor is registered under ``. A routing MISS, distinct + /// from the (impossible) "no owner" case: a live mailbox is always owned by + /// its actor — this means the *named* mailbox isn't registered/live. + #[error("no live mailbox registered as `{0}`")] + NoMailbox(String), + /// The owner rejected the transition (illegal Rubicon edge; no mutation). + #[error("illegal transition {from:?} -> {to:?}")] + Illegal { + from: KanbanColumn, + to: KanbanColumn, + }, + /// The actor RPC failed (mailbox closed, timeout, …). + #[error("kanban rpc failed: {0}")] + Rpc(String), +} + +/// Parse a `kanban..` step type into `(mailbox, target_phase)`, +/// where `` is the snake_case [`KanbanColumn`] name (e.g. `cognitive_work`). +/// Returns `None` for anything that isn't a well-formed kanban step. +pub fn parse_kanban_step(step_type: &str) -> Option<(&str, KanbanColumn)> { + let mut it = step_type.splitn(3, '.'); + match (it.next(), it.next(), it.next()) { + (Some("kanban"), Some(mailbox), Some(phase)) if !mailbox.is_empty() => { + phase_from_name(phase).map(|p| (mailbox, p)) + } + _ => None, + } +} + +/// Snake_case phase name → [`KanbanColumn`] (the `kanban.*` routing vocabulary; +/// inverse of the canonical column names). +fn phase_from_name(name: &str) -> Option { + Some(match name { + "planning" => KanbanColumn::Planning, + "cognitive_work" => KanbanColumn::CognitiveWork, + "evaluation" => KanbanColumn::Evaluation, + "commit" => KanbanColumn::Commit, + "plan" => KanbanColumn::Plan, + "prune" => KanbanColumn::Prune, + _ => return None, + }) +} + +/// The S4 **delivery edge**: resolve a `kanban..` step to its +/// owning ractor actor via the actor system's OWN name registry +/// ([`ractor::registry::where_is`]) and RPC it [`KanbanMsg::Advance`]. The owner +/// advances ITSELF; this only delivers. No bridge-held owner, no `UnifiedStep` +/// field — the target is recovered from the step string + the registry +/// (mailbox-as-owner addressing). Multi-mailbox resolves because `where_is` +/// looks up any registered mailbox by name. +pub async fn deliver_kanban_step(step_type: &str) -> Result { + let (mailbox, to) = parse_kanban_step(step_type) + .ok_or_else(|| KanbanRouteError::BadStepType(step_type.to_string()))?; + let cell = ractor::registry::where_is(mailbox) + .ok_or_else(|| KanbanRouteError::NoMailbox(mailbox.to_string()))?; + let actor: ActorRef = cell.into(); + let inner = ractor::call!(actor, |reply| KanbanMsg::Advance { to, reply }) + .map_err(|e| KanbanRouteError::Rpc(e.to_string()))?; + inner.map_err(|e| KanbanRouteError::Illegal { + from: e.from, + to: e.to, + }) +} + #[cfg(test)] mod tests { use super::*; @@ -220,4 +292,57 @@ mod tests { actor.stop(None); handle.await.expect("actor join"); } + + #[test] + fn parse_kanban_step_shapes() { + assert_eq!( + parse_kanban_step("kanban.mb42.cognitive_work"), + Some(("mb42", KanbanColumn::CognitiveWork)) + ); + assert_eq!(parse_kanban_step("lg.foo"), None); // wrong domain + assert_eq!(parse_kanban_step("kanban.mb42"), None); // no phase + assert_eq!(parse_kanban_step("kanban..commit"), None); // empty mailbox + assert_eq!(parse_kanban_step("kanban.mb42.bogus"), None); // unknown phase + } + + #[tokio::test] + async fn delivery_edge_resolves_via_registry_then_advances() { + // Register the owning actor under a name — `where_is` is the actor + // system's own registry, the S4 addressing source (no bespoke registry). + let name = "mb-kanban-route-test"; + let (actor, handle) = Actor::spawn( + Some(name.to_string()), + KanbanActor::::default(), + board(KanbanColumn::Planning), + ) + .await + .expect("spawn named"); + + // Legal: kanban..cognitive_work → resolves → owner advances. + let mv = deliver_kanban_step(&format!("kanban.{name}.cognitive_work")) + .await + .expect("delivered + advanced"); + assert_eq!(mv.to, KanbanColumn::CognitiveWork); + + // Unknown mailbox → graceful routing miss (NOT a panic, NOT a no-owner). + assert!(matches!( + deliver_kanban_step("kanban.no-such-mailbox.cognitive_work").await, + Err(KanbanRouteError::NoMailbox(_)) + )); + + // Illegal Rubicon edge → typed Illegal, relayed from the owner. + assert!(matches!( + deliver_kanban_step(&format!("kanban.{name}.commit")).await, + Err(KanbanRouteError::Illegal { .. }) + )); + + // Malformed step type → BadStepType. + assert!(matches!( + deliver_kanban_step("lg.noop").await, + Err(KanbanRouteError::BadStepType(_)) + )); + + actor.stop(None); + handle.await.expect("actor join"); + } }