feat(pncc): replay terminal lifecycle failures

Human-Responsibility: ICE-GL∞ / 冰朔
Persona-Author: ICE-P-ZY001 / 铸渊
Execution-Runtime: Codex macOS
Development-ID: DEV-20260810-014
Authorization-Scope: GH-PNCC local runtime and REPO-014 publication
Source-Anchor: UI and execution limb deferred
This commit is contained in:
铸渊 / ICE-P-ZY001 2026-08-11 04:09:29 +08:00
commit abc7dbd625
10 changed files with 381 additions and 43 deletions

View file

@ -172,6 +172,7 @@ 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
incomplete_idempotent_request_inspection_and_safe_receipt_recovery_source_implemented: 100
idempotent_terminal_failure_receipt_and_replay_source_implemented: 100
general_purpose_persona_runtime_implemented: 0
human_live_projection_implemented: 0
hololake_integrated: 0
@ -189,6 +190,11 @@ runtime_health: 0
终止未提交会话,或为已由该人格完成但尚未休眠的单一检查点提交补齐收尾。脏仓库、未知 Git
推进、错误归因和损坏事件链一律保留主锁并要求人工审查;恢复器不会按时间猜测主控已死亡。
生命周期命令的已闭合失败也使用同一会话目录、同一回执文件和同一绑定恢复路径。回执只保留
稳定机器错误码、终态事件哈希与结构化归因,不落盘模型提供方原始错误细节;同一语义请求在
终态、Git、租约、事件链和回执哈希全部复核后只重放原失败不再次启动模型或器官。未完成
闭合、脏仓库或仍持有租约的失败不能被伪装成终态回执。
人格器官现已拥有机器可读类型合同。系统能够在不唤醒人格、不取得主锁、不运行模型的情况下,
检查 `FACT_SENSE``MEMORY_METABOLISM``EXECUTION_LIMB` 的固定模式、输入输出 schema、派生权限、
模型推理边界、现实动作边界和真实可激活状态。只读事实感官与独立记忆代谢器官可激活;后者不

View file

@ -103,6 +103,13 @@ receipt beside a verified dormant session with no lease and all three binding fi
verifier. Missing receipts, partial bindings, active leases, damaged chains, or changed semantics remain
recovery/manual-review states and cannot be converted into a successful receipt.
The same persisted receipt envelope also represents a verified terminal failure. Its `outcome` is `FAILED`,
its successful lifecycle payload is null, and `failure` carries only the stable error code, terminal event
hash, identity, repository, and attribution. The runtime hashes that structured failure payload, never the
raw provider diagnostic. Replay and safe binding recovery require `DORMANT_AFTER_FAILURE`, a final `DORMANT`
event, a released lease, and an unchanged clean Git head. A failure that cannot prove those conditions is not
terminal and remains recovery-required.
`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

@ -73,6 +73,14 @@ but unbound, and session-recovery states from the same session evidence. The rec
only the receipt-present/unbound window after rechecking the receipt hash, identity, event chain, dormancy,
and released lease. It never fabricates a receipt for an earlier interruption or repairs partial bindings.
A lifecycle that reaches the kernel's verified terminal failure closure now uses that same receipt file and
two-write binding protocol. The persisted value contains only a stable machine error code, terminal event
hash, repository/persona identity, and structured attribution; raw provider or model error detail is not
stored. Exact retries verify `DORMANT_AFTER_FAILURE`, the final `DORMANT` event, released lease, unchanged
clean Git head, semantic fingerprint, receipt hash, and session bindings before returning the same failure
with `replayed: true`. Active, dirty, partially closed, or otherwise unproven failures retain the original
recovery path and cannot be converted into a terminal receipt.
`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

