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
+47 -7
View File
@@ -1,7 +1,7 @@
//! Grace periods, service restarts, and retirement of replaced versions (PROTOCOL.md section 3.7).
use crate::{Host, services::Service};
use serde_json::json;
use std::{collections::BTreeMap, time::Duration};
use std::{collections::BTreeMap, sync::atomic::Ordering, time::Duration};
use tokio::sync::watch;
pub(crate) struct OldProcess {
@@ -29,7 +29,11 @@ impl Host {
let processes = state
.processes
.iter()
.filter(|(_, process)| process.info.plugin == plugin && process.info.version != current)
.filter(|(_, process)| {
process.info.plugin == plugin
&& process.info.version != current
&& process.info.method != "rpc.discover"
})
.map(|(pid, process)| OldProcess {
pid: pid.clone(),
version: process.info.version.clone(),
@@ -44,12 +48,17 @@ impl Host {
.split_once('.')
.is_some_and(|(name, _)| name == plugin)
{
let ended = state
let process = state
.processes
.values()
.find(|process| process.id.as_ref() == Some(&service.request))
.map(|process| process.ended.subscribe());
services.push((service, ended));
.find(|process| process.id.as_ref() == Some(&service.request));
if process.is_some_and(|process| process.info.version == current)
|| (process.is_none() && service.starting.load(Ordering::SeqCst))
{
retained.push(service);
} else {
services.push((service, process.map(|process| process.ended.subscribe())));
}
} else {
retained.push(service);
}
@@ -67,6 +76,9 @@ impl Host {
pub(crate) fn begin_upgrade(&self, upgrade: Upgrade) {
let host = self.clone();
tokio::spawn(async move {
if host.0.shutdown.load(Ordering::SeqCst) {
return;
}
host.0.journal.write(
"core",
&upgrade.plugin,
@@ -96,7 +108,9 @@ impl Host {
if let Some(mut ended) = ended {
let _ = ended.wait_for(|ended| *ended).await;
}
host.start_service(service.method, service.params).await;
if !host.0.shutdown.load(Ordering::SeqCst) {
host.start_service(service.method, service.params).await;
}
});
}
for (version, processes) in versions {
@@ -116,6 +130,32 @@ impl Host {
for mut ended in processes {
let _ = ended.wait_for(|ended| *ended).await;
}
loop {
if host.0.shutdown.load(Ordering::SeqCst) {
host.0.retirements.lock().unwrap().remove(&retirement);
return;
}
let live: Vec<_> = host
.0
.state
.lock()
.unwrap()
.processes
.values()
.filter(|process| {
process.info.plugin == plugin
&& process.info.version == version
&& process.info.method != "rpc.discover"
})
.map(|process| process.ended.subscribe())
.collect();
if live.is_empty() {
break;
}
for mut ended in live {
let _ = ended.wait_for(|ended| *ended).await;
}
}
if host
.0
.graph