core: review fixes, init builds the agent, console without a terminal

Activation path (review T6R): a broken plugin or a stray file in
/plugins blocks no activation; candidates replace their own entries;
discovery has a 10 s deadline and its process is stopped after the
answer; an unchanged hash keeps plugin.prev.wasm; activations run in
their own task; the watcher is not recursive; no duplicate services;
rpc.retired waits for live processes of its version; reads and hashes
on blocking threads.

Guest I/O and per-call paths: a stdin write error is EOF, stdout and
stderr are drained before the status; stderr is lossy UTF-8 and goes
to the journal from its own reader; compiled components are freed;
shutdown starts no new processes; lock queues drop stopping processes;
compile before the admission gate; CommandPre per component;
core.unlock in order; null errors are successes; the journal thread
survives SQLite errors.

sausage init builds the agent with cargo.build; sausage without a
command creates ./world. The console reads plain lines without a
terminal. PROTOCOL.md and DECISIONS.md describe the decided rules. The
flake tests the whole workspace and checks the packaged cargo.build.
This commit is contained in:
2026-10-23 02:25:03 +01:00
parent 53b90a8ecf
commit d7b78ed0fd
27 changed files with 1800 additions and 352 deletions
+20 -13
View File
@@ -1,7 +1,7 @@
//! Serialized candidate-copy activation and revert under PROTOCOL.md section 10.3.
use crate::{
Host, RpcResult, error,
graph::{Interface, io_error, problems},
graph::{Interface, io_error, problems, read_hashed},
};
use serde_json::{Value, json};
use std::{collections::BTreeMap, path::PathBuf};
@@ -14,6 +14,13 @@ struct Candidate {
impl Host {
/// Copies first, discovers that copy, checks the combined graph, then installs it.
pub(crate) async fn activation(&self, _caller: &str, method: &str, params: Value) -> RpcResult {
let host = self.clone();
let method = method.to_owned();
tokio::spawn(async move { host.activation_body(&method, params).await })
.await
.map_err(|failure| error(-32603, &failure.to_string()))?
}
async fn activation_body(&self, method: &str, params: Value) -> RpcResult {
let _activation = self.0.activations.lock().await;
let mut candidates = Vec::new();
let result = self
@@ -79,12 +86,12 @@ impl Host {
};
let copy = directory.join(format!("activate-{}.wasm", uuid::Uuid::now_v7()));
// Read once. Discovery and installation both use the same private bytes.
let bytes = tokio::fs::read(source).await.map_err(io_error)?;
let (bytes, hash) = read_hashed(source).await.map_err(io_error)?;
tokio::fs::write(&copy, &bytes).await.map_err(io_error)?;
self.0
.journal
.write("core", plugin, "core", "activation copied candidate");
let discovery = self.discover_copy(plugin, &copy, &bytes).await;
let discovery = self.discover_copy(plugin, &copy, hash).await;
let interface = match discovery {
Ok(interface) => interface,
Err(error) => {
@@ -101,19 +108,13 @@ impl Host {
interface,
});
}
self.refresh_graph().await?;
let previous = self.0.graph.lock().unwrap().plugins.clone();
if previous.values().any(|entry| entry.error.is_some()) {
return Err(error(
-32003,
"activation waits for successful discovery of every plugin",
));
}
let mut proposed = previous.clone();
let names: Vec<_> = candidates
.iter()
.map(|candidate| candidate.plugin.clone())
.collect();
self.refresh_graph_except(&names).await?;
let previous = self.0.graph.lock().unwrap().plugins.clone();
let mut proposed = previous.clone();
for candidate in candidates.iter() {
proposed.insert(candidate.plugin.clone(), candidate.interface.clone());
}
@@ -160,6 +161,12 @@ impl Host {
let admission = self.0.admission.write().await;
// Every candidate already has a temporary name before any installed file changes.
for candidate in candidates {
if previous
.get(&candidate.plugin)
.is_some_and(|old| old.hash == candidate.interface.hash)
{
continue;
}
let directory = self.0.world.plugin_dir(&candidate.plugin);
let current = directory.join("plugin.wasm");
if tokio::fs::try_exists(&current).await.map_err(io_error)? {
@@ -180,7 +187,7 @@ impl Host {
);
if let Some(old) = previous
.get(&candidate.plugin)
.filter(|old| old.hash != candidate.interface.hash)
.filter(|old| !old.hash.is_empty() && old.hash != candidate.interface.hash)
{
let grace = old.document["x-sausage-grace"]
.as_f64()