mind-heartbeat: one heartbeat routes every Persona turn on an initiative timeline; the classifier's urgency multiplies regain, an operator mention fills, a Self-run completion reaches the classifier and never fills; burn is real spend read as the heartbeat; each Persona on her own Huginn; DM rooms answered alone; the mind-wake path deleted (squash of eureka-body/mind-heartbeat d54ba01..3a4d28f; Soul s1-s4)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
d54ba01103
commit
7db86f7dd1
33 changed files with 3535 additions and 1308 deletions
|
|
@ -40,7 +40,7 @@ edits `.cc` state. Huginn reads and writes minds.
|
|||
|
||||
## Layout
|
||||
|
||||
A Cargo workspace of five crates (`crates/eureka-pipeline` holds the document shapes and keys, `crates/mind-body` the control store, launch primitive and `mind-wake` one-shot; the three below are the mind, the daemon and the client):
|
||||
A Cargo workspace of five crates (`crates/eureka-pipeline` holds the document shapes and keys, `crates/mind-body` the control store, launch primitive and `mind-heartbeat` timeline; the three below are the mind, the daemon and the client):
|
||||
|
||||
- `crates/huginn-mind`: storage, identity, admission, and queries and derived
|
||||
status over memory documents.
|
||||
|
|
|
|||
|
|
@ -10,11 +10,11 @@ publish.workspace = true
|
|||
# store (`control`), the operator's CLI over it (`cli`, the `mind-control`
|
||||
# binary) and the one fail-closed reader every acting organ calls
|
||||
# (`control::read_effective`). It also holds the launch primitive (`launch`,
|
||||
# `queue`): the one way her organs open a run and start its unit. The waker
|
||||
# (`wake`, the `mind-wake` binary) is one cycle over them. The actuator
|
||||
# `queue`): the one way her organs open a run and start its unit. The heartbeat
|
||||
# (`heartbeat`, the `mind-heartbeat` binary) runs the initiative timeline over them. The actuator
|
||||
# (`self_run`, the `mind-self-run` binary) carries her one open Self run.
|
||||
#
|
||||
# `rust_decimal` is the dial's number type: heat, cadence and the run cap are
|
||||
# `rust_decimal` is the dial's number type: heat, the spend rate and the run cap are
|
||||
# money-adjacent and the tests pin exact products, which a float would blur. It
|
||||
# serialises as a string, so the stored document carries no float.
|
||||
#
|
||||
|
|
@ -72,10 +72,6 @@ path = "src/lib.rs"
|
|||
name = "mind-control"
|
||||
path = "src/main.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "mind-wake"
|
||||
path = "src/bin/mind-wake.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "mind-self-run"
|
||||
path = "src/bin/mind-self-run.rs"
|
||||
|
|
@ -83,3 +79,7 @@ path = "src/bin/mind-self-run.rs"
|
|||
[[bin]]
|
||||
name = "mind-persona"
|
||||
path = "src/bin/mind-persona.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "mind-heartbeat"
|
||||
path = "src/bin/mind-heartbeat.rs"
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
# mind-body
|
||||
|
||||
The Mind's body: the control store, the launch primitive, the `mind-wake`
|
||||
one-shot and the `mind-self-run` actuator.
|
||||
The Mind's body: the control store, the launch primitive, the `mind-heartbeat`
|
||||
timeline and the `mind-self-run` actuator.
|
||||
|
||||
`mind-self-run --instance <slug> [--endpoint rudp://<ip>:<port>]` runs inside
|
||||
`mind-self@<instance>`. It finds her one open Self run, starts `claude` over
|
||||
|
|
@ -10,28 +10,9 @@ ceiling and stops a child that outlives the flush (SIGINT, then SIGTERM and SIGK
|
|||
otherwise, 3 when she has no single open Self run and 2 when the arguments are
|
||||
wrong or the mind cannot be reached.
|
||||
|
||||
## The container lane
|
||||
## The heartbeat
|
||||
|
||||
`tests/wake.rs` holds one `#[ignore]`d test that runs the real `mind-wake`
|
||||
binary against the real `huginn-daemon` and a real store at the brake's root,
|
||||
and asserts that the host a run records is the kernel's hostname. It writes
|
||||
`/etc/gamecult/minds`, so it runs in a container only (`/.dockerenv`), as root,
|
||||
with the daemon built beside the binary. A plain `cargo test` skips it, and so
|
||||
does `cargo mutants`, which therefore reports the three `host()` mutants in
|
||||
`src/bin/mind-wake.rs` as missed. The lane that kills them:
|
||||
|
||||
```sh
|
||||
cargo build -p huginn-daemon -p mind-body --all-targets \
|
||||
&& cargo test -p mind-body --test wake -- --include-ignored
|
||||
```
|
||||
|
||||
On Yggdrasil, run it through the verify stopgap in the Rust image
|
||||
(`ygg-verify.sh <worktree> <rev> rust '<the command above>'`).
|
||||
|
||||
Mutation testing of the waker needs the glob form, because `-f src/wake.rs`
|
||||
finds no mutants in this workspace: `cargo mutants -p mind-body -f
|
||||
"**/src/wake.rs" -f "**/src/bin/mind-wake.rs"`. The second glob reaches
|
||||
`host()`, whose mutants only the container lane kills.
|
||||
`mind-heartbeat` is the one process that decides who wakes and when: acting burns initiative by spend, the classifier's urgency multiplies how fast it regains, and the operator tagging or naming a Persona fills it. `mind-heartbeat timeline` prints its latest record.
|
||||
|
||||
The actuator has its own ignored test, `the_real_bin_finds_her_run_...` in
|
||||
`tests/self_run.rs`: the real `mind-self-run` against the real daemon with a
|
||||
|
|
|
|||
182
crates/mind-body/src/bin/mind-heartbeat.rs
Normal file
182
crates/mind-body/src/bin/mind-heartbeat.rs
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
//! `mind-heartbeat [timeline]`: the swarm's initiative timeline.
|
||||
//!
|
||||
//! Without an argument it runs forever: one `mind_body::heartbeat::poll` every
|
||||
//! `HEARTBEAT_POLL_S` seconds (default 20), printing each outcome; a poll that
|
||||
//! cannot reach its mind, mouth or store prints the error and the loop goes on.
|
||||
//! `timeline` prints the latest trace record and exits. This reads the process's
|
||||
//! arguments, environment and credential files, the things the library never
|
||||
//! reads. Key files are read here and never printed.
|
||||
//!
|
||||
//! Environment: `HEARTBEAT_PERSONAS` (`slug,handle,Public Name,@matrix:id,rudp://<ip>:<port>`
|
||||
//! entries separated by `;`, the last field her own Huginn daemon: each Mind is
|
||||
//! its own daemon), `MIND_PERSONA_OPERATOR` (id[,id]),
|
||||
//! `HEARTBEAT_CLASSIFIER_CONFIG` (the classifier tunables document, default
|
||||
//! `classifier.cc` under `STATE_DIRECTORY`; absent means the standard one), `HEARTBEAT_POLL_S`,
|
||||
//! `BIFROST_CHAT_ENDPOINT` and `BIFROST_CHAT_KEY_FILE`,
|
||||
//! `MIND_CONNECTOR_ENDPOINT`, `MIND_CONNECTOR_KEY_FILE` and
|
||||
//! `HEARTBEAT_CLASSIFIER_MODEL` (the cheapest model), and `STATE_DIRECTORY`
|
||||
//! (`heartbeat.cc` lives there).
|
||||
|
||||
use std::net::SocketAddr;
|
||||
use std::path::Path;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Context, Result, anyhow, bail};
|
||||
use eureka_state::HuginnClient;
|
||||
use mind_body::chat_rudp::{RudpChat, connection_id};
|
||||
use mind_body::connector::{self, KitConnector};
|
||||
use mind_body::heartbeat::{
|
||||
CLASSIFIER_CALLER, Config, FileControl, FileStore, FileTunables, Organs, PersonaId, Seat,
|
||||
Store, poll,
|
||||
};
|
||||
use mind_body::launch::{SystemClock, SystemdLauncher};
|
||||
use mind_body::queue::HuginnMind;
|
||||
|
||||
const TIMEOUT: Duration = Duration::from_secs(15);
|
||||
const CHAT_TIMEOUT: Duration = Duration::from_secs(20);
|
||||
|
||||
fn host() -> String {
|
||||
std::fs::read_to_string("/proc/sys/kernel/hostname")
|
||||
.map(|name| name.trim().to_string())
|
||||
.ok()
|
||||
.filter(|name| !name.is_empty())
|
||||
.unwrap_or_else(|| "unknown".into())
|
||||
}
|
||||
|
||||
#[allow(
|
||||
clippy::disallowed_methods,
|
||||
reason = "the binary reads its own environment, and hands it to the library"
|
||||
)]
|
||||
fn setting(name: &str) -> Option<String> {
|
||||
std::env::var(name).ok().filter(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
fn required(name: &str) -> Result<String> {
|
||||
setting(name).ok_or_else(|| anyhow!("{name} is not set"))
|
||||
}
|
||||
|
||||
fn credential(name: &str) -> Result<String> {
|
||||
std::fs::read_to_string(required(name)?)
|
||||
.with_context(|| format!("the file named by {name} could not be read"))
|
||||
}
|
||||
|
||||
fn rudp(name: &str) -> Result<SocketAddr> {
|
||||
required(name)?
|
||||
.strip_prefix("rudp://")
|
||||
.and_then(|address| address.parse().ok())
|
||||
.ok_or_else(|| anyhow!("{name} is not rudp://<ip>:<port>"))
|
||||
}
|
||||
|
||||
fn whole(name: &str, default: i64) -> Result<i64> {
|
||||
setting(name).map_or(Ok(default), |value| {
|
||||
value
|
||||
.parse()
|
||||
.map_err(|_| anyhow!("{name} is not a whole number"))
|
||||
})
|
||||
}
|
||||
|
||||
fn config() -> Result<Config> {
|
||||
let personas = required("HEARTBEAT_PERSONAS")?
|
||||
.split(';')
|
||||
.filter(|spec| !spec.trim().is_empty())
|
||||
.map(PersonaId::parse)
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
let operators = required("MIND_PERSONA_OPERATOR")?
|
||||
.split(',')
|
||||
.map(|id| id.trim().to_string())
|
||||
.filter(|id| !id.is_empty())
|
||||
.collect();
|
||||
Config::new(personas, operators)
|
||||
}
|
||||
|
||||
fn store() -> Result<FileStore> {
|
||||
let Some(state) = setting("STATE_DIRECTORY") else {
|
||||
bail!("no STATE_DIRECTORY")
|
||||
};
|
||||
Ok(FileStore::new(Path::new(&state).join("heartbeat.cc")))
|
||||
}
|
||||
|
||||
fn timeline() -> Result<()> {
|
||||
match store()?.latest()? {
|
||||
Some(trace) => print!("{trace}"),
|
||||
None => println!("no poll has been recorded"),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn run() -> Result<()> {
|
||||
let config = config()?;
|
||||
let store = store()?;
|
||||
let chat_address = rudp("BIFROST_CHAT_ENDPOINT")?;
|
||||
let chat_id = connection_id(&credential("BIFROST_CHAT_KEY_FILE")?);
|
||||
let models = KitConnector::new(
|
||||
connector::endpoint(&required("MIND_CONNECTOR_ENDPOINT")?)
|
||||
.context("MIND_CONNECTOR_ENDPOINT must be <ip>:<port> or tcp://<ip>:<port>")?,
|
||||
&credential("MIND_CONNECTOR_KEY_FILE")?,
|
||||
required("HEARTBEAT_CLASSIFIER_MODEL")?,
|
||||
CLASSIFIER_CALLER,
|
||||
)?;
|
||||
let poll_s = u64::try_from(whole("HEARTBEAT_POLL_S", 20)?)
|
||||
.map_err(|_| anyhow!("HEARTBEAT_POLL_S is negative"))?
|
||||
.max(1);
|
||||
let minds: Vec<HuginnMind> = config
|
||||
.personas
|
||||
.iter()
|
||||
.map(|who| HuginnMind::new(HuginnClient::new(who.huginn, who.slug.clone(), TIMEOUT)))
|
||||
.collect();
|
||||
let chats: Vec<RudpChat> = config
|
||||
.personas
|
||||
.iter()
|
||||
.map(|who| RudpChat::new(chat_address, chat_id, who.slug.0.clone(), CHAT_TIMEOUT))
|
||||
.collect();
|
||||
let seats: Vec<Seat> = config
|
||||
.personas
|
||||
.iter()
|
||||
.zip(minds.iter().zip(chats.iter()))
|
||||
.map(|(who, (mind, chat))| Seat {
|
||||
who: who.clone(),
|
||||
mind,
|
||||
chat,
|
||||
})
|
||||
.collect();
|
||||
let tunables = FileTunables::new(match setting("HEARTBEAT_CLASSIFIER_CONFIG") {
|
||||
Some(path) => Path::new(&path).to_path_buf(),
|
||||
None => Path::new(&required("STATE_DIRECTORY")?).join("classifier.cc"),
|
||||
});
|
||||
let launcher = SystemdLauncher::new();
|
||||
let host = host();
|
||||
let organs = Organs {
|
||||
seats: &seats,
|
||||
control: &FileControl,
|
||||
models: &models,
|
||||
tunables: &tunables,
|
||||
launcher: &launcher,
|
||||
clock: &SystemClock,
|
||||
host: &host,
|
||||
store: &store,
|
||||
};
|
||||
loop {
|
||||
match poll(&organs, &config) {
|
||||
Ok(outcome) => println!("{outcome}"),
|
||||
Err(error) => eprintln!("mind-heartbeat: {error:#}"),
|
||||
}
|
||||
std::thread::sleep(Duration::from_secs(poll_s));
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(
|
||||
clippy::disallowed_methods,
|
||||
reason = "the binary reads its own arguments"
|
||||
)]
|
||||
fn main() {
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
let result = match args.as_slice() {
|
||||
[] => run(),
|
||||
[word] if word == "timeline" => timeline(),
|
||||
_ => Err(anyhow!("usage: mind-heartbeat [timeline]")),
|
||||
};
|
||||
if let Err(error) = result {
|
||||
eprintln!("mind-heartbeat: {error:#}");
|
||||
std::process::exit(2);
|
||||
}
|
||||
}
|
||||
|
|
@ -26,7 +26,7 @@ use eureka_state::HuginnClient;
|
|||
use mind_body::chat_rudp::{RudpChat, connection_id};
|
||||
use mind_body::cli::instance_and_endpoint;
|
||||
use mind_body::connector::{self, KitConnector};
|
||||
use mind_body::control::{CONTROL_ROOT, FileSource, control_path};
|
||||
use mind_body::control::{CONTROL_ROOT, FileSource, control_path, swarm_path};
|
||||
use mind_body::launch::{SystemClock, SystemdLauncher};
|
||||
use mind_body::persona::{Config, FileTrace, HuginnPersona, Organs, start};
|
||||
use mind_body::queue::HuginnMind;
|
||||
|
|
@ -69,7 +69,7 @@ fn run() -> Result<String> {
|
|||
.and_then(|address| address.parse().ok())
|
||||
.ok_or_else(|| anyhow!("BIFROST_CHAT_ENDPOINT is not rudp://<ip>:<port>"))?;
|
||||
let chat = RudpChat::new(chat_endpoint, connection_id(&credential("BIFROST_CHAT_KEY_FILE")?), instance.0.clone(), CHAT_TIMEOUT);
|
||||
let connector = KitConnector::new(connector::endpoint(&required("MIND_CONNECTOR_ENDPOINT")?)?, &credential("MIND_CONNECTOR_KEY_FILE")?, required("MIND_CONNECTOR_MODEL")?, instance.0.clone())?;
|
||||
let connector = KitConnector::new(connector::endpoint(&required("MIND_CONNECTOR_ENDPOINT")?).context("MIND_CONNECTOR_ENDPOINT must be <ip>:<port> or tcp://<ip>:<port>")?, &credential("MIND_CONNECTOR_KEY_FILE")?, required("MIND_CONNECTOR_MODEL")?, instance.0.clone())?;
|
||||
let source = FileSource::new(control_path(Path::new(CONTROL_ROOT), &instance)?);
|
||||
let mind = HuginnMind::new(HuginnClient::new(address, instance.clone(), TIMEOUT));
|
||||
let persona = HuginnPersona::new(HuginnClient::new(address, instance, TIMEOUT));
|
||||
|
|
@ -77,7 +77,8 @@ fn run() -> Result<String> {
|
|||
let launcher = SystemdLauncher::new();
|
||||
let host = host();
|
||||
let organs = Organs { mind: &mind, launcher: &launcher, clock: &SystemClock, host: &host, chat: &chat, connector: &connector, persona: &persona, trace: &trace };
|
||||
Ok(start(&organs, &source, &config)?.to_string())
|
||||
let swarm = FileSource::new(swarm_path(Path::new(CONTROL_ROOT)));
|
||||
Ok(start(&organs, &source, &swarm, &config)?.to_string())
|
||||
}
|
||||
|
||||
fn main() {
|
||||
|
|
|
|||
|
|
@ -1,51 +0,0 @@
|
|||
//! `mind-wake --instance <slug> [--endpoint rudp://<ip>:<port>]`: one waker
|
||||
//! cycle for one Mind. All behaviour is `mind_body::wake::cycle`; this reads the
|
||||
//! process's arguments, environment and clock, the things the library never
|
||||
//! reads, and prints the outcome. It exits 0 for every outcome and 2 when the
|
||||
//! mind cannot be reached or the arguments are wrong. Without `--endpoint` the
|
||||
//! daemon is `HUGINN_ENDPOINT`.
|
||||
|
||||
use std::path::Path;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::Result;
|
||||
use eureka_state::HuginnClient;
|
||||
use mind_body::cli::instance_and_endpoint;
|
||||
use mind_body::control::{CONTROL_ROOT, FileSource, control_path};
|
||||
use mind_body::launch::{Ports, SystemClock, SystemdLauncher};
|
||||
use mind_body::queue::HuginnMind;
|
||||
use mind_body::wake::cycle;
|
||||
|
||||
/// How long one call to the mind may take.
|
||||
const TIMEOUT: Duration = Duration::from_secs(15);
|
||||
|
||||
const USAGE: &str = "usage: mind-wake --instance <slug> [--endpoint rudp://<ip>:<port>]";
|
||||
|
||||
/// The host a run records: the kernel's name for this machine.
|
||||
fn host() -> String {
|
||||
std::fs::read_to_string("/proc/sys/kernel/hostname").map(|name| name.trim().to_string()).ok().filter(|name| !name.is_empty()).unwrap_or_else(|| "unknown".into())
|
||||
}
|
||||
|
||||
// The binary is the one place that reads the process: its arguments and the
|
||||
// daemon's address. The library reads neither (clippy.toml, `#![forbid]` in lib.rs).
|
||||
#[allow(clippy::disallowed_methods, reason = "the binary reads its own arguments and the daemon's address, and hands them to the library")]
|
||||
fn run() -> Result<String> {
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
let (instance, address) = instance_and_endpoint(&args, std::env::var("HUGINN_ENDPOINT").ok(), USAGE)?;
|
||||
let source = FileSource::new(control_path(Path::new(CONTROL_ROOT), &instance)?);
|
||||
let mind = HuginnMind::new(HuginnClient::new(address, instance, TIMEOUT));
|
||||
let launcher = SystemdLauncher::new();
|
||||
let host = host();
|
||||
let ports = Ports { mind: &mind, launcher: &launcher, clock: &SystemClock, host: &host };
|
||||
Ok(cycle(&ports, &source)?.to_string())
|
||||
}
|
||||
|
||||
fn main() {
|
||||
match run() {
|
||||
Ok(outcome) => println!("{outcome}"),
|
||||
Err(error) => {
|
||||
eprintln!("mind-wake: {error:#}");
|
||||
std::process::exit(2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -22,6 +22,7 @@ pub const PENDING: &str = "gamecult.persona.chat_pending.v1";
|
|||
pub const ACK: &str = "gamecult.persona.chat_ack.v1";
|
||||
pub const SEND_REQUEST: &str = "gamecult.persona.chat_send_request.v1";
|
||||
pub const SEND_RECEIPT: &str = "gamecult.persona.chat_send_receipt.v1";
|
||||
pub const REFUSAL: &str = "gamecult.persona.chat_refusal.v1";
|
||||
|
||||
/// One earlier line of a room, delivered with a message for its context.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
|
|
@ -43,6 +44,10 @@ pub struct ChatDelivery {
|
|||
pub text: String,
|
||||
pub at_unix_ms: u64,
|
||||
pub context: Vec<ContextLine>,
|
||||
/// The Matrix user ids the event's `m.mentions` names, as the mouth carries them.
|
||||
pub mentions: Vec<String>,
|
||||
/// Whether the room is a direct message.
|
||||
pub is_direct: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
|
|
@ -63,6 +68,56 @@ pub struct ChatAck {
|
|||
pub through_seq: u64,
|
||||
}
|
||||
|
||||
/// Why the mouth refused a request, as its typed names (Bifrost
|
||||
/// `tools/persona-chat-documents.mjs` `refusals`).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Refusal {
|
||||
Brake,
|
||||
PersonaMismatch,
|
||||
UnknownRoom,
|
||||
EmptyText,
|
||||
SendFailed,
|
||||
UnknownPersona,
|
||||
}
|
||||
|
||||
impl Refusal {
|
||||
/// The mouth's spelling: a MessagePack string, whatever the codec does with unit variants.
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
Self::Brake => "Brake",
|
||||
Self::PersonaMismatch => "PersonaMismatch",
|
||||
Self::UnknownRoom => "UnknownRoom",
|
||||
Self::EmptyText => "EmptyText",
|
||||
Self::SendFailed => "SendFailed",
|
||||
Self::UnknownPersona => "UnknownPersona",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Serialize for Refusal {
|
||||
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||||
serializer.serialize_str(self.name())
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for Refusal {
|
||||
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
||||
let name = String::deserialize(deserializer)?;
|
||||
[Self::Brake, Self::PersonaMismatch, Self::UnknownRoom, Self::EmptyText, Self::SendFailed, Self::UnknownPersona]
|
||||
.into_iter()
|
||||
.find(|refusal| refusal.name() == name)
|
||||
.ok_or_else(|| serde::de::Error::custom("the mouth named a refusal this side does not know"))
|
||||
}
|
||||
}
|
||||
|
||||
/// The mouth's typed answer to a request that names a Persona it does not
|
||||
/// serve: the pending request, the ack and the send alike.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ChatRefusal {
|
||||
pub persona: String,
|
||||
pub refusal: Refusal,
|
||||
}
|
||||
|
||||
/// A send. `request_id` is the mouth's transaction id, so a repeat of the same
|
||||
/// id is the same send.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
|
|
@ -93,7 +148,8 @@ pub fn decode<T: DeserializeOwned>(bytes: &[u8]) -> Result<T> {
|
|||
|
||||
/// The Persona organ's chat: what is waiting, what to say, what is done.
|
||||
pub trait Chat {
|
||||
/// Deliveries after `after_seq`, oldest first.
|
||||
/// Deliveries after `after_seq`, oldest first. A `chat_refusal` answer is an
|
||||
/// error that names the refusal.
|
||||
fn pending(&self, after_seq: u64) -> Result<Vec<ChatDelivery>>;
|
||||
fn send(&self, request: &ChatSendRequest) -> Result<ChatSendReceipt>;
|
||||
/// Every delivery up to and including `through_seq` is answered; the mouth
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
|
||||
use super::*;
|
||||
|
||||
const REFUSAL_BYTES: &[u8] = include_bytes!("../../tests/fixtures/persona-chat-v1/gamecult.persona.chat_refusal.v1.msgpack");
|
||||
const ACK_BYTES: &[u8] = include_bytes!("../../tests/fixtures/persona-chat-v1/gamecult.persona.chat_ack.v1.msgpack");
|
||||
const PENDING_BYTES: &[u8] = include_bytes!("../../tests/fixtures/persona-chat-v1/gamecult.persona.chat_pending.v1.msgpack");
|
||||
const PENDING_REQUEST_BYTES: &[u8] = include_bytes!("../../tests/fixtures/persona-chat-v1/gamecult.persona.chat_pending_request.v1.msgpack");
|
||||
|
|
@ -35,10 +36,17 @@ fn a_pending_answer_is_the_mouths_bytes_with_its_context() {
|
|||
text: "hello eureka".into(),
|
||||
at_unix_ms: 1_761_700_000_000,
|
||||
context: vec![ContextLine { sender: "@eureka:gamecult.org".into(), text: "earlier".into(), at_unix_ms: 1_761_699_999_000 }],
|
||||
mentions: vec!["@eureka:gamecult.org".into(), "@nova:gamecult.org".into()],
|
||||
is_direct: true,
|
||||
};
|
||||
golden(PENDING_BYTES, &ChatPending { persona: "eureka".into(), deliveries: vec![delivery] });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_refusal_is_the_mouths_bytes_and_a_typed_name() {
|
||||
golden(REFUSAL_BYTES, &ChatRefusal { persona: "ghost".into(), refusal: Refusal::UnknownPersona });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_send_and_its_receipt_are_the_mouths_bytes() {
|
||||
golden(
|
||||
|
|
|
|||
|
|
@ -29,8 +29,8 @@ use serde::de::DeserializeOwned;
|
|||
use sha2::{Digest, Sha256};
|
||||
|
||||
use crate::chat_client::{
|
||||
ACK, Chat, ChatAck, ChatDelivery, ChatPending, ChatPendingRequest, ChatSendReceipt, ChatSendRequest, PENDING, PENDING_REQUEST, SEND_RECEIPT, SEND_REQUEST, decode,
|
||||
encode,
|
||||
ACK, Chat, ChatAck, ChatDelivery, ChatPending, ChatPendingRequest, ChatRefusal, ChatSendReceipt, ChatSendRequest, PENDING, PENDING_REQUEST, REFUSAL, SEND_RECEIPT,
|
||||
SEND_REQUEST, decode, encode,
|
||||
};
|
||||
|
||||
/// The tag every chat document carries; the mouth refuses any other.
|
||||
|
|
@ -95,7 +95,7 @@ impl RudpChat {
|
|||
/// Puts one document and, when `answer` names a kind and record key, waits for
|
||||
/// the document the mouth answers it with. Documents of other kinds or keys
|
||||
/// (a late receipt of an earlier send) are skipped.
|
||||
fn exchange<T: Serialize>(&self, kind: &str, record_key: &str, document: &T, answer: Option<(&str, &str)>) -> Result<Option<Vec<u8>>> {
|
||||
fn exchange<T: Serialize>(&self, kind: &str, record_key: &str, document: &T, answer: Option<(&[&str], &str)>) -> Result<Option<(String, Vec<u8>)>> {
|
||||
let mut slot = self.link.borrow_mut();
|
||||
if slot.is_none() {
|
||||
*slot = Some(self.open()?);
|
||||
|
|
@ -117,7 +117,7 @@ impl RudpChat {
|
|||
};
|
||||
link.transport.send_schema_message(&message)?;
|
||||
let deadline = Instant::now() + self.reply_timeout;
|
||||
let Some((answer_kind, answer_key)) = answer else {
|
||||
let Some((answer_kinds, answer_key)) = answer else {
|
||||
// Nothing is answered; the ack is done when the mouth has the packet.
|
||||
let left = deadline.saturating_duration_since(Instant::now());
|
||||
link.transport.flush_reliable(left)?;
|
||||
|
|
@ -130,15 +130,15 @@ impl RudpChat {
|
|||
link.transport.poll_resends()?;
|
||||
if let Some(CultNetMessage::DocumentPutRaw { document, .. }) = link.transport.receive_schema_message_once()? {
|
||||
let tagged = document.tags.as_deref() == Some(&[TAG.to_string()][..]);
|
||||
if tagged && document.schema_id == answer_kind && document.record_key == answer_key {
|
||||
return Ok(Some(document.payload));
|
||||
if tagged && answer_kinds.contains(&document.schema_id.as_str()) && document.record_key == answer_key {
|
||||
return Ok(Some((document.schema_id, document.payload)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn call<Q: Serialize, A: DeserializeOwned>(&self, kind: &str, key: &str, request: &Q, answer: (&str, &str)) -> Result<A> {
|
||||
let payload = self.exchange(kind, key, request, Some(answer))?.ok_or_else(|| anyhow!("no answer payload"))?;
|
||||
let (_, payload) = self.exchange(kind, key, request, Some((&[answer.0][..], answer.1)))?.ok_or_else(|| anyhow!("no answer payload"))?;
|
||||
decode(&payload)
|
||||
}
|
||||
}
|
||||
|
|
@ -146,7 +146,13 @@ impl RudpChat {
|
|||
impl Chat for RudpChat {
|
||||
fn pending(&self, after_seq: u64) -> Result<Vec<ChatDelivery>> {
|
||||
let request = ChatPendingRequest { persona: self.persona.clone(), after_seq };
|
||||
let pending: ChatPending = self.call(PENDING_REQUEST, &self.persona, &request, (PENDING, &self.persona))?;
|
||||
let kinds: &[&str] = &[PENDING, REFUSAL];
|
||||
let (kind, payload) = self.exchange(PENDING_REQUEST, &self.persona, &request, Some((kinds, self.persona.as_str())))?.ok_or_else(|| anyhow!("no answer payload"))?;
|
||||
if kind == REFUSAL {
|
||||
let refusal: ChatRefusal = decode(&payload)?;
|
||||
bail!("the mouth refused the pending request: {}", refusal.refusal.name());
|
||||
}
|
||||
let pending: ChatPending = decode(&payload)?;
|
||||
if pending.persona != self.persona {
|
||||
bail!("the mouth answered for another Persona");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@
|
|||
//! mind-control [--root <dir>] --instance <slug> show
|
||||
//! mind-control --instance <slug> brake hold (also stops her running units)
|
||||
//! mind-control --instance <slug> brake release
|
||||
//! mind-control --instance <slug> dial set --heat H --base-cooldown-s S --base-run-usd U
|
||||
//! mind-control [--root <dir>] --instance <slug> check
|
||||
//! mind-control [--root <dir>] swarm show
|
||||
//! mind-control swarm brake hold | release
|
||||
|
|
@ -17,7 +16,7 @@
|
|||
//! The `swarm` verbs write `<root>/swarm.cc` and stop no unit: the swarm's
|
||||
//! brake and heat 0 are read by the organs that act on the swarm's behalf.
|
||||
//!
|
||||
//! `check` exits 0 only when the brake is released and the dial in bounds; a
|
||||
//! `check` exits 0 only when the instance brake and the swarm envelope are released; a
|
||||
//! systemd unit runs it as its `ExecCondition`. It writes nothing.
|
||||
//!
|
||||
//! `brake hold` writes the brake held and then, whatever the write's outcome,
|
||||
|
|
@ -35,12 +34,12 @@ use eureka_pipeline::{RunTurn, Slug};
|
|||
use rust_decimal::Decimal;
|
||||
|
||||
use crate::control::{
|
||||
BurnRate, ControlWriter, Effective, FileSource, Share, SwarmDial, SwarmWriter, control_path, derive, derive_swarm, load_state, load_swarm_state, swarm_path,
|
||||
ControlWriter, Effective, FileSource, Share, SwarmDial, SwarmWriter, control_path, derive, derive_swarm, load_state, load_swarm_state, swarm_path,
|
||||
};
|
||||
use crate::launch::unit;
|
||||
|
||||
const USAGE: &str =
|
||||
"usage: mind-control [--root <dir>] --instance <slug> show | check | brake hold | brake release | dial set --heat H --base-cooldown-s S --base-run-usd U | mind-control [--root <dir>] swarm show | brake hold | brake release | dial set --heat H --base-rate R --base-run U --k K | share set <instance> <weight>";
|
||||
"usage: mind-control [--root <dir>] --instance <slug> show | check | brake hold | brake release | mind-control [--root <dir>] swarm show | brake hold | brake release | dial set --heat H --base-rate R --base-run U --k K | share set <instance> <weight>";
|
||||
|
||||
/// The arguments every organ binary shares: `--instance <slug>` and an optional
|
||||
/// `--endpoint rudp://<ip>:<port>`, which `env_endpoint` (the binary's reading of
|
||||
|
|
@ -108,7 +107,7 @@ pub fn run(
|
|||
let words: Vec<&str> = command.iter().map(String::as_str).collect();
|
||||
match words.as_slice() {
|
||||
["show"] => show(root, &instance, out),
|
||||
["check"] => check(&path, &instance, out),
|
||||
["check"] => check(root, &path, &instance, out),
|
||||
["brake", "hold"] => {
|
||||
let written = ControlWriter::open(&path).and_then(|mut writer| writer.set_brake(false, now, set_by));
|
||||
let stopped = stop_units(&instance);
|
||||
|
|
@ -123,10 +122,6 @@ pub fn run(
|
|||
}
|
||||
}
|
||||
["brake", "release"] => ControlWriter::open(&path)?.set_brake(true, now, set_by),
|
||||
["dial", "set", flags @ ..] => {
|
||||
let dial = parse_dial(flags, now, set_by)?;
|
||||
ControlWriter::open(&path)?.set_dial(dial)
|
||||
}
|
||||
_ => bail!("{USAGE}"),
|
||||
}
|
||||
}
|
||||
|
|
@ -212,30 +207,6 @@ fn show_swarm(path: &Path, out: &mut impl Write) -> Result<()> {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_dial(flags: &[&str], now: DateTime<Utc>, set_by: &str) -> Result<BurnRate> {
|
||||
let (mut heat, mut cooldown, mut run_usd) = (None, None, None);
|
||||
let mut rest = flags.iter();
|
||||
while let Some(flag) = rest.next() {
|
||||
let value = rest.next().ok_or_else(|| anyhow!("{flag} needs a value"))?;
|
||||
let slot = match *flag {
|
||||
"--heat" => &mut heat,
|
||||
"--base-cooldown-s" => &mut cooldown,
|
||||
"--base-run-usd" => &mut run_usd,
|
||||
other => bail!("unknown flag {other:?}"),
|
||||
};
|
||||
if slot.replace(*value).is_some() {
|
||||
bail!("{flag} given twice");
|
||||
}
|
||||
}
|
||||
Ok(BurnRate {
|
||||
heat: Decimal::from_str(need(heat, "--heat")?).context("--heat is not a decimal")?,
|
||||
base_cooldown_s: need(cooldown, "--base-cooldown-s")?.parse().context("--base-cooldown-s is not a whole number of seconds")?,
|
||||
base_run_usd: Decimal::from_str(need(run_usd, "--base-run-usd")?).context("--base-run-usd is not a decimal")?,
|
||||
set_at: now,
|
||||
set_by: set_by.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
fn need<'a>(value: Option<&'a str>, name: &str) -> Result<&'a str> {
|
||||
value.ok_or_else(|| anyhow!("{name} is required"))
|
||||
}
|
||||
|
|
@ -245,8 +216,8 @@ fn need<'a>(value: Option<&'a str>, name: &str) -> Result<&'a str> {
|
|||
/// read through `FileSource`: it opens no writer and creates no file or
|
||||
/// directory. Any other reading is an error, which `main` turns into exit 2,
|
||||
/// and systemd's `ExecCondition` skips the unit on 1-254.
|
||||
fn check(path: &Path, instance: &Slug, out: &mut impl Write) -> Result<()> {
|
||||
let effective = derive(&FileSource::new(path), instance);
|
||||
fn check(root: &Path, path: &Path, instance: &Slug, out: &mut impl Write) -> Result<()> {
|
||||
let effective = derive(&FileSource::new(path), &FileSource::new(swarm_path(root)), instance);
|
||||
writeln!(out, "effective: {effective:?}")?;
|
||||
match effective {
|
||||
Effective::Released(_) => Ok(()),
|
||||
|
|
@ -269,21 +240,9 @@ fn show(root: &Path, instance: &Slug, out: &mut impl Write) -> Result<()> {
|
|||
)?,
|
||||
None => writeln!(out, "brake: absent")?,
|
||||
}
|
||||
match &state.dial {
|
||||
Some(dial) => writeln!(
|
||||
out,
|
||||
"dial: heat {} base_cooldown_s {} base_run_usd {} (set_at {}, set_by {})",
|
||||
dial.heat,
|
||||
dial.base_cooldown_s,
|
||||
dial.base_run_usd,
|
||||
dial.set_at.to_rfc3339(),
|
||||
dial.set_by
|
||||
)?,
|
||||
None => writeln!(out, "dial: absent")?,
|
||||
}
|
||||
}
|
||||
Err(error) => writeln!(out, "store: undecodable ({error:#})")?,
|
||||
}
|
||||
writeln!(out, "effective: {:?}", derive(&source, instance))?;
|
||||
writeln!(out, "effective: {:?}", derive(&source, &FileSource::new(swarm_path(root)), instance))?;
|
||||
Ok(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,9 @@
|
|||
//! `ProviderRequest` for the instance as caller, its output constrained to the
|
||||
//! turn's schema, and its answer the assistant text of a `Completed` result; a
|
||||
//! refusal, a failure or a stop other than the end of the turn is an error. The
|
||||
//! readout is the kit's spend readout for the same caller.
|
||||
//! readout is the kit's spend readout asked as this client's own caller: the
|
||||
//! service answers every caller's row to any admitted asker, and the asker is
|
||||
//! decrypted with its own key, so nobody asks as another caller.
|
||||
//!
|
||||
//! The connector reads its connection key as the file's text without the
|
||||
//! trailing line break; the same rule applies here (`key`). The endpoint is the
|
||||
|
|
@ -54,7 +56,7 @@ pub struct KitConnector {
|
|||
|
||||
impl KitConnector {
|
||||
/// `key_text` is the connection key file's text (`key` is applied here);
|
||||
/// `caller` is the instance the key belongs to, whose spend the readout asks for.
|
||||
/// `caller` is the caller id the key belongs to: the asker of every call.
|
||||
pub fn new(endpoint: SocketAddr, key_text: &str, model: impl Into<String>, caller: impl Into<String>) -> Result<Self> {
|
||||
let client = ConnectorClient::new(endpoint, key(key_text), MAX_FRAME_BYTES, Some(RESPONSE_TIMEOUT)).map_err(|_| anyhow!("the connector client could not be configured"))?;
|
||||
Ok(Self { client, model: model.into(), caller: caller.into() })
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
//! The operator's brake and burn-rate dial, and the one reader of them.
|
||||
//! The operator's brake and the swarm's dial, and the one reader of them.
|
||||
//!
|
||||
//! `Released` carries a `Grant`, the only value that hands a run cap to the
|
||||
//! launch: it cannot be built outside this module, so a cap exists only as the
|
||||
//! reading of a released brake and an in-bounds dial. The Grant names the
|
||||
//! reading of a released instance brake and a released swarm envelope. The Grant names the
|
||||
//! instance whose store was read. The store is at a fixed place, `CONTROL_ROOT`,
|
||||
//! and the one reader takes the instance only: no function, argument, variable
|
||||
//! or flag lets a caller choose a root, a path or a source, so a Grant is the
|
||||
|
|
@ -13,12 +13,13 @@
|
|||
//! `/etc/gamecult/minds/<instance>/control.cc`, root-owned and world-readable.
|
||||
//! The operator's CLI replaces a document whole; every organ that acts calls
|
||||
//! `read_effective` before the consequence and takes its answer as given. The
|
||||
//! cadence and the run cap are derived here and nowhere else.
|
||||
//! run cap is derived here and nowhere else; who wakes and when is the
|
||||
//! heartbeat's.
|
||||
//!
|
||||
//! Fail closed: the reader's only route to `Released` is a decodable store
|
||||
//! holding a released brake and an in-bounds dial. Anything else (no file, no
|
||||
//! brake, a held brake, no dial, a dial out of bounds, bytes that do not
|
||||
//! decode, a file that cannot be read) is `Held`, with the reason.
|
||||
//! Fail closed: the reader's only route to `Released` is a decodable instance
|
||||
//! store holding a released brake and a released swarm envelope. Anything else
|
||||
//! (no file, no brake, a held brake, a held, absent or out-of-bounds swarm dial,
|
||||
//! bytes that do not decode, a file that cannot be read) is `Held`, with the reason.
|
||||
//!
|
||||
//! The reader opens the file read-only through CultCache's read-only snapshot
|
||||
//! door: it takes no lock and creates no sibling file, so the units she runs as
|
||||
|
|
@ -35,14 +36,12 @@
|
|||
//! only way to narrow the envelope into a `Grant`, and it never widens one.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use chrono::{DateTime, Utc};
|
||||
use cultcache_rs::{CultCache, CultCacheEnvelope, DatabaseEntry, SingleFileMessagePackBackingStore};
|
||||
use eureka_pipeline::Slug;
|
||||
use rust_decimal::Decimal;
|
||||
use rust_decimal::prelude::ToPrimitive;
|
||||
|
||||
/// Where every instance's control directory lives. The reader reads only here;
|
||||
/// the operator's CLI takes its own `--root` for writing and display.
|
||||
|
|
@ -79,50 +78,21 @@ pub struct Brake {
|
|||
pub set_by: String,
|
||||
}
|
||||
|
||||
/// The burn-rate dial: `heat` scales how active she is; the base values say
|
||||
/// what heat 1.0 means. Cadence and run cap are derived from it, never stored.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)]
|
||||
#[cultcache(type = "eureka.control.burn_rate.v1", schema = "ControlBurnRate")]
|
||||
pub struct BurnRate {
|
||||
#[cultcache(key = 0)]
|
||||
pub heat: Decimal,
|
||||
#[cultcache(key = 1)]
|
||||
pub base_cooldown_s: u32,
|
||||
#[cultcache(key = 2)]
|
||||
pub base_run_usd: Decimal,
|
||||
#[cultcache(key = 3)]
|
||||
pub set_at: DateTime<Utc>,
|
||||
#[cultcache(key = 4)]
|
||||
pub set_by: String,
|
||||
}
|
||||
|
||||
/// Heat's inclusive bounds.
|
||||
pub fn heat_min() -> Decimal {
|
||||
Decimal::new(5, 2)
|
||||
}
|
||||
/// Heat's inclusive upper bound.
|
||||
pub fn heat_max() -> Decimal {
|
||||
Decimal::new(20, 1)
|
||||
}
|
||||
|
||||
impl BurnRate {
|
||||
/// The one bounds rule: the reader holds on it and the CLI refuses on it.
|
||||
pub fn in_bounds(&self) -> bool {
|
||||
self.heat >= heat_min() && self.heat <= heat_max() && self.base_cooldown_s > 0 && self.base_run_usd > Decimal::ZERO
|
||||
}
|
||||
|
||||
/// Wake cadence: `base_cooldown_s / heat`, to the nanosecond.
|
||||
fn cadence(&self) -> Option<Duration> {
|
||||
let seconds = Decimal::from(self.base_cooldown_s).checked_div(self.heat)?;
|
||||
let whole = seconds.trunc();
|
||||
let nanos = ((seconds - whole) * Decimal::from(1_000_000_000u32)).round();
|
||||
Some(Duration::new(whole.to_u64()?, nanos.to_u32()?))
|
||||
}
|
||||
|
||||
/// Per-run cap: `base_run_usd x heat`.
|
||||
fn run_cap_usd(&self) -> Option<Decimal> {
|
||||
self.base_run_usd.checked_mul(self.heat)
|
||||
}
|
||||
/// Calibration ceilings on the swarm dial's base values, so a typo (a
|
||||
/// `--base-run 10000`) is refused where it is written and held where it is read.
|
||||
/// They are guards, not policy: the operator raises them here.
|
||||
pub fn base_rate_max() -> Decimal {
|
||||
Decimal::new(100, 0)
|
||||
}
|
||||
pub fn base_run_max() -> Decimal {
|
||||
Decimal::new(50, 0)
|
||||
}
|
||||
pub const K_MAX: u32 = 16;
|
||||
|
||||
/// Why the reader said held.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
|
|
@ -145,8 +115,8 @@ pub enum Effective {
|
|||
Released(Grant),
|
||||
}
|
||||
|
||||
/// The proof that the brake was released and the dial in bounds when read, in
|
||||
/// the root-owned store of `instance`. Its fields are private and only
|
||||
/// The proof that the instance brake and the swarm envelope were released when
|
||||
/// read, in the root-owned stores of `instance`. Its fields are private and only
|
||||
/// `read_effective` makes one; `open_and_launch` takes it by value and refuses
|
||||
/// one read for another instance, so one reading opens at most one run, of the
|
||||
/// instance it was read for.
|
||||
|
|
@ -159,7 +129,7 @@ pub enum Effective {
|
|||
/// ```compile_fail,E0451
|
||||
/// use mind_body::control::Grant;
|
||||
/// // a struct literal
|
||||
/// let _ = Grant { instance: eureka_pipeline::Slug("eureka".into()), cadence: std::time::Duration::ZERO, run_cap_usd: rust_decimal::Decimal::ONE };
|
||||
/// let _ = Grant { instance: eureka_pipeline::Slug("eureka".into()), run_cap_usd: rust_decimal::Decimal::ONE };
|
||||
/// ```
|
||||
///
|
||||
/// ```compile_fail,E0277
|
||||
|
|
@ -183,12 +153,11 @@ pub enum Effective {
|
|||
/// ```compile_fail,E0277
|
||||
/// // not From its parts
|
||||
/// use mind_body::control::Grant;
|
||||
/// let _ = Grant::from((eureka_pipeline::Slug("eureka".into()), std::time::Duration::ZERO, rust_decimal::Decimal::ONE));
|
||||
/// let _ = Grant::from((eureka_pipeline::Slug("eureka".into()), rust_decimal::Decimal::ONE));
|
||||
/// ```
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub struct Grant {
|
||||
instance: Slug,
|
||||
cadence: Duration,
|
||||
run_cap_usd: Decimal,
|
||||
}
|
||||
|
||||
|
|
@ -198,11 +167,6 @@ impl Grant {
|
|||
&self.instance
|
||||
}
|
||||
|
||||
/// How often she may wake.
|
||||
pub fn cadence(&self) -> Duration {
|
||||
self.cadence
|
||||
}
|
||||
|
||||
/// The most one run may spend.
|
||||
pub fn run_cap_usd(&self) -> Decimal {
|
||||
self.run_cap_usd
|
||||
|
|
@ -211,22 +175,22 @@ impl Grant {
|
|||
/// A Grant for a launch test that has no store. It names its instance like
|
||||
/// any other, so the launch's instance check judges it.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn for_test(instance: Slug, cadence: Duration, run_cap_usd: Decimal) -> Self {
|
||||
Self { instance, cadence, run_cap_usd }
|
||||
pub(crate) fn for_test(instance: Slug, run_cap_usd: Decimal) -> Self {
|
||||
Self { instance, run_cap_usd }
|
||||
}
|
||||
}
|
||||
|
||||
/// The store's contents, decoded and not yet judged.
|
||||
/// The instance store's contents, decoded and not yet judged.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Default)]
|
||||
pub struct ControlState {
|
||||
pub brake: Option<Brake>,
|
||||
pub dial: Option<BurnRate>,
|
||||
}
|
||||
|
||||
impl ControlState {
|
||||
/// The one derivation. Order: brake first, then dial; each failing rung is
|
||||
/// its own reason.
|
||||
pub(crate) fn effective(&self, instance: &Slug) -> Effective {
|
||||
/// The one derivation. Order: the instance brake, then the swarm envelope
|
||||
/// (its own order is `SwarmState::effective`'s); each failing rung is its
|
||||
/// own reason. The Grant's cap is the envelope's run cap.
|
||||
pub(crate) fn effective(&self, instance: &Slug, swarm: SwarmEffective) -> Effective {
|
||||
let held = |reason| Effective::Held { reason };
|
||||
let Some(brake) = &self.brake else {
|
||||
return held(HeldReason::BrakeAbsent);
|
||||
|
|
@ -234,15 +198,9 @@ impl ControlState {
|
|||
if !brake.released {
|
||||
return held(HeldReason::BrakeHeld);
|
||||
}
|
||||
let Some(dial) = &self.dial else {
|
||||
return held(HeldReason::DialAbsent);
|
||||
};
|
||||
if !dial.in_bounds() {
|
||||
return held(HeldReason::DialOutOfBounds);
|
||||
}
|
||||
match (dial.cadence(), dial.run_cap_usd()) {
|
||||
(Some(cadence), Some(run_cap_usd)) => Effective::Released(Grant { instance: instance.clone(), cadence, run_cap_usd }),
|
||||
_ => held(HeldReason::DialOutOfBounds),
|
||||
match swarm {
|
||||
SwarmEffective::Held(reason) => held(reason),
|
||||
SwarmEffective::Released(envelope) => Effective::Released(Grant { instance: instance.clone(), run_cap_usd: envelope.run_cap_usd }),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -273,7 +231,6 @@ impl ControlSource for FileSource {
|
|||
fn registered_cache() -> Result<CultCache> {
|
||||
let mut cache = CultCache::new();
|
||||
cache.register_entry_type::<Brake>()?;
|
||||
cache.register_entry_type::<BurnRate>()?;
|
||||
Ok(cache)
|
||||
}
|
||||
|
||||
|
|
@ -285,7 +242,7 @@ fn swarm_cache() -> Result<CultCache> {
|
|||
Ok(cache)
|
||||
}
|
||||
|
||||
/// Decodes the store into its two documents. Any envelope the cache does not
|
||||
/// Decodes the instance store into its brake. Any envelope the cache does not
|
||||
/// know, or any payload that does not decode, is an error: a store the reader
|
||||
/// cannot fully read is not one it may act on.
|
||||
pub fn load_state(source: &impl ControlSource) -> Result<ControlState> {
|
||||
|
|
@ -293,13 +250,13 @@ pub fn load_state(source: &impl ControlSource) -> Result<ControlState> {
|
|||
for envelope in source.snapshot().context("control store cannot be read")? {
|
||||
cache.put_raw_envelope(envelope)?;
|
||||
}
|
||||
Ok(ControlState { brake: cache.get::<Brake>(BRAKE_KEY)?, dial: cache.get::<BurnRate>(DIAL_KEY)? })
|
||||
Ok(ControlState { brake: cache.get::<Brake>(BRAKE_KEY)? })
|
||||
}
|
||||
|
||||
/// The only reader every organ uses (the waker now; the permit issuer and the
|
||||
/// Persona organ later). The store is `instance`'s own at
|
||||
/// `CONTROL_ROOT/<instance>/control.cc`; the caller supplies neither a root, a
|
||||
/// path nor a source. An instance that is not a slug reads as held.
|
||||
/// The only reader every acting organ uses (the heartbeat, the Persona organ).
|
||||
/// The stores are `instance`'s own brake at `CONTROL_ROOT/<instance>/control.cc`
|
||||
/// and the swarm's at `CONTROL_ROOT/swarm.cc`; the caller supplies neither a
|
||||
/// root, a path nor a source. An instance that is not a slug reads as held.
|
||||
///
|
||||
/// Nothing outside this crate can name another place:
|
||||
///
|
||||
|
|
@ -326,8 +283,9 @@ pub fn load_state(source: &impl ControlSource) -> Result<ControlState> {
|
|||
/// let _ = mind_body::control::read_effective(&Slug("eureka".into()));
|
||||
/// ```
|
||||
pub fn read_effective(instance: &Slug) -> Effective {
|
||||
match control_path(&control_root(), instance) {
|
||||
Ok(path) => derive(&FileSource::new(path), instance),
|
||||
let root = control_root();
|
||||
match control_path(&root, instance) {
|
||||
Ok(path) => derive(&FileSource::new(path), &FileSource::new(swarm_path(&root)), instance),
|
||||
Err(_) => Effective::Held { reason: HeldReason::Undecodable },
|
||||
}
|
||||
}
|
||||
|
|
@ -358,12 +316,13 @@ pub(crate) fn with_root<T>(root: &Path, body: impl FnOnce() -> T) -> T {
|
|||
result
|
||||
}
|
||||
|
||||
/// The derivation over any source. The reader and the CLI's `show` (display
|
||||
/// only: its output is text) are its callers; nothing public returns its result
|
||||
/// for a source of the caller's.
|
||||
pub(crate) fn derive(source: &impl ControlSource, instance: &Slug) -> Effective {
|
||||
/// The derivation over any two sources, the instance's and the swarm's. The
|
||||
/// reader, the organs' gates and the CLI's `show` (display only: its output is
|
||||
/// text) are its callers; nothing public returns its result for a source of the
|
||||
/// caller's.
|
||||
pub(crate) fn derive(source: &impl ControlSource, swarm: &impl ControlSource, instance: &Slug) -> Effective {
|
||||
match load_state(source) {
|
||||
Ok(state) => state.effective(instance),
|
||||
Ok(state) => state.effective(instance, derive_swarm(swarm)),
|
||||
Err(_) => Effective::Held { reason: HeldReason::Undecodable },
|
||||
}
|
||||
}
|
||||
|
|
@ -499,17 +458,6 @@ impl ControlWriter {
|
|||
self.cache.put(BRAKE_KEY, &brake)?;
|
||||
self.settle()
|
||||
}
|
||||
|
||||
/// Refuses an out-of-bounds dial before anything is written, so the store
|
||||
/// never holds one that the CLI put there.
|
||||
pub fn set_dial(&mut self, dial: BurnRate) -> Result<()> {
|
||||
if !dial.in_bounds() {
|
||||
anyhow::bail!("dial refused: heat must be within {}..={}, base_cooldown_s above 0 and base_run_usd above 0", heat_min(), heat_max());
|
||||
}
|
||||
self.prepare()?;
|
||||
self.cache.put(DIAL_KEY, &dial)?;
|
||||
self.settle()
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -542,7 +490,12 @@ impl SwarmDial {
|
|||
/// CLI refuses on it. Heat 0 is in bounds (it is the stop); the reader
|
||||
/// answers it as held.
|
||||
pub fn in_bounds(&self) -> bool {
|
||||
self.heat >= Decimal::ZERO && self.heat <= heat_max() && self.base_rate_usd_per_h > Decimal::ZERO && self.base_run_usd > Decimal::ZERO && self.k >= 1
|
||||
self.heat >= Decimal::ZERO && self.heat <= heat_max() && self.base_rate_usd_per_h > Decimal::ZERO
|
||||
&& self.base_rate_usd_per_h <= base_rate_max()
|
||||
&& self.base_run_usd > Decimal::ZERO
|
||||
&& self.base_run_usd <= base_run_max()
|
||||
&& self.k >= 1
|
||||
&& self.k <= K_MAX
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -614,16 +567,16 @@ impl Envelope {
|
|||
/// Narrows this envelope and the instance's own reading into the Grant for
|
||||
/// one run of `budget_usd`. It is the only new way to build a Grant and it
|
||||
/// never widens: `None` unless the instance's own reading is released for
|
||||
/// this instance and `0 < budget_usd` is within both the swarm's run cap and
|
||||
/// the instance's own. The Grant's cap is the budget.
|
||||
/// this instance and `0 < budget_usd` is within the swarm's run cap. The
|
||||
/// Grant's cap is the budget.
|
||||
pub fn grant_for(&self, instance: &Slug, instance_effective: &Effective, budget_usd: Decimal) -> Option<Grant> {
|
||||
let Effective::Released(own) = instance_effective else {
|
||||
return None;
|
||||
};
|
||||
if own.instance != *instance || budget_usd <= Decimal::ZERO || budget_usd > self.run_cap_usd || budget_usd > own.run_cap_usd {
|
||||
if own.instance != *instance || budget_usd <= Decimal::ZERO || budget_usd > self.run_cap_usd {
|
||||
return None;
|
||||
}
|
||||
Some(Grant { instance: instance.clone(), cadence: own.cadence, run_cap_usd: budget_usd })
|
||||
Some(Grant { instance: instance.clone(), run_cap_usd: budget_usd })
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -723,8 +676,11 @@ impl SwarmWriter {
|
|||
pub fn set_dial(&mut self, dial: SwarmDial) -> Result<()> {
|
||||
if !dial.in_bounds() {
|
||||
anyhow::bail!(
|
||||
"swarm dial refused: heat must be within 0..={}, base_rate_usd_per_h and base_run_usd above 0, k at least 1",
|
||||
heat_max()
|
||||
"swarm dial refused: heat must be within 0..={}, base_rate_usd_per_h in (0, {}], base_run_usd in (0, {}], k in 1..={}",
|
||||
heat_max(),
|
||||
base_rate_max(),
|
||||
base_run_max(),
|
||||
K_MAX
|
||||
);
|
||||
}
|
||||
prepare_store(&self.path)?;
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@
|
|||
//! touches `/etc`. They run as root in the verify container like the writer's.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::Duration;
|
||||
|
||||
use chrono::{DateTime, TimeZone, Utc};
|
||||
use cultcache_rs::{CultCache, DatabaseEntry, SingleFileMessagePackBackingStore};
|
||||
|
|
@ -22,8 +21,16 @@ fn at(seconds: i64) -> DateTime<Utc> {
|
|||
Utc.timestamp_opt(1_800_000_000 + seconds, 0).unwrap()
|
||||
}
|
||||
|
||||
fn dial(heat: &str, cooldown: u32, run_usd: &str) -> BurnRate {
|
||||
BurnRate { heat: dec(heat), base_cooldown_s: cooldown, base_run_usd: dec(run_usd), set_at: at(0), set_by: "op".into() }
|
||||
/// A swarm dial at `heat` whose per-run cap before heat is `run_usd`.
|
||||
fn dial(heat: &str, _unused: u32, run_usd: &str) -> SwarmDial {
|
||||
SwarmDial { heat: dec(heat), base_rate_usd_per_h: dec("5"), base_run_usd: dec(run_usd), k: 2, set_at: at(0), set_by: "op".into() }
|
||||
}
|
||||
|
||||
/// The swarm store at `<root>/swarm.cc`: brake released and the given dial.
|
||||
fn release_swarm(root: &Path, dial: SwarmDial) {
|
||||
let mut writer = SwarmWriter::open(swarm_path(root)).unwrap();
|
||||
writer.set_brake(true, at(0), "op").unwrap();
|
||||
writer.set_dial(dial).unwrap();
|
||||
}
|
||||
|
||||
/// The store of the instance `eureka` under the root `dir`.
|
||||
|
|
@ -35,12 +42,11 @@ fn held(reason: HeldReason) -> Effective {
|
|||
Effective::Held { reason }
|
||||
}
|
||||
|
||||
/// A written store: brake released and the given dial, through the real writer.
|
||||
fn released_store(dir: &Path, dial: BurnRate) -> PathBuf {
|
||||
/// A written instance store with its brake released, and a released swarm.
|
||||
fn released_store(dir: &Path, dial: SwarmDial) -> PathBuf {
|
||||
let path = store_path(dir);
|
||||
let mut writer = ControlWriter::open(&path).unwrap();
|
||||
writer.set_brake(true, at(0), "op").unwrap();
|
||||
writer.set_dial(dial).unwrap();
|
||||
ControlWriter::open(&path).unwrap().set_brake(true, at(0), "op").unwrap();
|
||||
release_swarm(dir, dial);
|
||||
path
|
||||
}
|
||||
|
||||
|
|
@ -52,17 +58,6 @@ fn effective(path: &Path) -> Effective {
|
|||
with_root(directory.parent().unwrap(), || read_effective(&instance))
|
||||
}
|
||||
|
||||
/// A dial written around the writer's bounds check, so the reader's own check
|
||||
/// is what is under test.
|
||||
fn write_unchecked_dial(path: &Path, dial: &BurnRate) {
|
||||
let mut cache = CultCache::new();
|
||||
cache.register_entry_type::<BurnRate>().unwrap();
|
||||
cache.register_entry_type::<Brake>().unwrap();
|
||||
cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(path)).unwrap();
|
||||
cache.pull_all_backing_stores().unwrap();
|
||||
cache.put(DIAL_KEY, dial).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_source_that_fails_reads_as_held() {
|
||||
struct Failing;
|
||||
|
|
@ -71,7 +66,7 @@ fn a_source_that_fails_reads_as_held() {
|
|||
Err(anyhow::anyhow!("disk gone"))
|
||||
}
|
||||
}
|
||||
assert_eq!(derive(&Failing, &Slug("eureka".into())), Effective::Held { reason: HeldReason::Undecodable });
|
||||
assert_eq!(derive(&Failing, &Failing, &Slug("eureka".into())), Effective::Held { reason: HeldReason::Undecodable });
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -82,10 +77,10 @@ fn an_absent_store_reads_as_held() {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn an_absent_brake_reads_as_held_even_with_a_dial() {
|
||||
fn an_absent_brake_reads_as_held_even_with_a_released_swarm() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = store_path(dir.path());
|
||||
ControlWriter::open(&path).unwrap().set_dial(dial("1", 3600, "1")).unwrap();
|
||||
release_swarm(dir.path(), dial("1", 0, "1"));
|
||||
assert_eq!(effective(&path), held(HeldReason::BrakeAbsent));
|
||||
}
|
||||
|
||||
|
|
@ -163,78 +158,6 @@ fn a_decode_failure_outranks_a_held_brake() {
|
|||
assert_eq!(effective(&path), held(HeldReason::Undecodable));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_released_brake_with_no_dial_reads_held_dial_absent() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = store_path(dir.path());
|
||||
ControlWriter::open(&path).unwrap().set_brake(true, at(0), "op").unwrap();
|
||||
assert_eq!(effective(&path), held(HeldReason::DialAbsent));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dial_bounds_are_inclusive_at_both_ends() {
|
||||
for (heat, expected_in) in [("0.05", true), ("2.0", true), ("0.0499", false), ("2.0001", false), ("0", false), ("-1", false), ("3", false)] {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = released_store_unchecked(dir.path(), dial(heat, 3600, "1"));
|
||||
let got = effective(&path);
|
||||
if expected_in {
|
||||
assert!(matches!(got, Effective::Released(_)), "heat {heat}: {got:?}");
|
||||
} else {
|
||||
assert_eq!(got, held(HeldReason::DialOutOfBounds), "heat {heat}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_zero_base_cooldown_or_a_non_positive_run_cost_is_out_of_bounds() {
|
||||
for bad in [dial("1", 0, "1"), dial("1", 3600, "0"), dial("1", 3600, "-0.5")] {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = released_store_unchecked(dir.path(), bad.clone());
|
||||
assert_eq!(effective(&path), held(HeldReason::DialOutOfBounds), "{bad:?}");
|
||||
}
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let smallest = released_store_unchecked(dir.path(), dial("1", 1, "0.000001"));
|
||||
assert!(matches!(effective(&smallest), Effective::Released(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_check_gate_refuses_a_released_brake_over_an_out_of_bounds_dial() {
|
||||
// The writers refuse such a dial, so the store is built unchecked.
|
||||
for bad in [dial("3", 3600, "1"), dial("1", 0, "1"), dial("1", 3600, "0")] {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
released_store_unchecked(dir.path(), bad.clone());
|
||||
let args: Vec<String> = ["--instance", "eureka", "check"].iter().map(|a| a.to_string()).collect();
|
||||
let mut out = Vec::new();
|
||||
let result = crate::cli::run(&args, dir.path(), "op", at(1), &mut out, &mut |_| Ok(()));
|
||||
assert!(format!("{:#}", result.unwrap_err()).contains("DialOutOfBounds"), "{bad:?}");
|
||||
}
|
||||
}
|
||||
|
||||
fn released_store_unchecked(dir: &Path, bad: BurnRate) -> PathBuf {
|
||||
let path = store_path(dir);
|
||||
ControlWriter::open(&path).unwrap().set_brake(true, at(0), "op").unwrap();
|
||||
write_unchecked_dial(&path, &bad);
|
||||
path
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cadence_and_run_cap_are_the_dial_divided_and_multiplied_by_heat() {
|
||||
for (heat, cooldown, run_usd, cadence, cap) in [
|
||||
("0.5", 3600u32, "5", Duration::from_secs(7200), dec("2.5")),
|
||||
("2.0", 3600, "5", Duration::from_secs(1800), dec("10.0")),
|
||||
("1", 600, "3", Duration::from_secs(600), dec("3")),
|
||||
("0.05", 90, "4", Duration::from_secs(1800), dec("0.20")),
|
||||
("1.5", 10, "2", Duration::new(6, 666_666_667), dec("3.0")),
|
||||
("2.0", 1, "0.25", Duration::from_millis(500), dec("0.500")),
|
||||
] {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = released_store(dir.path(), dial(heat, cooldown, run_usd));
|
||||
let Effective::Released(grant) = effective(&path) else { panic!("heat {heat} cooldown {cooldown} run {run_usd}: not released") };
|
||||
assert_eq!(grant.cadence(), cadence, "heat {heat} cooldown {cooldown} run {run_usd}");
|
||||
assert_eq!(grant.run_cap_usd(), cap, "heat {heat} cooldown {cooldown} run {run_usd}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_operators_last_write_is_what_is_in_force_and_provenance_round_trips() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
|
@ -246,8 +169,6 @@ fn the_operators_last_write_is_what_is_in_force_and_provenance_round_trips() {
|
|||
let state = load_state(&FileSource::new(&path)).unwrap();
|
||||
let brake = state.brake.unwrap();
|
||||
assert_eq!((brake.released, brake.set_at, brake.set_by.as_str()), (false, at(60), "alice"));
|
||||
// The dial survives the brake write untouched.
|
||||
assert_eq!(state.dial.unwrap().set_by, "op");
|
||||
|
||||
ControlWriter::open(&path).unwrap().set_brake(true, at(120), "bob").unwrap();
|
||||
assert!(matches!(effective(&path), Effective::Released(_)));
|
||||
|
|
@ -278,40 +199,6 @@ fn reading_writes_nothing_and_takes_no_lock_file() {
|
|||
}
|
||||
|
||||
|
||||
/// A dial document under the dial's type id whose payload is not a dial.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)]
|
||||
#[cultcache(type = "eureka.control.burn_rate.v1", schema = "ControlBurnRate")]
|
||||
struct WrongDial {
|
||||
#[cultcache(key = 0)]
|
||||
heat: String,
|
||||
}
|
||||
|
||||
fn write_wrong_dial(path: &Path, heat: &str) {
|
||||
let mut cache = CultCache::new();
|
||||
cache.register_entry_type::<WrongDial>().unwrap();
|
||||
cache.register_entry_type::<crate::control::Brake>().unwrap();
|
||||
cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(path)).unwrap();
|
||||
cache.pull_all_backing_stores().unwrap();
|
||||
cache.put(crate::control::DIAL_KEY, &WrongDial { heat: heat.into() }).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_dial_that_does_not_decode_reads_held_whatever_the_brake_says() {
|
||||
for brake_released in [true, false] {
|
||||
for heat in ["not a number", "NaN", ""] {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = store_path(dir.path());
|
||||
ControlWriter::open(&path).unwrap().set_brake(brake_released, at(0), "op").unwrap();
|
||||
write_wrong_dial(&path, heat);
|
||||
assert_eq!(
|
||||
effective(&path),
|
||||
held(HeldReason::Undecodable),
|
||||
"brake released={brake_released}, dial heat {heat:?}: a dial that cannot be read is never replaced by a default"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
mod grant {
|
||||
//! A Grant is the proof that this instance's own control store released: the
|
||||
|
|
@ -332,9 +219,8 @@ mod grant {
|
|||
}
|
||||
|
||||
fn release(root: &Path, instance: &str) {
|
||||
let mut writer = ControlWriter::open(control_path(root, &slug(instance)).unwrap()).unwrap();
|
||||
writer.set_brake(true, at(0), "op").unwrap();
|
||||
writer.set_dial(dial("1", 3600, "5")).unwrap();
|
||||
ControlWriter::open(control_path(root, &slug(instance)).unwrap()).unwrap().set_brake(true, at(0), "op").unwrap();
|
||||
release_swarm(root, dial("1", 0, "5"));
|
||||
}
|
||||
|
||||
fn read(root: &Path, instance: &str) -> Effective {
|
||||
|
|
@ -542,6 +428,24 @@ mod swarm {
|
|||
assert_eq!(envelope_at(dir.path()), SwarmEffective::Held(HeldReason::Undecodable));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_swarm_dial_has_calibration_ceilings() {
|
||||
for (what, dial) in [
|
||||
("base rate over the ceiling", swarm_dial("1", "100.01", "2", 2)),
|
||||
("base run over the ceiling", swarm_dial("1", "5", "50.01", 2)),
|
||||
("k over the ceiling", swarm_dial("1", "5", "2", 17)),
|
||||
] {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
assert!(SwarmWriter::open(swarm_file(dir.path())).unwrap().set_dial(dial.clone()).is_err(), "{what}: the writer refuses it");
|
||||
write_unchecked(&swarm_file(dir.path()), &dial);
|
||||
SwarmWriter::open(swarm_file(dir.path())).unwrap().set_brake(true, at(0), "op").unwrap();
|
||||
assert_eq!(envelope_at(dir.path()), SwarmEffective::Held(HeldReason::DialOutOfBounds), "{what}: the reader holds on it");
|
||||
}
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
released_swarm(dir.path(), swarm_dial("2", "100", "50", 16));
|
||||
assert!(matches!(envelope_at(dir.path()), SwarmEffective::Released(_)), "the ceilings are inclusive");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn envelope_scales_by_heat() {
|
||||
for (heat, rate, run_cap) in [("0.5", "2.5", "1.25"), ("2", "10", "5")] {
|
||||
|
|
@ -568,9 +472,8 @@ mod swarm {
|
|||
fn instance_reading(instance: &str, run_usd: &str) -> Effective {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join(instance).join("control.cc");
|
||||
let mut writer = ControlWriter::open(&path).unwrap();
|
||||
writer.set_brake(true, at(0), "op").unwrap();
|
||||
writer.set_dial(dial("1", 600, run_usd)).unwrap();
|
||||
ControlWriter::open(&path).unwrap().set_brake(true, at(0), "op").unwrap();
|
||||
release_swarm(dir.path(), dial("1", 0, run_usd));
|
||||
with_root(dir.path(), || read_effective(&slug(instance)))
|
||||
}
|
||||
|
||||
|
|
@ -587,18 +490,6 @@ mod swarm {
|
|||
}
|
||||
let grant = envelope.grant_for(&eureka, &own, dec("1")).unwrap();
|
||||
assert_eq!(grant.instance(), &eureka);
|
||||
assert_eq!(grant.cadence(), Duration::from_secs(600), "the instance cadence is carried, not invented");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grant_for_does_not_widen_the_instance_own_cap() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
released_swarm(dir.path(), swarm_dial("1", "5", "2", 2));
|
||||
let envelope = released(dir.path());
|
||||
let eureka = slug("eureka");
|
||||
let own = instance_reading("eureka", "0.5");
|
||||
assert!(envelope.grant_for(&eureka, &own, dec("0.51")).is_none());
|
||||
assert_eq!(envelope.grant_for(&eureka, &own, dec("0.5")).unwrap().run_cap_usd(), dec("0.5"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
1326
crates/mind-body/src/heartbeat.rs
Normal file
1326
crates/mind-body/src/heartbeat.rs
Normal file
File diff suppressed because it is too large
Load diff
1340
crates/mind-body/src/heartbeat/tests.rs
Normal file
1340
crates/mind-body/src/heartbeat/tests.rs
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -17,6 +17,7 @@
|
|||
//! owns no file and stops no unit through the `Launcher` port: stopping her
|
||||
//! units is the operator's brake hold, through `SystemdLauncher::stop_all`.
|
||||
|
||||
|
||||
use anyhow::{Result, anyhow};
|
||||
use chrono::{DateTime, Duration, Utc};
|
||||
use cultnet_rs::Selection;
|
||||
|
|
@ -450,7 +451,6 @@ fn breaker_question(item: &PipelineRef, label: &Label, now: DateTime<Utc>) -> Pi
|
|||
#[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, PipelineSelectionPage, PipelineStatus};
|
||||
|
|
@ -480,7 +480,7 @@ mod tests {
|
|||
|
||||
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" };
|
||||
let grant = Grant::for_test(Slug(INSTANCE.into()), StdDuration::from_secs(60), cap);
|
||||
let grant = Grant::for_test(Slug(INSTANCE.into()), cap);
|
||||
open_and_launch(&ports, LaunchRequest { turn, claims: claims.to_vec(), agent: "agent-x".into(), grant })
|
||||
}
|
||||
|
||||
|
|
@ -1048,7 +1048,7 @@ mod tests {
|
|||
fn gated(mind: &Named, grant: &str, turn: RunTurn, claims: Vec<PipelineRef>) -> Result<LaunchOutcome> {
|
||||
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()), StdDuration::from_secs(60), five());
|
||||
let grant = Grant::for_test(Slug(grant.into()), five());
|
||||
let outcome = open_and_launch(&ports, LaunchRequest { turn, claims, agent: "agent-x".into(), grant });
|
||||
assert!(launcher.started.borrow().is_empty());
|
||||
outcome
|
||||
|
|
|
|||
|
|
@ -9,7 +9,8 @@
|
|||
//! repetition breaker, the one-live-Self-run rule, and the single path that
|
||||
//! opens a run in the mind and only then starts its unit.
|
||||
//!
|
||||
//! `wake` is the waker's one cycle over them: the `mind-wake` binary.
|
||||
//! `heartbeat` runs the swarm's initiative timeline over them: the one process,
|
||||
//! the `mind-heartbeat` binary, that decides who wakes and when.
|
||||
//!
|
||||
//! `persona` is the Persona organ: one start of `mind-persona@<instance>` drains her
|
||||
//! pending chat through the connector, one typed reply per turn (`chat_client` is
|
||||
|
|
@ -34,12 +35,12 @@ pub mod chat_rudp;
|
|||
pub mod cli;
|
||||
pub mod connector;
|
||||
pub mod control;
|
||||
pub mod heartbeat;
|
||||
pub mod launch;
|
||||
pub mod lease_client;
|
||||
pub mod persona;
|
||||
pub mod queue;
|
||||
pub mod self_run;
|
||||
pub mod wake;
|
||||
|
||||
#[cfg(test)]
|
||||
mod testkit;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
//! `mind-control`: the operator's hand on a Mind's brake and burn-rate dial.
|
||||
//! `mind-control`: the operator's hand on a Mind's brake and the swarm dial.
|
||||
//! All behaviour is `mind_body::cli`; this reads the process's environment and
|
||||
//! clock, the two things the library never reads, and owns the process umask.
|
||||
|
||||
|
|
|
|||
|
|
@ -2,12 +2,11 @@
|
|||
//! chat.
|
||||
//!
|
||||
//! A start (ruling `eureka-body:ruling:persona-turn-drains-deliveries`) reads the
|
||||
//! brake and the dial once, and a brake held or a dial out of bounds ends it
|
||||
//! instance brake and the swarm envelope once, and a held brake or envelope ends it
|
||||
//! dead before the mind, the chat or the connector is touched. Otherwise it
|
||||
//! adopts the Persona run the waker opened for it, or opens one (a start rung by
|
||||
//! the chat ear has none; the unit is already running, so `ThisUnit` stands in
|
||||
//! for the launcher and starts nothing). Then it takes up to `max_turns` turns.
|
||||
//! The first turn is taken whether or not anything is pending (an unprompted
|
||||
//! adopts the Persona run the heartbeat opened for it; a start with no such run
|
||||
//! opens none and ends `NoRun`, because only the heartbeat opens Persona turns.
|
||||
//! Then it takes up to `max_turns` turns. The first turn is taken whether or not anything is pending (an unprompted
|
||||
//! start is a wake: she may speak or start work); later turns need pending
|
||||
//! deliveries. The run's own budget (the Grant's cap) bounds the start: when the
|
||||
//! caller's ledger spend has grown by the cap since the first turn, it stops.
|
||||
|
|
@ -51,8 +50,8 @@ use serde::{Deserialize, Serialize};
|
|||
use serde_json::{Value, json};
|
||||
|
||||
use crate::chat_client::{Chat, ChatDelivery, ChatSendRequest};
|
||||
use crate::control::{ControlSource, Effective, HeldReason, load_state};
|
||||
use crate::launch::{Clock, Declined, LaunchOutcome, LaunchRequest, Launcher, Ports, open_and_launch};
|
||||
use crate::control::{ControlSource, Effective, HeldReason, derive, load_swarm_state};
|
||||
use crate::launch::{Clock, LaunchOutcome, LaunchRequest, Launcher, Ports, holders, open_and_launch};
|
||||
use crate::queue::{MindPort, any_of, citing, headers, in_force, of_kind};
|
||||
|
||||
/// The admitting agent's name on everything this organ admits.
|
||||
|
|
@ -267,7 +266,7 @@ pub struct TurnRequest {
|
|||
/// Names this turn of this run, so a repeat of the call is the same request
|
||||
/// to the connector (its replay store answers it without a second provider call).
|
||||
pub request_id: String,
|
||||
/// The most this call may cost, in micro-USD: the run's own budget.
|
||||
/// The most this call may cost, in micro-USD: what is left of the run's cap.
|
||||
pub max_cost_micro_usd: u64,
|
||||
pub instructions: String,
|
||||
pub input: String,
|
||||
|
|
@ -399,20 +398,6 @@ pub struct Organs<'a> {
|
|||
pub trace: &'a dyn Trace,
|
||||
}
|
||||
|
||||
/// The launcher of the run this very unit carries: its unit is running (it is
|
||||
/// this process), so a holder is live and starting it is nothing to do.
|
||||
struct ThisUnit;
|
||||
|
||||
impl Launcher for ThisUnit {
|
||||
fn start(&self, _instance: &Slug, _turn: RunTurn) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn alive(&self, _instance: &Slug, _turn: RunTurn) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
/// Why a turn did not complete.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Failure {
|
||||
|
|
@ -451,8 +436,9 @@ impl fmt::Display for Failure {
|
|||
pub enum Outcome {
|
||||
/// The brake or the dial did not allow it; nothing was touched.
|
||||
Dead(HeldReason),
|
||||
/// No run could be opened for this start.
|
||||
NoRun(Declined),
|
||||
/// No Persona run is held for this unit: only the heartbeat opens one, so
|
||||
/// a start the heartbeat did not make runs nothing and opens nothing.
|
||||
NoRun,
|
||||
Served { run: PipelineRef, answered: u32, failure: Option<Failure>, self_run: Option<PipelineRef> },
|
||||
}
|
||||
|
||||
|
|
@ -460,7 +446,7 @@ impl fmt::Display for Outcome {
|
|||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::Dead(reason) => write!(f, "dead: {reason:?}"),
|
||||
Self::NoRun(_) => f.write_str("no run could be opened"),
|
||||
Self::NoRun => f.write_str("no run is held for this start"),
|
||||
Self::Served { run, answered, failure, self_run } => {
|
||||
write!(f, "served: {} answered {answered}", run.id.0)?;
|
||||
if let Some(failure) = failure {
|
||||
|
|
@ -483,15 +469,11 @@ enum Step {
|
|||
Failed(Failure),
|
||||
}
|
||||
|
||||
/// The control store read once: what it allows and the dial's heat.
|
||||
fn read(control: &impl ControlSource, instance: &Slug) -> (Effective, Option<Decimal>) {
|
||||
match load_state(control) {
|
||||
Ok(state) => {
|
||||
let heat = state.dial.as_ref().map(|dial| dial.heat);
|
||||
(state.effective(instance), heat)
|
||||
}
|
||||
Err(_) => (Effective::Held { reason: HeldReason::Undecodable }, None),
|
||||
}
|
||||
/// The instance brake and the swarm store read once: what they allow and the
|
||||
/// swarm dial's heat.
|
||||
fn read(control: &impl ControlSource, swarm: &impl ControlSource, instance: &Slug) -> (Effective, Option<Decimal>) {
|
||||
let heat = load_swarm_state(swarm).ok().and_then(|state| state.dial).map(|dial| dial.heat);
|
||||
(derive(control, swarm, instance), heat)
|
||||
}
|
||||
|
||||
fn label_of(run: &PipelineRef) -> &str {
|
||||
|
|
@ -502,8 +484,8 @@ fn date(now: DateTime<Utc>) -> Date {
|
|||
Date(now.format("%Y-%m-%d").to_string())
|
||||
}
|
||||
|
||||
/// One start. `control` is the instance's control store.
|
||||
pub fn start(organs: &Organs, control: &impl ControlSource, config: &Config) -> Result<Outcome> {
|
||||
/// One start. `control` is the instance's control store and `swarm` the swarm's.
|
||||
pub fn start(organs: &Organs, control: &impl ControlSource, swarm: &impl ControlSource, config: &Config) -> Result<Outcome> {
|
||||
let noted = Noted { inner: organs.trace, failures: std::cell::Cell::new(0) };
|
||||
let organs = &Organs {
|
||||
mind: organs.mind,
|
||||
|
|
@ -516,27 +498,22 @@ pub fn start(organs: &Organs, control: &impl ControlSource, config: &Config) ->
|
|||
trace: ¬ed,
|
||||
};
|
||||
let instance = organs.mind.instance().clone();
|
||||
let grant = match read(control, &instance).0 {
|
||||
let grant = match read(control, swarm, &instance).0 {
|
||||
Effective::Held { reason } => return Ok(Outcome::Dead(reason)),
|
||||
Effective::Released(grant) => grant,
|
||||
};
|
||||
let budget_micro = (grant.run_cap_usd() * Decimal::from(1_000_000)).trunc().to_u64().unwrap_or(u64::MAX);
|
||||
let own = Ports { mind: organs.mind, launcher: &ThisUnit, clock: organs.clock, host: organs.host };
|
||||
let request = LaunchRequest { turn: RunTurn::PersonaTurn, claims: vec![], agent: AGENT.into(), grant };
|
||||
// A holder is the run the waker opened for this unit; none means the chat ear rang.
|
||||
let run = match open_and_launch(&own, request)? {
|
||||
LaunchOutcome::Launched { run } | LaunchOutcome::Busy { run } => run,
|
||||
LaunchOutcome::Refused(declined) => return Ok(Outcome::NoRun(declined)),
|
||||
LaunchOutcome::NotQueued { .. } | LaunchOutcome::Tripped { .. } => bail!("the launch answered a Persona turn with an answer only a Self run has"),
|
||||
};
|
||||
// The run is the one the heartbeat opened for this unit; none held, none run.
|
||||
let Some(holder) = holders(organs.mind, &instance, RunTurn::PersonaTurn)?.into_iter().next() else { return Ok(Outcome::NoRun) };
|
||||
let run = holder.id;
|
||||
let mut after_seq = 0;
|
||||
let mut answered = 0;
|
||||
let mut failure = None;
|
||||
let mut self_run = None;
|
||||
let mut spent_at_start = None;
|
||||
let mut start = None;
|
||||
for turn in 0..config.max_turns {
|
||||
let budget = Budget { micro: budget_micro, spent_at_start: &mut spent_at_start };
|
||||
match one_turn(organs, control, config, &run, turn, &mut after_seq, budget, &mut self_run)? {
|
||||
let budget = Budget { micro: budget_micro, start: &mut start };
|
||||
match one_turn(organs, control, swarm, config, &run, turn, &mut after_seq, budget, &mut self_run)? {
|
||||
Step::Idle | Step::Spent => break,
|
||||
Step::Answered => answered += 1,
|
||||
Step::Failed(why) => {
|
||||
|
|
@ -552,13 +529,15 @@ pub fn start(organs: &Organs, control: &impl ControlSource, config: &Config) ->
|
|||
/// The run's budget and where the caller's ledger stood at its first turn.
|
||||
struct Budget<'a> {
|
||||
micro: u64,
|
||||
spent_at_start: &'a mut Option<u64>,
|
||||
/// The caller's total and window at the run's first turn.
|
||||
start: &'a mut Option<(u64, u64)>,
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments, reason = "one turn's state is these few things, and a struct for them would be a second owner")]
|
||||
fn one_turn(
|
||||
organs: &Organs,
|
||||
control: &impl ControlSource,
|
||||
swarm: &impl ControlSource,
|
||||
config: &Config,
|
||||
run: &PipelineRef,
|
||||
turn: u32,
|
||||
|
|
@ -568,33 +547,44 @@ fn one_turn(
|
|||
) -> Result<Step> {
|
||||
let instance = organs.mind.instance().clone();
|
||||
// The gate runs before every connector call.
|
||||
let (effective, heat) = read(control, &instance);
|
||||
let (effective, heat) = read(control, swarm, &instance);
|
||||
if let Effective::Held { reason } = effective {
|
||||
return Ok(Step::Failed(Failure::Held(reason)));
|
||||
}
|
||||
let Ok(deliveries) = organs.chat.pending(*after_seq) else { return Ok(Step::Failed(Failure::Chat)) };
|
||||
let Ok(pending) = organs.chat.pending(*after_seq) else { return Ok(Step::Failed(Failure::Chat)) };
|
||||
let deliveries = next_batch(pending);
|
||||
if deliveries.is_empty() && turn > 0 {
|
||||
return Ok(Step::Idle);
|
||||
}
|
||||
let Ok(instructions) = organs.persona.instructions() else { return Ok(Step::Failed(Failure::Persona)) };
|
||||
let Ok(readout) = organs.connector.spend_readout() else { return Ok(Step::Failed(Failure::Readout)) };
|
||||
let spent = readout.callers.iter().find(|row| row.caller_id == instance.0).map(|row| row.spent_micro_usd);
|
||||
if let Some(spent) = spent {
|
||||
let start = *budget.spent_at_start.get_or_insert(spent);
|
||||
if spent.saturating_sub(start) >= budget.micro {
|
||||
return Ok(Step::Spent);
|
||||
}
|
||||
// The ledger resets at a window boundary: a run that sees the window turn is charged its whole grant, as the heartbeat charges it.
|
||||
// A readout with no row for her caller cannot say what is left of the run's cap: the run holds, no turn is taken.
|
||||
let Some(row) = readout.callers.iter().find(|row| row.caller_id == instance.0) else {
|
||||
let inputs = trace_inputs(config, &deliveries);
|
||||
let outcome = "held: the spend readout has no row for her caller".to_string();
|
||||
organs.trace.record(&TurnTrace { run: label_of(run).into(), turn, at: organs.clock.now(), inputs, outputs: vec![], outcome })?;
|
||||
return Ok(Step::Failed(Failure::Readout));
|
||||
};
|
||||
let window = readout.at_unix_ms.checked_div(row.window_ms).unwrap_or(0);
|
||||
let (spent_then, window_then) = *budget.start.get_or_insert((row.spent_micro_usd, window));
|
||||
let used = row.spent_micro_usd.saturating_sub(spent_then);
|
||||
if window != window_then || used >= budget.micro {
|
||||
return Ok(Step::Spent);
|
||||
}
|
||||
// This turn may cost what is left of the run's cap, never the whole of it.
|
||||
let remaining = budget.micro - used;
|
||||
let live = live_runs(organs.mind, &instance)?;
|
||||
let Some(burn) = burn_line(&readout, &instance.0, heat, live) else { return Ok(Step::Failed(Failure::Readout)) };
|
||||
let history = recent_runs(organs.mind, &instance)?;
|
||||
let rooms = reply_rooms(config, &deliveries);
|
||||
let request = TurnRequest {
|
||||
caller: instance.0.clone(),
|
||||
request_id: format!("{}-t{turn}", label_of(run)),
|
||||
max_cost_micro_usd: budget.micro.max(1),
|
||||
max_cost_micro_usd: remaining,
|
||||
instructions,
|
||||
input: render_input(config, &deliveries, &history, &burn),
|
||||
output_schema_json: reply_schema(&config.rooms),
|
||||
output_schema_json: reply_schema(&rooms),
|
||||
};
|
||||
let inputs = trace_inputs(config, &deliveries);
|
||||
let now = organs.clock.now();
|
||||
|
|
@ -604,7 +594,7 @@ fn one_turn(
|
|||
organs.trace.record(&record(vec![], "failed: connector".into()))?;
|
||||
return Ok(Step::Failed(Failure::Connector));
|
||||
};
|
||||
let reply = match decode_reply(&text, config) {
|
||||
let reply = match decode_reply(&text, &rooms) {
|
||||
Ok(reply) => reply,
|
||||
Err(why) => {
|
||||
let raw = TraceItem { provenance: "her own".into(), room: String::new(), text: text.chars().take(TRACE_RAW_MAX).collect() };
|
||||
|
|
@ -620,6 +610,11 @@ fn one_turn(
|
|||
};
|
||||
let mut outputs = Vec::new();
|
||||
for (index, say) in reply.say.iter().enumerate() {
|
||||
// The gate binds the send as it bound the call: a hold placed while the model was answering stops the reply.
|
||||
if let Effective::Held { reason } = read(control, swarm, &instance).0 {
|
||||
organs.trace.record(&record(outputs, format!("failed: {}", Failure::Held(reason))))?;
|
||||
return Ok(Step::Failed(Failure::Held(reason)));
|
||||
}
|
||||
let text = if say.room == config.burn_room { format!("{burn}\n{}", say.text) } else { say.text.clone() };
|
||||
let sent = ChatSendRequest { request_id: format!("{batch}-{index}"), persona: instance.0.clone(), room_alias: say.room.clone(), text: text.clone() };
|
||||
let delivered = organs.chat.send(&sent).is_ok_and(|receipt| receipt.refusal.is_none() && receipt.event_id.is_some());
|
||||
|
|
@ -631,7 +626,7 @@ fn one_turn(
|
|||
}
|
||||
if let Some(ask) = &reply.start_self_run {
|
||||
outputs.push(TraceItem { provenance: "her own".into(), room: String::new(), text: format!("start_self_run {} {}: {}", ask.campaign, ask.repo, ask.brief) });
|
||||
match start_self_run(organs, control, run, &batch, ask)? {
|
||||
match start_self_run(organs, control, swarm, run, &batch, ask)? {
|
||||
Ok(started) => *self_run = Some(started),
|
||||
Err(why) => {
|
||||
organs.trace.record(&record(outputs, format!("failed: {why}")))?;
|
||||
|
|
@ -649,13 +644,38 @@ fn one_turn(
|
|||
Ok(Step::Answered)
|
||||
}
|
||||
|
||||
/// The reply as the one type it must be, with every room one of hers.
|
||||
fn decode_reply(text: &str, config: &Config) -> Result<PersonaReply, Failure> {
|
||||
/// The oldest deliveries that share a conversation: the public rooms together, or one direct
|
||||
/// message room alone. A prefix by seq, so an ack through its last never passes an unanswered one,
|
||||
/// and one member's DM text never enters another room's turn.
|
||||
fn next_batch(mut deliveries: Vec<ChatDelivery>) -> Vec<ChatDelivery> {
|
||||
deliveries.sort_by_key(|delivery| delivery.seq);
|
||||
let Some(first) = deliveries.first() else { return deliveries };
|
||||
let (direct, room) = (first.is_direct, first.room_id.clone());
|
||||
let end = deliveries.iter().position(|delivery| delivery.is_direct != direct || (direct && delivery.room_id != room)).unwrap_or(deliveries.len());
|
||||
deliveries.truncate(end);
|
||||
deliveries
|
||||
}
|
||||
|
||||
/// The rooms a reply to these deliveries may name: hers, and the room each
|
||||
/// direct message arrived under (a DM's alias is its room id, which the mouth
|
||||
/// sends into). Never a room no delivery came from and she was not given.
|
||||
fn reply_rooms(config: &Config, deliveries: &[ChatDelivery]) -> Vec<String> {
|
||||
let mut rooms = config.rooms.clone();
|
||||
for delivery in deliveries.iter().filter(|delivery| delivery.is_direct) {
|
||||
if !rooms.contains(&delivery.room_alias) {
|
||||
rooms.push(delivery.room_alias.clone());
|
||||
}
|
||||
}
|
||||
rooms
|
||||
}
|
||||
|
||||
/// The reply as the one type it must be, with every room one of `rooms`.
|
||||
fn decode_reply(text: &str, rooms: &[String]) -> Result<PersonaReply, Failure> {
|
||||
let reply: PersonaReply = serde_json::from_str(text).map_err(|_| Failure::Undecodable)?;
|
||||
if reply.start_self_run.as_ref().is_some_and(|ask| !start_in_bounds(ask)) {
|
||||
return Err(Failure::Undecodable);
|
||||
}
|
||||
if reply.say.iter().any(|say| !config.rooms.contains(&say.room) || say.text.trim().is_empty()) {
|
||||
if reply.say.iter().any(|say| !rooms.contains(&say.room) || say.text.trim().is_empty()) {
|
||||
return Err(Failure::RoomNotAllowed);
|
||||
}
|
||||
Ok(reply)
|
||||
|
|
@ -667,6 +687,7 @@ fn decode_reply(text: &str, config: &Config) -> Result<PersonaReply, Failure> {
|
|||
fn start_self_run(
|
||||
organs: &Organs,
|
||||
control: &impl ControlSource,
|
||||
swarm: &impl ControlSource,
|
||||
run: &PipelineRef,
|
||||
batch: &str,
|
||||
ask: &StartSelfRun,
|
||||
|
|
@ -690,7 +711,7 @@ fn start_self_run(
|
|||
PipelineAdmissionOutcome::Committed { .. } | PipelineAdmissionOutcome::AlreadyAdmitted { .. } => {}
|
||||
_ => return Ok(Err(Failure::SelfRun)),
|
||||
}
|
||||
let Effective::Released(grant) = read(control, organs.mind.instance()).0 else { return Ok(Err(Failure::SelfRun)) };
|
||||
let Effective::Released(grant) = read(control, swarm, organs.mind.instance()).0 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 };
|
||||
Ok(match open_and_launch(&ports, request)? {
|
||||
|
|
@ -806,9 +827,15 @@ fn trace_inputs(config: &Config, deliveries: &[ChatDelivery]) -> Vec<TraceItem>
|
|||
}
|
||||
|
||||
/// A string as a JSON string literal on one line: whatever it holds, it cannot
|
||||
/// end its line or open another.
|
||||
fn quoted(text: &str) -> String {
|
||||
serde_json::to_string(text).unwrap_or_else(|_| "\"\"".into())
|
||||
/// end its line or open another. JSON leaves U+2028, U+2029 and U+0085 raw; they
|
||||
/// are escaped here so no line-separator reader splits the line.
|
||||
pub(crate) fn quoted(text: &str) -> String {
|
||||
let json = serde_json::to_string(text).unwrap_or_else(|_| "\"\"".into());
|
||||
json.chars().map(|c| match c {
|
||||
'\u{2028}' | '\u{2029}' | '\u{0085}' => format!("\\u{:04x}", u32::from(c)),
|
||||
other => other.to_string(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// One message line: the sender id and the role the organ derived from it stand
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ use tempfile::TempDir;
|
|||
|
||||
use super::*;
|
||||
use crate::chat_client::{ChatSendReceipt, ContextLine};
|
||||
use crate::control::{BurnRate, ControlWriter, FileSource};
|
||||
use crate::control::{ControlWriter, FileSource, SwarmDial, SwarmWriter};
|
||||
use crate::testkit::*;
|
||||
|
||||
type Log = Rc<RefCell<Vec<String>>>;
|
||||
|
|
@ -39,6 +39,10 @@ struct FakeChat {
|
|||
acked: RefCell<Vec<u64>>,
|
||||
pending_calls: Cell<usize>,
|
||||
fail_send_from: Cell<Option<usize>>,
|
||||
/// 0 a delivered receipt, 1 a refused one, 2 one with neither an event nor a refusal.
|
||||
receipt: Cell<u8>,
|
||||
/// Runs after each delivered send, where the world changes between turns.
|
||||
on_send: RefCell<Option<Box<dyn Fn()>>>,
|
||||
}
|
||||
|
||||
impl Chat for FakeChat {
|
||||
|
|
@ -53,7 +57,15 @@ impl Chat for FakeChat {
|
|||
}
|
||||
self.log.borrow_mut().push("send".into());
|
||||
self.sent.borrow_mut().push(request.clone());
|
||||
Ok(ChatSendReceipt { request_id: request.request_id.clone(), event_id: Some("$e".into()), refusal: None })
|
||||
if let Some(on_send) = &*self.on_send.borrow() {
|
||||
on_send();
|
||||
}
|
||||
let (event_id, refusal) = match self.receipt.get() {
|
||||
0 => (Some("$e".to_string()), None),
|
||||
1 => (None, Some("Brake".to_string())),
|
||||
_ => (None, None),
|
||||
};
|
||||
Ok(ChatSendReceipt { request_id: request.request_id.clone(), event_id, refusal })
|
||||
}
|
||||
|
||||
fn ack(&self, through_seq: u64) -> Result<()> {
|
||||
|
|
@ -71,6 +83,10 @@ struct FakeConnector {
|
|||
/// The caller's ledger spend, which a call may raise.
|
||||
spent: Rc<Cell<u64>>,
|
||||
readout_fails: Cell<bool>,
|
||||
/// The readout carries no row for the caller.
|
||||
no_row: Cell<bool>,
|
||||
/// The readout's clock, in unix ms, which a call may advance.
|
||||
at: Rc<Cell<u64>>,
|
||||
/// Runs inside each call, where time passes and the world changes.
|
||||
during: RefCell<Option<Box<dyn Fn()>>>,
|
||||
}
|
||||
|
|
@ -88,6 +104,10 @@ impl Connector for FakeConnector {
|
|||
fn spend_readout(&self) -> Result<SpendReadout> {
|
||||
let mut readout = readout();
|
||||
readout.callers[0].spent_micro_usd = self.spent.get();
|
||||
readout.at_unix_ms = self.at.get();
|
||||
if self.no_row.get() {
|
||||
readout.callers.clear();
|
||||
}
|
||||
if self.readout_fails.get() { Err(anyhow!("no readout")) } else { Ok(readout) }
|
||||
}
|
||||
}
|
||||
|
|
@ -147,6 +167,8 @@ fn delivery(seq: u64, room: &str, sender: &str, text: &str) -> ChatDelivery {
|
|||
text: text.into(),
|
||||
at_unix_ms: 1_700_000_000_000,
|
||||
context: vec![],
|
||||
mentions: vec![],
|
||||
is_direct: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -183,6 +205,8 @@ impl Rig {
|
|||
acked: RefCell::default(),
|
||||
pending_calls: Cell::new(0),
|
||||
fail_send_from: Cell::new(None),
|
||||
receipt: Cell::new(0),
|
||||
on_send: RefCell::new(None),
|
||||
},
|
||||
connector: FakeConnector {
|
||||
log: log.clone(),
|
||||
|
|
@ -190,6 +214,8 @@ impl Rig {
|
|||
requests: RefCell::default(),
|
||||
spent: Rc::new(Cell::new(1_234_567)),
|
||||
readout_fails: Cell::new(false),
|
||||
no_row: Cell::new(false),
|
||||
at: Rc::new(Cell::new(1)),
|
||||
during: RefCell::new(None),
|
||||
},
|
||||
persona: FakePersona(Cell::new(false)),
|
||||
|
|
@ -205,14 +231,40 @@ impl Rig {
|
|||
}
|
||||
|
||||
fn release(&self, heat: &str) {
|
||||
let mut writer = ControlWriter::open(self.store()).unwrap();
|
||||
writer.set_brake(true, at(0), "op").unwrap();
|
||||
writer
|
||||
.set_dial(BurnRate { heat: Decimal::from_str(heat).unwrap(), base_cooldown_s: 3600, base_run_usd: Decimal::from_str("5").unwrap(), set_at: at(0), set_by: "op".into() })
|
||||
.unwrap();
|
||||
ControlWriter::open(self.store()).unwrap().set_brake(true, at(0), "op").unwrap();
|
||||
let mut swarm = SwarmWriter::open(self.swarm_store()).unwrap();
|
||||
swarm.set_brake(true, at(0), "op").unwrap();
|
||||
let dial = SwarmDial {
|
||||
heat: Decimal::from_str(heat).unwrap(),
|
||||
base_rate_usd_per_h: Decimal::from_str("5").unwrap(),
|
||||
base_run_usd: Decimal::from_str("5").unwrap(),
|
||||
k: 1,
|
||||
set_at: at(0),
|
||||
set_by: "op".into(),
|
||||
};
|
||||
swarm.set_dial(dial).unwrap();
|
||||
}
|
||||
|
||||
fn swarm_store(&self) -> PathBuf {
|
||||
self.dir.path().join("swarm.cc")
|
||||
}
|
||||
|
||||
/// The heartbeat's part: the run a Persona unit starts under.
|
||||
fn open_the_run(&self) {
|
||||
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 };
|
||||
assert!(matches!(open_and_launch(&ports, request).unwrap(), LaunchOutcome::Launched { .. }));
|
||||
}
|
||||
}
|
||||
|
||||
fn go_with(&self, config: &Config) -> Outcome {
|
||||
self.open_the_run();
|
||||
self.start_with(config)
|
||||
}
|
||||
|
||||
fn start_with(&self, config: &Config) -> Outcome {
|
||||
let organs = Organs {
|
||||
mind: &self.mind,
|
||||
launcher: &self.launcher,
|
||||
|
|
@ -223,7 +275,7 @@ impl Rig {
|
|||
persona: &self.persona,
|
||||
trace: &self.trace,
|
||||
};
|
||||
start(&organs, &FileSource::new(self.store()), config).unwrap()
|
||||
start(&organs, &FileSource::new(self.store()), &FileSource::new(self.swarm_store()), config).unwrap()
|
||||
}
|
||||
|
||||
fn go(&self) -> Outcome {
|
||||
|
|
@ -255,15 +307,13 @@ fn operator_says(seq: u64, text: &str) -> ChatDelivery {
|
|||
|
||||
#[test]
|
||||
fn no_grant_no_connector_call() {
|
||||
for setup in ["no store", "brake held", "dial absent"] {
|
||||
for setup in ["no store", "brake held", "swarm absent", "swarm held"] {
|
||||
let rig = Rig::new(vec![operator_says(1, "hello")], &[says(&[("operator", "hi")])]);
|
||||
match setup {
|
||||
"no store" => std::fs::remove_file(rig.store()).unwrap(),
|
||||
"brake held" => ControlWriter::open(rig.store()).unwrap().set_brake(false, at(0), "op").unwrap(),
|
||||
_ => {
|
||||
std::fs::remove_file(rig.store()).unwrap();
|
||||
ControlWriter::open(rig.store()).unwrap().set_brake(true, at(0), "op").unwrap();
|
||||
}
|
||||
"swarm absent" => std::fs::remove_file(rig.swarm_store()).unwrap(),
|
||||
_ => SwarmWriter::open(rig.swarm_store()).unwrap().set_brake(false, at(0), "op").unwrap(),
|
||||
}
|
||||
let outcome = rig.go();
|
||||
assert!(matches!(outcome, Outcome::Dead(_)), "{setup}: {outcome:?}");
|
||||
|
|
@ -275,11 +325,19 @@ fn no_grant_no_connector_call() {
|
|||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_start_the_heartbeat_did_not_make_opens_nothing_and_touches_nothing() {
|
||||
let rig = Rig::new(vec![operator_says(1, "hello")], &[says(&[("operator", "hi")])]);
|
||||
assert_eq!(rig.start_with(&config()), Outcome::NoRun);
|
||||
assert!(rig.mind.ids(K::Run).is_empty() && rig.launcher.started.borrow().is_empty());
|
||||
assert_eq!((rig.turns(), rig.chat.pending_calls.get()), (0, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_gate_is_read_again_before_every_connector_call() {
|
||||
let rig = Rig::new(vec![operator_says(1, "one")], &[says(&[("operator", "a")]), says(&[("operator", "b")])]);
|
||||
let (store, deliveries) = (rig.store(), rig.chat.deliveries.clone());
|
||||
*rig.connector.during.borrow_mut() = Some(Box::new(move || {
|
||||
*rig.chat.on_send.borrow_mut() = Some(Box::new(move || {
|
||||
ControlWriter::open(&store).unwrap().set_brake(false, at(0), "op").unwrap();
|
||||
deliveries.borrow_mut().push(operator_says(2, "two"));
|
||||
}));
|
||||
|
|
@ -289,6 +347,70 @@ fn the_gate_is_read_again_before_every_connector_call() {
|
|||
assert_eq!(rig.chat.acked.borrow().as_slice(), &[1], "the held turn acks nothing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_refused_or_event_less_receipt_is_not_a_delivered_send() {
|
||||
for receipt in [1, 2] {
|
||||
let rig = Rig::new(vec![operator_says(1, "hello")], &[says(&[("operator", "hi")])]);
|
||||
rig.chat.receipt.set(receipt);
|
||||
let Outcome::Served { failure, .. } = rig.go() else { panic!("served") };
|
||||
assert_eq!(failure, Some(Failure::Send), "receipt kind {receipt}");
|
||||
assert!(rig.chat.acked.borrow().is_empty(), "a send that was not delivered is not acked");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recent_runs_are_the_newest_five_newest_first() {
|
||||
let rig = Rig::new(vec![], &[]);
|
||||
for n in 1..=7 {
|
||||
let label = format!("mind-{n}");
|
||||
rig.mind.committed(vec![crate::testkit::run(&label, RunTurn::PersonaTurn, RunOperator::Mind, &[])]);
|
||||
rig.mind.committed(vec![crate::testkit::close(crate::testkit::run_ref(&label), crate::testkit::recorded())]);
|
||||
}
|
||||
let lines = recent_runs(&rig.mind, &Slug(INSTANCE.into())).unwrap();
|
||||
assert_eq!(lines.len(), 5);
|
||||
assert!(lines[0].starts_with("mind-7 ") && lines[4].starts_with("mind-3 "), "{lines:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_separators_in_member_text_cannot_end_its_line() {
|
||||
let seps = [char::from_u32(0x2028).unwrap(), char::from_u32(0x2029).unwrap(), char::from_u32(0x85).unwrap()];
|
||||
let text = format!("a{}b{}c{}d", seps[0], seps[1], seps[2]);
|
||||
let line = message_line(&config(), "message", "swarm", "@m:chat", Some("M"), &text);
|
||||
assert_eq!(line.matches('\n').count(), 1, "{line:?}");
|
||||
assert!(seps.iter().all(|sep| !line.contains(*sep)), "{line:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_held_swarm_envelope_stops_a_running_turn() {
|
||||
let rig = Rig::new(vec![operator_says(1, "one")], &[says(&[("operator", "a")]), says(&[("operator", "b")])]);
|
||||
let (swarm, deliveries) = (rig.swarm_store(), rig.chat.deliveries.clone());
|
||||
*rig.chat.on_send.borrow_mut() = Some(Box::new(move || {
|
||||
SwarmWriter::open(&swarm).unwrap().set_brake(false, at(0), "op").unwrap();
|
||||
deliveries.borrow_mut().push(operator_says(2, "two"));
|
||||
}));
|
||||
let Outcome::Served { answered, failure, .. } = rig.go() else { panic!("served") };
|
||||
assert_eq!((rig.turns(), answered), (1, 1), "the second turn makes no connector call");
|
||||
assert_eq!(failure, Some(Failure::Held(HeldReason::BrakeHeld)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_hold_placed_while_the_model_answers_stops_the_send_whichever_brake_it_is() {
|
||||
for swarm_wide in [true, false] {
|
||||
let rig = Rig::new(vec![operator_says(1, "one")], &[says(&[("operator", "a"), ("swarm", "b")])]);
|
||||
let (swarm, store) = (rig.swarm_store(), rig.store());
|
||||
*rig.connector.during.borrow_mut() = Some(Box::new(move || {
|
||||
if swarm_wide {
|
||||
SwarmWriter::open(&swarm).unwrap().set_brake(false, at(0), "op").unwrap();
|
||||
} else {
|
||||
ControlWriter::open(&store).unwrap().set_brake(false, at(0), "op").unwrap();
|
||||
}
|
||||
}));
|
||||
let Outcome::Served { answered, failure, .. } = rig.go() else { panic!("served") };
|
||||
assert_eq!((rig.turns(), answered, failure), (1, 0, Some(Failure::Held(HeldReason::BrakeHeld))), "swarm-wide {swarm_wide}");
|
||||
assert!(rig.chat.sent.borrow().is_empty() && rig.chat.acked.borrow().is_empty(), "nothing sent, nothing acked");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reply_decodes_into_sends_and_ack() {
|
||||
let rig = Rig::new(vec![operator_says(1, "a"), delivery(2, "swarm", "@bot:chat", "b")], &[says(&[("operator", "first"), ("swarm", "second")])]);
|
||||
|
|
@ -317,17 +439,17 @@ fn the_turn_is_one_tool_less_call_constrained_to_the_reply_schema_with_her_rooms
|
|||
#[test]
|
||||
fn a_reply_is_decoded_as_the_one_type_and_nothing_else_is_scanned() {
|
||||
let c = config();
|
||||
assert!(decode_reply(&says(&[("swarm", "x")]), &c).is_ok());
|
||||
assert!(decode_reply(&says(&[("swarm", "x")]), &c.rooms).is_ok());
|
||||
for bad in [
|
||||
"I will start_self_run now",
|
||||
r#"{"say":[],"start_self_run":null} and more"#,
|
||||
r#"{"say":[],"start_self_run":null,"extra":1}"#,
|
||||
"",
|
||||
] {
|
||||
assert_eq!(decode_reply(bad, &c), Err(Failure::Undecodable), "{bad}");
|
||||
assert_eq!(decode_reply(bad, &c.rooms), Err(Failure::Undecodable), "{bad}");
|
||||
}
|
||||
assert_eq!(decode_reply(&says(&[("elsewhere", "x")]), &c), Err(Failure::RoomNotAllowed));
|
||||
assert_eq!(decode_reply(&says(&[("swarm", " ")]), &c), Err(Failure::RoomNotAllowed));
|
||||
assert_eq!(decode_reply(&says(&[("elsewhere", "x")]), &c.rooms), Err(Failure::RoomNotAllowed));
|
||||
assert_eq!(decode_reply(&says(&[("swarm", " ")]), &c.rooms), Err(Failure::RoomNotAllowed));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -350,6 +472,32 @@ fn a_room_outside_hers_sends_nothing_and_acks_nothing() {
|
|||
assert!(rig.chat.sent.borrow().is_empty() && rig.chat.acked.borrow().is_empty());
|
||||
}
|
||||
|
||||
fn direct(seq: u64, room_id: &str, sender: &str, text: &str) -> ChatDelivery {
|
||||
ChatDelivery { is_direct: true, ..delivery(seq, room_id, sender, text) }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_members_dm_is_answered_in_its_room_and_no_other_unconfigured_room_is() {
|
||||
let dm = "!abc:chat";
|
||||
let rig = Rig::new(vec![direct(1, dm, "@m:chat", "hello, are you there")], &[says(&[(dm, "yes")])]);
|
||||
let Outcome::Served { failure, answered, .. } = rig.go() else { panic!("served") };
|
||||
assert_eq!((failure, answered), (None, 1));
|
||||
assert_eq!(rig.chat.sent.borrow()[0].room_alias, dm);
|
||||
let request = &rig.connector.requests.borrow()[0];
|
||||
let schema: serde_json::Value = serde_json::from_str(&request.output_schema_json).unwrap();
|
||||
assert_eq!(schema["properties"]["say"]["items"]["properties"]["room"]["enum"], json!(["swarm", "burn", "operator", dm]));
|
||||
assert!(request.input.contains(dm), "the DM's room is among the rooms she is told she may speak in");
|
||||
|
||||
let rig = Rig::new(vec![direct(1, dm, "@m:chat", "hello")], &[says(&[("!other:chat", "psst")])]);
|
||||
let Outcome::Served { failure, .. } = rig.go() else { panic!("served") };
|
||||
assert_eq!(failure, Some(Failure::RoomNotAllowed));
|
||||
assert!(rig.chat.sent.borrow().is_empty() && rig.chat.acked.borrow().is_empty());
|
||||
|
||||
let rig = Rig::new(vec![delivery(1, "!notdm:chat", "@m:chat", "hello")], &[says(&[("!notdm:chat", "psst")])]);
|
||||
let Outcome::Served { failure, .. } = rig.go() else { panic!("served") };
|
||||
assert_eq!(failure, Some(Failure::RoomNotAllowed), "a room that is not a DM is hers only when configured");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn start_self_run_admits_brief_and_launches() {
|
||||
let reply = json!({
|
||||
|
|
@ -387,8 +535,8 @@ fn a_start_outside_the_reply_bounds_fails_the_turn_without_an_admission_or_an_ac
|
|||
#[test]
|
||||
fn a_reply_without_start_self_run_is_not_a_reply_though_null_is() {
|
||||
let c = config();
|
||||
assert_eq!(decode_reply(r#"{"say":[]}"#, &c), Err(Failure::Undecodable), "the schema requires the field, so does the decoder");
|
||||
assert!(decode_reply(r#"{"say":[],"start_self_run":null}"#, &c).is_ok());
|
||||
assert_eq!(decode_reply(r#"{"say":[]}"#, &c.rooms), Err(Failure::Undecodable), "the schema requires the field, so does the decoder");
|
||||
assert!(decode_reply(r#"{"say":[],"start_self_run":null}"#, &c.rooms).is_ok());
|
||||
let rig = Rig::new(vec![operator_says(1, "go")], &[r#"{"say":[{"room":"operator","text":"hi"}]}"#.into()]);
|
||||
let Outcome::Served { failure, .. } = rig.go() else { panic!("served") };
|
||||
assert_eq!(failure, Some(Failure::Undecodable));
|
||||
|
|
@ -399,7 +547,7 @@ fn a_reply_without_start_self_run_is_not_a_reply_though_null_is() {
|
|||
fn the_decoder_holds_the_bounds_the_schema_states() {
|
||||
let c = config();
|
||||
let start = |brief: &str, repo: &str, campaign: &str| json!({ "say": [], "start_self_run": { "brief": brief, "repo": repo, "campaign": campaign } }).to_string();
|
||||
let ok = |brief: &str, repo: &str, campaign: &str| decode_reply(&start(brief, repo, campaign), &c).is_ok();
|
||||
let ok = |brief: &str, repo: &str, campaign: &str| decode_reply(&start(brief, repo, campaign), &c.rooms).is_ok();
|
||||
assert!(ok("b", "GameCult/Huginn", "eureka-body"));
|
||||
assert!(ok(&"b".repeat(1000), "a-b/c.d_e-f", &"c".repeat(64)));
|
||||
assert!(ok(&"\u{e9}".repeat(1000), "a/b", "c._-"), "the brief bound counts characters, as the schema does");
|
||||
|
|
@ -482,13 +630,13 @@ fn an_unprompted_start_takes_one_turn_whoever_started_it() {
|
|||
let ring = Rig::new(vec![], &[says(&[("swarm", "x")]), says(&[("swarm", "y")])]);
|
||||
let Outcome::Served { answered, .. } = ring.go() else { panic!("served") };
|
||||
assert_eq!((ring.turns(), answered), (1, 1), "an unprompted start takes one turn and stops with nothing pending");
|
||||
assert_eq!(ring.mind.ids(K::Run).len(), 1, "the ring-started unit opens its own run");
|
||||
assert_eq!(ring.mind.ids(K::Run).len(), 1, "the unit carries the one run the heartbeat opened");
|
||||
|
||||
let waker = Rig::new(vec![], &[says(&[("swarm", "good morning")]), says(&[("swarm", "again")])]);
|
||||
waker.mind.committed(vec![run("woken", RunTurn::PersonaTurn, RunOperator::Mind, &[])]);
|
||||
// Past the launch grace: only the unit being this process keeps the holder live.
|
||||
waker.clock.0.set(600_000);
|
||||
let Outcome::Served { run: carried, answered, .. } = waker.go() else { panic!("served") };
|
||||
let Outcome::Served { run: carried, answered, .. } = waker.start_with(&config()) else { panic!("served") };
|
||||
assert_eq!(carried, run_ref("woken"), "the unit carries the run the waker opened");
|
||||
assert_eq!((waker.turns(), answered), (1, 1), "a wake turn happens once, then nothing is pending");
|
||||
assert_eq!(waker.mind.ids(K::Run).len(), 1);
|
||||
|
|
@ -738,7 +886,7 @@ fn a_turn_is_named_by_its_run_and_its_number() {
|
|||
deliveries.borrow_mut().push(operator_says(next.get(), "more"));
|
||||
}
|
||||
}));
|
||||
let Outcome::Served { run: carried, answered, .. } = rig.go() else { panic!("served") };
|
||||
let Outcome::Served { run: carried, answered, .. } = rig.start_with(&config()) else { panic!("served") };
|
||||
assert_eq!((carried, answered), (run_ref("woken"), 2));
|
||||
let ids: Vec<_> = rig.connector.requests.borrow().iter().map(|request| request.request_id.clone()).collect();
|
||||
assert_eq!(ids, ["woken-t0", "woken-t1"]);
|
||||
|
|
@ -764,3 +912,76 @@ fn the_burn_line_counts_the_live_runs_of_the_mind() {
|
|||
busy.go();
|
||||
assert!(busy.sent_texts()[0].contains("live runs 2\n"), "her Persona run and her Self run: {:?}", busy.sent_texts());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_run_that_crosses_a_spend_window_is_spent_though_the_ledger_reads_zero() {
|
||||
// The ledger resets at the window boundary, so growth since the first reading
|
||||
// would read 0 and the run's cap would start over.
|
||||
let replies: Vec<_> = (0..8).map(|_| says(&[("swarm", "x")])).collect();
|
||||
let rig = Rig::new(vec![operator_says(1, "a")], &replies);
|
||||
let (deliveries, spent, next) = (rig.chat.deliveries.clone(), rig.connector.spent.clone(), Cell::new(1));
|
||||
let clock = rig.connector.at.clone();
|
||||
*rig.connector.during.borrow_mut() = Some(Box::new(move || {
|
||||
next.set(next.get() + 1);
|
||||
deliveries.borrow_mut().push(operator_says(next.get(), "more"));
|
||||
spent.set(0);
|
||||
clock.set(clock.get() + 86_400_000);
|
||||
}));
|
||||
let Outcome::Served { failure, .. } = rig.go_with(&Config { max_turns: 3, ..config() }) else { panic!("served") };
|
||||
assert_eq!((rig.turns(), failure), (1, None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_turn_asks_the_connector_for_what_is_left_of_the_run_cap_never_the_whole_cap() {
|
||||
let replies: Vec<_> = (0..8).map(|_| says(&[("swarm", "x")])).collect();
|
||||
let rig = Rig::new(vec![operator_says(1, "a")], &replies);
|
||||
let (deliveries, spent, next) = (rig.chat.deliveries.clone(), rig.connector.spent.clone(), Cell::new(1));
|
||||
*rig.connector.during.borrow_mut() = Some(Box::new(move || {
|
||||
next.set(next.get() + 1);
|
||||
deliveries.borrow_mut().push(operator_says(next.get(), "more"));
|
||||
spent.set(spent.get() + 4_999_999);
|
||||
}));
|
||||
let Outcome::Served { failure, answered, .. } = rig.go_with(&Config { max_turns: 3, ..config() }) else { panic!("served") };
|
||||
let asked: Vec<u64> = rig.connector.requests.borrow().iter().map(|request| request.max_cost_micro_usd).collect();
|
||||
// The cap is 5 USD: the first turn may cost it all, the second the one micro-USD left, and the third finds none.
|
||||
assert_eq!(asked, [5_000_000, 1]);
|
||||
assert_eq!((failure, answered), (None, 2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_readout_with_no_row_for_her_caller_holds_the_run_and_the_trace_says_why() {
|
||||
let rig = Rig::new(vec![operator_says(1, "a")], &[says(&[("swarm", "x")])]);
|
||||
rig.connector.no_row.set(true);
|
||||
let Outcome::Served { failure, answered, .. } = rig.go() else { panic!("served") };
|
||||
assert_eq!((rig.turns(), answered, failure), (0, 0, Some(Failure::Readout)));
|
||||
let trace = rig.trace.0.borrow();
|
||||
assert_eq!(trace.len(), 1);
|
||||
assert_eq!(trace[0].outcome, "held: the spend readout has no row for her caller");
|
||||
assert!(rig.chat.acked.borrow().is_empty() && rig.chat.sent.borrow().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deliveries_from_different_dm_rooms_are_never_in_one_turn() {
|
||||
let (a, b) = ("!a:chat", "!b:chat");
|
||||
let pending = vec![
|
||||
direct(1, a, "@ann:chat", "secret-a"),
|
||||
direct(2, b, "@bob:chat", "hi-b"),
|
||||
delivery(3, "swarm", "@m:chat", "public-c"),
|
||||
direct(4, a, "@ann:chat", "more-a"),
|
||||
direct(5, a, "@ann:chat", "again-a"),
|
||||
];
|
||||
let replies = [says(&[(a, "1")]), says(&[(b, "2")]), says(&[("swarm", "3")]), says(&[(a, "4")])];
|
||||
let rig = Rig::new(pending, &replies);
|
||||
let Outcome::Served { failure, answered, .. } = rig.go() else { panic!("served") };
|
||||
assert_eq!((failure, answered), (None, 4));
|
||||
let inputs: Vec<String> = rig.connector.requests.borrow().iter().map(|request| request.input.clone()).collect();
|
||||
let texts = ["secret-a", "hi-b", "public-c", "more-a"];
|
||||
for (turn, input) in inputs.iter().enumerate() {
|
||||
for (other, text) in texts.iter().enumerate() {
|
||||
assert_eq!(input.contains(text), turn == other, "turn {turn} text {text}");
|
||||
}
|
||||
}
|
||||
assert!(inputs[3].contains("again-a"), "one room's consecutive deliveries share its turn");
|
||||
assert!(!inputs[0].contains("again-a"), "a later delivery waits for its turn");
|
||||
assert_eq!(*rig.chat.acked.borrow(), vec![1, 2, 3, 5]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1007,9 +1007,11 @@ mod process {
|
|||
}
|
||||
}
|
||||
|
||||
/// Whether a process of this id still exists.
|
||||
/// Whether a process of this id still runs. A zombie is dead: with no init to reap
|
||||
/// it (cargo as PID 1 in a one-command job) it stays in the table after it is killed.
|
||||
fn alive(pid: &str) -> bool {
|
||||
Command::new("kill").args(["-0", pid]).stderr(Stdio::null()).status().unwrap().success()
|
||||
let Ok(stat) = std::fs::read_to_string(format!("/proc/{pid}/stat")) else { return false };
|
||||
stat.rsplit_once(')').is_some_and(|(_, rest)| !rest.trim_start().starts_with('Z'))
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -53,6 +53,11 @@ impl Recording {
|
|||
self.running.borrow_mut().retain(|unit| *unit != (Slug(INSTANCE.into()), turn));
|
||||
}
|
||||
|
||||
/// One named instance's unit ends.
|
||||
pub(crate) fn stop(&self, instance: &Slug, turn: RunTurn) {
|
||||
self.running.borrow_mut().retain(|unit| *unit != (instance.clone(), turn));
|
||||
}
|
||||
|
||||
pub(crate) fn revive(&self, turn: RunTurn) {
|
||||
self.running.borrow_mut().push((Slug(INSTANCE.into()), turn));
|
||||
}
|
||||
|
|
@ -84,10 +89,15 @@ impl TestMind {
|
|||
/// A mind holding its identity, the repo's stewardship, the campaign and a
|
||||
/// first target.
|
||||
pub(crate) fn seeded() -> Self {
|
||||
Self::seeded_as(INSTANCE)
|
||||
}
|
||||
|
||||
/// The same, for another instance name.
|
||||
pub(crate) fn seeded_as(name: &str) -> Self {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let mind = Mind::open(dir.path(), &Slug(INSTANCE.into())).unwrap();
|
||||
let this = Self { _dir: dir, instance: Slug(INSTANCE.into()), mind: RefCell::new(mind), now: Cell::new(at(0)) };
|
||||
this.put(vec![instance(), stewardship(), campaign(), target()]);
|
||||
let mind = Mind::open(dir.path(), &Slug(name.into())).unwrap();
|
||||
let this = Self { _dir: dir, instance: Slug(name.into()), mind: RefCell::new(mind), now: Cell::new(at(0)) };
|
||||
this.put(vec![instance(name), stewardship(name), campaign(), target()]);
|
||||
this
|
||||
}
|
||||
|
||||
|
|
@ -95,7 +105,7 @@ impl TestMind {
|
|||
pub(crate) fn put(&self, documents: Vec<PipelineDocument>) -> PipelineAdmissionOutcome {
|
||||
self.now.set(self.now.get() + Duration::seconds(1));
|
||||
let batch = PipelineAdmissionBatch {
|
||||
instance: Slug(INSTANCE.into()),
|
||||
instance: self.instance.clone(),
|
||||
provenance: PipelineProvenance { faculty: Faculty::Hands, agent: "test".into(), session: "test".into(), tool: "test".into() },
|
||||
documents,
|
||||
};
|
||||
|
|
@ -146,7 +156,7 @@ impl MindPort for TestMind {
|
|||
fn admit(&self, agent: &str, session: &str, documents: Vec<PipelineDocument>) -> Result<PipelineAdmissionOutcome> {
|
||||
self.now.set(self.now.get() + Duration::seconds(1));
|
||||
let batch = PipelineAdmissionBatch {
|
||||
instance: Slug(INSTANCE.into()),
|
||||
instance: self.instance.clone(),
|
||||
provenance: PipelineProvenance { faculty: Faculty::SelfFaculty, agent: agent.into(), session: session.into(), tool: "mind-body".into() },
|
||||
documents,
|
||||
};
|
||||
|
|
@ -178,18 +188,18 @@ fn doc_ref() -> DocRef {
|
|||
DocRef { path: "docs/eureka-body-target.md".into(), start_line: 1, end_line: 9, commit: Sha("df7507b".into()) }
|
||||
}
|
||||
|
||||
fn instance() -> PipelineDocument {
|
||||
fn instance(name: &str) -> PipelineDocument {
|
||||
PipelineDocument::Instance(PipelineInstance {
|
||||
instance: Slug(INSTANCE.into()),
|
||||
instance: Slug(name.into()),
|
||||
display_name: "test mind".into(),
|
||||
created_at: Date("2026-10-09".into()),
|
||||
host: "test".into(),
|
||||
})
|
||||
}
|
||||
|
||||
fn stewardship() -> PipelineDocument {
|
||||
fn stewardship(name: &str) -> PipelineDocument {
|
||||
PipelineDocument::Stewardship(PipelineStewardship {
|
||||
instance: Slug(INSTANCE.into()),
|
||||
instance: Slug(name.into()),
|
||||
repo: REPO.into(),
|
||||
sequence: 1,
|
||||
assigned_on: Date("2026-10-09".into()),
|
||||
|
|
@ -326,8 +336,12 @@ pub(crate) fn run_ref(label: &str) -> PipelineRef {
|
|||
}
|
||||
|
||||
pub(crate) fn run(label: &str, turn: RunTurn, operated_by: RunOperator, claims: &[PipelineRef]) -> PipelineDocument {
|
||||
run_in(INSTANCE, label, turn, operated_by, claims)
|
||||
}
|
||||
|
||||
pub(crate) fn run_in(instance: &str, label: &str, turn: RunTurn, operated_by: RunOperator, claims: &[PipelineRef]) -> PipelineDocument {
|
||||
PipelineDocument::Run(PipelineRun {
|
||||
instance: Slug(INSTANCE.into()),
|
||||
instance: Slug(instance.into()),
|
||||
label: label.into(),
|
||||
operated_by,
|
||||
turn,
|
||||
|
|
|
|||
|
|
@ -1,117 +0,0 @@
|
|||
//! The waker: one cycle, no memory between cycles.
|
||||
//!
|
||||
//! A cycle is three steps in a fixed order. (1) The brake and the dial are read
|
||||
//! once, through `control::derive`, the derivation behind `read_effective` and
|
||||
//! the unit gate; held ends the cycle before the mind, the clock or a unit is
|
||||
//! touched. (2) The newest Persona turn of hers is found, whatever became of
|
||||
//! it, and her cadence (the Grant's, from the dial) is waited out since its
|
||||
//! admission. (3) `open_and_launch` is called once for a Persona turn with no
|
||||
//! claims and the Grant, and its answer is the cycle's.
|
||||
//!
|
||||
//! The waker owns no file, no timer state and no cursor: the run in the mind is
|
||||
//! the grant (target invariant run-is-the-grant), and whether she is awake is
|
||||
//! decided by admission and answered by the launch primitive. The timer that
|
||||
//! calls a cycle is a fixed poll; it decides nothing. The waker starts no Self
|
||||
//! run and ends no run or unit.
|
||||
|
||||
use std::fmt;
|
||||
|
||||
use anyhow::{Result, anyhow, bail};
|
||||
use chrono::{DateTime, Duration, Utc};
|
||||
use cultnet_rs::Selection;
|
||||
use eureka_pipeline::{PipelineKind, PipelineRef, RunOperator, RunTurn, Slug};
|
||||
use huginn_mind::PipelinePageItems;
|
||||
|
||||
use crate::control::{ControlSource, Effective, HeldReason, derive};
|
||||
use crate::launch::{Declined, LaunchOutcome, LaunchRequest, Ports, open_and_launch};
|
||||
use crate::queue::{MindPort, any_of, of_kind};
|
||||
|
||||
/// The admitting agent's name on the receipt of every run the waker opens.
|
||||
pub const AGENT: &str = "mind-wake";
|
||||
|
||||
/// What one cycle did. Every outcome is an answer; only a mind that cannot be
|
||||
/// reached is an error.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum WakeOutcome {
|
||||
/// The brake or the dial did not allow a launch; nothing else was touched.
|
||||
Held { reason: HeldReason },
|
||||
/// Her cadence since the newest Persona turn has not passed; `until` is when it has.
|
||||
NotYet { until: DateTime<Utc> },
|
||||
/// A Persona turn of hers is live.
|
||||
Busy { run: PipelineRef },
|
||||
Launched { run: PipelineRef },
|
||||
Refused(Declined),
|
||||
}
|
||||
|
||||
impl fmt::Display for WakeOutcome {
|
||||
/// One line, with ids and times the mind gave and never a value a refusal
|
||||
/// carries.
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::Held { reason } => write!(f, "held: {reason:?}"),
|
||||
Self::NotYet { until } => write!(f, "not yet: until {}", until.to_rfc3339()),
|
||||
Self::Busy { run } => write!(f, "busy: {}", run.id.0),
|
||||
Self::Launched { run } => write!(f, "launched: {}", run.id.0),
|
||||
Self::Refused(declined) => write!(f, "refused: {}", declined_code(declined)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn declined_code(declined: &Declined) -> &'static str {
|
||||
match declined {
|
||||
Declined::Admission(_) => "admission",
|
||||
Declined::Conflict => "conflict",
|
||||
Declined::AlreadyAdmitted => "already_admitted",
|
||||
Declined::StartFailed => "start_failed",
|
||||
Declined::GrantForOtherInstance => "grant_for_other_instance",
|
||||
Declined::PersonaRunTakesNoClaims => "persona_run_takes_claims",
|
||||
}
|
||||
}
|
||||
|
||||
/// When the newest Persona turn of hers, of any status, was admitted. The
|
||||
/// spellings of the two filters are the types' own, and one page of one row is
|
||||
/// asked for: an idle wake is a query.
|
||||
fn newest_persona_turn(mind: &dyn MindPort, instance: &Slug) -> Result<Option<DateTime<Utc>>> {
|
||||
let turn = format!("{:?}", RunTurn::PersonaTurn);
|
||||
let operator = format!("{:?}", RunOperator::Mind);
|
||||
let runs = any_of(of_kind(PipelineKind::Run), "root", &[&instance.0]);
|
||||
let selection = Selection { descending: true, limit: Some(1), ..any_of(any_of(runs, "turn", &[&turn]), "operated_by", &[&operator]) };
|
||||
let PipelinePageItems::Headers(headers) = mind.query(&selection)?.items else {
|
||||
bail!("the mind answered documents to a header selection");
|
||||
};
|
||||
headers
|
||||
.first()
|
||||
.map(|newest| {
|
||||
DateTime::parse_from_rfc3339(&newest.admission.admitted_at)
|
||||
.map(|at| at.with_timezone(&Utc))
|
||||
.map_err(|_| anyhow!("the newest Persona turn's admission time is not a timestamp"))
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
|
||||
/// One cycle. `control` is the instance's control store; the reading is taken
|
||||
/// once and its Grant is the one handed to the launch.
|
||||
pub fn cycle(ports: &Ports, control: &impl ControlSource) -> Result<WakeOutcome> {
|
||||
let instance = ports.mind.instance().clone();
|
||||
let grant = match derive(control, &instance) {
|
||||
Effective::Held { reason } => return Ok(WakeOutcome::Held { reason }),
|
||||
Effective::Released(grant) => grant,
|
||||
};
|
||||
if let Some(newest) = newest_persona_turn(ports.mind, &instance)? {
|
||||
let cadence = Duration::from_std(grant.cadence()).map_err(|_| anyhow!("the dial's cadence is out of range"))?;
|
||||
let until = newest + cadence;
|
||||
if until > ports.clock.now() {
|
||||
return Ok(WakeOutcome::NotYet { until });
|
||||
}
|
||||
}
|
||||
let request = LaunchRequest { turn: RunTurn::PersonaTurn, claims: vec![], agent: AGENT.into(), grant };
|
||||
match open_and_launch(ports, request)? {
|
||||
LaunchOutcome::Launched { run } => Ok(WakeOutcome::Launched { run }),
|
||||
LaunchOutcome::Busy { run } => Ok(WakeOutcome::Busy { run }),
|
||||
LaunchOutcome::Refused(declined) => Ok(WakeOutcome::Refused(declined)),
|
||||
LaunchOutcome::NotQueued { .. } | LaunchOutcome::Tripped { .. } => bail!("the launch answered a Persona turn with an answer only a Self run has"),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
|
@ -1,349 +0,0 @@
|
|||
//! The waker's tests: a real mind over a temporary directory, the real control
|
||||
//! store written by the operator's writer and read by the real derivation, a
|
||||
//! recording launcher and a settable clock. A wait is observed where it is
|
||||
//! decided, as the `until` the cycle returns, never after a sleep.
|
||||
|
||||
use std::cell::Cell;
|
||||
use std::str::FromStr;
|
||||
|
||||
use anyhow::{Result, anyhow};
|
||||
use chrono::{DateTime, Duration, Utc};
|
||||
use cultnet_rs::Selection;
|
||||
use eureka_pipeline::{PipelineDocument, PipelineKind as K, ResolutionOutcome, RunOperator, RunTurn, Slug};
|
||||
use huginn_mind::{MindRefusal, PipelineAdmissionOutcome, PipelinePageItems, PipelineSelectionPage};
|
||||
use rust_decimal::Decimal;
|
||||
use tempfile::TempDir;
|
||||
|
||||
use super::*;
|
||||
use crate::control::{BurnRate, ControlWriter, FileSource};
|
||||
use crate::launch::Launcher;
|
||||
use crate::testkit::*;
|
||||
|
||||
/// Every call the units port receives, counted over a recording launcher.
|
||||
struct Units {
|
||||
rec: Recording,
|
||||
calls: Cell<usize>,
|
||||
}
|
||||
|
||||
impl Launcher for Units {
|
||||
fn start(&self, instance: &Slug, turn: RunTurn) -> Result<()> {
|
||||
self.calls.set(self.calls.get() + 1);
|
||||
self.rec.start(instance, turn)
|
||||
}
|
||||
|
||||
fn alive(&self, instance: &Slug, turn: RunTurn) -> bool {
|
||||
self.calls.set(self.calls.get() + 1);
|
||||
self.rec.alive(instance, turn)
|
||||
}
|
||||
}
|
||||
|
||||
/// A mind that counts the queries and admissions it is asked for.
|
||||
struct Counting<'a> {
|
||||
inner: &'a TestMind,
|
||||
queries: Cell<usize>,
|
||||
admits: Cell<usize>,
|
||||
/// The header rows the queries brought back.
|
||||
rows: Cell<usize>,
|
||||
}
|
||||
|
||||
impl MindPort for Counting<'_> {
|
||||
fn instance(&self) -> &Slug {
|
||||
self.inner.instance()
|
||||
}
|
||||
|
||||
fn query(&self, selection: &Selection) -> Result<PipelineSelectionPage> {
|
||||
self.queries.set(self.queries.get() + 1);
|
||||
let page = self.inner.query(selection)?;
|
||||
if let PipelinePageItems::Headers(headers) = &page.items {
|
||||
self.rows.set(self.rows.get() + headers.len());
|
||||
}
|
||||
Ok(page)
|
||||
}
|
||||
|
||||
fn admit(&self, agent: &str, session: &str, documents: Vec<PipelineDocument>) -> Result<PipelineAdmissionOutcome> {
|
||||
self.admits.set(self.admits.get() + 1);
|
||||
self.inner.admit(agent, session, documents)
|
||||
}
|
||||
}
|
||||
|
||||
/// A mind that cannot be reached.
|
||||
struct Unreachable(Slug);
|
||||
|
||||
impl MindPort for Unreachable {
|
||||
fn instance(&self) -> &Slug {
|
||||
&self.0
|
||||
}
|
||||
|
||||
fn query(&self, _selection: &Selection) -> Result<PipelineSelectionPage> {
|
||||
Err(anyhow!("the mind could not be asked"))
|
||||
}
|
||||
|
||||
fn admit(&self, _agent: &str, _session: &str, _documents: Vec<PipelineDocument>) -> Result<PipelineAdmissionOutcome> {
|
||||
Err(anyhow!("the mind could not be asked"))
|
||||
}
|
||||
}
|
||||
|
||||
struct Rig {
|
||||
mind: TestMind,
|
||||
launcher: Units,
|
||||
clock: Fixed,
|
||||
dir: TempDir,
|
||||
}
|
||||
|
||||
fn dec(text: &str) -> Decimal {
|
||||
Decimal::from_str(text).unwrap()
|
||||
}
|
||||
|
||||
fn seconds(whole: i64, nanos: i64) -> Duration {
|
||||
Duration::seconds(whole) + Duration::nanoseconds(nanos)
|
||||
}
|
||||
|
||||
impl Rig {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
mind: TestMind::seeded(),
|
||||
launcher: Units { rec: Recording::default(), calls: Cell::new(0) },
|
||||
clock: Fixed(Cell::new(at(0))),
|
||||
dir: TempDir::new().unwrap(),
|
||||
}
|
||||
}
|
||||
|
||||
fn store(&self) -> std::path::PathBuf {
|
||||
self.dir.path().join(INSTANCE).join("control.cc")
|
||||
}
|
||||
|
||||
/// The brake released and the dial set, through the operator's writer.
|
||||
fn release(&self, heat: &str, cooldown_s: u32, run_usd: &str) {
|
||||
let mut writer = ControlWriter::open(self.store()).unwrap();
|
||||
writer.set_brake(true, at(0), "op").unwrap();
|
||||
writer.set_dial(BurnRate { heat: dec(heat), base_cooldown_s: cooldown_s, base_run_usd: dec(run_usd), set_at: at(0), set_by: "op".into() }).unwrap();
|
||||
}
|
||||
|
||||
fn cycle(&self) -> WakeOutcome {
|
||||
self.cycle_through(&self.mind).unwrap()
|
||||
}
|
||||
|
||||
fn cycle_through(&self, mind: &dyn MindPort) -> Result<WakeOutcome> {
|
||||
let ports = Ports { mind, launcher: &self.launcher, clock: &self.clock, host: "yggdrasil-host" };
|
||||
super::cycle(&ports, &FileSource::new(self.store()))
|
||||
}
|
||||
|
||||
/// A Persona turn of hers, opened and then ended as given.
|
||||
fn persona(&self, label: &str, ending: ResolutionOutcome) {
|
||||
self.mind.committed(vec![run(label, RunTurn::PersonaTurn, RunOperator::Mind, &[])]);
|
||||
self.mind.committed(vec![close(run_ref(label), ending)]);
|
||||
}
|
||||
|
||||
fn admitted(&self, label: &str) -> DateTime<Utc> {
|
||||
let stamp = self.mind.view(K::Run, &run_ref(label).id.0).unwrap().admission.admitted_at;
|
||||
DateTime::parse_from_rfc3339(&stamp).unwrap().with_timezone(&Utc)
|
||||
}
|
||||
|
||||
fn runs(&self) -> usize {
|
||||
self.mind.ids(K::Run).len()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_the_brake_held_or_the_dial_absent_a_cycle_queries_nothing_admits_nothing_and_calls_no_unit() {
|
||||
type Prepare = fn(&Rig);
|
||||
let cases: [(&str, HeldReason, Prepare); 4] = [
|
||||
("no store", HeldReason::BrakeAbsent, |_| {}),
|
||||
("brake held", HeldReason::BrakeHeld, |rig| {
|
||||
rig.release("1", 600, "5");
|
||||
ControlWriter::open(rig.store()).unwrap().set_brake(false, at(1), "op").unwrap();
|
||||
}),
|
||||
("dial absent", HeldReason::DialAbsent, |rig| {
|
||||
ControlWriter::open(rig.store()).unwrap().set_brake(true, at(0), "op").unwrap();
|
||||
}),
|
||||
("store undecodable", HeldReason::Undecodable, |rig| {
|
||||
rig.release("1", 600, "5");
|
||||
std::fs::write(rig.store(), b"junk").unwrap();
|
||||
}),
|
||||
];
|
||||
for (name, reason, prepare) in cases {
|
||||
// No Persona turn yet, so a released brake would launch one.
|
||||
let rig = Rig::new();
|
||||
prepare(&rig);
|
||||
let counting = Counting { inner: &rig.mind, queries: Cell::new(0), admits: Cell::new(0), rows: Cell::new(0) };
|
||||
let outcome = rig.cycle_through(&counting).unwrap();
|
||||
assert_eq!(outcome, WakeOutcome::Held { reason }, "{name}");
|
||||
assert_eq!((counting.queries.get(), counting.admits.get(), rig.launcher.calls.get(), rig.runs()), (0, 0, 0, 0), "{name}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_wait_is_the_grants_cadence_after_the_newest_persona_turn_and_ends_exactly_there() {
|
||||
// (heat, base cooldown, the cadence base_cooldown / heat): whole, halved,
|
||||
// and one that carries nanoseconds, so an offset or scale shows. The last
|
||||
// three sit beyond a second below, a day above and at the dial's maximum
|
||||
// (the dial allows all three), so a floor or a ceiling on the wait shows too.
|
||||
let heats = [
|
||||
("1", 600, seconds(600, 0)),
|
||||
("2", 600, seconds(300, 0)),
|
||||
("1.5", 1000, seconds(666, 666_666_667)),
|
||||
("0.05", 3600, seconds(72_000, 0)),
|
||||
("2", 1, seconds(0, 500_000_000)),
|
||||
("0.05", 10_000, seconds(200_000, 0)),
|
||||
// The dial's maximum (base cooldown u32::MAX at heat 0.05): a ceiling
|
||||
// anywhere below it, a week or a year included, changes the wait.
|
||||
("0.05", 4_294_967_295, seconds(85_899_345_900, 0)),
|
||||
];
|
||||
for (heat, cooldown, cadence) in heats {
|
||||
let rig = Rig::new();
|
||||
rig.release(heat, cooldown, "5");
|
||||
rig.persona("p1", recorded());
|
||||
let until = rig.admitted("p1") + cadence;
|
||||
|
||||
rig.clock.0.set(until - Duration::milliseconds(1));
|
||||
assert_eq!(rig.cycle(), WakeOutcome::NotYet { until }, "heat {heat}, a millisecond early");
|
||||
assert_eq!((rig.launcher.calls.get(), rig.runs()), (0, 1), "heat {heat}: waiting touches no unit and admits nothing");
|
||||
|
||||
rig.clock.0.set(until);
|
||||
assert!(matches!(rig.cycle(), WakeOutcome::Launched { .. }), "heat {heat}, at the boundary");
|
||||
assert_eq!(rig.runs(), 2, "heat {heat}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_same_age_waits_under_one_dial_and_launches_under_another() {
|
||||
let age = |heat: &str| {
|
||||
let rig = Rig::new();
|
||||
rig.release(heat, 600, "5");
|
||||
rig.persona("p1", recorded());
|
||||
rig.clock.0.set(rig.admitted("p1") + Duration::seconds(400));
|
||||
rig.cycle()
|
||||
};
|
||||
assert!(matches!(age("1"), WakeOutcome::NotYet { .. }), "600 s cadence at 400 s");
|
||||
assert!(matches!(age("2"), WakeOutcome::Launched { .. }), "300 s cadence at 400 s");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_newer_self_run_or_operator_turn_does_not_move_the_boundary() {
|
||||
let rig = Rig::new();
|
||||
rig.release("1", 600, "5");
|
||||
rig.persona("p1", recorded());
|
||||
let until = rig.admitted("p1") + Duration::seconds(600);
|
||||
rig.mind.committed(vec![run("s1", RunTurn::SelfRun, RunOperator::Mind, &[])]);
|
||||
rig.mind.committed(vec![run("op1", RunTurn::PersonaTurn, RunOperator::Operator, &[])]);
|
||||
assert!(rig.admitted("s1") > rig.admitted("p1") && rig.admitted("op1") > rig.admitted("p1"));
|
||||
|
||||
rig.clock.0.set(until - Duration::milliseconds(1));
|
||||
assert_eq!(rig.cycle(), WakeOutcome::NotYet { until });
|
||||
rig.clock.0.set(until);
|
||||
assert!(matches!(rig.cycle(), WakeOutcome::Launched { .. }), "the boundary is still her Persona turn's");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_ended_persona_turn_sets_the_boundary_whatever_ended_it_and_the_newest_wins() {
|
||||
for (name, ending) in [("recorded", recorded()), ("withdrawn", withdrawn())] {
|
||||
let rig = Rig::new();
|
||||
rig.release("1", 600, "5");
|
||||
rig.persona("p1", recorded());
|
||||
rig.persona("p2", ending);
|
||||
assert!(rig.admitted("p2") > rig.admitted("p1"), "{name}");
|
||||
let until = rig.admitted("p2") + Duration::seconds(600);
|
||||
rig.clock.0.set(until - Duration::milliseconds(1));
|
||||
assert_eq!(rig.cycle(), WakeOutcome::NotYet { until }, "{name}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finding_the_newest_persona_turn_is_one_query_that_reads_one_row_however_many_there_are() {
|
||||
let rig = Rig::new();
|
||||
rig.release("1", 600, "5");
|
||||
for label in ["p1", "p2", "p3"] {
|
||||
rig.persona(label, recorded());
|
||||
}
|
||||
rig.clock.0.set(rig.admitted("p3") + Duration::seconds(1));
|
||||
let counting = Counting { inner: &rig.mind, queries: Cell::new(0), admits: Cell::new(0), rows: Cell::new(0) };
|
||||
assert!(matches!(rig.cycle_through(&counting).unwrap(), WakeOutcome::NotYet { .. }));
|
||||
assert_eq!((counting.queries.get(), counting.rows.get()), (1, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_live_persona_turn_past_the_cadence_answers_busy_and_the_cycle_admits_nothing_itself() {
|
||||
let rig = Rig::new();
|
||||
rig.release("1", 600, "5");
|
||||
rig.mind.committed(vec![run("p1", RunTurn::PersonaTurn, RunOperator::Mind, &[])]);
|
||||
rig.launcher.rec.revive(RunTurn::PersonaTurn);
|
||||
rig.clock.0.set(rig.admitted("p1") + Duration::seconds(601));
|
||||
let counting = Counting { inner: &rig.mind, queries: Cell::new(0), admits: Cell::new(0), rows: Cell::new(0) };
|
||||
assert_eq!(rig.cycle_through(&counting).unwrap(), WakeOutcome::Busy { run: run_ref("p1") });
|
||||
assert_eq!(counting.admits.get(), 0);
|
||||
assert!(rig.launcher.rec.started.borrow().is_empty());
|
||||
assert_eq!(rig.runs(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_first_released_cycle_launches_a_persona_run_with_no_claims_and_the_grants_cap() {
|
||||
let rig = Rig::new();
|
||||
// Decoys: a Self run of hers and an operator's Persona turn are not her Persona turns.
|
||||
rig.mind.committed(vec![run("s1", RunTurn::SelfRun, RunOperator::Mind, &[])]);
|
||||
rig.mind.committed(vec![run("op1", RunTurn::PersonaTurn, RunOperator::Operator, &[])]);
|
||||
rig.release("2", 600, "3.25");
|
||||
let WakeOutcome::Launched { run: opened } = rig.cycle() else { panic!("not launched") };
|
||||
let view = rig.mind.view(K::Run, &opened.id.0).unwrap();
|
||||
let PipelineDocument::Run(held) = &view.document else { panic!("not a run") };
|
||||
assert_eq!((held.operated_by, held.turn), (RunOperator::Mind, RunTurn::PersonaTurn));
|
||||
assert!(held.claims.is_empty() && held.campaigns.is_empty());
|
||||
assert_eq!(held.budget_usd.0, "6.5", "base_run_usd x heat");
|
||||
assert_eq!(held.host.0, "yggdrasil-host");
|
||||
assert_eq!(view.admission.provenance.agent.0, "mind-wake");
|
||||
assert_eq!(*rig.launcher.rec.started.borrow(), vec![(Slug(INSTANCE.into()), RunTurn::PersonaTurn)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_consecutive_cycles_launch_once_and_the_second_waits_or_answers_busy() {
|
||||
let rig = Rig::new();
|
||||
rig.release("1", 600, "5");
|
||||
let WakeOutcome::Launched { run: first } = rig.cycle() else { panic!("not launched") };
|
||||
let stamp = rig.mind.view(K::Run, &first.id.0).unwrap().admission.admitted_at;
|
||||
let until = DateTime::parse_from_rfc3339(&stamp).unwrap().with_timezone(&Utc) + Duration::seconds(600);
|
||||
assert_eq!(rig.cycle(), WakeOutcome::NotYet { until }, "straight after: within the cadence");
|
||||
rig.clock.0.set(until);
|
||||
assert_eq!(rig.cycle(), WakeOutcome::Busy { run: first }, "past the cadence with her turn still live");
|
||||
assert_eq!((rig.runs(), rig.launcher.rec.started.borrow().len()), (1, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_unit_that_does_not_start_and_an_admission_that_is_refused_are_refusals_not_errors() {
|
||||
let rig = Rig::new();
|
||||
rig.release("1", 600, "5");
|
||||
rig.launcher.rec.fail.set(true);
|
||||
assert_eq!(rig.cycle(), WakeOutcome::Refused(Declined::StartFailed));
|
||||
|
||||
let rig = Rig::new();
|
||||
rig.release("1", 600, "5");
|
||||
let refusing = Answering { inner: &rig.mind, outcome: PipelineAdmissionOutcome::Conflict { identities: vec![] } };
|
||||
assert_eq!(rig.cycle_through(&refusing).unwrap(), WakeOutcome::Refused(Declined::Conflict));
|
||||
assert!(rig.launcher.rec.started.borrow().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_mind_that_cannot_be_reached_is_an_error_and_starts_nothing() {
|
||||
let rig = Rig::new();
|
||||
rig.release("1", 600, "5");
|
||||
assert!(rig.cycle_through(&Unreachable(Slug(INSTANCE.into()))).is_err());
|
||||
assert_eq!(rig.launcher.calls.get(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_outcome_prints_ids_and_codes_and_never_a_refusals_values() {
|
||||
let canary = "canary-8c1f";
|
||||
let refused = Declined::Admission(MindRefusal::RepoNotInCampaign { repo: canary.into() });
|
||||
assert_eq!(WakeOutcome::Refused(refused).to_string(), "refused: admission");
|
||||
for (declined, code) in [
|
||||
(Declined::Conflict, "conflict"),
|
||||
(Declined::AlreadyAdmitted, "already_admitted"),
|
||||
(Declined::StartFailed, "start_failed"),
|
||||
(Declined::GrantForOtherInstance, "grant_for_other_instance"),
|
||||
(Declined::PersonaRunTakesNoClaims, "persona_run_takes_claims"),
|
||||
] {
|
||||
assert_eq!(WakeOutcome::Refused(declined).to_string(), format!("refused: {code}"));
|
||||
}
|
||||
assert_eq!(WakeOutcome::Held { reason: HeldReason::BrakeHeld }.to_string(), "held: BrakeHeld");
|
||||
assert_eq!(WakeOutcome::Launched { run: run_ref("p1") }.to_string(), format!("launched: {INSTANCE}:run:p1"));
|
||||
assert_eq!(WakeOutcome::Busy { run: run_ref("p1") }.to_string(), format!("busy: {INSTANCE}:run:p1"));
|
||||
assert_eq!(WakeOutcome::NotYet { until: at(0) }.to_string(), "not yet: until 2026-10-09T13:15:00+00:00");
|
||||
}
|
||||
|
|
@ -36,18 +36,16 @@ fn show_reports_every_rung_from_an_absent_store_to_released() {
|
|||
let root = tempfile::tempdir().unwrap();
|
||||
let (r, out) = go(root.path(), &["--instance", "eureka", "show"], "op", 0);
|
||||
r.unwrap();
|
||||
assert!(out.contains("brake: absent") && out.contains("dial: absent") && out.contains("BrakeAbsent"), "{out}");
|
||||
assert!(out.contains("brake: absent") && out.contains("BrakeAbsent"), "{out}");
|
||||
|
||||
go(root.path(), &["--instance", "eureka", "brake", "release"], "alice", 1).0.unwrap();
|
||||
let (_, out) = go(root.path(), &["--instance", "eureka", "show"], "op", 2);
|
||||
assert!(out.contains("brake: released") && out.contains("set_by alice") && out.contains("DialAbsent"), "{out}");
|
||||
assert!(out.contains("brake: released") && out.contains("set_by alice") && out.contains("BrakeAbsent"), "the swarm is still unreleased: {out}");
|
||||
|
||||
go(root.path(), &["--instance", "eureka", "dial", "set", "--heat", "0.5", "--base-cooldown-s", "3600", "--base-run-usd", "5"], "bob", 3)
|
||||
.0
|
||||
.unwrap();
|
||||
go(root.path(), &["swarm", "brake", "release"], "bob", 3).0.unwrap();
|
||||
go(root.path(), &["swarm", "dial", "set", "--heat", "0.5", "--base-rate", "4", "--base-run", "5", "--k", "2"], "bob", 3).0.unwrap();
|
||||
let (_, out) = go(root.path(), &["--instance", "eureka", "show"], "op", 4);
|
||||
assert!(out.contains("heat 0.5") && out.contains("base_cooldown_s 3600") && out.contains("set_by bob"), "{out}");
|
||||
assert!(out.contains("Released") && out.contains("7200s"), "{out}");
|
||||
assert!(out.contains("Released") && out.contains("run_cap_usd: 2.5"), "{out}");
|
||||
|
||||
go(root.path(), &["--instance", "eureka", "brake", "hold"], "alice", 5).0.unwrap();
|
||||
let (_, out) = go(root.path(), &["--instance", "eureka", "show"], "op", 6);
|
||||
|
|
@ -63,27 +61,6 @@ fn instances_do_not_share_a_store() {
|
|||
assert!(root.path().join("one").join("control.cc").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_dial_set_outside_bounds_refuses_and_leaves_the_store_unchanged() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
go(root.path(), &["--instance", "eureka", "brake", "release"], "op", 0).0.unwrap();
|
||||
go(root.path(), &["--instance", "eureka", "dial", "set", "--heat", "1", "--base-cooldown-s", "60", "--base-run-usd", "1"], "op", 1).0.unwrap();
|
||||
let path = root.path().join("eureka").join("control.cc");
|
||||
let before = std::fs::read(&path).unwrap();
|
||||
for heat in ["2.0001", "0.0499", "0", "-1"] {
|
||||
let (r, _) =
|
||||
go(root.path(), &["--instance", "eureka", "dial", "set", "--heat", heat, "--base-cooldown-s", "60", "--base-run-usd", "1"], "op", 9);
|
||||
assert!(r.is_err(), "heat {heat} must be refused");
|
||||
assert_eq!(std::fs::read(&path).unwrap(), before, "heat {heat} changed the store");
|
||||
}
|
||||
|
||||
// A refusal on a store that does not exist yet must not create it.
|
||||
let fresh = tempfile::tempdir().unwrap();
|
||||
let (r, _) = go(fresh.path(), &["--instance", "eureka", "dial", "set", "--heat", "5", "--base-cooldown-s", "60", "--base-run-usd", "1"], "op", 0);
|
||||
assert!(r.is_err());
|
||||
assert!(!fresh.path().join("eureka").join("control.cc").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_commands_are_refused_and_write_nothing() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
|
|
@ -94,11 +71,6 @@ fn malformed_commands_are_refused_and_write_nothing() {
|
|||
&["--instance", "eureka", "brake"],
|
||||
&["--instance", "eureka", "brake", "toggle"],
|
||||
&["--instance", "eureka", "dial", "set", "--heat", "1"],
|
||||
&["--instance", "eureka", "dial", "set", "--heat", "1", "--heat", "1", "--base-cooldown-s", "60", "--base-run-usd", "1"],
|
||||
&["--instance", "eureka", "dial", "set", "--heat", "x", "--base-cooldown-s", "60", "--base-run-usd", "1"],
|
||||
&["--instance", "eureka", "dial", "set", "--heat", "1", "--base-cooldown-s", "1.5", "--base-run-usd", "1"],
|
||||
&["--instance", "eureka", "dial", "set", "--heat", "1", "--base-cooldown-s", "60", "--base-run-usd", "1", "--extra", "1"],
|
||||
&["--instance", "eureka", "dial", "set", "--heat"],
|
||||
&["--instance", "../evil", "brake", "release"],
|
||||
&["--instances", "eureka", "brake", "release"],
|
||||
&["eureka", "eureka", "brake", "release"],
|
||||
|
|
@ -129,7 +101,7 @@ fn the_binary_exits_nonzero_with_the_usage_when_refused() {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn the_binary_exits_2_for_check_while_held_and_0_after_release_with_a_dial() {
|
||||
fn the_binary_exits_2_for_check_while_held_and_0_after_release_with_a_swarm() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let run = |args: &[&str]| {
|
||||
std::process::Command::new(env!("CARGO_BIN_EXE_mind-control"))
|
||||
|
|
@ -146,10 +118,14 @@ fn the_binary_exits_2_for_check_while_held_and_0_after_release_with_a_dial() {
|
|||
assert!(String::from_utf8_lossy(&output.stderr).contains("held: BrakeAbsent"));
|
||||
assert_eq!(std::fs::read_dir(root.path()).unwrap().count(), 0);
|
||||
|
||||
// Released with no dial is still refused; with a dial it opens.
|
||||
// Released with no swarm is still refused; with a released swarm it opens.
|
||||
assert!(run(&["brake", "release"]).status.success());
|
||||
assert_eq!(run(&["check"]).status.code(), Some(2));
|
||||
assert!(run(&["dial", "set", "--heat", "0.5", "--base-cooldown-s", "3600", "--base-run-usd", "5"]).status.success());
|
||||
let swarm = |args: &[&str]| {
|
||||
std::process::Command::new(env!("CARGO_BIN_EXE_mind-control")).arg("--root").arg(root.path()).arg("swarm").args(args).output().unwrap()
|
||||
};
|
||||
assert!(swarm(&["brake", "release"]).status.success());
|
||||
assert!(swarm(&["dial", "set", "--heat", "0.5", "--base-rate", "4", "--base-run", "5", "--k", "2"]).status.success());
|
||||
let open = run(&["check"]);
|
||||
assert_eq!(open.status.code(), Some(0));
|
||||
assert!(String::from_utf8_lossy(&open.stdout).contains("Released"));
|
||||
|
|
@ -212,6 +188,8 @@ fn check_refuses_every_reading_but_released() {
|
|||
go(root.path(), &["--instance", "eureka", "brake", "hold"], "op", 1).0.unwrap();
|
||||
held(root.path(), "BrakeHeld");
|
||||
go(root.path(), &["--instance", "eureka", "brake", "release"], "op", 2).0.unwrap();
|
||||
held(root.path(), "BrakeAbsent");
|
||||
go(root.path(), &["swarm", "brake", "release"], "op", 3).0.unwrap();
|
||||
held(root.path(), "DialAbsent");
|
||||
// DialOutOfBounds cannot be written through the CLI: it is pinned in the crate's own tests.
|
||||
// An undecodable store is a held reading too.
|
||||
|
|
@ -222,12 +200,11 @@ fn check_refuses_every_reading_but_released() {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn check_opens_on_release_with_a_dial_and_is_per_instance() {
|
||||
fn check_opens_on_release_with_a_swarm_and_is_per_instance() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
go(root.path(), &["--instance", "eureka", "brake", "release"], "op", 0).0.unwrap();
|
||||
go(root.path(), &["--instance", "eureka", "dial", "set", "--heat", "0.5", "--base-cooldown-s", "3600", "--base-run-usd", "5"], "op", 1)
|
||||
.0
|
||||
.unwrap();
|
||||
go(root.path(), &["swarm", "brake", "release"], "op", 1).0.unwrap();
|
||||
go(root.path(), &["swarm", "dial", "set", "--heat", "0.5", "--base-rate", "4", "--base-run", "5", "--k", "2"], "op", 1).0.unwrap();
|
||||
let (result, out) = check_out(root.path());
|
||||
result.unwrap();
|
||||
assert!(out.contains("Released"), "{out}");
|
||||
|
|
@ -240,13 +217,12 @@ fn check_opens_on_release_with_a_dial_and_is_per_instance() {
|
|||
fn check_holds_for_the_named_instance_with_a_released_prefix_neighbour() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
go(root.path(), &["--instance", "eureka", "brake", "hold"], "op", 0).0.unwrap();
|
||||
go(root.path(), &["swarm", "brake", "release"], "op", 0).0.unwrap();
|
||||
go(root.path(), &["swarm", "dial", "set", "--heat", "0.5", "--base-rate", "4", "--base-run", "5", "--k", "2"], "op", 0).0.unwrap();
|
||||
// Neighbours that share the held instance's text as a prefix, or only an edge of it, are released with a dial.
|
||||
for (i, neighbour) in ["eureka-two", "eureka2", "xeureka"].into_iter().enumerate() {
|
||||
let at = 1 + 2 * i as i64;
|
||||
go(root.path(), &["--instance", neighbour, "brake", "release"], "op", at).0.unwrap();
|
||||
go(root.path(), &["--instance", neighbour, "dial", "set", "--heat", "0.5", "--base-cooldown-s", "3600", "--base-run-usd", "5"], "op", at + 1)
|
||||
.0
|
||||
.unwrap();
|
||||
let (open, _) = go(root.path(), &["--instance", neighbour, "check"], "op", 10);
|
||||
open.unwrap_or_else(|error| panic!("{neighbour} is released and must open: {error:#}"));
|
||||
}
|
||||
|
|
@ -304,14 +280,14 @@ fn brake_hold_stops_once_with_the_instance_and_the_store_already_held() {
|
|||
#[test]
|
||||
fn only_brake_hold_calls_the_stopper() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let dial = ["--instance", "eureka", "dial", "set", "--heat", "1", "--base-cooldown-s", "60", "--base-run-usd", "1"];
|
||||
let dial = ["swarm", "dial", "set", "--heat", "1", "--base-rate", "4", "--base-run", "1", "--k", "2"];
|
||||
let commands: &[&[&str]] = &[
|
||||
&["--instance", "eureka", "brake", "release"],
|
||||
&dial,
|
||||
&["--instance", "eureka", "show"],
|
||||
&["--instance", "eureka", "brake"],
|
||||
&["--instance", "eureka", "brake", "toggle"],
|
||||
&["--instance", "eureka", "dial", "set", "--heat", "99", "--base-cooldown-s", "60", "--base-run-usd", "1"],
|
||||
&["swarm", "dial", "set", "--heat", "99", "--base-rate", "4", "--base-run", "1", "--k", "2"],
|
||||
&["--instance", "../evil", "brake", "release"],
|
||||
&["--instance", "eureka", "brake", "hold", "now"],
|
||||
&[],
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@
|
|||
use std::net::TcpListener;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use mind_body::heartbeat::Models;
|
||||
use rust_decimal::Decimal;
|
||||
use mind_body::connector::{KitConnector, endpoint, key};
|
||||
use mind_body::persona::{Connector, TurnRequest};
|
||||
use model_connector::{
|
||||
|
|
@ -16,6 +18,8 @@ use model_connector::{
|
|||
const CALLER: &str = "eureka";
|
||||
const MODEL: &str = "claude-test-model";
|
||||
const KEY: &str = "connector-key-for-test";
|
||||
const HEARTBEAT: &str = "heartbeat";
|
||||
const HEARTBEAT_KEY: &str = "the-heartbeats-own-key";
|
||||
|
||||
enum Behaviour {
|
||||
Reply(&'static str),
|
||||
|
|
@ -81,11 +85,13 @@ fn daemon(behaviour: Behaviour) -> Daemon {
|
|||
let prices = PriceTable::from([(MODEL.to_string(), ModelPrice { rates: rate, long_context: None, overhead_tokens: 0 })]);
|
||||
let admission =
|
||||
CallerAdmission::new(CALLER, key(KEY), 1, [MODEL.to_string()], 2, 64 * 1024, 8192, SpendCeiling(1 << 40), SpendWindowMs(3_600_000)).unwrap();
|
||||
let observer =
|
||||
CallerAdmission::new(HEARTBEAT, key(HEARTBEAT_KEY), 1, [MODEL.to_string()], 2, 64 * 1024, 8192, SpendCeiling(1 << 40), SpendWindowMs(3_600_000)).unwrap();
|
||||
let limits = ServeLimits { max_frame_bytes: 256 * 1024, max_connections: 8, socket_timeout_ms: 5_000, max_expiry_skew_ms: 300_000 };
|
||||
let provider = Arc::new(FakeProvider { behaviour, seen: Mutex::default() });
|
||||
let backend = provider.clone();
|
||||
std::thread::spawn(move || {
|
||||
let _ = serve(listener, limits, vec![admission], prices, &replay, backend);
|
||||
let _ = serve(listener, limits, vec![admission, observer], prices, &replay, backend);
|
||||
});
|
||||
Daemon { endpoint, provider, _store: store }
|
||||
}
|
||||
|
|
@ -161,3 +167,14 @@ fn endpoints_are_the_connectors_loopback_tcp_address_and_a_rudp_url_is_refused()
|
|||
assert_eq!(key("secret\r\n"), "secret");
|
||||
assert_eq!(key("a b\n\n"), "a b");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_heartbeat_reads_a_personas_spend_as_itself_and_picks_her_row() {
|
||||
let daemon = daemon(Behaviour::Reply("{}"));
|
||||
connector(&daemon, MODEL).turn(&turn_request()).unwrap();
|
||||
let heartbeat = KitConnector::new(endpoint(&daemon.endpoint).unwrap(), HEARTBEAT_KEY, MODEL, HEARTBEAT).unwrap();
|
||||
let spent = Models::spent(&heartbeat, CALLER).expect("a persona's row, asked under the heartbeat's own key");
|
||||
assert_eq!(spent.usd, Decimal::new(13, 6));
|
||||
assert_eq!(Models::spent(&heartbeat, HEARTBEAT).unwrap().usd, Decimal::ZERO, "its own row is there too");
|
||||
assert!(Models::spent(&heartbeat, "nobody").is_err(), "a caller with no row is an error, not zero");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,60 +19,12 @@ use std::time::Duration;
|
|||
|
||||
use chrono::{DateTime, TimeZone, Utc};
|
||||
use eureka_pipeline::Slug;
|
||||
use mind_body::control::{BurnRate, ControlWriter, FileSource, SwarmWriter, control_path, load_state, swarm_path};
|
||||
use rust_decimal::Decimal;
|
||||
use std::str::FromStr;
|
||||
|
||||
fn dec(text: &str) -> Decimal {
|
||||
Decimal::from_str(text).unwrap()
|
||||
}
|
||||
use mind_body::control::{ControlWriter, FileSource, SwarmWriter, control_path, load_state, swarm_path};
|
||||
|
||||
fn at(seconds: i64) -> DateTime<Utc> {
|
||||
Utc.timestamp_opt(1_800_000_000 + seconds, 0).unwrap()
|
||||
}
|
||||
|
||||
fn dial(heat: &str, cooldown: u32, run_usd: &str) -> BurnRate {
|
||||
BurnRate { heat: dec(heat), base_cooldown_s: cooldown, base_run_usd: dec(run_usd), set_at: at(0), set_by: "op".into() }
|
||||
}
|
||||
|
||||
/// The store of the instance `eureka` under the root `dir`.
|
||||
fn store_path(dir: &Path) -> PathBuf {
|
||||
dir.join("eureka").join("control.cc")
|
||||
}
|
||||
|
||||
/// A written store: brake released and the given dial, through the real writer.
|
||||
fn released_store(dir: &Path, dial: BurnRate) -> PathBuf {
|
||||
let path = store_path(dir);
|
||||
let mut writer = ControlWriter::open(&path).unwrap();
|
||||
writer.set_brake(true, at(0), "op").unwrap();
|
||||
writer.set_dial(dial).unwrap();
|
||||
path
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn setting_the_dial_replaces_it_whole_and_keeps_the_brake() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = released_store(dir.path(), dial("1", 3600, "1"));
|
||||
let mut next = dial("0.5", 100, "8");
|
||||
next.set_at = at(30);
|
||||
next.set_by = "carol".into();
|
||||
ControlWriter::open(&path).unwrap().set_dial(next.clone()).unwrap();
|
||||
let state = load_state(&FileSource::new(&path)).unwrap();
|
||||
assert_eq!(state.dial, Some(next));
|
||||
assert!(state.brake.unwrap().released);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_writer_refuses_an_out_of_bounds_dial_and_leaves_the_store_unchanged() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = released_store(dir.path(), dial("1", 3600, "1"));
|
||||
let before = std::fs::read(&path).unwrap();
|
||||
for bad in [dial("2.0001", 3600, "1"), dial("0.0499", 3600, "1"), dial("1", 0, "1"), dial("1", 3600, "0")] {
|
||||
assert!(ControlWriter::open(&path).unwrap().set_dial(bad).is_err());
|
||||
assert_eq!(std::fs::read(&path).unwrap(), before);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_control_path_is_derived_from_a_valid_instance_only() {
|
||||
let root = Path::new(mind_body::control::CONTROL_ROOT);
|
||||
|
|
@ -107,7 +59,6 @@ mod modes {
|
|||
"write" => {
|
||||
let mut writer = ControlWriter::open(&path).unwrap();
|
||||
writer.set_brake(true, at(0), "op").unwrap();
|
||||
writer.set_dial(dial("1", 3600, "1")).unwrap();
|
||||
}
|
||||
"brake" => {
|
||||
ControlWriter::open(&path).unwrap().set_brake(true, at(0), "op").unwrap();
|
||||
|
|
@ -339,11 +290,7 @@ mod unsafe_installs {
|
|||
.unwrap();
|
||||
let bin = env!("CARGO_BIN_EXE_mind-control");
|
||||
for round in 0..100 {
|
||||
let verb: &[&str] = if round % 2 == 0 {
|
||||
&["brake", "release"]
|
||||
} else {
|
||||
&["dial", "set", "--heat", "1", "--base-cooldown-s", "60", "--base-run-usd", "1"]
|
||||
};
|
||||
let verb: &[&str] = if round % 2 == 0 { &["brake", "release"] } else { &["show"] };
|
||||
let status = Command::new("sh")
|
||||
.args(["-c", "umask 000; exec \"$@\"", "sh", bin, "--root", minds.to_str().unwrap(), "--instance", &instance])
|
||||
.args(verb)
|
||||
|
|
|
|||
Binary file not shown.
1
crates/mind-body/tests/fixtures/persona-chat-v1/gamecult.persona.chat_refusal.v1.msgpack
vendored
Normal file
1
crates/mind-body/tests/fixtures/persona-chat-v1/gamecult.persona.chat_refusal.v1.msgpack
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
’¥ghost®UnknownPersona
|
||||
|
|
@ -15,8 +15,7 @@ use std::path::Path;
|
|||
|
||||
use chrono::{TimeZone, Utc};
|
||||
use eureka_pipeline::Slug;
|
||||
use mind_body::control::{BurnRate, ControlWriter, Effective, HeldReason, control_path, read_effective};
|
||||
use rust_decimal::Decimal;
|
||||
use mind_body::control::{ControlWriter, Effective, HeldReason, control_path, read_effective};
|
||||
|
||||
/// A name no real install has a store for under the real root.
|
||||
const FORGED: &str = "forge-probe-instance";
|
||||
|
|
@ -26,9 +25,6 @@ fn release(root: &Path, instance: &str) {
|
|||
let mut writer = ControlWriter::open(control_path(root, &Slug(instance.into())).unwrap()).unwrap();
|
||||
let at = Utc.timestamp_opt(1_800_000_000, 0).unwrap();
|
||||
writer.set_brake(true, at, "forger").unwrap();
|
||||
writer
|
||||
.set_dial(BurnRate { heat: Decimal::ONE, base_cooldown_s: 3600, base_run_usd: Decimal::new(5, 0), set_at: at, set_by: "forger".into() })
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn reads(instance: &str) -> Effective {
|
||||
|
|
|
|||
|
|
@ -3,24 +3,31 @@ mind_body: mod chat_rudp
|
|||
mind_body: mod cli
|
||||
mind_body: mod connector
|
||||
mind_body: mod control
|
||||
mind_body: mod heartbeat
|
||||
mind_body: mod launch
|
||||
mind_body: mod lease_client
|
||||
mind_body: mod persona
|
||||
mind_body: mod queue
|
||||
mind_body: mod self_run
|
||||
mind_body: mod wake
|
||||
mind_body::chat_client: # [derive (Clone , Copy , Debug , PartialEq , Eq)] pub enum Refusal { Brake , PersonaMismatch , UnknownRoom , EmptyText , SendFailed , UnknownPersona , }
|
||||
mind_body::chat_client: const ACK : & str
|
||||
mind_body::chat_client: const PENDING : & str
|
||||
mind_body::chat_client: const PENDING_REQUEST : & str
|
||||
mind_body::chat_client: const REFUSAL : & str
|
||||
mind_body::chat_client: const SEND_RECEIPT : & str
|
||||
mind_body::chat_client: const SEND_REQUEST : & str
|
||||
mind_body::chat_client: fn decode < T : DeserializeOwned > (bytes : & [u8]) -> Result < T >
|
||||
mind_body::chat_client: fn encode < T : Serialize > (document : & T) -> Result < Vec < u8 > >
|
||||
mind_body::chat_client: impl Refusal
|
||||
mind_body::chat_client: impl Refusal: fn name (self) -> & 'static str
|
||||
mind_body::chat_client: impl Serialize for Refusal
|
||||
mind_body::chat_client: impl< 'de > Deserialize < 'de > for Refusal
|
||||
mind_body::chat_client: pub trait Chat { fn pending (& self , after_seq : u64) -> Result < Vec < ChatDelivery > > ; fn send (& self , request : & ChatSendRequest) -> Result < ChatSendReceipt > ; fn ack (& self , through_seq : u64) -> Result < () > ; }
|
||||
mind_body::chat_client: struct ChatAck # [derive (Clone , Debug , PartialEq , Eq , Serialize , Deserialize)] pub-fields[persona: String, through_seq: u64]
|
||||
mind_body::chat_client: struct ChatDelivery # [derive (Clone , Debug , PartialEq , Eq , Serialize , Deserialize)] pub-fields[seq: u64, room_alias: String, room_id: String, event_id: String, sender: String, sender_display: String, text: String, at_unix_ms: u64, context: Vec < ContextLine >]
|
||||
mind_body::chat_client: struct ChatDelivery # [derive (Clone , Debug , PartialEq , Eq , Serialize , Deserialize)] pub-fields[seq: u64, room_alias: String, room_id: String, event_id: String, sender: String, sender_display: String, text: String, at_unix_ms: u64, context: Vec < ContextLine >, mentions: Vec < String >, is_direct: bool]
|
||||
mind_body::chat_client: struct ChatPending # [derive (Clone , Debug , PartialEq , Eq , Serialize , Deserialize)] pub-fields[persona: String, deliveries: Vec < ChatDelivery >]
|
||||
mind_body::chat_client: struct ChatPendingRequest # [derive (Clone , Debug , PartialEq , Eq , Serialize , Deserialize)] pub-fields[persona: String, after_seq: u64]
|
||||
mind_body::chat_client: struct ChatRefusal # [derive (Clone , Debug , PartialEq , Eq , Serialize , Deserialize)] pub-fields[persona: String, refusal: Refusal]
|
||||
mind_body::chat_client: struct ChatSendReceipt # [derive (Clone , Debug , PartialEq , Eq , Serialize , Deserialize)] pub-fields[request_id: String, event_id: Option < String >, refusal: Option < String >]
|
||||
mind_body::chat_client: struct ChatSendRequest # [derive (Clone , Debug , PartialEq , Eq , Serialize , Deserialize)] pub-fields[request_id: String, persona: String, room_alias: String, text: String]
|
||||
mind_body::chat_client: struct ContextLine # [derive (Clone , Debug , PartialEq , Eq , Serialize , Deserialize)] pub-fields[sender: String, text: String, at_unix_ms: u64]
|
||||
|
|
@ -46,23 +53,22 @@ mind_body::control: # [derive (Debug , PartialEq , Eq)] pub enum SwarmEffective
|
|||
mind_body::control: const BRAKE_KEY : & str
|
||||
mind_body::control: const CONTROL_ROOT : & str
|
||||
mind_body::control: const DIAL_KEY : & str
|
||||
mind_body::control: const K_MAX : u32
|
||||
mind_body::control: const SWARM_FILE : & str
|
||||
mind_body::control: fn base_rate_max () -> Decimal
|
||||
mind_body::control: fn base_run_max () -> Decimal
|
||||
mind_body::control: fn control_path (root : & Path , instance : & Slug) -> Result < PathBuf >
|
||||
mind_body::control: fn heat_max () -> Decimal
|
||||
mind_body::control: fn heat_min () -> Decimal
|
||||
mind_body::control: fn load_state (source : & impl ControlSource) -> Result < ControlState >
|
||||
mind_body::control: fn load_swarm_state (source : & impl ControlSource) -> Result < SwarmState >
|
||||
mind_body::control: fn read_effective (instance : & Slug) -> Effective
|
||||
mind_body::control: fn read_envelope () -> SwarmEffective
|
||||
mind_body::control: fn swarm_path (root : & Path) -> PathBuf
|
||||
mind_body::control: impl BurnRate
|
||||
mind_body::control: impl BurnRate: fn in_bounds (& self) -> bool
|
||||
mind_body::control: impl ControlSource for FileSource
|
||||
mind_body::control: impl ControlState
|
||||
mind_body::control: impl ControlWriter
|
||||
mind_body::control: impl ControlWriter: fn open (path : impl Into < PathBuf >) -> Result < Self >
|
||||
mind_body::control: impl ControlWriter: fn set_brake (& mut self , released : bool , set_at : DateTime < Utc > , set_by : & str) -> Result < () >
|
||||
mind_body::control: impl ControlWriter: fn set_dial (& mut self , dial : BurnRate) -> Result < () >
|
||||
mind_body::control: impl Envelope
|
||||
mind_body::control: impl Envelope: fn grant_for (& self , instance : & Slug , instance_effective : & Effective , budget_usd : Decimal) -> Option < Grant >
|
||||
mind_body::control: impl Envelope: fn k (& self) -> u32
|
||||
|
|
@ -72,7 +78,6 @@ mind_body::control: impl Envelope: fn share (& self , instance : & Slug) -> Deci
|
|||
mind_body::control: impl FileSource
|
||||
mind_body::control: impl FileSource: fn new (path : impl Into < PathBuf >) -> Self
|
||||
mind_body::control: impl Grant
|
||||
mind_body::control: impl Grant: fn cadence (& self) -> Duration
|
||||
mind_body::control: impl Grant: fn instance (& self) -> & Slug
|
||||
mind_body::control: impl Grant: fn run_cap_usd (& self) -> Decimal
|
||||
mind_body::control: impl Share
|
||||
|
|
@ -89,8 +94,7 @@ mind_body::control: impl std :: error :: Error for UnsafeStore
|
|||
mind_body::control: impl std :: fmt :: Display for UnsafeStore
|
||||
mind_body::control: pub trait ControlSource { fn snapshot (& self) -> Result < Vec < CultCacheEnvelope > > ; }
|
||||
mind_body::control: struct Brake # [derive (Clone , Debug , PartialEq , Eq , DatabaseEntry)] pub-fields[released: bool, set_at: DateTime < Utc >, set_by: String]
|
||||
mind_body::control: struct BurnRate # [derive (Clone , Debug , PartialEq , Eq , DatabaseEntry)] pub-fields[heat: Decimal, base_cooldown_s: u32, base_run_usd: Decimal, set_at: DateTime < Utc >, set_by: String]
|
||||
mind_body::control: struct ControlState # [derive (Clone , Debug , PartialEq , Eq , Default)] pub-fields[brake: Option < Brake >, dial: Option < BurnRate >]
|
||||
mind_body::control: struct ControlState # [derive (Clone , Debug , PartialEq , Eq , Default)] pub-fields[brake: Option < Brake >]
|
||||
mind_body::control: struct ControlWriter pub-fields[]
|
||||
mind_body::control: struct Envelope # [derive (Debug , PartialEq , Eq)] pub-fields[]
|
||||
mind_body::control: struct FileSource pub-fields[]
|
||||
|
|
@ -100,6 +104,46 @@ mind_body::control: struct SwarmDial # [derive (Clone , Debug , PartialEq , Eq ,
|
|||
mind_body::control: struct SwarmState # [derive (Clone , Debug , PartialEq , Eq , Default)] pub-fields[brake: Option < Brake >, dial: Option < SwarmDial >, shares: Vec < Share >]
|
||||
mind_body::control: struct SwarmWriter pub-fields[]
|
||||
mind_body::control: struct UnsafeStore # [derive (Debug)] pub-fields[path: PathBuf, mode: u32, uid: u32]
|
||||
mind_body::heartbeat: # [derive (Clone , Debug , PartialEq , Eq)] pub enum Outcome { Held (HeldReason) , Polled { launched : Vec < Slug > , } , }
|
||||
mind_body::heartbeat: const AGENT : & str
|
||||
mind_body::heartbeat: const CLASSIFIER_CALLER : & str
|
||||
mind_body::heartbeat: fn poll (organs : & Organs , config : & Config) -> Result < Outcome >
|
||||
mind_body::heartbeat: fn store_classifier_config (path : impl Into < PathBuf > , config : & ClassifierConfig) -> Result < () >
|
||||
mind_body::heartbeat: impl ClassifierConfig
|
||||
mind_body::heartbeat: impl ClassifierConfig: fn standard () -> Self
|
||||
mind_body::heartbeat: impl ClassifierConfig: fn validate (& self) -> Result < () >
|
||||
mind_body::heartbeat: impl Config
|
||||
mind_body::heartbeat: impl Config: fn new (personas : Vec < PersonaId > , operators : Vec < String >) -> Result < Self >
|
||||
mind_body::heartbeat: impl Control for FileControl
|
||||
mind_body::heartbeat: impl FileStore
|
||||
mind_body::heartbeat: impl FileStore: fn new (path : impl Into < PathBuf >) -> Self
|
||||
mind_body::heartbeat: impl FileTunables
|
||||
mind_body::heartbeat: impl FileTunables: fn new (path : impl Into < PathBuf >) -> Self
|
||||
mind_body::heartbeat: impl Initiative
|
||||
mind_body::heartbeat: impl Models for KitConnector
|
||||
mind_body::heartbeat: impl PersonaId
|
||||
mind_body::heartbeat: impl PersonaId: fn parse (spec : & str) -> Result < Self >
|
||||
mind_body::heartbeat: impl Store for FileStore
|
||||
mind_body::heartbeat: impl Tunables for FileTunables
|
||||
mind_body::heartbeat: impl fmt :: Display for Outcome
|
||||
mind_body::heartbeat: impl fmt :: Display for PollTrace
|
||||
mind_body::heartbeat: pub trait Control { fn envelope (& self) -> SwarmEffective ; fn effective (& self , instance : & Slug) -> Effective ; }
|
||||
mind_body::heartbeat: pub trait Models { fn classify (& self , request : & TurnRequest) -> Result < String > ; fn spent (& self , caller : & str) -> Result < Spend > ; }
|
||||
mind_body::heartbeat: pub trait Store { fn load (& self , instance : & Slug) -> Result < Option < Initiative > > ; fn save (& self , instance : & Slug , initiative : & Initiative) -> Result < () > ; fn record (& self , trace : & PollTrace) -> Result < () > ; fn latest (& self) -> Result < Option < PollTrace > > ; }
|
||||
mind_body::heartbeat: pub trait Tunables { fn classifier (& self) -> Result < ClassifierConfig > ; }
|
||||
mind_body::heartbeat: struct BurnTrace # [derive (Clone , Debug , PartialEq , Eq , Serialize , Deserialize)] pub-fields[run: String, spend_usd: Decimal, source: String]
|
||||
mind_body::heartbeat: struct ClassifierConfig # [derive (Clone , Debug , PartialEq , Eq , DatabaseEntry)] pub-fields[instructions: String, show_idle: bool, show_urgency: bool, show_completions: bool, urgency_min: Decimal, urgency_max: Decimal, urgency_ttl_s: u32]
|
||||
mind_body::heartbeat: struct Config # [derive (Clone , Debug , PartialEq , Eq)] pub-fields[personas: Vec < PersonaId >, operators: Vec < String >]
|
||||
mind_body::heartbeat: struct FileControl pub-fields[]
|
||||
mind_body::heartbeat: struct FileStore pub-fields[]
|
||||
mind_body::heartbeat: struct FileTunables pub-fields[]
|
||||
mind_body::heartbeat: struct Initiative # [derive (Clone , Debug , PartialEq , Eq , DatabaseEntry)] pub-fields[initiative_usd: Decimal, as_of: DateTime < Utc >, urgency: Decimal, urgency_at: DateTime < Utc >, last_burned_run: String, filled_events: Vec < String >, classified_seq: String, launched_run: String, launch_spend_usd: Decimal, launch_spend_known: bool, completions_through: String, launch_window: u64]
|
||||
mind_body::heartbeat: struct InstanceTrace # [derive (Clone , Debug , PartialEq , Eq , Serialize , Deserialize)] pub-fields[instance: String, initiative_usd: Decimal, rate_usd_per_h: Decimal, urgency: Decimal, urgency_age_s: i64, busy: bool, released: bool, crossing_at: String, waited_s: i64, idle_s: i64, filled_by: String, fill_reason: String, note: String]
|
||||
mind_body::heartbeat: struct Organs< 'a > pub-fields[seats: & 'a [Seat < 'a >], control: & 'a dyn Control, models: & 'a dyn Models, tunables: & 'a dyn Tunables, launcher: & 'a dyn Launcher, clock: & 'a dyn Clock, host: & 'a str, store: & 'a dyn Store]
|
||||
mind_body::heartbeat: struct PersonaId # [derive (Clone , Debug , PartialEq , Eq)] pub-fields[slug: Slug, public_name: String, handle: String, matrix_id: String, huginn: SocketAddr]
|
||||
mind_body::heartbeat: struct PollTrace # [derive (Clone , Debug , PartialEq , Eq , DatabaseEntry)] pub-fields[at: DateTime < Utc >, instances: Vec < InstanceTrace >, launches: Vec < String >, burns: Vec < BurnTrace >, classifier: String]
|
||||
mind_body::heartbeat: struct Seat< 'a > pub-fields[who: PersonaId, mind: & 'a dyn MindPort, chat: & 'a dyn Chat]
|
||||
mind_body::heartbeat: struct Spend # [derive (Clone , Copy , Debug , PartialEq , Eq)] pub-fields[usd: Decimal, window: u64]
|
||||
mind_body::launch: # [derive (Clone , Debug , PartialEq , Eq)] pub enum Declined { Admission (MindRefusal) , Conflict , AlreadyAdmitted , StartFailed , GrantForOtherInstance , PersonaRunTakesNoClaims , }
|
||||
mind_body::launch: # [derive (Clone , Debug , PartialEq , Eq)] pub enum LaunchOutcome { Launched { run : PipelineRef , } , NotQueued { item : PipelineRef , } , Tripped { item : PipelineRef , question : PipelineRef , } , Busy { run : PipelineRef , } , Refused (Declined) , }
|
||||
mind_body::launch: const START_GRACE_S : i64
|
||||
|
|
@ -144,7 +188,7 @@ mind_body::lease_client: struct TokenFd pub-fields[]
|
|||
mind_body::lease_client: struct TokenUsage # [derive (Clone , Debug , Default , PartialEq , Eq , Serialize , Deserialize)] pub-fields[input_tokens: u64, cache_read_tokens: u64, cache_write_tokens: u64, output_tokens: u64, reasoning_tokens: Option < u64 >]
|
||||
mind_body::lease_client::socket: impl Lease for Granted
|
||||
mind_body::persona: # [derive (Clone , Copy , Debug , PartialEq , Eq)] pub enum Failure { Held (HeldReason) , Chat , Persona , Readout , Connector , Undecodable , RoomNotAllowed , Send , SelfRun , Ack , }
|
||||
mind_body::persona: # [derive (Debug , PartialEq , Eq)] pub enum Outcome { Dead (HeldReason) , NoRun (Declined) , Served { run : PipelineRef , answered : u32 , failure : Option < Failure > , self_run : Option < PipelineRef > } , }
|
||||
mind_body::persona: # [derive (Debug , PartialEq , Eq)] pub enum Outcome { Dead (HeldReason) , NoRun , Served { run : PipelineRef , answered : u32 , failure : Option < Failure > , self_run : Option < PipelineRef > } , }
|
||||
mind_body::persona: const AGENT : & str
|
||||
mind_body::persona: const ENV_BURN_ROOM : & str
|
||||
mind_body::persona: const ENV_OPERATOR : & str
|
||||
|
|
@ -153,7 +197,7 @@ mind_body::persona: const ENV_SELF_SENDER : & str
|
|||
mind_body::persona: const MAX_TURNS_PER_START : u32
|
||||
mind_body::persona: fn burn_line (readout : & SpendReadout , caller : & str , heat : Option < Decimal > , live_runs : usize) -> Option < String >
|
||||
mind_body::persona: fn reply_schema (rooms : & [String]) -> String
|
||||
mind_body::persona: fn start (organs : & Organs , control : & impl ControlSource , config : & Config) -> Result < Outcome >
|
||||
mind_body::persona: fn start (organs : & Organs , control : & impl ControlSource , swarm : & impl ControlSource , config : & Config) -> Result < Outcome >
|
||||
mind_body::persona: impl Config
|
||||
mind_body::persona: impl Config: fn from_args (args : & [String] , usage : & str) -> Result < (Self , Vec < String >) >
|
||||
mind_body::persona: impl Config: fn from_args_and_env (args : & [String] , lookup : impl Fn (& str) -> Option < String > , usage : & str) -> Result < (Self , Vec < String >) >
|
||||
|
|
@ -161,7 +205,6 @@ mind_body::persona: impl FileTrace
|
|||
mind_body::persona: impl FileTrace: fn new (path : impl Into < PathBuf >) -> Self
|
||||
mind_body::persona: impl HuginnPersona
|
||||
mind_body::persona: impl HuginnPersona: fn new (client : HuginnClient) -> Self
|
||||
mind_body::persona: impl Launcher for ThisUnit
|
||||
mind_body::persona: impl PersonaState for HuginnPersona
|
||||
mind_body::persona: impl Sender
|
||||
mind_body::persona: impl Trace for FileTrace
|
||||
|
|
@ -230,7 +273,3 @@ mind_body::self_run: impl fmt :: Display for SelfRunOutcome
|
|||
mind_body::self_run: pub trait Child { fn write_line (& mut self , line : & str) ; fn close_input (& mut self) ; fn next (& mut self , timeout : StdDuration) -> Read ; fn stop (& mut self , how : Stop) -> bool ; }
|
||||
mind_body::self_run: pub trait Spawner { fn spawn (& self , args : & [String] , token : & TokenFd) -> Result < Box < dyn Child > > ; }
|
||||
mind_body::self_run: struct ClaudeSpawner pub-fields[]
|
||||
mind_body::wake: # [derive (Clone , Debug , PartialEq , Eq)] pub enum WakeOutcome { Held { reason : HeldReason } , NotYet { until : DateTime < Utc > } , Busy { run : PipelineRef } , Launched { run : PipelineRef } , Refused (Declined) , }
|
||||
mind_body::wake: const AGENT : & str
|
||||
mind_body::wake: fn cycle (ports : & Ports , control : & impl ControlSource) -> Result < WakeOutcome >
|
||||
mind_body::wake: impl fmt :: Display for WakeOutcome
|
||||
|
|
|
|||
|
|
@ -1,205 +0,0 @@
|
|||
//! The `mind-wake` binary as a process: its arguments and its exit codes. The
|
||||
//! brake's root is a constant, so a process test can only ever see the absent
|
||||
//! store of an instance no host has; the cycle's behaviour is tested in the
|
||||
//! library against a real mind and a real store. One test, `#[ignore]`d outside a
|
||||
//! container, puts the real binary, the real daemon and a real store at the
|
||||
//! brake's root together, which is where the host a run records can be seen.
|
||||
|
||||
use std::net::UdpSocket;
|
||||
use std::path::Path;
|
||||
use std::process::{Child, Command, Output, Stdio};
|
||||
use std::str::FromStr;
|
||||
use std::time::Duration;
|
||||
|
||||
use chrono::Utc;
|
||||
use cultnet_rs::Selection;
|
||||
use eureka_pipeline::{
|
||||
Date, DocRef, PipelineCampaign, PipelineDocument, PipelineInstance, PipelineKind, PipelineStewardship, PipelineTarget, Sha, Slug, TargetInvariant,
|
||||
};
|
||||
use huginn_mind::{Faculty, Mind, PipelineAdmissionBatch, PipelineAdmissionOutcome, PipelinePageItems, PipelineProvenance};
|
||||
use mind_body::control::{BurnRate, CONTROL_ROOT, ControlWriter, control_path};
|
||||
use rust_decimal::Decimal;
|
||||
use tempfile::TempDir;
|
||||
|
||||
const NO_SUCH_INSTANCE: &str = "wake-test-no-such-instance";
|
||||
|
||||
fn wake(args: &[&str]) -> Output {
|
||||
Command::new(env!("CARGO_BIN_EXE_mind-wake")).args(args).env_remove("HUGINN_ENDPOINT").output().unwrap()
|
||||
}
|
||||
|
||||
/// The binary with `HUGINN_ENDPOINT` set to `endpoint`.
|
||||
fn wake_with_env_endpoint(args: &[&str], endpoint: &str) -> Output {
|
||||
Command::new(env!("CARGO_BIN_EXE_mind-wake")).args(args).env("HUGINN_ENDPOINT", endpoint).output().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn without_an_instance_or_with_a_stray_argument_it_exits_2_with_the_usage_and_echoes_nothing() {
|
||||
let canary = "canary-argument-3f9a";
|
||||
for args in [vec![], vec!["--endpoint", "rudp://127.0.0.1:9"], vec!["--instance", NO_SUCH_INSTANCE, canary], vec!["--instance", NO_SUCH_INSTANCE, "--instance", canary]] {
|
||||
let output = wake(&args);
|
||||
assert_eq!(output.status.code(), Some(2), "{args:?}");
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
assert!(stderr.contains("usage: mind-wake"), "{args:?}: {stderr}");
|
||||
assert!(!stderr.contains(canary) && output.stdout.is_empty(), "{args:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_instance_that_is_not_a_slug_is_exit_2_and_is_not_echoed() {
|
||||
let canary = "Canary/../Instance-5e2b";
|
||||
let output = wake(&["--instance", canary, "--endpoint", "rudp://127.0.0.1:9"]);
|
||||
assert_eq!(output.status.code(), Some(2));
|
||||
assert!(!String::from_utf8_lossy(&output.stderr).contains("anary"), "{}", String::from_utf8_lossy(&output.stderr));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_held_brake_ends_the_cycle_with_exit_0_before_the_mind_is_contacted() {
|
||||
// The endpoint answers no one (port 9 on loopback), so a cycle that
|
||||
// reached the mind would exit 2; the absent store holds the cycle first.
|
||||
let output = wake(&["--instance", NO_SUCH_INSTANCE, "--endpoint", "rudp://127.0.0.1:9"]);
|
||||
assert_eq!(output.status.code(), Some(0), "{}", String::from_utf8_lossy(&output.stderr));
|
||||
assert_eq!(String::from_utf8_lossy(&output.stdout).trim(), "held: BrakeAbsent");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_malformed_endpoint_or_none_is_exit_2_and_the_endpoint_is_not_echoed() {
|
||||
let canary = "canary-endpoint-77c1";
|
||||
let bad = wake(&["--instance", NO_SUCH_INSTANCE, "--endpoint", canary]);
|
||||
assert_eq!(bad.status.code(), Some(2));
|
||||
assert!(!String::from_utf8_lossy(&bad.stderr).contains(canary));
|
||||
let none = wake(&["--instance", NO_SUCH_INSTANCE]);
|
||||
assert_eq!(none.status.code(), Some(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn without_the_flag_the_daemon_is_found_through_huginn_endpoint_and_a_bad_value_is_not_echoed() {
|
||||
// With no endpoint at all the cycle exits 2 (above); with the variable set it
|
||||
// goes on to the brake, which holds an instance no host has, and exits 0.
|
||||
let held = wake_with_env_endpoint(&["--instance", NO_SUCH_INSTANCE], "rudp://127.0.0.1:9");
|
||||
assert_eq!(held.status.code(), Some(0), "{}", String::from_utf8_lossy(&held.stderr));
|
||||
assert_eq!(String::from_utf8_lossy(&held.stdout).trim(), "held: BrakeAbsent");
|
||||
|
||||
let canary = "canary-env-endpoint-4d02";
|
||||
let bad = wake_with_env_endpoint(&["--instance", NO_SUCH_INSTANCE], canary);
|
||||
assert_eq!(bad.status.code(), Some(2));
|
||||
assert!(!String::from_utf8_lossy(&bad.stderr).contains(canary) && bad.stdout.is_empty());
|
||||
|
||||
// The flag wins over the variable: a valid flag with a bad variable still holds.
|
||||
let flag = wake_with_env_endpoint(&["--instance", NO_SUCH_INSTANCE, "--endpoint", "rudp://127.0.0.1:9"], canary);
|
||||
assert_eq!(flag.status.code(), Some(0));
|
||||
}
|
||||
|
||||
const INSTANCE: &str = "yggdrasil";
|
||||
const CAMPAIGN: &str = "eureka-body";
|
||||
const REPO: &str = "GameCult/Huginn";
|
||||
|
||||
fn doc_ref() -> DocRef {
|
||||
DocRef { path: "docs/eureka-body-target.md".into(), start_line: 1, end_line: 9, commit: Sha("df7507b".into()) }
|
||||
}
|
||||
|
||||
/// The identity, stewardship, campaign and target a Persona turn's run cites.
|
||||
fn seed() -> Vec<PipelineDocument> {
|
||||
let date = || Date("2026-10-09".into());
|
||||
vec![
|
||||
PipelineDocument::Instance(PipelineInstance { instance: Slug(INSTANCE.into()), display_name: "test mind".into(), created_at: date(), host: "test".into() }),
|
||||
PipelineDocument::Stewardship(PipelineStewardship { instance: Slug(INSTANCE.into()), repo: REPO.into(), sequence: 1, assigned_on: date(), note: "assigned".into() }),
|
||||
PipelineDocument::Campaign(PipelineCampaign {
|
||||
slug: Slug(CAMPAIGN.into()),
|
||||
title: "Eureka body".into(),
|
||||
repos: vec![REPO.into()],
|
||||
working_branch: "main".into(),
|
||||
target_doc: doc_ref(),
|
||||
}),
|
||||
PipelineDocument::Target(PipelineTarget {
|
||||
campaign: Slug(CAMPAIGN.into()),
|
||||
revision: 1,
|
||||
invariants: vec![TargetInvariant { label: "run-is-the-grant".into(), statement: "A run is the grant.".into() }],
|
||||
not_in_scope: vec![],
|
||||
canonical_implementations: vec![],
|
||||
repos: vec![],
|
||||
doc: doc_ref(),
|
||||
}),
|
||||
]
|
||||
}
|
||||
|
||||
/// The daemon, ended with the test whether it passes or not.
|
||||
struct Daemon(Child);
|
||||
|
||||
impl Drop for Daemon {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.0.kill();
|
||||
let _ = self.0.wait();
|
||||
}
|
||||
}
|
||||
|
||||
/// The real binary against the real daemon and a real store at the brake's
|
||||
/// root. It writes `/etc/gamecult/minds`, so it runs in a container only
|
||||
/// (`/.dockerenv`), as root, after `cargo build -p huginn-daemon` has put the
|
||||
/// daemon beside the binary. `PATH` is empty so no unit is ever reached: the
|
||||
/// run opens, its unit fails to start and the run is withdrawn, which is the
|
||||
/// outcome printed.
|
||||
///
|
||||
/// `cargo test` and `cargo mutants` skip it, so the `host()` mutants survive
|
||||
/// there. Run it in a container as root with the README's lane command
|
||||
/// (`crates/mind-body/README.md`).
|
||||
#[test]
|
||||
#[ignore = "container only: root, /etc/gamecult/minds and a built huginn-daemon beside the binary"]
|
||||
fn the_real_bin_against_the_real_daemon_opens_a_run_that_records_this_machines_host_and_the_agent() {
|
||||
assert!(Path::new("/.dockerenv").exists(), "this test writes the brake's root and runs in a container only");
|
||||
let slug = Slug(INSTANCE.into());
|
||||
let state = TempDir::new().unwrap();
|
||||
{
|
||||
let mut mind = Mind::open(state.path(), &slug).unwrap();
|
||||
let batch = PipelineAdmissionBatch {
|
||||
instance: slug.clone(),
|
||||
provenance: PipelineProvenance { faculty: Faculty::Hands, agent: "test".into(), session: "test".into(), tool: "test".into() },
|
||||
documents: seed(),
|
||||
};
|
||||
assert!(matches!(mind.admit(batch, Utc::now()), PipelineAdmissionOutcome::Committed { .. }));
|
||||
}
|
||||
let mut writer = ControlWriter::open(control_path(Path::new(CONTROL_ROOT), &slug).unwrap()).unwrap();
|
||||
writer.set_brake(true, Utc::now(), "op").unwrap();
|
||||
let dial = BurnRate { heat: Decimal::from_str("1").unwrap(), base_cooldown_s: 600, base_run_usd: Decimal::from_str("5").unwrap(), set_at: Utc::now(), set_by: "op".into() };
|
||||
writer.set_dial(dial).unwrap();
|
||||
|
||||
let port = UdpSocket::bind("127.0.0.1:0").unwrap().local_addr().unwrap().port();
|
||||
let endpoint = format!("rudp://127.0.0.1:{port}");
|
||||
let wake_exe = Path::new(env!("CARGO_BIN_EXE_mind-wake"));
|
||||
let daemon_exe = wake_exe.with_file_name("huginn-daemon");
|
||||
assert!(daemon_exe.exists(), "build huginn-daemon first: {}", daemon_exe.display());
|
||||
let daemon = Daemon(
|
||||
Command::new(&daemon_exe)
|
||||
.args(["--state-root", state.path().to_str().unwrap(), "--instance", INSTANCE, "--bind", &format!("127.0.0.1:{port}")])
|
||||
.args(["--qdrant-url", "http://127.0.0.1:1", "--ollama-url", "http://127.0.0.1:1", "--embedding-model", "x"])
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.unwrap(),
|
||||
);
|
||||
|
||||
// Exit 2 before the daemon answers has opened nothing; try again until it does.
|
||||
let mut output = None;
|
||||
for _ in 0..40 {
|
||||
let attempt = Command::new(wake_exe).args(["--instance", INSTANCE, "--endpoint", &endpoint]).env("PATH", "").output().unwrap();
|
||||
if attempt.status.code() != Some(2) {
|
||||
output = Some(attempt);
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(500));
|
||||
}
|
||||
let output = output.expect("the daemon never answered");
|
||||
assert_eq!(output.status.code(), Some(0), "{}", String::from_utf8_lossy(&output.stderr));
|
||||
assert_eq!(String::from_utf8_lossy(&output.stdout).trim(), "refused: start_failed");
|
||||
drop(daemon);
|
||||
|
||||
let hostname = std::fs::read_to_string("/proc/sys/kernel/hostname").unwrap().trim().to_string();
|
||||
assert!(!hostname.is_empty());
|
||||
let mind = Mind::open(state.path(), &slug).unwrap();
|
||||
let runs = Selection { schemas: Some(vec![PipelineKind::Run.type_id().to_string()]), ..Selection::default() };
|
||||
let PipelinePageItems::Headers(headers) = mind.query(&runs).unwrap().items else { panic!("documents to a header selection") };
|
||||
assert_eq!(headers.len(), 1);
|
||||
let view = mind.view(&headers[0].id).unwrap().unwrap();
|
||||
let PipelineDocument::Run(run) = &view.document else { panic!("not a run") };
|
||||
assert_eq!(run.host.0, hostname);
|
||||
assert_eq!(view.admission.provenance.agent.0, "mind-wake");
|
||||
}
|
||||
|
|
@ -504,10 +504,10 @@ pipeline kinds are in Huginn `docs/eureka-substrate-map.md`.
|
|||
| --- | --- | --- | --- |
|
||||
| Pipeline wire ids | `epiphany.pipeline.<kind>.v2`, epoch `epiphany.pipeline.epoch.v2`, defined by the leaf, the Huginn crate `crates/eureka-pipeline` (lib `eureka_pipeline`), ids unchanged. | Renamed once to `eureka.pipeline.<kind>.v3` by one `mind.redb` migration, at the next breaking leaf change or before the first hosted mind (ruling `wire-names-rename-before-hosting`). Adding the `run` kind or the `Mind` authority variant is additive (old stored documents decode; no migration), so it does not trigger the rename; a field added with the leaf's `= absent` marker is additive too (leaf-read-anchors r2: old bytes decode, empty fields encode byte-identically, `golden/envelopes.txt` gains lines and loses none); a field added any other way would. | Huginn owns the leaf. The migration is a live-mind upgrade, which is the operator's (`mind-rules-which-forks`). |
|
||||
| Leaf crate and schema catalogue | Huginn `crates/eureka-pipeline`, `schemas/cultnet/` (13 pipeline schemas, `gamecult.persona_state.v0`, index, README). `epiphany.work_organ_state.v0` dies: no reader outside Epiphany (grep, F1/F3). | Landed at Huginn `afc35f9` (leaf-into-huginn). Wire pinned by `crates/eureka-pipeline/golden/envelopes.txt` and huginn-mind's opt-in `query::tests::stored_documents_read_back`. Huginn is AGPL-3.0-only (ruling `eureka-body:ruling:leaf-relicensed-agpl`). | Huginn. Forbidden: any Cargo source resolving into GameCult/Epiphany (`nothing-runs-from-epiphany`). |
|
||||
| `run` | `<instance>:run:<label>` (cut-run-kind r2). Label `mind-<YYYYMMDD>T<HHMMSS><mmm>Z` from mind-launch's `run_label`; two launches in one millisecond produce the same key with different content, which admission answers `Conflict`, so the name stays injective. Order is admission ordinal (F15), not label. | Each wake opens a PersonaTurn run with no claims (ruling `eureka-substrate:ruling:every-wake-a-run`); the Persona tool opens SelfRun runs with claims; both go through `open_and_launch`, and the unit starts only after the run commits (the run is the grant). The Self in a unit admits under its label and ends it `Recorded { reason }`. A run whose unit is not alive past a 120 s start grace is closed `Recorded { reason: "unit not active" }` by the next `open_and_launch` of the same turn, before it checks anything else. It is Recorded, not Withdrawn, so a unit that keeps dying counts as an empty ended run and trips the breaker. Only a unit whose start failed in the launch itself is Withdrawn. Cadence comes from the newest PersonaTurn run's `admitted_at` (any status), not `started_on` (a date cannot carry seconds). | Opening: `open_and_launch`, called by the waker for PersonaTurn and by the Persona tool for SelfRun, each with a `Grant` that only `read_effective` constructs. One live run of hers per turn and instance: huginn-mind admission refuses a second in-force run with `operated_by: Mind` of the same turn and instance as `AlreadyLive { run }` (ruling `one-live-self-run`, option `one-live-per-turn`; cut run-live-admission). `open_and_launch` answers `Busy` from that refusal, or earlier, when the holder it has just read is alive. Operator runs never count. `operated_by` is declared by the writer (follow-up `eureka-substrate:follow_up:operator-authority-needs-operator-credential`). Ending: the Self that worked it; a dead one is closed by the next launch of its turn (cut mind-launch r3). No sweep exists elsewhere. Forbidden: Hands, Soul, Imagination, Life. |
|
||||
| Repetition breaker | No state of its own (rulings `self-waker`, `merge-gate-own-soul`). Trip condition derived from one selection: the documents citing the item, in any role. Counted runs are the item's ended SelfRun runs of hers (`operated_by: Mind`, not withdrawn) admitted after the newest question `raised_in` the item. Trip when the last 3 counted runs are empty, or 10 of the last 50. A run is empty when no citing document other than a run has an ordinal in its window (its admission up to the next run's, or now). Documents that cite something else never count, wherever they were admitted (F21). | Evaluated in `open_and_launch` when a Self run claims the item, not by the waker. When tripped, it admits one `question` labelled `breaker-<run label>` under the item's campaign, `raised_in` the item, and launches nothing. The run label is the label the refused launch would have used, so every trip has its own key. The existing blocked-spec recipe then removes the item from the queue. Answered by a ruling or withdrawn: either reopens the item, and that question is the boundary, so a reopened item starts a fresh count. | `open_and_launch` admits the question; any Self may answer it (substrate-internal, `mind-rules-which-forks`). |
|
||||
| `run` | `<instance>:run:<label>` (cut-run-kind r2). Label `mind-<YYYYMMDD>T<HHMMSS><mmm>Z` from mind-launch's `run_label`; two launches in one millisecond produce the same key with different content, which admission answers `Conflict`, so the name stays injective. Order is admission ordinal (F15), not label. | The heartbeat opens a PersonaTurn run with no claims each time it launches a Persona turn (a `mind-persona` start that finds no such run opens none and runs nothing) (ruling `eureka-substrate:ruling:every-wake-a-run`); the Persona tool opens SelfRun runs with claims; both go through `open_and_launch`, and the unit starts only after the run commits (the run is the grant). The Self in a unit admits under its label and ends it `Recorded { reason }`. A run whose unit is not alive past a 120 s start grace is closed `Recorded { reason: "unit not active" }` by the next `open_and_launch` of the same turn, before it checks anything else. It is Recorded, not Withdrawn, so a unit that keeps dying counts as an empty ended run and trips the breaker. Only a unit whose start failed in the launch itself is Withdrawn. Idle time comes from the newest PersonaTurn run's `admitted_at` (any status), not `started_on` (a date cannot carry seconds); there is no cadence, the heartbeat's initiative timeline decides when a Persona wakes. | Opening: `open_and_launch`, called by the heartbeat for PersonaTurn and by the Persona tool for SelfRun, each with a `Grant` that only `read_effective` constructs. One live run of hers per turn and instance: huginn-mind admission refuses a second in-force run with `operated_by: Mind` of the same turn and instance as `AlreadyLive { run }` (ruling `one-live-self-run`, option `one-live-per-turn`; cut run-live-admission). `open_and_launch` answers `Busy` from that refusal, or earlier, when the holder it has just read is alive. Operator runs never count. `operated_by` is declared by the writer (follow-up `eureka-substrate:follow_up:operator-authority-needs-operator-credential`). Ending: the Self that worked it; a dead one is closed by the next launch of its turn (cut mind-launch r3). No sweep exists elsewhere. Forbidden: Hands, Soul, Imagination, Life. |
|
||||
| Repetition breaker | No state of its own (rulings `self-waker`, `merge-gate-own-soul`). Trip condition derived from one selection: the documents citing the item, in any role. Counted runs are the item's ended SelfRun runs of hers (`operated_by: Mind`, not withdrawn) admitted after the newest question `raised_in` the item. Trip when the last 3 counted runs are empty, or 10 of the last 50. A run is empty when no citing document other than a run has an ordinal in its window (its admission up to the next run's, or now). Documents that cite something else never count, wherever they were admitted (F21). | Evaluated in `open_and_launch` when a Self run claims the item, not by the heartbeat. When tripped, it admits one `question` labelled `breaker-<run label>` under the item's campaign, `raised_in` the item, and launches nothing. The run label is the label the refused launch would have used, so every trip has its own key. The existing blocked-spec recipe then removes the item from the queue. Answered by a ruling or withdrawn: either reopens the item, and that question is the boundary, so a reopened item starts a fresh count. | `open_and_launch` admits the question; any Self may answer it (substrate-internal, `mind-rules-which-forks`). |
|
||||
| Brake | One document per instance in a root-owned CultCache store `/etc/gamecult/minds/<instance>/control.cc`, type `eureka.control.brake.v1 { released: bool, set_at, set_by }`. | Replaced whole by the operator's CLI; never appended. Absent or undecodable reads as held. `mind-control brake hold` writes it held and then, in the same command, stops her running units: `systemctl stop` (blocking) over `mind-self@<i>:*` and `mind-persona@<i>:*`, through `SystemdLauncher::stop_all` (cut brake-stop; ruling `self-run-actuator`; F18-F20). | Operator only (ruling `brake-dial-root-store-now`); her units read it, and the file is not writable by any user she runs as. The hold is the one stopper of her units. `read_effective` is the one reader. Its `Released` answer carries a `Grant` (cadence and run cap, private fields, not `Clone`), which `open_and_launch` takes by value, so no run opens without a released reading (cut mind-launch r3). |
|
||||
| Burn-rate dial | Same store, `eureka.control.burn_rate.v1 { heat, base_cooldown_s, base_run_usd, set_at, set_by }`; `heat` in 0.05..=2.0. | Replaced whole by the CLI. Absent or out of bounds reads as heat 0, which launches nothing (fail closed, LiteLLM scar rider of ruling `connector-owner`). Schedules and her own rest arrive later as follow-up `burn-schedule-and-rest`. | Operator. Derived (never stored): wake cadence `base_cooldown_s / heat`, per-run cap `--max-budget-usd = base_run_usd × heat`, per-request `max_cost_usd`. |
|
||||
| Swarm dial (supersedes the per-instance burn-rate dial) | `CONTROL_ROOT/swarm.cc` only; the per-instance `control.cc` holds the brake and nothing else. `eureka.control.burn_rate.v1`, `Grant.cadence` and `base_cooldown_s` are deleted (cut mind-heartbeat). | Operator CLI `mind-control swarm ...`. | Ceilings: heat 2, base rate 100 USD/h, base run 50 USD, k 16. |
|
||||
| Connector request and transcript items | Request: `(caller_runtime_id, request_id)`, the replay key the daemon already keeps (`replay.cc`). Items: UserText, AssistantText, ToolCall, ToolResult, plus `ReasoningState { provider, model, opaque }` (ruling `connector-opaque-state-item`). Contract ids become provider-neutral `gamecult.model.*.v3` (follow-up `connector-contract-untyped`); the connector serves Ghostlight too, so not `eureka.*`. | Invocation expires at `expires_at_unix_ms`; replay record per key; restart-era `Indeterminate`. The caller stores the transcript and replays it verbatim; foreign-tagged reasoning state inside an in-flight tool exchange is refused typed; completed-turn foreign state is settled per provider in the connector cut (follow-up `connector-reasoning-state-cheap-providers`). | The connector produces, reads or refuses reasoning bytes; callers never branch on provider. |
|
||||
| Connector spend ledger | The reservation lives in the replay record itself (cut connector-spend, F33): integer micro-USD, windowed per caller (ruling `connector-spend-caller-window-ceiling`). | Reserve before the provider runs, settle to actual; refusals `CostBound` (the invocation's own `max_cost_micro_usd`) and `SpendCeiling` (the caller's window); a ledger that cannot be written refuses the request. | The kit's `ProviderCall::charge` is the one billing owner; a backend reports usage or `None` (bills the reservation). The root caller config sets the ceiling. |
|
||||
| Claude connector backend (B4a, landed `200933f`) | `ClaudeCodeBackend` in `crates/claude-connector`, provider tag `claude-code`: one `claude -p` child per call in its own process group, tool-less, system prompt in a private temp file; reasoning state is a named-MessagePack `ClaudeCodeHandle { session_id, instance, prefix_sha256 }`; usage is `model_usage_since_last_result` summed over models, in list-price units from B4b's price table (ruling `claude-code-plan-quota-fuse-2`). | A handle resumes (always `--fork-session`) only when it decodes, names this instance, its session file exists and its digest matches the request's instructions, model and earlier items; anything else re-seeds from the caller's transcript (ruling `claude-code-handle-reseeds`). The CLI's session store under `config_dir` is a cache; the caller's transcript is the truth. A refusal at the door with explicitly empty usage bills zero; unknown usage, a crash or a deadline bills the reservation. | The backend owns the argv, env, process and stream mapping. It never reads `total_cost_usd`, never forwards `ANTHROPIC_API_KEY`, never retries, never spawns twice per execute. Forbidden: `--bare`, `--fallback-model`, `--mcp-config` and `--dangerously-skip-permissions` (B4d changes the MCP line, by spec). Her organs hold none of it: the subscription token lives in the connector's own environment. |
|
||||
|
|
@ -515,7 +515,7 @@ pipeline kinds are in Huginn `docs/eureka-substrate-map.md`.
|
|||
| Persona state | `gamecult.persona_state.v0`, one per mind, `personaId` = instance (cut-huginn-persona-state r2, eureka-substrate). | Replaced whole under CAS with a receipt; she may rewrite any field (`identity-write-all-visible`). | The Persona organ is the only writer. |
|
||||
| Identity diffs | The pair of receipts of two consecutive Persona puts whose identity fields (publicName, publicDescription, presentation, values) differ. Derived, not stored. | Composed by the Persona organ at put time and sent as one delivery request through the crossing; a failed post is retried by Bifrost's delivery journal, not by her. | Persona organ composes; Bifrost posts under the permit. No rate limit. |
|
||||
| Discord body surfaces | Outbound: crossing ids `epiphany.persona_discord_delivery_request.v0` and `..._permit.v0`, agent `epiphany.Persona`, runtime `epiphany-starfire` (F6), renamed to `eureka.*` and `eureka-yggdrasil` in the mouth rebinding (`mouth-crossing-on-yggdrasil`). Inbound: Bifrost persona-feedback deliveries for target `epiphany` (F6). Persona-session tools reach both (`body-includes-discord`). | Request, permit, post, receipt (Bifrost journal). Inbound deliveries: **unprobed** who records that a delivery was answered; `run-is-the-grant` forbids her organ from holding that fact. Blocks the Persona cuts only, not the cuts admitted today. | Bifrost owns posting and delivery records; her permit issuer grants while the brake is released. |
|
||||
| Units and instance templates | `mind-wake@<instance>.timer/.service`, `mind-self@<instance>:<label>.service`, `mind-persona@<instance>:<label>.service` (one-shot per Persona turn; `RuntimeMaxSec` set at install bounds a hung unit), `mind-permit@<instance>`; users `mind-<instance>` (organs) and `mind-<instance>-self` (Self runs); state `/var/lib/gamecult/minds/<instance>/`; control `/etc/gamecult/minds/<instance>/`. Instance `eureka`. | Hand-installed by gamecult-ops now; Idunn targets declared before the first hosted member, which deletes the hand install (`mind-units-templated-interim`). | gamecult-ops installs; Idunn later. Starting: `open_and_launch` through `SystemdLauncher::start`, as her users, who are granted start only. The instance in a unit name is always the mind's (`MindPort::instance`), passed to every `Launcher` call; `SystemdLauncher` holds none. The instance and label are validated by the leaf's grammar before any `systemctl` argv is built. Stopping: only the operator's `brake hold`, as root (cut brake-stop). The unit-level brake gate and the start-only grant are follow-up `mind-units-brake-gate`. None of this exists on Yggdrasil yet (F19, F22). Each piece's identity, lifecycle and authority: the section "The mind units" below (cut mind-units-install; unit names pending question `mind-unit-names`). |
|
||||
| Units and instance templates | `mind-heartbeat.service` (one, not per instance), `mind-self@<instance>:<label>.service`, `mind-persona@<instance>:<label>.service` (one-shot per Persona turn; `RuntimeMaxSec` set at install bounds a hung unit), `mind-permit@<instance>`; users `mind-<instance>` (organs) and `mind-<instance>-self` (Self runs); state `/var/lib/gamecult/minds/<instance>/`; control `/etc/gamecult/minds/<instance>/`. Instance `eureka`. | Hand-installed by gamecult-ops now; Idunn targets declared before the first hosted member, which deletes the hand install (`mind-units-templated-interim`). | gamecult-ops installs; Idunn later. Starting: `open_and_launch` through `SystemdLauncher::start`, as her users, who are granted start only. The instance in a unit name is always the mind's (`MindPort::instance`), passed to every `Launcher` call; `SystemdLauncher` holds none. The instance and label are validated by the leaf's grammar before any `systemctl` argv is built. Stopping: only the operator's `brake hold`, as root (cut brake-stop). The unit-level brake gate and the start-only grant are follow-up `mind-units-brake-gate`. None of this exists on Yggdrasil yet (F19, F22). Each piece's identity, lifecycle and authority: the section "The mind units" below (cut mind-units-install; unit names pending question `mind-unit-names`). |
|
||||
| Self-run credential | None in her unit. The connector holds the one subscription token; a Self run borrows it as a lease over a unix socket (`CLAUDE_LEASE_SOCKET`), and the token reaches `claude` only as a descriptor it reads once (`CLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR`). The child's environment is cleared and rebuilt from an allow-list (`PATH`, `LANG`, `LC_*`, `TERM`, and its own `HOME` and `CLAUDE_CONFIG_DIR`), so no credential variable in the unit can outrank the lease. | Rotated by the operator at the connector, never in her unit: no `EnvironmentFile` carries a token for `mind-self@` (rulings `self-runs-through-connector`, `self-run-credential-lease`; `self-run-token-reach` is superseded). | The connector's: one lease per unit invocation, bound to the peer; the unit holds no credential variable. Her `claude` runs her tools, so anything in the unit's environment is readable from `/proc/<claude>/environ` by her Bash (ruling `claude-code-runs-her-tools-2`). |
|
||||
| Wake frame | Fixed prompt frame (provenance, jurisdiction, brake obedience) in `/etc/gamecult/minds/<instance>/wake-frame.md`, root-owned. Composed by the Persona organ from host config; the waker passes the unit only the instance and the run label. | Changed only by an install. | Host config (ruling `self-waker` rider 2); never a file her runs merge into. |
|
||||
| Epiphany residue | F5, F8, F11, as they stood before the teardown. | Teardown landed (gamecult-ops `9c12adb`); what it did not name is follow-up `eureka-body:follow_up:epiphany-body-leftovers`; the repo is archived last. | gamecult-ops for host and repo tooling; Idunn for its legacy target list (follow-up). |
|
||||
|
|
@ -531,13 +531,13 @@ hers per turn (`one-live-self-run`) and only then can `User=` name a per-instanc
|
|||
|
||||
| Thing | Identity | Lifecycle | Who may start, stop, enable or change it |
|
||||
| --- | --- | --- | --- |
|
||||
| Waker template | `/etc/systemd/system/mind-wake@.service` and `mind-wake@.timer`, sources `systemd/` in gamecult-ops. Instance unit `mind-wake@<i>.service`, `User=mind-%i`. | Installed and replaced by `scripts/install-mind-units-yggdrasil.sh`. The timer is a fixed poll (1 min after the last cycle ends); it decides nothing: the cadence is the dial's, read by the waker from the newest Persona run (mind-wake r3). Schedules and rest (follow-up `burn-schedule-and-rest`) belong in the dial, so they need no unit change. Each tick runs one cycle, `Type=oneshot`, `TimeoutStartSec=5min`, `Restart=no`. Not gated at the unit: the waker reads the brake itself and returns `Held` before any consequence, and a second reading would protect nothing. | Install, change: root via the script. Enable or disable `mind-wake@<i>.timer`: root only, by hand in the runbook. The script never enables it, so installing starts nothing. Start the service: the timer (systemd as root). Her users have no grant on `mind-wake@`. |
|
||||
| Heartbeat unit | One `mind-heartbeat.service` (not per instance; `mind-wake@` timers and units are retired). Needs `HEARTBEAT_PERSONAS` (each entry ends with that Persona's own Huginn `rudp://<ip>:<port>`: each Mind is its own daemon, ruling `swarm-one-connector-many-callers-mind-per-daemon`), `MIND_PERSONA_OPERATOR`, `BIFROST_CHAT_ENDPOINT` and `_KEY_FILE`, `MIND_CONNECTOR_ENDPOINT`, `_KEY_FILE`, `HEARTBEAT_CLASSIFIER_MODEL`, `STATE_DIRECTORY`. | Idunn-managed, sole writer of `heartbeat.cc`. | Install, replace, restart. |
|
||||
| Persona template | `/etc/systemd/system/mind-persona@.service`. Instance unit `mind-persona@<i>.service`, `User=mind-%i`, `Type=exec`, no `[Install]`, so it cannot be enabled. | Inactive until started for one Persona turn. Ends by its own exit, at `RuntimeMaxSec=30min`, or by the brake hold's stop (`TimeoutStopSec=30s`, then SIGKILL to the whole cgroup). `Restart=no`, and nothing else restarts it. Every start first runs the gate (below); while the brake is not Released the unit is skipped and ends inactive. `ExecStart` names the Persona organ's binary in the Huginn release; that cut ships the binary and owns the argv. Until then a released start fails at exec (203/EXEC), and the closing launch counts it as a dead run. | Start: `mind-<i>` only, through `open_and_launch` (the waker), by polkit's verb `start` on this one unit name. Stop, kill, restart: root only, which in practice means `mind-control brake hold` (cut brake-stop). Change: root via the script. Enable: nobody. |
|
||||
| Self template | `/etc/systemd/system/mind-self@.service`. Instance unit `mind-self@<i>.service`, `User=mind-%i-self`, `Type=exec`, no `[Install]`. | As the Persona unit, with `RuntimeMaxSec=4h`. `ExecStart` names the Self-run actuator's binary in the Huginn release; that cut adds the state directory, `RuntimeDirectory=` (each child's fresh configuration), `MIND_SELF_RUN_CAP_S` equal to `RuntimeMaxSec` in seconds, `CLAUDE_LEASE_SOCKET` and the argv. It adds no token and no `EnvironmentFile` with a credential. | Start: `mind-<i>` only (the Persona's start-Self-run tool through `open_and_launch`), by polkit, verb `start`. Stop, kill, restart: root only. Change: root via the script. Enable: nobody. |
|
||||
| Organ user | `mind-<i>`: system user and group, shell `/usr/sbin/nologin`, home `/nonexistent`. | Created by the install script if absent and never modified by it after that. Deleted by root at the mind's retirement. Runs the waker and Persona units. | Root creates and deletes it. It may start `mind-persona@<i>` and `mind-self@<i>`, and nothing else. It cannot write the control store (root 0644, dir 0755) or stop a unit. It can signal its own processes with `kill(2)`, as any Unix user can. That only ends spend, and the ended run is closed Recorded by the next launch, so it adds no second stopper of her units. |
|
||||
| Self user | `mind-<i>-self`: system user and group, nologin, home `/nonexistent`. The install script refuses an instance slug ending in `-self`, so this name never collides with another mind's organ user. | As the organ user. | Root creates and deletes it. It starts no unit. Polkit refuses it every verb, and the rule refuses any subject whose name ends in `-self`. |
|
||||
| `mind-control` | `/opt/gamecult/huginn/releases/<sha>/mind-control`, root:root 0700, reached by the `current` symlink; `/usr/local/sbin/mind-control` links to `/opt/gamecult/huginn/current/mind-control`. | Built and installed with each Huginn release by the Release step of `runbooks/huginn-yggdrasil.md`. Replaced when `current` flips. The previous release is kept until the new one verifies. | Install, replace: root via the Release step. Run: root only (mode 0700), which covers the operator's verbs and the gate, which systemd runs as root through the `+` prefix. No user of hers can execute it. |
|
||||
| `mind-wake` | `/opt/gamecult/huginn/releases/<sha>/mind-wake`, root:root 0755. | Same release step and lifecycle as `mind-control`. | Install, replace: root. Run: the waker unit as `mind-<i>`. |
|
||||
| `mind-heartbeat` | `/opt/gamecult/huginn/releases/<sha>/mind-heartbeat`, root:root 0755. | Owner of initiative: reads the swarm envelope, the mind (busy, idle, run ends), the mouth (typed sender, `m.mentions`, room) and the connector (classifier urgency, spend); writes `heartbeat.cc` (Initiative per instance, poll traces) and launches Persona turns through `open_and_launch`. The operator tagging or naming a Persona fills its initiative once per message; a member never does. | `mind-heartbeat timeline` prints the latest poll. |
|
||||
| The gate | `ExecCondition=+/opt/gamecult/huginn/current/mind-control --instance %i check` in both spending templates. The verb is cut mind-unit-gate. | Runs at every start of a Persona or Self unit. It exits 0 only when `read_effective` is `Released`; held, absent, undecodable or out of bounds exit 2 (systemd skips on 1-254). It holds no state and writes nothing. | Owner: `read_effective`, the one derivation. Change: a Huginn release (the verb) or the script (the line). It closes the window in which a launch that read Released before a hold starts its unit after the hold's stop (follow-up `mind-units-brake-gate`). |
|
||||
| Polkit rule | `/etc/polkit-1/rules.d/60-mind-units.rules`, root 0644, source `polkit/60-mind-units.rules` in gamecult-ops. Needs package `polkitd` (absent today, F22; question `mind-unit-start-grant`). | Installed and replaced by the script. Instance-generic: one file serves every mind. | Root changes it. It grants exactly one thing: action `org.freedesktop.systemd1.manage-units`, verb `start`, unit `mind-persona@<i>.service` or `mind-self@<i>.service`, to subject `mind-<i>`. Every other request falls through to systemd's default, `auth_admin`, which an unattended caller cannot satisfy. |
|
||||
| Control store | `/etc/gamecult/minds/<i>/control.cc`, root:root 0644 in a 0755 dir (cut mind-control-store). | The install script writes `brake hold` once, if no store exists, so a fresh install reads `held` with a name and time rather than `absent`. Afterwards only the operator's verbs change it. | Operator only (`brake-dial-root-store-now`). |
|
||||
|
|
|
|||
Loading…
Reference in a new issue