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:
parent
acf0d5f80b
commit
5b042a65fe
11 changed files with 657 additions and 11 deletions
|
|
@ -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
|
||||
推进、错误归因和损坏事件链一律保留主锁并要求人工审查;恢复器不会按时间猜测主控已死亡。
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
×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<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, ×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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue