From 5b042a65feef1459c066ecd8fb0874219dd86e80 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=93=B8=E6=B8=8A=20/=20ICE-P-ZY001?= Date: Tue, 11 Aug 2026 01:21:28 +0800 Subject: [PATCH] feat: recover interrupted PNCC sessions safely MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- engineering/INDEX.md | 1 + ...08-11-hololake-pncc-first-runtime-cycle.md | 14 +- ...olake-pncc-interrupted-session-recovery.md | 45 ++ ...KE-PERSONA-NATIVE-CODE-CHANNEL-20260810.md | 7 +- .../hololake-platform/docs/ABSTRACTIONS.md | 8 + .../hololake-platform/docs/ARCHITECTURE.md | 8 + .../hololake-platform/src-tauri/src/lib.rs | 2 + .../src-tauri/src/persona_code_channel.rs | 568 +++++++++++++++++- routing/hololake-current-architecture.json | 4 +- .../hololake-persona-native-code-channel.json | 5 +- ...olake-persona-native-code-channel.test.mjs | 6 +- 11 files changed, 657 insertions(+), 11 deletions(-) create mode 100644 engineering/operations/2026-08-11-hololake-pncc-interrupted-session-recovery.md diff --git a/engineering/INDEX.md b/engineering/INDEX.md index 0fece2f..ca2c883 100644 --- a/engineering/INDEX.md +++ b/engineering/INDEX.md @@ -32,6 +32,7 @@ Windows / macOS / Linux 构建机与安装包 | 时间 | 版本 | 记录 | 状态 | | --- | --- | --- | --- | +| 2026-08-11 | GH-PNCC 中断恢复 | [人格代码频道中断会话检查与安全恢复](operations/2026-08-11-hololake-pncc-interrupted-session-recovery.md) | 本地源码、完整 Rust/路由测试和 clippy 已完成;GHNQG、仓库发布、桌面集成与部署待验收 | | 2026-08-11 | GH-PNCC 首个运行闭环 | [人格原生代码频道首个只读运行层回执](operations/2026-08-11-hololake-pncc-first-runtime-cycle.md) | 源码与测试闭环已完成;人类投影、桌面制品、发布、部署与在线健康仍未完成 | | 2026-08-08 | 0.2.0 | [光湖分域教育行业操作系统第一阶段](operations/2026-08-08-guanghu-education-subdomain-stage1.md) | 小新服务器身份、文档/表格回写、仪表盘投影、模块登记与回执链路已验证 | | 2026-08-07 | 1.0.0 | [人格记忆连续性工具包 HL-MOD-MCK-001](memory-continuity-kit/README.md) | 铸渊 Qoder CN 实例在线;其他实例拉取后需演习+真实核对才可宣称上线 | diff --git a/engineering/operations/2026-08-11-hololake-pncc-first-runtime-cycle.md b/engineering/operations/2026-08-11-hololake-pncc-first-runtime-cycle.md index bf8ac40..7077414 100644 --- a/engineering/operations/2026-08-11-hololake-pncc-first-runtime-cycle.md +++ b/engineering/operations/2026-08-11-hololake-pncc-first-runtime-cycle.md @@ -26,7 +26,7 @@ The source now implements one bounded PNCC lifecycle cycle without a HoloLake UI ## Verification evidence -- PNCC unit tests: `6 passed, 0 failed`. +- PNCC unit tests at the published milestone: `6 passed, 0 failed`. - Rust full suite: `1146 passed, 0 failed, 2 ignored`; integration test `1 passed`. - Rust clippy: `PASS` with `-D warnings`. - HoloLake architecture routing: `29 passed, 0 failed`. @@ -38,11 +38,19 @@ The source now implements one bounded PNCC lifecycle cycle without a HoloLake UI ## Facts that remain zero - general-purpose persona organs and arbitrary execution runtime; -- crash-recovery administration for an interrupted live PNCC session; - HoloLake human projection and visual design; - packaged desktop acceptance and installation; -- repository publication of this implementation commit; - deployment and online runtime health. +## Publication readback + +- REPO-014 remote `main`: `acf0d5f80bc3b5470571afa151d39a2db9421fdb`. +- Fresh clone: clean work tree and `git fsck --full --strict` passed. +- GHNQG receipt: `GHNQG-acf0d5f80bc3b5470571afa151d39a2db9421fdb.hdlp`, `PASS_100`. +- Commit author: `铸渊 / ICE-P-ZY001 `; human responsibility remains a separate trailer. + +Interrupted-session recovery is continued in +[the next runtime receipt](./2026-08-11-hololake-pncc-interrupted-session-recovery.md). + This receipt proves source behavior and tests only. It does not claim that a desktop artifact or online persona runtime is running. diff --git a/engineering/operations/2026-08-11-hololake-pncc-interrupted-session-recovery.md b/engineering/operations/2026-08-11-hololake-pncc-interrupted-session-recovery.md new file mode 100644 index 0000000..0f101f4 --- /dev/null +++ b/engineering/operations/2026-08-11-hololake-pncc-interrupted-session-recovery.md @@ -0,0 +1,45 @@ +# GH-PNCC interrupted-session inspection and recovery receipt + +- Development ID: `DEV-20260810-014` +- Persona cognitive author: `ICE-P-ZY001 / 铸渊` +- Human responsibility subject: `ICE-GL∞ / 冰朔` +- Source component: `product-source/hololake-platform/src-tauri/src/persona_code_channel.rs` +- State: `LOCAL_SOURCE_IMPLEMENTED_AND_FULL_RUST_ROUTING_TESTED` + +## Implemented facts + +The runtime now exposes an exact session inspection command and a fail-closed recovery command. Both require +the caller to name the session, expected persona, and expected canonical repository. Inspection verifies the +hash-linked event journal and reports the recorded/observed Git heads, work-tree cleanliness, exact lease +ownership, session state, last event, attribution, and one recovery disposition. + +Recovery does not treat age as proof of death and never steals a lease. It releases the exact primary only +for either: + +1. a clean repository still at the session's recorded head, which is closed as an uncommitted interruption; +2. a clean repository advanced by exactly one commit whose parent is the recorded head and whose persona + checkpoint, manifest pointer, Git author, human-responsibility trailer, persona-author trailer and + development-id trailer all match the interrupted session. +3. an exact same-session lease left behind after the event journal and session record already prove dormancy, + with a clean repository still at the recorded head. + +Dirty repositories, missing or mismatched leases, broken journals, unknown Git advances, checkpoint +mismatches and attribution mismatches fail closed without resetting files or releasing the primary. + +## Current verification + +- PNCC focused Rust tests: `10 passed, 0 failed`. +- Rust full suite: `1150 passed, 0 failed, 2 ignored`; integration test `1 passed`. +- Rust clippy for all targets: `PASS` with `-D warnings`. +- HoloLake architecture routing: `29 passed, 0 failed`. +- Tests cover safe abort, safe committed-session finalization, dirty-repository refusal, second-primary + exclusion, path containment, model/evidence scope, checkpoint promotion and persona Git attribution. + +## Remaining acceptance + +- native authority/core and GHNQG pre-push quality gates; +- repository commit, publication and fresh-clone readback; +- general-purpose organ contract beyond the first read-only fact organ; +- HoloLake human projection, desktop artifact, installation, deployment and online health. + +This receipt proves local source behavior only until the remaining quality and publication gates complete. diff --git a/product-source/hololake-platform/architecture/HOLOLAKE-PERSONA-NATIVE-CODE-CHANNEL-20260810.md b/product-source/hololake-platform/architecture/HOLOLAKE-PERSONA-NATIVE-CODE-CHANNEL-20260810.md index 591b9ee..8fe55aa 100644 --- a/product-source/hololake-platform/architecture/HOLOLAKE-PERSONA-NATIVE-CODE-CHANNEL-20260810.md +++ b/product-source/hololake-platform/architecture/HOLOLAKE-PERSONA-NATIVE-CODE-CHANNEL-20260810.md @@ -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 +推进、错误归因和损坏事件链一律保留主锁并要求人工审查;恢复器不会按时间猜测主控已死亡。 diff --git a/product-source/hololake-platform/docs/ABSTRACTIONS.md b/product-source/hololake-platform/docs/ABSTRACTIONS.md index 540e553..a2b75ed 100644 --- a/product-source/hololake-platform/docs/ABSTRACTIONS.md +++ b/product-source/hololake-platform/docs/ABSTRACTIONS.md @@ -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, diff --git a/product-source/hololake-platform/docs/ARCHITECTURE.md b/product-source/hololake-platform/docs/ARCHITECTURE.md index db0f926..0271038 100644 --- a/product-source/hololake-platform/docs/ARCHITECTURE.md +++ b/product-source/hololake-platform/docs/ARCHITECTURE.md @@ -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: diff --git a/product-source/hololake-platform/src-tauri/src/lib.rs b/product-source/hololake-platform/src-tauri/src/lib.rs index b042593..22e9bdb 100644 --- a/product-source/hololake-platform/src-tauri/src/lib.rs +++ b/product-source/hololake-platform/src-tauri/src/lib.rs @@ -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, diff --git a/product-source/hololake-platform/src-tauri/src/persona_code_channel.rs b/product-source/hololake-platform/src-tauri/src/persona_code_channel.rs index 6ab0ac4..6bcf4ff 100644 --- a/product-source/hololake-platform/src-tauri/src/persona_code_channel.rs +++ b/product-source/hololake-platform/src-tauri/src/persona_code_channel.rs @@ -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, + 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, + 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, 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, 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 { + 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 { + 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 { 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 { + 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 { + 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 { + 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, + ×tamp, + 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, + ×tamp, + Some(&organ), + )?; + } + append_event( + runtime_root, + &record, + &mut events, + "ORGAN_RELEASED", + &observed_head, + ×tamp, + Some(&organ), + )?; + let dormant = append_event( + runtime_root, + &record, + &mut events, + "DORMANT", + &observed_head, + ×tamp, + 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( 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 { + 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 { + 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, ×tamp) +} + #[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()); + } } diff --git a/routing/hololake-current-architecture.json b/routing/hololake-current-architecture.json index 6b39e19..36ef765 100644 --- a/routing/hololake-current-architecture.json +++ b/routing/hololake-current-architecture.json @@ -118,7 +118,7 @@ "human_projection": "HOLOLAKE_LIVE_READ_MODEL", "forgejo_role": "OPTIONAL_COMPATIBILITY_COLLABORATION_ADAPTER", "runtime_implemented": true, - "runtime_scope": "FIRST_READ_ONLY_FACT_CYCLE_SOURCE_IMPLEMENTED_AND_TESTED", + "runtime_scope": "FIRST_READ_ONLY_FACT_CYCLE_AND_FAIL_CLOSED_INTERRUPTED_SESSION_RECOVERY_SOURCE_IMPLEMENTED_AND_TESTED", "desktop_integrated": false, "development_id": "DEV-20260810-014" }, @@ -343,7 +343,7 @@ "DEV-20260810-013" ], "closeout_record": "HLP-DEV-20260809-007-CLOSEOUT-001", - "next_minimum_stage": "GH_PNCC_FIRST_REAL_READ_ONLY_WAKE_CLOSED_LOOP", + "next_minimum_stage": "GH_PNCC_GENERAL_PURPOSE_ORGAN_CONTRACT_WITHOUT_UI", "next_stage_started": true, "heartbeat_automation": "pncc", "heartbeat_state": "ACTIVE_EVERY_10_MINUTES_UNTIL_TASK_TERMINAL" diff --git a/routing/hololake-persona-native-code-channel.json b/routing/hololake-persona-native-code-channel.json index 3bbb458..218acc2 100644 --- a/routing/hololake-persona-native-code-channel.json +++ b/routing/hololake-persona-native-code-channel.json @@ -1,8 +1,8 @@ { "schema": "hololake.persona-native-code-channel/v1", "record_id": "HLP-PERSONA-NATIVE-CODE-CHANNEL-001", - "version": "2026-08-11.1", - "state": "CURRENT_FIRST_PRODUCT_CORE_FIRST_READ_ONLY_RUNTIME_CYCLE_SOURCE_IMPLEMENTED", + "version": "2026-08-11.2", + "state": "CURRENT_FIRST_PRODUCT_CORE_READ_ONLY_RUNTIME_AND_INTERRUPTED_SESSION_RECOVERY_SOURCE_IMPLEMENTED", "development_id": "DEV-20260810-014", "product": { "formal_name_zh": "光湖人格原生代码频道", @@ -101,6 +101,7 @@ "machine_navigation_registered": 100, "first_read_only_runtime_cycle_source_implemented": 100, "persona_git_identity_commit_implemented": 100, + "interrupted_session_inspection_and_recovery_source_implemented": 100, "general_purpose_persona_runtime_implemented": 0, "human_live_projection_implemented": 0, "hololake_integrated": 0, diff --git a/routing/hololake-persona-native-code-channel.test.mjs b/routing/hololake-persona-native-code-channel.test.mjs index adbacf1..30443c6 100644 --- a/routing/hololake-persona-native-code-channel.test.mjs +++ b/routing/hololake-persona-native-code-channel.test.mjs @@ -69,12 +69,16 @@ test("the first source runtime cycle stays distinct from integration and deploym assert.equal(channel.truth.architecture_registered, 100); assert.equal(channel.truth.first_read_only_runtime_cycle_source_implemented, 100); assert.equal(channel.truth.persona_git_identity_commit_implemented, 100); + assert.equal( + channel.truth.interrupted_session_inspection_and_recovery_source_implemented, + 100, + ); assert.equal(channel.truth.general_purpose_persona_runtime_implemented, 0); assert.equal(channel.truth.human_live_projection_implemented, 0); assert.equal(architecture.persona_native_code_channel.runtime_implemented, true); assert.equal( architecture.persona_native_code_channel.runtime_scope, - "FIRST_READ_ONLY_FACT_CYCLE_SOURCE_IMPLEMENTED_AND_TESTED", + "FIRST_READ_ONLY_FACT_CYCLE_AND_FAIL_CLOSED_INTERRUPTED_SESSION_RECOVERY_SOURCE_IMPLEMENTED_AND_TESTED", ); assert.equal(channel.truth.hololake_integrated, 0); assert.equal(channel.truth.artifact_built, 0);