Add Epiphany runtime links

This commit is contained in:
Meta Vens 2026-05-12 02:47:16 +01:00
commit 7eba3e6d6d
5 changed files with 130 additions and 0 deletions

View file

@ -76,6 +76,7 @@ use codex_protocol::protocol::EpiphanyModeState as CoreEpiphanyModeState;
use codex_protocol::protocol::EpiphanyObservation as CoreEpiphanyObservation;
use codex_protocol::protocol::EpiphanyPlanningState as CoreEpiphanyPlanningState;
use codex_protocol::protocol::EpiphanyRetrievalStatus as CoreEpiphanyRetrievalStatus;
use codex_protocol::protocol::EpiphanyRuntimeLink as CoreEpiphanyRuntimeLink;
use codex_protocol::protocol::EpiphanyScratchPad as CoreEpiphanyScratchPad;
use codex_protocol::protocol::EpiphanySubgoal as CoreEpiphanySubgoal;
use codex_protocol::protocol::EpiphanyThreadState as CoreEpiphanyThreadState;
@ -5426,6 +5427,8 @@ pub struct ThreadEpiphanyUpdatePatch {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub acceptance_receipts: Vec<CoreEpiphanyAcceptanceReceipt>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub runtime_links: Vec<CoreEpiphanyRuntimeLink>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub observations: Vec<CoreEpiphanyObservation>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub evidence: Vec<CoreEpiphanyEvidenceRecord>,
@ -5487,6 +5490,7 @@ pub enum ThreadEpiphanyStateUpdatedField {
InvestigationCheckpoint,
JobBindings,
AcceptanceReceipts,
RuntimeLinks,
Observations,
Evidence,
Churn,

View file

@ -5207,6 +5207,7 @@ impl CodexMessageProcessor {
investigation_checkpoint: patch.investigation_checkpoint,
job_bindings: patch.job_bindings,
acceptance_receipts: patch.acceptance_receipts,
runtime_links: patch.runtime_links,
observations: patch.observations,
evidence: patch.evidence,
churn: patch.churn,
@ -6567,6 +6568,7 @@ impl CodexMessageProcessor {
investigation_checkpoint: patch.investigation_checkpoint,
job_bindings: patch.job_bindings,
acceptance_receipts: patch.acceptance_receipts,
runtime_links: patch.runtime_links,
observations: patch.observations,
evidence,
churn: patch.churn,
@ -6658,6 +6660,7 @@ impl CodexMessageProcessor {
investigation_checkpoint: patch.investigation_checkpoint,
job_bindings: patch.job_bindings,
acceptance_receipts: patch.acceptance_receipts,
runtime_links: patch.runtime_links,
observations: patch.observations,
evidence: patch.evidence,
churn: patch.churn,
@ -15691,6 +15694,12 @@ fn imagination_role_accept_patch_errors(patch: &ThreadEpiphanyUpdatePatch) -> Ve
"acceptance receipt changes are owned by roleAccept, not worker statePatch".to_string(),
);
}
if !patch.runtime_links.is_empty() {
errors.push(
"runtime link changes are owned by launch/read-back surfaces, not worker statePatch"
.to_string(),
);
}
if patch.churn.is_some() || patch.mode.is_some() {
errors.push(
"churn or mode changes are not allowed through imagination role acceptance".to_string(),
@ -15753,6 +15762,12 @@ fn modeling_role_accept_patch_errors(patch: &ThreadEpiphanyUpdatePatch) -> Vec<S
"acceptance receipt changes are owned by roleAccept, not worker statePatch".to_string(),
);
}
if !patch.runtime_links.is_empty() {
errors.push(
"runtime link changes are owned by launch/read-back surfaces, not worker statePatch"
.to_string(),
);
}
if patch.planning.is_some() {
errors
.push("planning changes are not allowed through modeling role acceptance".to_string());
@ -17956,6 +17971,7 @@ fn thread_epiphany_patch_has_state_replacements(patch: &ThreadEpiphanyUpdatePatc
|| patch.investigation_checkpoint.is_some()
|| patch.job_bindings.is_some()
|| !patch.acceptance_receipts.is_empty()
|| !patch.runtime_links.is_empty()
|| patch.churn.is_some()
|| patch.mode.is_some()
|| patch.planning.is_some()
@ -18064,6 +18080,9 @@ fn epiphany_update_patch_changed_fields(
if !patch.acceptance_receipts.is_empty() {
fields.push(ThreadEpiphanyStateUpdatedField::AcceptanceReceipts);
}
if !patch.runtime_links.is_empty() {
fields.push(ThreadEpiphanyStateUpdatedField::RuntimeLinks);
}
if !patch.observations.is_empty() {
fields.push(ThreadEpiphanyStateUpdatedField::Observations);
}

View file

@ -40,6 +40,7 @@ use codex_protocol::protocol::EpiphanyModeState;
use codex_protocol::protocol::EpiphanyObservation;
use codex_protocol::protocol::EpiphanyPlanningState;
use codex_protocol::protocol::EpiphanyRetrievalState;
use codex_protocol::protocol::EpiphanyRuntimeLink;
use codex_protocol::protocol::EpiphanyScratchPad;
use codex_protocol::protocol::EpiphanyStateItem;
use codex_protocol::protocol::EpiphanySubgoal;
@ -127,6 +128,7 @@ pub struct EpiphanyStateUpdate {
pub investigation_checkpoint: Option<EpiphanyInvestigationCheckpoint>,
pub job_bindings: Option<Vec<EpiphanyJobBinding>>,
pub acceptance_receipts: Vec<EpiphanyAcceptanceReceipt>,
pub runtime_links: Vec<EpiphanyRuntimeLink>,
pub observations: Vec<EpiphanyObservation>,
pub evidence: Vec<EpiphanyEvidenceRecord>,
pub churn: Option<EpiphanyChurnState>,
@ -186,6 +188,7 @@ impl EpiphanyStateUpdate {
&& self.investigation_checkpoint.is_none()
&& self.job_bindings.is_none()
&& self.acceptance_receipts.is_empty()
&& self.runtime_links.is_empty()
&& self.observations.is_empty()
&& self.evidence.is_empty()
&& self.churn.is_none()
@ -525,15 +528,22 @@ impl CodexThread {
launcher_job_id.as_str(),
backend_job_id.as_str(),
);
let replacement_runtime_link =
build_epiphany_runtime_link(&request, backend_job_id.as_str());
let next_job_bindings = replace_or_append_epiphany_job_binding(
current_state.job_bindings.clone(),
replacement_binding,
);
let next_runtime_links = replace_or_append_epiphany_runtime_link(
current_state.runtime_links.clone(),
replacement_runtime_link,
);
let validation_errors = epiphany_state_update_validation_errors(
&current_state,
&EpiphanyStateUpdate {
job_bindings: Some(next_job_bindings.clone()),
runtime_links: next_runtime_links.clone(),
..Default::default()
},
);
@ -548,6 +558,7 @@ impl CodexThread {
.epiphany_update_state(EpiphanyStateUpdate {
expected_revision: request.expected_revision,
job_bindings: Some(next_job_bindings),
runtime_links: next_runtime_links,
..Default::default()
})
.await?;
@ -852,6 +863,23 @@ fn build_epiphany_job_launch_binding(
}
}
fn build_epiphany_runtime_link(
request: &EpiphanyJobLaunchRequest,
runtime_job_id: &str,
) -> EpiphanyRuntimeLink {
EpiphanyRuntimeLink {
id: format!("runtime-link-{}-{runtime_job_id}", request.binding_id),
binding_id: request.binding_id.clone(),
surface: "jobLaunch".to_string(),
role_id: request.owner_role.clone(),
authority_scope: request.authority_scope.clone(),
runtime_job_id: runtime_job_id.to_string(),
runtime_result_id: None,
linked_subgoal_ids: request.linked_subgoal_ids.clone(),
linked_graph_node_ids: request.linked_graph_node_ids.clone(),
}
}
fn open_epiphany_runtime_spine_job(
store_path: &std::path::Path,
state: &EpiphanyThreadState,
@ -931,6 +959,18 @@ fn replace_or_append_epiphany_job_binding(
bindings
}
fn replace_or_append_epiphany_runtime_link(
mut links: Vec<EpiphanyRuntimeLink>,
replacement: EpiphanyRuntimeLink,
) -> Vec<EpiphanyRuntimeLink> {
if let Some(existing) = links.iter_mut().find(|link| link.id == replacement.id) {
*existing = replacement;
return links;
}
links.push(replacement);
links
}
fn clear_epiphany_job_binding_backend(
mut bindings: Vec<EpiphanyJobBinding>,
binding_index: usize,
@ -1050,6 +1090,12 @@ fn epiphany_state_update_validation_errors(
&known_evidence_ids,
));
}
if !update.runtime_links.is_empty() {
errors.extend(validate_epiphany_runtime_links(
&state.runtime_links,
&update.runtime_links,
));
}
if let Some(planning) = update.planning.as_ref() {
errors.extend(validate_epiphany_planning_state(planning));
}
@ -1058,6 +1104,37 @@ fn epiphany_state_update_validation_errors(
errors
}
fn validate_epiphany_runtime_links(
existing: &[EpiphanyRuntimeLink],
links: &[EpiphanyRuntimeLink],
) -> Vec<String> {
let mut errors = Vec::new();
let mut seen_ids = existing
.iter()
.map(|link| link.id.as_str())
.collect::<HashSet<_>>();
for link in links {
require_nonempty_update(&link.id, "runtime_link.id", &mut errors);
require_nonempty_update(&link.binding_id, "runtime_link.binding_id", &mut errors);
require_nonempty_update(&link.surface, "runtime_link.surface", &mut errors);
require_nonempty_update(&link.role_id, "runtime_link.role_id", &mut errors);
require_nonempty_update(
&link.authority_scope,
"runtime_link.authority_scope",
&mut errors,
);
require_nonempty_update(
&link.runtime_job_id,
"runtime_link.runtime_job_id",
&mut errors,
);
if !seen_ids.insert(link.id.as_str()) {
errors.push(format!("runtime link id {:?} is duplicated", link.id));
}
}
errors
}
fn validate_epiphany_acceptance_receipts(
existing: &[EpiphanyAcceptanceReceipt],
receipts: &[EpiphanyAcceptanceReceipt],
@ -1407,6 +1484,7 @@ fn apply_epiphany_state_update(
state.job_bindings = job_bindings;
}
prepend_recent(&mut state.acceptance_receipts, update.acceptance_receipts);
prepend_recent(&mut state.runtime_links, update.runtime_links);
if let Some(churn) = update.churn {
state.churn = Some(churn);
}
@ -2072,6 +2150,7 @@ mod epiphany_update_tests {
let binding =
build_epiphany_job_launch_binding(&request, "epiphany-heartbeat-launch-1", "turn-1");
let runtime_link = build_epiphany_runtime_link(&request, "turn-1");
assert_eq!(
binding.backend_kind,
@ -2084,6 +2163,13 @@ mod epiphany_update_tests {
.as_deref()
.is_some_and(|note| note.contains("heartbeat activation"))
);
assert_eq!(
runtime_link.id,
"runtime-link-modeling-checkpoint-worker-turn-1"
);
assert_eq!(runtime_link.runtime_job_id, "turn-1");
assert_eq!(runtime_link.runtime_result_id, None);
assert_eq!(runtime_link.role_id, "epiphany-modeler");
}
}

View file

@ -339,6 +339,7 @@ fn sample_epiphany_state_for_prompt() -> EpiphanyThreadState {
investigation_checkpoint: None,
job_bindings: Vec::new(),
acceptance_receipts: Vec::new(),
runtime_links: Vec::new(),
scratch: Some(EpiphanyScratchPad {
summary: Some("Inject the state as a developer fragment, not as a user message.".to_string()),
hypothesis: Some("Keeping the block bounded will help resume without making prompt sludge.".to_string()),

View file

@ -3027,6 +3027,9 @@ pub struct EpiphanyThreadState {
#[ts(type = "Array<EpiphanyAcceptanceReceipt>")]
pub acceptance_receipts: Vec<EpiphanyAcceptanceReceipt>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
#[ts(type = "Array<EpiphanyRuntimeLink>")]
pub runtime_links: Vec<EpiphanyRuntimeLink>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
#[ts(type = "Array<EpiphanyObservation>")]
pub observations: Vec<EpiphanyObservation>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
@ -3067,6 +3070,23 @@ pub struct EpiphanyAcceptanceReceipt {
pub summary: Option<String>,
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema, TS, Default)]
pub struct EpiphanyRuntimeLink {
pub id: String,
pub binding_id: String,
pub surface: String,
pub role_id: String,
pub authority_scope: String,
pub runtime_job_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(type = "string | null")]
pub runtime_result_id: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub linked_subgoal_ids: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub linked_graph_node_ids: Vec<String>,
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema, TS, Default)]
pub struct EpiphanySubgoal {
pub id: String,