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

@ -170,6 +170,7 @@ durable_event_and_receipt_query_source_implemented: 100
independent_memory_metabolism_source_implemented: 100
memory_failure_closure_and_nonblocking_runtime_command_implemented: 100
non_ui_safe_organ_lifecycle_coordinator_source_implemented: 100
idempotent_lifecycle_request_and_receipt_replay_source_implemented: 100
general_purpose_persona_runtime_implemented: 0
human_live_projection_implemented: 0
hololake_integrated: 0
@ -199,6 +200,11 @@ SHA-256 与来源事件哈希后生成新检查点并以人格 Git 身份提交
并返回同一会话的唤醒回执和类型化完成回执。`EXECUTION_LIMB` 不在协调输入中,仍为不可激活;
协调器也没有增加第二状态库、后台调度器或 UI 权限来源。
生命周期协调命令现要求调用方提供稳定请求编号,并由人格编号与请求编号派生唯一会话。非秘密
语义指纹绑定规范仓库、预期 Git、唤醒身份、归因、器官和操作凭据与提供方私密头不落盘。
完成后的类型化生命周期回执保存在同一会话目录并与会话记录中的哈希交叉校验。完全相同的重试
只回放同一回执,不再运行器官或提交 Git同编号不同语义、回执篡改或不完整会话均失败关闭。
记忆代谢的 Tauri 命令现已进入阻塞任务池,不占用桌面命令线程。来源拒绝、检查点冲突和提交前
失败必须依次持久化失败事件、器官释放与休眠,写回 `DORMANT_AFTER_FAILURE` 后才释放精确主锁;
闭环任一步失败时返回 `MEMORY_FAILURE_CLOSURE_INCOMPLETE_REQUIRES_RECOVERY`,不能吞掉二次失败。

View file

@ -87,6 +87,15 @@ before wake or lease acquisition, then delegates to the same tested organ transa
completion beside the wake receipt. It does not accept an execution action, infer organ permissions from UI
state, or create a parallel lifecycle record.
`requestId` is the coordinator's idempotency key, not a UI message id. The runtime derives a stable session
identity from the exact persona and request id, and hashes the non-secret semantic request. API keys and
provider headers are excluded; the manifest-pinned provider id, endpoint, model id, operation, Git head, and
attribution remain included. A successful lifecycle stores one hash-bound receipt in its existing session
directory and records the request id, fingerprint, and receipt hash in the same session record. Exact retries
return that verified lifecycle with `replayed: true`. A changed request under the same id fails with
`PERSONA_LIFECYCLE_REQUEST_CONFLICT`; a partial session without a complete receipt fails recovery-required
instead of launching a duplicate.
`PersonaRuntimeQueryReceipt` is a bounded projection of the durable runtime files, not another truth store.
It filters by the caller's expected persona and canonical repository, validates each matching event chain,
and returns at most 100 newest session summaries. Dormant sessions expose no active organ. The receipt keeps

View file

@ -58,6 +58,15 @@ sense. It then owns one complete session from wake through the existing organ tr
the wake receipt and the typed completion receipt. It cannot express or activate `EXECUTION_LIMB`, does not
add a scheduler or second state store, and runs the complete lifecycle on the blocking task pool.
The coordinator also requires a caller-stable lifecycle request id. Persona id plus request id derives one
deterministic session id, while a credential-free fingerprint binds the canonical repository, expected Git
head, wake identity, attribution, organ, and semantic operation. A completed request persists its typed
lifecycle receipt beside the existing session record and cross-binds its hash into that record. An identical
retry verifies the session event chain, dormant state, released lease, fingerprint, and receipt hash before
replaying the same lifecycle value without invoking an organ or committing Git again. Reusing the request id
for different semantics is a conflict. A session without a complete bound receipt is recovery-required and
is never treated as permission to start a second lifecycle.
`query_persona_code_channel_runtime` is the bounded read model for later projection surfaces. The caller must
name one exact persona and canonical repository and may request at most 100 sessions. The kernel reads the
existing session records and event journals directly, verifies every returned hash chain, sorts by the last

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