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

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