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 <noreply@anthropic.com>
This commit is contained in:
parent
9faeb94035
commit
3e48e122af
14 changed files with 1139 additions and 171 deletions
|
|
@ -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<u8, Trouble> {
|
|||
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<String>, top_k: Option<u32>, json: bool) -> Result<u8, Trouble> {
|
||||
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<String>,
|
||||
documents: bool,
|
||||
) -> Result<u8, Trouble> {
|
||||
let spec = PipelineRef {
|
||||
kind: door::kind_of(spec_id)?,
|
||||
id: Short(spec_id.to_string()),
|
||||
};
|
||||
let failure: RefCell<Option<Trouble>> = RefCell::new(None);
|
||||
let as_of = Cell::new(0);
|
||||
let ask = |selection: &Selection| -> anyhow::Result<PipelineSelectionPage> {
|
||||
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<String> = 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<String>,
|
||||
top_k: Option<u32>,
|
||||
json: bool,
|
||||
) -> Result<u8, Trouble> {
|
||||
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()))?);
|
||||
|
|
|
|||
|
|
@ -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 <id>.",
|
||||
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("<id>"))),
|
||||
("blocked", blocked_help()),
|
||||
("unjudged", selection(unjudged(&c()))),
|
||||
("history", selection(history("epiphany.pipeline.<kind>.v2", "<id>"))),
|
||||
("cut", selection(cut(&c(), "<label>"))),
|
||||
|
|
|
|||
173
crates/eureka-state/src/hold.rs
Normal file
173
crates/eureka-state/src/hold.rs
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
//! What an open question holds: the one decider, read by the heartbeat's gate
|
||||
//! and by `huginn blocked`, so the two cannot disagree.
|
||||
//!
|
||||
//! A question raised in a run holds every claim of that run, the set that
|
||||
//! failed together; a question raised in anything else holds that item. A
|
||||
//! question answered with the option `retire` keeps holding the claims of the
|
||||
//! run it was raised in for as long as they are in force: the answer has said
|
||||
//! they are not to be worked, and whoever resolves them is the one writer of
|
||||
//! that. Answering or withdrawing a question with any other choice lifts its
|
||||
//! hold. The question's text is display only.
|
||||
//!
|
||||
//! Nothing here keeps state. Every answer is read through `ask`, one typed
|
||||
//! selection at a time, so a caller with a daemon and a caller with a
|
||||
//! temporary mind read the same way.
|
||||
|
||||
use anyhow::{Result, bail};
|
||||
use cultnet_rs::{Citation, FieldPredicate, Incoming, RecordRef, Selection};
|
||||
use huginn_mind::eureka_pipeline::{PipelineKind, PipelineRef};
|
||||
use huginn_mind::{
|
||||
PipelineDocumentSummary, PipelineFacts, PipelinePageItems, PipelineSelectionPage,
|
||||
PipelineStatusSummary,
|
||||
};
|
||||
|
||||
/// The option label of a breaker question whose answer retires the claims it holds.
|
||||
pub const RETIRE: &str = "retire";
|
||||
|
||||
/// One page of a selection, from whoever reads the mind.
|
||||
pub type Ask<'a> = dyn Fn(&Selection) -> Result<PipelineSelectionPage> + 'a;
|
||||
|
||||
/// A question and the items it holds.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Hold {
|
||||
pub question: PipelineDocumentSummary,
|
||||
pub items: Vec<PipelineRef>,
|
||||
/// The question is answered `retire`: the items are held until they are resolved.
|
||||
pub retired: bool,
|
||||
}
|
||||
|
||||
/// The documents that cite `target`, under `role` or any.
|
||||
pub fn citing(target: &PipelineRef, role: Option<&str>) -> Citation {
|
||||
Citation {
|
||||
target: RecordRef::new(target.kind.type_id(), target.id.0.clone()),
|
||||
role: role.map(Into::into),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn of_kind(kind: PipelineKind) -> Selection {
|
||||
Selection {
|
||||
schemas: Some(vec![kind.type_id().to_string()]),
|
||||
..Selection::default()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn any_of(mut selection: Selection, index: &str, values: &[&str]) -> Selection {
|
||||
selection
|
||||
.fields
|
||||
.get_or_insert_with(Vec::new)
|
||||
.push(FieldPredicate {
|
||||
index: index.into(),
|
||||
op: "any_of".into(),
|
||||
values: Some(values.iter().map(|value| value.to_string()).collect()),
|
||||
number: None,
|
||||
});
|
||||
selection
|
||||
}
|
||||
|
||||
pub fn in_force(selection: Selection) -> Selection {
|
||||
any_of(selection, "in_force", &["true"])
|
||||
}
|
||||
|
||||
/// Every page of a selection, in the mind's order. The one paging loop.
|
||||
pub fn headers(ask: &Ask, selection: &Selection) -> Result<Vec<PipelineDocumentSummary>> {
|
||||
let mut all = Vec::new();
|
||||
let mut asking = selection.clone();
|
||||
loop {
|
||||
let page = ask(&asking)?;
|
||||
let PipelinePageItems::Headers(items) = page.items else {
|
||||
bail!("the mind answered documents to a header selection")
|
||||
};
|
||||
all.extend(items);
|
||||
match page.next {
|
||||
Some(cursor) => asking.cursor = Some(cursor),
|
||||
None => return Ok(all),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The claims of the run `run`, resolved or not (a citation outlives its
|
||||
/// resolution). Empty when the mind has no such run.
|
||||
pub fn claims_of(ask: &Ask, run: &PipelineRef) -> Result<Vec<PipelineRef>> {
|
||||
let one = Selection {
|
||||
keys: Some(vec![run.id.0.clone()]),
|
||||
..of_kind(PipelineKind::Run)
|
||||
};
|
||||
Ok(headers(ask, &one)?
|
||||
.into_iter()
|
||||
.flat_map(|header| match header.facts {
|
||||
PipelineFacts::Run { claims, .. } => claims,
|
||||
_ => Vec::new(),
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Every question holding anything: the open ones, each with what it holds,
|
||||
/// then the answered-`retire` ones whose claims are still in force.
|
||||
pub fn holds(ask: &Ask) -> Result<Vec<Hold>> {
|
||||
let mut found = Vec::new();
|
||||
for question in headers(ask, &in_force(of_kind(PipelineKind::Question)))? {
|
||||
if let PipelineFacts::Question {
|
||||
raised_in: Some(at),
|
||||
..
|
||||
} = &question.facts
|
||||
{
|
||||
let items = if at.kind == PipelineKind::Run {
|
||||
claims_of(ask, at)?
|
||||
} else {
|
||||
vec![at.clone()]
|
||||
};
|
||||
found.push(Hold {
|
||||
question,
|
||||
items,
|
||||
retired: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
let asked_in = Selection {
|
||||
cited: Some(Incoming {
|
||||
role: "raised_in".into(),
|
||||
exists: true,
|
||||
}),
|
||||
..of_kind(PipelineKind::Run)
|
||||
};
|
||||
for run in headers(ask, &asked_in)? {
|
||||
let questions = Selection {
|
||||
cites: Some(citing(&run.id, Some("raised_in"))),
|
||||
..of_kind(PipelineKind::Question)
|
||||
};
|
||||
for question in headers(ask, &questions)? {
|
||||
if matches!(question.status, PipelineStatusSummary::InForce)
|
||||
|| !answered_retire(ask, &question.id)?
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let mut items = Vec::new();
|
||||
for claim in claims_of(ask, &run.id)? {
|
||||
let live = Selection {
|
||||
keys: Some(vec![claim.id.0.clone()]),
|
||||
..in_force(of_kind(claim.kind))
|
||||
};
|
||||
if !headers(ask, &live)?.is_empty() {
|
||||
items.push(claim);
|
||||
}
|
||||
}
|
||||
if !items.is_empty() {
|
||||
found.push(Hold {
|
||||
question,
|
||||
items,
|
||||
retired: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(found)
|
||||
}
|
||||
|
||||
/// Whether a ruling answers `question` with the option `retire`.
|
||||
fn answered_retire(ask: &Ask, question: &PipelineRef) -> Result<bool> {
|
||||
let answers = Selection {
|
||||
cites: Some(citing(question, Some("answers"))),
|
||||
..of_kind(PipelineKind::Ruling)
|
||||
};
|
||||
Ok(headers(ask, &answers)?.iter().any(|ruling| matches!(&ruling.facts, PipelineFacts::Ruling { choice: Some(choice), .. } if choice.0 == RETIRE)))
|
||||
}
|
||||
|
|
@ -30,6 +30,8 @@ use huginn_mind::eureka_pipeline::Slug;
|
|||
use huginn_mind::{HuginnMindRequest, HuginnMindResponse, MAX_DEFERRED_BODY_BYTES, PipelineProvenance};
|
||||
use serde_json::Value;
|
||||
|
||||
pub mod hold;
|
||||
|
||||
/// The correlation key of the one request a session carries.
|
||||
const MESSAGE_ID: &str = "eureka-state-call";
|
||||
const POLL: Duration = Duration::from_millis(2);
|
||||
|
|
|
|||
|
|
@ -11,9 +11,11 @@ use std::sync::{Arc, Mutex};
|
|||
use cultnet_rs::Selection;
|
||||
use huginn_mind::envelope::{OperationFailure, decode_request, encode_failure, encode_response};
|
||||
use huginn_mind::eureka_pipeline::{
|
||||
CodeLocation, CommitRange, CutVerification, Date, Evidence, EvidenceKind, MutationRecord, OrgRepo, PipelineCutReport,
|
||||
PipelineCutSpec, PipelineDocument, PipelineKind, PipelineRef, PipelineRuling, Promise, ReportCommit, RulingAuthority, Short,
|
||||
StructuralDelta, pipeline_key,
|
||||
CodeLocation, CommitRange, CutVerification, Date, Evidence, EvidenceKind, MutationRecord,
|
||||
OrgRepo, PipelineCutReport, PipelineCutSpec, PipelineDocument, PipelineKind, PipelineQuestion,
|
||||
PipelineRef, PipelineResolution, PipelineRuling, PipelineRun, Promise, QuestionOption,
|
||||
ReportCommit, ResolutionOutcome, RulingAuthority, RunOperator, RunTurn, Short, StructuralDelta,
|
||||
pipeline_key,
|
||||
};
|
||||
use huginn_mind::{
|
||||
AdmissionFacts, HuginnMindRequest, HuginnMindResponse, IndexStatus, MindRefusal, MindStatus, PersonaStatus, PipelineAdmissionOutcome, PipelineDocumentView,
|
||||
|
|
@ -747,17 +749,6 @@ fn the_recipes_send_exactly_the_pages_selections() {
|
|||
"cited": { "role": "cut_spec", "exists": false },
|
||||
}),
|
||||
),
|
||||
(
|
||||
vec!["blocked", "eureka-substrate:cut_spec:cut-x.r1"],
|
||||
json!({
|
||||
"schemas": [schema("question")],
|
||||
"fields": [in_force],
|
||||
"cites": {
|
||||
"target": { "schemaId": schema("cut_spec"), "recordKey": "eureka-substrate:cut_spec:cut-x.r1" },
|
||||
"role": "raised_in",
|
||||
},
|
||||
}),
|
||||
),
|
||||
(
|
||||
vec!["unjudged", c],
|
||||
json!({
|
||||
|
|
@ -802,6 +793,134 @@ fn the_recipes_send_exactly_the_pages_selections() {
|
|||
}
|
||||
}
|
||||
|
||||
/// `huginn blocked` answers with the hold the heartbeat's gate reads: a question
|
||||
/// raised in a run holds every claim of that run, and an answer of `retire`
|
||||
/// keeps holding the claims until they are resolved.
|
||||
#[test]
|
||||
fn blocked_names_the_questions_that_hold_a_spec_as_the_gate_reads_them() {
|
||||
let server = live();
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let admit = |name: &str, documents: Value| {
|
||||
let file = write(&dir, name, &documents);
|
||||
let ran = at(server.addr, &admit_args(&file), None);
|
||||
assert_eq!(ran.code, 0, "{name}: {}", ran.err);
|
||||
};
|
||||
let value = |document: PipelineDocument| serde_json::to_value(document).unwrap();
|
||||
let spec_id = |cut: &str| format!("{CAMPAIGN}:cut_spec:cut-{cut}.r1");
|
||||
let held = |cut: &str| at(server.addr, &["blocked", &spec_id(cut)], None);
|
||||
admit("campaign.json", json!([campaign_json()]));
|
||||
admit("specs.json", json!([spec("A"), spec("B")]));
|
||||
let claim = PipelineRef {
|
||||
kind: PipelineKind::CutSpec,
|
||||
id: Short(spec_id("A")),
|
||||
};
|
||||
admit(
|
||||
"run.json",
|
||||
json!([value(PipelineDocument::Run(PipelineRun {
|
||||
instance: slug(INSTANCE),
|
||||
label: "failed".into(),
|
||||
operated_by: RunOperator::Mind,
|
||||
turn: RunTurn::SelfRun,
|
||||
host: "test".into(),
|
||||
started_on: Date("2026-10-10".into()),
|
||||
budget_usd: "0".into(),
|
||||
claims: vec![claim.clone()],
|
||||
campaigns: vec![slug(CAMPAIGN)],
|
||||
}))]),
|
||||
);
|
||||
let run = PipelineRef {
|
||||
kind: PipelineKind::Run,
|
||||
id: Short(format!("{INSTANCE}:run:failed")),
|
||||
};
|
||||
let resolution = |subject: &PipelineRef, outcome: ResolutionOutcome| {
|
||||
value(PipelineDocument::Resolution(PipelineResolution {
|
||||
subject: subject.clone(),
|
||||
sequence: 1,
|
||||
outcome,
|
||||
rationale: "So.".into(),
|
||||
resolved_on: Date("2026-10-10".into()),
|
||||
}))
|
||||
};
|
||||
admit(
|
||||
"ended.json",
|
||||
json!([resolution(
|
||||
&run,
|
||||
ResolutionOutcome::Recorded {
|
||||
reason: "unit not active".into()
|
||||
}
|
||||
)]),
|
||||
);
|
||||
assert!(
|
||||
held("A").out.starts_with("matched 0 "),
|
||||
"no question yet: {}",
|
||||
held("A").out
|
||||
);
|
||||
|
||||
let question = format!("{CAMPAIGN}:question:fork");
|
||||
admit(
|
||||
"question.json",
|
||||
json!([value(PipelineDocument::Question(PipelineQuestion {
|
||||
campaign: slug(CAMPAIGN),
|
||||
label: "fork".into(),
|
||||
title: "A fork".into(),
|
||||
question: "Which?".into(),
|
||||
options: ["reopen", "retire"]
|
||||
.iter()
|
||||
.map(|name| QuestionOption {
|
||||
label: (*name).into(),
|
||||
text: "an option".into()
|
||||
})
|
||||
.collect(),
|
||||
recommended: "reopen".into(),
|
||||
depends: vec![],
|
||||
raised_in: Some(run),
|
||||
asked_on: Date("2026-10-10".into()),
|
||||
}))]),
|
||||
);
|
||||
let shown = held("A");
|
||||
assert_eq!(shown.code, 0, "{}", shown.err);
|
||||
assert!(
|
||||
shown.out.starts_with(&format!("{question} InForce ")) && shown.out.contains("matched 1 "),
|
||||
"a question raised in the run holds its claim: {}",
|
||||
shown.out
|
||||
);
|
||||
assert!(
|
||||
held("B").out.starts_with("matched 0 "),
|
||||
"and not a spec the run never claimed: {}",
|
||||
held("B").out
|
||||
);
|
||||
|
||||
let PipelineDocument::Ruling(mut answer) = ruling(None) else {
|
||||
unreachable!()
|
||||
};
|
||||
(answer.answers, answer.choice) = (Some(Short(question.clone())), Some("retire".into()));
|
||||
admit(
|
||||
"answer.json",
|
||||
json!([value(PipelineDocument::Ruling(answer))]),
|
||||
);
|
||||
let shown = held("A");
|
||||
assert!(
|
||||
shown.out.starts_with(&format!("{question} Resolved")) && shown.out.contains("matched 1 "),
|
||||
"retired and not yet resolved, it still holds: {}",
|
||||
shown.out
|
||||
);
|
||||
|
||||
admit(
|
||||
"withdrawn.json",
|
||||
json!([resolution(
|
||||
&claim,
|
||||
ResolutionOutcome::Withdrawn {
|
||||
reason: "retired".into()
|
||||
}
|
||||
)]),
|
||||
);
|
||||
assert!(
|
||||
held("A").out.starts_with("matched 0 "),
|
||||
"the claim resolved, nothing holds it: {}",
|
||||
held("A").out
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paging_and_query_flags_shape_the_selection() {
|
||||
let (selection, semantic) = sent(&["gaps", "--cursor", "abc", "--documents"]);
|
||||
|
|
|
|||
|
|
@ -131,7 +131,7 @@ mod grant {
|
|||
fn launch_as(mind_instance: &str, grant: Grant, turn: RunTurn) -> Result<LaunchOutcome> {
|
||||
let mind = SilentMind(slug(mind_instance));
|
||||
let ports = Ports { mind: &mind, launcher: &NoUnits, clock: &Now, host: "host" };
|
||||
open_and_launch(&ports, LaunchRequest { turn, claims: vec![], agent: "agent".into(), grant })
|
||||
open_and_launch(&ports, LaunchRequest { turn, claims: vec![], agent: "agent".into(), grant, held: crate::queue::Held::default() })
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -41,8 +41,8 @@ use huginn_mind::{PipelineDocumentSummary, PipelineFacts, PipelinePageItems};
|
|||
|
||||
use crate::chat_client::{Chat, ChatDelivery};
|
||||
use crate::control::{ControlSource, Effective, FileSource, HeldReason, SwarmEffective, read_effective, read_envelope, set_mode_world_readable};
|
||||
use crate::launch::{Clock, LaunchOutcome, LaunchRequest, Launcher, Ports, holders, open_and_launch};
|
||||
use crate::queue::{MindPort, any_of, citing, claims_of, headers, of_kind};
|
||||
use crate::launch::{Clock, LaunchOutcome, LaunchRequest, Launcher, Ports, holders, open_and_launch, retire_answered, slot};
|
||||
use crate::queue::{Held, MindPort, any_of, citing, claims_of, headers, of_kind, on_hold};
|
||||
use crate::self_run::inbox;
|
||||
use crate::tempo::{self, Standing};
|
||||
|
||||
|
|
@ -557,13 +557,36 @@ enum Launched {
|
|||
Refused(String),
|
||||
}
|
||||
|
||||
fn ports<'a>(organs: &Organs<'a>, mind: &'a dyn MindPort) -> Ports<'a> {
|
||||
Ports {
|
||||
mind,
|
||||
launcher: organs.launcher,
|
||||
clock: organs.clock,
|
||||
host: organs.host,
|
||||
}
|
||||
}
|
||||
|
||||
/// The one way the heartbeat starts a run: the same brake read and the same
|
||||
/// opener for a Persona turn (a beat, an operator mention, a Self run that ended)
|
||||
/// and for the Self run a beat gives to the briefs her Self holds.
|
||||
fn launch(organs: &Organs, row: &Row, turn: RunTurn, claims: Vec<PipelineRef>) -> Result<Launched> {
|
||||
let Effective::Released(grant) = organs.control.effective(row.slug()) else { return Ok(Launched::Refused("the brake is held".into())) };
|
||||
let ports = Ports { mind: row.seat.mind, launcher: organs.launcher, clock: organs.clock, host: organs.host };
|
||||
let request = LaunchRequest { turn, claims, agent: AGENT.into(), grant };
|
||||
fn launch(
|
||||
organs: &Organs,
|
||||
row: &Row,
|
||||
turn: RunTurn,
|
||||
claims: Vec<PipelineRef>,
|
||||
held: Held,
|
||||
) -> Result<Launched> {
|
||||
let Effective::Released(grant) = organs.control.effective(row.slug()) else {
|
||||
return Ok(Launched::Refused("the brake is held".into()));
|
||||
};
|
||||
let request = LaunchRequest {
|
||||
turn,
|
||||
claims,
|
||||
agent: AGENT.into(),
|
||||
grant,
|
||||
held,
|
||||
};
|
||||
let ports = ports(organs, row.seat.mind);
|
||||
Ok(match open_and_launch(&ports, request) {
|
||||
Ok(LaunchOutcome::Launched { run }) => Launched::Run(label_of(&run)),
|
||||
Ok(LaunchOutcome::Busy { .. }) => Launched::Busy,
|
||||
|
|
@ -579,25 +602,56 @@ fn launch(organs: &Organs, row: &Row, turn: RunTurn, claims: Vec<PipelineRef>) -
|
|||
/// The most briefs one Self run claims.
|
||||
const BRIEFS_PER_SELF_RUN: usize = 64;
|
||||
|
||||
/// What a Self run would claim for this Mind, read at one instant.
|
||||
struct Claimable {
|
||||
/// The briefs in force that no live Self run holds and no open question holds.
|
||||
claims: Vec<PipelineRef>,
|
||||
/// How many briefs an open question holds.
|
||||
held: usize,
|
||||
/// The hold they were chosen by, which the launch consults and reads no second time.
|
||||
hold: Held,
|
||||
/// Self runs in force whose unit is gone: the launch closes them.
|
||||
dead: Vec<PipelineRef>,
|
||||
}
|
||||
|
||||
/// The briefs a Self run would claim for this Mind: those in force that no live
|
||||
/// Self run holds and no open question holds, and how many an open question
|
||||
/// holds. A Self run claims up to `BRIEFS_PER_SELF_RUN` briefs and every run it
|
||||
/// ends empty counts against all of them, so one cause trips them together; a
|
||||
/// trip holds the claims of its run, not the Mind, until the operator answers
|
||||
/// or withdraws its question, and the count starts afresh. `inbox::derive`
|
||||
/// reads `queue::on_hold`; nothing here decides what is held.
|
||||
fn claimable(row: &Row) -> Result<(Vec<PipelineRef>, usize)> {
|
||||
/// Self run holds and no open question holds. A Self run claims up to
|
||||
/// `BRIEFS_PER_SELF_RUN` briefs and every run it ends empty counts against all
|
||||
/// of them, so one cause trips them together; a trip holds the claims of its
|
||||
/// run, not the Mind, until the operator answers or withdraws its question, and
|
||||
/// the count starts afresh. Whether a holder lives is `launch::slot`'s answer
|
||||
/// (a dead one is closed by the launch, so it does not stop the successor), and
|
||||
/// what is held is `queue::on_hold`'s, read once here and carried into the
|
||||
/// launch; nothing here decides either.
|
||||
fn claimable(organs: &Organs, row: &Row) -> Result<Claimable> {
|
||||
let mind = row.seat.mind;
|
||||
if !holders(mind, row.slug(), RunTurn::SelfRun)?.is_empty() {
|
||||
return Ok((Vec::new(), 0));
|
||||
let hold = on_hold(mind)?;
|
||||
let found = slot(
|
||||
&ports(organs, mind),
|
||||
row.slug(),
|
||||
RunTurn::SelfRun,
|
||||
organs.clock.now(),
|
||||
)?;
|
||||
if found.live.is_some() {
|
||||
return Ok(Claimable {
|
||||
claims: Vec::new(),
|
||||
held: 0,
|
||||
hold,
|
||||
dead: Vec::new(),
|
||||
});
|
||||
}
|
||||
let (free, held) = inbox::derive_counting_held(mind, &HashSet::new())?;
|
||||
let (free, held) = inbox::derive_counting_held(mind, &hold, &HashSet::new())?;
|
||||
let claims = free
|
||||
.into_iter()
|
||||
.map(|brief| PipelineRef { kind: PipelineKind::FollowUp, id: brief.id.as_str().into() })
|
||||
.take(BRIEFS_PER_SELF_RUN)
|
||||
.collect();
|
||||
Ok((claims, held))
|
||||
Ok(Claimable {
|
||||
claims,
|
||||
held,
|
||||
hold,
|
||||
dead: found.dead,
|
||||
})
|
||||
}
|
||||
|
||||
/// One poll.
|
||||
|
|
@ -664,7 +718,7 @@ pub fn poll(organs: &Organs, config: &Config) -> Result<Outcome> {
|
|||
if mentioned.is_none() && !self_ended {
|
||||
continue;
|
||||
}
|
||||
match launch(organs, row, RunTurn::PersonaTurn, vec![])? {
|
||||
match launch(organs, row, RunTurn::PersonaTurn, vec![], Held::default())? {
|
||||
Launched::Run(label) => {
|
||||
if let Some(key) = mentioned {
|
||||
state.handled_mentions.push(key);
|
||||
|
|
@ -703,15 +757,44 @@ pub fn poll(organs: &Organs, config: &Config) -> Result<Outcome> {
|
|||
let mut passed: Vec<Standing> = standings.clone();
|
||||
while due && let Some(winner) = tempo::pick(&passed, beat_s) {
|
||||
// Her Self holds briefs: the beat is a Self run for them; otherwise a Persona turn.
|
||||
let (claims, held) = claimable(&rows[winner]).unwrap_or_else(|error| {
|
||||
rows[winner].note = format!("briefs: {error}");
|
||||
(Vec::new(), 0)
|
||||
});
|
||||
if held > 0 {
|
||||
rows[winner].note = format!("{held} briefs held by open questions");
|
||||
// An answer of retire is written before the briefs are read, so a retired brief is resolved, not claimed.
|
||||
if let Err(error) = retire_answered(rows[winner].seat.mind, AGENT, now) {
|
||||
rows[winner].note = format!("retire: {error}");
|
||||
}
|
||||
let turn = if claims.is_empty() { RunTurn::PersonaTurn } else { RunTurn::SelfRun };
|
||||
match launch(organs, &rows[winner], turn, claims)? {
|
||||
let Claimable {
|
||||
claims,
|
||||
held,
|
||||
hold,
|
||||
dead,
|
||||
} = claimable(organs, &rows[winner]).unwrap_or_else(|error| {
|
||||
rows[winner].note = format!("briefs: {error}");
|
||||
Claimable {
|
||||
claims: Vec::new(),
|
||||
held: 0,
|
||||
hold: Held::default(),
|
||||
dead: Vec::new(),
|
||||
}
|
||||
});
|
||||
let mut notes: Vec<String> = Vec::new();
|
||||
if held > 0 {
|
||||
notes.push(format!("{held} briefs held by open questions"));
|
||||
}
|
||||
if !claims.is_empty() && !dead.is_empty() {
|
||||
let ids: Vec<&str> = dead.iter().map(|run| run.id.0.as_str()).collect();
|
||||
notes.push(format!(
|
||||
"closing the Self run whose unit is gone: {}",
|
||||
ids.join(", ")
|
||||
));
|
||||
}
|
||||
if !notes.is_empty() {
|
||||
rows[winner].note = notes.join("; ");
|
||||
}
|
||||
let turn = if claims.is_empty() {
|
||||
RunTurn::PersonaTurn
|
||||
} else {
|
||||
RunTurn::SelfRun
|
||||
};
|
||||
match launch(organs, &rows[winner], turn, claims, hold)? {
|
||||
Launched::Run(label) => {
|
||||
state.last_beat_at = now;
|
||||
rows[winner].busy = turn == RunTurn::PersonaTurn;
|
||||
|
|
|
|||
|
|
@ -1100,3 +1100,144 @@ fn a_beat_with_no_held_brief_writes_no_held_note() {
|
|||
let notes: Vec<String> = rig.store.traces.borrow().iter().flat_map(|trace| trace.instances.iter().map(|instance| instance.note.clone())).collect();
|
||||
assert!(notes.iter().all(|note| !note.contains("held")), "{notes:?}");
|
||||
}
|
||||
|
||||
/// Her Self run in force whose unit is gone, claiming `claims`, admitted `ago` seconds before now.
|
||||
fn crashed_self_run(rig: &Rig, claims: &[PipelineRef], ago: i64) {
|
||||
rig.minds[ANN].admit_at(rig.now() - Duration::seconds(ago));
|
||||
rig.minds[ANN].committed(vec![run_in(
|
||||
"ann",
|
||||
"crashed",
|
||||
RunTurn::SelfRun,
|
||||
RunOperator::Mind,
|
||||
claims,
|
||||
)]);
|
||||
}
|
||||
|
||||
/// Polls on, a beat apart, leaving every run to its unit: nothing here ends one.
|
||||
fn polls(rig: &Rig, count: usize) {
|
||||
for _ in 0..count {
|
||||
rig.advance(1000);
|
||||
rig.poll();
|
||||
}
|
||||
}
|
||||
|
||||
fn notes_of(rig: &Rig) -> Vec<String> {
|
||||
rig.store
|
||||
.traces
|
||||
.borrow()
|
||||
.iter()
|
||||
.flat_map(|trace| trace.instances.iter().map(|instance| instance.note.clone()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_self_run_left_in_force_by_a_crash_is_closed_by_the_next_beat_and_the_successor_launches() {
|
||||
let (rig, source) = rig_with_a_persona_turn();
|
||||
brief_by(&rig, ANN, crate::persona::AGENT, "orphan", &source);
|
||||
crashed_self_run(&rig, &[brief_ref("orphan")], 3600);
|
||||
assert_eq!(self_runs(&rig, ANN).len(), 1);
|
||||
polls(&rig, 8);
|
||||
let runs = self_runs(&rig, ANN);
|
||||
assert_eq!(runs.len(), 2, "one successor, once");
|
||||
assert!(
|
||||
matches!(
|
||||
rig.minds[ANN]
|
||||
.view(K::Run, "ann:run:crashed")
|
||||
.unwrap()
|
||||
.status,
|
||||
huginn_mind::PipelineStatus::Resolved { .. }
|
||||
),
|
||||
"the dead holder was closed by the launch"
|
||||
);
|
||||
assert_eq!(
|
||||
claims_of_run(&runs[1]),
|
||||
vec![brief_ref("orphan").id.0],
|
||||
"claiming the brief the dead run left"
|
||||
);
|
||||
assert!(
|
||||
notes_of(&rig)
|
||||
.contains(&"closing the Self run whose unit is gone: ann:run:crashed".to_string()),
|
||||
"{:?}",
|
||||
notes_of(&rig)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_self_run_whose_unit_may_still_be_starting_or_is_running_gets_no_successor() {
|
||||
let (rig, source) = rig_with_a_persona_turn();
|
||||
brief_by(&rig, ANN, crate::persona::AGENT, "orphan", &source);
|
||||
crashed_self_run(&rig, &[brief_ref("orphan")], 30);
|
||||
rig.advance(10);
|
||||
rig.poll();
|
||||
assert_eq!(
|
||||
self_runs(&rig, ANN).len(),
|
||||
1,
|
||||
"inside the start grace the unit may still be starting"
|
||||
);
|
||||
rig.launcher
|
||||
.revive_as(&Slug("ann".into()), RunTurn::SelfRun);
|
||||
rig.advance(3600);
|
||||
rig.poll();
|
||||
polls(&rig, 4);
|
||||
assert_eq!(
|
||||
self_runs(&rig, ANN).len(),
|
||||
1,
|
||||
"a run whose unit is active holds the briefs however old it is"
|
||||
);
|
||||
assert!(matches!(
|
||||
rig.minds[ANN]
|
||||
.view(K::Run, "ann:run:crashed")
|
||||
.unwrap()
|
||||
.status,
|
||||
huginn_mind::PipelineStatus::InForce
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_held_brief_says_so_in_the_trace() {
|
||||
let (rig, source) = rig_with_a_persona_turn();
|
||||
brief_by(&rig, ANN, crate::persona::AGENT, "solo", &source);
|
||||
fail_bundle(&rig, &[brief_ref("solo")]);
|
||||
beats_until(&rig, 12, || {
|
||||
notes_of(&rig).contains(&"1 briefs held by open questions".to_string())
|
||||
});
|
||||
assert!(
|
||||
notes_of(&rig).contains(&"1 briefs held by open questions".to_string()),
|
||||
"{:?}",
|
||||
notes_of(&rig)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_answer_of_retire_resolves_the_bundle_and_nothing_relaunches() {
|
||||
let (rig, source) = rig_with_a_persona_turn();
|
||||
let items = failed_bundle(&rig, &source);
|
||||
beats(&rig, 3);
|
||||
let question = rig.minds[ANN]
|
||||
.ids(K::Question)
|
||||
.pop()
|
||||
.expect("the trip asked");
|
||||
assert_eq!(self_runs(&rig, ANN).len(), 3);
|
||||
rig.minds[ANN].committed(vec![crate::testkit::ruling(
|
||||
"answer",
|
||||
Some((&question, "retire")),
|
||||
)]);
|
||||
beats(&rig, 6);
|
||||
assert_eq!(
|
||||
self_runs(&rig, ANN).len(),
|
||||
3,
|
||||
"no Self run after the answer: retire does not relaunch"
|
||||
);
|
||||
for item in &items {
|
||||
let view = rig.minds[ANN].view(K::FollowUp, &item.id.0).unwrap();
|
||||
assert!(
|
||||
matches!(view.status, huginn_mind::PipelineStatus::Resolved { record, .. } if matches!(record.outcome, eureka_pipeline::ResolutionOutcome::Withdrawn { .. })),
|
||||
"{item:?} is resolved Withdrawn"
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
rig.minds[ANN].ids(K::Question).len(),
|
||||
1,
|
||||
"and no second question"
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
//! The one way her organs open a run and start its unit.
|
||||
//!
|
||||
//! `open_and_launch` is the whole path, in this order: (0) the in-force runs of
|
||||
//! hers of the request's turn are read, a live one (its unit active, or
|
||||
//! admitted within `START_GRACE_S`) answers Busy and a dead one is to be closed
|
||||
//! Recorded; (1) every cut-spec claim must be in the queue; (2) no claim may be held by an open question, and none may
|
||||
//! hers of the request's turn are read through `slot`, which tells a live one
|
||||
//! (its unit active, or admitted within `START_GRACE_S`: it answers Busy) from a
|
||||
//! dead one (to be closed Recorded); (1) every cut-spec claim must be in the queue; (2) no claim may be held by an open question (the `Held` snapshot the request carries), and none may
|
||||
//! trip the repetition breaker (a trip raises one question in the run whose end
|
||||
//! tripped it, which holds that run's claims); (3) the run is admitted, in the one batch that
|
||||
//! closes the dead holders, so liveness is decided by the in-force record alone
|
||||
|
|
@ -26,10 +26,16 @@ use eureka_pipeline::{
|
|||
Date, Label, PipelineDocument, PipelineKind, PipelineQuestion, PipelineRef, PipelineResolution, PipelineRun, QuestionOption, ResolutionOutcome,
|
||||
RunOperator, RunTurn, Slug, pipeline_key,
|
||||
};
|
||||
use huginn_mind::{MindRefusal, PipelineAdmissionOutcome, PipelineDocumentSummary, PipelineFacts};
|
||||
use huginn_mind::{
|
||||
BATCH_MAX, MindRefusal, PipelineAdmissionOutcome, PipelineDocumentSummary, PipelineFacts,
|
||||
};
|
||||
|
||||
use crate::control::Grant;
|
||||
use crate::queue::{Breaker, MindPort, any_of, breaker, citing, claims_of, headers, in_force, of_kind, on_hold, queue};
|
||||
use crate::queue::{
|
||||
Breaker, Held, MindPort, any_of, breaker, citing, claims_of, headers, holds, in_force, of_kind,
|
||||
queue,
|
||||
};
|
||||
use eureka_state::hold::RETIRE;
|
||||
|
||||
/// How long a run just admitted may have no active unit before its slot is
|
||||
/// treated as dead: `systemctl start --no-block` returns before the unit runs.
|
||||
|
|
@ -221,6 +227,10 @@ pub struct LaunchRequest {
|
|||
/// The reading of a released instance brake and a released swarm brake.
|
||||
/// Taken by value, so one reading opens at most one run.
|
||||
pub grant: Grant,
|
||||
/// The hold the caller chose its claims by, read once with `on_hold`: the
|
||||
/// gate refuses against it and reads it no second time. A Persona turn
|
||||
/// claims nothing, so it carries the empty one.
|
||||
pub held: Held,
|
||||
}
|
||||
|
||||
/// Why admission did not open the run: its own refusal, or one of the two
|
||||
|
|
@ -320,8 +330,43 @@ pub(crate) fn holders(mind: &dyn MindPort, instance: &Slug, turn: RunTurn) -> Re
|
|||
.collect())
|
||||
}
|
||||
|
||||
/// The holders of a turn's slot, told apart: the live one, if any (its unit
|
||||
/// active, or admitted within `START_GRACE_S`), and the dead, which the next
|
||||
/// launch closes. The one reader of whether a holder lives: step 0 and the
|
||||
/// heartbeat's claim of briefs both ask here.
|
||||
pub(crate) struct Slot {
|
||||
pub live: Option<PipelineRef>,
|
||||
pub dead: Vec<PipelineRef>,
|
||||
}
|
||||
|
||||
pub(crate) fn slot(
|
||||
ports: &Ports,
|
||||
instance: &Slug,
|
||||
turn: RunTurn,
|
||||
now: DateTime<Utc>,
|
||||
) -> Result<Slot> {
|
||||
let mut found = Slot {
|
||||
live: None,
|
||||
dead: Vec::new(),
|
||||
};
|
||||
for holder in holders(ports.mind, instance, turn)? {
|
||||
if ports.launcher.alive(instance, turn) || !past_grace(&holder.admission.admitted_at, now) {
|
||||
found.live = Some(holder.id);
|
||||
return Ok(found);
|
||||
}
|
||||
found.dead.push(holder.id);
|
||||
}
|
||||
Ok(found)
|
||||
}
|
||||
|
||||
pub fn open_and_launch(ports: &Ports, request: LaunchRequest) -> Result<LaunchOutcome> {
|
||||
let LaunchRequest { turn, claims, agent, grant } = request;
|
||||
let LaunchRequest {
|
||||
turn,
|
||||
claims,
|
||||
agent,
|
||||
grant,
|
||||
held,
|
||||
} = request;
|
||||
let instance = ports.mind.instance().clone();
|
||||
if grant.instance() != &instance {
|
||||
return Ok(LaunchOutcome::Refused(Declined::GrantForOtherInstance));
|
||||
|
|
@ -336,25 +381,31 @@ pub fn open_and_launch(ports: &Ports, request: LaunchRequest) -> Result<LaunchOu
|
|||
// in the batch that admits whatever comes next.
|
||||
let mut batch: Vec<PipelineDocument> = Vec::new();
|
||||
let mut ending: Vec<PipelineRef> = Vec::new();
|
||||
for holder in holders(ports.mind, &instance, turn)? {
|
||||
if ports.launcher.alive(&instance, turn) || !past_grace(&holder.admission.admitted_at, now) {
|
||||
return Ok(LaunchOutcome::Busy { run: holder.id });
|
||||
}
|
||||
batch.push(close_dead(ports.mind, &holder.id, now)?);
|
||||
ending.push(holder.id);
|
||||
let found = slot(ports, &instance, turn, now)?;
|
||||
if let Some(run) = found.live {
|
||||
return Ok(LaunchOutcome::Busy { run });
|
||||
}
|
||||
for dead in found.dead {
|
||||
batch.push(close_dead(ports.mind, &dead, now)?);
|
||||
ending.push(dead);
|
||||
}
|
||||
|
||||
if turn == RunTurn::SelfRun {
|
||||
let open = queue(ports.mind, &instance, &ending)?;
|
||||
let held = on_hold(ports.mind)?;
|
||||
if let Some(item) = claims.iter().find(|claim| held.contains(claim) || (claim.kind == PipelineKind::CutSpec && !open.contains(claim))) {
|
||||
let open = queue(ports.mind, &instance, &ending, &held)?;
|
||||
if let Some(item) = claims.iter().find(|claim| {
|
||||
held.contains(claim) || (claim.kind == PipelineKind::CutSpec && !open.contains(claim))
|
||||
}) {
|
||||
return Ok(LaunchOutcome::NotQueued { item: item.clone() });
|
||||
}
|
||||
for item in &claims {
|
||||
if let Breaker::Trip { run } = breaker(ports.mind, item, &ending)? {
|
||||
let held = claims_of(ports.mind, &run)?.len();
|
||||
let question = PipelineDocument::Question(breaker_question(item, &run, held, &label, now));
|
||||
let id = PipelineRef { kind: PipelineKind::Question, id: key_of(&question)?.as_str().into() };
|
||||
let count = claims_of(ports.mind, &run)?.len();
|
||||
let question =
|
||||
PipelineDocument::Question(breaker_question(item, &run, count, &label, now));
|
||||
let id = PipelineRef {
|
||||
kind: PipelineKind::Question,
|
||||
id: key_of(&question)?.as_str().into(),
|
||||
};
|
||||
batch.push(question);
|
||||
return Ok(match committed(ports.mind.admit(&agent, &label.0, batch)?) {
|
||||
Ok(()) => LaunchOutcome::Tripped { run, question: id },
|
||||
|
|
@ -427,6 +478,34 @@ fn committed(outcome: PipelineAdmissionOutcome) -> Result<(), Declined> {
|
|||
}
|
||||
}
|
||||
|
||||
/// Resolves, Withdrawn, every item an answer `retire` keeps held that is still
|
||||
/// in force, in the admissions of one batch each: the one writer of a
|
||||
/// retirement, so that a retired claim leaves the follow-up lists and the queue
|
||||
/// instead of waiting held for ever. Returns how many it resolved.
|
||||
pub fn retire_answered(mind: &dyn MindPort, agent: &str, now: DateTime<Utc>) -> Result<usize> {
|
||||
let retired: Vec<PipelineRef> = holds(mind)?
|
||||
.into_iter()
|
||||
.filter(|hold| hold.retired)
|
||||
.flat_map(|hold| hold.items)
|
||||
.collect();
|
||||
let label = run_label(now);
|
||||
let mut resolved = 0;
|
||||
for chunk in retired.chunks(BATCH_MAX) {
|
||||
let mut batch = Vec::new();
|
||||
for item in chunk {
|
||||
let outcome = ResolutionOutcome::Withdrawn {
|
||||
reason: "retired by the answer to a breaker question".into(),
|
||||
};
|
||||
batch.push(closure(mind, item, now, outcome, "The operator answered the breaker question for the run that claimed this item with retire, so it is no longer worked.")?);
|
||||
}
|
||||
match committed(mind.admit(agent, &format!("retire-{}", label.0), batch)?) {
|
||||
Ok(()) => resolved += chunk.len(),
|
||||
Err(_) => return Err(anyhow!("the retired claims could not be resolved")),
|
||||
}
|
||||
}
|
||||
Ok(resolved)
|
||||
}
|
||||
|
||||
/// `breaker-<run label>`: one question per trip, since each trip is a launch
|
||||
/// with a label of its own.
|
||||
fn breaker_label(label: &Label) -> Label {
|
||||
|
|
@ -441,14 +520,14 @@ fn breaker_question(claim: &PipelineRef, run: &PipelineRef, held: usize, label:
|
|||
label: breaker_label(label),
|
||||
title: "Repeated runs on the same work admitted nothing".into(),
|
||||
question: format!(
|
||||
"Run {} and the runs before it left nothing that cites the work it held (claims held: {held}), so none of it launches again. Answering or withdrawing this reopens every claim of that run with a fresh count.",
|
||||
"Run {} and the runs before it left nothing that cites the work it held (claims held: {held}), so none of it launches again. Answering with reopen, or withdrawing this, reopens every claim of that run with a fresh count; answering with retire resolves every claim of it Withdrawn.",
|
||||
run.id.0
|
||||
)
|
||||
.as_str()
|
||||
.into(),
|
||||
options: vec![
|
||||
QuestionOption { label: "reopen".into(), text: "Answer to reopen every claim of the run with a fresh count.".into() },
|
||||
QuestionOption { label: "retire".into(), text: "Resolve the claims so they are no longer worked, and answer with this option.".into() },
|
||||
QuestionOption { label: RETIRE.into(), text: "Answer to retire every claim of the run: each is resolved Withdrawn and none is worked again.".into() },
|
||||
],
|
||||
recommended: "reopen".into(),
|
||||
depends: vec![],
|
||||
|
|
@ -489,7 +568,16 @@ mod tests {
|
|||
fn try_launch_through(&self, mind: &dyn MindPort, turn: RunTurn, claims: &[PipelineRef]) -> Result<LaunchOutcome> {
|
||||
let ports = Ports { mind, launcher: &self.launcher, clock: &self.clock, host: "yggdrasil-host" };
|
||||
let grant = Grant::for_test(Slug(INSTANCE.into()));
|
||||
open_and_launch(&ports, LaunchRequest { turn, claims: claims.to_vec(), agent: "agent-x".into(), grant })
|
||||
open_and_launch(
|
||||
&ports,
|
||||
LaunchRequest {
|
||||
turn,
|
||||
claims: claims.to_vec(),
|
||||
agent: "agent-x".into(),
|
||||
grant,
|
||||
held: crate::queue::on_hold(mind)?,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn at(&self, milliseconds: i64) {
|
||||
|
|
@ -950,6 +1038,128 @@ mod tests {
|
|||
assert!(matches!(rig.launch(RunTurn::SelfRun, &[brief]), LaunchOutcome::Launched { .. }), "withdrawn, the hold lifts");
|
||||
}
|
||||
|
||||
/// A mind where a question lands the moment the hold has been read once.
|
||||
struct Landing<'a> {
|
||||
inner: &'a TestMind,
|
||||
question: RefCell<Option<Vec<PipelineDocument>>>,
|
||||
hold_reads: Cell<u32>,
|
||||
}
|
||||
|
||||
impl MindPort for Landing<'_> {
|
||||
fn instance(&self) -> &Slug {
|
||||
self.inner.instance()
|
||||
}
|
||||
|
||||
fn query(&self, selection: &Selection) -> Result<huginn_mind::PipelineSelectionPage> {
|
||||
let answer = self.inner.query(selection);
|
||||
let reads_the_hold = selection.schemas.as_deref()
|
||||
== Some(&[K::Question.type_id().to_string()][..])
|
||||
&& selection
|
||||
.fields
|
||||
.iter()
|
||||
.flatten()
|
||||
.any(|predicate| predicate.index == "in_force");
|
||||
if reads_the_hold {
|
||||
self.hold_reads.set(self.hold_reads.get() + 1);
|
||||
if let Some(documents) = self.question.take() {
|
||||
self.inner.committed(documents);
|
||||
}
|
||||
}
|
||||
answer
|
||||
}
|
||||
|
||||
fn admit(
|
||||
&self,
|
||||
agent: &str,
|
||||
session: &str,
|
||||
documents: Vec<PipelineDocument>,
|
||||
) -> Result<Outcome> {
|
||||
self.inner.admit(agent, session, documents)
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_launch_reads_the_hold_once_and_a_question_landing_after_it_changes_no_answer_within_the_launch()
|
||||
{
|
||||
let rig = Rig::new();
|
||||
rig.mind.committed(vec![spec("x")]);
|
||||
let landing = Landing {
|
||||
inner: &rig.mind,
|
||||
question: RefCell::new(Some(vec![question_in("fork", &spec_ref("x"))])),
|
||||
hold_reads: Cell::new(0),
|
||||
};
|
||||
// The request's snapshot is read first (the question lands behind it); the queue and the gate use that one.
|
||||
let outcome = rig.launch_through(&landing, RunTurn::SelfRun, &[spec_ref("x")]);
|
||||
assert!(
|
||||
matches!(outcome, LaunchOutcome::Launched { .. }),
|
||||
"{outcome:?}: queue and gate answered from one snapshot"
|
||||
);
|
||||
assert_eq!(
|
||||
landing.hold_reads.get(),
|
||||
1,
|
||||
"the hold was read once for the whole launch"
|
||||
);
|
||||
assert_eq!(
|
||||
on_hold(&rig.mind).unwrap().items(),
|
||||
[spec_ref("x")],
|
||||
"the question did land"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retiring_resolves_every_claim_an_answer_of_retire_holds_once() {
|
||||
let rig = Rig::new();
|
||||
rig.mind
|
||||
.committed(vec![spec("x"), follow_up("brief", &spec_ref("x"))]);
|
||||
let brief = reference(K::FollowUp, &id("follow_up", "brief"));
|
||||
rig.mind.committed(vec![run(
|
||||
"failed",
|
||||
RunTurn::SelfRun,
|
||||
RunOperator::Mind,
|
||||
&[brief.clone(), spec_ref("x")],
|
||||
)]);
|
||||
rig.mind
|
||||
.committed(vec![close(run_ref("failed"), recorded())]);
|
||||
rig.mind
|
||||
.committed(vec![question_in("fork", &run_ref("failed"))]);
|
||||
let question = id("question", "fork");
|
||||
assert_eq!(
|
||||
retire_answered(&rig.mind, "agent-x", rig.clock.now()).unwrap(),
|
||||
0,
|
||||
"an open question retires nothing"
|
||||
);
|
||||
rig.mind
|
||||
.committed(vec![ruling("answer", Some((&question, "reopen")))]);
|
||||
assert_eq!(
|
||||
retire_answered(&rig.mind, "agent-x", rig.clock.now()).unwrap(),
|
||||
0,
|
||||
"reopen retires nothing"
|
||||
);
|
||||
rig.mind
|
||||
.committed(vec![question_in("fork2", &run_ref("failed"))]);
|
||||
rig.mind.committed(vec![ruling(
|
||||
"answer2",
|
||||
Some((&id("question", "fork2"), "retire")),
|
||||
)]);
|
||||
assert_eq!(
|
||||
retire_answered(&rig.mind, "agent-x", rig.clock.now()).unwrap(),
|
||||
2
|
||||
);
|
||||
for item in [&brief, &spec_ref("x")] {
|
||||
let view = rig.mind.view(item.kind, &item.id.0).unwrap();
|
||||
assert!(
|
||||
matches!(view.status, PipelineStatus::Resolved { record, .. } if matches!(record.outcome, ResolutionOutcome::Withdrawn { .. })),
|
||||
"{item:?}"
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
retire_answered(&rig.mind, "agent-x", rig.clock.now()).unwrap(),
|
||||
0,
|
||||
"resolved, nothing is left to retire"
|
||||
);
|
||||
assert!(rig.mind.ids(K::Run).len() == 1, "and nothing launched");
|
||||
}
|
||||
|
||||
/// The one source of every drawn case: splitmix64 from a fixed seed, so a
|
||||
/// failure names a case that runs again.
|
||||
struct Draw(u64);
|
||||
|
|
@ -1074,7 +1284,16 @@ mod tests {
|
|||
let (launcher, clock) = (Recording::default(), Fixed(Cell::new(at(0))));
|
||||
let ports = Ports { mind, launcher: &launcher, clock: &clock, host: "yggdrasil-host" };
|
||||
let grant = Grant::for_test(Slug(grant.into()));
|
||||
let outcome = open_and_launch(&ports, LaunchRequest { turn, claims, agent: "agent-x".into(), grant });
|
||||
let outcome = open_and_launch(
|
||||
&ports,
|
||||
LaunchRequest {
|
||||
turn,
|
||||
claims,
|
||||
agent: "agent-x".into(),
|
||||
grant,
|
||||
held: Held::default(),
|
||||
},
|
||||
);
|
||||
assert!(launcher.started.borrow().is_empty());
|
||||
outcome
|
||||
}
|
||||
|
|
|
|||
|
|
@ -59,7 +59,7 @@ use crate::connector::render_plan;
|
|||
use crate::control::{ControlSource, Effective, HeldReason, derive};
|
||||
use crate::heartbeat::{PollTrace, TempoView};
|
||||
use crate::launch::{Clock, LaunchOutcome, LaunchRequest, Launcher, Ports, holders, open_and_launch};
|
||||
use crate::queue::{MindPort, any_of, citing, headers, in_force, live_claims, of_kind};
|
||||
use crate::queue::{MindPort, any_of, citing, headers, in_force, live_claims, of_kind, on_hold};
|
||||
use crate::recall;
|
||||
|
||||
/// The admitting agent's name on everything this organ admits.
|
||||
|
|
@ -955,7 +955,7 @@ fn start_self_run(
|
|||
}
|
||||
let Effective::Released(grant) = read(control, swarm, organs.mind.instance()) else { return Ok(Err(Failure::SelfRun)) };
|
||||
let ports = Ports { mind: organs.mind, launcher: organs.launcher, clock: organs.clock, host: organs.host };
|
||||
let request = LaunchRequest { turn: RunTurn::SelfRun, claims: vec![claim], agent: AGENT.into(), grant };
|
||||
let request = LaunchRequest { turn: RunTurn::SelfRun, claims: vec![claim], agent: AGENT.into(), grant, held: on_hold(organs.mind)? };
|
||||
Ok(match open_and_launch(&ports, request)? {
|
||||
LaunchOutcome::Launched { run } | LaunchOutcome::Busy { run } => Ok(run),
|
||||
_ => Err(Failure::SelfRun),
|
||||
|
|
|
|||
|
|
@ -227,7 +227,7 @@ impl Rig {
|
|||
let effective = read(&FileSource::new(self.store()), &FileSource::new(self.swarm_store()), self.mind.instance());
|
||||
if let Effective::Released(grant) = effective {
|
||||
let ports = Ports { mind: &self.mind, launcher: &Recording::default(), clock: &self.clock, host: "yggdrasil-host" };
|
||||
let request = LaunchRequest { turn: RunTurn::PersonaTurn, claims: vec![], agent: "mind-heartbeat".into(), grant };
|
||||
let request = LaunchRequest { turn: RunTurn::PersonaTurn, claims: vec![], agent: "mind-heartbeat".into(), grant, held: crate::queue::Held::default() };
|
||||
assert!(matches!(open_and_launch(&ports, request).unwrap(), LaunchOutcome::Launched { .. }));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,12 +7,17 @@
|
|||
//! record (target invariant run-is-the-grant).
|
||||
|
||||
use anyhow::{Result, anyhow, bail};
|
||||
use cultnet_rs::{Citation, FieldPredicate, Incoming, RecordRef, Selection};
|
||||
use eureka_pipeline::{Line, PipelineDocument, PipelineKind, PipelineRef, ResolutionOutcome, RunOperator, RunTurn, Slug};
|
||||
use cultnet_rs::{Incoming, Selection};
|
||||
use eureka_pipeline::{
|
||||
Line, PipelineDocument, PipelineKind, PipelineRef, ResolutionOutcome, RunOperator, RunTurn,
|
||||
Slug,
|
||||
};
|
||||
use eureka_state::hold::{self, Hold};
|
||||
use eureka_state::{ClientError, HuginnClient};
|
||||
use huginn_mind::{
|
||||
Faculty, HuginnMindRequest, HuginnMindResponse, PipelineAdmissionBatch, PipelineAdmissionOutcome, PipelineDocumentSummary, PipelineFacts,
|
||||
PipelinePageItems, PipelineProvenance, PipelineSelectionPage, PipelineStatusSummary, SemanticQuery,
|
||||
Faculty, HuginnMindRequest, HuginnMindResponse, PipelineAdmissionBatch,
|
||||
PipelineAdmissionOutcome, PipelineDocumentSummary, PipelineFacts, PipelineProvenance,
|
||||
PipelineSelectionPage, PipelineStatusSummary, SemanticQuery,
|
||||
};
|
||||
|
||||
/// The last `CONSECUTIVE` ended runs over an item, all empty, trip its breaker.
|
||||
|
|
@ -109,42 +114,14 @@ fn client_error_code(error: &ClientError) -> &'static str {
|
|||
}
|
||||
}
|
||||
|
||||
/// The documents that cite `target`, under `role` or any.
|
||||
pub(crate) fn citing(target: &PipelineRef, role: Option<&str>) -> Citation {
|
||||
Citation { target: RecordRef::new(target.kind.type_id(), target.id.0.clone()), role: role.map(Into::into) }
|
||||
}
|
||||
pub(crate) use eureka_state::hold::{any_of, citing, in_force, of_kind};
|
||||
|
||||
pub(crate) fn of_kind(kind: PipelineKind) -> Selection {
|
||||
Selection { schemas: Some(vec![kind.type_id().to_string()]), ..Selection::default() }
|
||||
}
|
||||
|
||||
pub(crate) fn any_of(mut selection: Selection, index: &str, values: &[&str]) -> Selection {
|
||||
selection.fields.get_or_insert_with(Vec::new).push(FieldPredicate {
|
||||
index: index.into(),
|
||||
op: "any_of".into(),
|
||||
values: Some(values.iter().map(|value| value.to_string()).collect()),
|
||||
number: None,
|
||||
});
|
||||
selection
|
||||
}
|
||||
|
||||
pub(crate) fn in_force(selection: Selection) -> Selection {
|
||||
any_of(selection, "in_force", &["true"])
|
||||
}
|
||||
|
||||
/// Every page of a selection, in the mind's order. The one paging loop.
|
||||
pub(crate) fn headers(mind: &dyn MindPort, selection: &Selection) -> Result<Vec<PipelineDocumentSummary>> {
|
||||
let mut all = Vec::new();
|
||||
let mut asking = selection.clone();
|
||||
loop {
|
||||
let page = mind.query(&asking)?;
|
||||
let PipelinePageItems::Headers(items) = page.items else { bail!("the mind answered documents to a header selection") };
|
||||
all.extend(items);
|
||||
match page.next {
|
||||
Some(cursor) => asking.cursor = Some(cursor),
|
||||
None => return Ok(all),
|
||||
}
|
||||
}
|
||||
/// Every page of a selection, in the mind's order.
|
||||
pub(crate) fn headers(
|
||||
mind: &dyn MindPort,
|
||||
selection: &Selection,
|
||||
) -> Result<Vec<PipelineDocumentSummary>> {
|
||||
hold::headers(&|asking| mind.query(asking), selection)
|
||||
}
|
||||
|
||||
/// The claims of the runs of `instance` that are still in force and not in
|
||||
|
|
@ -165,43 +142,64 @@ pub(crate) fn live_claims(mind: &dyn MindPort, instance: &Slug, ending: &[Pipeli
|
|||
/// The claims of the run `run`, resolved or not (a citation outlives its
|
||||
/// resolution). Empty when the mind has no such run.
|
||||
pub(crate) fn claims_of(mind: &dyn MindPort, run: &PipelineRef) -> Result<Vec<PipelineRef>> {
|
||||
let one = Selection { keys: Some(vec![run.id.0.clone()]), ..of_kind(PipelineKind::Run) };
|
||||
Ok(headers(mind, &one)?
|
||||
.into_iter()
|
||||
.flat_map(|header| match header.facts {
|
||||
PipelineFacts::Run { claims, .. } => claims,
|
||||
_ => Vec::new(),
|
||||
})
|
||||
.collect())
|
||||
hold::claims_of(&|asking| mind.query(asking), run)
|
||||
}
|
||||
|
||||
/// The items an in-force question holds: the one owner of the stop. A question
|
||||
/// raised in a run holds every claim of that run, the set that failed together;
|
||||
/// a question raised in anything else holds that item. The question's text is
|
||||
/// display only, and a held item is released by answering or withdrawing it.
|
||||
pub fn on_hold(mind: &dyn MindPort) -> Result<Vec<PipelineRef>> {
|
||||
let mut held: Vec<PipelineRef> = Vec::new();
|
||||
for header in headers(mind, &in_force(of_kind(PipelineKind::Question)))? {
|
||||
if let PipelineFacts::Question { raised_in: Some(item), .. } = header.facts {
|
||||
if item.kind == PipelineKind::Run {
|
||||
held.extend(claims_of(mind, &item)?);
|
||||
} else {
|
||||
held.push(item);
|
||||
}
|
||||
}
|
||||
/// Every question holding anything and what it holds: `eureka_state::hold`,
|
||||
/// the one decider, which `huginn blocked` reads too.
|
||||
pub fn holds(mind: &dyn MindPort) -> Result<Vec<Hold>> {
|
||||
hold::holds(&|asking| mind.query(asking))
|
||||
}
|
||||
|
||||
/// A snapshot of the items an open question holds, read once by whoever
|
||||
/// launches and passed to everything that launch consults, so the gate, the
|
||||
/// queue and the claims they chose cannot answer differently within one launch.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct Held(Vec<PipelineRef>);
|
||||
|
||||
impl Held {
|
||||
pub fn contains(&self, item: &PipelineRef) -> bool {
|
||||
self.0.contains(item)
|
||||
}
|
||||
Ok(held)
|
||||
|
||||
pub fn items(&self) -> &[PipelineRef] {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
/// The items an in-force question holds, read now: the one owner of the stop.
|
||||
pub fn on_hold(mind: &dyn MindPort) -> Result<Held> {
|
||||
Ok(Held(
|
||||
holds(mind)?
|
||||
.into_iter()
|
||||
.flat_map(|hold| hold.items)
|
||||
.collect(),
|
||||
))
|
||||
}
|
||||
|
||||
/// What may be worked, oldest first: the cut specs in force that no report
|
||||
/// answers, that no open question holds, and that no live run claims. A run in
|
||||
/// `ending` is one the caller closes in the same admission that opens the next,
|
||||
/// so it claims nothing here.
|
||||
pub fn queue(mind: &dyn MindPort, instance: &Slug, ending: &[PipelineRef]) -> Result<Vec<PipelineRef>> {
|
||||
let unreported = Selection { cited: Some(Incoming { role: "cut_spec".into(), exists: false }), ..in_force(of_kind(PipelineKind::CutSpec)) };
|
||||
let blocked = on_hold(mind)?;
|
||||
pub fn queue(
|
||||
mind: &dyn MindPort,
|
||||
instance: &Slug,
|
||||
ending: &[PipelineRef],
|
||||
held: &Held,
|
||||
) -> Result<Vec<PipelineRef>> {
|
||||
let unreported = Selection {
|
||||
cited: Some(Incoming {
|
||||
role: "cut_spec".into(),
|
||||
exists: false,
|
||||
}),
|
||||
..in_force(of_kind(PipelineKind::CutSpec))
|
||||
};
|
||||
let claimed = live_claims(mind, instance, ending)?;
|
||||
Ok(headers(mind, &unreported)?.into_iter().map(|header| header.id).filter(|spec| !blocked.contains(spec) && !claimed.contains(spec)).collect())
|
||||
Ok(headers(mind, &unreported)?
|
||||
.into_iter()
|
||||
.map(|header| header.id)
|
||||
.filter(|spec| !held.contains(spec) && !claimed.contains(spec))
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Whether an item may still be launched on.
|
||||
|
|
@ -282,6 +280,15 @@ mod tests {
|
|||
use eureka_pipeline::{RunOperator, RunTurn};
|
||||
|
||||
use super::*;
|
||||
|
||||
/// `queue` against a hold read now.
|
||||
fn queued(
|
||||
mind: &dyn MindPort,
|
||||
instance: &Slug,
|
||||
ending: &[PipelineRef],
|
||||
) -> Result<Vec<PipelineRef>> {
|
||||
queue(mind, instance, ending, &on_hold(mind)?)
|
||||
}
|
||||
use crate::testkit::*;
|
||||
|
||||
/// A mind holding the spec `x` and a history of ended runs on it.
|
||||
|
|
@ -408,8 +415,19 @@ mod tests {
|
|||
assert_eq!(breaker(&mind, &item, &[]).unwrap(), Breaker::Clear, "open, it is not yet ended");
|
||||
assert!(matches!(breaker(&mind, &item, &[run_ref("dying")]).unwrap(), Breaker::Trip { .. }));
|
||||
let instance = Slug(INSTANCE.into());
|
||||
assert!(queue(&mind, &instance, &[]).unwrap().iter().all(|spec| *spec != item), "it claims the item");
|
||||
assert!(queue(&mind, &instance, &[run_ref("dying")]).unwrap().contains(&item), "an ending run claims nothing");
|
||||
assert!(
|
||||
queued(&mind, &instance, &[])
|
||||
.unwrap()
|
||||
.iter()
|
||||
.all(|spec| *spec != item),
|
||||
"it claims the item"
|
||||
);
|
||||
assert!(
|
||||
queued(&mind, &instance, &[run_ref("dying")])
|
||||
.unwrap()
|
||||
.contains(&item),
|
||||
"an ending run claims nothing"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -468,7 +486,32 @@ mod tests {
|
|||
assert!(matches!(outcome, PipelineAdmissionOutcome::Committed { .. }), "{outcome:?}");
|
||||
let facts = mind.view(PipelineKind::Run, &run_ref("p").id.0).unwrap().admission.provenance;
|
||||
assert_eq!(
|
||||
(facts.faculty, facts.agent.0.as_str(), facts.session.0.as_str(), facts.tool.0.as_str()),
|
||||
queued(&port, port.instance(), &[]).unwrap(),
|
||||
vec![spec_ref("x")]
|
||||
);
|
||||
let outcome = port
|
||||
.admit(
|
||||
"agent-x",
|
||||
"session-y",
|
||||
vec![run("p", RunTurn::PersonaTurn, RunOperator::Mind, &[])],
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
matches!(outcome, PipelineAdmissionOutcome::Committed { .. }),
|
||||
"{outcome:?}"
|
||||
);
|
||||
let facts = mind
|
||||
.view(PipelineKind::Run, &run_ref("p").id.0)
|
||||
.unwrap()
|
||||
.admission
|
||||
.provenance;
|
||||
assert_eq!(
|
||||
(
|
||||
facts.faculty,
|
||||
facts.agent.0.as_str(),
|
||||
facts.session.0.as_str(),
|
||||
facts.tool.0.as_str()
|
||||
),
|
||||
(Faculty::SelfFaculty, "agent-x", "session-y", "mind-body")
|
||||
);
|
||||
let refused = port.query(&any_of(of_kind(PipelineKind::Ruling), "turn", &["canary-turn-5d2e"])).unwrap_err();
|
||||
|
|
@ -523,19 +566,43 @@ mod tests {
|
|||
let mind = TestMind::seeded();
|
||||
mind.committed(vec![spec("a"), spec("b"), spec("c"), spec("d"), spec("e")]);
|
||||
let instance = Slug(INSTANCE.into());
|
||||
let all: Vec<PipelineRef> = ["a", "b", "c", "d", "e"].iter().map(|cut| spec_ref(cut)).collect();
|
||||
assert_eq!(queue(&mind, &instance, &[]).unwrap(), all);
|
||||
let all: Vec<PipelineRef> = ["a", "b", "c", "d", "e"]
|
||||
.iter()
|
||||
.map(|cut| spec_ref(cut))
|
||||
.collect();
|
||||
assert_eq!(queued(&mind, &instance, &[]).unwrap(), all);
|
||||
|
||||
mind.committed(vec![report("b")]);
|
||||
mind.committed(vec![question_in("fork", &spec_ref("c"))]);
|
||||
mind.committed(vec![run("held", RunTurn::SelfRun, RunOperator::Operator, &[spec_ref("d")])]);
|
||||
assert_eq!(queue(&mind, &instance, &[]).unwrap(), vec![spec_ref("a"), spec_ref("e")]);
|
||||
mind.committed(vec![run(
|
||||
"held",
|
||||
RunTurn::SelfRun,
|
||||
RunOperator::Operator,
|
||||
&[spec_ref("d")],
|
||||
)]);
|
||||
assert_eq!(
|
||||
queued(&mind, &instance, &[]).unwrap(),
|
||||
vec![spec_ref("a"), spec_ref("e")]
|
||||
);
|
||||
|
||||
mind.committed(vec![close(run_ref("held"), recorded())]);
|
||||
assert_eq!(queue(&mind, &instance, &[]).unwrap(), vec![spec_ref("a"), spec_ref("d"), spec_ref("e")], "a closed run claims nothing");
|
||||
assert_eq!(
|
||||
queued(&mind, &instance, &[]).unwrap(),
|
||||
vec![spec_ref("a"), spec_ref("d"), spec_ref("e")],
|
||||
"a closed run claims nothing"
|
||||
);
|
||||
let other = Slug("another-instance".into());
|
||||
mind.committed(vec![run("again", RunTurn::SelfRun, RunOperator::Operator, &[spec_ref("a")])]);
|
||||
assert_eq!(queue(&mind, &other, &[]).unwrap().len(), 3, "claims are read for the instance asked");
|
||||
mind.committed(vec![run(
|
||||
"again",
|
||||
RunTurn::SelfRun,
|
||||
RunOperator::Operator,
|
||||
&[spec_ref("a")],
|
||||
)]);
|
||||
assert_eq!(
|
||||
queued(&mind, &other, &[]).unwrap().len(),
|
||||
3,
|
||||
"claims are read for the instance asked"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -563,26 +630,85 @@ mod tests {
|
|||
fn a_question_raised_in_a_run_holds_every_claim_of_it_until_answered_or_withdrawn() {
|
||||
for withdraw in [false, true] {
|
||||
let (mind, a, b) = bundle();
|
||||
assert!(on_hold(&mind).unwrap().is_empty());
|
||||
assert!(on_hold(&mind).unwrap().items().is_empty());
|
||||
mind.committed(vec![question_in("fork", &run_ref("f2"))]);
|
||||
let held = on_hold(&mind).unwrap();
|
||||
assert!(held.contains(&a) && held.contains(&b) && held.len() == 2, "{held:?}: the claims of the resolved run");
|
||||
assert_eq!(queue(&mind, &Slug(INSTANCE.into()), &[]).unwrap(), Vec::<PipelineRef>::new(), "queue reads the same hold");
|
||||
assert!(
|
||||
held.contains(&a) && held.contains(&b) && held.items().len() == 2,
|
||||
"{held:?}: the claims of the resolved run"
|
||||
);
|
||||
assert_eq!(
|
||||
queued(&mind, &Slug(INSTANCE.into()), &[]).unwrap(),
|
||||
Vec::<PipelineRef>::new(),
|
||||
"queue reads the same hold"
|
||||
);
|
||||
let question = id("question", "fork");
|
||||
if withdraw {
|
||||
mind.committed(vec![close(reference(PipelineKind::Question, &question), withdrawn())]);
|
||||
} else {
|
||||
mind.committed(vec![ruling("answer", Some((&question, "A")))]);
|
||||
}
|
||||
assert!(on_hold(&mind).unwrap().is_empty(), "answered or withdrawn, nothing is held (withdrawn: {withdraw})");
|
||||
assert!(
|
||||
on_hold(&mind).unwrap().items().is_empty(),
|
||||
"answered or withdrawn, nothing is held (withdrawn: {withdraw})"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_question_answered_retire_keeps_holding_the_claims_that_are_still_in_force() {
|
||||
let (mind, a, b) = bundle();
|
||||
mind.committed(vec![question_in("fork", &run_ref("f2"))]);
|
||||
let question = id("question", "fork");
|
||||
mind.committed(vec![ruling(
|
||||
"answer",
|
||||
Some((&question, eureka_state::hold::RETIRE)),
|
||||
)]);
|
||||
let held = on_hold(&mind).unwrap();
|
||||
assert!(
|
||||
held.contains(&a) && held.contains(&b) && held.items().len() == 2,
|
||||
"{held:?}: retire holds what the answer retired"
|
||||
);
|
||||
let retired: Vec<bool> = holds(&mind)
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|hold| hold.retired)
|
||||
.collect();
|
||||
assert_eq!(retired, [true]);
|
||||
mind.committed(vec![close(a.clone(), withdrawn())]);
|
||||
assert_eq!(
|
||||
on_hold(&mind).unwrap().items(),
|
||||
[b.clone()],
|
||||
"a resolved claim drops out"
|
||||
);
|
||||
mind.committed(vec![close(b, withdrawn())]);
|
||||
assert!(
|
||||
on_hold(&mind).unwrap().items().is_empty(),
|
||||
"all resolved, nothing is held"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_question_answered_with_another_choice_holds_nothing() {
|
||||
let (mind, _, _) = bundle();
|
||||
mind.committed(vec![question_in("fork", &run_ref("f2"))]);
|
||||
mind.committed(vec![ruling(
|
||||
"answer",
|
||||
Some((&id("question", "fork"), "reopen")),
|
||||
)]);
|
||||
assert!(on_hold(&mind).unwrap().items().is_empty());
|
||||
assert!(holds(&mind).unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_question_raised_in_anything_else_holds_that_item_alone() {
|
||||
let (mind, a, b) = bundle();
|
||||
mind.committed(vec![question_in("fork", &a)]);
|
||||
assert_eq!(on_hold(&mind).unwrap(), vec![a], "the legacy shape: raised in the item, it holds the item and no claimant");
|
||||
assert_eq!(
|
||||
on_hold(&mind).unwrap().items(),
|
||||
[a],
|
||||
"the legacy shape: raised in the item, it holds the item and no claimant"
|
||||
);
|
||||
assert!(!on_hold(&mind).unwrap().contains(&b));
|
||||
}
|
||||
|
||||
|
|
@ -619,8 +745,12 @@ mod tests {
|
|||
fn an_unreachable_mind_names_no_input() {
|
||||
let canary = "canary-instance-7f3a91";
|
||||
let endpoint: SocketAddr = "127.0.0.1:9".parse().unwrap();
|
||||
let port = HuginnMind::new(HuginnClient::new(endpoint, Slug(canary.into()), Duration::from_millis(300)));
|
||||
let error = queue(&port, &Slug(canary.into()), &[]).unwrap_err();
|
||||
let port = HuginnMind::new(HuginnClient::new(
|
||||
endpoint,
|
||||
Slug(canary.into()),
|
||||
Duration::from_millis(300),
|
||||
));
|
||||
let error = queued(&port, &Slug(canary.into()), &[]).unwrap_err();
|
||||
let shown = format!("{error:#}");
|
||||
assert!(shown.contains("unavailable"), "{shown}");
|
||||
assert!(!shown.contains(canary) && !shown.contains("127.0.0.1"), "{shown}");
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ use eureka_pipeline::PipelineKind;
|
|||
use huginn_mind::{PipelineFacts, PipelineStatusSummary};
|
||||
|
||||
use crate::persona;
|
||||
use crate::queue::{MindPort, any_of, headers, in_force, of_kind, on_hold};
|
||||
use crate::queue::{Held, MindPort, any_of, headers, in_force, of_kind, on_hold};
|
||||
|
||||
/// The owner a follow-up names when it is a brief for her Self.
|
||||
pub const OWNER: &str = "self-run";
|
||||
|
|
@ -67,16 +67,16 @@ fn open_briefs(mind: &dyn MindPort) -> Result<Vec<Brief>> {
|
|||
|
||||
/// Her briefs in force that the session has not been told and no open question
|
||||
/// holds: a held brief is neither claimed nor told until its question is
|
||||
/// answered or withdrawn.
|
||||
/// answered or withdrawn. Reads the hold once.
|
||||
pub fn derive(mind: &dyn MindPort, told: &HashSet<String>) -> Result<Vec<Brief>> {
|
||||
Ok(derive_counting_held(mind, told)?.0)
|
||||
Ok(derive_counting_held(mind, &on_hold(mind)?, told)?.0)
|
||||
}
|
||||
|
||||
/// `derive`, and how many of her briefs in force an open question holds.
|
||||
pub fn derive_counting_held(mind: &dyn MindPort, told: &HashSet<String>) -> Result<(Vec<Brief>, usize)> {
|
||||
let holds = on_hold(mind)?;
|
||||
/// `derive` against a hold the caller already read, and how many of her briefs
|
||||
/// in force that hold keeps back.
|
||||
pub fn derive_counting_held(mind: &dyn MindPort, holds: &Held, told: &HashSet<String>) -> Result<(Vec<Brief>, usize)> {
|
||||
let (held, free): (Vec<Brief>, Vec<Brief>) =
|
||||
open_briefs(mind)?.into_iter().partition(|brief| holds.iter().any(|item| item.kind == PipelineKind::FollowUp && item.id.0 == brief.id));
|
||||
open_briefs(mind)?.into_iter().partition(|brief| holds.items().iter().any(|item| item.kind == PipelineKind::FollowUp && item.id.0 == brief.id));
|
||||
Ok((free.into_iter().filter(|brief| !told.contains(&brief.id)).collect(), held.len()))
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -56,7 +56,12 @@ impl Recording {
|
|||
}
|
||||
|
||||
pub(crate) fn revive(&self, turn: RunTurn) {
|
||||
self.running.borrow_mut().push((Slug(INSTANCE.into()), turn));
|
||||
self.revive_as(&Slug(INSTANCE.into()), turn);
|
||||
}
|
||||
|
||||
/// `revive` for a named instance.
|
||||
pub(crate) fn revive_as(&self, instance: &Slug, turn: RunTurn) {
|
||||
self.running.borrow_mut().push((instance.clone(), turn));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -366,7 +371,7 @@ pub(crate) fn question_in(label: &str, item: &PipelineRef) -> PipelineDocument {
|
|||
label: label.into(),
|
||||
title: "A fork".into(),
|
||||
question: "Which?".into(),
|
||||
options: ["A", "B"].iter().map(|name| QuestionOption { label: (*name).into(), text: "an option".into() }).collect(),
|
||||
options: ["A", "B", "reopen", "retire"].iter().map(|name| QuestionOption { label: (*name).into(), text: "an option".into() }).collect(),
|
||||
recommended: "A".into(),
|
||||
depends: vec![],
|
||||
raised_in: Some(item.clone()),
|
||||
|
|
|
|||
Loading…
Reference in a new issue