huginn-mind: refusals name a holder by a typed RunId; a run born closed owes no live slot
AlreadyLive and AlreadyClaimed carry RunId, which parses as a run key on every door, so a peer cannot fill it with an arbitrary string. The live check applies only to a run the batch leaves in force. Tests pin a holder that differs in start, host and budget, a born-closed run, and the second live Persona turn's admission refusal in mind-body. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
3b15c39f83
commit
5ef4f5eef6
4 changed files with 119 additions and 19 deletions
|
|
@ -41,7 +41,7 @@ use serde::{Deserialize, Serialize};
|
|||
use crate::docs::{CitationRole, Docs, Staged, citations, kind_of_id, outcome_citations};
|
||||
use crate::mind::{HuginnMindEpoch, Mind, unavailable};
|
||||
use crate::receipt::{self, CommitOutcome, PipelineProvenance};
|
||||
use crate::refusal::MindRefusal;
|
||||
use crate::refusal::{MindRefusal, RunId};
|
||||
use crate::store::MindStore;
|
||||
|
||||
/// The most envelopes one batch may carry.
|
||||
|
|
@ -542,11 +542,11 @@ fn run_rule(docs: &Docs, run_key: &str, run: &PipelineRun, replay: bool) -> Resu
|
|||
return Err(MindRefusal::CitesResolvedDocument { kind: claim.kind, id: claim.id.0.clone() });
|
||||
}
|
||||
if let Some(holder) = docs.claim_holder(run_key, claim) {
|
||||
return Err(MindRefusal::AlreadyClaimed { item: claim.id.0.clone(), run: holder.into() });
|
||||
return Err(MindRefusal::AlreadyClaimed { item: claim.id.0.clone(), run: RunId::held(holder) });
|
||||
}
|
||||
}
|
||||
if !replay && let Some(holder) = docs.live_holder(run_key, run) {
|
||||
return Err(MindRefusal::AlreadyLive { run: holder.into() });
|
||||
if !replay && docs.in_force(K::Run, run_key) && let Some(holder) = docs.live_holder(run_key, run) {
|
||||
return Err(MindRefusal::AlreadyLive { run: RunId::held(holder) });
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -1994,7 +1994,7 @@ mod tests {
|
|||
fn a_second_run_on_a_live_claim_is_already_claimed() {
|
||||
let mut mind = with_specs();
|
||||
committed(admit(&mut mind, vec![run("a", &[spec_ref("1")])]));
|
||||
let claimed = MindRefusal::AlreadyClaimed { item: spec_ref("1").id.0, run: run_key("a") };
|
||||
let claimed = MindRefusal::AlreadyClaimed { item: spec_ref("1").id.0, run: RunId::held(&run_key("a")) };
|
||||
assert_eq!(refusal(admit(&mut mind, vec![run("b", &[spec_ref("1")])])), claimed);
|
||||
assert_eq!(refusal(admit(&mut mind, vec![run("b", &[spec_ref("2"), spec_ref("1")])])), claimed, "any one held claim refuses the run");
|
||||
committed(admit(&mut mind, vec![run("b", &[spec_ref("2")])]));
|
||||
|
|
@ -2016,14 +2016,14 @@ mod tests {
|
|||
fn a_second_live_run_of_hers_of_one_turn_is_already_live() {
|
||||
let mut mind = with_specs();
|
||||
committed(admit(&mut mind, vec![mind_run("a", RunTurn::SelfRun, &[])]));
|
||||
let live_a = MindRefusal::AlreadyLive { run: run_key("a") };
|
||||
let live_a = MindRefusal::AlreadyLive { run: RunId::held(&run_key("a")) };
|
||||
assert_eq!(refusal(admit(&mut mind, vec![mind_run("b", RunTurn::SelfRun, &[])])), live_a, "a claimless holder");
|
||||
assert_eq!(refusal(admit(&mut mind, vec![mind_run("b", RunTurn::SelfRun, &[spec_ref("1")])])), live_a);
|
||||
committed(admit(&mut mind, vec![recorded("a")]));
|
||||
committed(admit(&mut mind, vec![mind_run("b", RunTurn::SelfRun, &[spec_ref("1")])]));
|
||||
let live_b = MindRefusal::AlreadyLive { run: run_key("b") };
|
||||
let live_b = MindRefusal::AlreadyLive { run: RunId::held(&run_key("b")) };
|
||||
assert_eq!(refusal(admit(&mut mind, vec![mind_run("c", RunTurn::SelfRun, &[spec_ref("2")])])), live_b, "a claiming holder");
|
||||
let claimed = MindRefusal::AlreadyClaimed { item: spec_ref("1").id.0, run: run_key("b") };
|
||||
let claimed = MindRefusal::AlreadyClaimed { item: spec_ref("1").id.0, run: RunId::held(&run_key("b")) };
|
||||
assert_eq!(refusal(admit(&mut mind, vec![mind_run("c", RunTurn::SelfRun, &[spec_ref("1")])])), claimed, "claims answer first");
|
||||
}
|
||||
|
||||
|
|
@ -2038,7 +2038,7 @@ mod tests {
|
|||
committed(admit(&mut mind, vec![run("op1", &[])]));
|
||||
committed(admit(&mut mind, vec![run("op2", &[])]));
|
||||
committed(admit(&mut mind, vec![mind_run("p", RunTurn::PersonaTurn, &[])]));
|
||||
let live_p = MindRefusal::AlreadyLive { run: run_key("p") };
|
||||
let live_p = MindRefusal::AlreadyLive { run: RunId::held(&run_key("p")) };
|
||||
assert_eq!(refusal(admit(&mut mind, vec![mind_run("p2", RunTurn::PersonaTurn, &[])])), live_p);
|
||||
committed(admit(&mut mind, vec![recorded("a")]));
|
||||
committed(admit(&mut mind, vec![mind_run("b", RunTurn::SelfRun, &[])]));
|
||||
|
|
@ -2050,6 +2050,33 @@ mod tests {
|
|||
committed(admit(&mut operator_only, vec![mind_run("a", RunTurn::SelfRun, &[])]));
|
||||
}
|
||||
|
||||
/// The slot is keyed on instance and turn alone: a holder that started
|
||||
/// earlier, on another host and under another budget still blocks.
|
||||
#[test]
|
||||
fn a_holder_that_differs_in_start_host_and_budget_still_holds_the_slot() {
|
||||
let mut mind = with_specs();
|
||||
let D::Run(mut holder) = mind_run("a", RunTurn::SelfRun, &[]) else { unreachable!() };
|
||||
holder.started_on = eureka_pipeline::Date("2026-01-01".into());
|
||||
holder.host = s("elsewhere");
|
||||
holder.budget_usd = s("9");
|
||||
committed(admit(&mut mind, vec![D::Run(holder)]));
|
||||
assert_eq!(
|
||||
refusal(admit(&mut mind, vec![mind_run("b", RunTurn::SelfRun, &[])])),
|
||||
MindRefusal::AlreadyLive { run: RunId::held(&run_key("a")) }
|
||||
);
|
||||
}
|
||||
|
||||
/// A run opened and closed in one batch is never live, so a live holder
|
||||
/// of its turn does not refuse it; it holds no slot afterwards either.
|
||||
#[test]
|
||||
fn a_run_born_closed_beside_a_live_holder_is_admitted() {
|
||||
let mut mind = with_specs();
|
||||
committed(admit(&mut mind, vec![mind_run("a", RunTurn::SelfRun, &[])]));
|
||||
committed(admit(&mut mind, vec![mind_run("b", RunTurn::SelfRun, &[]), recorded("b")]));
|
||||
committed(admit(&mut mind, vec![recorded("a")]));
|
||||
committed(admit(&mut mind, vec![mind_run("c", RunTurn::SelfRun, &[])]));
|
||||
}
|
||||
|
||||
/// Two runs of hers of one turn in one batch hold nothing: the batch is
|
||||
/// checked against itself, refused whole, and nothing is written.
|
||||
#[test]
|
||||
|
|
@ -2072,13 +2099,13 @@ mod tests {
|
|||
committed(admit(&mut mind, vec![mind_run("b", RunTurn::SelfRun, &[])]));
|
||||
assert_eq!(
|
||||
refusal(admit(&mut mind, vec![resolution(closure.clone(), withdrawn())])),
|
||||
MindRefusal::AlreadyLive { run: run_key("b") }
|
||||
MindRefusal::AlreadyLive { run: RunId::held(&run_key("b")) }
|
||||
);
|
||||
committed(admit(&mut mind, vec![recorded("b")]));
|
||||
committed(admit(&mut mind, vec![resolution(closure, withdrawn())]));
|
||||
assert_eq!(
|
||||
refusal(admit(&mut mind, vec![mind_run("c", RunTurn::SelfRun, &[])])),
|
||||
MindRefusal::AlreadyLive { run: run_key("a") },
|
||||
MindRefusal::AlreadyLive { run: RunId::held(&run_key("a")) },
|
||||
"the reinstated run holds the slot"
|
||||
);
|
||||
}
|
||||
|
|
@ -2151,7 +2178,7 @@ mod tests {
|
|||
committed(admit(&mut mind, vec![run("c", &[spec_ref("1")])]));
|
||||
assert_eq!(
|
||||
refusal(admit(&mut mind, vec![resolution(closure.clone(), withdrawn())])),
|
||||
MindRefusal::AlreadyClaimed { item: spec_ref("1").id.0, run: run_key("c") }
|
||||
MindRefusal::AlreadyClaimed { item: spec_ref("1").id.0, run: RunId::held(&run_key("c")) }
|
||||
);
|
||||
|
||||
let mut free = with_specs();
|
||||
|
|
@ -2173,7 +2200,7 @@ mod tests {
|
|||
committed(admit(&mut mind, vec![run("c", &[spec_ref(held)])]));
|
||||
assert_eq!(
|
||||
refusal(admit(&mut mind, vec![resolution(closure.clone(), withdrawn())])),
|
||||
MindRefusal::AlreadyClaimed { item: spec_ref(expected).id.0, run: run_key("c") }
|
||||
MindRefusal::AlreadyClaimed { item: spec_ref(expected).id.0, run: RunId::held(&run_key("c")) }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -2206,7 +2233,7 @@ mod tests {
|
|||
committed(admit(&mut mind, vec![run("a", &[spec_ref("2"), spec_ref("1")])]));
|
||||
assert_eq!(
|
||||
refusal(admit(&mut mind, vec![run("b", &[spec_ref("1")])])),
|
||||
MindRefusal::AlreadyClaimed { item: spec_ref("1").id.0, run: run_key("a") }
|
||||
MindRefusal::AlreadyClaimed { item: spec_ref("1").id.0, run: RunId::held(&run_key("a")) }
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@ pub use query::{
|
|||
PipelineSelectionPage, PipelineStatus, PipelineStatusSummary, Ranked, SUMMARY_MAX_BYTES, SemanticQuery,
|
||||
};
|
||||
pub use receipt::{DocumentVersion, Faculty, HuginnCommitReceipt, PipelineProvenance, RECEIPT_SCHEMA_VERSION};
|
||||
pub use refusal::MindRefusal;
|
||||
pub use refusal::{MindRefusal, RunId};
|
||||
pub use store::{MindStore, OwnedRedbMessagePackBackingStore};
|
||||
pub use wire::{
|
||||
DeferredAnswer, HuginnMindRequest, HuginnMindResponse, IndexStatus, MAX_DEFERRED_BODY_BYTES, MIND_REQUEST_SCHEMA, MIND_REQUEST_SCHEMA_JSON,
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
//! serialises it, the tools (Cut 13) show it.
|
||||
|
||||
use cultnet_rs::SelectionRefusal;
|
||||
use eureka_pipeline::{PipelineKind, PipelineRefusal};
|
||||
use eureka_pipeline::{PipelineKind, PipelineRef, PipelineRefusal, Short};
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
|
|
@ -57,11 +57,11 @@ pub enum MindRefusal {
|
|||
NotStewarded { repo: String },
|
||||
/// A run claims work another run in force already holds: `item` is the
|
||||
/// claimed document's id and `run` the id of the run that holds it.
|
||||
AlreadyClaimed { item: String, run: String },
|
||||
AlreadyClaimed { item: String, run: RunId },
|
||||
/// A run of hers opens, or is reinstated, while another in-force run of
|
||||
/// hers of the same instance and turn is live: `run` is the holder's id
|
||||
/// (ruling one-live-self-run).
|
||||
AlreadyLive { run: String },
|
||||
AlreadyLive { run: RunId },
|
||||
/// The one refusal a mind never raises: the answer exceeds the largest
|
||||
/// body the organ will deliver by any plane, one send or a deferred body.
|
||||
/// It lives here because a refusal rides the response schema and there is
|
||||
|
|
@ -118,6 +118,50 @@ impl From<SelectionRefusal> for MindRefusal {
|
|||
}
|
||||
}
|
||||
|
||||
/// The id of a run: a full pipeline id of kind Run. A refusal that names a
|
||||
/// holder carries one, so a reader gets a run's id from the wire only if it
|
||||
/// parses as one; a peer cannot fill it with any other string. The only ways
|
||||
/// in are `TryFrom<String>`, which every deserialized value passes, and the
|
||||
/// admission rules, which name a run the mind holds. The error is a fixed
|
||||
/// message: it never echoes the offered value.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||
#[serde(try_from = "String", into = "String")]
|
||||
pub struct RunId(String);
|
||||
|
||||
impl RunId {
|
||||
/// The id as text.
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
|
||||
/// The reference to the run this id names.
|
||||
pub fn to_ref(&self) -> PipelineRef {
|
||||
PipelineRef { kind: PipelineKind::Run, id: Short(self.0.clone()) }
|
||||
}
|
||||
|
||||
/// The key of a run the mind holds, which admission already held to the
|
||||
/// grammar when it admitted the run.
|
||||
pub(crate) fn held(key: &str) -> Self {
|
||||
Self(key.into())
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<String> for RunId {
|
||||
type Error = &'static str;
|
||||
|
||||
fn try_from(id: String) -> Result<Self, Self::Error> {
|
||||
let candidate = PipelineRef { kind: PipelineKind::Run, id: Short(id) };
|
||||
candidate.validate_ref().map_err(|_| "not a run id")?;
|
||||
Ok(Self(candidate.id.0))
|
||||
}
|
||||
}
|
||||
|
||||
impl From<RunId> for String {
|
||||
fn from(id: RunId) -> Self {
|
||||
id.0
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
|
@ -151,4 +195,20 @@ mod tests {
|
|||
};
|
||||
assert!(matches!(MindRefusal::from(outside), MindRefusal::Unavailable { .. }));
|
||||
}
|
||||
|
||||
/// A run id from the wire is a run's id or nothing: other kinds' ids and
|
||||
/// arbitrary strings are refused, and the refusal never repeats the text.
|
||||
#[test]
|
||||
fn a_run_id_is_a_run_key_and_never_any_other_string() {
|
||||
let held = "eureka:run:mind-1";
|
||||
let id: RunId = serde_json::from_str(&format!("\"{held}\"")).unwrap();
|
||||
assert_eq!((id.as_str(), serde_json::to_string(&id).unwrap()), (held, format!("\"{held}\"")));
|
||||
assert_eq!(id.to_ref(), PipelineRef { kind: PipelineKind::Run, id: Short(held.into()) });
|
||||
for bad in ["not even a key", "eureka:cut_spec:cut-x.r1", "eureka:run:", "eureka:run:a:b", ""] {
|
||||
let error = serde_json::from_str::<RunId>(&format!("\"{bad}\"")).unwrap_err().to_string();
|
||||
assert!(error.contains("not a run id") && (bad.is_empty() || !error.contains(bad)), "{error}");
|
||||
}
|
||||
let wire = serde_json::from_str::<MindRefusal>(r#"{"AlreadyLive":{"run":"not even a key"}}"#);
|
||||
assert!(wire.is_err());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -338,7 +338,20 @@ mod tests {
|
|||
// One live Persona turn of hers at a time (admission): end this one.
|
||||
rig.mind.committed(vec![close(run, recorded())]);
|
||||
}
|
||||
assert_eq!(rig.started(), 3, "a Persona turn is never Busy");
|
||||
assert_eq!(rig.started(), 3, "each Persona turn ended before the next opened, so none was refused");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_second_live_persona_turn_is_refused_by_admission_and_starts_nothing() {
|
||||
let rig = Rig::new();
|
||||
let LaunchOutcome::Launched { run } = rig.launch(RunTurn::PersonaTurn, &[], five()) else { panic!("not launched") };
|
||||
rig.at(1);
|
||||
let second = rig.launch(RunTurn::PersonaTurn, &[], five());
|
||||
let LaunchOutcome::Refused(Declined::Admission(MindRefusal::AlreadyLive { run: holder })) = second else {
|
||||
panic!("not refused as live: {second:?}")
|
||||
};
|
||||
assert_eq!(holder.to_ref(), run);
|
||||
assert_eq!((rig.runs(), rig.started()), (1, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
Loading…
Reference in a new issue