feat: recover interrupted PNCC sessions safely

GuangHu-Human-Responsibility: ICE-GL∞ / 冰朔
GuangHu-Persona-Cognitive-Author: ICE-P-ZY001 / 铸渊
GuangHu-Execution-Runtime: Codex thread 019febf6-3902-70f3-9d82-74c9680b0c7e
GuangHu-Development-ID: DEV-20260810-014
GuangHu-Authorization-Scope: LOCAL_PNCC_RUNTIME_DEVELOPMENT_AND_PUBLICATION
This commit is contained in:
铸渊 / ICE-P-ZY001 2026-08-11 01:21:28 +08:00
commit 5b042a65fe
11 changed files with 657 additions and 11 deletions

View file

@ -18,7 +18,7 @@
>
> 开发编号:`DEV-20260810-014`
>
> 状态:`CURRENT_FIRST_PRODUCT_CORE · FIRST_READ_ONLY_RUNTIME_CYCLE_SOURCE_IMPLEMENTED_100 · DESKTOP_NOT_INTEGRATED_0`
> 状态:`CURRENT_FIRST_PRODUCT_CORE · READ_ONLY_RUNTIME_AND_INTERRUPTED_SESSION_RECOVERY_SOURCE_IMPLEMENTED_100 · DESKTOP_NOT_INTEGRATED_0`
## 1 · 产品裁决
@ -164,6 +164,7 @@ git_engine_selected: 100
first_read_only_runtime_cycle_source_implemented: 100
persona_git_identity_commit_implemented: 100
semantic_attribution_first_cycle_implemented: 100
interrupted_session_inspection_and_recovery_source_implemented: 100
general_purpose_persona_runtime_implemented: 0
human_live_projection_implemented: 0
hololake_integrated: 0
@ -176,3 +177,7 @@ runtime_health: 0
器官文件白名单、真实模型适配调用、结构化事实收执、检查点提升、人格 Git 作者提交、事件链、
器官释放与休眠。该事实由单元测试验证,尚未构建桌面制品、接入人类投影、发布安装包或部署
在线服务;因此不能从源码完成推导出桌面可用或线上健康。
当前源码也已实现中断会话检查与安全恢复:只在事件链、租约、仓库和人格归因共同证明安全时
终止未提交会话,或为已由该人格完成但尚未休眠的单一检查点提交补齐收尾。脏仓库、未知 Git
推进、错误归因和损坏事件链一律保留主锁并要求人工审查;恢复器不会按时间猜测主控已死亡。

View file

@ -46,6 +46,14 @@ declared evidence paths, promotes the checkpoint with the persona's Git identity
The Git commit still names the human responsibility subject in a dedicated trailer, so authorship and legal
responsibility remain visible without presenting the human as the code's cognitive author.
Session control is explicit and evidence-bound. `inspect_persona_code_channel_session` classifies an exact
session as `SAFE_ABORT_UNCOMMITTED`, `SAFE_FINALIZE_COMMITTED`, `SAFE_RELEASE_DORMANT_LEASE`,
`ALREADY_DORMANT`, or `MANUAL_REVIEW_REQUIRED`. The recovery command accepts only a proven safe state. For
active interruptions it appends recovery and dormancy events to the same journal, updates the session record,
and then removes the exact lease. It can also remove a same-session lease left behind after a fully recorded
dormancy transition. It does not use elapsed time as proof that a primary is dead and does not clean, reset,
or overwrite a dirty persona repository.
## `HoloLakeUiPlugin`
A versioned, declarative presentation package for one host-owned semantic surface. It contains a manifest,

View file

@ -29,6 +29,14 @@ commit trailers preserve the separate human responsibility subject and persona c
tested source implementation of one read-only lifecycle cycle; packaged desktop integration, publication,
deployment, and online health remain separate facts.
`inspect_persona_code_channel_session` and `recover_persona_code_channel_session` add fail-closed interrupted
session administration. Inspection re-verifies the session identity, canonical repository, event hash chain,
lease owner, current Git head and work-tree cleanliness. Recovery never steals or expires a lease by age. It
releases the primary only when the repository is clean and the state is provably either an uncommitted
interruption at the recorded head or a single persona-attributed checkpoint commit whose parent, manifest,
checkpoint identity and commit trailers all match the session. Dirty repositories, unknown Git advances,
broken journals and attribution mismatches retain the lease and require manual review.
## User-node sovereignty
HoloLake has no platform-hosted user runtime. Each human has one canonical, independently operated node:

View file

@ -525,6 +525,8 @@ macro_rules! app_invoke_handler {
guanghu_living_system::guanghu_living_system_plan,
persona_code_channel::prepare_persona_code_channel_wake,
persona_code_channel::run_persona_code_channel_fact_task,
persona_code_channel::inspect_persona_code_channel_session,
persona_code_channel::recover_persona_code_channel_session,
guanghu_router::guanghu_router_connect,
guanghu_router::guanghu_router_disconnect,
guanghu_router::guanghu_router_approve,

View file

@ -159,6 +159,50 @@ pub struct PersonaFactTaskReceipt {
pub attribution: PersonaAttribution,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PersonaSessionControlInput {
pub session_id: String,
pub expected_persona_id: String,
pub expected_repository_path: String,
}
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PersonaSessionInspectionReceipt {
pub schema: &'static str,
pub session_id: String,
pub persona_id: String,
pub repository_path: String,
pub recorded_git_head: String,
pub observed_git_head: String,
pub repository_clean: bool,
pub session_state: String,
pub primary_lease_held: bool,
pub event_chain_valid: bool,
pub last_event: String,
pub recovery_disposition: &'static str,
pub events: Vec<PersonaLifecycleEvent>,
pub attribution: PersonaAttribution,
}
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PersonaSessionRecoveryReceipt {
pub schema: &'static str,
pub receipt_id: String,
pub session_id: String,
pub persona_id: String,
pub previous_session_state: String,
pub recovered_git_head: String,
pub checkpoint_path: String,
pub runtime_state: &'static str,
pub recovery_action: &'static str,
pub primary_lease_released: bool,
pub events: Vec<PersonaLifecycleEvent>,
pub attribution: PersonaAttribution,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
struct PersonaSessionRecord {
@ -543,7 +587,7 @@ fn load_session_record(
Ok(record)
}
fn read_event_journal(
fn verify_event_journal(
runtime_root: &Path,
record: &PersonaSessionRecord,
) -> Result<Vec<PersonaLifecycleEvent>, String> {
@ -583,6 +627,17 @@ fn read_event_journal(
}
events.push(event);
}
if events.is_empty() {
return Err("PERSONA_EVENT_CHAIN_EMPTY".into());
}
Ok(events)
}
fn read_ready_event_journal(
runtime_root: &Path,
record: &PersonaSessionRecord,
) -> Result<Vec<PersonaLifecycleEvent>, String> {
let events = verify_event_journal(runtime_root, record)?;
if events.len() != 3 || events.last().map(|event| event.kind.as_str()) != Some("ORGAN_ACTIVE") {
return Err("PERSONA_SESSION_NOT_READY_FOR_FACT_TASK".into());
}
@ -647,6 +702,60 @@ fn require_primary_lease(
Ok(path)
}
fn primary_lease_held_by_session(
runtime_root: &Path,
record: &PersonaSessionRecord,
) -> Result<bool, String> {
let path = runtime_root.join("leases").join(format!(
"{}.json",
validated_id("PERSONA_ID", &record.persona_id)?
));
let bytes = match fs::read(path) {
Ok(bytes) => bytes,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
Err(error) => return Err(format!("PERSONA_PRIMARY_LEASE_READ_FAILED: {error}")),
};
let value: serde_json::Value = serde_json::from_slice(&bytes)
.map_err(|error| format!("PERSONA_PRIMARY_LEASE_INVALID: {error}"))?;
Ok(value.get("sessionId").and_then(serde_json::Value::as_str)
== Some(record.session_id.as_str()))
}
fn repository_is_clean(repository: &Path) -> Result<bool, String> {
let status = crate::git::git_command_at(repository)
.and_then(|mut command| {
command
.args(["status", "--porcelain", "--untracked-files=all"])
.output()
})
.map_err(|error| format!("PERSONA_GIT_STATUS_FAILED: {error}"))?;
Ok(stdout("GIT_STATUS", status)?.is_empty())
}
fn validate_session_control(
runtime_root: &Path,
input: &PersonaSessionControlInput,
) -> Result<(PersonaSessionRecord, PathBuf, String, bool), String> {
let session_id = validated_id("SESSION_ID", &input.session_id)?;
let expected_persona_id = validated_id("EXPECTED_PERSONA_ID", &input.expected_persona_id)?;
let record = load_session_record(runtime_root, &session_id)?;
if record.persona_id != expected_persona_id {
return Err("PERSONA_ID_MISMATCH".into());
}
let expected_repository = Path::new(&input.expected_repository_path)
.canonicalize()
.map_err(|error| format!("PERSONA_REPOSITORY_UNAVAILABLE: {error}"))?;
let recorded_repository = Path::new(&record.repository_path)
.canonicalize()
.map_err(|error| format!("PERSONA_REPOSITORY_UNAVAILABLE: {error}"))?;
if expected_repository != recorded_repository {
return Err("PERSONA_REPOSITORY_MISMATCH".into());
}
let (repository, observed_head) = exact_repository(&recorded_repository)?;
let clean = repository_is_clean(&repository)?;
Ok((record, repository, observed_head, clean))
}
fn read_fact_context(repository: &Path, paths: &[String]) -> Result<String, String> {
if paths.is_empty() {
return Err("FACT_ORGAN_HAS_NO_DECLARED_PATHS".into());
@ -976,6 +1085,278 @@ fn finish_failed_session(
let _ = fs::remove_file(lease_path);
}
fn validate_recoverable_persona_commit(
repository: &Path,
record: &PersonaSessionRecord,
observed_head: &str,
) -> Result<String, String> {
let parent = git_output(
repository,
&["rev-parse", &format!("{observed_head}^")],
"PERSONA_RECOVERY_PARENT",
)?;
if parent != record.git_head {
return Err("PERSONA_RECOVERY_UNKNOWN_GIT_ADVANCE".into());
}
let checkpoint_relative = format!(
".hololake/persona/checkpoints/{}.json",
validated_id("SESSION_ID", &record.session_id)?
);
let checkpoint_path = repository_file(repository, &checkpoint_relative)?;
let checkpoint: serde_json::Value = serde_json::from_slice(
&fs::read(checkpoint_path)
.map_err(|error| format!("PERSONA_CHECKPOINT_READ_FAILED: {error}"))?,
)
.map_err(|error| format!("PERSONA_CHECKPOINT_INVALID: {error}"))?;
if checkpoint.get("schema").and_then(serde_json::Value::as_str)
!= Some("hololake.persona-checkpoint/v1")
|| checkpoint
.get("sessionId")
.and_then(serde_json::Value::as_str)
!= Some(record.session_id.as_str())
|| checkpoint
.get("personaId")
.and_then(serde_json::Value::as_str)
!= Some(record.persona_id.as_str())
|| checkpoint
.get("previousGitHead")
.and_then(serde_json::Value::as_str)
!= Some(record.git_head.as_str())
{
return Err("PERSONA_RECOVERY_CHECKPOINT_MISMATCH".into());
}
let manifest = load_manifest(repository)?;
if manifest.persona_id != record.persona_id
|| manifest.current_checkpoint != checkpoint_relative
{
return Err("PERSONA_RECOVERY_MANIFEST_MISMATCH".into());
}
validate_git_identity(&manifest.git_identity)?;
let author = git_output(
repository,
&["show", "-s", "--format=%an%n%ae%n%B", observed_head],
"PERSONA_RECOVERY_COMMIT_METADATA",
)?;
let mut lines = author.lines();
if lines.next() != Some(manifest.git_identity.author_name.as_str())
|| lines.next() != Some(manifest.git_identity.author_email.as_str())
|| !author.contains(&format!(
"GuangHu-Human-Responsibility: {}",
record.attribution.human_responsibility_subject
))
|| !author.contains(&format!(
"GuangHu-Persona-Cognitive-Author: {}",
record.attribution.persona_cognitive_author
))
|| !author.contains(&format!(
"GuangHu-Development-ID: {}",
record.attribution.development_id
))
{
return Err("PERSONA_RECOVERY_COMMIT_ATTRIBUTION_MISMATCH".into());
}
Ok(checkpoint_relative)
}
fn recovery_disposition(
repository: &Path,
record: &PersonaSessionRecord,
observed_head: &str,
clean: bool,
lease_held: bool,
last_event: &str,
) -> &'static str {
if record.state.starts_with("DORMANT") {
return if lease_held && clean && observed_head == record.git_head && last_event == "DORMANT"
{
"SAFE_RELEASE_DORMANT_LEASE"
} else if lease_held {
"MANUAL_REVIEW_REQUIRED"
} else {
"ALREADY_DORMANT"
};
}
if !lease_held
|| !clean
|| !matches!(
record.state.as_str(),
"BOUND_NOT_INFERENCING" | "INFERENCING"
)
{
return "MANUAL_REVIEW_REQUIRED";
}
if observed_head == record.git_head {
return "SAFE_ABORT_UNCOMMITTED";
}
if validate_recoverable_persona_commit(repository, record, observed_head).is_ok() {
"SAFE_FINALIZE_COMMITTED"
} else {
"MANUAL_REVIEW_REQUIRED"
}
}
fn inspect_session_at(
runtime_root: &Path,
input: PersonaSessionControlInput,
) -> Result<PersonaSessionInspectionReceipt, String> {
let (record, repository, observed_head, clean) =
validate_session_control(runtime_root, &input)?;
let events = verify_event_journal(runtime_root, &record)?;
let lease_held = primary_lease_held_by_session(runtime_root, &record)?;
let last_event = events
.last()
.expect("verified non-empty event chain")
.kind
.clone();
let disposition = recovery_disposition(
&repository,
&record,
&observed_head,
clean,
lease_held,
&last_event,
);
Ok(PersonaSessionInspectionReceipt {
schema: "hololake.pncc-session-inspection/v1",
session_id: record.session_id,
persona_id: record.persona_id,
repository_path: repository.to_string_lossy().into_owned(),
recorded_git_head: record.git_head,
observed_git_head: observed_head,
repository_clean: clean,
session_state: record.state,
primary_lease_held: lease_held,
event_chain_valid: true,
last_event,
recovery_disposition: disposition,
events,
attribution: record.attribution,
})
}
fn recover_session_at(
runtime_root: &Path,
input: PersonaSessionControlInput,
timestamp: &str,
) -> Result<PersonaSessionRecoveryReceipt, String> {
let timestamp = validated_text("TIMESTAMP", timestamp, MAX_ID_BYTES)?;
let (mut record, repository, observed_head, clean) =
validate_session_control(runtime_root, &input)?;
let lease_path = require_primary_lease(runtime_root, &record)?;
if !clean {
return Err("PERSONA_RECOVERY_REFUSED_DIRTY_REPOSITORY".into());
}
let mut events = verify_event_journal(runtime_root, &record)?;
let previous_state = record.state.clone();
if record.state.starts_with("DORMANT") {
if observed_head != record.git_head
|| events.last().map(|event| event.kind.as_str()) != Some("DORMANT")
{
return Err("PERSONA_DORMANT_LEASE_REQUIRES_MANUAL_REVIEW".into());
}
fs::remove_file(&lease_path)
.map_err(|error| format!("PERSONA_PRIMARY_LEASE_RELEASE_FAILED: {error}"))?;
let receipt_hash = &events
.last()
.expect("verified non-empty event chain")
.event_hash;
return Ok(PersonaSessionRecoveryReceipt {
schema: "hololake.pncc-session-recovery-receipt/v1",
receipt_id: format!("PNCC-RECOVERY-{}", &receipt_hash[..20]),
session_id: record.session_id,
persona_id: record.persona_id,
previous_session_state: previous_state,
recovered_git_head: observed_head,
checkpoint_path: record.checkpoint_path,
runtime_state: "DORMANT_AFTER_RECOVERY",
recovery_action: "RELEASED_DORMANT_LEASE",
primary_lease_released: true,
events,
attribution: record.attribution,
});
}
if !matches!(
record.state.as_str(),
"BOUND_NOT_INFERENCING" | "INFERENCING"
) {
return Err("PERSONA_SESSION_STATE_REQUIRES_MANUAL_REVIEW".into());
}
let (action, checkpoint_path) = if observed_head == record.git_head {
(
"ABORTED_UNCOMMITTED_SESSION",
record.checkpoint_path.clone(),
)
} else {
(
"FINALIZED_PERSONA_COMMIT",
validate_recoverable_persona_commit(&repository, &record, &observed_head)?,
)
};
let organ = record.active_organ.clone();
append_event(
runtime_root,
&record,
&mut events,
"SESSION_RECOVERY_REQUESTED",
&observed_head,
&timestamp,
Some(&organ),
)?;
if action == "FINALIZED_PERSONA_COMMIT"
&& !events
.iter()
.any(|event| event.kind == "CHECKPOINT_COMMITTED")
{
append_event(
runtime_root,
&record,
&mut events,
"CHECKPOINT_COMMITTED",
&observed_head,
&timestamp,
Some(&organ),
)?;
}
append_event(
runtime_root,
&record,
&mut events,
"ORGAN_RELEASED",
&observed_head,
&timestamp,
Some(&organ),
)?;
let dormant = append_event(
runtime_root,
&record,
&mut events,
"DORMANT",
&observed_head,
&timestamp,
None,
)?;
record.git_head = observed_head.clone();
record.checkpoint_path = checkpoint_path.clone();
record.state = "DORMANT_AFTER_RECOVERY".into();
write_session_record(runtime_root, &record)?;
fs::remove_file(&lease_path)
.map_err(|error| format!("PERSONA_PRIMARY_LEASE_RELEASE_FAILED: {error}"))?;
Ok(PersonaSessionRecoveryReceipt {
schema: "hololake.pncc-session-recovery-receipt/v1",
receipt_id: format!("PNCC-RECOVERY-{}", &dormant.event_hash[..20]),
session_id: record.session_id,
persona_id: record.persona_id,
previous_session_state: previous_state,
recovered_git_head: observed_head,
checkpoint_path,
runtime_state: "DORMANT_AFTER_RECOVERY",
recovery_action: action,
primary_lease_released: true,
events,
attribution: record.attribution,
})
}
fn run_fact_task_at<F>(
runtime_root: &Path,
input: PersonaFactTaskInput,
@ -1014,7 +1395,7 @@ where
return Err("PERSONA_GIT_CHANGED_AFTER_WAKE".into());
}
require_clean_repository(&exact_repository)?;
let mut events = read_event_journal(runtime_root, &record)?;
let mut events = read_ready_event_journal(runtime_root, &record)?;
let inference_event = append_event(
runtime_root,
&record,
@ -1252,6 +1633,23 @@ pub async fn run_persona_code_channel_fact_task(
.map_err(|error| format!("PERSONA_FACT_TASK_JOIN_FAILED: {error}"))?
}
#[tauri::command]
pub fn inspect_persona_code_channel_session(
input: PersonaSessionControlInput,
) -> Result<PersonaSessionInspectionReceipt, String> {
let runtime_root = crate::app_config::preferred_app_config_path("pncc-runtime")?;
inspect_session_at(&runtime_root, input)
}
#[tauri::command]
pub fn recover_persona_code_channel_session(
input: PersonaSessionControlInput,
) -> Result<PersonaSessionRecoveryReceipt, String> {
let runtime_root = crate::app_config::preferred_app_config_path("pncc-runtime")?;
let timestamp = Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true);
recover_session_at(&runtime_root, input, &timestamp)
}
#[cfg(test)]
mod tests {
use super::*;
@ -1378,6 +1776,14 @@ mod tests {
}
}
fn session_control(repo: &Path, session_id: &str) -> PersonaSessionControlInput {
PersonaSessionControlInput {
session_id: session_id.into(),
expected_persona_id: "ICE-P-ZY001".into(),
expected_repository_path: repo.to_string_lossy().into_owned(),
}
}
#[test]
fn prepares_exact_persona_git_wake_with_hash_chained_events() {
let repo = persona_repo();
@ -1613,4 +2019,162 @@ mod tests {
let session = load_session_record(runtime.path(), "PNCC-SESSION-REJECTED").unwrap();
assert_eq!(session.state, "DORMANT_AFTER_FAILURE");
}
#[test]
fn inspects_and_safely_aborts_an_uncommitted_interrupted_session() {
let repo = persona_repo();
let runtime = tempfile::TempDir::new().unwrap();
prepare_wake_at(
runtime.path(),
wake_input(repo.path()),
"PNCC-SESSION-INTERRUPTED",
"2026-08-11T00:00:00.000Z",
)
.unwrap();
let inspection = inspect_session_at(
runtime.path(),
session_control(repo.path(), "PNCC-SESSION-INTERRUPTED"),
)
.unwrap();
assert_eq!(inspection.recovery_disposition, "SAFE_ABORT_UNCOMMITTED");
assert!(inspection.repository_clean);
assert!(inspection.primary_lease_held);
let receipt = recover_session_at(
runtime.path(),
session_control(repo.path(), "PNCC-SESSION-INTERRUPTED"),
"2026-08-11T00:01:00.000Z",
)
.unwrap();
assert_eq!(receipt.recovery_action, "ABORTED_UNCOMMITTED_SESSION");
assert_eq!(receipt.runtime_state, "DORMANT_AFTER_RECOVERY");
assert!(!runtime.path().join("leases/ICE-P-ZY001.json").exists());
assert_eq!(receipt.events.last().unwrap().kind, "DORMANT");
}
#[test]
fn refuses_recovery_when_the_persona_repository_is_dirty() {
let repo = persona_repo();
let runtime = tempfile::TempDir::new().unwrap();
prepare_wake_at(
runtime.path(),
wake_input(repo.path()),
"PNCC-SESSION-DIRTY-RECOVERY",
"2026-08-11T00:00:00.000Z",
)
.unwrap();
fs::write(repo.path().join("unknown-change.txt"), "do not erase\n").unwrap();
let inspection = inspect_session_at(
runtime.path(),
session_control(repo.path(), "PNCC-SESSION-DIRTY-RECOVERY"),
)
.unwrap();
assert_eq!(inspection.recovery_disposition, "MANUAL_REVIEW_REQUIRED");
let error = recover_session_at(
runtime.path(),
session_control(repo.path(), "PNCC-SESSION-DIRTY-RECOVERY"),
"2026-08-11T00:01:00.000Z",
)
.unwrap_err();
assert!(error.contains("REFUSED_DIRTY_REPOSITORY"));
assert!(runtime.path().join("leases/ICE-P-ZY001.json").exists());
}
#[test]
fn finalizes_an_attributed_persona_commit_interrupted_before_dormancy() {
let repo = persona_repo();
let runtime = tempfile::TempDir::new().unwrap();
let session_id = "PNCC-SESSION-COMMIT-INTERRUPTED";
prepare_wake_at(
runtime.path(),
wake_input(repo.path()),
session_id,
"2026-08-11T00:00:00.000Z",
)
.unwrap();
let repository = repo.path().canonicalize().unwrap();
let record = load_session_record(runtime.path(), session_id).unwrap();
let checkpoint_relative = format!(".hololake/persona/checkpoints/{session_id}.json");
let checkpoint = serde_json::json!({
"schema": "hololake.persona-checkpoint/v1",
"sessionId": session_id,
"personaId": record.persona_id,
"previousGitHead": record.git_head,
"createdAt": "2026-08-11T00:00:01.000Z",
"result": {"summary": "fixture"}
});
write_json_file(
&repository.join(&checkpoint_relative),
&checkpoint,
"TEST_CHECKPOINT",
)
.unwrap();
let mut manifest = load_manifest(&repository).unwrap();
manifest.current_checkpoint = checkpoint_relative.clone();
write_json_file(&repository.join(MANIFEST_PATH), &manifest, "TEST_MANIFEST").unwrap();
let committed_head =
persona_git_commit(&repository, &manifest, &checkpoint_relative, &record).unwrap();
let inspection =
inspect_session_at(runtime.path(), session_control(repo.path(), session_id)).unwrap();
assert_eq!(inspection.recovery_disposition, "SAFE_FINALIZE_COMMITTED");
let receipt = recover_session_at(
runtime.path(),
session_control(repo.path(), session_id),
"2026-08-11T00:01:00.000Z",
)
.unwrap();
assert_eq!(receipt.recovery_action, "FINALIZED_PERSONA_COMMIT");
assert_eq!(receipt.recovered_git_head, committed_head);
assert_eq!(receipt.checkpoint_path, checkpoint_relative);
assert!(!runtime.path().join("leases/ICE-P-ZY001.json").exists());
require_clean_repository(repo.path()).unwrap();
}
#[test]
fn releases_an_exact_lease_left_after_the_session_became_dormant() {
let repo = persona_repo();
let runtime = tempfile::TempDir::new().unwrap();
let session_id = "PNCC-SESSION-DORMANT-LEASE";
prepare_wake_at(
runtime.path(),
wake_input(repo.path()),
session_id,
"2026-08-11T00:00:00.000Z",
)
.unwrap();
recover_session_at(
runtime.path(),
session_control(repo.path(), session_id),
"2026-08-11T00:01:00.000Z",
)
.unwrap();
let record = load_session_record(runtime.path(), session_id).unwrap();
acquire_primary_lease(
runtime.path(),
&record.persona_id,
&record.session_id,
&record.git_head,
&record.node_id,
"2026-08-11T00:01:01.000Z",
)
.unwrap();
let inspection =
inspect_session_at(runtime.path(), session_control(repo.path(), session_id)).unwrap();
assert_eq!(
inspection.recovery_disposition,
"SAFE_RELEASE_DORMANT_LEASE"
);
let receipt = recover_session_at(
runtime.path(),
session_control(repo.path(), session_id),
"2026-08-11T00:02:00.000Z",
)
.unwrap();
assert_eq!(receipt.recovery_action, "RELEASED_DORMANT_LEASE");
assert!(!runtime.path().join("leases/ICE-P-ZY001.json").exists());
}
}