diff --git a/epiphany-core/src/heartbeat_state.rs b/epiphany-core/src/heartbeat_state.rs index 166fe9cf..8abb0302 100644 --- a/epiphany-core/src/heartbeat_state.rs +++ b/epiphany-core/src/heartbeat_state.rs @@ -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, - artifact_dir: impl AsRef, - target_heartbeat_rate: f64, - artifact_limit: usize, -) -> Result { - 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::>() - .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::>(), - "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, artifact_dir: impl AsRef, @@ -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, value: &Value) -> Result<()> { .with_context(|| format!("failed to write {}", path.display())) } -fn latest_json_artifacts(artifact_dir: impl AsRef, limit: usize) -> Vec { - 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 = 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::>()) - .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 = 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) } diff --git a/epiphany-core/src/heartbeat_state/heartbeat_projection.rs b/epiphany-core/src/heartbeat_state/heartbeat_projection.rs new file mode 100644 index 00000000..f4454ec5 --- /dev/null +++ b/epiphany-core/src/heartbeat_state/heartbeat_projection.rs @@ -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, + artifact_dir: impl AsRef, + target_heartbeat_rate: f64, + artifact_limit: usize, +) -> Result { + 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::>() + .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::>(), + "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, limit: usize) -> Vec { + 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 = 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::>()) + .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 = 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, + }) +} diff --git a/notes/fresh-workspace-handoff.md b/notes/fresh-workspace-handoff.md index a6060b2f..3c51d02c 100644 --- a/notes/fresh-workspace-handoff.md +++ b/notes/fresh-workspace-handoff.md @@ -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. diff --git a/state/ledgers.msgpack b/state/ledgers.msgpack index 0d55c1f6..2078fa1f 100644 Binary files a/state/ledgers.msgpack and b/state/ledgers.msgpack differ diff --git a/state/map.yaml b/state/map.yaml index 9999560d..4286a22c 100644 --- a/state/map.yaml +++ b/state/map.yaml @@ -98,7 +98,7 @@ accepted_design: - `notes/codex-auth-spine-inventory.md` is the current source-grounded inventory of the Codex organ that may survive. Corrected compliance invariant: Codex-compatible auth identity should remain anchored in retained vendored Codex-derived auth machinery, while Epiphany owns typed request/status/event/runtime surfaces around it. Codex app-server, apps, skills, marketplace, plugin UX, MCP OAuth handlers, and collaboration-mode surfaces are not part of the keeper spine. - `epiphany-openai-runtime` worker result ingress now parses assistant output directly into launch-kind-specific typed documents and stores typed nested `statePatch` / `selfPatch` payloads as MessagePack inside `EpiphanyRuntimeRoleWorkerResult`; generic `serde_json::Value` no longer mediates role/reorient worker results in that crate. JSON still exists at the OpenAI text edge because the model's output is text, but the runtime boundary after parsing is typed. - Agent self-memory review/application now has typed `AgentSelfPatch` core APIs. The old JSON-value selfPatch functions remain only as edge compatibility wrappers for CLI/artifact ingress; `epiphany-agent-memory-store`, heartbeat rumination application, and repo initialization selfPatch processing parse once at the edge and then operate on typed documents. Role-result self-persistence no longer exports a JSON-value reviewer; native review uses `review_role_self_patch_document`. - - Heartbeat durable document/schema types live in `epiphany-core/src/heartbeat_state/heartbeat_documents.rs`; the root heartbeat module remains the store/scheduling/status/cognition orchestration surface until those responsibilities are split into their own tested seams. + - Heartbeat durable document/schema types live in `epiphany-core/src/heartbeat_state/heartbeat_documents.rs`; heartbeat status/artifact JSON projection lives in `epiphany-core/src/heartbeat_state/heartbeat_projection.rs`; the root heartbeat module remains the store/scheduling/cognition orchestration surface until those responsibilities are split into their own tested seams. - `epiphany-openai-auth-spine` is now a thin Epiphany-named boundary over retained vendored `codex-login`, not a clean-room credential store or token-refresh clone. Keep this impurity sealed as the Codex-compatible subscription auth organ while Epiphany owns typed request/status/event/runtime documents around it. - `notes/epiphany-architectural-teardown.md` is the current foundation-cleanup directive. Its suspicion pass supersedes outward Phase 6 expansion as the active priority until the control-plane ownership problems are resolved. - `notes/epiphany-ideal-architecture-rebuild-plan.md` is the replacement blueprint: the smallest coherent architecture is durable `EpiphanyState`, runtime-spine-owned `RuntimeState`, separate role memory/heartbeat state, `epiphany-core` as policy owner, app-server as adapter, Aquarium as reflector, typed result acceptance receipts, and derived view lenses instead of a protocol verb zoo. @@ -287,8 +287,8 @@ rejected_paths: current_status: summary: The Codex starvation pass has five typing receipts, fifty-six processor/foundation cuts, a real physical extraction in `epiphany-codex-bridge`, a root `epiphany-state-model` crate for durable Epiphany state, a source-grounded Codex auth spine inventory, the native `epiphany-openai-adapter` document boundary, a buildable Codex auth/model wrapper in `epiphany-openai-codex-spine`, the first native runtime caller in `epiphany-openai-runtime`, native heartbeat launch planning/job opening in `epiphany-core`, native state-update contract/validation/application in `epiphany-core`, native exact retrieval path matching in `epiphany-core`, and the route-facing mutation/view/coordinator projection services in `epiphany-codex-bridge`. `codex-protocol` no longer defines the durable `Epiphany*` state structs; it re-exports the native model for legacy rollout/API compatibility while `epiphany-core` imports the native model directly. `epiphany-core` now has no direct vendored Codex dependency: pressure derivation accepts native `EpiphanyTokenUsageSnapshot`, Codex rollout replay moved to `codex-core::epiphany_rollout`, and exact retrieval search uses Epiphany-owned workspace walking/path scoring instead of `codex-file-search`. The bridge owns Codex-facing Epiphany adapters for scene, jobs, context/planning/graph query, retrieval/indexing projection, result projection, runtime-spine result adaptation, launch/prompt doctrine, mutation document mechanics, mutation service update/promote/role-accept/reorient-accept/role-launch/reorient-launch/job-launch/job-interrupt application, live-state projection, pressure/pre-compaction rendering, freshness/reorientation projection, CRRC/coordinator/role-board projection, watcher invalidation adaptation, full `thread/epiphany/view` response construction, freshness/context/graph-query response construction, role/reorient result response construction, distill/propose patch-response construction, coordinator status/view construction, and acceptance/evidence signal helpers. Vendored app-server no longer retains root Epiphany modules; `codex_message_processor.rs` still contains route dispatch, JSON-RPC response shaping, watcher registration/snapshotting calls, and notification emission, but its root no longer imports the evacuated Epiphany policy/protocol registry or owns the watcher invalidation manager. Codex product surfaces for apps, skills, plugins, marketplace, and external agent config import are cut from the live app-server/protocol path: list routes return empty lists only where compatibility shells still exist, mutation/detail routes report disabled where they survive, `externalAgentConfig/*` routes and import-completed notification are gone, helper/import modules and stale implementation-shape tests were deleted, runtime feature enablement no longer supports `apps` or `plugins`, config writes no longer emit plugin-toggle analytics, plugin/skill cache invalidation on config writes is gone, plugin startup warmups and app-list refreshes were removed, and app-server no longer directly depends on `codex-plugin`, `codex-chatgpt`, or `codex-core-plugins`. `Config::to_mcp_config` now keeps user-declared MCP servers but no longer accepts `PluginsManager`, folds plugin MCP servers into the runtime, enables Codex apps, enables skill MCP dependency install, or advertises plugin capability summaries; `TurnContext::apps_enabled()` now returns false so model turns cannot awaken Codex Apps through ChatGPT auth or feature flags; session startup and per-turn context no longer load skills, turns receive an empty `SkillLoadOutcome`, internal `Op::ListSkills` is gone from `codex-protocol` / `codex-core` / TUI command routing, the Codex skill watcher has been collapsed to a no-op marker with no file watcher, skill-root registration, cache-clear task, or `SkillsUpdateAvailable` session listener, model turns no longer run skill mention collection, skill env-var prompts, skill MCP dependency installation, skill instruction injection, or implicit skill invocation analytics from shell tools, and the turn prompt/router path no longer loads plugin/app capability summaries, injects available-skill/available-plugin/plugin instruction fragments, parses plugin/app mentions, records app/plugin invocation telemetry, or filters MCP tools through Codex app connectors. Skill mutation/list/notification is gone from the app-server edge too: `skills/list`, `skills/config/write`, `skills/changed`, `Op::ListSkills`, `EventMsg::ListSkillsResponse`, `EventMsg::SkillsUpdateAvailable`, the app-server handlers, TUI slash/startup/list/manage-skill surfaces, generated schema entries, core skill-list suite, and fake live-reload/developer-message tests were deleted. Skill mention plumbing is also gone from the live TUI/protocol edge: no `UserInput::Skill` variant, app-server schema branch, TUI skill popup, `skills_all` cache, composer `set_skill_mentions`, skill mention insertion, `skill://` / `SKILL.md` history decoding, or stale skill popup snapshot remains. The `codex-core-skills` crate, core `skills` re-export shim, no-op `skills_watcher`, session `SkillsManager` service, and per-turn `TurnSkillsContext` are deleted; core/protocol no longer keep legacy `` or `` prompt compatibility, memory usage classification, snapshot normalization, or cleanup paths. Remaining product-shaped skill residue is also cut from memory templates, app-server docs/tests/warnings, TUI tooltips/placeholders/warnings/comments, app-server v1 docs, agent-tool schema text, stale `UserInput::Skill` test arms, and obsolete feature flags/schema entries. App-instruction prompt compatibility is cut too: no `APPS_INSTRUCTIONS` protocol constants, no `` snapshot canonicalization, and MCP fixture tests now use neutral `demo_mcp` / `calendar_mcp` names instead of `codex_apps` / `/api/codex/apps` branding. The TUI app connector product path is also deleted: no `/apps` slash command, app-link view, connector AppEvent variants, app connector cache/popup/refresh loop, app mention insertion, `app://` mention codec support, stale app-popup tests, or TUI browser-opening shim remains. The `app/list` route and `app/list/updated` notification are gone from app-server protocol, generated schemas, app-server dispatch, test helpers, and docs; `AppInfo`/app-list metadata schema structs are deleted; the orphaned `codex-connectors` crate and `core::connectors` shim are deleted from the workspace; Codex app analytics no longer tracks `codex_app_mentioned` / `codex_app_used` facts, events, reducer arms, client APIs, or dedupe cache state; `[apps.*]`, `Feature::Apps`, `include_apps_instructions`, app config/read schemas, app managed requirements, app prompt tests, and app file-MCP tests are gone; `Feature::Plugins`, `Feature::RemotePlugin`, `plugin://` structured mentions, plugin instruction tags, and stale plugin popup snapshots are gone too; `[plugins]` / `[marketplaces]`, `PluginConfig`, `MarketplaceConfig`, marketplace edit helpers, stale plugin/external-agent README route claims, and generated schema entries are removed from config; and the bundled `codex-skills` sample-skill crate plus embedded imagegen/openai-docs/plugin-creator/skill-creator/skill-installer payloads are deleted from the workspace. The vendored `vendor/codex/sdk` subtree is deleted as non-spine app-server client/release scaffolding, including stale generated Python SDK route types for removed product surfaces; the root pnpm workspace no longer lists `sdk/typescript`. Live MCP tool approval is named `McpToolApproval`, not `AppToolApproval`, and remains under MCP server config; live structured user-input mentions are MCP-shaped only. `codex_message_processor.rs` is about 1,009 lines after route/lifecycle/projection/resume-helper/config-helper extraction; root `message_processor.rs` is about 1,292 lines after the external-agent/config-product cuts; `epiphany_read_routes.rs` is about 579 lines after the full view and read-response extractions; `epiphany_mutation_routes.rs` is about 694 lines after route-load/notification helper cuts and product-surface starvation; the old vendored `epiphany_state_helpers.rs` rollout-state loader is deleted and its remaining function lives in `epiphany-codex-bridge::state`; and `codex_thread.rs` is about 1,425 lines after state-update law moved native. `epiphany-openai-adapter` defines CultCache `DatabaseEntry` request/event/receipt/auth-status documents with no Codex app-server or generic JSON dependency. `epiphany-openai-codex-spine` owns the native OpenAI model transport path: it exposes `default_codex_home` and `auth_manager`, projects `epiphany-openai-auth-spine` `AuthManager` / `CodexAuth` into typed `EpiphanyOpenAiAdapterStatus`, builds a local serializable OpenAI Responses request body instead of Codex protocol request/item/config types, opens HTTP Responses streams through `codex-client`, parses Responses SSE frames into typed stream events and receipts, and no longer directly or transitively depends on Codex's Responses client, model-provider stack, `codex-protocol`, `codex-api`, `codex-model-provider`, or `codex-model-provider-info`; it intentionally reaches retained vendored `codex-login` only through `epiphany-openai-auth-spine`. Auth identity, credential storage, and ChatGPT token refresh remain owned by the Codex-compatible auth organ; external bearer command auth and agent identity remain retained Codex auth capabilities to expose only if a typed Epiphany surface needs them. `epiphany-openai-runtime` no longer depends directly on `codex-login`; it writes OpenAI status/request/stream-event/receipt records plus runtime sessions/jobs/results/events into `epiphany-core::runtime_spine_cache`, can emit the assistant text artifact for legacy result consumers, is now the default executor for startup-only repo birth specialists, and now has `run-worker` to load a durable `EpiphanyRuntimeWorkerLaunchRequest`, call the typed OpenAI runtime through the spine, persist typed `EpiphanyRuntimeRoleWorkerResult` / `EpiphanyRuntimeReorientWorkerResult` documents, and complete the original heartbeat/specialist runtime job without Codex worker execution; `codex exec` survives only as explicit fallback in repo-birth startup. `epiphany-mvp-coordinator` now invokes `epiphany-openai-runtime run-worker` for launched runtime job ids and no longer creates a second coordinator-owned shadow runtime job/result as a compensator. `epiphany-core::plan_runtime_spine_heartbeat_launch` validates launch contracts/conflicts and projects durable job bindings/runtime links; `epiphany-core::open_runtime_spine_heartbeat_job` owns the initialize-runtime/ensure-session/create-job sequence and now persists `EpiphanyRuntimeWorkerLaunchRequest` CultCache records keyed by runtime job id, with indexed kind/contract fields plus MessagePack-encoded typed launch documents instead of discarding work intent after queue receipt creation; `roleResult` and `reorientResult` now require typed worker result documents; generic job summaries are lifecycle receipts, not reviewable findings; `epiphany-core::EpiphanyStateUpdate`, `epiphany_state_update_validation_errors`, and `apply_epiphany_state_update` own state-update document shape and mutation law while `codex-core` calls them for compatibility. Vendored `CodexThread::epiphany_launch_job` is now mostly revision check, persistence validation, launch request handoff, and rollout/session writeback for the compatibility route. Vendored `codex-core` no longer has `epiphany_distillation`, `epiphany_promotion`, `epiphany_proposal`, or `epiphany_retrieval` husk modules; it re-exports native surfaces from `epiphany-core` directly, and now owns only the real Codex rollout adapter because it is a host seam. `epiphany-runtime-spine` advertises OpenAI adapter status/model-request/stream-event/receipt CultNet schemas and mutation contracts, so the native caller now has a real storage route instead of only JSON-RPC. MCP itself is correctly JSON at its protocol edge; the target is typed Epiphany intent/result/receipt wrappers around MCP, not replacing MCP JSON. The current OpenAI request path no longer pulls `codex-api`; `codex-login` remains deliberately sealed behind `epiphany-openai-auth-spine`, and the next spine decision is another Epiphany-in-vendor evacuation surface unless a concrete typed auth-status/runtime need requires exposing external bearer or agent-identity auth. latest_cut: > - Heartbeat durable document/schema types were split into - `heartbeat_state/heartbeat_documents.rs`, leaving root `heartbeat_state.rs` - as the remaining store/scheduling/status/cognition orchestration organ. - Focused heartbeat tests and `epiphany-core` check pass. + Heartbeat status/artifact JSON projection was split into + `heartbeat_state/heartbeat_projection.rs`, leaving root `heartbeat_state.rs` + responsible for store access, scheduler physiology, and Void cognition + orchestration. Focused heartbeat tests and `epiphany-core` check pass. next_action: Continue the Codex starvation campaign at the next Epiphany-in-vendor evacuation surface; keep vendored Codex code as host routing/lifecycle glue only, preserve retained `codex-login` auth ownership, and prefer cuts that expose testable ownership boundaries over line-count theater.