Split heartbeat status projection

This commit is contained in:
Meta Vens 2026-05-18 00:34:07 +01:00
commit f3db1bf66b
5 changed files with 309 additions and 286 deletions

View file

@ -5,14 +5,19 @@ use chrono::Duration;
use cultcache_rs::CultCache;
use cultcache_rs::SingleFileMessagePackBackingStore;
use serde_json::Value;
use std::cmp::Reverse;
use std::collections::BTreeMap;
use std::collections::BTreeSet;
use std::fs;
use std::path::Path;
mod heartbeat_documents;
mod heartbeat_projection;
pub use heartbeat_documents::*;
pub use heartbeat_projection::heartbeat_status_projection;
use heartbeat_projection::history_event_json;
use heartbeat_projection::pending_turn_json;
use heartbeat_projection::schedule_participant_json;
use heartbeat_projection::selection_policy_json;
const HEARTBEAT_ARENA_MAINTENANCE: &str = "maintenance";
const HEARTBEAT_ARENA_SCENE: &str = "scene";
@ -295,71 +300,6 @@ pub fn initialize_ghostlight_scene_heartbeat_store(
write_heartbeat_state_entry(store_path, &state)
}
pub fn heartbeat_status_projection(
store_path: impl AsRef<Path>,
artifact_dir: impl AsRef<Path>,
target_heartbeat_rate: f64,
artifact_limit: usize,
) -> Result<Value> {
let store_path = store_path.as_ref();
let Some(state) = load_heartbeat_state_entry(store_path)? else {
return Ok(serde_json::json!({
"schema_version": HEARTBEAT_STATUS_SCHEMA_VERSION,
"ok": true,
"status": "missing",
"stateFile": null,
"storeFile": store_path,
"cultCacheStore": cultcache_status(store_path),
"artifactDir": artifact_dir.as_ref(),
"targetHeartbeatRate": if target_heartbeat_rate > 0.0 { Some(target_heartbeat_rate) } else { None },
"sceneClock": null,
"participants": [],
"latestEvent": null,
"history": [],
"latestArtifacts": latest_json_artifacts(artifact_dir, artifact_limit),
"availableActions": ["init", "tick", "pump", "complete", "status"],
}));
};
let cognition = load_heartbeat_cognition_entry(store_path)?;
let history: Vec<_> = state
.history
.iter()
.rev()
.take(artifact_limit)
.cloned()
.collect::<Vec<_>>()
.into_iter()
.rev()
.map(history_event_json)
.collect();
Ok(serde_json::json!({
"schema_version": HEARTBEAT_STATUS_SCHEMA_VERSION,
"ok": true,
"status": "ready",
"stateFile": null,
"storeFile": store_path,
"cultCacheStore": cultcache_status(store_path),
"artifactDir": artifact_dir.as_ref(),
"targetHeartbeatRate": state.target_heartbeat_rate,
"sceneClock": state.scene_clock,
"participants": state.participants.iter().map(participant_status_json).collect::<Vec<_>>(),
"latestEvent": history.last().cloned(),
"history": history,
"latestArtifacts": latest_json_artifacts(artifact_dir, artifact_limit),
"cognitionQuarantine": cognition.as_ref().map(heartbeat_cognition_status_json),
"sleepCycle": cognition.as_ref().and_then(|entry| entry.sleep_cycle.clone()),
"memoryResonance": cognition.as_ref().and_then(|entry| entry.memory_resonance.clone()),
"incubation": cognition.as_ref().and_then(|entry| entry.incubation.clone()),
"thoughtLanes": cognition.as_ref().and_then(|entry| entry.thought_lanes.clone()),
"bridge": cognition.as_ref().and_then(|entry| entry.bridge.clone()),
"candidateInterventions": cognition.as_ref().and_then(|entry| entry.candidate_interventions.clone()),
"appraisals": cognition.as_ref().and_then(|entry| entry.appraisals.clone()),
"reactions": cognition.as_ref().and_then(|entry| entry.reactions.clone()),
"adaptivePacing": state.extra.get("adaptivePacing"),
"availableActions": ["init", "tick", "pump", "complete", "status", "routine"],
}))
}
pub fn run_void_routine_store(
store_path: impl AsRef<Path>,
artifact_dir: impl AsRef<Path>,
@ -1225,7 +1165,7 @@ fn readiness_snapshot(
.collect()
}
fn participant_arena(participant: &HeartbeatParticipant) -> &str {
pub(super) fn participant_arena(participant: &HeartbeatParticipant) -> &str {
if participant.arena.trim().is_empty() {
HEARTBEAT_ARENA_MAINTENANCE
} else {
@ -1233,7 +1173,7 @@ fn participant_arena(participant: &HeartbeatParticipant) -> &str {
}
}
fn participant_kind(participant: &HeartbeatParticipant) -> &str {
pub(super) fn participant_kind(participant: &HeartbeatParticipant) -> &str {
if participant.participant_kind.trim().is_empty() {
PARTICIPANT_KIND_AGENT
} else {
@ -1267,215 +1207,6 @@ fn write_json_artifact(path: impl AsRef<Path>, value: &Value) -> Result<()> {
.with_context(|| format!("failed to write {}", path.display()))
}
fn latest_json_artifacts(artifact_dir: impl AsRef<Path>, limit: usize) -> Vec<Value> {
let artifact_dir = artifact_dir.as_ref();
let Ok(read_dir) = fs::read_dir(artifact_dir) else {
return Vec::new();
};
let mut paths: Vec<_> = read_dir
.filter_map(Result::ok)
.map(|entry| entry.path())
.filter(|path| {
path.extension()
.is_some_and(|extension| extension == "json")
})
.filter_map(|path| {
let modified = path.metadata().and_then(|meta| meta.modified()).ok()?;
Some((path, modified))
})
.collect();
paths.sort_by_key(|item| Reverse(item.1));
paths
.into_iter()
.take(limit)
.filter_map(|(path, modified)| {
let raw = fs::read_to_string(&path).ok()?;
let payload: Value = serde_json::from_str(&raw).ok()?;
let modified_at: chrono::DateTime<chrono::Utc> = modified.into();
Some(serde_json::json!({
"path": path,
"name": path.file_name().and_then(|name| name.to_str()),
"modifiedAt": modified_at.to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
"schemaVersion": payload.get("schema_version"),
"kind": path.file_stem().and_then(|stem| stem.to_str()).and_then(|stem| stem.rsplit('.').next()).unwrap_or("json"),
"summary": artifact_summary(&payload),
}))
})
.collect()
}
fn artifact_summary(payload: &Value) -> Value {
if payload.get("schema_version")
== Some(&Value::String(VOID_ROUTINE_SCHEMA_VERSION.to_string()))
{
return serde_json::json!({
"runId": payload.get("runId"),
"isNapping": payload.pointer("/sleepCycle/isNapping"),
"resonanceCount": payload.pointer("/memoryResonance/pairs").and_then(Value::as_array).map(Vec::len),
"incubationCount": payload.pointer("/incubation/themes").and_then(Value::as_array).map(Vec::len),
"appraisalCount": payload.pointer("/appraisals/participantAppraisals").and_then(Value::as_array).map(Vec::len),
"reactionCount": payload.pointer("/reactions/reactions").and_then(Value::as_array).map(Vec::len),
});
}
let event = if payload.get("actionId").is_some() {
Some(payload)
} else {
payload.get("event")
};
if let Some(event) = event {
return serde_json::json!({
"selectedRole": event.get("selectedRole"),
"actionType": event.get("actionType"),
"actionId": event.get("actionId"),
"coordinatorAction": event.get("coordinatorAction"),
});
}
if let Some(selection) = payload
.get("next_actor_selection")
.or_else(|| payload.get("nextActorSelection"))
{
return serde_json::json!({
"selectionKind": selection.get("selection_kind").or_else(|| selection.get("selectionKind")),
"selectedActorId": selection.get("selected_actor_id").or_else(|| selection.get("selectedActorId")),
});
}
let keys = payload
.as_object()
.map(|object| object.keys().take(8).cloned().collect::<Vec<_>>())
.unwrap_or_default();
serde_json::json!({ "keys": keys })
}
fn cultcache_status(store_path: &Path) -> Value {
let metadata = store_path.metadata().ok();
let modified_at = metadata
.as_ref()
.and_then(|meta| meta.modified().ok())
.map(|time| {
let time: chrono::DateTime<chrono::Utc> = time.into();
time.to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
});
serde_json::json!({
"storeFile": store_path,
"present": metadata.is_some(),
"sizeBytes": metadata.as_ref().map(|meta| meta.len()),
"modifiedAt": modified_at,
"entryType": HEARTBEAT_STATE_TYPE,
"entryKey": HEARTBEAT_STATE_KEY,
"cognitionEntryType": HEARTBEAT_COGNITION_TYPE,
"cognitionEntryKey": HEARTBEAT_COGNITION_KEY,
})
}
fn heartbeat_cognition_status_json(cognition: &EpiphanyHeartbeatCognitionEntry) -> Value {
serde_json::json!({
"schema_version": cognition.schema_version,
"updatedAt": cognition.updated_at,
"latestRunId": cognition.latest_run_id,
"latestArtifactRef": cognition.latest_artifact_ref,
"source": cognition.source,
"contract": "Experimental Void/Ghostlight thought-weather is quarantined outside stable heartbeat scheduling state. Status may project it for inspection, but scheduler policy owns only typed timing physiology.",
})
}
fn participant_status_json(participant: &HeartbeatParticipant) -> Value {
serde_json::json!({
"agentId": participant.agent_id,
"roleId": participant.role_id,
"displayName": participant.display_name,
"arena": participant_arena(participant),
"participantKind": participant_kind(participant),
"initiativeSpeed": participant.initiative_speed,
"personalityCooldownMultiplier": personality_cooldown_multiplier(participant),
"moodCooldownMultiplier": mood_cooldown_multiplier(participant),
"effectiveCooldownMultiplier": effective_cooldown_multiplier(participant),
"personalityTiming": participant.extra.get("personalityTiming"),
"moodTiming": participant.extra.get("moodTiming"),
"nextReadyAt": participant.next_ready_at,
"reactionBias": participant.reaction_bias,
"interruptThreshold": participant.interrupt_threshold,
"currentLoad": participant.current_load,
"status": participant.status,
"lastActionId": participant.last_action_id,
"lastWokeAt": participant.last_woke_at,
"lastFinishedAt": participant.last_finished_at,
"pendingTurn": participant.pending_turn.as_ref().map(pending_turn_json),
})
}
fn schedule_participant_json(participant: &HeartbeatParticipant) -> Value {
serde_json::json!({
"agent_id": participant.agent_id,
"role_id": participant.role_id,
"display_name": participant.display_name,
"arena": participant_arena(participant),
"participant_kind": participant_kind(participant),
"initiative_speed": participant.initiative_speed,
"personality_cooldown_multiplier": personality_cooldown_multiplier(participant),
"mood_cooldown_multiplier": mood_cooldown_multiplier(participant),
"effective_cooldown_multiplier": effective_cooldown_multiplier(participant),
"next_ready_at": participant.next_ready_at,
"reaction_bias": participant.reaction_bias,
"interrupt_threshold": participant.interrupt_threshold,
"current_load": participant.current_load,
"status": participant.status,
"pending_turn": participant.pending_turn.as_ref().map(pending_turn_json),
"constraints": participant.constraints,
})
}
fn selection_policy_json(policy: &HeartbeatSelectionPolicy) -> Value {
serde_json::json!({
"mode": policy.mode,
"reaction_precedence": policy.reaction_precedence,
"minimum_speed": policy.minimum_speed,
"tie_breakers": policy.tie_breakers,
})
}
fn pending_turn_json(turn: &HeartbeatPendingTurn) -> Value {
serde_json::json!({
"status": turn.status,
"scheduleId": turn.schedule_id,
"actionId": turn.action_id,
"actionType": turn.action_type,
"actionScale": turn.action_scale,
"localAffordanceBasis": turn.local_affordance_basis,
"startedAt": turn.started_at,
"startedSceneClock": turn.started_scene_clock,
"baseRecovery": turn.base_recovery,
"personalityCooldownMultiplier": turn.extra.get("personalityCooldownMultiplier"),
"moodCooldownMultiplier": turn.extra.get("moodCooldownMultiplier"),
"effectiveCooldownMultiplier": turn.extra.get("effectiveCooldownMultiplier"),
"recovery": turn.recovery,
"cooldownPolicy": turn.cooldown_policy,
"completedAt": turn.completed_at,
"completedSceneClock": turn.completed_scene_clock,
"nextReadyAt": turn.next_ready_at,
})
}
fn history_event_json(event: HeartbeatHistoryEvent) -> Value {
serde_json::json!({
"ts": event.ts,
"scheduleId": event.schedule_id,
"selectedRole": event.selected_role,
"selectedAgentId": event.selected_agent_id,
"actionId": event.action_id,
"actionType": event.action_type,
"arena": event.arena,
"participantKind": event.participant_kind,
"actionScale": event.action_scale,
"coordinatorAction": event.coordinator_action,
"targetRole": event.target_role,
"workRole": event.work_role,
"sceneClock": event.scene_clock,
"nextReadyAt": event.next_ready_at,
"turnStatus": event.turn_status,
"cooldownStartedAfterCompletion": event.cooldown_started_after_completion,
})
}
fn rumination_patch(role_id: &str, action_id: &str) -> Value {
let display_name = display_name_for_role(role_id);
serde_json::json!({
@ -2064,7 +1795,7 @@ fn number_at(value: &Value, pointer: &str) -> f64 {
.unwrap_or(0.0)
}
fn personality_cooldown_multiplier(participant: &HeartbeatParticipant) -> f64 {
pub(super) fn personality_cooldown_multiplier(participant: &HeartbeatParticipant) -> f64 {
participant
.extra
.get("personalityCooldownMultiplier")
@ -2073,7 +1804,7 @@ fn personality_cooldown_multiplier(participant: &HeartbeatParticipant) -> f64 {
.clamp(0.25, 3.0)
}
fn mood_cooldown_multiplier(participant: &HeartbeatParticipant) -> f64 {
pub(super) fn mood_cooldown_multiplier(participant: &HeartbeatParticipant) -> f64 {
participant
.extra
.get("moodCooldownMultiplier")
@ -2082,7 +1813,7 @@ fn mood_cooldown_multiplier(participant: &HeartbeatParticipant) -> f64 {
.clamp(0.25, 3.0)
}
fn effective_cooldown_multiplier(participant: &HeartbeatParticipant) -> f64 {
pub(super) fn effective_cooldown_multiplier(participant: &HeartbeatParticipant) -> f64 {
round3(personality_cooldown_multiplier(participant) * mood_cooldown_multiplier(participant))
.clamp(0.20, 4.0)
}

View file

@ -0,0 +1,292 @@
use super::HEARTBEAT_COGNITION_KEY;
use super::HEARTBEAT_COGNITION_TYPE;
use super::HEARTBEAT_STATE_KEY;
use super::HEARTBEAT_STATE_TYPE;
use super::HEARTBEAT_STATUS_SCHEMA_VERSION;
use super::HeartbeatHistoryEvent;
use super::HeartbeatParticipant;
use super::HeartbeatPendingTurn;
use super::HeartbeatSelectionPolicy;
use super::VOID_ROUTINE_SCHEMA_VERSION;
use super::effective_cooldown_multiplier;
use super::load_heartbeat_cognition_entry;
use super::load_heartbeat_state_entry;
use super::mood_cooldown_multiplier;
use super::participant_arena;
use super::participant_kind;
use super::personality_cooldown_multiplier;
use anyhow::Result;
use serde_json::Value;
use std::cmp::Reverse;
use std::fs;
use std::path::Path;
pub fn heartbeat_status_projection(
store_path: impl AsRef<Path>,
artifact_dir: impl AsRef<Path>,
target_heartbeat_rate: f64,
artifact_limit: usize,
) -> Result<Value> {
let store_path = store_path.as_ref();
let Some(state) = load_heartbeat_state_entry(store_path)? else {
return Ok(serde_json::json!({
"schema_version": HEARTBEAT_STATUS_SCHEMA_VERSION,
"ok": true,
"status": "missing",
"stateFile": null,
"storeFile": store_path,
"cultCacheStore": cultcache_status(store_path),
"artifactDir": artifact_dir.as_ref(),
"targetHeartbeatRate": if target_heartbeat_rate > 0.0 { Some(target_heartbeat_rate) } else { None },
"sceneClock": null,
"participants": [],
"latestEvent": null,
"history": [],
"latestArtifacts": latest_json_artifacts(artifact_dir, artifact_limit),
"availableActions": ["init", "tick", "pump", "complete", "status"],
}));
};
let cognition = load_heartbeat_cognition_entry(store_path)?;
let history: Vec<_> = state
.history
.iter()
.rev()
.take(artifact_limit)
.cloned()
.collect::<Vec<_>>()
.into_iter()
.rev()
.map(history_event_json)
.collect();
Ok(serde_json::json!({
"schema_version": HEARTBEAT_STATUS_SCHEMA_VERSION,
"ok": true,
"status": "ready",
"stateFile": null,
"storeFile": store_path,
"cultCacheStore": cultcache_status(store_path),
"artifactDir": artifact_dir.as_ref(),
"targetHeartbeatRate": state.target_heartbeat_rate,
"sceneClock": state.scene_clock,
"participants": state.participants.iter().map(participant_status_json).collect::<Vec<_>>(),
"latestEvent": history.last().cloned(),
"history": history,
"latestArtifacts": latest_json_artifacts(artifact_dir, artifact_limit),
"cognitionQuarantine": cognition.as_ref().map(heartbeat_cognition_status_json),
"sleepCycle": cognition.as_ref().and_then(|entry| entry.sleep_cycle.clone()),
"memoryResonance": cognition.as_ref().and_then(|entry| entry.memory_resonance.clone()),
"incubation": cognition.as_ref().and_then(|entry| entry.incubation.clone()),
"thoughtLanes": cognition.as_ref().and_then(|entry| entry.thought_lanes.clone()),
"bridge": cognition.as_ref().and_then(|entry| entry.bridge.clone()),
"candidateInterventions": cognition.as_ref().and_then(|entry| entry.candidate_interventions.clone()),
"appraisals": cognition.as_ref().and_then(|entry| entry.appraisals.clone()),
"reactions": cognition.as_ref().and_then(|entry| entry.reactions.clone()),
"adaptivePacing": state.extra.get("adaptivePacing"),
"availableActions": ["init", "tick", "pump", "complete", "status", "routine"],
}))
}
fn latest_json_artifacts(artifact_dir: impl AsRef<Path>, limit: usize) -> Vec<Value> {
let artifact_dir = artifact_dir.as_ref();
let Ok(read_dir) = fs::read_dir(artifact_dir) else {
return Vec::new();
};
let mut paths: Vec<_> = read_dir
.filter_map(Result::ok)
.map(|entry| entry.path())
.filter(|path| path.extension().is_some_and(|extension| extension == "json"))
.filter_map(|path| {
let modified = path.metadata().and_then(|meta| meta.modified()).ok()?;
Some((path, modified))
})
.collect();
paths.sort_by_key(|item| Reverse(item.1));
paths
.into_iter()
.take(limit)
.filter_map(|(path, modified)| {
let raw = fs::read_to_string(&path).ok()?;
let payload: Value = serde_json::from_str(&raw).ok()?;
let modified_at: chrono::DateTime<chrono::Utc> = modified.into();
Some(serde_json::json!({
"path": path,
"name": path.file_name().and_then(|name| name.to_str()),
"modifiedAt": modified_at.to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
"schemaVersion": payload.get("schema_version"),
"kind": path.file_stem().and_then(|stem| stem.to_str()).and_then(|stem| stem.rsplit('.').next()).unwrap_or("json"),
"summary": artifact_summary(&payload),
}))
})
.collect()
}
fn artifact_summary(payload: &Value) -> Value {
if payload.get("schema_version") == Some(&Value::String(VOID_ROUTINE_SCHEMA_VERSION.to_string()))
{
return serde_json::json!({
"runId": payload.get("runId"),
"isNapping": payload.pointer("/sleepCycle/isNapping"),
"resonanceCount": payload.pointer("/memoryResonance/pairs").and_then(Value::as_array).map(Vec::len),
"incubationCount": payload.pointer("/incubation/themes").and_then(Value::as_array).map(Vec::len),
"appraisalCount": payload.pointer("/appraisals/participantAppraisals").and_then(Value::as_array).map(Vec::len),
"reactionCount": payload.pointer("/reactions/reactions").and_then(Value::as_array).map(Vec::len),
});
}
let event = if payload.get("actionId").is_some() {
Some(payload)
} else {
payload.get("event")
};
if let Some(event) = event {
return serde_json::json!({
"selectedRole": event.get("selectedRole"),
"actionType": event.get("actionType"),
"actionId": event.get("actionId"),
"coordinatorAction": event.get("coordinatorAction"),
});
}
if let Some(selection) = payload
.get("next_actor_selection")
.or_else(|| payload.get("nextActorSelection"))
{
return serde_json::json!({
"selectionKind": selection.get("selection_kind").or_else(|| selection.get("selectionKind")),
"selectedActorId": selection.get("selected_actor_id").or_else(|| selection.get("selectedActorId")),
});
}
let keys = payload
.as_object()
.map(|object| object.keys().take(8).cloned().collect::<Vec<_>>())
.unwrap_or_default();
serde_json::json!({ "keys": keys })
}
fn cultcache_status(store_path: &Path) -> Value {
let metadata = store_path.metadata().ok();
let modified_at = metadata
.as_ref()
.and_then(|meta| meta.modified().ok())
.map(|time| {
let time: chrono::DateTime<chrono::Utc> = time.into();
time.to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
});
serde_json::json!({
"storeFile": store_path,
"present": metadata.is_some(),
"sizeBytes": metadata.as_ref().map(|meta| meta.len()),
"modifiedAt": modified_at,
"entryType": HEARTBEAT_STATE_TYPE,
"entryKey": HEARTBEAT_STATE_KEY,
"cognitionEntryType": HEARTBEAT_COGNITION_TYPE,
"cognitionEntryKey": HEARTBEAT_COGNITION_KEY,
})
}
fn heartbeat_cognition_status_json(cognition: &super::EpiphanyHeartbeatCognitionEntry) -> Value {
serde_json::json!({
"schema_version": cognition.schema_version,
"updatedAt": cognition.updated_at,
"latestRunId": cognition.latest_run_id,
"latestArtifactRef": cognition.latest_artifact_ref,
"source": cognition.source,
"contract": "Experimental Void/Ghostlight thought-weather is quarantined outside stable heartbeat scheduling state. Status may project it for inspection, but scheduler policy owns only typed timing physiology.",
})
}
fn participant_status_json(participant: &HeartbeatParticipant) -> Value {
serde_json::json!({
"agentId": participant.agent_id,
"roleId": participant.role_id,
"displayName": participant.display_name,
"arena": participant_arena(participant),
"participantKind": participant_kind(participant),
"initiativeSpeed": participant.initiative_speed,
"personalityCooldownMultiplier": personality_cooldown_multiplier(participant),
"moodCooldownMultiplier": mood_cooldown_multiplier(participant),
"effectiveCooldownMultiplier": effective_cooldown_multiplier(participant),
"personalityTiming": participant.extra.get("personalityTiming"),
"moodTiming": participant.extra.get("moodTiming"),
"nextReadyAt": participant.next_ready_at,
"reactionBias": participant.reaction_bias,
"interruptThreshold": participant.interrupt_threshold,
"currentLoad": participant.current_load,
"status": participant.status,
"lastActionId": participant.last_action_id,
"lastWokeAt": participant.last_woke_at,
"lastFinishedAt": participant.last_finished_at,
"pendingTurn": participant.pending_turn.as_ref().map(pending_turn_json),
})
}
pub(super) fn schedule_participant_json(participant: &HeartbeatParticipant) -> Value {
serde_json::json!({
"agent_id": participant.agent_id,
"role_id": participant.role_id,
"display_name": participant.display_name,
"arena": participant_arena(participant),
"participant_kind": participant_kind(participant),
"initiative_speed": participant.initiative_speed,
"personality_cooldown_multiplier": personality_cooldown_multiplier(participant),
"mood_cooldown_multiplier": mood_cooldown_multiplier(participant),
"effective_cooldown_multiplier": effective_cooldown_multiplier(participant),
"next_ready_at": participant.next_ready_at,
"reaction_bias": participant.reaction_bias,
"interrupt_threshold": participant.interrupt_threshold,
"current_load": participant.current_load,
"status": participant.status,
"pending_turn": participant.pending_turn.as_ref().map(pending_turn_json),
"constraints": participant.constraints,
})
}
pub(super) fn selection_policy_json(policy: &HeartbeatSelectionPolicy) -> Value {
serde_json::json!({
"mode": policy.mode,
"reaction_precedence": policy.reaction_precedence,
"minimum_speed": policy.minimum_speed,
"tie_breakers": policy.tie_breakers,
})
}
pub(super) fn pending_turn_json(turn: &HeartbeatPendingTurn) -> Value {
serde_json::json!({
"status": turn.status,
"scheduleId": turn.schedule_id,
"actionId": turn.action_id,
"actionType": turn.action_type,
"actionScale": turn.action_scale,
"localAffordanceBasis": turn.local_affordance_basis,
"startedAt": turn.started_at,
"startedSceneClock": turn.started_scene_clock,
"baseRecovery": turn.base_recovery,
"personalityCooldownMultiplier": turn.extra.get("personalityCooldownMultiplier"),
"moodCooldownMultiplier": turn.extra.get("moodCooldownMultiplier"),
"effectiveCooldownMultiplier": turn.extra.get("effectiveCooldownMultiplier"),
"recovery": turn.recovery,
"cooldownPolicy": turn.cooldown_policy,
"completedAt": turn.completed_at,
"completedSceneClock": turn.completed_scene_clock,
"nextReadyAt": turn.next_ready_at,
})
}
pub(super) fn history_event_json(event: HeartbeatHistoryEvent) -> Value {
serde_json::json!({
"ts": event.ts,
"scheduleId": event.schedule_id,
"selectedRole": event.selected_role,
"selectedAgentId": event.selected_agent_id,
"actionId": event.action_id,
"actionType": event.action_type,
"arena": event.arena,
"participantKind": event.participant_kind,
"actionScale": event.action_scale,
"coordinatorAction": event.coordinator_action,
"targetRole": event.target_role,
"workRole": event.work_role,
"sceneClock": event.scene_clock,
"nextReadyAt": event.next_ready_at,
"turnStatus": event.turn_status,
"cooldownStartedAfterCompletion": event.cooldown_started_after_completion,
})
}

View file

@ -58,7 +58,7 @@ remembers doctrine; the branch remembers the blade.
- Thread start route handling and its async lifecycle task now live in `codex_message_processor/thread_start_routes.rs`; root no longer owns `thread_start_task`.
- Thread config helpers now live in `codex_message_processor/thread_config.rs`: instruction-source loading, thread config override construction, config-load error shaping, and dynamic-tool validation. The pure dynamic-tool validation contract has module-local unit tests.
- Turn control routes now live in `codex_message_processor/turn_routes.rs`: turn start, thread item injection, app-server client info setting, turn steer, turn interrupt, mode normalization, and input-limit validation with module-local unit tests.
- Heartbeat durable document/schema types now live in `epiphany-core/src/heartbeat_state/heartbeat_documents.rs`; the root `heartbeat_state.rs` keeps store access, scheduling, status rendering, and Void cognition orchestration for the next cuts. Focused heartbeat tests and `epiphany-core` check pass after the split.
- Heartbeat durable document/schema types now live in `epiphany-core/src/heartbeat_state/heartbeat_documents.rs`, and heartbeat status/artifact JSON projection lives in `epiphany-core/src/heartbeat_state/heartbeat_projection.rs`. The root `heartbeat_state.rs` keeps store access, scheduling, and Void cognition orchestration for the next cuts. Focused heartbeat tests and `epiphany-core` check pass after the splits.
- Thread projection now lives in `codex_message_processor/thread_projection.rs`: state-db/thread-store/rollout summary projection, `ConversationSummary` to API `Thread` mapping, thread-title attachment helpers, turn pagination, and rollout turn reconstruction. `codex_message_processor.rs` is about 1,009 lines after this extraction and mostly owns JSON-RPC dispatch, processor construction, shared thread lookup/submit/error helpers, and lifecycle shutdown.
- Turn paging/reconstruction now lives in `codex_message_processor/thread_turn_projection.rs`; `thread_projection.rs` keeps state-db/thread-store/rollout summary projection and `ConversationSummary` to API `Thread` mapping. Focused `thread_turns_list` cursor tests and stale in-progress turn interruption coverage pass.
- The `thread/turns/list` route handler now lives in `codex_message_processor/thread_turn_routes.rs`, beside the turn projection helpers it consumes. `thread_read_routes.rs` is back to persisted/live thread listing, `thread/read`, loaded-thread listing, and summary reads.

Binary file not shown.

File diff suppressed because one or more lines are too long