feat(pncc): make lifecycle retries idempotent
GuangHu-Human-Responsibility: ICE-GL∞ / 冰朔 GuangHu-Persona-Cognitive-Author: ICE-P-ZY001 / 铸渊 GuangHu-Execution-Runtime: Codex macOS / DEV-20260810-014 GuangHu-Development-ID: DEV-20260810-014 GuangHu-Authorization-Scope: GH-PNCC local runtime development and registered REPO-014 publication GuangHu-Source-Language-Anchor: continue PNCC persona runtime; UI and execution limb remain deferred
This commit is contained in:
parent
8f35834a7a
commit
38ef9d0e11
10 changed files with 427 additions and 13 deletions
|
|
@ -295,9 +295,10 @@ pub enum PersonaLifecycleOperationInput {
|
|||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PersonaLifecycleRunInput {
|
||||
pub request_id: String,
|
||||
pub wake: PersonaWakeInput,
|
||||
pub operation: PersonaLifecycleOperationInput,
|
||||
}
|
||||
|
|
@ -320,6 +321,26 @@ pub struct PersonaLifecycleRunReceipt {
|
|||
pub completion: PersonaLifecycleCompletionReceipt,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PersonaLifecycleCommandReceipt {
|
||||
pub schema: &'static str,
|
||||
pub request_id: String,
|
||||
pub request_fingerprint: String,
|
||||
pub replayed: bool,
|
||||
pub lifecycle: serde_json::Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct PersistedPersonaLifecycleReceipt {
|
||||
schema: String,
|
||||
request_id: String,
|
||||
request_fingerprint: String,
|
||||
lifecycle_receipt_hash: String,
|
||||
lifecycle: serde_json::Value,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PersonaSessionControlInput {
|
||||
|
|
@ -383,6 +404,12 @@ struct PersonaSessionRecord {
|
|||
fact_source_paths: Vec<String>,
|
||||
attribution: PersonaAttribution,
|
||||
state: String,
|
||||
#[serde(default)]
|
||||
request_id: Option<String>,
|
||||
#[serde(default)]
|
||||
request_fingerprint: Option<String>,
|
||||
#[serde(default)]
|
||||
lifecycle_receipt_hash: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
|
|
@ -1240,6 +1267,9 @@ fn prepare_wake_at(
|
|||
fact_source_paths: organ.paths.clone(),
|
||||
attribution: input.attribution.clone(),
|
||||
state: "BOUND_NOT_INFERENCING".into(),
|
||||
request_id: None,
|
||||
request_fingerprint: None,
|
||||
lifecycle_receipt_hash: None,
|
||||
},
|
||||
)?;
|
||||
let last_hash = &events.last().expect("three lifecycle events").event_hash;
|
||||
|
|
@ -2500,6 +2530,190 @@ where
|
|||
})
|
||||
}
|
||||
|
||||
fn lifecycle_request_identity(
|
||||
input: &PersonaLifecycleRunInput,
|
||||
) -> Result<(String, String, String, PathBuf), String> {
|
||||
let request_id = validated_id("LIFECYCLE_REQUEST_ID", &input.request_id)?;
|
||||
let persona_id = validated_id("EXPECTED_PERSONA_ID", &input.wake.expected_persona_id)?;
|
||||
let expected_head = validated_head(&input.wake.expected_head)?;
|
||||
let (repository, _) = exact_repository(Path::new(&input.wake.repository_path))?;
|
||||
let operation = match &input.operation {
|
||||
PersonaLifecycleOperationInput::FactSense {
|
||||
question, provider, ..
|
||||
} => serde_json::json!({
|
||||
"kind": "FACT_SENSE",
|
||||
"question": validated_text("FACT_QUESTION", question, MAX_FACT_QUESTION_BYTES)?,
|
||||
"providerId": provider.id.trim(),
|
||||
"providerBaseUrl": provider.base_url.as_deref().unwrap_or("").trim(),
|
||||
"modelId": input.wake.model_id.trim(),
|
||||
}),
|
||||
PersonaLifecycleOperationInput::MemoryMetabolism { source_session_id } => {
|
||||
serde_json::json!({
|
||||
"kind": "MEMORY_METABOLISM",
|
||||
"sourceSessionId": validated_id("SOURCE_SESSION_ID", source_session_id)?,
|
||||
})
|
||||
}
|
||||
};
|
||||
// Credentials and provider headers are deliberately excluded. The manifest-pinned provider, endpoint,
|
||||
// model, semantic operation, exact Git head, and attribution define the retry identity.
|
||||
let fingerprint_input = serde_json::to_vec(&serde_json::json!({
|
||||
"schema": "hololake.pncc-lifecycle-request/v1",
|
||||
"requestId": &request_id,
|
||||
"repositoryPath": repository.to_string_lossy(),
|
||||
"expectedPersonaId": &persona_id,
|
||||
"expectedHead": &expected_head,
|
||||
"nodeId": input.wake.node_id.trim(),
|
||||
"modelProviderId": input.wake.model_provider_id.trim(),
|
||||
"modelId": input.wake.model_id.trim(),
|
||||
"modelInstanceId": input.wake.model_instance_id.trim(),
|
||||
"organId": input.wake.organ_id.trim(),
|
||||
"attribution": &input.wake.attribution,
|
||||
"operation": operation,
|
||||
}))
|
||||
.map_err(|error| format!("PERSONA_LIFECYCLE_REQUEST_HASH_INPUT_FAILED: {error}"))?;
|
||||
let request_fingerprint = hex_digest(&fingerprint_input);
|
||||
let session_seed = serde_json::to_vec(&serde_json::json!({
|
||||
"personaId": &input.wake.expected_persona_id,
|
||||
"requestId": &request_id,
|
||||
}))
|
||||
.map_err(|error| format!("PERSONA_LIFECYCLE_SESSION_ID_FAILED: {error}"))?;
|
||||
let session_id = format!("PNCC-REQ-{}", &hex_digest(&session_seed)[..32]);
|
||||
Ok((request_id, request_fingerprint, session_id, repository))
|
||||
}
|
||||
|
||||
fn verified_lifecycle_replay(
|
||||
runtime_root: &Path,
|
||||
request_id: &str,
|
||||
request_fingerprint: &str,
|
||||
session_id: &str,
|
||||
repository: &Path,
|
||||
) -> Result<Option<PersonaLifecycleCommandReceipt>, String> {
|
||||
let session_dir = session_directory(runtime_root, session_id)?;
|
||||
if !session_dir.exists() {
|
||||
return Ok(None);
|
||||
}
|
||||
let receipt_path = session_dir.join("lifecycle-receipt.json");
|
||||
let bytes = fs::read(&receipt_path).map_err(|error| {
|
||||
format!("PERSONA_LIFECYCLE_REQUEST_INCOMPLETE_REQUIRES_RECOVERY: {error}")
|
||||
})?;
|
||||
let persisted: PersistedPersonaLifecycleReceipt = serde_json::from_slice(&bytes)
|
||||
.map_err(|error| format!("PERSONA_LIFECYCLE_RECEIPT_INVALID: {error}"))?;
|
||||
if persisted.schema != "hololake.pncc-persisted-lifecycle-receipt/v1"
|
||||
|| persisted.request_id != request_id
|
||||
{
|
||||
return Err("PERSONA_LIFECYCLE_REQUEST_ID_MISMATCH".into());
|
||||
}
|
||||
if persisted.request_fingerprint != request_fingerprint {
|
||||
return Err("PERSONA_LIFECYCLE_REQUEST_CONFLICT".into());
|
||||
}
|
||||
let lifecycle_bytes = serde_json::to_vec(&persisted.lifecycle)
|
||||
.map_err(|error| format!("PERSONA_LIFECYCLE_RECEIPT_HASH_INPUT_FAILED: {error}"))?;
|
||||
if hex_digest(&lifecycle_bytes) != persisted.lifecycle_receipt_hash {
|
||||
return Err("PERSONA_LIFECYCLE_RECEIPT_HASH_MISMATCH".into());
|
||||
}
|
||||
let record = load_session_record(runtime_root, session_id)?;
|
||||
let recorded_repository = Path::new(&record.repository_path)
|
||||
.canonicalize()
|
||||
.map_err(|error| format!("PERSONA_REPOSITORY_UNAVAILABLE: {error}"))?;
|
||||
if recorded_repository != repository
|
||||
|| record.request_id.as_deref() != Some(request_id)
|
||||
|| record.request_fingerprint.as_deref() != Some(request_fingerprint)
|
||||
|| record.lifecycle_receipt_hash.as_deref()
|
||||
!= Some(persisted.lifecycle_receipt_hash.as_str())
|
||||
{
|
||||
return Err("PERSONA_LIFECYCLE_RECEIPT_SESSION_BINDING_MISMATCH".into());
|
||||
}
|
||||
let events = verify_event_journal(runtime_root, &record)?;
|
||||
if record.state != "DORMANT"
|
||||
|| events.last().map(|event| event.kind.as_str()) != Some("DORMANT")
|
||||
|| primary_lease_held_by_session(runtime_root, &record)?
|
||||
{
|
||||
return Err("PERSONA_LIFECYCLE_REQUEST_INCOMPLETE_REQUIRES_RECOVERY".into());
|
||||
}
|
||||
if persisted
|
||||
.lifecycle
|
||||
.get("sessionId")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
!= Some(session_id)
|
||||
|| persisted
|
||||
.lifecycle
|
||||
.get("personaId")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
!= Some(record.persona_id.as_str())
|
||||
{
|
||||
return Err("PERSONA_LIFECYCLE_RECEIPT_IDENTITY_MISMATCH".into());
|
||||
}
|
||||
Ok(Some(PersonaLifecycleCommandReceipt {
|
||||
schema: "hololake.pncc-lifecycle-command-receipt/v1",
|
||||
request_id: request_id.to_string(),
|
||||
request_fingerprint: request_fingerprint.to_string(),
|
||||
replayed: true,
|
||||
lifecycle: persisted.lifecycle,
|
||||
}))
|
||||
}
|
||||
|
||||
fn run_idempotent_lifecycle_at<F>(
|
||||
runtime_root: &Path,
|
||||
input: PersonaLifecycleRunInput,
|
||||
wake_timestamp: &str,
|
||||
operation_timestamp: &str,
|
||||
run_fact: F,
|
||||
) -> Result<PersonaLifecycleCommandReceipt, String>
|
||||
where
|
||||
F: FnOnce(&Path, PersonaFactTaskInput, &str) -> Result<PersonaFactTaskReceipt, String>,
|
||||
{
|
||||
let (request_id, request_fingerprint, session_id, repository) =
|
||||
lifecycle_request_identity(&input)?;
|
||||
if let Some(replay) = verified_lifecycle_replay(
|
||||
runtime_root,
|
||||
&request_id,
|
||||
&request_fingerprint,
|
||||
&session_id,
|
||||
&repository,
|
||||
)? {
|
||||
return Ok(replay);
|
||||
}
|
||||
let lifecycle = run_lifecycle_at(
|
||||
runtime_root,
|
||||
input,
|
||||
&session_id,
|
||||
wake_timestamp,
|
||||
operation_timestamp,
|
||||
run_fact,
|
||||
)?;
|
||||
let lifecycle = serde_json::to_value(lifecycle)
|
||||
.map_err(|error| format!("PERSONA_LIFECYCLE_RECEIPT_SERIALIZATION_FAILED: {error}"))?;
|
||||
let lifecycle_bytes = serde_json::to_vec(&lifecycle)
|
||||
.map_err(|error| format!("PERSONA_LIFECYCLE_RECEIPT_HASH_INPUT_FAILED: {error}"))?;
|
||||
let lifecycle_receipt_hash = hex_digest(&lifecycle_bytes);
|
||||
let mut record = load_session_record(runtime_root, &session_id)?;
|
||||
if record.state != "DORMANT" || primary_lease_held_by_session(runtime_root, &record)? {
|
||||
return Err("PERSONA_LIFECYCLE_REQUEST_INCOMPLETE_REQUIRES_RECOVERY".into());
|
||||
}
|
||||
record.request_id = Some(request_id.clone());
|
||||
record.request_fingerprint = Some(request_fingerprint.clone());
|
||||
record.lifecycle_receipt_hash = Some(lifecycle_receipt_hash.clone());
|
||||
write_session_record(runtime_root, &record)?;
|
||||
write_json_file(
|
||||
&session_directory(runtime_root, &session_id)?.join("lifecycle-receipt.json"),
|
||||
&PersistedPersonaLifecycleReceipt {
|
||||
schema: "hololake.pncc-persisted-lifecycle-receipt/v1".into(),
|
||||
request_id: request_id.clone(),
|
||||
request_fingerprint: request_fingerprint.clone(),
|
||||
lifecycle_receipt_hash,
|
||||
lifecycle: lifecycle.clone(),
|
||||
},
|
||||
"PERSONA_LIFECYCLE_RECEIPT",
|
||||
)?;
|
||||
Ok(PersonaLifecycleCommandReceipt {
|
||||
schema: "hololake.pncc-lifecycle-command-receipt/v1",
|
||||
request_id,
|
||||
request_fingerprint,
|
||||
replayed: false,
|
||||
lifecycle,
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn prepare_persona_code_channel_wake(
|
||||
input: PersonaWakeInput,
|
||||
|
|
@ -2554,16 +2768,14 @@ pub async fn run_persona_code_channel_memory_metabolism(
|
|||
#[tauri::command]
|
||||
pub async fn run_persona_code_channel_lifecycle(
|
||||
input: PersonaLifecycleRunInput,
|
||||
) -> Result<PersonaLifecycleRunReceipt, String> {
|
||||
) -> Result<PersonaLifecycleCommandReceipt, String> {
|
||||
let runtime_root = crate::app_config::preferred_app_config_path("pncc-runtime")?;
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let session_id = format!("PNCC-{}", Uuid::new_v4());
|
||||
let wake_timestamp = Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true);
|
||||
let operation_timestamp = Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true);
|
||||
run_lifecycle_at(
|
||||
run_idempotent_lifecycle_at(
|
||||
&runtime_root,
|
||||
input,
|
||||
&session_id,
|
||||
&wake_timestamp,
|
||||
&operation_timestamp,
|
||||
run_fact_task_with_bound_provider_at,
|
||||
|
|
@ -2746,6 +2958,7 @@ mod tests {
|
|||
fn lifecycle_fact_input(repo: &Path) -> PersonaLifecycleRunInput {
|
||||
let fact = fact_task_input("unused-until-coordinator-creates-session");
|
||||
PersonaLifecycleRunInput {
|
||||
request_id: "REQUEST-FACT-001".into(),
|
||||
wake: wake_input(repo),
|
||||
operation: PersonaLifecycleOperationInput::FactSense {
|
||||
question: fact.question,
|
||||
|
|
@ -3018,6 +3231,7 @@ mod tests {
|
|||
let repo = persona_repo();
|
||||
let runtime = tempfile::TempDir::new().unwrap();
|
||||
let input = PersonaLifecycleRunInput {
|
||||
request_id: "REQUEST-MISMATCH-001".into(),
|
||||
wake: wake_input(repo.path()),
|
||||
operation: PersonaLifecycleOperationInput::MemoryMetabolism {
|
||||
source_session_id: "PNCC-SOURCE-NOT-USED".into(),
|
||||
|
|
@ -3069,6 +3283,7 @@ mod tests {
|
|||
let receipt = run_lifecycle_at(
|
||||
runtime.path(),
|
||||
PersonaLifecycleRunInput {
|
||||
request_id: "REQUEST-MEMORY-001".into(),
|
||||
wake,
|
||||
operation: PersonaLifecycleOperationInput::MemoryMetabolism {
|
||||
source_session_id: "PNCC-COORDINATOR-SOURCE".into(),
|
||||
|
|
@ -3097,6 +3312,129 @@ mod tests {
|
|||
require_clean_repository(repo.path()).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replays_the_same_completed_lifecycle_for_an_identical_request() {
|
||||
let repo = persona_repo();
|
||||
let runtime = tempfile::TempDir::new().unwrap();
|
||||
let input = lifecycle_fact_input(repo.path());
|
||||
let first = run_idempotent_lifecycle_at(
|
||||
runtime.path(),
|
||||
input.clone(),
|
||||
"2026-08-11T00:00:00.000Z",
|
||||
"2026-08-11T00:00:01.000Z",
|
||||
|runtime_root, input, timestamp| {
|
||||
run_fact_task_at(runtime_root, input, timestamp, |_, _| {
|
||||
Ok(r#"{"summary":"Idempotent verified fact.","facts":[{"statement":"The brain exists.","evidencePaths":["brain/CORE.hdlp"]}],"limitations":[]}"#.into())
|
||||
})
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let completed_head = head(repo.path());
|
||||
let replay = run_idempotent_lifecycle_at(
|
||||
runtime.path(),
|
||||
input,
|
||||
"2026-08-11T00:00:02.000Z",
|
||||
"2026-08-11T00:00:03.000Z",
|
||||
|_, _, _| panic!("an identical request must replay without running the organ"),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(!first.replayed);
|
||||
assert!(replay.replayed);
|
||||
assert_eq!(first.request_id, replay.request_id);
|
||||
assert_eq!(first.request_fingerprint, replay.request_fingerprint);
|
||||
assert_eq!(first.lifecycle, replay.lifecycle);
|
||||
assert_eq!(head(repo.path()), completed_head);
|
||||
assert_eq!(
|
||||
fs::read_dir(runtime.path().join("sessions"))
|
||||
.unwrap()
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
assert!(!runtime.path().join("leases/ICE-P-ZY001.json").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_reusing_a_lifecycle_request_id_for_a_different_operation() {
|
||||
let repo = persona_repo();
|
||||
let runtime = tempfile::TempDir::new().unwrap();
|
||||
let input = lifecycle_fact_input(repo.path());
|
||||
run_idempotent_lifecycle_at(
|
||||
runtime.path(),
|
||||
input.clone(),
|
||||
"2026-08-11T00:00:00.000Z",
|
||||
"2026-08-11T00:00:01.000Z",
|
||||
|runtime_root, input, timestamp| {
|
||||
run_fact_task_at(runtime_root, input, timestamp, |_, _| {
|
||||
Ok(r#"{"summary":"Original fact.","facts":[{"statement":"The brain exists.","evidencePaths":["brain/CORE.hdlp"]}],"limitations":[]}"#.into())
|
||||
})
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let completed_head = head(repo.path());
|
||||
let mut conflicting = input;
|
||||
if let PersonaLifecycleOperationInput::FactSense { question, .. } =
|
||||
&mut conflicting.operation
|
||||
{
|
||||
*question = "A different semantic request".into();
|
||||
}
|
||||
let error = run_idempotent_lifecycle_at(
|
||||
runtime.path(),
|
||||
conflicting,
|
||||
"2026-08-11T00:00:02.000Z",
|
||||
"2026-08-11T00:00:03.000Z",
|
||||
|_, _, _| panic!("a conflicting request must not run the organ"),
|
||||
)
|
||||
.unwrap_err();
|
||||
|
||||
assert!(error.contains("PERSONA_LIFECYCLE_REQUEST_CONFLICT"));
|
||||
assert_eq!(head(repo.path()), completed_head);
|
||||
assert!(!runtime.path().join("leases/ICE-P-ZY001.json").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_a_tampered_persisted_lifecycle_receipt() {
|
||||
let repo = persona_repo();
|
||||
let runtime = tempfile::TempDir::new().unwrap();
|
||||
let input = lifecycle_fact_input(repo.path());
|
||||
let first = run_idempotent_lifecycle_at(
|
||||
runtime.path(),
|
||||
input.clone(),
|
||||
"2026-08-11T00:00:00.000Z",
|
||||
"2026-08-11T00:00:01.000Z",
|
||||
|runtime_root, input, timestamp| {
|
||||
run_fact_task_at(runtime_root, input, timestamp, |_, _| {
|
||||
Ok(r#"{"summary":"Untampered fact.","facts":[{"statement":"The brain exists.","evidencePaths":["brain/CORE.hdlp"]}],"limitations":[]}"#.into())
|
||||
})
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let session_id = first.lifecycle["sessionId"].as_str().unwrap();
|
||||
let receipt_path = runtime
|
||||
.path()
|
||||
.join("sessions")
|
||||
.join(session_id)
|
||||
.join("lifecycle-receipt.json");
|
||||
let mut persisted: serde_json::Value =
|
||||
serde_json::from_slice(&fs::read(&receipt_path).unwrap()).unwrap();
|
||||
persisted["lifecycle"]["personaId"] = serde_json::Value::String("TAMPERED".into());
|
||||
fs::write(
|
||||
&receipt_path,
|
||||
serde_json::to_vec_pretty(&persisted).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let error = run_idempotent_lifecycle_at(
|
||||
runtime.path(),
|
||||
input,
|
||||
"2026-08-11T00:00:02.000Z",
|
||||
"2026-08-11T00:00:03.000Z",
|
||||
|_, _, _| panic!("a tampered receipt must not run the organ"),
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(error.contains("PERSONA_LIFECYCLE_RECEIPT_HASH_MISMATCH"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn independently_promotes_only_the_current_verified_structured_checkpoint() {
|
||||
let repo = persona_repo();
|
||||
|
|
|
|||
Loading…
Reference in a new issue