diff --git a/epiphany-core/src/heartbeat_state.rs b/epiphany-core/src/heartbeat_state.rs index b0387d67..8b04ca3f 100644 --- a/epiphany-core/src/heartbeat_state.rs +++ b/epiphany-core/src/heartbeat_state.rs @@ -9,6 +9,7 @@ use std::fs; use std::path::Path; mod heartbeat_documents; +mod heartbeat_pacing; mod heartbeat_projection; mod heartbeat_roles; mod heartbeat_store; @@ -29,6 +30,11 @@ use heartbeat_projection::history_event_json; use heartbeat_projection::pending_turn_json; use heartbeat_projection::schedule_participant_json; use heartbeat_projection::selection_policy_json; +use heartbeat_pacing::adaptive_swarm_pacing; +use heartbeat_pacing::effective_cooldown_multiplier; +use heartbeat_pacing::mood_cooldown_multiplier; +use heartbeat_pacing::personality_cooldown_multiplier; +use heartbeat_pacing::running_turn_count; use heartbeat_roles::ROLE_ORDER; use heartbeat_roles::agent_id_for_role; use heartbeat_roles::default_participant; @@ -1339,165 +1345,6 @@ fn apply_mood_timing_from_appraisals(state: &mut EpiphanyHeartbeatStateEntry, ap } } -#[derive(Clone, Debug)] -struct AdaptiveSwarmPacing { - pressure: f64, - effective_heartbeat_rate: f64, - target_concurrency: usize, - running_turns: usize, - active_participants: usize, - signals: Value, -} - -fn adaptive_swarm_pacing( - state: &EpiphanyHeartbeatStateEntry, - options: &HeartbeatPumpOptions, -) -> AdaptiveSwarmPacing { - let active_participants = state - .participants - .iter() - .filter(|participant| participant.status == "active") - .count(); - let running_turns = running_turn_count(state); - let mood_signals = swarm_mood_signals(state); - let external_urgency = options.external_urgency.clamp(0.0, 1.0); - let pending_pressure = if active_participants == 0 { - 0.0 - } else { - (running_turns as f64 / active_participants as f64).clamp(0.0, 1.0) - }; - let pressure = [ - external_urgency, - mood_signals.max_anxiety, - mood_signals.max_urgency, - mood_signals.max_reaction_intensity, - mood_signals.max_thought_pressure, - pending_pressure * 0.65, - ] - .into_iter() - .fold(0.0_f64, f64::max) - .clamp(0.0, 1.0); - - let base_rate = options - .base_heartbeat_rate - .max(state.pacing_policy.minimum_effective_rate) - .max(0.001); - let min_rate = options.min_heartbeat_rate.max(0.001).min(base_rate); - let max_rate = options.max_heartbeat_rate.max(base_rate).max(min_rate); - let pressure_curve = pressure * pressure; - let effective_heartbeat_rate = round6(min_rate + (max_rate - min_rate) * pressure_curve); - - let max_concurrency = options - .max_concurrency - .max(1) - .min(active_participants.max(1)); - let relaxed_floor = if pressure < 0.18 { - 0 - } else { - options.min_concurrency.min(max_concurrency) - }; - let target_concurrency = if pressure < 0.18 { - relaxed_floor - } else { - let span = max_concurrency.saturating_sub(relaxed_floor); - (relaxed_floor + (span as f64 * pressure).ceil() as usize).min(max_concurrency) - }; - - AdaptiveSwarmPacing { - pressure: round3(pressure), - effective_heartbeat_rate, - target_concurrency, - running_turns, - active_participants, - signals: serde_json::json!({ - "externalUrgency": round3(external_urgency), - "maxAnxiety": round3(mood_signals.max_anxiety), - "averageAnxiety": round3(mood_signals.average_anxiety), - "maxUrgency": round3(mood_signals.max_urgency), - "maxArousal": round3(mood_signals.max_arousal), - "maxThoughtPressure": round3(mood_signals.max_thought_pressure), - "maxReactionIntensity": round3(mood_signals.max_reaction_intensity), - "pendingPressure": round3(pending_pressure), - "contract": "Anxiety-like state raises tempo and concurrency; calm state lets the swarm sleep slow.", - }), - } -} - -#[derive(Clone, Debug, Default)] -struct SwarmMoodSignals { - max_anxiety: f64, - average_anxiety: f64, - max_urgency: f64, - max_arousal: f64, - max_thought_pressure: f64, - max_reaction_intensity: f64, -} - -fn swarm_mood_signals(state: &EpiphanyHeartbeatStateEntry) -> SwarmMoodSignals { - let mut signals = SwarmMoodSignals::default(); - let mut anxiety_total = 0.0; - let mut anxiety_count = 0_usize; - for participant in &state.participants { - let Some(mood) = participant.extra.get("moodTiming") else { - continue; - }; - let anxiety = number_at(mood, "/anxiety"); - let urgency = number_at(mood, "/urgency"); - let arousal = number_at(mood, "/arousal"); - let thought_pressure = number_at(mood, "/thoughtPressure"); - let reaction_intensity = number_at(mood, "/reactionIntensity"); - signals.max_anxiety = signals.max_anxiety.max(anxiety); - signals.max_urgency = signals.max_urgency.max(urgency); - signals.max_arousal = signals.max_arousal.max(arousal); - signals.max_thought_pressure = signals.max_thought_pressure.max(thought_pressure); - signals.max_reaction_intensity = signals.max_reaction_intensity.max(reaction_intensity); - anxiety_total += anxiety; - anxiety_count += 1; - } - if anxiety_count > 0 { - signals.average_anxiety = anxiety_total / anxiety_count as f64; - } - signals -} - -fn running_turn_count(state: &EpiphanyHeartbeatStateEntry) -> usize { - state - .participants - .iter() - .filter(|participant| is_turn_pending(participant)) - .count() -} - -fn number_at(value: &Value, pointer: &str) -> f64 { - value - .pointer(pointer) - .and_then(Value::as_f64) - .unwrap_or(0.0) -} - -pub(super) fn personality_cooldown_multiplier(participant: &HeartbeatParticipant) -> f64 { - participant - .extra - .get("personalityCooldownMultiplier") - .and_then(Value::as_f64) - .unwrap_or(1.0) - .clamp(0.25, 3.0) -} - -pub(super) fn mood_cooldown_multiplier(participant: &HeartbeatParticipant) -> f64 { - participant - .extra - .get("moodCooldownMultiplier") - .and_then(Value::as_f64) - .unwrap_or(1.0) - .clamp(0.25, 3.0) -} - -pub(super) fn effective_cooldown_multiplier(participant: &HeartbeatParticipant) -> f64 { - round3(personality_cooldown_multiplier(participant) * mood_cooldown_multiplier(participant)) - .clamp(0.20, 4.0) -} - fn build_memory_resonance(records: &[RoleMemoryRecord]) -> Value { let mut pairs = Vec::new(); for (left_index, left) in records.iter().enumerate() { @@ -3220,11 +3067,11 @@ fn now_stamp() -> String { chrono::Utc::now().format("%Y%m%dT%H%M%SZ").to_string() } -fn round6(value: f64) -> f64 { +pub(super) fn round6(value: f64) -> f64 { (value * 1_000_000.0).round() / 1_000_000.0 } -fn round3(value: f64) -> f64 { +pub(super) fn round3(value: f64) -> f64 { (value * 1_000.0).round() / 1_000.0 } diff --git a/epiphany-core/src/heartbeat_state/heartbeat_pacing.rs b/epiphany-core/src/heartbeat_state/heartbeat_pacing.rs new file mode 100644 index 00000000..15f76a80 --- /dev/null +++ b/epiphany-core/src/heartbeat_state/heartbeat_pacing.rs @@ -0,0 +1,193 @@ +use super::EpiphanyHeartbeatStateEntry; +use super::HeartbeatParticipant; +use super::HeartbeatPumpOptions; +use super::round3; +use super::round6; +use serde_json::Value; + +#[derive(Clone, Debug)] +pub(super) struct AdaptiveSwarmPacing { + pub(super) pressure: f64, + pub(super) effective_heartbeat_rate: f64, + pub(super) target_concurrency: usize, + pub(super) running_turns: usize, + pub(super) active_participants: usize, + pub(super) signals: Value, +} + +pub(super) fn adaptive_swarm_pacing( + state: &EpiphanyHeartbeatStateEntry, + options: &HeartbeatPumpOptions, +) -> AdaptiveSwarmPacing { + let active_participants = state + .participants + .iter() + .filter(|participant| participant.status == "active") + .count(); + let running_turns = running_turn_count(state); + let mood_signals = swarm_mood_signals(state); + let external_urgency = options.external_urgency.clamp(0.0, 1.0); + let pending_pressure = if active_participants == 0 { + 0.0 + } else { + (running_turns as f64 / active_participants as f64).clamp(0.0, 1.0) + }; + let pressure = [ + external_urgency, + mood_signals.max_anxiety, + mood_signals.max_urgency, + mood_signals.max_reaction_intensity, + mood_signals.max_thought_pressure, + pending_pressure * 0.65, + ] + .into_iter() + .fold(0.0_f64, f64::max) + .clamp(0.0, 1.0); + + let base_rate = options + .base_heartbeat_rate + .max(state.pacing_policy.minimum_effective_rate) + .max(0.001); + let min_rate = options.min_heartbeat_rate.max(0.001).min(base_rate); + let max_rate = options.max_heartbeat_rate.max(base_rate).max(min_rate); + let pressure_curve = pressure * pressure; + let effective_heartbeat_rate = round6(min_rate + (max_rate - min_rate) * pressure_curve); + + let max_concurrency = options + .max_concurrency + .max(1) + .min(active_participants.max(1)); + let relaxed_floor = if pressure < 0.18 { + 0 + } else { + options.min_concurrency.min(max_concurrency) + }; + let target_concurrency = if pressure < 0.18 { + relaxed_floor + } else { + let span = max_concurrency.saturating_sub(relaxed_floor); + (relaxed_floor + (span as f64 * pressure).ceil() as usize).min(max_concurrency) + }; + AdaptiveSwarmPacing { + pressure: round3(pressure), + effective_heartbeat_rate, + target_concurrency, + running_turns, + active_participants, + signals: serde_json::json!({ + "externalUrgency": round3(external_urgency), + "maxAnxiety": round3(mood_signals.max_anxiety), + "averageAnxiety": round3(mood_signals.average_anxiety), + "maxUrgency": round3(mood_signals.max_urgency), + "maxArousal": round3(mood_signals.max_arousal), + "maxThoughtPressure": round3(mood_signals.max_thought_pressure), + "maxReactionIntensity": round3(mood_signals.max_reaction_intensity), + "pendingPressure": round3(pending_pressure), + "contract": "Anxiety-like state raises tempo and concurrency; calm state lets the swarm sleep slow.", + }), + } +} + +#[derive(Default)] +struct SwarmMoodSignals { + max_anxiety: f64, + average_anxiety: f64, + max_urgency: f64, + max_arousal: f64, + max_thought_pressure: f64, + max_reaction_intensity: f64, +} + +fn swarm_mood_signals(state: &EpiphanyHeartbeatStateEntry) -> SwarmMoodSignals { + let mut signals = SwarmMoodSignals::default(); + let mut anxiety_total = 0.0; + let mut anxiety_count = 0_usize; + for participant in &state.participants { + let Some(mood) = participant.extra.get("moodTiming") else { + continue; + }; + let anxiety = number_at(mood, "/anxiety"); + let urgency = number_at(mood, "/urgency"); + let arousal = number_at(mood, "/arousal"); + let thought_pressure = number_at(mood, "/thoughtPressure"); + let reaction_intensity = number_at(mood, "/reactionIntensity"); + signals.max_anxiety = signals.max_anxiety.max(anxiety); + signals.max_urgency = signals.max_urgency.max(urgency); + signals.max_arousal = signals.max_arousal.max(arousal); + signals.max_thought_pressure = signals.max_thought_pressure.max(thought_pressure); + signals.max_reaction_intensity = signals.max_reaction_intensity.max(reaction_intensity); + anxiety_total += anxiety; + anxiety_count += 1; + } + if anxiety_count > 0 { + signals.average_anxiety = anxiety_total / anxiety_count as f64; + } + signals +} + +pub(super) fn running_turn_count(state: &EpiphanyHeartbeatStateEntry) -> usize { + state + .participants + .iter() + .filter(|participant| { + participant + .pending_turn + .as_ref() + .is_some_and(|turn| turn.status == "running") + }) + .count() +} + +fn number_at(value: &Value, pointer: &str) -> f64 { + value + .pointer(pointer) + .and_then(Value::as_f64) + .unwrap_or(0.0) +} + +pub(super) fn personality_cooldown_multiplier(participant: &HeartbeatParticipant) -> f64 { + participant + .extra + .get("personalityCooldownMultiplier") + .and_then(Value::as_f64) + .unwrap_or(1.0) + .clamp(0.25, 3.0) +} + +pub(super) fn mood_cooldown_multiplier(participant: &HeartbeatParticipant) -> f64 { + participant + .extra + .get("moodCooldownMultiplier") + .and_then(Value::as_f64) + .unwrap_or(1.0) + .clamp(0.25, 3.0) +} + +pub(super) fn effective_cooldown_multiplier(participant: &HeartbeatParticipant) -> f64 { + round3(personality_cooldown_multiplier(participant) * mood_cooldown_multiplier(participant)) + .clamp(0.20, 4.0) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::heartbeat_state::default_heartbeat_state; + use pretty_assertions::assert_eq; + + #[test] + fn running_turn_count_reads_only_running_pending_turns() { + let mut state = default_heartbeat_state(1.0); + state.participants[0].pending_turn = + Some(crate::heartbeat_state::HeartbeatPendingTurn { + status: "running".to_string(), + ..Default::default() + }); + state.participants[1].pending_turn = + Some(crate::heartbeat_state::HeartbeatPendingTurn { + status: "completed".to_string(), + ..Default::default() + }); + + assert_eq!(running_turn_count(&state), 1); + } +} diff --git a/notes/fresh-workspace-handoff.md b/notes/fresh-workspace-handoff.md index a08ff8d5..3e16f104 100644 --- a/notes/fresh-workspace-handoff.md +++ b/notes/fresh-workspace-handoff.md @@ -60,6 +60,7 @@ remembers doctrine; the branch remembers the blade. - 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`, role/default construction lives in `epiphany-core/src/heartbeat_state/heartbeat_roles.rs`, persistence/validation lives in `epiphany-core/src/heartbeat_state/heartbeat_store.rs`, and status/artifact JSON projection lives in `epiphany-core/src/heartbeat_state/heartbeat_projection.rs`. The root `heartbeat_state.rs` keeps scheduling and Void cognition orchestration for the next cuts. Focused heartbeat tests and `epiphany-core` check pass after the splits. - Heartbeat role/default and store seams now have module-local unit tests: fixed lane catalog, Ghostlight scene role projection, and typed state/cognition CultCache round-trip. Keep future heartbeat cuts aligned to those seam tests instead of growing root integration-only coverage. +- Heartbeat adaptive pacing now lives in `epiphany-core/src/heartbeat_state/heartbeat_pacing.rs`, including running-turn count and effective cooldown multipliers. It has a module-local unit test for pending-turn counting; root scheduling calls the pacing seam instead of owning pressure/concurrency math directly. - 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 5c4d45fb..a2aad770 100644 Binary files a/state/ledgers.msgpack and b/state/ledgers.msgpack differ diff --git a/state/map.yaml b/state/map.yaml index ba20748f..1f789071 100644 --- a/state/map.yaml +++ b/state/map.yaml @@ -100,6 +100,7 @@ accepted_design: - 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`; role/default construction lives in `epiphany-core/src/heartbeat_state/heartbeat_roles.rs`; heartbeat persistence/validation lives in `epiphany-core/src/heartbeat_state/heartbeat_store.rs`; heartbeat status/artifact JSON projection lives in `epiphany-core/src/heartbeat_state/heartbeat_projection.rs`; the root heartbeat module remains the scheduling/cognition orchestration surface until those responsibilities are split into their own tested seams. - Heartbeat role/default and store seams are pinned by module-local unit tests for fixed lane catalog, Ghostlight scene role projection, and typed state/cognition CultCache round-trip. + - Heartbeat adaptive pacing lives in `epiphany-core/src/heartbeat_state/heartbeat_pacing.rs`; it owns pressure/concurrency derivation, running-turn count, and effective cooldown multipliers for scheduler/projection callers. - `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. @@ -288,8 +289,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 role/default and store seams gained module-local unit tests for - fixed lane catalog, Ghostlight scene role projection, and typed - state/cognition CultCache round-trip. Focused heartbeat tests and - `epiphany-core` check pass. + Heartbeat adaptive pacing was split into + `heartbeat_state/heartbeat_pacing.rs`, with module-local coverage for + running pending-turn counts. 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.