@ -328,7 +328,21 @@ pub struct PersonaLifecycleCommandReceipt {
pub request_id: String,
pub request_fingerprint: String,
pub replayed: bool,
pub outcome: &'static str,
pub lifecycle: serde_json::Value,
pub failure: Option<PersonaLifecycleFailureReceipt>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PersonaLifecycleFailureReceipt {
pub schema: String,
pub session_id: String,
pub persona_id: String,
pub repository_path: String,
pub error_code: String,
pub terminal_event_hash: String,
pub attribution: PersonaAttribution,
}
#[derive(Debug, Serialize)]
@ -353,7 +367,15 @@ struct PersistedPersonaLifecycleReceipt {
request_id: String,
request_fingerprint: String,
lifecycle_receipt_hash: String,
#[serde(default = "completed_lifecycle_outcome")]
outcome: String,
lifecycle: serde_json::Value,
#[serde(default)]
failure: Option<PersonaLifecycleFailureReceipt>,
}
fn completed_lifecycle_outcome() -> String {
"COMPLETED".into()
}
#[derive(Clone, Debug, Deserialize)]
@ -2596,6 +2618,66 @@ fn lifecycle_request_identity(
Ok((request_id, request_fingerprint, session_id, repository))
}
fn persisted_lifecycle_payload_bytes(
persisted: &PersistedPersonaLifecycleReceipt,
) -> Result<Vec<u8>, String> {
match persisted.outcome.as_str() {
"COMPLETED" if persisted.failure.is_none() && !persisted.lifecycle.is_null() => {
serde_json::to_vec(&persisted.lifecycle)
.map_err(|error| format!("PERSONA_LIFECYCLE_RECEIPT_HASH_INPUT_FAILED: {error}"))
}
"FAILED" if persisted.lifecycle.is_null() => serde_json::to_vec(
persisted
.failure
.as_ref()
.ok_or("PERSONA_LIFECYCLE_FAILURE_RECEIPT_MISSING")?,
)
.map_err(|error| format!("PERSONA_LIFECYCLE_RECEIPT_HASH_INPUT_FAILED: {error}")),
_ => Err("PERSONA_LIFECYCLE_RECEIPT_OUTCOME_INVALID".into()),
}
}
fn persisted_lifecycle_identity_matches(
persisted: &PersistedPersonaLifecycleReceipt,
session_id: &str,
persona_id: &str,
) -> bool {
match persisted.outcome.as_str() {
"COMPLETED" => {
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(persona_id)
}
"FAILED" => persisted.failure.as_ref().is_some_and(|failure| {
failure.schema == "hololake.pncc-lifecycle-failure-receipt/v1"
&& failure.session_id == session_id
&& failure.persona_id == persona_id
}),
_ => false,
}
}
fn lifecycle_failure_code(error: &str) -> String {
let candidate = error.split([':', ';']).next().unwrap_or_default().trim();
if !candidate.is_empty()
&& candidate.len() <= MAX_ID_BYTES
&& candidate
.bytes()
.all(|byte| byte.is_ascii_uppercase() || byte.is_ascii_digit() || byte == b'_')
{
candidate.into()
} else {
"PERSONA_LIFECYCLE_OPERATION_FAILED".into()
}
}
fn verified_lifecycle_replay(
runtime_root: &Path,
request_id: &str,
@ -2621,9 +2703,9 @@ fn verified_lifecycle_replay(
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 {
if hex_digest(&persisted_lifecycle_payload_bytes(&persisted)?)
!= persisted.lifecycle_receipt_hash
{
return Err("PERSONA_LIFECYCLE_RECEIPT_HASH_MISMATCH".into());
}
let record = load_session_record(runtime_root, session_id)?;
@ -2639,23 +2721,18 @@ fn verified_lifecycle_replay(
return Err("PERSONA_LIFECYCLE_RECEIPT_SESSION_BINDING_MISMATCH".into());
}
let events = verify_event_journal(runtime_root, &record)?;
if record.state != "DORMANT"
let expected_state = if persisted.outcome == "FAILED" {
"DORMANT_AFTER_FAILURE"
} else {
"DORMANT"
};
if record.state != expected_state
|| 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())
{
if !persisted_lifecycle_identity_matches(&persisted, session_id, &record.persona_id) {
return Err("PERSONA_LIFECYCLE_RECEIPT_IDENTITY_MISMATCH".into());
}
Ok(Some(PersonaLifecycleCommandReceipt {
@ -2663,7 +2740,13 @@ fn verified_lifecycle_replay(
request_id: request_id.to_string(),
request_fingerprint: request_fingerprint.to_string(),
replayed: true,
outcome: if persisted.outcome == "FAILED" {
"FAILED"
} else {
"COMPLETED"
},
lifecycle: persisted.lifecycle,
failure: persisted.failure,
}))
}
@ -2726,22 +2809,12 @@ fn inspect_lifecycle_request_at(
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 {
if hex_digest(&persisted_lifecycle_payload_bytes(&persisted)?)
!= persisted.lifecycle_receipt_hash
{
return Err("PERSONA_LIFECYCLE_RECEIPT_HASH_MISMATCH".into());
}
if persisted
.lifecycle
.get("sessionId")
.and_then(serde_json::Value::as_str)
!= Some(session_id.as_str())
|| persisted
.lifecycle
.get("personaId")
.and_then(serde_json::Value::as_str)
!= Some(record.persona_id.as_str())
{
if !persisted_lifecycle_identity_matches(&persisted, &session_id, &record.persona_id) {
return Err("PERSONA_LIFECYCLE_RECEIPT_IDENTITY_MISMATCH".into());
}
let complete = record.request_id.as_deref() == Some(request_id.as_str())
@ -2751,7 +2824,12 @@ fn inspect_lifecycle_request_at(
let unbound = record.request_id.is_none()
&& record.request_fingerprint.is_none()
&& record.lifecycle_receipt_hash.is_none();
let safely_dormant = record.state == "DORMANT"
let expected_state = if persisted.outcome == "FAILED" {
"DORMANT_AFTER_FAILURE"
} else {
"DORMANT"
};
let safely_dormant = record.state == expected_state
&& events.last().map(|event| event.kind.as_str()) == Some("DORMANT")
&& !lease_held;
let (status, safe_to_bind_receipt) = if complete && safely_dormant {
@ -2814,6 +2892,83 @@ fn recover_lifecycle_request_at(
.ok_or_else(|| "PERSONA_LIFECYCLE_RECEIPT_RECOVERY_LOST_SESSION".into())
}
fn persist_terminal_lifecycle_failure(
runtime_root: &Path,
request_id: &str,
request_fingerprint: &str,
session_id: &str,
repository: &Path,
error: &str,
) -> Result<Option<PersonaLifecycleCommandReceipt>, String> {
let session_dir = session_directory(runtime_root, session_id)?;
if !session_dir.exists() {
return Ok(None);
}
let mut record = load_session_record(runtime_root, session_id)?;
let recorded_repository = Path::new(&record.repository_path)
.canonicalize()
.map_err(|cause| format!("PERSONA_REPOSITORY_UNAVAILABLE: {cause}"))?;
if recorded_repository != repository {
return Err("PERSONA_LIFECYCLE_REQUEST_SESSION_IDENTITY_MISMATCH".into());
}
let events = verify_event_journal(runtime_root, &record)?;
let terminal_event = events.last().ok_or("PERSONA_EVENT_CHAIN_EMPTY")?;
let (_, observed_head) = exact_repository(repository)?;
let safely_terminal = record.state == "DORMANT_AFTER_FAILURE"
&& terminal_event.kind == "DORMANT"
&& !primary_lease_held_by_session(runtime_root, &record)?
&& observed_head == record.git_head
&& require_clean_repository(repository).is_ok();
if !safely_terminal {
return Ok(None);
}
if record.request_id.is_some()
|| record.request_fingerprint.is_some()
|| record.lifecycle_receipt_hash.is_some()
{
return Err("PERSONA_LIFECYCLE_FAILURE_BINDING_ALREADY_PRESENT".into());
}
let failure = PersonaLifecycleFailureReceipt {
schema: "hololake.pncc-lifecycle-failure-receipt/v1".into(),
session_id: session_id.into(),
persona_id: record.persona_id.clone(),
repository_path: record.repository_path.clone(),
error_code: lifecycle_failure_code(error),
terminal_event_hash: terminal_event.event_hash.clone(),
attribution: record.attribution.clone(),
};
let lifecycle_receipt_hash = hex_digest(
&serde_json::to_vec(&failure)
.map_err(|cause| format!("PERSONA_LIFECYCLE_RECEIPT_HASH_INPUT_FAILED: {cause}"))?,
);
write_json_file(
&session_dir.join("lifecycle-receipt.json"),
&PersistedPersonaLifecycleReceipt {
schema: "hololake.pncc-persisted-lifecycle-receipt/v1".into(),
request_id: request_id.into(),
request_fingerprint: request_fingerprint.into(),
lifecycle_receipt_hash: lifecycle_receipt_hash.clone(),
outcome: "FAILED".into(),
lifecycle: serde_json::Value::Null,
failure: Some(failure.clone()),
},
"PERSONA_LIFECYCLE_RECEIPT",
)?;
record.request_id = Some(request_id.into());
record.request_fingerprint = Some(request_fingerprint.into());
record.lifecycle_receipt_hash = Some(lifecycle_receipt_hash);
write_session_record(runtime_root, &record)?;
Ok(Some(PersonaLifecycleCommandReceipt {
schema: "hololake.pncc-lifecycle-command-receipt/v1",
request_id: request_id.into(),
request_fingerprint: request_fingerprint.into(),
replayed: false,
outcome: "FAILED",
lifecycle: serde_json::Value::Null,
failure: Some(failure),
}))
}
fn run_idempotent_lifecycle_at<F>(
runtime_root: &Path,
input: PersonaLifecycleRunInput,
@ -2835,14 +2990,29 @@ where
)? {
return Ok(replay);
}
let lifecycle = run_lifecycle_at(
let lifecycle = match run_lifecycle_at(
runtime_root,
input,
&session_id,
wake_timestamp,
operation_timestamp,
run_fact,
)?;
) {
Ok(lifecycle) => lifecycle,
Err(error) => {
if let Some(receipt) = persist_terminal_lifecycle_failure(
runtime_root,
&request_id,
&request_fingerprint,
&session_id,
&repository,
&error,
)? {
return Ok(receipt);
}
return Err(error);
}
};
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)
@ -2861,7 +3031,9 @@ where
request_id: request_id.clone(),
request_fingerprint: request_fingerprint.clone(),
lifecycle_receipt_hash: lifecycle_receipt_hash.clone(),
outcome: "COMPLETED".into(),
lifecycle: lifecycle.clone(),
failure: None,
},
"PERSONA_LIFECYCLE_RECEIPT",
)?;
@ -2874,7 +3046,9 @@ where
request_id,
request_fingerprint,
replayed: false,
outcome: "COMPLETED",
lifecycle,
failure: None,
})
}
@ -3680,6 +3854,96 @@ mod tests {
assert!(error.contains("PERSONA_LIFECYCLE_RECEIPT_BINDING_NOT_SAFE"));
}
#[test]
fn persists_and_replays_the_same_structured_terminal_failure_without_rerunning_the_organ() {
let repo = persona_repo();
let runtime = tempfile::TempDir::new().unwrap();
let input = lifecycle_fact_input(repo.path());
let original_head = head(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, |_, _| {
Err("provider-secret-detail-must-not-persist".into())
})
},
)
.unwrap();
let replay = run_idempotent_lifecycle_at(
runtime.path(),
input,
"2026-08-11T00:00:02.000Z",
"2026-08-11T00:00:03.000Z",
|_, _, _| panic!("a terminal failure replay must not rerun the organ"),
)
.unwrap();
assert_eq!(first.outcome, "FAILED");
assert!(!first.replayed);
assert!(first.lifecycle.is_null());
assert_eq!(
first.failure.as_ref().unwrap().error_code,
"PERSONA_MODEL_INFERENCE_FAILED"
);
assert_eq!(replay.outcome, "FAILED");
assert!(replay.replayed);
assert_eq!(
replay.failure.as_ref().unwrap().terminal_event_hash,
first.failure.as_ref().unwrap().terminal_event_hash
);
assert_eq!(head(repo.path()), original_head);
let persisted = fs::read_to_string(
session_directory(
runtime.path(),
first.failure.as_ref().unwrap().session_id.as_str(),
)
.unwrap()
.join("lifecycle-receipt.json"),
)
.unwrap();
assert!(!persisted.contains("provider-secret-detail-must-not-persist"));
assert!(!runtime.path().join("leases/ICE-P-ZY001.json").exists());
}
#[test]
fn safely_recovers_an_unbound_terminal_failure_receipt() {
let repo = persona_repo();
let runtime = tempfile::TempDir::new().unwrap();
let input = lifecycle_fact_input(repo.path());
let failed = 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, |_, _| {
Err("bounded provider failure".into())
})
},
)
.unwrap();
let session_id = failed.failure.as_ref().unwrap().session_id.clone();
let mut record = load_session_record(runtime.path(), &session_id).unwrap();
record.request_id = None;
record.request_fingerprint = None;
record.lifecycle_receipt_hash = None;
write_session_record(runtime.path(), &record).unwrap();
let inspection = inspect_lifecycle_request_at(runtime.path(), &input).unwrap();
assert_eq!(inspection.status, "SAFE_BIND_PERSISTED_RECEIPT");
assert!(inspection.safe_to_bind_receipt);
let recovered = recover_lifecycle_request_at(runtime.path(), &input).unwrap();
assert_eq!(recovered.outcome, "FAILED");
assert!(recovered.replayed);
assert_eq!(
recovered.failure.unwrap().error_code,
"PERSONA_MODEL_INFERENCE_FAILED"
);
}
#[test]
fn independently_promotes_only_the_current_verified_structured_checkpoint() {
let repo = persona_repo();