use super::*;
pub(crate) const PERSIST_HEARTBEAT_SECS: i64 = 30;
pub(crate) const BROADCAST_LOG_LINE_MAX_BYTES: usize = 8 * 1024;
fn truncate_log_line(line: &str) -> String {
if line.len() <= BROADCAST_LOG_LINE_MAX_BYTES {
return line.to_string();
}
let cut = leviath_core::text::floor_char_boundary(line, BROADCAST_LOG_LINE_MAX_BYTES);
format!(
"{} [truncated {} bytes]",
line.split_at(cut).0,
line.len() - cut
)
}
#[derive(Component, Default)]
pub struct PersistWatermark {
last: Option<(usize, usize, leviath_core::run_meta::RunStatus)>,
last_written_at: Option<i64>,
last_progress_at: Option<i64>,
last_taint: Option<(usize, usize)>,
}
impl PersistWatermark {
pub fn last_progress_at(&self) -> Option<i64> {
self.last_progress_at
}
pub(crate) fn persisted_status(&self) -> Option<leviath_core::run_meta::RunStatus> {
self.last.as_ref().map(|(_, _, status)| status.clone())
}
#[cfg(test)]
pub(crate) fn backdate(&mut self, at: i64) {
self.last_written_at = Some(at);
self.last_progress_at = Some(at);
}
#[cfg(test)]
pub(crate) fn stamp_status(&mut self, status: leviath_core::run_meta::RunStatus) {
self.last = Some((0, 0, status));
}
}
#[derive(Resource)]
pub struct PersistenceStage(pub UnboundedSender<PersistMsg>);
type ReflectInteractionStatusQuery = (
Entity,
&'static mut AgentState,
Option<&'static AwaitingInteraction>,
);
pub fn reflect_interaction_status(
hub: Option<Res<InteractionHub>>,
mut agents: Query<
ReflectInteractionStatusQuery,
(Without<FanOutWaiting>, Without<WaitingForChildren>),
>,
mut commands: Commands,
) {
crate::tick_scope::clear();
let Some(hub) = hub else { return };
let pending: std::collections::HashSet<String> =
hub.pending().into_iter().map(|(id, _)| id).collect();
for (entity, mut state, marked) in agents.iter_mut() {
crate::tick_scope::enter(entity);
match (pending.contains(&state.agent_id), marked.is_some()) {
(true, false) => {
if state.status == AgentStatus::Active {
state.status = AgentStatus::Waiting;
commands.entity(entity).insert(AwaitingInteraction);
}
}
(false, true) => {
commands.entity(entity).remove::<AwaitingInteraction>();
if state.status == AgentStatus::Waiting {
state.status = AgentStatus::Active;
}
}
_ => {}
}
}
}
pub(crate) fn reconcile_stage_ledger(
ledger: &mut StageLedger,
cursor_index: usize,
status: &AgentStatus,
now: i64,
) {
use leviath_core::run_meta::StageRunStatus;
let active = crate::persistence::stage_status_from(status);
let run_is_over = matches!(
status,
AgentStatus::Complete | AgentStatus::Error { .. } | AgentStatus::Cancelled
);
for rec in ledger.0.iter_mut() {
if rec.index == cursor_index {
rec.entered = true;
if rec.started_at.is_none() {
rec.started_at = Some(now);
}
if active == StageRunStatus::Complete && rec.ended_at.is_none() {
rec.ended_at = Some(now);
}
rec.status = active.clone();
continue;
}
rec.entered |= rec.prompt_tokens > 0 || rec.completion_tokens > 0;
if !rec.entered {
rec.status = match run_is_over {
true => StageRunStatus::Skipped,
false => StageRunStatus::Pending,
};
continue;
}
rec.status = StageRunStatus::Complete;
if rec.ended_at.is_none() {
rec.ended_at = Some(now);
}
}
}
type PersistenceQuery = (
Entity,
&'static RunMetadata,
&'static AgentState,
&'static ContextWindow,
&'static StageCursor,
&'static TokenTotals,
&'static mut PersistWatermark,
Option<&'static mut StageLedger>,
Option<&'static mut StageIoBuffer>,
Option<&'static crate::taint::TaintGate>,
Option<&'static crate::components::ParentRef>,
Option<&'static crate::components::SubAgentChildren>,
Option<&'static crate::fanout::FanOutWaiting>,
(
Option<&'static crate::interaction_points::AwaitingInteractionPoint>,
Option<&'static crate::interaction_points::InteractionPointCursor>,
Option<&'static crate::interaction_points::InteractionPointRounds>,
Option<&'static crate::persistence::RunOutcomeFlags>,
Option<&'static crate::persistence::FinalOutput>,
),
);
pub fn dispatch_persistence(
mut agents: Query<PersistenceQuery>,
stage: Res<PersistenceStage>,
hub: Option<Res<InteractionHub>>,
sink: Option<Res<crate::host::WorldEventSink>>,
) {
crate::tick_scope::clear();
for (
entity,
md,
state,
window,
cursor,
totals,
mut watermark,
mut ledger,
buffer,
taint_gate,
parent_ref,
children,
fan_out_waiting,
(awaiting_point, ip_cursor, ip_rounds, outcome_flags, final_output),
) in agents.iter_mut()
{
crate::tick_scope::enter(entity);
let now = chrono::Utc::now().timestamp();
if let Some(ledger) = ledger.as_deref_mut() {
reconcile_stage_ledger(ledger, cursor.index, &state.status, now);
}
let (output_appends, log_appends) = match buffer {
Some(mut buf) => (
std::mem::take(&mut buf.output),
std::mem::take(&mut buf.logs),
),
None => (Vec::new(), Vec::new()),
};
let has_appends = !output_appends.is_empty() || !log_appends.is_empty();
let status = crate::persistence::run_status_from(&state.status);
let current = (state.iteration, cursor.index, status);
let watermark_changed = watermark.last.as_ref() != Some(¤t);
let due_for_heartbeat = watermark
.last_written_at
.is_none_or(|at| now.saturating_sub(at) >= PERSIST_HEARTBEAT_SECS);
if !watermark_changed && !has_appends && !due_for_heartbeat {
continue; }
if let Some(sink) = &sink {
for (_idx, line) in output_appends.iter().chain(log_appends.iter()) {
let _ = sink.0.send(crate::host::WorldEvent::Log {
run_id: md.run_id.clone(),
agent_id: state.agent_id.clone(),
line: truncate_log_line(line),
});
}
}
if !watermark_changed && !due_for_heartbeat {
let _ = stage.0.send(PersistMsg::StageLines {
run_id: md.run_id.clone(),
output_appends,
log_appends,
});
continue;
}
if watermark_changed {
watermark.last = Some(current);
watermark.last_progress_at = Some(now);
}
watermark.last_written_at = Some(now);
let depth = parent_ref.map(|p| p.depth).unwrap_or(0);
let max_child_depth = children.map(|c| c.max_child_depth).unwrap_or(0);
let flags = outcome_flags.cloned().unwrap_or_default();
let meta = build_run_meta(
crate::persistence::RunMetaSources {
md,
state,
totals,
flags: &flags,
final_output,
},
crate::persistence::RunPosition {
stage_index: cursor.index,
now_secs: now,
last_progress_at: watermark.last_progress_at(),
depth,
max_child_depth,
},
);
let context = build_context_snapshot(window, &state.current_stage);
let stages = ledger.as_deref().map(|l| l.0.clone()).unwrap_or_default();
let taint_audit = taint_gate
.filter(|g| !g.audit_log().is_empty())
.and_then(|g| {
let key = (cursor.index, g.audit_log().len());
if watermark.last_taint == Some(key) {
return None;
}
watermark.last_taint = Some(key);
Some((
cursor.index,
serde_json::to_string(g.audit_log())
.expect("GateEvent slice always serializes"),
))
});
let fanout = fan_out_waiting
.map(|w| serde_json::to_string(&w.to_state()).expect("FanOutState always serializes"));
let interactions = awaiting_point.and_then(|_| {
let request = hub
.as_ref()?
.pending()
.into_iter()
.find(|(aid, req)| aid == &state.agent_id && req.id.contains("-point-"))?;
let ip_state = crate::interaction_points::InteractionPointState {
cursor: ip_cursor.map_or(0, |c| c.0),
round: ip_rounds.map_or(0, |r| r.0),
body: request.1.body.unwrap_or_default(),
};
Some(serde_json::to_string(&ip_state).expect("InteractionPointState always serializes"))
});
let final_output_body = final_output.map(|o| o.0.content.clone());
let _ = stage.0.send(PersistMsg::Snapshot(Box::new(PersistJob {
run_id: md.run_id.clone(),
meta,
context,
stages,
output_appends,
log_appends,
taint_audit,
final_output: final_output_body,
fanout,
interactions,
})));
}
}