mind-body launch: dead runs closed in the opening batch, Busy from admission, breaker over the item's citations
open_and_launch takes a Grant by value. Step 0 reads the in-force runs of hers of the request's turn on the instance (typed facts): a live unit or a run inside START_GRACE_S answers Busy; a dead one is closed Recorded in the same admission that opens the next run, so the in-force record is the one owner of liveness. Admission's AlreadyLive maps to Busy. The alias Busy query and the whole-mind breaker scan are deleted: the breaker counts the documents that cite the item, starting after the newest question raised in it, and a trip's question is keyed by the run label. SystemdLauncher holds no instance; unit names are validated before systemctl sees them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
ed5fc195d2
commit
f624feec75
3 changed files with 592 additions and 206 deletions
|
|
@ -1,29 +1,40 @@
|
|||
//! The one way her organs open a run and start its unit.
|
||||
//!
|
||||
//! `open_and_launch` is the whole path: Busy, queue membership and the
|
||||
//! repetition breaker are checked in that order, the run is admitted into the
|
||||
//! mind, and only after it commits is the unit started. The run is the grant
|
||||
//! (target invariant run-is-the-grant), so there is no pressure file, lease or
|
||||
//! breaker state beside it, and a unit never starts without a committed run.
|
||||
//! The primitive owns no file and stops no unit: stopping her units is the
|
||||
//! operator's brake hold, which is another cut's.
|
||||
//! `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
|
||||
//! trip the repetition breaker; (3) the run is admitted, in the one batch that
|
||||
//! closes the dead holders, so liveness is decided by the in-force record alone
|
||||
//! and the unit table is only the evidence for a close; (4) the unit is started,
|
||||
//! only after that batch commits. The run is the grant (target invariant
|
||||
//! run-is-the-grant), so there is no pressure file, lease or breaker state
|
||||
//! beside it, and a unit never starts without a committed run. A run opens
|
||||
//! only against a `Grant`, which `read_effective` alone makes. The primitive
|
||||
//! owns no file and stops no unit: stopping her units is the operator's brake
|
||||
//! hold, which is another cut's.
|
||||
|
||||
use anyhow::{Result, anyhow};
|
||||
use chrono::{DateTime, Utc};
|
||||
use chrono::{DateTime, Duration, Utc};
|
||||
use cultnet_rs::Selection;
|
||||
use eureka_pipeline::{
|
||||
Date, Label, PipelineDocument, PipelineKind, PipelineQuestion, PipelineRef, PipelineResolution, PipelineRun, QuestionOption,
|
||||
ResolutionOutcome, RunOperator, RunTurn, Slug,
|
||||
Date, Label, PipelineDocument, PipelineKind, PipelineQuestion, PipelineRef, PipelineResolution, PipelineRun, QuestionOption, ResolutionOutcome,
|
||||
RunOperator, RunTurn, Slug, pipeline_key,
|
||||
};
|
||||
use huginn_mind::{MindRefusal, PipelineAdmissionOutcome};
|
||||
use rust_decimal::Decimal;
|
||||
use huginn_mind::{MindRefusal, PipelineAdmissionOutcome, PipelineFacts};
|
||||
|
||||
use crate::queue::{Breaker, MindPort, any_of, breaker, headers, in_force, of_kind, queue};
|
||||
use crate::control::Grant;
|
||||
use crate::queue::{Breaker, MindPort, any_of, breaker, citing, headers, in_force, of_kind, queue};
|
||||
|
||||
/// 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.
|
||||
pub const START_GRACE_S: i64 = 120;
|
||||
|
||||
/// Starts the unit that carries a run, and says whether it is still running.
|
||||
/// Nothing here stops a unit.
|
||||
pub trait Launcher {
|
||||
fn start(&self, turn: RunTurn, label: &Label) -> Result<()>;
|
||||
fn alive(&self, turn: RunTurn, label: &Label) -> bool;
|
||||
fn start(&self, instance: &Slug, turn: RunTurn, label: &Label) -> Result<()>;
|
||||
fn alive(&self, instance: &Slug, turn: RunTurn, label: &Label) -> bool;
|
||||
}
|
||||
|
||||
/// The time the primitive reads: run labels, dates and receipts.
|
||||
|
|
@ -61,42 +72,53 @@ impl Systemctl for RealSystemctl {
|
|||
|
||||
/// Her units under systemd, templated by instance:
|
||||
/// `mind-persona@<instance>:<label>.service` and
|
||||
/// `mind-self@<instance>:<label>.service`.
|
||||
/// `mind-self@<instance>:<label>.service`. The launcher holds no instance: the
|
||||
/// caller names it on every call, and `open_and_launch` always names the
|
||||
/// mind's own.
|
||||
pub struct SystemdLauncher {
|
||||
instance: Slug,
|
||||
systemctl: Box<dyn Systemctl>,
|
||||
}
|
||||
|
||||
impl SystemdLauncher {
|
||||
pub fn new(instance: Slug) -> Self {
|
||||
Self::over(instance, Box::new(RealSystemctl))
|
||||
pub fn new() -> Self {
|
||||
Self::over(Box::new(RealSystemctl))
|
||||
}
|
||||
|
||||
pub(crate) fn over(instance: Slug, systemctl: Box<dyn Systemctl>) -> Self {
|
||||
Self { instance, systemctl }
|
||||
}
|
||||
|
||||
/// The unit of one run: the template by turn, then the instance and the
|
||||
/// label. Start and alive both name it here.
|
||||
pub(crate) fn unit(&self, turn: RunTurn, label: &Label) -> String {
|
||||
let template = match turn {
|
||||
RunTurn::PersonaTurn => "mind-persona",
|
||||
RunTurn::SelfRun => "mind-self",
|
||||
};
|
||||
format!("{template}@{}:{}.service", self.instance.0, label.0)
|
||||
pub(crate) fn over(systemctl: Box<dyn Systemctl>) -> Self {
|
||||
Self { systemctl }
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for SystemdLauncher {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// The unit of one run: the template by turn, then the instance and the label,
|
||||
/// both held to the leaf's grammar first so neither can escape the name. The
|
||||
/// error names neither value. Start and alive both name the unit here.
|
||||
pub(crate) fn unit(instance: &Slug, turn: RunTurn, label: &Label) -> Result<String> {
|
||||
instance.validate_slug().map_err(|_| anyhow!("the instance is not a unit name"))?;
|
||||
label.validate_label().map_err(|_| anyhow!("the label is not a unit name"))?;
|
||||
let template = match turn {
|
||||
RunTurn::PersonaTurn => "mind-persona",
|
||||
RunTurn::SelfRun => "mind-self",
|
||||
};
|
||||
Ok(format!("{template}@{}:{}.service", instance.0, label.0))
|
||||
}
|
||||
|
||||
impl Launcher for SystemdLauncher {
|
||||
fn start(&self, turn: RunTurn, label: &Label) -> Result<()> {
|
||||
let reply = self.systemctl.run(&["start", "--no-block", &self.unit(turn, label)])?;
|
||||
fn start(&self, instance: &Slug, turn: RunTurn, label: &Label) -> Result<()> {
|
||||
let reply = self.systemctl.run(&["start", "--no-block", &unit(instance, turn, label)?])?;
|
||||
if reply.success { Ok(()) } else { Err(anyhow!("systemctl did not start the unit")) }
|
||||
}
|
||||
|
||||
fn alive(&self, turn: RunTurn, label: &Label) -> bool {
|
||||
self.systemctl
|
||||
.run(&["is-active", &self.unit(turn, label)])
|
||||
.is_ok_and(|reply| matches!(reply.stdout.trim(), "active" | "activating" | "reloading"))
|
||||
fn alive(&self, instance: &Slug, turn: RunTurn, label: &Label) -> bool {
|
||||
let Ok(unit) = unit(instance, turn, label) else {
|
||||
return false;
|
||||
};
|
||||
self.systemctl.run(&["is-active", &unit]).is_ok_and(|reply| matches!(reply.stdout.trim(), "active" | "activating" | "reloading"))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -120,8 +142,9 @@ pub struct LaunchRequest {
|
|||
pub claims: Vec<PipelineRef>,
|
||||
/// The admitting agent's name on the receipt.
|
||||
pub agent: String,
|
||||
/// The most one run may spend, from the dial's `read_effective`.
|
||||
pub run_cap_usd: Decimal,
|
||||
/// The reading of a released brake and an in-bounds dial; its cap is the
|
||||
/// run's budget. Taken by value, so one reading opens at most one run.
|
||||
pub grant: Grant,
|
||||
}
|
||||
|
||||
/// Why admission did not open the run: its own refusal, or one of the two
|
||||
|
|
@ -137,17 +160,23 @@ pub enum Declined {
|
|||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum LaunchOutcome {
|
||||
Launched { run: PipelineRef },
|
||||
NotQueued { item: PipelineRef },
|
||||
Tripped { item: PipelineRef, question: PipelineRef },
|
||||
Busy { run: PipelineRef },
|
||||
Launched {
|
||||
run: PipelineRef,
|
||||
},
|
||||
NotQueued {
|
||||
item: PipelineRef,
|
||||
},
|
||||
Tripped {
|
||||
item: PipelineRef,
|
||||
question: PipelineRef,
|
||||
},
|
||||
/// The live holder of the request's turn, from step 0 or from admission.
|
||||
Busy {
|
||||
run: PipelineRef,
|
||||
},
|
||||
Refused(Declined),
|
||||
}
|
||||
|
||||
fn opened_run(instance: &Slug, label: &Label) -> PipelineRef {
|
||||
PipelineRef { kind: PipelineKind::Run, id: format!("{}:run:{}", instance.0, label.0).as_str().into() }
|
||||
}
|
||||
|
||||
fn date(now: DateTime<Utc>) -> Date {
|
||||
Date(now.format("%Y-%m-%d").to_string())
|
||||
}
|
||||
|
|
@ -157,30 +186,77 @@ fn root_of(item: &PipelineRef) -> &str {
|
|||
item.id.0.split(':').next().unwrap_or_default()
|
||||
}
|
||||
|
||||
fn key_of(document: &PipelineDocument) -> Result<String> {
|
||||
pipeline_key(document).map_err(|_| anyhow!("the document could not be keyed"))
|
||||
}
|
||||
|
||||
/// Whether a run admitted at `admitted_at` has had `START_GRACE_S` to start
|
||||
/// its unit. A stamp that does not parse has not.
|
||||
fn past_grace(admitted_at: &str, now: DateTime<Utc>) -> bool {
|
||||
DateTime::parse_from_rfc3339(admitted_at).is_ok_and(|at| now.signed_duration_since(at) >= Duration::seconds(START_GRACE_S))
|
||||
}
|
||||
|
||||
/// The resolution that closes a dead run of hers: Recorded, not Withdrawn, so
|
||||
/// a unit that keeps dying counts as an empty run and trips the breaker. Its
|
||||
/// sequence is the run's next, counting a closure that was itself withdrawn.
|
||||
fn close_dead(mind: &dyn MindPort, run: &PipelineRef, now: DateTime<Utc>) -> Result<PipelineDocument> {
|
||||
let closures = Selection { cites: Some(citing(run, Some("subject"))), ..of_kind(PipelineKind::Resolution) };
|
||||
let latest = headers(mind, &closures)?
|
||||
.iter()
|
||||
.filter_map(|header| match header.facts {
|
||||
PipelineFacts::Resolution { sequence, .. } => Some(sequence),
|
||||
_ => None,
|
||||
})
|
||||
.max()
|
||||
.unwrap_or(0);
|
||||
Ok(PipelineDocument::Resolution(PipelineResolution {
|
||||
subject: run.clone(),
|
||||
sequence: latest + 1,
|
||||
outcome: ResolutionOutcome::Recorded { reason: "unit not active".into() },
|
||||
rationale: "The run's unit was not active when its turn's slot was next wanted.".into(),
|
||||
resolved_on: date(now),
|
||||
}))
|
||||
}
|
||||
|
||||
pub fn open_and_launch(ports: &Ports, request: LaunchRequest) -> Result<LaunchOutcome> {
|
||||
let LaunchRequest { turn, claims, agent, run_cap_usd } = request;
|
||||
let LaunchRequest { turn, claims, agent, grant } = request;
|
||||
let instance = ports.mind.instance().clone();
|
||||
let now = ports.clock.now();
|
||||
let label = run_label(now);
|
||||
|
||||
if turn == RunTurn::SelfRun {
|
||||
let live = in_force(any_of(any_of(any_of(of_kind(PipelineKind::Run), "root", &[&instance.0]), "turn", &["SelfRun"]), "operated_by", &["Mind"]));
|
||||
if let Some(run) = headers(ports.mind, &live)?.into_iter().next() {
|
||||
return Ok(LaunchOutcome::Busy { run: run.id });
|
||||
// Step 0: a live holder of this turn's slot answers Busy; a dead one is closed
|
||||
// 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 headers(ports.mind, &in_force(any_of(of_kind(PipelineKind::Run), "root", &[&instance.0])))? {
|
||||
let PipelineFacts::Run { label: held, turn: held_turn, operated_by: RunOperator::Mind, .. } = &holder.facts else {
|
||||
continue;
|
||||
};
|
||||
if *held_turn != turn {
|
||||
continue;
|
||||
}
|
||||
if ports.launcher.alive(&instance, turn, held) || !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 open = queue(ports.mind, &instance)?;
|
||||
if let Some(item) = claims.iter().find(|claim| claim.kind == PipelineKind::CutSpec && !open.contains(claim)) {
|
||||
return Ok(LaunchOutcome::NotQueued { item: item.clone() });
|
||||
}
|
||||
for item in &claims {
|
||||
if breaker(ports.mind, item)? == Breaker::Trip {
|
||||
let question = breaker_question(item, now);
|
||||
let id = PipelineRef { kind: PipelineKind::Question, id: format!("{}:question:{}", root_of(item), breaker_label(item).0).as_str().into() };
|
||||
return Ok(match committed(ports.mind.admit(&agent, &label.0, vec![PipelineDocument::Question(question)])?) {
|
||||
Ok(()) => LaunchOutcome::Tripped { item: item.clone(), question: id },
|
||||
Err(declined) => LaunchOutcome::Refused(declined),
|
||||
});
|
||||
|
||||
if turn == RunTurn::SelfRun {
|
||||
let open = queue(ports.mind, &instance, &ending)?;
|
||||
if let Some(item) = claims.iter().find(|claim| claim.kind == PipelineKind::CutSpec && !open.contains(claim)) {
|
||||
return Ok(LaunchOutcome::NotQueued { item: item.clone() });
|
||||
}
|
||||
for item in &claims {
|
||||
if breaker(ports.mind, item, &ending)? == Breaker::Trip {
|
||||
let question = PipelineDocument::Question(breaker_question(item, &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 { item: item.clone(), question: id },
|
||||
Err(declined) => LaunchOutcome::Refused(declined),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -191,24 +267,32 @@ pub fn open_and_launch(ports: &Ports, request: LaunchRequest) -> Result<LaunchOu
|
|||
campaigns.push(root);
|
||||
}
|
||||
}
|
||||
let run = PipelineRun {
|
||||
let run = PipelineDocument::Run(PipelineRun {
|
||||
instance: instance.clone(),
|
||||
label: label.clone(),
|
||||
operated_by: RunOperator::Mind,
|
||||
turn,
|
||||
host: ports.host.into(),
|
||||
started_on: date(now),
|
||||
budget_usd: run_cap_usd.normalize().to_string().as_str().into(),
|
||||
budget_usd: grant.run_cap_usd().normalize().to_string().as_str().into(),
|
||||
claims,
|
||||
campaigns,
|
||||
};
|
||||
if let Err(declined) = committed(ports.mind.admit(&agent, &label.0, vec![PipelineDocument::Run(run)])?) {
|
||||
return Ok(LaunchOutcome::Refused(declined));
|
||||
});
|
||||
let opened = PipelineRef { kind: PipelineKind::Run, id: key_of(&run)?.as_str().into() };
|
||||
batch.push(run);
|
||||
match ports.mind.admit(&agent, &label.0, batch)? {
|
||||
PipelineAdmissionOutcome::Refused(MindRefusal::AlreadyLive { run: holder }) => {
|
||||
return Ok(LaunchOutcome::Busy { run: holder.to_ref() });
|
||||
}
|
||||
answer => {
|
||||
if let Err(declined) = committed(answer) {
|
||||
return Ok(LaunchOutcome::Refused(declined));
|
||||
}
|
||||
}
|
||||
}
|
||||
let run = opened_run(&instance, &label);
|
||||
if ports.launcher.start(turn, &label).is_err() {
|
||||
if ports.launcher.start(&instance, turn, &label).is_err() {
|
||||
let withdrawal = PipelineResolution {
|
||||
subject: run,
|
||||
subject: opened,
|
||||
sequence: 1,
|
||||
outcome: ResolutionOutcome::Withdrawn { reason: "the unit failed to start".into() },
|
||||
rationale: "The run committed and its unit did not start, so it holds no work.".into(),
|
||||
|
|
@ -219,7 +303,7 @@ pub fn open_and_launch(ports: &Ports, request: LaunchRequest) -> Result<LaunchOu
|
|||
Err(_) => Err(anyhow!("the run could not be withdrawn after its unit failed to start")),
|
||||
};
|
||||
}
|
||||
Ok(LaunchOutcome::Launched { run })
|
||||
Ok(LaunchOutcome::Launched { run: opened })
|
||||
}
|
||||
|
||||
/// Whether admission landed the batch; if not, why.
|
||||
|
|
@ -232,19 +316,21 @@ fn committed(outcome: PipelineAdmissionOutcome) -> Result<(), Declined> {
|
|||
}
|
||||
}
|
||||
|
||||
/// `breaker-<item local>`, with the local's dots (a label has none) as dashes.
|
||||
fn breaker_label(item: &PipelineRef) -> Label {
|
||||
let local = item.id.0.splitn(3, ':').nth(2).unwrap_or_default();
|
||||
Label(format!("breaker-{}", local.replace('.', "-")))
|
||||
/// `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 {
|
||||
Label(format!("breaker-{}", label.0))
|
||||
}
|
||||
|
||||
/// The question a tripped item raises, in the item's own campaign.
|
||||
fn breaker_question(item: &PipelineRef, now: DateTime<Utc>) -> PipelineQuestion {
|
||||
fn breaker_question(item: &PipelineRef, label: &Label, now: DateTime<Utc>) -> PipelineQuestion {
|
||||
PipelineQuestion {
|
||||
campaign: Slug(root_of(item).to_string()),
|
||||
label: breaker_label(item),
|
||||
label: breaker_label(label),
|
||||
title: "Repeated runs on one item admitted nothing".into(),
|
||||
question: "The last runs on this item admitted nothing but themselves, so it launches no more. A ruling or any other new document reopens it.".into(),
|
||||
question:
|
||||
"The last runs on this item left nothing that cites it, so it launches no more. Answering this reopens the item and starts a fresh count."
|
||||
.into(),
|
||||
options: vec![
|
||||
QuestionOption { label: "reopen".into(), text: "Rule on the item, which reopens it.".into() },
|
||||
QuestionOption { label: "retire".into(), text: "Resolve the item so it is no longer worked.".into() },
|
||||
|
|
@ -259,9 +345,11 @@ fn breaker_question(item: &PipelineRef, now: DateTime<Utc>) -> PipelineQuestion
|
|||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::cell::{Cell, RefCell};
|
||||
use std::time::Duration as StdDuration;
|
||||
|
||||
use eureka_pipeline::PipelineKind as K;
|
||||
use huginn_mind::PipelineAdmissionOutcome as Outcome;
|
||||
use huginn_mind::{PipelineAdmissionOutcome as Outcome, PipelineStatus};
|
||||
use rust_decimal::Decimal;
|
||||
|
||||
use super::*;
|
||||
use crate::testkit::*;
|
||||
|
|
@ -282,14 +370,26 @@ mod tests {
|
|||
}
|
||||
|
||||
fn launch_through(&self, mind: &dyn MindPort, turn: RunTurn, claims: &[PipelineRef], cap: Decimal) -> LaunchOutcome {
|
||||
self.try_launch_through(mind, turn, claims, cap).unwrap()
|
||||
}
|
||||
|
||||
fn try_launch_through(&self, mind: &dyn MindPort, turn: RunTurn, claims: &[PipelineRef], cap: Decimal) -> Result<LaunchOutcome> {
|
||||
let ports = Ports { mind, launcher: &self.launcher, clock: &self.clock, host: "yggdrasil-host" };
|
||||
open_and_launch(&ports, LaunchRequest { turn, claims: claims.to_vec(), agent: "agent-x".into(), run_cap_usd: cap }).unwrap()
|
||||
let grant = Grant::for_test(StdDuration::from_secs(60), cap);
|
||||
open_and_launch(&ports, LaunchRequest { turn, claims: claims.to_vec(), agent: "agent-x".into(), grant })
|
||||
}
|
||||
|
||||
fn at(&self, milliseconds: i64) {
|
||||
self.clock.0.set(at(milliseconds));
|
||||
}
|
||||
|
||||
/// The clock `seconds` after the run with this id was admitted.
|
||||
fn age(&self, run: &str, seconds: i64) {
|
||||
let admitted = self.mind.view(K::Run, run).unwrap().admission.admitted_at;
|
||||
let admitted = DateTime::parse_from_rfc3339(&admitted).unwrap().with_timezone(&Utc);
|
||||
self.clock.0.set(admitted + Duration::seconds(seconds));
|
||||
}
|
||||
|
||||
fn started(&self) -> usize {
|
||||
self.launcher.started.borrow().len()
|
||||
}
|
||||
|
|
@ -297,6 +397,16 @@ mod tests {
|
|||
fn runs(&self) -> usize {
|
||||
self.mind.ids(K::Run).len()
|
||||
}
|
||||
|
||||
/// A run of hers in force, with a unit that is running.
|
||||
fn seed(&self, label: &str, turn: RunTurn, claims: &[PipelineRef]) {
|
||||
self.mind.committed(vec![run(label, turn, RunOperator::Mind, claims)]);
|
||||
self.launcher.revive(label);
|
||||
}
|
||||
|
||||
fn in_force(&self, run: &str) -> bool {
|
||||
self.mind.view(K::Run, &run_ref(run).id.0).unwrap().status == PipelineStatus::InForce
|
||||
}
|
||||
}
|
||||
|
||||
fn five() -> Decimal {
|
||||
|
|
@ -308,6 +418,12 @@ mod tests {
|
|||
run
|
||||
}
|
||||
|
||||
fn the_closure(rig: &Rig, run: &str, sequence: u32) -> (PipelineResolution, String) {
|
||||
let view = rig.mind.view(K::Resolution, &format!("{INSTANCE}:resolution:run.{run}.n{sequence}")).expect("the closure");
|
||||
let PipelineDocument::Resolution(closure) = view.document else { panic!("not a resolution") };
|
||||
(closure, view.admission.receipt_id)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_queued_spec_opens_a_run_that_records_its_launch_and_then_its_unit_starts() {
|
||||
let rig = Rig::new();
|
||||
|
|
@ -322,11 +438,19 @@ mod tests {
|
|||
assert_eq!(held.campaigns, vec![Slug(CAMPAIGN.into())]);
|
||||
assert_eq!(view.admission.provenance.agent.0, "agent-x");
|
||||
assert_eq!(view.admission.provenance.session.0, "mind-20261009T131500000Z");
|
||||
assert_eq!(*rig.launcher.started.borrow(), vec![(RunTurn::SelfRun, label("mind-20261009T131500000Z"))]);
|
||||
assert_eq!(*rig.launcher.started.borrow(), vec![(Slug(INSTANCE.into()), RunTurn::SelfRun, label("mind-20261009T131500000Z"))]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_runs_budget_is_the_cap_passed_in() {
|
||||
fn the_unit_started_names_the_minds_instance() {
|
||||
let rig = Rig::new();
|
||||
rig.launch(RunTurn::PersonaTurn, &[], five());
|
||||
let started = rig.launcher.started.borrow();
|
||||
assert_eq!(started[0].0, *rig.mind.instance());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_runs_budget_is_the_grants_cap_at_two_caps() {
|
||||
let rig = Rig::new();
|
||||
for (n, (cap, want)) in [(Decimal::new(5, 0), "5"), (Decimal::new(750, 2), "7.5"), (Decimal::new(125, 1), "12.5")].into_iter().enumerate() {
|
||||
rig.at(n as i64);
|
||||
|
|
@ -335,23 +459,20 @@ mod tests {
|
|||
assert_eq!(the_run(&view).budget_usd.0, want);
|
||||
assert_eq!(the_run(&view).turn, RunTurn::PersonaTurn);
|
||||
assert!(the_run(&view).claims.is_empty() && the_run(&view).campaigns.is_empty());
|
||||
// One live Persona turn of hers at a time (admission): end this one.
|
||||
// One live Persona turn of hers at a time: end this one.
|
||||
rig.mind.committed(vec![close(run, recorded())]);
|
||||
}
|
||||
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() {
|
||||
fn a_live_persona_turn_is_busy_to_a_persona_launch_and_does_not_block_a_self_run() {
|
||||
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));
|
||||
assert_eq!(rig.launch(RunTurn::PersonaTurn, &[], five()), LaunchOutcome::Busy { run });
|
||||
assert_eq!((rig.runs(), rig.started()), (1, 1), "Busy admits and starts nothing");
|
||||
assert!(matches!(rig.launch(RunTurn::SelfRun, &[], five()), LaunchOutcome::Launched { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -364,7 +485,8 @@ mod tests {
|
|||
fn two_launches_in_one_millisecond_open_one_run() {
|
||||
let rig = Rig::new();
|
||||
assert!(matches!(rig.launch(RunTurn::PersonaTurn, &[], five()), LaunchOutcome::Launched { .. }));
|
||||
let second = rig.launch(RunTurn::PersonaTurn, &[], Decimal::new(6, 0));
|
||||
// A Self run holds another slot, so only the key (instance and label) collides.
|
||||
let second = rig.launch(RunTurn::SelfRun, &[], Decimal::new(6, 0));
|
||||
assert!(matches!(second, LaunchOutcome::Refused(_)), "{second:?}");
|
||||
assert_eq!((rig.runs(), rig.started()), (1, 1));
|
||||
}
|
||||
|
|
@ -397,10 +519,8 @@ mod tests {
|
|||
assert_eq!(outcome, LaunchOutcome::Refused(Declined::StartFailed));
|
||||
let opened = format!("{INSTANCE}:run:mind-20261009T131500000Z");
|
||||
let status = |id: &str| rig.mind.view(K::Run, id).unwrap().status;
|
||||
assert!(
|
||||
matches!(status(&opened), huginn_mind::PipelineStatus::Resolved { record, .. } if matches!(record.outcome, ResolutionOutcome::Withdrawn { .. }))
|
||||
);
|
||||
assert_eq!(status(&run_ref("other").id.0), huginn_mind::PipelineStatus::InForce);
|
||||
assert!(matches!(status(&opened), PipelineStatus::Resolved { record, .. } if matches!(record.outcome, ResolutionOutcome::Withdrawn { .. })));
|
||||
assert_eq!(status(&run_ref("other").id.0), PipelineStatus::InForce);
|
||||
// The spec is free again.
|
||||
rig.launcher.fail.set(false);
|
||||
rig.at(1);
|
||||
|
|
@ -431,7 +551,7 @@ mod tests {
|
|||
rig.mind.committed(vec![run("op", RunTurn::SelfRun, RunOperator::Operator, &[])]);
|
||||
rig.mind.committed(vec![run("gone", RunTurn::SelfRun, RunOperator::Mind, &[])]);
|
||||
rig.mind.committed(vec![close(run_ref("gone"), withdrawn())]);
|
||||
rig.mind.committed(vec![run("persona", RunTurn::PersonaTurn, RunOperator::Mind, &[])]);
|
||||
rig.seed("persona", RunTurn::PersonaTurn, &[]);
|
||||
rig.at(1);
|
||||
let LaunchOutcome::Launched { run: first } = rig.launch(RunTurn::SelfRun, &[spec_ref("x")], five()) else {
|
||||
panic!("blocked by a run that is not hers")
|
||||
|
|
@ -446,6 +566,142 @@ mod tests {
|
|||
assert!(matches!(rig.launch(RunTurn::SelfRun, &[spec_ref("y")], five()), LaunchOutcome::Launched { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_alive_run_is_busy_however_old_and_is_not_closed() {
|
||||
let rig = Rig::new();
|
||||
rig.mind.committed(vec![spec("x")]);
|
||||
rig.seed("old", RunTurn::SelfRun, &[spec_ref("x")]);
|
||||
rig.age(&run_ref("old").id.0, 24 * 3600);
|
||||
assert_eq!(rig.launch(RunTurn::SelfRun, &[], five()), LaunchOutcome::Busy { run: run_ref("old") });
|
||||
assert!(rig.in_force("old"));
|
||||
assert_eq!((rig.runs(), rig.started()), (1, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_claimless_alive_run_is_busy() {
|
||||
let rig = Rig::new();
|
||||
rig.mind.committed(vec![spec("x")]);
|
||||
rig.seed("bare", RunTurn::SelfRun, &[]);
|
||||
rig.age(&run_ref("bare").id.0, 3600);
|
||||
assert_eq!(rig.launch(RunTurn::SelfRun, &[spec_ref("x")], five()), LaunchOutcome::Busy { run: run_ref("bare") });
|
||||
assert!(rig.in_force("bare"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_dead_run_is_closed_past_the_grace_in_the_batch_that_opens_the_next() {
|
||||
let rig = Rig::new();
|
||||
rig.mind.committed(vec![spec("x")]);
|
||||
// Her run on x, admitted, and its unit never ran.
|
||||
rig.mind.committed(vec![run("dead", RunTurn::SelfRun, RunOperator::Mind, &[spec_ref("x")])]);
|
||||
let dead = run_ref("dead").id.0;
|
||||
rig.age(&dead, START_GRACE_S - 1);
|
||||
assert_eq!(
|
||||
rig.launch(RunTurn::SelfRun, &[], five()),
|
||||
LaunchOutcome::Busy { run: run_ref("dead") },
|
||||
"inside the grace the unit may still be starting"
|
||||
);
|
||||
assert!(rig.in_force("dead"));
|
||||
assert_eq!((rig.runs(), rig.started()), (1, 0));
|
||||
|
||||
rig.age(&dead, START_GRACE_S);
|
||||
let LaunchOutcome::Launched { run: next } = rig.launch(RunTurn::SelfRun, &[spec_ref("x")], five()) else {
|
||||
panic!("the dead run's claim stayed held")
|
||||
};
|
||||
assert!(!rig.in_force("dead"));
|
||||
let (closure, closed_in) = the_closure(&rig, "dead", 1);
|
||||
assert_eq!(closure.outcome, ResolutionOutcome::Recorded { reason: "unit not active".into() });
|
||||
assert_eq!(closure.subject, run_ref("dead"));
|
||||
assert_eq!(
|
||||
closed_in,
|
||||
rig.mind.view(K::Run, &next.id.0).unwrap().admission.receipt_id,
|
||||
"one admission closes the dead run and opens the next"
|
||||
);
|
||||
assert_eq!(rig.started(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_dead_runs_closure_takes_the_next_sequence_of_its_subject() {
|
||||
let rig = Rig::new();
|
||||
rig.mind.committed(vec![run("dead", RunTurn::SelfRun, RunOperator::Mind, &[])]);
|
||||
rig.mind.committed(vec![close(run_ref("dead"), recorded())]);
|
||||
// Withdrawing that closure puts the run back in force.
|
||||
let first = reference(K::Resolution, &format!("{INSTANCE}:resolution:run.dead.n1"));
|
||||
rig.mind.committed(vec![close(first, withdrawn())]);
|
||||
assert!(rig.in_force("dead"));
|
||||
rig.age(&run_ref("dead").id.0, 600);
|
||||
assert!(matches!(rig.launch(RunTurn::SelfRun, &[], five()), LaunchOutcome::Launched { .. }));
|
||||
assert!(!rig.in_force("dead"));
|
||||
assert_eq!(the_closure(&rig, "dead", 2).0.sequence, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_a_dead_holder_of_the_requests_turn_is_closed() {
|
||||
let rig = Rig::new();
|
||||
rig.mind.committed(vec![run("op", RunTurn::SelfRun, RunOperator::Operator, &[])]);
|
||||
rig.mind.committed(vec![run("persona", RunTurn::PersonaTurn, RunOperator::Mind, &[])]);
|
||||
rig.mind.committed(vec![run("dead", RunTurn::SelfRun, RunOperator::Mind, &[])]);
|
||||
rig.age(&run_ref("dead").id.0, 3600);
|
||||
assert!(matches!(rig.launch(RunTurn::SelfRun, &[], five()), LaunchOutcome::Launched { .. }));
|
||||
assert!(!rig.in_force("dead"));
|
||||
assert!(rig.in_force("op") && rig.in_force("persona"), "an Operator run and a run of the other turn are never closed by this launch");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_run_whose_admit_answer_was_lost_is_closed_by_the_next_launch_of_its_turn() {
|
||||
struct LosesAnswer<'a>(&'a TestMind);
|
||||
impl MindPort for LosesAnswer<'_> {
|
||||
fn instance(&self) -> &Slug {
|
||||
self.0.instance()
|
||||
}
|
||||
|
||||
fn query(&self, selection: &Selection) -> Result<huginn_mind::PipelineSelectionPage> {
|
||||
self.0.query(selection)
|
||||
}
|
||||
|
||||
fn admit(&self, agent: &str, session: &str, documents: Vec<PipelineDocument>) -> Result<Outcome> {
|
||||
self.0.admit(agent, session, documents)?;
|
||||
Err(anyhow!("the answer was lost"))
|
||||
}
|
||||
}
|
||||
let rig = Rig::new();
|
||||
assert!(rig.try_launch_through(&LosesAnswer(&rig.mind), RunTurn::SelfRun, &[], five()).is_err());
|
||||
assert_eq!((rig.runs(), rig.started()), (1, 0), "committed, and no unit was started");
|
||||
let lost = rig.mind.ids(K::Run).remove(0);
|
||||
rig.age(&lost, START_GRACE_S);
|
||||
assert!(matches!(rig.launch(RunTurn::SelfRun, &[], five()), LaunchOutcome::Launched { .. }));
|
||||
assert_eq!((rig.runs(), rig.started()), (2, 1));
|
||||
let lost_label = lost.rsplit(':').next().unwrap().to_string();
|
||||
assert!(!rig.in_force(&lost_label));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_port_that_admits_a_rival_live_run_between_step_0_and_the_admission_is_busy_and_starts_nothing() {
|
||||
struct Racing<'a> {
|
||||
inner: &'a TestMind,
|
||||
raced: Cell<bool>,
|
||||
}
|
||||
impl MindPort for Racing<'_> {
|
||||
fn instance(&self) -> &Slug {
|
||||
self.inner.instance()
|
||||
}
|
||||
|
||||
fn query(&self, selection: &Selection) -> Result<huginn_mind::PipelineSelectionPage> {
|
||||
self.inner.query(selection)
|
||||
}
|
||||
|
||||
fn admit(&self, agent: &str, session: &str, documents: Vec<PipelineDocument>) -> Result<Outcome> {
|
||||
if !self.raced.replace(true) {
|
||||
self.inner.committed(vec![run("rival", RunTurn::SelfRun, RunOperator::Mind, &[])]);
|
||||
}
|
||||
self.inner.admit(agent, session, documents)
|
||||
}
|
||||
}
|
||||
let rig = Rig::new();
|
||||
let racing = Racing { inner: &rig.mind, raced: Cell::new(false) };
|
||||
assert_eq!(rig.launch_through(&racing, RunTurn::SelfRun, &[], five()), LaunchOutcome::Busy { run: run_ref("rival") });
|
||||
assert_eq!((rig.runs(), rig.started()), (1, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tripped_item_raises_one_question_launches_nothing_and_a_ruling_reopens_it() {
|
||||
let rig = Rig::new();
|
||||
|
|
@ -453,7 +709,7 @@ mod tests {
|
|||
history(&rig.mind, &spec_ref("x"), &[false, false, false]);
|
||||
let before = rig.runs();
|
||||
let outcome = rig.launch(RunTurn::SelfRun, &[spec_ref("x")], five());
|
||||
let question = id("question", "breaker-cut-x-r1");
|
||||
let question = id("question", "breaker-mind-20261009T131500000Z");
|
||||
assert_eq!(outcome, LaunchOutcome::Tripped { item: spec_ref("x"), question: reference(K::Question, &question) });
|
||||
assert_eq!((rig.runs(), rig.started()), (before, 0));
|
||||
assert_eq!(rig.mind.ids(K::Question), vec![question.clone()]);
|
||||
|
|
@ -463,12 +719,65 @@ mod tests {
|
|||
assert_eq!(view.admission.provenance.session.0, "mind-20261009T131500000Z");
|
||||
// Open, the question blocks the item.
|
||||
assert_eq!(rig.launch(RunTurn::SelfRun, &[spec_ref("x")], five()), LaunchOutcome::NotQueued { item: spec_ref("x") });
|
||||
// The ruling that answers it is a document after the last run.
|
||||
// The ruling that answers it opens the item, and the count starts afresh.
|
||||
rig.mind.committed(vec![ruling("reopen-x", Some((&question, "reopen")))]);
|
||||
rig.at(1);
|
||||
assert!(matches!(rig.launch(RunTurn::SelfRun, &[spec_ref("x")], five()), LaunchOutcome::Launched { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn launching_a_tripped_item_with_another_queued_trips_and_launches_nothing() {
|
||||
let rig = Rig::new();
|
||||
rig.mind.committed(vec![spec("x"), spec("y")]);
|
||||
history_as(&rig.mind, &spec_ref("x"), "x", &[false, false, false]);
|
||||
let outcome = rig.launch(RunTurn::SelfRun, &[spec_ref("x")], five());
|
||||
assert!(matches!(outcome, LaunchOutcome::Tripped { .. }), "{outcome:?}");
|
||||
assert_eq!(rig.started(), 0);
|
||||
assert!(matches!(rig.launch(RunTurn::SelfRun, &[spec_ref("y")], five()), LaunchOutcome::Launched { .. }), "the other item still launches");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_second_trip_raises_a_second_question_with_its_own_key() {
|
||||
let rig = Rig::new();
|
||||
rig.mind.committed(vec![spec("x")]);
|
||||
history(&rig.mind, &spec_ref("x"), &[false, false, false]);
|
||||
let LaunchOutcome::Tripped { question: first, .. } = rig.launch(RunTurn::SelfRun, &[spec_ref("x")], five()) else { panic!("no first trip") };
|
||||
rig.mind.committed(vec![ruling("reopen", Some((&first.id.0, "reopen")))]);
|
||||
history_as(&rig.mind, &spec_ref("x"), "g", &[false, false, false]);
|
||||
rig.at(1);
|
||||
let LaunchOutcome::Tripped { question: second, .. } = rig.launch(RunTurn::SelfRun, &[spec_ref("x")], five()) else {
|
||||
panic!("no second trip")
|
||||
};
|
||||
assert_ne!(first, second);
|
||||
assert_eq!(rig.mind.ids(K::Question).len(), 2);
|
||||
assert_eq!(rig.started(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nine_empty_runs_of_fifty_before_the_answer_do_not_trip_after_it() {
|
||||
let rig = Rig::new();
|
||||
rig.mind.committed(vec![spec("x")]);
|
||||
let item = spec_ref("x");
|
||||
history(&rig.mind, &item, &[false, false, true, false, false, true, false, false, true, false, false, true, false]);
|
||||
rig.mind.committed(vec![question_in("fork", &item)]);
|
||||
rig.mind.committed(vec![ruling("answer", Some((&id("question", "fork"), "A")))]);
|
||||
history_as(&rig.mind, &item, "g", &[false]);
|
||||
assert!(matches!(rig.launch(RunTurn::SelfRun, &[item], five()), LaunchOutcome::Launched { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_dead_run_is_an_ended_empty_run_for_the_breaker_and_its_claim_is_free() {
|
||||
let rig = Rig::new();
|
||||
rig.mind.committed(vec![spec("x")]);
|
||||
history(&rig.mind, &spec_ref("x"), &[false, false]);
|
||||
rig.mind.committed(vec![run("dead", RunTurn::SelfRun, RunOperator::Mind, &[spec_ref("x")])]);
|
||||
rig.age(&run_ref("dead").id.0, 600);
|
||||
let outcome = rig.launch(RunTurn::SelfRun, &[spec_ref("x")], five());
|
||||
assert!(matches!(outcome, LaunchOutcome::Tripped { .. }), "{outcome:?}");
|
||||
assert!(!rig.in_force("dead"), "the trip's batch closes the dead run too");
|
||||
assert_eq!((rig.runs(), rig.started()), (3, 0));
|
||||
}
|
||||
|
||||
struct Spy {
|
||||
calls: RefCell<Vec<Vec<String>>>,
|
||||
success: Cell<bool>,
|
||||
|
|
@ -482,45 +791,67 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
fn spied(instance: &str) -> (SystemdLauncher, &'static Spy) {
|
||||
fn spied() -> (SystemdLauncher, &'static Spy) {
|
||||
let spy: &'static Spy = Box::leak(Box::new(Spy { calls: RefCell::default(), success: Cell::new(true), stdout: RefCell::default() }));
|
||||
(SystemdLauncher::over(Slug(instance.into()), Box::new(spy)), spy)
|
||||
(SystemdLauncher::over(Box::new(spy)), spy)
|
||||
}
|
||||
|
||||
fn eureka() -> Slug {
|
||||
Slug("eureka".into())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn units_are_templated_by_instance_and_turn() {
|
||||
let (launcher, spy) = spied("eureka");
|
||||
let (launcher, spy) = spied();
|
||||
let l = label("mind-20261009T131500123Z");
|
||||
assert_eq!(launcher.unit(RunTurn::PersonaTurn, &l), "mind-persona@eureka:mind-20261009T131500123Z.service");
|
||||
assert_eq!(launcher.unit(RunTurn::SelfRun, &l), "mind-self@eureka:mind-20261009T131500123Z.service");
|
||||
launcher.start(RunTurn::SelfRun, &l).unwrap();
|
||||
launcher.start(RunTurn::PersonaTurn, &l).unwrap();
|
||||
assert_eq!(unit(&eureka(), RunTurn::PersonaTurn, &l).unwrap(), "mind-persona@eureka:mind-20261009T131500123Z.service");
|
||||
assert_eq!(unit(&eureka(), RunTurn::SelfRun, &l).unwrap(), "mind-self@eureka:mind-20261009T131500123Z.service");
|
||||
launcher.start(&eureka(), RunTurn::SelfRun, &l).unwrap();
|
||||
launcher.start(&Slug("eureka.test".into()), RunTurn::PersonaTurn, &l).unwrap();
|
||||
assert_eq!(
|
||||
*spy.calls.borrow(),
|
||||
vec![
|
||||
vec!["start", "--no-block", "mind-self@eureka:mind-20261009T131500123Z.service"],
|
||||
vec!["start", "--no-block", "mind-persona@eureka:mind-20261009T131500123Z.service"],
|
||||
vec!["start", "--no-block", "mind-persona@eureka.test:mind-20261009T131500123Z.service"],
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_unit_is_alive_while_active_activating_or_reloading() {
|
||||
let (launcher, spy) = spied("eureka");
|
||||
let (launcher, spy) = spied();
|
||||
let l = label("a");
|
||||
for (said, alive) in [("active\n", true), ("activating\n", true), ("reloading\n", true), ("inactive\n", false), ("failed\n", false), ("", false)] {
|
||||
for (said, alive) in
|
||||
[("active\n", true), ("activating\n", true), ("reloading\n", true), ("inactive\n", false), ("failed\n", false), ("", false)]
|
||||
{
|
||||
*spy.stdout.borrow_mut() = said.to_string();
|
||||
assert_eq!(launcher.alive(RunTurn::SelfRun, &l), alive, "{said:?}");
|
||||
assert_eq!(launcher.alive(&eureka(), RunTurn::SelfRun, &l), alive, "{said:?}");
|
||||
}
|
||||
assert_eq!(spy.calls.borrow()[0], vec!["is-active", "mind-self@eureka:a.service"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failed_start_names_neither_instance_nor_label() {
|
||||
let (launcher, spy) = spied("canary-instance-7f3a91");
|
||||
let (launcher, spy) = spied();
|
||||
spy.success.set(false);
|
||||
let error = launcher.start(RunTurn::SelfRun, &label("canary-label-2c8d04")).unwrap_err();
|
||||
let error = launcher.start(&Slug("canary-instance-7f3a91".into()), RunTurn::SelfRun, &label("canary-label-2c8d04")).unwrap_err();
|
||||
let shown = format!("{error:#}");
|
||||
assert!(!shown.contains("canary"), "{shown}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_instances_and_labels_make_start_fail_and_alive_false_with_no_systemctl_call() {
|
||||
let (launcher, spy) = spied();
|
||||
*spy.stdout.borrow_mut() = "active\n".into();
|
||||
for hostile in ["", ".", "..", "a/b", "a@b", "a:b", "a b", "a\nb", "eureka/../x\n", "x\n--now", "canary/9"] {
|
||||
let as_instance = Slug(hostile.into());
|
||||
let error = launcher.start(&as_instance, RunTurn::SelfRun, &label("ok")).unwrap_err();
|
||||
assert!(!format!("{error:#}").contains("canary"), "{error:#}");
|
||||
assert!(!launcher.alive(&as_instance, RunTurn::SelfRun, &label("ok")), "instance {hostile:?}");
|
||||
let error = launcher.start(&eureka(), RunTurn::PersonaTurn, &label(hostile)).unwrap_err();
|
||||
assert!(!format!("{error:#}").contains("canary"), "{error:#}");
|
||||
assert!(!launcher.alive(&eureka(), RunTurn::PersonaTurn, &label(hostile)), "label {hostile:?}");
|
||||
}
|
||||
assert!(spy.calls.borrow().is_empty(), "no hostile name reached systemctl: {:?}", spy.calls.borrow());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@
|
|||
|
||||
use anyhow::{Result, anyhow, bail};
|
||||
use cultnet_rs::{Citation, FieldPredicate, Incoming, RecordRef, Selection};
|
||||
use eureka_pipeline::{PipelineDocument, PipelineKind, PipelineRef, ResolutionOutcome, Slug};
|
||||
use eureka_pipeline::{PipelineDocument, PipelineKind, PipelineRef, ResolutionOutcome, RunOperator, RunTurn, Slug};
|
||||
use eureka_state::{ClientError, HuginnClient};
|
||||
use huginn_mind::{
|
||||
Faculty, HuginnMindRequest, HuginnMindResponse, PipelineAdmissionBatch, PipelineAdmissionOutcome, PipelineDocumentSummary, PipelineFacts,
|
||||
|
|
@ -113,41 +113,30 @@ pub(crate) fn in_force(selection: Selection) -> Selection {
|
|||
any_of(selection, "in_force", &["true"])
|
||||
}
|
||||
|
||||
/// Every page of a selection, in the mind's order, handed to `visit` until it
|
||||
/// answers `false`. The one paging loop.
|
||||
fn scan(mind: &dyn MindPort, selection: &Selection, mut visit: impl FnMut(PipelineDocumentSummary) -> bool) -> Result<()> {
|
||||
/// 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") };
|
||||
for item in items {
|
||||
if !visit(item) {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
all.extend(items);
|
||||
match page.next {
|
||||
Some(cursor) => asking.cursor = Some(cursor),
|
||||
None => return Ok(()),
|
||||
None => return Ok(all),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn headers(mind: &dyn MindPort, selection: &Selection) -> Result<Vec<PipelineDocumentSummary>> {
|
||||
let mut all = Vec::new();
|
||||
scan(mind, selection, |header| {
|
||||
all.push(header);
|
||||
true
|
||||
})?;
|
||||
Ok(all)
|
||||
}
|
||||
|
||||
/// The claims of the runs of `instance` that are still in force: the work
|
||||
/// someone holds.
|
||||
pub(crate) fn live_claims(mind: &dyn MindPort, instance: &Slug) -> Result<Vec<PipelineRef>> {
|
||||
/// The claims of the runs of `instance` that are still in force and not in
|
||||
/// `ending`: the work someone holds.
|
||||
pub(crate) fn live_claims(mind: &dyn MindPort, instance: &Slug, ending: &[PipelineRef]) -> Result<Vec<PipelineRef>> {
|
||||
let runs = in_force(any_of(of_kind(PipelineKind::Run), "root", &[&instance.0]));
|
||||
let mut claims = Vec::new();
|
||||
for header in headers(mind, &runs)? {
|
||||
if let PipelineFacts::Run { claims: held, .. } = header.facts {
|
||||
if let PipelineFacts::Run { claims: held, .. } = header.facts
|
||||
&& !ending.contains(&header.id)
|
||||
{
|
||||
claims.extend(held);
|
||||
}
|
||||
}
|
||||
|
|
@ -155,8 +144,10 @@ pub(crate) fn live_claims(mind: &dyn MindPort, instance: &Slug) -> Result<Vec<Pi
|
|||
}
|
||||
|
||||
/// What may be worked, oldest first: the cut specs in force that no report
|
||||
/// answers, that no open question blocks, and that no live run claims.
|
||||
pub fn queue(mind: &dyn MindPort, instance: &Slug) -> Result<Vec<PipelineRef>> {
|
||||
/// answers, that no open question blocks, 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 mut blocked: Vec<PipelineRef> = Vec::new();
|
||||
for header in headers(mind, &in_force(of_kind(PipelineKind::Question)))? {
|
||||
|
|
@ -164,12 +155,8 @@ pub fn queue(mind: &dyn MindPort, instance: &Slug) -> Result<Vec<PipelineRef>> {
|
|||
blocked.push(item);
|
||||
}
|
||||
}
|
||||
let claimed = live_claims(mind, instance)?;
|
||||
Ok(headers(mind, &unreported)?
|
||||
.into_iter()
|
||||
.map(|header| header.id)
|
||||
.filter(|spec| !blocked.contains(spec) && !claimed.contains(spec))
|
||||
.collect())
|
||||
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())
|
||||
}
|
||||
|
||||
/// Whether an item may still be launched on.
|
||||
|
|
@ -179,44 +166,46 @@ pub enum Breaker {
|
|||
Trip,
|
||||
}
|
||||
|
||||
/// The repetition breaker, derived from the item's Self runs and from what
|
||||
/// else the mind admitted.
|
||||
/// The repetition breaker, derived from the documents that cite the item.
|
||||
///
|
||||
/// A run is a Self run of the item that claims it. A run that was withdrawn
|
||||
/// never ran and is no run here. An ended run is empty when no document other
|
||||
/// than a run of this item, or the resolution that closes one, was admitted
|
||||
/// from its own admission up to the next run's (or to now). Trip when the last
|
||||
/// `CONSECUTIVE` ended runs are empty, or `EMPTY_SHARE` of the last `WINDOW`.
|
||||
/// A document admitted after the last run, such as a ruling, makes that run
|
||||
/// non-empty, so reopening an item resets it.
|
||||
pub fn breaker(mind: &dyn MindPort, item: &PipelineRef) -> Result<Breaker> {
|
||||
let claiming = Citation { target: RecordRef::new(item.kind.type_id(), item.id.0.clone()), role: Some("claims".into()) };
|
||||
let runs = headers(mind, &Selection { cites: Some(claiming), ..any_of(of_kind(PipelineKind::Run), "turn", &["SelfRun"]) })?;
|
||||
let withdrawn = |run: &PipelineDocumentSummary| {
|
||||
matches!(run.status, PipelineStatusSummary::Resolved { outcome: ResolutionOutcome::Withdrawn { .. }, .. })
|
||||
};
|
||||
// (admission ordinal, ended) of each run that ran, oldest first.
|
||||
let ran: Vec<(u64, bool)> = runs
|
||||
/// One role-less citation selection over every schema gives the set: the runs
|
||||
/// that claim the item, and whatever else points at it (a follow-up sourced
|
||||
/// from it, a report, a resolution, a question raised in it). The newest
|
||||
/// question raised in the item is the boundary: only runs admitted after it
|
||||
/// count, so answering a trip starts a fresh count. A counted run is a Self run
|
||||
/// of hers (typed `turn` and `operated_by`) that was not withdrawn; a
|
||||
/// withdrawn run never ran and is no run here. A counted run is ended when
|
||||
/// resolved or listed in `ending`, and is empty when no document of the set
|
||||
/// other than a run was admitted from its own admission up to the next counted
|
||||
/// run's (or to now). Trip when the last `CONSECUTIVE` ended runs are empty, or
|
||||
/// `EMPTY_SHARE` of the last `WINDOW`. What else the mind admitted, such as a
|
||||
/// Persona turn's run or an unrelated ruling, cites nothing here and counts for
|
||||
/// nothing.
|
||||
pub fn breaker(mind: &dyn MindPort, item: &PipelineRef, ending: &[PipelineRef]) -> Result<Breaker> {
|
||||
let citing = Citation { target: RecordRef::new(item.kind.type_id(), item.id.0.clone()), role: None };
|
||||
let mut set = headers(mind, &Selection { cites: Some(citing), ..Selection::default() })?;
|
||||
set.sort_by_key(|header| header.admission.ordinal);
|
||||
let boundary = set
|
||||
.iter()
|
||||
.filter(|run| !withdrawn(run))
|
||||
.map(|run| (run.admission.ordinal, matches!(run.status, PipelineStatusSummary::Resolved { .. })))
|
||||
.filter(|header| matches!(&header.facts, PipelineFacts::Question { raised_in: Some(raised), .. } if raised == item))
|
||||
.map(|header| header.admission.ordinal)
|
||||
.max();
|
||||
let after = |header: &&PipelineDocumentSummary| boundary.is_none_or(|boundary| header.admission.ordinal > boundary);
|
||||
let withdrawn =
|
||||
|run: &PipelineDocumentSummary| matches!(run.status, PipelineStatusSummary::Resolved { outcome: ResolutionOutcome::Withdrawn { .. }, .. });
|
||||
// (admission ordinal, ended) of each counted run, oldest first.
|
||||
let ran: Vec<(u64, bool)> = set
|
||||
.iter()
|
||||
.filter(after)
|
||||
.filter(|header| {
|
||||
matches!(header.facts, PipelineFacts::Run { turn: RunTurn::SelfRun, operated_by: RunOperator::Mind, .. }) && !withdrawn(header)
|
||||
})
|
||||
.map(|run| (run.admission.ordinal, matches!(run.status, PipelineStatusSummary::Resolved { .. }) || ending.contains(&run.id)))
|
||||
.collect();
|
||||
let ended: Vec<usize> = (0..ran.len()).filter(|index| ran[*index].1).collect();
|
||||
let considered: Vec<usize> = ended.iter().rev().take(WINDOW).copied().collect();
|
||||
let Some(oldest) = considered.last().map(|index| ran[*index].0) else { return Ok(Breaker::Clear) };
|
||||
|
||||
let run_ids: Vec<&PipelineRef> = runs.iter().map(|run| &run.id).collect();
|
||||
let mut admitted = Vec::new();
|
||||
scan(mind, &Selection { descending: true, ..Selection::default() }, |header| {
|
||||
if header.admission.ordinal < oldest {
|
||||
return false;
|
||||
}
|
||||
let closes_a_run = matches!(&header.facts, PipelineFacts::Resolution { subject, .. } if run_ids.contains(&subject));
|
||||
if !run_ids.contains(&&header.id) && !closes_a_run {
|
||||
admitted.push(header.admission.ordinal);
|
||||
}
|
||||
true
|
||||
})?;
|
||||
let admitted: Vec<u64> =
|
||||
set.iter().filter(|header| !matches!(header.facts, PipelineFacts::Run { .. })).map(|header| header.admission.ordinal).collect();
|
||||
|
||||
// Newest first, like `considered`.
|
||||
let empty: Vec<bool> = considered
|
||||
|
|
@ -255,7 +244,7 @@ mod tests {
|
|||
|
||||
fn tripped(runs: &[bool]) -> bool {
|
||||
let (mind, item) = worked(runs);
|
||||
breaker(&mind, &item).unwrap() == Breaker::Trip
|
||||
breaker(&mind, &item, &[]).unwrap() == Breaker::Trip
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -292,6 +281,55 @@ mod tests {
|
|||
assert!(!tripped(&runs), "the tenth empty run is the 51st newest");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn three_empty_runs_trip_after_any_history_of_non_empty_ones() {
|
||||
for before in [0usize, 1, 12, 20] {
|
||||
let mut runs = vec![N; before];
|
||||
runs.extend([E, E, E]);
|
||||
assert!(tripped(&runs), "{before} non-empty runs then three empty ones");
|
||||
assert!(!tripped(&[vec![N; before], vec![E, E]].concat()), "two empty ones never trip");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_documents_that_cite_the_item_make_a_run_non_empty() {
|
||||
let (mind, item) = worked(&[]);
|
||||
mind.committed(vec![spec("other")]);
|
||||
for n in 0..3 {
|
||||
// A Persona turn's run, a ruling and a follow-up of another item all land in the window.
|
||||
mind.committed(vec![run(&format!("p{n}"), RunTurn::PersonaTurn, RunOperator::Mind, &[])]);
|
||||
mind.committed(vec![ruling(&format!("r{n}"), None), follow_up(&format!("o{n}"), &spec_ref("other"))]);
|
||||
ended_run(&mind, &item, "h", n, false);
|
||||
mind.committed(vec![close(run_ref(&format!("p{n}")), recorded())]);
|
||||
}
|
||||
assert_eq!(breaker(&mind, &item, &[]).unwrap(), Breaker::Trip);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_run_in_ending_is_an_ended_empty_run() {
|
||||
let (mind, item) = worked(&[E, E]);
|
||||
mind.committed(vec![run("dying", RunTurn::SelfRun, RunOperator::Mind, std::slice::from_ref(&item))]);
|
||||
assert_eq!(breaker(&mind, &item, &[]).unwrap(), Breaker::Clear, "open, it is not yet ended");
|
||||
assert_eq!(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");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_newest_question_in_the_item_starts_a_fresh_count() {
|
||||
// Nine empty of the last fifty, then a question about the item is raised and answered.
|
||||
let (mind, item) = worked(&[E, E, N, E, E, N, E, E, N, E, E, N, E]);
|
||||
assert_eq!(breaker(&mind, &item, &[]).unwrap(), Breaker::Clear);
|
||||
mind.committed(vec![question_in("fork", &item)]);
|
||||
mind.committed(vec![ruling("answer", Some((&id("question", "fork"), "A")))]);
|
||||
ended_run(&mind, &item, "g", 0, false);
|
||||
assert_eq!(breaker(&mind, &item, &[]).unwrap(), Breaker::Clear, "the tenth empty run, but nine of them are before the question");
|
||||
ended_run(&mind, &item, "g", 1, false);
|
||||
ended_run(&mind, &item, "g", 2, false);
|
||||
assert_eq!(breaker(&mind, &item, &[]).unwrap(), Breaker::Trip, "three empty runs after it");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_withdrawn_run_never_ran_and_is_no_boundary() {
|
||||
let withdrawn_between = |ruling_after: bool| {
|
||||
|
|
@ -299,11 +337,11 @@ mod tests {
|
|||
mind.committed(vec![run("w", RunTurn::SelfRun, RunOperator::Mind, std::slice::from_ref(&item))]);
|
||||
mind.committed(vec![close(run_ref("w"), withdrawn())]);
|
||||
if ruling_after {
|
||||
mind.committed(vec![ruling("after", None)]);
|
||||
mind.committed(vec![follow_up("after", &item)]);
|
||||
}
|
||||
ended_run(&mind, &item, "h", 1, false);
|
||||
ended_run(&mind, &item, "h", 2, false);
|
||||
breaker(&mind, &item).unwrap()
|
||||
breaker(&mind, &item, &[]).unwrap()
|
||||
};
|
||||
assert_eq!(withdrawn_between(false), Breaker::Trip, "the withdrawn run is neither counted nor does it break the streak");
|
||||
assert_eq!(withdrawn_between(true), Breaker::Clear, "a document after the withdrawn run still lands in the first run's window");
|
||||
|
|
@ -313,13 +351,13 @@ mod tests {
|
|||
fn a_runs_window_includes_its_own_batch_and_stops_before_the_next_runs() {
|
||||
let (mind, item) = worked(&[E, E]);
|
||||
// The third run opens with a ruling in its batch; it is still open.
|
||||
mind.committed(vec![run("h2", RunTurn::SelfRun, RunOperator::Mind, std::slice::from_ref(&item)), ruling("with-run", None)]);
|
||||
mind.committed(vec![run("h2", RunTurn::SelfRun, RunOperator::Mind, std::slice::from_ref(&item)), follow_up("with-run", &item)]);
|
||||
mind.committed(vec![close(run_ref("h2"), recorded())]);
|
||||
assert_eq!(breaker(&mind, &item).unwrap(), Breaker::Clear, "the ruling in its own batch is in its window");
|
||||
assert_eq!(breaker(&mind, &item, &[]).unwrap(), Breaker::Clear, "the ruling in its own batch is in its window");
|
||||
|
||||
let (mind, item) = worked(&[E, E, E]);
|
||||
mind.committed(vec![run("live", RunTurn::SelfRun, RunOperator::Mind, std::slice::from_ref(&item)), ruling("with-next", None)]);
|
||||
assert_eq!(breaker(&mind, &item).unwrap(), Breaker::Trip, "the next run's batch is not the previous run's window");
|
||||
mind.committed(vec![run("live", RunTurn::SelfRun, RunOperator::Mind, std::slice::from_ref(&item)), follow_up("with-next", &item)]);
|
||||
assert_eq!(breaker(&mind, &item, &[]).unwrap(), Breaker::Trip, "the next run's batch is not the previous run's window");
|
||||
}
|
||||
|
||||
/// The adapter over the wire vocabulary, answered by a real mind.
|
||||
|
|
@ -329,11 +367,14 @@ mod tests {
|
|||
mind.committed(vec![spec("x")]);
|
||||
let port = HuginnMind::over(Slug(INSTANCE.into()), Box::new(|request| Ok(mind.answer(request))));
|
||||
assert_eq!(port.instance().0, INSTANCE);
|
||||
assert_eq!(queue(&port, port.instance()).unwrap(), vec![spec_ref("x")]);
|
||||
assert_eq!(queue(&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"));
|
||||
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();
|
||||
assert!(format!("{refused:#}").contains("refused the query"));
|
||||
assert!(!format!("{refused:#}").contains("canary"), "{refused:#}");
|
||||
|
|
@ -345,18 +386,18 @@ mod tests {
|
|||
mind.committed(vec![spec("x"), spec("y")]);
|
||||
history_as(&mind, &spec_ref("x"), "x", &[E, E]);
|
||||
history_as(&mind, &spec_ref("y"), "y", &[E, E, E]);
|
||||
assert_eq!(breaker(&mind, &spec_ref("x")).unwrap(), Breaker::Clear);
|
||||
assert_eq!(breaker(&mind, &spec_ref("y")).unwrap(), Breaker::Trip);
|
||||
assert_eq!(breaker(&mind, &spec_ref("x"), &[]).unwrap(), Breaker::Clear);
|
||||
assert_eq!(breaker(&mind, &spec_ref("y"), &[]).unwrap(), Breaker::Trip);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_oldest_counted_runs_own_batch_is_in_its_window() {
|
||||
let (mind, item) = worked(&[]);
|
||||
mind.committed(vec![run("h0", RunTurn::SelfRun, RunOperator::Mind, std::slice::from_ref(&item)), ruling("with-oldest", None)]);
|
||||
mind.committed(vec![run("h0", RunTurn::SelfRun, RunOperator::Mind, std::slice::from_ref(&item)), follow_up("with-oldest", &item)]);
|
||||
mind.committed(vec![close(run_ref("h0"), recorded())]);
|
||||
ended_run(&mind, &item, "h", 1, false);
|
||||
ended_run(&mind, &item, "h", 2, false);
|
||||
assert_eq!(breaker(&mind, &item).unwrap(), Breaker::Clear);
|
||||
assert_eq!(breaker(&mind, &item, &[]).unwrap(), Breaker::Clear);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -365,18 +406,18 @@ mod tests {
|
|||
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);
|
||||
assert_eq!(queue(&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")]);
|
||||
assert_eq!(queue(&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!(queue(&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");
|
||||
assert_eq!(queue(&mind, &other, &[]).unwrap().len(), 3, "claims are read for the instance asked");
|
||||
}
|
||||
|
||||
/// A daemon that is not there: the failure names no input, endpoint or
|
||||
|
|
@ -386,7 +427,7 @@ mod tests {
|
|||
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 error = queue(&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}");
|
||||
|
|
|
|||
|
|
@ -8,13 +8,13 @@ use anyhow::{Result, anyhow};
|
|||
use chrono::{DateTime, Duration, TimeZone, Utc};
|
||||
use cultnet_rs::Selection;
|
||||
use eureka_pipeline::{
|
||||
CommitRange, CutVerification, Date, DocRef, Label, PipelineCampaign, PipelineCutReport, PipelineCutSpec, PipelineDocument,
|
||||
PipelineFollowUp, PipelineInstance, PipelineKind, PipelineQuestion, PipelineRef, PipelineResolution, PipelineRuling, PipelineRun, PipelineStewardship,
|
||||
CommitRange, CutVerification, Date, DocRef, Label, PipelineCampaign, PipelineCutReport, PipelineCutSpec, PipelineDocument, PipelineFollowUp,
|
||||
PipelineInstance, PipelineKind, PipelineQuestion, PipelineRef, PipelineResolution, PipelineRuling, PipelineRun, PipelineStewardship,
|
||||
PipelineTarget, QuestionOption, ResolutionOutcome, RulingAuthority, RunOperator, RunTurn, Sha, Slug, StructuralDelta, TargetInvariant,
|
||||
};
|
||||
use huginn_mind::{
|
||||
Faculty, HuginnMindRequest, HuginnMindResponse, Mind, OwnedRedbMessagePackBackingStore, PipelineAdmissionBatch, PipelineAdmissionOutcome, PipelineDocumentView, PipelineProvenance,
|
||||
PipelineSelectionPage,
|
||||
Faculty, HuginnMindRequest, HuginnMindResponse, Mind, OwnedRedbMessagePackBackingStore, PipelineAdmissionBatch, PipelineAdmissionOutcome,
|
||||
PipelineDocumentView, PipelineProvenance, PipelineSelectionPage,
|
||||
};
|
||||
use tempfile::TempDir;
|
||||
|
||||
|
|
@ -37,24 +37,38 @@ impl Clock for Fixed {
|
|||
}
|
||||
}
|
||||
|
||||
/// A launcher that records every start, and fails them on command.
|
||||
/// A launcher that records every start as (instance, turn, label), fails them
|
||||
/// on command, and answers `alive` from a set of labels: `start` fills it,
|
||||
/// `kill` empties it for a label and `revive` fills it for a seeded run.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct Recording {
|
||||
pub(crate) started: RefCell<Vec<(RunTurn, Label)>>,
|
||||
pub(crate) started: RefCell<Vec<(Slug, RunTurn, Label)>>,
|
||||
pub(crate) fail: Cell<bool>,
|
||||
running: RefCell<Vec<Label>>,
|
||||
}
|
||||
|
||||
impl Recording {
|
||||
pub(crate) fn kill(&self, run: &str) {
|
||||
self.running.borrow_mut().retain(|label| label.0 != run);
|
||||
}
|
||||
|
||||
pub(crate) fn revive(&self, run: &str) {
|
||||
self.running.borrow_mut().push(label(run));
|
||||
}
|
||||
}
|
||||
|
||||
impl Launcher for Recording {
|
||||
fn start(&self, turn: RunTurn, label: &Label) -> Result<()> {
|
||||
fn start(&self, instance: &Slug, turn: RunTurn, label: &Label) -> Result<()> {
|
||||
if self.fail.get() {
|
||||
return Err(anyhow!("refused"));
|
||||
}
|
||||
self.started.borrow_mut().push((turn, label.clone()));
|
||||
self.started.borrow_mut().push((instance.clone(), turn, label.clone()));
|
||||
self.running.borrow_mut().push(label.clone());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn alive(&self, _turn: RunTurn, _label: &Label) -> bool {
|
||||
false
|
||||
fn alive(&self, _instance: &Slug, _turn: RunTurn, label: &Label) -> bool {
|
||||
self.running.borrow().contains(label)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -341,14 +355,14 @@ pub(crate) fn withdrawn() -> ResolutionOutcome {
|
|||
}
|
||||
|
||||
/// Her Self run on `item`, ended, as `n` in a history: opened, closed, and
|
||||
/// followed by a ruling when `leaves_something` (the one document that makes it
|
||||
/// non-empty).
|
||||
/// followed by a follow-up sourced from `item` when `leaves_something` (the
|
||||
/// document that cites the item, which makes the run non-empty).
|
||||
pub(crate) fn ended_run(mind: &TestMind, item: &PipelineRef, prefix: &str, n: usize, leaves_something: bool) {
|
||||
let label = format!("{prefix}{n}");
|
||||
mind.committed(vec![run(&label, RunTurn::SelfRun, RunOperator::Mind, std::slice::from_ref(item))]);
|
||||
let mut closing = vec![close(run_ref(&label), recorded())];
|
||||
if leaves_something {
|
||||
closing.push(ruling(&format!("left{n}"), None));
|
||||
closing.push(follow_up(&format!("{prefix}left{n}"), item));
|
||||
}
|
||||
mind.committed(closing);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue