refactor: clear pi-era residue + make CI gate green (harness-neutral cleanup) #158

Merged
clawdie merged 1 commit from cleanup-pi-zot-residue into main 2026-06-24 00:36:29 +02:00
12 changed files with 121 additions and 76 deletions

View file

@ -102,6 +102,18 @@ cargo test --workspace
cargo build --workspace --release
```
**Mandatory before every merge to `main`:** run the full gate via
```sh
./scripts/ci-checks.sh # fmt --check, clippy -D warnings, test, markdown gate
```
`.forgejo/workflows/ci.yml` encodes the same checks, but **no Actions runner is
currently registered**, so nothing enforces them server-side. Until a runner is
active, `ci-checks.sh` passing locally is the only gate — a green run is a
prerequisite for merging. (A compile break reached `main` once because this step
was skipped; do not skip it.)
## ISO Takeover Gates
| Gate | Target | Status |

View file

@ -327,7 +327,7 @@ mod tests {
"id": "pane-1",
"agent": "pi",
"state": "working",
"pi_session_id": "pi-session-1",
"session_id": "pi-session-1",
"last_event_at": "2026-05-27T11:59:59.000Z",
"cwd": "/repo"
}]

View file

@ -130,7 +130,7 @@ async fn daemon_client_live_socket_check_with_local_fake_agent() {
.iter()
.find(|pane| pane.id == agent_id)
.unwrap();
assert_eq!(pane.pi_session_id.as_deref(), Some("manual-test"));
assert_eq!(pane.session_id.as_deref(), Some("manual-test"));
assert!(pane.cwd.is_some());
let kill = client.kill_agent(agent_id).await.unwrap();
@ -370,7 +370,7 @@ async fn harness_double_spawn_session_isolation() {
assert_ne!(pane_a.id, pane_b.id);
// Verify session isolation: both share the same session_id (test agent default)
assert_eq!(pane_a.pi_session_id, pane_b.pi_session_id);
assert_eq!(pane_a.session_id, pane_b.session_id);
// Kill one agent — snapshot may still include stopped panes briefly
let kill = client.kill_agent(&pane_a.id).await.unwrap();

View file

@ -49,6 +49,8 @@ pub struct RuntimeInventory {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pi: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub zot: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub npm_prefix: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub package_manager: Option<String>,

View file

@ -74,7 +74,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
let socket_handle =
tokio::spawn(async move { socket::serve(socket_state, socket_shutdown).await });
// Auto-spawn one Pi agent if configured (live "Operator Image" OOTB flow).
// Auto-spawn one agent if configured (live "Operator Image" OOTB flow).
// Runs after the socket server is up so the spawn registers on the board;
// no-op unless COLIBRI_AUTOSPAWN is set and a DeepSeek key is present.
socket::autospawn_agent_if_configured(&state).await;

View file

@ -416,9 +416,7 @@ pub async fn autospawn_agent_if_configured(state: &SharedState) {
return;
}
if state.config.deepseek_api_key.is_none() {
info!(
"autospawn: DEEPSEEK_API_KEY not set; skipping (operator can add it via Join Hive)"
);
info!("autospawn: DEEPSEEK_API_KEY not set; skipping (operator can add it via Join Hive)");
return;
}
@ -438,22 +436,13 @@ pub async fn autospawn_agent_if_configured(state: &SharedState) {
return;
}
// Default argv depends on the harness: zot is a request/response peer
// driven over stdin (`zot rpc`), while pi self-drives (`pi --mode json`).
// `--mode json` is NOT a valid zot flag, so a single default would break one
// of them — pick per binary. Override with COLIBRI_AUTOSPAWN_ARGS.
let default_args = if agent_name == "zot" {
"rpc"
} else {
"--mode json"
};
// Default argv depends on the harness (see default_agent_args). Override
// wholesale with COLIBRI_AUTOSPAWN_ARGS.
let args: Vec<String> = std::env::var("COLIBRI_AUTOSPAWN_ARGS")
.ok()
.filter(|s| !s.trim().is_empty())
.unwrap_or_else(|| default_args.to_string())
.split_whitespace()
.map(str::to_string)
.collect();
.map(|s| s.split_whitespace().map(str::to_string).collect())
.unwrap_or_else(|| default_agent_args(&agent_binary));
info!(binary = %agent_binary, ?args, "autospawn: spawning agent on host (DeepSeek-backed)");
@ -554,6 +543,20 @@ fn basename(path: &str) -> String {
.to_string()
}
/// Default argv for an agent harness, by binary basename. zot is an RPC peer
/// (`zot rpc`, driven over stdin); every other harness is assumed to be a
/// self-driving JSON emitter (`pi --mode json`). `--mode json` is not a valid
/// zot flag, so the default must vary by harness — a single shared default
/// would break one of them. Single source of truth for both autospawn and the
/// non-local spawn path.
fn default_agent_args(binary: &str) -> Vec<String> {
if basename(binary) == "zot" {
vec!["rpc".to_string()]
} else {
vec!["--mode".to_string(), "json".to_string()]
}
}
fn env_truthy(name: &str) -> bool {
std::env::var(name)
.map(|v| {
@ -565,6 +568,10 @@ fn env_truthy(name: &str) -> bool {
.unwrap_or(false)
}
// Spawn parameters are passed positionally to mirror the socket command shape;
// grouping them into a struct would not improve clarity here. Matches the same
// allow on prepare_spawn_command.
#[allow(clippy::too_many_arguments)]
async fn cmd_spawn_agent(
state: &SharedState,
provider_str: String,
@ -590,10 +597,12 @@ async fn cmd_spawn_agent(
let args = local_args.unwrap_or_default();
(model.clone(), args)
} else {
(
std::env::var("COLIBRI_AGENT_BINARY").unwrap_or_else(|_| "hermes-agent".to_string()),
vec!["--mode".to_string(), "json".to_string()],
)
// Non-local provider: a managed agent harness. Default to zot (the
// current default harness); argv follows the harness via
// default_agent_args so we never spawn `zot --mode json`.
let binary = std::env::var("COLIBRI_AGENT_BINARY").unwrap_or_else(|_| "zot".to_string());
let args = default_agent_args(&binary);
(binary, args)
};
// An agent invoked in RPC mode (`zot rpc` / `--rpc`) blocks on stdin until
@ -1093,7 +1102,7 @@ mod tests {
let data = response.data.unwrap();
assert_eq!(data["schema"], "clawdie.glasspane.snapshot.v1");
assert_eq!(data["panes"][0]["id"], "pane-a");
assert_eq!(data["panes"][0]["pi_session_id"], "pi-s");
assert_eq!(data["panes"][0]["session_id"], "pi-s");
}
#[tokio::test]
@ -1130,7 +1139,7 @@ mod tests {
.find(|pane| pane.id == pane_id)
.unwrap();
assert_eq!(pane.state, colibri_glasspane::AgentState::Done);
assert_eq!(pane.pi_session_id.as_deref(), Some("pi-fake"));
assert_eq!(pane.session_id.as_deref(), Some("pi-fake"));
}
#[tokio::test]

View file

@ -317,11 +317,7 @@ impl RpcSender {
/// Send one prompt as a newline-delimited request on the agent's stdin.
/// Returns the request id used. Errors if the stdin pipe has closed.
pub async fn send_prompt(&self, message: &str) -> std::io::Result<String> {
let id = (self
.seq
.fetch_add(1, std::sync::atomic::Ordering::SeqCst)
+ 1)
.to_string();
let id = (self.seq.fetch_add(1, std::sync::atomic::Ordering::SeqCst) + 1).to_string();
let line = build_rpc_prompt(&id, message);
let mut guard = self.stdin.lock().await;
let stdin = guard.as_mut().ok_or_else(|| {
@ -552,7 +548,7 @@ pub fn jail_wrap(
/// Configuration for spawning an agent subprocess.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentSpawnConfig {
/// Agent binary or command to run (e.g. "pi", "hermes-agent", etc.).
/// Agent binary or command to run (e.g. "zot", "pi", etc.).
pub binary: String,
/// Command-line arguments.
#[serde(default)]

View file

@ -29,7 +29,7 @@ mod integration {
ingestor.ingest_line_at(line, t + Duration::from_secs(i as u64));
}
assert_eq!(ingestor.state(), AgentState::Done);
assert_eq!(ingestor.pi_session_id(), Some("s1"));
assert_eq!(ingestor.session_id(), Some("s1"));
}
/// Agent stays idle through unknown events (no crash, forward-compatible)
@ -81,7 +81,7 @@ mod integration {
id: "a".into(),
agent: "pi".into(),
state: AgentState::Working,
pi_session_id: Some("s1".into()),
session_id: Some("s1".into()),
last_event_at: Some("2026-05-27T10:00:00Z".into()),
cwd: None,
stalled: false,
@ -90,7 +90,7 @@ mod integration {
id: "b".into(),
agent: "pi".into(),
state: AgentState::Blocked,
pi_session_id: Some("s2".into()),
session_id: Some("s2".into()),
last_event_at: Some("2026-05-27T10:00:01Z".into()),
cwd: None,
stalled: false,
@ -99,7 +99,7 @@ mod integration {
id: "c".into(),
agent: "pi".into(),
state: AgentState::Idle,
pi_session_id: None,
session_id: None,
last_event_at: None,
cwd: None,
stalled: false,
@ -124,7 +124,7 @@ mod integration {
id: "p1".into(),
agent: "pi".into(),
state: AgentState::Working,
pi_session_id: Some("sess-1".into()),
session_id: Some("sess-1".into()),
last_event_at: Some("2026-05-27T10:00:00Z".into()),
cwd: None,
stalled: false,
@ -141,7 +141,7 @@ mod integration {
fn default_ingestor_is_idle() {
let ingestor = PiJsonlIngestor::default();
assert_eq!(ingestor.state(), AgentState::Idle);
assert!(ingestor.pi_session_id().is_none());
assert!(ingestor.session_id().is_none());
}
/// Queue update blocks agent

View file

@ -4,7 +4,7 @@
//! Uses scripts/fake-pi-agent.py which emits the colibri-pi-events JSONL taxonomy.
//!
//! With real Pi binary (when installed):
//! COLIBRI_PI_BINARY=pi cargo test -p colibri-daemon --test pi_spawn_live -- --nocapture
//! COLIBRI_AGENT_BINARY=pi cargo test -p colibri-daemon --test pi_spawn_live -- --nocapture
use std::path::PathBuf;
use std::process::Stdio;
@ -79,13 +79,13 @@ async fn pi_spawn_path_produces_correct_glasspane_state() {
"expected Done after full agent run"
);
assert!(
pane.pi_session_id.is_some(),
"expected pi_session_id from session event, got {:?}",
pane.pi_session_id
pane.session_id.is_some(),
"expected session_id from session event, got {:?}",
pane.session_id
);
eprintln!(
"✓ Pi spawn proof: {} accepted, state={:?}, session={:?}",
accepted, pane.state, pane.pi_session_id
accepted, pane.state, pane.session_id
);
}

View file

@ -74,7 +74,7 @@ struct App {
// harness additions
status_msg: Option<(String, u8)>, // (message, ttl ticks)
detail_pane: Option<usize>, // index into snapshot.panes, or None
session_filter: Option<String>, // filter by pi_session_id, or None
session_filter: Option<String>, // filter by session_id, or None
sessions: Vec<String>, // all known session IDs
session_idx: usize, // which session is selected
}
@ -113,7 +113,7 @@ impl App {
Some(sid) => snap
.panes
.iter()
.filter(|p| p.pi_session_id.as_deref() == Some(sid.as_str()))
.filter(|p| p.session_id.as_deref() == Some(sid.as_str()))
.collect(),
None => snap.panes.iter().collect(),
}
@ -132,7 +132,7 @@ impl App {
let mut ids: Vec<String> = snap
.panes
.iter()
.filter_map(|p| p.pi_session_id.clone())
.filter_map(|p| p.session_id.clone())
.collect();
ids.sort();
ids.dedup();
@ -377,7 +377,7 @@ impl App {
format!("{:?}", p.state),
Style::default().fg(color),
)),
Cell::from(p.pi_session_id.as_deref().unwrap_or("")),
Cell::from(p.session_id.as_deref().unwrap_or("")),
Cell::from(p.cwd.as_deref().unwrap_or("")),
Cell::from(if p.stalled { "" } else { "" }),
])
@ -429,7 +429,7 @@ impl App {
),
Span::raw(" "),
Span::styled("Session: ", Style::default().add_modifier(Modifier::BOLD)),
Span::raw(pane.pi_session_id.as_deref().unwrap_or("")),
Span::raw(pane.session_id.as_deref().unwrap_or("")),
]),
Line::from(vec![
Span::styled("CWD: ", Style::default().add_modifier(Modifier::BOLD)),
@ -649,7 +649,7 @@ mod tests {
id: "a".into(),
agent: "pi".into(),
state: AgentState::Working,
pi_session_id: Some("s1".into()),
session_id: Some("s1".into()),
last_event_at: None,
cwd: None,
stalled: false,
@ -658,7 +658,7 @@ mod tests {
id: "b".into(),
agent: "pi".into(),
state: AgentState::Idle,
pi_session_id: Some("s2".into()),
session_id: Some("s2".into()),
last_event_at: None,
cwd: None,
stalled: false,
@ -693,7 +693,7 @@ mod tests {
id: "a".into(),
agent: "pi".into(),
state: AgentState::Working,
pi_session_id: Some("s2".into()),
session_id: Some("s2".into()),
last_event_at: None,
cwd: None,
stalled: false,
@ -702,7 +702,7 @@ mod tests {
id: "b".into(),
agent: "pi".into(),
state: AgentState::Working,
pi_session_id: Some("s1".into()),
session_id: Some("s1".into()),
last_event_at: None,
cwd: None,
stalled: false,
@ -711,7 +711,7 @@ mod tests {
id: "c".into(),
agent: "pi".into(),
state: AgentState::Working,
pi_session_id: Some("s1".into()),
session_id: Some("s1".into()),
last_event_at: None,
cwd: None,
stalled: false,

View file

@ -201,7 +201,7 @@ pub fn pane_from_jsonl_with_id(
id: pane_id.into(),
agent: agent.into(),
state: ingestor.state,
pi_session_id: ingestor.pi_session_id,
session_id: ingestor.session_id,
last_event_at: None,
cwd: ingestor.cwd,
stalled: false,
@ -223,7 +223,7 @@ pub fn pane_from_jsonl(agent: impl Into<String>, jsonl: &str) -> Pane {
pub struct PiStreamUpdate {
pub pi_type: String,
pub state: AgentState,
pub pi_session_id: Option<String>,
pub session_id: Option<String>,
pub cwd: Option<String>,
pub observed_at: SystemTime,
}
@ -244,7 +244,7 @@ pub struct PaneReaderStats {
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PiJsonlIngestor {
state: AgentState,
pi_session_id: Option<String>,
session_id: Option<String>,
cwd: Option<String>,
last_event_at: Option<SystemTime>,
/// Which harness produced the stream. Pi events are read directly; zot
@ -256,7 +256,7 @@ impl Default for PiJsonlIngestor {
fn default() -> Self {
Self {
state: AgentState::Idle,
pi_session_id: None,
session_id: None,
cwd: None,
last_event_at: None,
runtime: AgentRuntime::Pi,
@ -277,8 +277,8 @@ impl PiJsonlIngestor {
self.state
}
pub fn pi_session_id(&self) -> Option<&str> {
self.pi_session_id.as_deref()
pub fn session_id(&self) -> Option<&str> {
self.session_id.as_deref()
}
pub fn cwd(&self) -> Option<&str> {
@ -312,7 +312,7 @@ impl PiJsonlIngestor {
if ty == "session" || ty == "session_started" {
if let Some(sid) = value.get("id").and_then(Value::as_str) {
self.pi_session_id = Some(sid.to_string());
self.session_id = Some(sid.to_string());
}
if let Some(cwd) = value.get("cwd").and_then(Value::as_str) {
self.cwd = Some(cwd.to_string());
@ -322,7 +322,7 @@ impl PiJsonlIngestor {
Some(PiStreamUpdate {
pi_type: ty.to_string(),
state: self.state,
pi_session_id: self.pi_session_id.clone(),
session_id: self.session_id.clone(),
cwd: self.cwd.clone(),
observed_at,
})
@ -373,8 +373,8 @@ impl SupervisedPane {
self.ingestor.state()
}
pub fn pi_session_id(&self) -> Option<&str> {
self.ingestor.pi_session_id()
pub fn session_id(&self) -> Option<&str> {
self.ingestor.session_id()
}
pub fn cwd(&self) -> Option<&str> {
@ -407,7 +407,7 @@ impl SupervisedPane {
id: self.id.clone(),
agent: self.agent.clone(),
state: self.state(),
pi_session_id: self.pi_session_id().map(str::to_string),
session_id: self.session_id().map(str::to_string),
last_event_at: self.last_event_at().map(system_time_to_rfc3339),
cwd: self.cwd().map(str::to_string),
stalled: self.is_stalled_at(now, stall_after),
@ -627,12 +627,18 @@ fn system_time_to_rfc3339(time: SystemTime) -> String {
/// A supervised pane — one agent occupying one PTY/session slot.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Pane {
/// Colibri-owned pane id. Distinct from the Pi session id.
/// Colibri-owned pane id. Distinct from the agent's own session id.
pub id: String,
pub agent: String,
pub state: AgentState,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pi_session_id: Option<String>,
// `alias` keeps deserializing the legacy `pi_session_id` key (pre-zot wire
// format / persisted snapshots) onto the harness-neutral field.
#[serde(
default,
alias = "pi_session_id",
skip_serializing_if = "Option::is_none"
)]
pub session_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_event_at: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
@ -737,7 +743,7 @@ mod tests {
id: "p1".into(),
agent: "pi".into(),
state: AgentState::Working,
pi_session_id: Some("pi-session-1".into()),
session_id: Some("pi-session-1".into()),
last_event_at: None,
cwd: Some("/repo".into()),
stalled: false,
@ -746,7 +752,7 @@ mod tests {
id: "p2".into(),
agent: "pi".into(),
state: AgentState::Blocked,
pi_session_id: None,
session_id: None,
last_event_at: None,
cwd: None,
stalled: true,
@ -779,7 +785,7 @@ mod tests {
assert_eq!(pane.state, AgentState::Done);
assert_eq!(pane.id, "pane-a");
assert_eq!(
pane.pi_session_id.as_deref(),
pane.session_id.as_deref(),
Some("019e5e59-6645-7e21-aca2-b57ccf0f8578")
);
assert_eq!(pane.cwd.as_deref(), Some("/home/clawdija/clawdie-ai"));
@ -815,7 +821,7 @@ mod tests {
.unwrap();
assert_eq!(update.pi_type, "session");
assert_eq!(update.observed_at, t(10));
assert_eq!(ingestor.pi_session_id(), Some("pi-s"));
assert_eq!(ingestor.session_id(), Some("pi-s"));
assert_eq!(ingestor.cwd(), Some("/repo"));
assert_eq!(ingestor.last_event_at(), Some(t(10)));
@ -825,7 +831,7 @@ mod tests {
}
#[test]
fn supervisor_keeps_colibri_pane_id_separate_from_pi_session_id() {
fn supervisor_keeps_colibri_pane_id_separate_from_session_id() {
let mut supervisor = PaneSupervisor::new();
supervisor.attach_pane_at("pane-1", "pi", t(0));
supervisor.ingest_line_at(
@ -840,8 +846,8 @@ mod tests {
.pop()
.unwrap();
assert_eq!(pane.id, "pane-1");
assert_eq!(pane.pi_session_id.as_deref(), Some("pi-session-1"));
assert_ne!(pane.id, pane.pi_session_id.unwrap());
assert_eq!(pane.session_id.as_deref(), Some("pi-session-1"));
assert_ne!(pane.id, pane.session_id.unwrap());
}
#[test]
@ -861,7 +867,7 @@ mod tests {
let pane = supervisor.get("pane-a").unwrap();
assert_eq!(pane.state(), AgentState::Working);
assert_eq!(pane.pi_session_id(), Some("pi-s"));
assert_eq!(pane.session_id(), Some("pi-s"));
assert_eq!(
pane.last_event_at(),
Some(t(100) + Duration::from_millis(3))
@ -896,7 +902,7 @@ mod tests {
let pane = supervisor.get("pane-reader").unwrap();
assert_eq!(pane.state(), AgentState::Done);
assert_eq!(pane.pi_session_id(), Some("pi-s"));
assert_eq!(pane.session_id(), Some("pi-s"));
assert_eq!(pane.last_event_at(), Some(t(13)));
}

View file

@ -21,6 +21,8 @@ struct RuntimeInventory {
#[serde(skip_serializing_if = "Option::is_none")]
pi: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
zot: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
npm_prefix: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
package_manager: Option<String>,
@ -156,6 +158,23 @@ fn detect_pi_version() -> Option<String> {
.find_map(|candidate| pi_package_version_from_bin(&candidate))
}
/// Detect the installed zot agent version. zot is a single Go binary (not an
/// npm package), so this is a plain `--version` probe across `$ZOT_BIN`, PATH,
/// and the usual candidate locations.
fn detect_zot_version() -> Option<String> {
if let Ok(zot_bin) = env::var("ZOT_BIN") {
if let Some(version) = command_output(&zot_bin, &["--version"]) {
return Some(version);
}
}
if let Some(version) = command_output("zot", &["--version"]) {
return Some(version);
}
command_candidates("zot")
.into_iter()
.find_map(|candidate| command_output(&candidate, &["--version"]))
}
fn detect_package_manager() -> Option<String> {
if command_output("pkg", &["--version"]).is_some() {
return Some("pkg".to_string());
@ -180,6 +199,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
node: command_output("node", &["--version"]),
npm: command_output("npm", &["--version"]),
pi: detect_pi_version(),
zot: detect_zot_version(),
npm_prefix: command_output("npm", &["config", "get", "prefix"]),
package_manager: detect_package_manager(),
iso_npm_globals_pin: BTreeMap::new(),