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:
铸渊 / ICE-P-ZY001 2026-08-11 03:21:15 +08:00
commit 38ef9d0e11
10 changed files with 427 additions and 13 deletions

View file

@ -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();