use std::path::{Path, PathBuf};
use chrono::{DateTime, Utc};
use serde_json::Value;
use crate::error::{Error, Result};
use crate::paths::RunPaths;
use crate::projections::{read_manifest_opt, read_node_opt, write_manifest, write_node};
use crate::report::ReportOrigin;
use crate::schema::{
ChildRef, Event, IdValidationError, Kind, Lifecycle, Manifest, MergeTxn, Node, NodeId, RunId,
Status, TmuxIdentity, WorkerExit, STATE_SCHEMA_VERSION,
};
fn corrupt_id(events_path: &Path, ev: &Event, e: &IdValidationError) -> Error {
Error::CorruptEventLog {
path: events_path.to_path_buf(),
reason: format!("event seq={} kind={}: {e}", ev.seq, ev.kind),
}
}
fn opt_run_id(events_path: &Path, ev: &Event, d: &Value, field: &str) -> Result<Option<RunId>> {
match d.get(field) {
None | Some(Value::Null) => Ok(None),
Some(Value::String(s)) => RunId::parse_str(s)
.map(Some)
.map_err(|e| corrupt_id(events_path, ev, &e)),
Some(_) => Err(Error::CorruptEventLog {
path: events_path.to_path_buf(),
reason: format!(
"event seq={} kind={} `{field}` must be a JSON string or null",
ev.seq, ev.kind
),
}),
}
}
fn opt_node_id(events_path: &Path, ev: &Event, d: &Value, field: &str) -> Result<Option<NodeId>> {
match d.get(field) {
None | Some(Value::Null) => Ok(None),
Some(Value::String(s)) => NodeId::parse_str(s)
.map(Some)
.map_err(|e| corrupt_id(events_path, ev, &e)),
Some(_) => Err(Error::CorruptEventLog {
path: events_path.to_path_buf(),
reason: format!(
"event seq={} kind={} `{field}` must be a JSON string or null",
ev.seq, ev.kind
),
}),
}
}
fn data_kind(v: &Value) -> Option<Kind> {
match serde_json::from_value::<Kind>(v.clone()) {
Ok(Kind::Unknown) | Err(_) => None,
Ok(k) => Some(k),
}
}
fn data_status(v: &Value) -> Option<Status> {
serde_json::from_value(v.clone()).ok()
}
fn require_status(ev: &Event, path: PathBuf) -> Result<Status> {
data_status(ev.data.get("status").unwrap_or(&Value::Null)).ok_or_else(|| {
Error::CorruptEventLog {
path,
reason: format!("{} missing/invalid `status`", ev.kind),
}
})
}
fn want_str<'a>(events_path: &Path, ev: &Event, d: &'a Value, field: &str) -> Result<&'a str> {
d.get(field)
.and_then(Value::as_str)
.ok_or_else(|| Error::CorruptEventLog {
path: events_path.to_path_buf(),
reason: format!(
"event seq={} kind={} missing `{field}` string field",
ev.seq, ev.kind
),
})
}
fn optional_bool(events_path: &Path, ev: &Event, d: &Value, field: &str) -> Result<Option<bool>> {
match d.get(field) {
None | Some(Value::Null) => Ok(None),
Some(Value::Bool(b)) => Ok(Some(*b)),
Some(_) => Err(Error::CorruptEventLog {
path: events_path.to_path_buf(),
reason: format!(
"event seq={} kind={} `{field}` must be a JSON boolean or null",
ev.seq, ev.kind
),
}),
}
}
fn optional_i32(d: &Value, field: &str, events_path: &Path, ev: &Event) -> Result<Option<i32>> {
match d.get(field) {
None | Some(Value::Null) => Ok(None),
Some(v) => {
let raw = v.as_i64().ok_or_else(|| Error::CorruptEventLog {
path: events_path.to_path_buf(),
reason: format!(
"event seq={} kind={} `{field}` must be integer",
ev.seq, ev.kind
),
})?;
i32::try_from(raw)
.map(Some)
.map_err(|_| Error::CorruptEventLog {
path: events_path.to_path_buf(),
reason: format!(
"event seq={} kind={} `{field}` out of i32 range: {raw}",
ev.seq, ev.kind
),
})
}
}
}
fn optional_ts(
d: &Value,
field: &str,
events_path: &Path,
ev: &Event,
) -> Result<Option<DateTime<Utc>>> {
match d.get(field) {
None | Some(Value::Null) => Ok(None),
Some(Value::String(s)) => DateTime::parse_from_rfc3339(s)
.map(|dt| Some(dt.with_timezone(&Utc)))
.map_err(|_| Error::CorruptEventLog {
path: events_path.to_path_buf(),
reason: format!(
"event seq={} kind={} `{field}` not RFC3339",
ev.seq, ev.kind
),
}),
Some(_) => Err(Error::CorruptEventLog {
path: events_path.to_path_buf(),
reason: format!(
"event seq={} kind={} `{field}` must be RFC3339 string or null",
ev.seq, ev.kind
),
}),
}
}
pub(crate) enum ProjectionOp {
Manifest(Manifest),
Node(Node),
}
pub(crate) fn commit_ops(paths: &RunPaths, ops: Vec<ProjectionOp>) -> Result<()> {
for op in ops {
match op {
ProjectionOp::Manifest(m) => write_manifest(paths, &m)?,
ProjectionOp::Node(n) => write_node(paths, &n)?,
}
}
Ok(())
}
pub(crate) fn reduce_event_to_ops(paths: &RunPaths, ev: &Event) -> Result<Vec<ProjectionOp>> {
if ev.run_id != paths.run_id {
return Err(Error::CorruptEventLog {
path: paths.events(),
reason: format!(
"event seq={} envelope run_id {:?} does not match run {:?}",
ev.seq,
ev.run_id.as_str(),
paths.run_id.as_str()
),
});
}
#[allow(clippy::match_same_arms)]
match ev.kind.as_str() {
"run.created" => reduce_run_created(paths, ev),
"run.status" => reduce_run_status(paths, ev),
"node.created" => reduce_node_created(paths, ev),
"node.status" => reduce_node_status(paths, ev),
"node.report" => reduce_node_report(paths, ev),
"node.retry" => reduce_node_retry(paths, ev),
"worker.exited" => reduce_worker_exited(paths, ev),
"node.death_observed" => reduce_node_death_observed(paths, ev),
KIND_MERGE_STARTED => reduce_merge_started(paths, ev),
KIND_MERGE_ABORTED => reduce_merge_aborted(paths, ev),
"child.spawned" => reduce_child_spawned(paths, ev),
"supervisor.attached" => reduce_supervisor_attached(paths, ev),
"supervisor.cursor_advanced" => reduce_supervisor_cursor_advanced(paths, ev),
"supervisor.exited" => Ok(vec![]),
"orchestrator.decision" | "discuss.critical" => Ok(vec![]),
"run.notified" => Ok(vec![]),
"cleanup.window_missing"
| "cleanup.worktree_missing"
| "cleanup.branch_remove_failed"
| "cleanup.branch_preserved"
| "cleanup.session_killed"
| "cleanup.session_retained" => Ok(vec![]),
"supervisor.child_id_quarantined" => Ok(vec![]),
_ => Ok(vec![]),
}
}
fn op_path(paths: &RunPaths, op: &ProjectionOp) -> PathBuf {
match op {
ProjectionOp::Manifest(_) => paths.manifest(),
ProjectionOp::Node(n) => paths.node(&n.node_id),
}
}
pub fn plan_projections(paths: &RunPaths, event: &Event) -> Result<Vec<PathBuf>> {
let ops = reduce_event_to_ops(paths, event)?;
Ok(ops.iter().map(|op| op_path(paths, op)).collect())
}
pub(crate) fn apply_event(paths: &RunPaths, ev: &Event) -> Result<()> {
let ops = reduce_event_to_ops(paths, ev)?;
commit_ops(paths, ops)
}
#[cfg(test)]
pub(crate) fn validate_event(paths: &RunPaths, ev: &Event) -> Result<()> {
reduce_event_to_ops(paths, ev).map(|_| ())
}
fn require_envelope_node_id(events_path: &Path, ev: &Event) -> Result<NodeId> {
ev.node_id.clone().ok_or_else(|| Error::CorruptEventLog {
path: events_path.to_path_buf(),
reason: format!(
"event seq={} kind={} missing top-level `node_id`",
ev.seq, ev.kind
),
})
}
fn reduce_run_created(paths: &RunPaths, ev: &Event) -> Result<Vec<ProjectionOp>> {
if let Some(existing) = read_manifest_opt(paths)? {
if existing.run_id != ev.run_id {
return Err(Error::CorruptEventLog {
path: paths.manifest(),
reason: format!(
"run.created run_id={} conflicts with existing manifest run_id={}",
ev.run_id, existing.run_id
),
});
}
return Ok(vec![]);
}
let events_path = paths.events();
let d = &ev.data;
let kind =
data_kind(d.get("kind").unwrap_or(&Value::Null)).ok_or_else(|| Error::CorruptEventLog {
path: events_path.clone(),
reason: "run.created missing/invalid `kind`".into(),
})?;
let lifecycle: Lifecycle = serde_json::from_value(
d.get("lifecycle").cloned().unwrap_or(Value::Null),
)
.map_err(|_| Error::CorruptEventLog {
path: events_path.clone(),
reason: "run.created missing/invalid `lifecycle`".into(),
})?;
let title = want_str(&events_path, ev, d, "title")?.to_string();
let m = Manifest {
schema_version: STATE_SCHEMA_VERSION,
applied_seq: 0,
run_id: paths.run_id.clone(),
kind,
lifecycle,
title,
status: Status::Pending,
created_at: ev.ts,
updated_at: ev.ts,
source_repo: d
.get("source_repo")
.and_then(Value::as_str)
.map(str::to_string),
source_branch: d
.get("source_branch")
.and_then(Value::as_str)
.map(str::to_string),
worktree_root: d
.get("worktree_root")
.and_then(Value::as_str)
.map(str::to_string),
managed_tmux_session: d
.get("managed_tmux_session")
.and_then(Value::as_str)
.map(str::to_string),
notify_cmd: d
.get("notify_cmd")
.and_then(Value::as_str)
.map(str::to_string),
harness: d.get("harness").and_then(Value::as_str).map(str::to_string),
node_count: 0,
parent_run_id: opt_run_id(&events_path, ev, d, "parent_run_id")?,
parent_node_id: opt_node_id(&events_path, ev, d, "parent_node_id")?,
};
Ok(vec![ProjectionOp::Manifest(m)])
}
fn reduce_run_status(paths: &RunPaths, ev: &Event) -> Result<Vec<ProjectionOp>> {
let mut m = match read_manifest_opt(paths)? {
Some(m) => m,
None => return Ok(vec![]),
};
let new_status = require_status(ev, paths.events())?;
if m.status.is_terminal() {
trace_terminal_noop(ev, m.status, new_status);
return Ok(vec![]);
}
if m.status == new_status {
return Ok(vec![]);
}
m.status = new_status;
m.updated_at = ev.ts;
Ok(vec![ProjectionOp::Manifest(m)])
}
fn tmux_identity_from_data(d: &Value) -> Option<TmuxIdentity> {
let nonempty = |key| {
d.get(key)
.and_then(Value::as_str)
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string)
};
let session = nonempty("tmux_session")?;
let window_id = nonempty("tmux_window_id")?;
Some(TmuxIdentity {
socket: nonempty("tmux_socket"),
session,
window_id,
pane_id: nonempty("tmux_pane_id"),
})
}
fn reduce_node_created(paths: &RunPaths, ev: &Event) -> Result<Vec<ProjectionOp>> {
let events_path = paths.events();
let node_id = require_envelope_node_id(&events_path, ev)?;
if read_node_opt(paths, &node_id)?.is_some() {
return Ok(vec![]);
}
let d = &ev.data;
let kind =
data_kind(d.get("kind").unwrap_or(&Value::Null)).ok_or_else(|| Error::CorruptEventLog {
path: events_path.clone(),
reason: format!(
"event seq={} kind=node.created missing/invalid `kind`",
ev.seq
),
})?;
let n = Node {
schema_version: STATE_SCHEMA_VERSION,
node_id,
run_id: paths.run_id.clone(),
parent_node_id: opt_node_id(&events_path, ev, d, "parent_node_id")?,
kind,
status: Status::Pending,
task: d.get("task").and_then(Value::as_str).map(str::to_string),
worktree_path: d
.get("worktree_path")
.and_then(Value::as_str)
.map(str::to_string),
branch: d.get("branch").and_then(Value::as_str).map(str::to_string),
base_sha: d
.get("base_sha")
.and_then(Value::as_str)
.filter(|s| !s.is_empty())
.map(str::to_string),
tmux_window: d
.get("tmux_window")
.and_then(Value::as_str)
.map(str::to_string),
tmux_identity: tmux_identity_from_data(d),
agent_pid: optional_i32(d, "agent_pid", &events_path, ev)?,
agent_pid_start_time: optional_ts(d, "agent_pid_start_time", &events_path, ev)?,
supervisor_pid: optional_i32(d, "supervisor_pid", &events_path, ev)?,
children: Vec::new(),
started_at: Some(ev.ts),
updated_at: ev.ts,
last_report: None,
last_processed_report_seq_by_child: serde_json::Map::default(),
retry_attempts: 0,
worker_exit: None,
pending_merge: None,
first_death_at: None,
};
let mut ops = vec![ProjectionOp::Node(n)];
if let Some(mut m) = read_manifest_opt(paths)? {
m.updated_at = ev.ts;
ops.push(ProjectionOp::Manifest(m));
}
Ok(ops)
}
fn reduce_node_retry(paths: &RunPaths, ev: &Event) -> Result<Vec<ProjectionOp>> {
let events_path = paths.events();
let node_id = require_envelope_node_id(&events_path, ev)?;
let mut n = match read_node_opt(paths, &node_id)? {
Some(n) => n,
None => return Ok(vec![]),
};
if n.status.is_terminal() {
tracing::debug!(
target: "octl_core::reducer",
seq = ev.seq, kind = %ev.kind, node_id = %node_id, current = ?n.status,
"no-op: node.retry against terminal node"
);
return Ok(vec![]);
}
let d = &ev.data;
n.branch = d.get("branch").and_then(Value::as_str).map(str::to_string);
n.base_sha = d
.get("base_sha")
.and_then(Value::as_str)
.filter(|s| !s.is_empty())
.map(str::to_string);
n.worktree_path = d
.get("worktree_path")
.and_then(Value::as_str)
.map(str::to_string);
n.tmux_window = d
.get("tmux_window")
.and_then(Value::as_str)
.map(str::to_string);
n.tmux_identity = tmux_identity_from_data(d);
n.agent_pid = optional_i32(d, "agent_pid", &events_path, ev)?;
n.agent_pid_start_time = optional_ts(d, "agent_pid_start_time", &events_path, ev)?;
n.status = Status::Pending;
n.started_at = Some(ev.ts);
n.updated_at = ev.ts;
n.last_report = None;
n.pending_merge = None;
n.worker_exit = None;
n.first_death_at = None;
n.retry_attempts = d
.get("attempt")
.and_then(Value::as_u64)
.map_or_else(|| n.retry_attempts.saturating_add(1), |a| a as u32);
Ok(vec![ProjectionOp::Node(n)])
}
fn reduce_node_status(paths: &RunPaths, ev: &Event) -> Result<Vec<ProjectionOp>> {
let events_path = paths.events();
let node_id = require_envelope_node_id(&events_path, ev)?;
let mut n = match read_node_opt(paths, &node_id)? {
Some(n) => n,
None => return Ok(vec![]),
};
let new_status = require_status(ev, events_path)?;
if n.status.is_terminal() {
trace_terminal_noop(ev, n.status, new_status);
return Ok(vec![]);
}
if n.status == new_status {
return Ok(vec![]);
}
n.status = new_status;
if new_status.is_terminal() {
n.pending_merge = None;
}
n.updated_at = ev.ts;
Ok(vec![ProjectionOp::Node(n)])
}
fn reduce_node_report(paths: &RunPaths, ev: &Event) -> Result<Vec<ProjectionOp>> {
let events_path = paths.events();
let node_id = require_envelope_node_id(&events_path, ev)?;
let mut n = match read_node_opt(paths, &node_id)? {
Some(n) => n,
None => return Ok(vec![]),
};
if n.status.is_terminal() {
if matches!(n.status, Status::Failed | Status::Done)
&& report_is_confirmed_explicit_merge(&ev.data)
{
if n.last_report.as_ref() == Some(&ev.data) && n.status == Status::Done {
return Ok(vec![]);
}
tracing::info!(
target: "octl_core::reducer",
seq = ev.seq, kind = %ev.kind, node_id = %node_id, prior = ?n.status,
"adopting late explicit-merge report against terminal node (invariant #5 teardown)"
);
n.last_report = Some(ev.data.clone());
n.status = Status::Done;
n.pending_merge = None;
n.updated_at = ev.ts;
return Ok(vec![ProjectionOp::Node(n)]);
}
tracing::debug!(
target: "octl_core::reducer",
seq = ev.seq, kind = %ev.kind, node_id = %node_id, current = ?n.status,
"no-op: node.report against terminal node"
);
return Ok(vec![]);
}
let new_status = report_terminal_status(&events_path, ev)?;
n.last_report = Some(ev.data.clone());
n.status = new_status;
n.pending_merge = None;
n.updated_at = ev.ts;
Ok(vec![ProjectionOp::Node(n)])
}
fn reduce_worker_exited(paths: &RunPaths, ev: &Event) -> Result<Vec<ProjectionOp>> {
let events_path = paths.events();
let node_id = require_envelope_node_id(&events_path, ev)?;
let code = optional_i32(&ev.data, "exit_code", &events_path, ev)?;
let signal = optional_i32(&ev.data, "signal", &events_path, ev)?;
match (code, signal) {
(Some(_), None) | (None, Some(_)) => {}
_ => {
return Err(Error::CorruptEventLog {
path: events_path,
reason: format!(
"event seq={} kind=worker.exited must carry EXACTLY one of `exit_code` or `signal`",
ev.seq
),
});
}
}
let mut n = match read_node_opt(paths, &node_id)? {
Some(n) => n,
None => return Ok(vec![]),
};
if n.worker_exit.is_some() {
return Ok(vec![]);
}
n.worker_exit = Some(WorkerExit {
code,
signal,
at: ev.ts,
});
n.updated_at = ev.ts;
Ok(vec![ProjectionOp::Node(n)])
}
fn reduce_node_death_observed(paths: &RunPaths, ev: &Event) -> Result<Vec<ProjectionOp>> {
let events_path = paths.events();
let node_id = require_envelope_node_id(&events_path, ev)?;
let mut n = match read_node_opt(paths, &node_id)? {
Some(n) => n,
None => return Ok(vec![]),
};
if n.first_death_at.is_some()
|| n.status.is_terminal()
|| n.worker_exit.is_some()
|| n.last_report.is_some()
|| n.pending_merge.is_some()
{
return Ok(vec![]);
}
n.first_death_at = Some(ev.ts);
n.updated_at = ev.ts;
Ok(vec![ProjectionOp::Node(n)])
}
pub const KIND_MERGE_STARTED: &str = "merge.started";
pub const KIND_MERGE_ABORTED: &str = "merge.aborted";
fn reduce_merge_started(paths: &RunPaths, ev: &Event) -> Result<Vec<ProjectionOp>> {
let events_path = paths.events();
let node_id = require_envelope_node_id(&events_path, ev)?;
let txn: MergeTxn =
serde_json::from_value(ev.data.clone()).map_err(|e| Error::CorruptEventLog {
path: events_path.clone(),
reason: format!(
"event seq={} kind=merge.started has an invalid MergeTxn payload: {e}",
ev.seq
),
})?;
let mut n = match read_node_opt(paths, &node_id)? {
Some(n) => n,
None => return Ok(vec![]),
};
if n.status.is_terminal() {
return Ok(vec![]);
}
if n.pending_merge.as_ref().map(|t| t.op_id.as_str()) == Some(txn.op_id.as_str()) {
return Ok(vec![]);
}
n.pending_merge = Some(Box::new(txn));
n.updated_at = ev.ts;
Ok(vec![ProjectionOp::Node(n)])
}
fn reduce_merge_aborted(paths: &RunPaths, ev: &Event) -> Result<Vec<ProjectionOp>> {
let events_path = paths.events();
let node_id = require_envelope_node_id(&events_path, ev)?;
let op_id = ev
.data
.get("op_id")
.and_then(Value::as_str)
.ok_or_else(|| Error::CorruptEventLog {
path: events_path.clone(),
reason: format!(
"event seq={} kind=merge.aborted is missing string `op_id`",
ev.seq
),
})?;
let mut n = match read_node_opt(paths, &node_id)? {
Some(n) => n,
None => return Ok(vec![]),
};
match n.pending_merge.as_ref() {
Some(t) if t.op_id == op_id => {}
_ => return Ok(vec![]),
}
n.pending_merge = None;
n.updated_at = ev.ts;
Ok(vec![ProjectionOp::Node(n)])
}
fn trace_terminal_noop(ev: &Event, current: Status, incoming: Status) {
if current == incoming {
tracing::debug!(
target: "octl_core::reducer",
seq = ev.seq, kind = %ev.kind, status = ?current,
"no-op: status re-applied to terminal target"
);
} else {
tracing::warn!(
target: "octl_core::reducer",
seq = ev.seq, kind = %ev.kind, current = ?current, incoming = ?incoming,
"no-op: ignored conflicting transition from terminal target"
);
}
}
fn report_is_confirmed_explicit_merge(data: &Value) -> bool {
ReportOrigin::report_is_confirmed_merge(data)
}
fn report_terminal_status(events_path: &Path, ev: &Event) -> Result<Status> {
let corrupt = |reason: String| Error::CorruptEventLog {
path: events_path.to_path_buf(),
reason,
};
let cancelled = optional_bool(events_path, ev, &ev.data, "cancelled")?.unwrap_or(false);
let success = optional_bool(events_path, ev, &ev.data, "success")?;
if cancelled {
if success == Some(true) {
return Err(corrupt(format!(
"event seq={} kind=node.report has contradictory `success: true` with `cancelled: true`",
ev.seq
)));
}
Ok(Status::Cancelled)
} else {
match success {
Some(true) => Ok(Status::Done),
Some(false) => Ok(Status::Failed),
None => Err(corrupt(format!(
"event seq={} kind=node.report must set boolean `success` or `cancelled: true`",
ev.seq
))),
}
}
}
fn reduce_child_spawned(paths: &RunPaths, ev: &Event) -> Result<Vec<ProjectionOp>> {
let events_path = paths.events();
let parent_node_id = ev.node_id.clone().ok_or_else(|| Error::CorruptEventLog {
path: events_path.clone(),
reason: format!(
"event seq={} kind=child.spawned missing parent `node_id`",
ev.seq
),
})?;
let child_run_id = RunId::parse_str(want_str(&events_path, ev, &ev.data, "child_run_id")?)
.map_err(|e| corrupt_id(&events_path, ev, &e))?;
let child_node_id = NodeId::parse_str(
ev.data
.get("child_node_id")
.and_then(Value::as_str)
.unwrap_or("n-0001"),
)
.map_err(|e| corrupt_id(&events_path, ev, &e))?;
let mut n = match read_node_opt(paths, &parent_node_id)? {
Some(n) => n,
None => return Ok(vec![]),
};
let new_ref = ChildRef {
run_id: child_run_id,
node_id: child_node_id,
};
if n.children.iter().any(|c| c == &new_ref) {
return Ok(vec![]);
}
n.children.push(new_ref);
n.updated_at = ev.ts;
Ok(vec![ProjectionOp::Node(n)])
}
fn reduce_supervisor_attached(paths: &RunPaths, ev: &Event) -> Result<Vec<ProjectionOp>> {
let events_path = paths.events();
let node_id = require_envelope_node_id(&events_path, ev)?;
let raw = ev
.data
.get("pid")
.and_then(Value::as_i64)
.ok_or_else(|| Error::CorruptEventLog {
path: events_path.clone(),
reason: format!(
"event seq={} kind=supervisor.attached missing/invalid `pid`",
ev.seq
),
})?;
let pid = i32::try_from(raw).map_err(|_| Error::CorruptEventLog {
path: events_path.clone(),
reason: format!(
"event seq={} kind=supervisor.attached `pid` out of i32 range: {raw}",
ev.seq
),
})?;
let mut n = match read_node_opt(paths, &node_id)? {
Some(n) => n,
None => return Ok(vec![]),
};
if n.supervisor_pid == Some(pid) {
return Ok(vec![]);
}
n.supervisor_pid = Some(pid);
n.updated_at = ev.ts;
Ok(vec![ProjectionOp::Node(n)])
}
fn reduce_supervisor_cursor_advanced(paths: &RunPaths, ev: &Event) -> Result<Vec<ProjectionOp>> {
let events_path = paths.events();
let node_id = require_envelope_node_id(&events_path, ev)?;
let child_run_id = want_str(&events_path, ev, &ev.data, "child_run_id")?;
RunId::parse_str(child_run_id).map_err(|e| corrupt_id(&events_path, ev, &e))?;
let report_seq = ev
.data
.get("report_seq")
.and_then(Value::as_u64)
.ok_or_else(|| Error::CorruptEventLog {
path: events_path.clone(),
reason: format!(
"event seq={} kind=supervisor.cursor_advanced missing/invalid `report_seq`",
ev.seq
),
})?;
let mut n = match read_node_opt(paths, &node_id)? {
Some(n) => n,
None => return Ok(vec![]),
};
if let Some(prev) = n
.last_processed_report_seq_by_child
.get(child_run_id)
.and_then(Value::as_u64)
{
if report_seq <= prev {
return Ok(vec![]);
}
}
n.last_processed_report_seq_by_child
.insert(child_run_id.to_string(), Value::from(report_seq));
n.updated_at = ev.ts;
Ok(vec![ProjectionOp::Node(n)])
}
#[cfg(test)]
mod tests {
use super::*;
use crate::schema::Event;
use chrono::Utc;
use tempfile::TempDir;
fn event(run_id: &str) -> Event {
Event {
ts: Utc::now(),
seq: 1,
kind: "run.status".into(),
run_id: RunId::parse_str(run_id).unwrap(),
node_id: None,
idempotency_key: None,
data: serde_json::json!({ "status": "running" }),
}
}
#[test]
fn orchestrator_decision_and_discuss_critical_reduce_to_noop() {
let tmp = TempDir::new().unwrap();
let run_id = "01jxsnap000000000000000000";
let rid = RunId::parse_str(run_id).unwrap();
let dir = crate::run_dir(tmp.path(), &rid);
std::fs::create_dir_all(&dir).unwrap();
let paths = RunPaths::new(dir, run_id).unwrap();
let mut created = event(run_id);
created.kind = "run.created".into();
created.data = serde_json::json!({
"kind": "spinoff", "lifecycle": "autonomous", "title": "t"
});
apply_event(&paths, &created).expect("run.created applies");
let manifest_before = std::fs::read(paths.manifest()).unwrap();
for (seq, kind) in [(10u64, "orchestrator.decision"), (11, "discuss.critical")] {
let mut ev = event(run_id);
ev.seq = seq;
ev.kind = kind.into();
ev.data = serde_json::json!({ "summary": "x", "arbitrary": [1, 2, 3] });
let ops = reduce_event_to_ops(&paths, &ev).expect("audit kind reduces cleanly");
assert!(ops.is_empty(), "{kind} must plan no projection ops");
apply_event(&paths, &ev).expect("audit kind applies as no-op");
}
assert_eq!(
std::fs::read(paths.manifest()).unwrap(),
manifest_before,
"audit events must not mutate the manifest"
);
assert!(!paths.nodes_dir().exists(), "no node projection created");
}
#[test]
fn run_created_folds_harness_when_present_and_defaults_none() {
let tmp = TempDir::new().unwrap();
let run_id = "01jxhrnsaa0000000000000001";
let rid = RunId::parse_str(run_id).unwrap();
let dir = crate::run_dir(tmp.path(), &rid);
std::fs::create_dir_all(&dir).unwrap();
let paths = RunPaths::new(dir, run_id).unwrap();
let mut created = event(run_id);
created.kind = "run.created".into();
created.data = serde_json::json!({
"kind": "spinoff", "lifecycle": "autonomous", "title": "t",
"harness": "pi", "harness_source": "flag",
});
apply_event(&paths, &created).expect("run.created applies");
let m = read_manifest_opt(&paths).unwrap().unwrap();
assert_eq!(m.harness.as_deref(), Some("pi"));
let run_id2 = "01jxhrnsaa0000000000000002";
let rid2 = RunId::parse_str(run_id2).unwrap();
let dir2 = crate::run_dir(tmp.path(), &rid2);
std::fs::create_dir_all(&dir2).unwrap();
let paths2 = RunPaths::new(dir2, run_id2).unwrap();
let mut created2 = event(run_id2);
created2.kind = "run.created".into();
created2.data =
serde_json::json!({ "kind": "spinoff", "lifecycle": "autonomous", "title": "t" });
apply_event(&paths2, &created2).expect("run.created applies");
let m2 = read_manifest_opt(&paths2).unwrap().unwrap();
assert_eq!(m2.harness, None);
}
fn bootstrap_retry_node(tmp: &TempDir, run_id: &str) -> RunPaths {
let rid = RunId::parse_str(run_id).unwrap();
let dir = crate::run_dir(tmp.path(), &rid);
std::fs::create_dir_all(&dir).unwrap();
let paths = RunPaths::new(dir, run_id).unwrap();
let mut created = event(run_id);
created.kind = "run.created".into();
created.data =
serde_json::json!({ "kind": "spinoff", "lifecycle": "autonomous", "title": "t" });
apply_event(&paths, &created).expect("run.created applies");
let mut node = event(run_id);
node.seq = 2;
node.kind = "node.created".into();
node.node_id = Some(NodeId::parse_str("n-0001").unwrap());
node.data = serde_json::json!({
"kind": "spinoff",
"branch": "wt/foo",
"worktree_path": "/tmp/old-wt",
"agent_pid": 111,
});
apply_event(&paths, &node).expect("node.created applies");
paths
}
#[test]
fn node_retry_rewires_node_and_increments_attempts() {
let tmp = TempDir::new().unwrap();
let run_id = "01jxsnap000000000000000000";
let paths = bootstrap_retry_node(&tmp, run_id);
let mut retry = event(run_id);
retry.seq = 3;
retry.kind = "node.retry".into();
retry.node_id = Some(NodeId::parse_str("n-0001").unwrap());
retry.data = serde_json::json!({
"attempt": 1,
"reason": "agent-died",
"branch": "wt/foo-r1",
"base_sha": "a".repeat(40),
"worktree_path": "/tmp/new-wt",
"agent_pid": 222,
"tmux_session": "s",
"tmux_window_id": "@9",
});
apply_event(&paths, &retry).expect("node.retry applies");
let n = read_n0001(&paths);
assert_eq!(n.retry_attempts, 1, "attempt bound incremented");
assert_eq!(
n.branch.as_deref(),
Some("wt/foo-r1"),
"rewired to new branch"
);
assert_eq!(n.worktree_path.as_deref(), Some("/tmp/new-wt"));
assert_eq!(n.agent_pid, Some(222), "rewired to new agent pid");
assert_eq!(n.status, Status::Pending, "node returns to pending");
assert!(n.last_report.is_none());
assert_eq!(
n.tmux_identity.as_ref().map(|t| t.window_id.as_str()),
Some("@9"),
"rewired tmux identity"
);
let mut retry2 = event(run_id);
retry2.seq = 4;
retry2.kind = "node.retry".into();
retry2.node_id = Some(NodeId::parse_str("n-0001").unwrap());
retry2.data = serde_json::json!({
"attempt": 2, "reason": "agent-died", "branch": "wt/foo-r2",
"worktree_path": "/tmp/new-wt-2", "agent_pid": 333,
});
apply_event(&paths, &retry2).expect("node.retry applies");
assert_eq!(read_n0001(&paths).retry_attempts, 2);
}
#[test]
fn node_retry_against_terminal_node_is_noop() {
let tmp = TempDir::new().unwrap();
let run_id = "01jxsnap000000000000000000";
let paths = bootstrap_retry_node(&tmp, run_id);
let mut report = event(run_id);
report.seq = 3;
report.kind = "node.report".into();
report.node_id = Some(NodeId::parse_str("n-0001").unwrap());
report.data = serde_json::json!({ "success": true });
apply_event(&paths, &report).expect("node.report applies");
assert_eq!(read_n0001(&paths).status, Status::Done);
let mut retry = event(run_id);
retry.seq = 4;
retry.kind = "node.retry".into();
retry.node_id = Some(NodeId::parse_str("n-0001").unwrap());
retry.data = serde_json::json!({
"attempt": 1, "reason": "agent-died", "branch": "wt/foo-r1",
"worktree_path": "/tmp/new-wt", "agent_pid": 222,
});
apply_event(&paths, &retry).expect("node.retry applies as no-op");
let n = read_n0001(&paths);
assert_eq!(n.status, Status::Done, "terminal node not resurrected");
assert_eq!(n.retry_attempts, 0, "no increment against terminal node");
assert_eq!(n.agent_pid, Some(111), "not rewired");
}
#[test]
fn apply_event_rejects_event_from_a_different_run() {
let tmp = TempDir::new().unwrap();
let run_id = "01jxsnap000000000000000000";
let rid = RunId::parse_str(run_id).unwrap();
let dir = crate::run_dir(tmp.path(), &rid);
std::fs::create_dir_all(&dir).unwrap();
let paths = RunPaths::new(dir, run_id).unwrap();
let foreign = event("02jxsnap000000000000000000");
let err = apply_event(&paths, &foreign).expect_err("cross-run event must be rejected");
assert!(matches!(err, Error::CorruptEventLog { .. }), "got {err:?}");
let mine = event(run_id);
apply_event(&paths, &mine).expect("matching run_id must be accepted");
}
#[test]
fn tmux_identity_from_data_reads_qualified_fields() {
let d = serde_json::json!({
"tmux_socket": "/private/tmp/tmux-501/default",
"tmux_session": "octl",
"tmux_window_id": "@42",
});
let id = tmux_identity_from_data(&d).expect("qualified identity");
assert_eq!(id.socket.as_deref(), Some("/private/tmp/tmux-501/default"));
assert_eq!(id.session, "octl");
assert_eq!(id.window_id, "@42");
assert_eq!(id.pane_id, None);
let d2 = serde_json::json!({
"tmux_socket": null,
"tmux_session": "octl",
"tmux_window_id": "@7",
});
let id2 = tmux_identity_from_data(&d2).expect("identity without socket");
assert_eq!(id2.socket, None);
assert_eq!(id2.window_id, "@7");
let d3 = serde_json::json!({
"tmux_session": "octl",
"tmux_window_id": "@42",
"tmux_pane_id": "%7",
});
let id3 = tmux_identity_from_data(&d3).expect("identity with pane");
assert_eq!(id3.pane_id.as_deref(), Some("%7"));
assert_eq!(id3.capture_target(), "%7");
let d4 = serde_json::json!({
"tmux_session": "octl",
"tmux_window_id": "@42",
"tmux_pane_id": null,
});
let id4 = tmux_identity_from_data(&d4).expect("identity with null pane");
assert_eq!(id4.pane_id, None);
assert_eq!(id4.capture_target(), "@42");
}
#[test]
fn tmux_identity_from_data_back_compat_is_none() {
let legacy = serde_json::json!({ "tmux_window": "🚀 wt/x" });
assert!(tmux_identity_from_data(&legacy).is_none());
let partial = serde_json::json!({ "tmux_window_id": "@42" });
assert!(tmux_identity_from_data(&partial).is_none());
}
#[test]
fn node_created_populates_tmux_identity() {
let tmp = TempDir::new().unwrap();
let run_id = "01jxsnap000000000000000000";
let rid = RunId::parse_str(run_id).unwrap();
let dir = crate::run_dir(tmp.path(), &rid);
std::fs::create_dir_all(&dir).unwrap();
let paths = RunPaths::new(dir, run_id).unwrap();
let mut ev = event(run_id);
ev.seq = 2;
ev.kind = "node.created".into();
ev.node_id = Some(NodeId::parse_str("n-0001").unwrap());
ev.data = serde_json::json!({
"kind": "spinoff",
"tmux_window": "🚀 wt/x",
"tmux_socket": "/private/tmp/tmux-501/default",
"tmux_session": "octl",
"tmux_window_id": "@42",
});
apply_event(&paths, &ev).expect("node.created applies");
let n = read_node_opt(&paths, &NodeId::parse_str("n-0001").unwrap())
.unwrap()
.unwrap();
let id = n.tmux_identity.expect("qualified identity recorded");
assert_eq!(id.session, "octl");
assert_eq!(id.window_id, "@42");
assert_eq!(n.tmux_window.as_deref(), Some("🚀 wt/x"));
let run2 = "02jxsnap000000000000000000";
let rid2 = RunId::parse_str(run2).unwrap();
let dir2 = crate::run_dir(tmp.path(), &rid2);
std::fs::create_dir_all(&dir2).unwrap();
let paths2 = RunPaths::new(dir2, run2).unwrap();
let mut ev2 = event(run2);
ev2.seq = 2;
ev2.kind = "node.created".into();
ev2.node_id = Some(NodeId::parse_str("n-0001").unwrap());
ev2.data = serde_json::json!({ "kind": "spinoff", "tmux_window": "🚀 wt/y" });
apply_event(&paths2, &ev2).expect("legacy node.created applies");
let n2 = read_node_opt(&paths2, &NodeId::parse_str("n-0001").unwrap())
.unwrap()
.unwrap();
assert!(n2.tmux_identity.is_none());
assert_eq!(n2.tmux_window.as_deref(), Some("🚀 wt/y"));
}
fn seed_run_with_node(tmp: &TempDir, run_id: &str) -> RunPaths {
let rid = RunId::parse_str(run_id).unwrap();
let dir = crate::run_dir(tmp.path(), &rid);
std::fs::create_dir_all(&dir).unwrap();
let paths = RunPaths::new(dir, run_id).unwrap();
let mut created = event(run_id);
created.kind = "run.created".into();
created.data = serde_json::json!({
"kind": "spinoff", "lifecycle": "autonomous", "title": "t"
});
apply_event(&paths, &created).expect("run.created applies");
let mut node = event(run_id);
node.seq = 2;
node.kind = "node.created".into();
node.node_id = Some(NodeId::parse_str("n-0001").unwrap());
node.data = serde_json::json!({ "kind": "spinoff" });
apply_event(&paths, &node).expect("node.created applies");
paths
}
fn read_n0001(paths: &RunPaths) -> Node {
read_node_opt(paths, &NodeId::parse_str("n-0001").unwrap())
.unwrap()
.unwrap()
}
fn merge_started_event(run_id: &str, seq: u64, op_id: &str, expected: &str) -> Event {
let mut ev = event(run_id);
ev.seq = seq;
ev.kind = KIND_MERGE_STARTED.into();
ev.node_id = Some(NodeId::parse_str("n-0001").unwrap());
ev.data = serde_json::json!({
"op_id": op_id,
"source_branch": "main",
"worker_branch": "wt/worker",
"expected_source_oid": expected,
"worker_oid": "cafebabecafebabecafebabecafebabecafebabe",
"base_sha": null,
"driver_pid": 4242,
"driver_pid_start_secs": null,
"started_at": "2026-08-15T00:00:00Z",
});
ev
}
#[test]
fn merge_started_records_pending_transaction() {
let tmp = TempDir::new().unwrap();
let run_id = "01jxsnap000000000000000000";
let paths = seed_run_with_node(&tmp, run_id);
apply_event(&paths, &merge_started_event(run_id, 3, "op-1", "aaa")).unwrap();
let n = read_n0001(&paths);
assert_eq!(
n.status,
Status::Pending,
"recording a merge is not terminal"
);
let txn = n.pending_merge.expect("transaction recorded");
assert_eq!(txn.op_id, "op-1");
assert_eq!(txn.expected_source_oid, "aaa");
}
#[test]
fn merge_aborted_clears_matching_transaction_only() {
let tmp = TempDir::new().unwrap();
let run_id = "01jxsnap000000000000000000";
let paths = seed_run_with_node(&tmp, run_id);
apply_event(&paths, &merge_started_event(run_id, 3, "op-1", "aaa")).unwrap();
let mut stale = event(run_id);
stale.seq = 4;
stale.kind = KIND_MERGE_ABORTED.into();
stale.node_id = Some(NodeId::parse_str("n-0001").unwrap());
stale.data = serde_json::json!({ "op_id": "op-OTHER", "reason": "x" });
apply_event(&paths, &stale).unwrap();
assert!(
read_n0001(&paths).pending_merge.is_some(),
"stale abort is a no-op"
);
let mut abort = event(run_id);
abort.seq = 5;
abort.kind = KIND_MERGE_ABORTED.into();
abort.node_id = Some(NodeId::parse_str("n-0001").unwrap());
abort.data = serde_json::json!({ "op_id": "op-1", "reason": "no mutation" });
apply_event(&paths, &abort).unwrap();
let n = read_n0001(&paths);
assert!(
n.pending_merge.is_none(),
"matching abort clears the transaction"
);
assert_eq!(n.status, Status::Pending, "abort does not terminalize");
}
#[test]
fn terminal_report_clears_pending_merge() {
let tmp = TempDir::new().unwrap();
let run_id = "01jxsnap000000000000000000";
let paths = seed_run_with_node(&tmp, run_id);
apply_event(&paths, &merge_started_event(run_id, 3, "op-1", "aaa")).unwrap();
let mut report = event(run_id);
report.seq = 4;
report.kind = "node.report".into();
report.node_id = Some(NodeId::parse_str("n-0001").unwrap());
report.data = serde_json::json!({ "success": true, "via": "explicit-merge" });
apply_event(&paths, &report).unwrap();
let n = read_n0001(&paths);
assert_eq!(n.status, Status::Done);
assert!(
n.pending_merge.is_none(),
"completed merge clears the transaction"
);
}
#[test]
fn late_merge_adoption_requires_run_merge_origin_not_forged_via() {
let tmp = TempDir::new().unwrap();
let drive = |run_id: &str, report_data: Value| -> Status {
let paths = seed_run_with_node(&tmp, run_id);
let mut fail = event(run_id);
fail.seq = 3;
fail.kind = "node.status".into();
fail.node_id = Some(NodeId::parse_str("n-0001").unwrap());
fail.data = serde_json::json!({ "status": "failed" });
apply_event(&paths, &fail).unwrap();
assert_eq!(read_n0001(&paths).status, Status::Failed);
let mut report = event(run_id);
report.seq = 4;
report.kind = "node.report".into();
report.node_id = Some(NodeId::parse_str("n-0001").unwrap());
report.data = report_data;
apply_event(&paths, &report).unwrap();
read_n0001(&paths).status
};
let mut agent_forged = serde_json::json!({ "success": true, "via": "explicit-merge" });
crate::ReportOrigin::Agent.stamp(&mut agent_forged);
assert_eq!(
drive("01jxsnap000000000000000001", agent_forged),
Status::Failed,
"an Agent-origin report with a forged via must not be adopted"
);
let malformed = serde_json::json!({
"success": true, "via": "explicit-merge", "origin": "garbage-not-an-object"
});
assert_eq!(
drive("01jxsnap000000000000000002", malformed),
Status::Failed,
"a malformed origin must not re-unlock the legacy via adoption path"
);
let mut run_merge = serde_json::json!({ "success": true });
crate::ReportOrigin::RunMerge {
op_id: Some("op-1".into()),
worker_oid: Some("cafebabe".into()),
}
.stamp(&mut run_merge);
assert_eq!(
drive("01jxsnap000000000000000003", run_merge),
Status::Done,
"a genuine RunMerge-origin report is adopted and corrects Failed→Done"
);
let legacy = serde_json::json!({ "success": true, "via": "explicit-merge" });
assert_eq!(
drive("01jxsnap000000000000000004", legacy),
Status::Done,
"a legacy via-only report (no origin field) is still adopted"
);
}
#[test]
fn terminal_node_status_clears_pending_merge() {
let tmp = TempDir::new().unwrap();
let run_id = "01jxsnap000000000000000000";
let paths = seed_run_with_node(&tmp, run_id);
apply_event(&paths, &merge_started_event(run_id, 3, "op-1", "aaa")).unwrap();
assert!(read_n0001(&paths).pending_merge.is_some());
let mut status = event(run_id);
status.seq = 4;
status.kind = "node.status".into();
status.node_id = Some(NodeId::parse_str("n-0001").unwrap());
status.data = serde_json::json!({ "status": "failed" });
apply_event(&paths, &status).unwrap();
let n = read_n0001(&paths);
assert_eq!(n.status, Status::Failed);
assert!(
n.pending_merge.is_none(),
"terminal status clears the transaction"
);
}
#[test]
fn supervisor_attached_sets_supervisor_pid() {
let tmp = TempDir::new().unwrap();
let run_id = "01jxsnap000000000000000000";
let paths = seed_run_with_node(&tmp, run_id);
assert_eq!(read_n0001(&paths).supervisor_pid, None);
let mut ev = event(run_id);
ev.seq = 3;
ev.kind = "supervisor.attached".into();
ev.node_id = Some(NodeId::parse_str("n-0001").unwrap());
ev.data = serde_json::json!({ "pid": 47820 });
apply_event(&paths, &ev).expect("supervisor.attached applies");
assert_eq!(read_n0001(&paths).supervisor_pid, Some(47820));
}
#[test]
fn supervisor_attached_latest_wins_and_idempotent_on_replay() {
let tmp = TempDir::new().unwrap();
let run_id = "01jxsnap000000000000000000";
let paths = seed_run_with_node(&tmp, run_id);
let mut ev = event(run_id);
ev.kind = "supervisor.attached".into();
ev.node_id = Some(NodeId::parse_str("n-0001").unwrap());
ev.seq = 3;
ev.data = serde_json::json!({ "pid": 100 });
apply_event(&paths, &ev).expect("first attach applies");
assert_eq!(read_n0001(&paths).supervisor_pid, Some(100));
ev.seq = 4;
ev.data = serde_json::json!({ "pid": 200 });
apply_event(&paths, &ev).expect("second attach applies");
let after_second = read_n0001(&paths);
assert_eq!(after_second.supervisor_pid, Some(200));
let ops = reduce_event_to_ops(&paths, &ev).expect("replay reduces cleanly");
assert!(ops.is_empty(), "re-applying same pid must plan no ops");
apply_event(&paths, &ev).expect("replay applies as no-op");
assert_eq!(read_n0001(&paths).updated_at, after_second.updated_at);
}
#[test]
fn supervisor_cursor_advanced_sets_report_cursor() {
let tmp = TempDir::new().unwrap();
let run_id = "01jxsnap000000000000000000";
let paths = seed_run_with_node(&tmp, run_id);
let child = "02jxsnap000000000000000000";
let mut ev = event(run_id);
ev.seq = 3;
ev.kind = "supervisor.cursor_advanced".into();
ev.node_id = Some(NodeId::parse_str("n-0001").unwrap());
ev.data = serde_json::json!({ "child_run_id": child, "report_seq": 7 });
apply_event(&paths, &ev).expect("cursor_advanced applies");
let n = read_n0001(&paths);
assert_eq!(
n.last_processed_report_seq_by_child.get(child),
Some(&Value::from(7u64))
);
}
#[test]
fn supervisor_cursor_advanced_is_monotonic_and_idempotent() {
let tmp = TempDir::new().unwrap();
let run_id = "01jxsnap000000000000000000";
let paths = seed_run_with_node(&tmp, run_id);
let child_a = "02jxsnap000000000000000000";
let child_b = "03jxsnap000000000000000000";
let mut ev = event(run_id);
ev.kind = "supervisor.cursor_advanced".into();
ev.node_id = Some(NodeId::parse_str("n-0001").unwrap());
ev.seq = 3;
ev.data = serde_json::json!({ "child_run_id": child_a, "report_seq": 5 });
apply_event(&paths, &ev).expect("seq 5 applies");
let ops = reduce_event_to_ops(&paths, &ev).expect("replay reduces cleanly");
assert!(ops.is_empty(), "re-applying same cursor must plan no ops");
ev.seq = 4;
ev.data = serde_json::json!({ "child_run_id": child_a, "report_seq": 3 });
let ops = reduce_event_to_ops(&paths, &ev).expect("older seq reduces cleanly");
assert!(ops.is_empty(), "older seq must plan no ops");
apply_event(&paths, &ev).expect("older seq applies as no-op");
assert_eq!(
read_n0001(&paths)
.last_processed_report_seq_by_child
.get(child_a),
Some(&Value::from(5u64))
);
ev.seq = 5;
ev.data = serde_json::json!({ "child_run_id": child_a, "report_seq": 9 });
apply_event(&paths, &ev).expect("higher seq applies");
ev.seq = 6;
ev.data = serde_json::json!({ "child_run_id": child_b, "report_seq": 1 });
apply_event(&paths, &ev).expect("second child applies");
let n = read_n0001(&paths);
assert_eq!(
n.last_processed_report_seq_by_child.get(child_a),
Some(&Value::from(9u64))
);
assert_eq!(
n.last_processed_report_seq_by_child.get(child_b),
Some(&Value::from(1u64))
);
}
#[test]
fn supervisor_state_events_reject_malformed_payloads() {
let tmp = TempDir::new().unwrap();
let run_id = "01jxsnap000000000000000000";
let paths = seed_run_with_node(&tmp, run_id);
let nid = Some(NodeId::parse_str("n-0001").unwrap());
let mut ev = event(run_id);
ev.seq = 3;
ev.kind = "supervisor.attached".into();
ev.node_id = nid.clone();
ev.data = serde_json::json!({});
assert!(matches!(
reduce_event_to_ops(&paths, &ev),
Err(Error::CorruptEventLog { .. })
));
ev.node_id = None;
ev.data = serde_json::json!({ "pid": 1 });
assert!(matches!(
reduce_event_to_ops(&paths, &ev),
Err(Error::CorruptEventLog { .. })
));
let mut ev2 = event(run_id);
ev2.seq = 4;
ev2.kind = "supervisor.cursor_advanced".into();
ev2.node_id = nid.clone();
ev2.data = serde_json::json!({ "child_run_id": "../etc", "report_seq": 1 });
assert!(matches!(
reduce_event_to_ops(&paths, &ev2),
Err(Error::CorruptEventLog { .. })
));
ev2.data = serde_json::json!({ "child_run_id": "02jxsnap000000000000000000" });
assert!(matches!(
reduce_event_to_ops(&paths, &ev2),
Err(Error::CorruptEventLog { .. })
));
}
#[test]
fn removed_or_garbage_kind_in_created_events_is_rejected() {
let tmp = TempDir::new().unwrap();
let run_id = "01jxsnap000000000000000000";
let rid = RunId::parse_str(run_id).unwrap();
let dir = crate::run_dir(tmp.path(), &rid);
std::fs::create_dir_all(&dir).unwrap();
let paths = RunPaths::new(dir, run_id).unwrap();
for bad in ["code", "orchestrate", "bugfix", "make-skill", "garbage"] {
let mut ev = event(run_id);
ev.kind = "run.created".into();
ev.node_id = None;
ev.data = serde_json::json!({ "kind": bad, "lifecycle": "autonomous", "title": "t" });
assert!(
matches!(
reduce_event_to_ops(&paths, &ev),
Err(Error::CorruptEventLog { .. })
),
"run.created with kind {bad:?} must be rejected, not folded to Unknown"
);
}
let mut ok = event(run_id);
ok.kind = "run.created".into();
ok.node_id = None;
ok.data = serde_json::json!({ "kind": "spinoff", "lifecycle": "autonomous", "title": "t" });
assert!(reduce_event_to_ops(&paths, &ok).is_ok());
}
#[cfg(unix)]
fn projection_inodes(paths: &RunPaths) -> std::collections::BTreeMap<PathBuf, u64> {
use std::os::unix::fs::MetadataExt;
let mut consider = vec![paths.manifest()];
for dir in [paths.nodes_dir()] {
if let Ok(rd) = std::fs::read_dir(&dir) {
for ent in rd.flatten() {
let p = ent.path();
if p.extension().and_then(|s| s.to_str()) == Some("json") {
consider.push(p);
}
}
}
}
let mut map = std::collections::BTreeMap::new();
for p in consider {
if let Ok(md) = std::fs::symlink_metadata(&p) {
if md.file_type().is_file() {
map.insert(p, md.ino());
}
}
}
map
}
#[cfg(unix)]
fn assert_plan_matches_apply(paths: &RunPaths, ev: &Event, expect_writes: bool) {
use std::collections::BTreeSet;
let before = projection_inodes(paths);
let planned: BTreeSet<PathBuf> = plan_projections(paths, ev)
.unwrap_or_else(|e| panic!("plan_projections({}) errored: {e:?}", ev.kind))
.into_iter()
.collect();
apply_event(paths, ev)
.unwrap_or_else(|e| panic!("apply_event({}) errored: {e:?}", ev.kind));
let after = projection_inodes(paths);
let touched: BTreeSet<PathBuf> = after
.iter()
.filter(|(p, ino)| before.get(*p) != Some(*ino))
.map(|(p, _)| p.clone())
.collect();
assert_eq!(
planned, touched,
"kind={}: plan_projections must name exactly the files apply_event writes",
ev.kind
);
if expect_writes {
assert!(
!touched.is_empty(),
"kind={}: expected this event to write at least one projection",
ev.kind
);
}
}
#[cfg(unix)]
#[test]
fn plan_projections_matches_apply_for_every_kind() {
let tmp = TempDir::new().unwrap();
let run_id = "01jxsnap000000000000000000";
let rid = RunId::parse_str(run_id).unwrap();
let dir = crate::run_dir(tmp.path(), &rid);
std::fs::create_dir_all(&dir).unwrap();
let paths = RunPaths::new(dir, run_id).unwrap();
let nid = || Some(NodeId::parse_str("n-0001").unwrap());
let child = "02jxsnap000000000000000000";
let mut next_seq = 0u64;
let mut at = |kind: &str, node_id, data| {
next_seq += 1;
Event {
ts: Utc::now(),
seq: next_seq,
kind: kind.into(),
run_id: rid.clone(),
node_id,
idempotency_key: None,
data,
}
};
assert_plan_matches_apply(
&paths,
&at(
"run.created",
None,
serde_json::json!({ "kind": "spinoff", "lifecycle": "autonomous", "title": "t" }),
),
true,
);
assert_plan_matches_apply(
&paths,
&at(
"run.status",
None,
serde_json::json!({ "status": "running" }),
),
true,
);
assert_plan_matches_apply(
&paths,
&at(
"node.created",
nid(),
serde_json::json!({ "kind": "spinoff" }),
),
true,
);
assert_plan_matches_apply(
&paths,
&at(
"node.status",
nid(),
serde_json::json!({ "status": "running" }),
),
true,
);
assert_plan_matches_apply(
&paths,
&at(
"supervisor.attached",
nid(),
serde_json::json!({ "pid": 4242 }),
),
true,
);
assert_plan_matches_apply(
&paths,
&at(
"supervisor.cursor_advanced",
nid(),
serde_json::json!({ "child_run_id": child, "report_seq": 3 }),
),
true,
);
assert_plan_matches_apply(
&paths,
&at(
"child.spawned",
nid(),
serde_json::json!({ "child_run_id": child, "child_node_id": "n-0001" }),
),
true,
);
assert_plan_matches_apply(
&paths,
&at("node.report", nid(), serde_json::json!({ "success": true })),
true,
);
assert_plan_matches_apply(
&paths,
&at(
"node.status",
nid(),
serde_json::json!({ "status": "failed" }),
),
false,
);
for kind in [
"supervisor.exited",
"orchestrator.decision",
"discuss.critical",
"cleanup.window_missing",
] {
assert_plan_matches_apply(&paths, &at(kind, None, serde_json::json!({})), false);
}
}
#[test]
fn worker_exited_records_clean_exit_without_transitioning_status() {
let tmp = TempDir::new().unwrap();
let run_id = "01jxsnap000000000000000000";
let paths = bootstrap_retry_node(&tmp, run_id);
let mut ev = event(run_id);
ev.seq = 3;
ev.kind = "worker.exited".into();
ev.node_id = Some(NodeId::parse_str("n-0001").unwrap());
ev.data = serde_json::json!({ "exit_code": 0 });
apply_event(&paths, &ev).expect("worker.exited applies");
let n = read_node_opt(&paths, &NodeId::parse_str("n-0001").unwrap())
.unwrap()
.unwrap();
let exit = n.worker_exit.expect("worker_exit recorded");
assert_eq!(exit.code, Some(0));
assert_eq!(exit.signal, None);
assert!(exit.is_clean());
assert_eq!(
n.status,
Status::Pending,
"the exit fact never transitions status"
);
}
#[test]
fn worker_exited_records_signal_and_is_first_write_wins() {
let tmp = TempDir::new().unwrap();
let run_id = "01jxsnap000000000000000000";
let paths = bootstrap_retry_node(&tmp, run_id);
let nid = NodeId::parse_str("n-0001").unwrap();
let mut ev = event(run_id);
ev.seq = 3;
ev.kind = "worker.exited".into();
ev.node_id = Some(nid.clone());
ev.data = serde_json::json!({ "signal": 9 });
apply_event(&paths, &ev).expect("worker.exited applies");
let n = read_node_opt(&paths, &nid).unwrap().unwrap();
let exit = n.worker_exit.expect("worker_exit recorded");
assert_eq!(exit.signal, Some(9));
assert!(exit.is_failure());
let mut dup = event(run_id);
dup.seq = 4;
dup.kind = "worker.exited".into();
dup.node_id = Some(nid.clone());
dup.data = serde_json::json!({ "exit_code": 0 });
apply_event(&paths, &dup).expect("duplicate worker.exited applies as no-op");
let n2 = read_node_opt(&paths, &nid).unwrap().unwrap();
assert_eq!(
n2.worker_exit.unwrap().signal,
Some(9),
"first-write-wins: the replayed exit must not overwrite the recorded fact"
);
}
#[test]
fn worker_exited_without_code_or_signal_is_corrupt() {
let tmp = TempDir::new().unwrap();
let run_id = "01jxsnap000000000000000000";
let paths = bootstrap_retry_node(&tmp, run_id);
let mut ev = event(run_id);
ev.seq = 3;
ev.kind = "worker.exited".into();
ev.node_id = Some(NodeId::parse_str("n-0001").unwrap());
ev.data = serde_json::json!({});
match reduce_event_to_ops(&paths, &ev) {
Err(Error::CorruptEventLog { .. }) => {}
Ok(_) => panic!("an empty worker.exited payload must be rejected, not applied"),
Err(other) => panic!("expected CorruptEventLog, got {other:?}"),
}
ev.data = serde_json::json!({ "exit_code": 0, "signal": 9 });
match reduce_event_to_ops(&paths, &ev) {
Err(Error::CorruptEventLog { .. }) => {}
Ok(_) => panic!("a worker.exited with both fields must be rejected"),
Err(other) => panic!("expected CorruptEventLog, got {other:?}"),
}
}
#[test]
fn node_death_observed_records_first_death_first_write_wins() {
let tmp = TempDir::new().unwrap();
let run_id = "01jxsnap000000000000000000";
let paths = bootstrap_retry_node(&tmp, run_id);
let nid = NodeId::parse_str("n-0001").unwrap();
let mut ev = event(run_id);
ev.seq = 3;
ev.kind = "node.death_observed".into();
ev.node_id = Some(nid.clone());
ev.data = serde_json::json!({});
apply_event(&paths, &ev).expect("node.death_observed applies");
let first = read_node_opt(&paths, &nid)
.unwrap()
.unwrap()
.first_death_at
.expect("first_death_at recorded");
assert_eq!(first, ev.ts, "the anchor is the event's own timestamp");
let mut later = event(run_id);
later.seq = 4;
later.kind = "node.death_observed".into();
later.node_id = Some(nid.clone());
later.ts = ev.ts + chrono::Duration::seconds(30);
later.data = serde_json::json!({});
apply_event(&paths, &later).expect("re-observation applies as no-op");
assert_eq!(
read_node_opt(&paths, &nid).unwrap().unwrap().first_death_at,
Some(first),
"first-write-wins: a re-observation must not reset the anchor"
);
}
#[test]
fn node_death_observed_noop_when_worker_exit_present() {
let tmp = TempDir::new().unwrap();
let run_id = "01jxsnap000000000000000000";
let paths = bootstrap_retry_node(&tmp, run_id);
let nid = NodeId::parse_str("n-0001").unwrap();
let mut exit = event(run_id);
exit.seq = 3;
exit.kind = "worker.exited".into();
exit.node_id = Some(nid.clone());
exit.data = serde_json::json!({ "exit_code": 0 });
apply_event(&paths, &exit).unwrap();
let mut death = event(run_id);
death.seq = 4;
death.kind = "node.death_observed".into();
death.node_id = Some(nid.clone());
death.data = serde_json::json!({});
apply_event(&paths, &death).expect("applies as no-op");
assert_eq!(
read_node_opt(&paths, &nid).unwrap().unwrap().first_death_at,
None,
"a told worker.exited makes the crash backstop moot; no anchor recorded"
);
}
#[test]
fn node_retry_clears_worker_exit() {
let tmp = TempDir::new().unwrap();
let run_id = "01jxsnap000000000000000000";
let paths = bootstrap_retry_node(&tmp, run_id);
let nid = NodeId::parse_str("n-0001").unwrap();
let mut exit = event(run_id);
exit.seq = 3;
exit.kind = "worker.exited".into();
exit.node_id = Some(nid.clone());
exit.data = serde_json::json!({ "exit_code": 7 });
apply_event(&paths, &exit).unwrap();
assert!(read_node_opt(&paths, &nid)
.unwrap()
.unwrap()
.worker_exit
.is_some());
let mut retry = event(run_id);
retry.seq = 4;
retry.kind = "node.retry".into();
retry.node_id = Some(nid.clone());
retry.data = serde_json::json!({
"attempt": 1,
"reason": "agent-died",
"branch": "wt/foo",
"worktree_path": "/tmp/new-wt",
"agent_pid": 222,
});
apply_event(&paths, &retry).unwrap();
let n = read_node_opt(&paths, &nid).unwrap().unwrap();
assert!(
n.worker_exit.is_none(),
"node.retry must clear the previous attempt's worker_exit"
);
assert_eq!(
n.status,
Status::Pending,
"retry returns the node to Pending"
);
}
}