diff --git a/epiphany-codex-bridge/src/mutation_service.rs b/epiphany-codex-bridge/src/mutation_service.rs index 16c85aeb..77bb2b83 100644 --- a/epiphany-codex-bridge/src/mutation_service.rs +++ b/epiphany-codex-bridge/src/mutation_service.rs @@ -26,7 +26,6 @@ use epiphany_core::EpiphanyStateUpdatedField; use epiphany_core::EpiphanyTokenUsageSnapshot; use epiphany_core::MIND_GATEWAY_REVIEW_TYPE; use epiphany_core::RuntimeSpineHeartbeatJobOptions; -use epiphany_core::RuntimeSpineHeartbeatLaunchPlanOptions; use epiphany_core::SOUL_VERDICT_RECEIPT_TYPE; use epiphany_core::SUBSTRATE_GATE_REPO_ACCESS_GRANT_RECEIPT_TYPE; use epiphany_core::apply_coordinator_state_update_to_state; @@ -35,22 +34,17 @@ use epiphany_core::build_epiphany_role_launch_request_with_dynamic_context; use epiphany_core::clear_epiphany_job_binding_backend; use epiphany_core::continuity_recovery_receipt_from_reorient_finding; use epiphany_core::epiphany_role_label; -use epiphany_core::epiphany_state_update_validation_errors; use epiphany_core::evaluate_promotion; use epiphany_core::eyes_evidence_packet_from_research_finding; use epiphany_core::mind_state_commit_receipt; use epiphany_core::open_runtime_spine_heartbeat_job; -use epiphany_core::plan_runtime_spine_heartbeat_launch; use epiphany_core::put_substrate_gate_repo_access_grant_receipt; -use epiphany_core::replace_or_append_epiphany_job_binding; -use epiphany_core::runtime_job_snapshot; use epiphany_core::runtime_substrate_gate_repo_access_grant_receipt; use epiphany_core::soul_verdict_receipt_from_verification_finding; use epiphany_core::substrate_gate_repo_access_grant_for_launch; use epiphany_state_model::EpiphanyEvidenceRecord; use epiphany_state_model::EpiphanyJobKind as CoreEpiphanyJobKind; use epiphany_state_model::EpiphanyRetrievalState; -use epiphany_state_model::EpiphanyRuntimeLink; use epiphany_state_model::EpiphanyThreadState; use crate::cultnet::EpiphanySurfaceSource; @@ -210,125 +204,38 @@ pub async fn launch_epiphany_job_on_thread( // runtime-spine opens the typed job. The bridge stitches those typed // documents into Codex's JSON-RPC thread shell until CultNet owns the route. let current_state = thread.epiphany_state().await.unwrap_or_default(); - validate_expected_revision(request.expected_revision, current_state.revision)?; let runtime_store = thread.epiphany_runtime_spine_store_path().await; - let completed_prior_link = terminal_runtime_link_for_binding( + let launch_plan = epiphany_core::plan_coordinator_job_launch( ¤t_state, - request.binding_id.as_str(), + &request, runtime_store.as_path(), - )?; - let mut planning_state = current_state.clone(); - if let Some(link) = completed_prior_link.clone() { - planning_state.runtime_links.insert(0, link); - } - - let launcher_job_id = format!("epiphany-heartbeat-launch-{}", Uuid::new_v4()); - let backend_job_id = Uuid::new_v4().to_string(); - let launch_plan = plan_runtime_spine_heartbeat_launch( - &planning_state, - RuntimeSpineHeartbeatLaunchPlanOptions { - binding_id: request.binding_id.clone(), - kind: request.kind, - scope: request.scope.clone(), - owner_role: request.owner_role.clone(), - authority_scope: request.authority_scope.clone(), - linked_subgoal_ids: request.linked_subgoal_ids.clone(), - linked_graph_node_ids: request.linked_graph_node_ids.clone(), - instruction: request.instruction.clone(), - launch_document: request.launch_document.clone(), - output_contract_id: request.output_contract_id.clone(), - organ_launch_contract: request.organ_launch_contract.clone(), - max_runtime_seconds: request.max_runtime_seconds, - runtime_job_id: backend_job_id.clone(), - }, + format!("epiphany-heartbeat-launch-{}", Uuid::new_v4()), + Uuid::new_v4().to_string(), ) .map_err(|err| EpiphanyBridgeError::InvalidRequest(err.to_string()))?; - let next_job_bindings = replace_or_append_epiphany_job_binding( - current_state.job_bindings.clone(), - launch_plan.binding, - ); - - let mut runtime_links = Vec::new(); - runtime_links.push(launch_plan.runtime_link.clone()); - if let Some(link) = completed_prior_link { - runtime_links.push(link); - } - - let validation_errors = epiphany_state_update_validation_errors( - ¤t_state, - &EpiphanyStateUpdate { - job_bindings: Some(next_job_bindings.clone()), - runtime_links: runtime_links.clone(), - ..Default::default() - }, - ); - if !validation_errors.is_empty() { - return Err(EpiphanyBridgeError::InvalidRequest(format!( - "invalid Epiphany job launch patch: {}", - validation_errors.join("; ") - ))); - } open_epiphany_runtime_spine_job( runtime_store.as_path(), - &planning_state, + &launch_plan.planning_state, &request, - backend_job_id.as_str(), + launch_plan.backend_job_id.as_str(), )?; maybe_put_substrate_gate_launch_grant( runtime_store.as_path(), &request, - backend_job_id.as_str(), + launch_plan.backend_job_id.as_str(), )?; - let epiphany_state = apply_epiphany_state_update_to_thread( - thread, - EpiphanyStateUpdate { - expected_revision: request.expected_revision, - job_bindings: Some(next_job_bindings), - runtime_links, - ..Default::default() - }, - ) - .await?; + let epiphany_state = + apply_epiphany_state_update_to_thread(thread, launch_plan.state_update).await?; Ok(EpiphanyJobLaunchResult { epiphany_state, binding_id: request.binding_id, - launcher_job_id, - backend_job_id, + launcher_job_id: launch_plan.launcher_job_id, + backend_job_id: launch_plan.backend_job_id, }) } -fn terminal_runtime_link_for_binding( - state: &EpiphanyThreadState, - binding_id: &str, - runtime_store: &Path, -) -> BridgeResult> { - let Some(link) = state - .runtime_links - .iter() - .find(|link| link.binding_id == binding_id && !link.runtime_job_id.trim().is_empty()) - else { - return Ok(None); - }; - if link.runtime_result_id.is_some() { - return Ok(None); - } - let Some(snapshot) = runtime_job_snapshot(runtime_store, link.runtime_job_id.as_str()) - .map_err(|err| EpiphanyBridgeError::Fatal(err.to_string()))? - else { - return Ok(None); - }; - let Some(result) = snapshot.result else { - return Ok(None); - }; - let mut terminal = link.clone(); - terminal.id = format!("{}-{}", link.id, result.result_id); - terminal.surface = "runtimeResult".to_string(); - terminal.runtime_result_id = Some(result.result_id); - Ok(Some(terminal)) -} - pub async fn interrupt_epiphany_job_on_thread( thread: &impl EpiphanyMutationHost, request: EpiphanyJobInterruptRequest, @@ -1002,6 +909,30 @@ fn map_coordinator_admission_error( #[cfg(test)] mod acceptance_architecture_tests { + #[test] + fn launch_route_delegates_planning_to_the_native_organ() { + let source = include_str!("mutation_service.rs"); + let launch_start = source + .find("pub async fn launch_epiphany_job_on_thread") + .unwrap(); + let interrupt_start = source + .find("pub async fn interrupt_epiphany_job_on_thread") + .unwrap(); + let launch = &source[launch_start..interrupt_start]; + assert_eq!(launch.matches("plan_coordinator_job_launch").count(), 1); + for forbidden in [ + ["plan", "runtime", "spine", "heartbeat", "launch"].join("_"), + ["replace", "or", "append", "epiphany", "job", "binding"].join("_"), + ["runtime", "job", "snapshot"].join("_"), + ["epiphany", "state", "update", "validation", "errors"].join("_"), + ] { + assert!( + !launch.contains(&forbidden), + "launch route regrew native planning policy {forbidden:?}" + ); + } + } + #[test] fn acceptance_routes_have_no_individual_or_post_state_receipt_writes() { let source = include_str!("mutation_service.rs"); diff --git a/epiphany-core/src/coordinator_launch.rs b/epiphany-core/src/coordinator_launch.rs new file mode 100644 index 00000000..dce98cda --- /dev/null +++ b/epiphany-core/src/coordinator_launch.rs @@ -0,0 +1,140 @@ +use crate::*; +use anyhow::{Result, anyhow}; +use epiphany_state_model::{EpiphanyRuntimeLink, EpiphanyThreadState}; +use std::path::Path; + +#[derive(Debug, Clone)] +pub struct EpiphanyCoordinatorJobLaunchPlan { + pub launcher_job_id: String, + pub backend_job_id: String, + pub planning_state: EpiphanyThreadState, + pub heartbeat_plan: RuntimeSpineHeartbeatLaunchPlan, + pub state_update: EpiphanyStateUpdate, +} + +pub fn plan_coordinator_job_launch( + state: &EpiphanyThreadState, + request: &EpiphanyJobLaunchRequest, + runtime_store: &Path, + launcher_job_id: String, + backend_job_id: String, +) -> Result { + if let Some(expected) = request.expected_revision + && expected != state.revision + { + return Err(anyhow!( + "epiphany state revision mismatch: expected {expected}, found {}", + state.revision + )); + } + let completed_prior_link = + terminal_runtime_link_for_binding(state, &request.binding_id, runtime_store)?; + let mut planning_state = state.clone(); + if let Some(link) = completed_prior_link.clone() { + planning_state.runtime_links.insert(0, link); + } + let heartbeat_plan = plan_runtime_spine_heartbeat_launch( + &planning_state, + RuntimeSpineHeartbeatLaunchPlanOptions { + binding_id: request.binding_id.clone(), + kind: request.kind, + scope: request.scope.clone(), + owner_role: request.owner_role.clone(), + authority_scope: request.authority_scope.clone(), + linked_subgoal_ids: request.linked_subgoal_ids.clone(), + linked_graph_node_ids: request.linked_graph_node_ids.clone(), + instruction: request.instruction.clone(), + launch_document: request.launch_document.clone(), + output_contract_id: request.output_contract_id.clone(), + organ_launch_contract: request.organ_launch_contract.clone(), + max_runtime_seconds: request.max_runtime_seconds, + runtime_job_id: backend_job_id.clone(), + }, + )?; + let job_bindings = replace_or_append_epiphany_job_binding( + state.job_bindings.clone(), + heartbeat_plan.binding.clone(), + ); + let mut runtime_links = vec![heartbeat_plan.runtime_link.clone()]; + if let Some(link) = completed_prior_link { + runtime_links.push(link); + } + let state_update = EpiphanyStateUpdate { + expected_revision: request.expected_revision, + job_bindings: Some(job_bindings), + runtime_links, + ..Default::default() + }; + let errors = epiphany_state_update_validation_errors(state, &state_update); + if !errors.is_empty() { + return Err(anyhow!( + "invalid Epiphany job launch patch: {}", + errors.join("; ") + )); + } + Ok(EpiphanyCoordinatorJobLaunchPlan { + launcher_job_id, + backend_job_id, + planning_state, + heartbeat_plan, + state_update, + }) +} + +fn terminal_runtime_link_for_binding( + state: &EpiphanyThreadState, + binding_id: &str, + runtime_store: &Path, +) -> Result> { + let Some(link) = state + .runtime_links + .iter() + .find(|link| link.binding_id == binding_id && !link.runtime_job_id.trim().is_empty()) + else { + return Ok(None); + }; + if link.runtime_result_id.is_some() { + return Ok(None); + } + let Some(snapshot) = runtime_job_snapshot(runtime_store, &link.runtime_job_id)? else { + return Ok(None); + }; + let Some(result) = snapshot.result else { + return Ok(None); + }; + let mut terminal = link.clone(); + terminal.id = format!("{}-{}", link.id, result.result_id); + terminal.surface = "runtimeResult".to_string(); + terminal.runtime_result_id = Some(result.result_id); + Ok(Some(terminal)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn stale_revision_refuses_launch_before_runtime_writes() { + let state = EpiphanyThreadState { + revision: 2, + ..Default::default() + }; + let request = build_epiphany_role_launch_request( + "thread-1", + EpiphanyRoleResultRoleId::Modeling, + Some(1), + Some(60), + &state, + ) + .expect("role launch request"); + let error = plan_coordinator_job_launch( + &state, + &request, + Path::new("missing.cc"), + "launcher".to_string(), + "backend".to_string(), + ) + .expect_err("stale launch must fail"); + assert!(error.to_string().contains("revision mismatch")); + } +} diff --git a/epiphany-core/src/lib.rs b/epiphany-core/src/lib.rs index 8346dac8..a9866154 100644 --- a/epiphany-core/src/lib.rs +++ b/epiphany-core/src/lib.rs @@ -3,6 +3,7 @@ mod agent_memory; mod agent_utterance_state; mod continuity_gateway; mod coordinator_acceptance; +mod coordinator_launch; mod coordinator_results; mod coordinator_service; mod coordinator_state; @@ -139,6 +140,8 @@ pub use coordinator_acceptance::read_accepted_coordinator_state; pub use coordinator_acceptance::reorient_acceptance_claimed_effects; pub use coordinator_acceptance::role_acceptance_claimed_effects; pub use coordinator_acceptance::role_acceptance_enforceable_receipts; +pub use coordinator_launch::EpiphanyCoordinatorJobLaunchPlan; +pub use coordinator_launch::plan_coordinator_job_launch; pub use coordinator_results::EpiphanyCoordinatorReorientResultSnapshot; pub use coordinator_results::EpiphanyCoordinatorRoleResultSnapshot; pub use coordinator_results::read_reorient_result_snapshot; diff --git a/notes/fresh-workspace-handoff.md b/notes/fresh-workspace-handoff.md index a0a4e5cc..bd281543 100644 --- a/notes/fresh-workspace-handoff.md +++ b/notes/fresh-workspace-handoff.md @@ -1820,6 +1820,16 @@ proof `.epiphany-smoke\repo-work-readiness-20260621-113714` and wrapper proof The machine is no longer cleared to move outward. Free it from the Codex organ first. +Native worker-launch planning has been extracted into +`epiphany-core/src/coordinator_launch.rs`. It owns revision refusal, prior +terminal-link discovery, heartbeat launch planning, binding replacement, and +the derived state update. The bridge delegates those decisions and a source +guard prevents the policy from growing back. The remaining launch wound is +transactional: runtime documents, the optional Substrate Gate grant, and +thread-state linkage are still separate compatibility writes. The next cut is +one prepared atomic launch commit with injected-failure proof; do not remove +the coordinator JSON-RPC dependency before that invariant is real. + ## Immediate Re-entry Instruction After compaction, first rehydrate and reorient from the listed files and git diff --git a/notes/native-coordinator-liberation-map.md b/notes/native-coordinator-liberation-map.md index 76ecf3ae..135a6a31 100644 --- a/notes/native-coordinator-liberation-map.md +++ b/notes/native-coordinator-liberation-map.md @@ -170,6 +170,22 @@ interruption. Best-effort rollback across two files is not atomicity. A negative smoke must inject failure after prerequisite receipts and after state write and prove no unwitnessed accepted state becomes authoritative. +## Launch Transaction Wound + +Launch planning now belongs to `coordinator_launch`: it validates the expected +thread revision, inspects the prior runtime binding, prepares the heartbeat +launch documents, and derives the new thread-state linkage without writing. +The Codex bridge is a compatibility actuator for that plan; it no longer owns +those policy decisions. + +Persistence is not yet a coherent launch transaction. Runtime identity, +session, job, event, worker request, optional Substrate Gate grant, and thread +state linkage are still published through separate writes. None may become the +authoritative sign of a completed launch until one native commit primitive +publishes the prepared set atomically, or a typed prepared transaction gives +interruption a deterministic finish-or-refuse law. Rollback and reconciliation +are not owners. + ## Invariants - There is one durable thread-state owner and one runtime lifecycle owner. diff --git a/state/ledgers.msgpack b/state/ledgers.msgpack index 61b749ee..3462e710 100644 Binary files a/state/ledgers.msgpack and b/state/ledgers.msgpack differ diff --git a/state/map.yaml b/state/map.yaml index 6428d98f..f9381928 100644 --- a/state/map.yaml +++ b/state/map.yaml @@ -659,4 +659,4 @@ current_status: - Service lifecycle receipt history is now event-only: `load_epiphany_cultmesh_daemon_service_lifecycle_receipts` filters out the `epiphany-local/daemon-service-lifecycle-receipt/latest` mirror, while `load_latest_epiphany_cultmesh_daemon_service_lifecycle_receipt` remains the latest-state reader. Focused unit test `service_lifecycle_receipt_history_excludes_latest_mirror` proves two per-receipt writes return exactly two history records and latest points at the second. Fresh smoke `verse-query-lifecycle-history-filter-smoke` and live overview/triage `local-20260618-145632-497-efac6f0d` / `local-20260618-145632-321-867947af` prove compact swarm sights still expose service lifecycle attention plus the present elevated runbook artifact without daemon pokes or service mutation. Do not use a broad unscoped cargo test filter for this check on Windows; it fans out across bin harnesses and hit a broken pipe during this pass. - `epiphany-verse-query seed-compact` is the compact seed receipt for wrappers and read-only rites. The older `seed` command still prints the full local Verse context for explicit debugging, but compact wrapper modes now avoid full `seed`/final `query` readback after the combined overview proof exposed Windows debug stack overflow from full-context serialization pressure. last_completed_repo_swarm_cut: Huginn live-fire has crossed the first real repo Body from Persona pressure to branch-local Hands commit and Soul/Mind closure, and now has repeatable closure and redacted-public-proof mouths. On `E:\Projects\Huginn` branch `epiphany/huginn/first-awakening`, `tools/epiphany_local_run.ps1 -Mode repo-persona-intake` accepted item `huginn-inspection-readback-livefire`, Weksa/Persona intake remained sealed, and `epiphany-work derive-plan` lowered it to model-authored safe family `repo.status_section` for `README.md` only. `tools/epiphany_local_run.ps1 -Mode repo-swarm-run` advanced four bounded pulses: run-from-plan, adopt-from-plan, execute-from-plan, and close-from-execute. Huginn commit `e9b07c38fceabbdc89ea35c2156326c80c0393a2` updated only `README.md`; closure receipt `.epiphany\work\work-close-huginn-inspection-readback-livefire.json` records status `closed`, Soul verdict `passed`, Mind state commit `repo-work-close-huginn-inspection-readback-livefire-mind-commit`, safe family `repo.status_section`, path scope `README.md`, Bifrost publication gate, and `privateStateExposed=false`. Verification also ran Huginn's TypeScript compiler through concrete Node path: `C:\Program Files\nodejs\node.exe E:\Projects\Huginn\node_modules\typescript\bin\tsc -p tsconfig.json` passed. Native verifier `epiphany-repo-livefire-closure-smoke --workspace E:\Projects\Huginn --item huginn-inspection-readback-livefire --expected-path README.md --expected-family repo.status_section --expected-gate awaiting-publication --expected-blocker bifrost-publication-missing` checks closure receipt, Mind map entry, overview gate/blocker, authority seals, and `git show` changed paths, returning `status=ok`, `soulVerdict=passed`, `publicationGate=Bifrost`, and `privateStateExposed=false`. Wrapper `tools/epiphany_local_run.ps1 -Mode repo-livefire-closure -Workspace E:\Projects\Huginn -RepoWorkItem huginn-inspection-readback-livefire -SkipBuild` exposes the same read-only proof through the local operator mouth; artifact `local-20260621-190156-803-2f85ba15` printed commit, path, gate, blocker, Soul verdict, Mind commit receipt, and `privateStateExposed=False` without push, merge, publication, or target mutation. New wrapper `tools/epiphany_local_run.ps1 -Mode repo-work-public-proof -Workspace E:\Projects\Huginn -RepoWorkItem huginn-inspection-readback-livefire -SkipBuild` exports the redacted proof bundle and repo-local Verse index without publishing; artifact `local-20260621-190645-345-4555b1dc` wrote `.epiphany\public\proof-bundles\repo-work-public-proof-huginn-inspection-readback-livefire.json`, proof id `repo-work-public-proof-huginn-inspection-readback-livefire`, SHA-256 `857545b199f3e37eb06c2e757e5cfc201958e46667bb1f8403fd333a22620f63`, nine artifact rows, zero publication rows, and `privateStateExposed=False`. Gjallar readback `local-20260621-190701-140-b9519ec6` sees the `REPO-WORK-PUBLIC-PROOF` row and action row from Huginn's repo-local Verse with the same SHA-256 while Bifrost publication remains missing. Launcher compact modes are now named compact operator-context modes instead of read-only modes, and `bifrost-public-proof` summaries print item, target public Verse, room, proof SHA-256, review receipts, and credit receipts so publication receipts are auditable without claiming upstream sync. - next_action: Extract worker launch into its own native organ, following `notes/native-coordinator-liberation-map.md`. Role/reorient acceptance now publishes state and every witness atomically through the unified store; obsolete cross-store writers are deleted and guarded. Move typed dynamic context, launch document construction, runtime-spine opening/interruption, and state linkage into the launch organ, then remove `thread/epiphany/*` JSON-RPC from `epiphany-mvp-coordinator`. + next_action: Complete the native launch transaction described in `notes/native-coordinator-liberation-map.md`. Pure launch planning now belongs to `coordinator_launch`; the compatibility bridge only actuates its plan, but runtime documents, optional Substrate Gate grant, and thread-state linkage remain separate writes. Create one atomic prepared launch commit and negative failure proof before removing `thread/epiphany/*` JSON-RPC from `epiphany-mvp-coordinator`.