From 3e48e122af8df885e502c15d6e196a83eb586fe1 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 1 Nov 2026 04:11:38 +0100 Subject: [PATCH] hold: one decider of what a question holds, read once per launch; a dead Self holder no longer stalls the successor; retire is read s5 findings of cut-self-session-live: - blocked-recipe-misses-run-held-spec: eureka_state::hold is the one decider (a question holds the item it is raised in, or every claim of the run it is raised in; an answer of retire keeps holding the claims still in force). queue::on_hold and `huginn blocked` both read it. - hold-read-not-one-snapshot: on_hold returns a Held snapshot that the request carries; claimable, queue and the gate read it, none reads it again. - dead-self-holder-stalls-successor: launch::slot is the one reader of whether a holder lives; claimable asks it, a dead holder is closed by the launch and the beat's trace says so. - retire-answer-reopens: launch::retire_answered resolves the claims of an answered-retire question Withdrawn; the beat runs it before reading briefs. - held-note-threshold-unpinned: a test pins the note for exactly one held brief. Co-Authored-By: Claude Sonnet 5.5 --- crates/eureka-state/src/bin/huginn/main.rs | 95 +++++- crates/eureka-state/src/bin/huginn/recipes.rs | 33 +- crates/eureka-state/src/hold.rs | 173 ++++++++++ crates/eureka-state/src/lib.rs | 2 + crates/eureka-state/tests/cli.rs | 147 ++++++++- crates/mind-body/src/control/tests.rs | 2 +- crates/mind-body/src/heartbeat.rs | 135 ++++++-- crates/mind-body/src/heartbeat/tests.rs | 141 +++++++++ crates/mind-body/src/launch.rs | 263 ++++++++++++++-- crates/mind-body/src/persona.rs | 4 +- crates/mind-body/src/persona/tests.rs | 2 +- crates/mind-body/src/queue.rs | 296 +++++++++++++----- crates/mind-body/src/self_run/inbox.rs | 14 +- crates/mind-body/src/testkit.rs | 9 +- 14 files changed, 1142 insertions(+), 174 deletions(-) create mode 100644 crates/eureka-state/src/hold.rs diff --git a/crates/eureka-state/src/bin/huginn/main.rs b/crates/eureka-state/src/bin/huginn/main.rs index 4a5d159..d144797 100644 --- a/crates/eureka-state/src/bin/huginn/main.rs +++ b/crates/eureka-state/src/bin/huginn/main.rs @@ -22,13 +22,17 @@ mod render; mod schema; mod trouble; +use std::cell::{Cell, RefCell}; use std::io::Write; use std::process::ExitCode; use std::sync::OnceLock; use clap::{CommandFactory, FromArgMatches, Parser, Subcommand}; use cultnet_rs::{PROJECTION_DOCUMENT, Selection}; -use huginn_mind::eureka_pipeline::{Line, PipelineDocument, PipelineRef, Short, Slug, pipeline_key}; +use eureka_state::hold; +use huginn_mind::eureka_pipeline::{ + Line, PipelineDocument, PipelineKind, PipelineRef, Short, Slug, pipeline_key, +}; use huginn_mind::{ BATCH_MAX, Faculty, HuginnMindRequest, HuginnMindResponse, MindRefusal, PipelineAdmissionBatch, PipelineAdmissionOutcome, PipelinePageItems, PipelineProvenance, PipelineSelectionPage, SemanticQuery, SourceQuery, @@ -182,7 +186,15 @@ fn run(settings: &Settings, command: Command) -> Result { let semantic = near.map(|text| SemanticQuery { text: Line(text), top_k: top_k.unwrap_or(DEFAULT_TOP_K) }); query(settings, selection, semantic, None, documents) } - Command::Sources { text, repo, top_k, json } => sources(settings, text, repo, top_k, json), + Command::Sources { + text, + repo, + top_k, + json, + } => sources(settings, text, repo, top_k, json), + Command::Recipe(Recipe::Blocked { spec_id, page }) => { + blocked(settings, &spec_id, page.cursor, page.documents) + } Command::Recipe(recipe) => { let ask = recipe.ask()?; query(settings, door::selection_value(&ask.selection)?, ask.semantic, ask.page.cursor, ask.page.documents) @@ -264,9 +276,82 @@ fn query( } } -fn sources(settings: &Settings, text: String, repo: Option, top_k: Option, json: bool) -> Result { - let query = SourceQuery { text: Line(text), top_k: top_k.unwrap_or(DEFAULT_TOP_K), repo: repo.map(Short) }; - match settings.ask(|instance| HuginnMindRequest::SearchSources { instance: instance.clone(), query })? { +/// The open questions that hold a spec, and the answered-`retire` ones whose +/// claims are not yet resolved: `eureka_state::hold`'s answer, the one the +/// heartbeat's gate reads, printed as a selection of those questions. +fn blocked( + settings: &Settings, + spec_id: &str, + cursor: Option, + documents: bool, +) -> Result { + let spec = PipelineRef { + kind: door::kind_of(spec_id)?, + id: Short(spec_id.to_string()), + }; + let failure: RefCell> = RefCell::new(None); + let as_of = Cell::new(0); + let ask = |selection: &Selection| -> anyhow::Result { + let selection = selection.clone(); + match settings.ask(|instance| HuginnMindRequest::Query { + instance: instance.clone(), + selection, + semantic: None, + }) { + Ok(HuginnMindResponse::Query(page)) => { + as_of.set(page.as_of); + Ok(page) + } + Ok(_) => Err(anyhow::anyhow!( + "the mind answered a query with something else" + )), + Err(trouble) => { + *failure.borrow_mut() = Some(trouble); + Err(anyhow::anyhow!("the mind could not be asked")) + } + } + }; + let holding = match hold::holds(&ask) { + Ok(holds) => holds, + Err(error) => { + return Err(failure + .take() + .unwrap_or_else(|| Trouble::Internal(error.to_string()))); + } + }; + let keys: Vec = holding + .iter() + .filter(|held| held.items.contains(&spec)) + .map(|held| held.question.id.id.0.clone()) + .collect(); + if keys.is_empty() { + say(format_args!("matched 0 as_of {} next -", as_of.get())); + return Ok(0); + } + let selection = Selection { + schemas: Some(vec![PipelineKind::Question.type_id().to_string()]), + keys: Some(keys), + ..Selection::default() + }; + query(settings, selection, None, cursor, documents) +} + +fn sources( + settings: &Settings, + text: String, + repo: Option, + top_k: Option, + json: bool, +) -> Result { + let query = SourceQuery { + text: Line(text), + top_k: top_k.unwrap_or(DEFAULT_TOP_K), + repo: repo.map(Short), + }; + match settings.ask(|instance| HuginnMindRequest::SearchSources { + instance: instance.clone(), + query, + })? { HuginnMindResponse::Sources(found) => { if json { say(serde_json::to_string_pretty(&found).map_err(|error| Trouble::Internal(error.to_string()))?); diff --git a/crates/eureka-state/src/bin/huginn/recipes.rs b/crates/eureka-state/src/bin/huginn/recipes.rs index 78bf092..8bcc46e 100644 --- a/crates/eureka-state/src/bin/huginn/recipes.rs +++ b/crates/eureka-state/src/bin/huginn/recipes.rs @@ -1,6 +1,8 @@ //! The campaign progress view as named subcommands. Each recipe is exactly the //! selection `references/campaign-state.md` gives (Eureka 278b881, "Recipes"), //! built per call and never cached. Each one's `--help` prints its selection. +//! The one exception is `blocked`, which asks what a question holds and so +//! reads through `eureka_state::hold`, the decider the heartbeat's gate reads. use clap::{Args, Subcommand}; use huginn_mind::SemanticQuery; @@ -66,7 +68,7 @@ pub enum Recipe { #[command(flatten)] page: Paging, }, - /// Open questions raised in a spec: the spec waits on the operator. + /// Questions that hold a spec, answered-retire ones included: the spec waits on the operator. Blocked { spec_id: String, #[command(flatten)] @@ -125,7 +127,12 @@ impl Recipe { Self::Open { campaigns, page } => (open(&campaigns), None, page), Self::Findings { campaign, blocking, page } => (findings(&campaign, blocking), None, page), Self::Unreported { campaign, page } => (unreported(&campaign), None, page), - Self::Blocked { spec_id, page } => (blocked(&spec_id), None, page), + // Several selections, not one: `main` answers it through `eureka_state::hold`. + Self::Blocked { .. } => { + return Err(Trouble::Internal( + "blocked is answered by the hold, not by one selection".into(), + )); + } Self::Unjudged { campaign, page } => (unjudged(&campaign), None, page), Self::History { id, page } => (history(door::kind_of(&id)?.type_id(), &id), None, page), Self::Cut { campaign, label, page } => (cut(&campaign, &label), None, page), @@ -198,14 +205,6 @@ fn unreported(campaign: &str) -> Value { }) } -fn blocked(spec_id: &str) -> Value { - json!({ - "schemas": [schema(PipelineKind::Question)], - "fields": [in_force()], - "cites": { "target": { "schemaId": schema(PipelineKind::CutSpec), "recordKey": spec_id }, "role": "raised_in" }, - }) -} - fn unjudged(campaign: &str) -> Value { json!({ "schemas": [schema(PipelineKind::CutReport)], @@ -257,6 +256,18 @@ fn gaps() -> Value { }) } +/// `blocked` is not one selection: it reads the in-force questions and, for each, +/// what it holds, by `eureka_state::hold`, the decider the heartbeat's gate reads. +fn blocked_help() -> String { + let questions = json!({ "schemas": [schema(PipelineKind::Question)], "fields": [in_force()] }); + format!( + "selection (first of several): +{} +A question holds the item it is raised in, or every claim of the run it is raised in; one answered `retire` keeps holding the claims still in force. The recipe prints the questions that hold .", + serde_json::to_string_pretty(&questions).unwrap_or_default() + ) +} + /// Each recipe's `--help` addendum: its selection with placeholders in the /// arguments' places. The same builders as execution, so help cannot drift /// from what runs. @@ -276,7 +287,7 @@ pub fn help() -> Vec<(&'static str, String)> { ("open", selection(open(&[c()]))), ("findings", findings_help), ("unreported", selection(unreported(&c()))), - ("blocked", selection(blocked(""))), + ("blocked", blocked_help()), ("unjudged", selection(unjudged(&c()))), ("history", selection(history("epiphany.pipeline..v2", ""))), ("cut", selection(cut(&c(), "