fix(pi): reconcile prompt authority without partial state

This commit is contained in:
SaladDay
2026-08-03 04:09:23 +00:00
parent 692ef41883
commit c94983d5fa
+336 -53
View File
@@ -95,6 +95,21 @@ impl PromptService {
}
pub fn delete_prompt(state: &AppState, app: AppType, id: &str) -> Result<(), AppError> {
if matches!(app, AppType::Pi) {
let guard = lock_instruction_files()?;
let prompts = state.db.get_prompts(AppType::Pi.as_str())?;
let snapshot =
PiPromptFileService::read_under_guard(&guard, PiPromptFileKind::GlobalContext)?;
reject_pi_prompt_mutation_during_drift(&prompts, &snapshot, id, None)?;
if prompts.get(id).is_some_and(|prompt| prompt.enabled)
|| preferred_pi_prompt_match(&prompts, &snapshot) == Some(id)
{
return Err(AppError::InvalidInput(
"无法删除 Pi 当前生效的提示词".to_string(),
));
}
return state.db.delete_prompt(AppType::Pi.as_str(), id);
}
let prompts = state.db.get_prompts(app.as_str())?;
if let Some(prompt) = prompts.get(id) {
@@ -338,60 +353,40 @@ impl PromptService {
guard: &PiInstructionFileGuard,
) -> Result<(), AppError> {
const MAX_EXTERNAL_RETRIES: usize = 3;
let original = db.get_prompts(AppType::Pi.as_str())?;
for _ in 0..MAX_EXTERNAL_RETRIES {
let snapshot =
PiPromptFileService::read_under_guard(guard, PiPromptFileKind::GlobalContext)?;
let mut prompts = db.get_prompts(AppType::Pi.as_str())?;
for prompt in prompts.values_mut() {
prompt.enabled = false;
}
if snapshot.exists {
let active_id = prompts
.iter()
.find_map(|(id, prompt)| {
(prompt.content == snapshot.content).then(|| id.clone())
})
.unwrap_or_else(|| {
let digest = format!("{:x}", Sha256::digest(snapshot.content.as_bytes()));
let base = format!("native-{digest}");
let mut id = base.clone();
let mut suffix = 1_u32;
while prompts.contains_key(&id) {
id = format!("{base}-{suffix}");
suffix += 1;
}
let timestamp = chrono::Utc::now().timestamp();
prompts.insert(
id.clone(),
Prompt {
id: id.clone(),
name: "Imported from Pi AGENTS.md".to_string(),
content: snapshot.content.clone(),
description: Some(
"Device-local native state preserved during portable import"
.to_string(),
),
enabled: false,
created_at: Some(timestamp),
updated_at: Some(timestamp),
},
);
id
});
prompts
.get_mut(&active_id)
.expect("selected Pi prompt is present")
.enabled = true;
let prompts = build_pi_reconciled_library(&original, &snapshot);
let precommit =
PiPromptFileService::read_under_guard(guard, PiPromptFileKind::GlobalContext)?;
if precommit.revision != snapshot.revision {
continue;
}
db.save_prompt_selection(AppType::Pi.as_str(), &prompts)?;
#[cfg(test)]
replace_pi_agents_after_reconcile_save_for_test(&snapshot.path)?;
let verified =
PiPromptFileService::read_under_guard(guard, PiPromptFileKind::GlobalContext)?;
match PiPromptFileService::read_under_guard(guard, PiPromptFileKind::GlobalContext)
{
Ok(verified) => verified,
Err(error) => {
restore_pi_library_after_failed_reconcile(db, &original, &error)?;
return Err(error);
}
};
if verified.revision == snapshot.revision {
return Ok(());
}
restore_pi_library_after_failed_reconcile(
db,
&original,
&AppError::Conflict(
"Pi AGENTS.md changed after prompt-library publication".to_string(),
),
)?;
}
Err(AppError::Conflict(
@@ -410,13 +405,8 @@ impl PromptService {
.iter()
.filter_map(|(id, prompt)| prompt.enabled.then_some(id.clone()))
.collect::<Vec<_>>();
let matched_prompt_id = if snapshot.exists {
prompts
.iter()
.find_map(|(id, prompt)| (prompt.content == snapshot.content).then(|| id.clone()))
} else {
None
};
let matched_prompt_id =
preferred_pi_prompt_match(&prompts, &snapshot).map(ToOwned::to_owned);
let expected_enabled = matched_prompt_id.iter().cloned().collect::<Vec<_>>();
let needs_reconciliation = persisted_enabled != expected_enabled
|| (snapshot.exists && matched_prompt_id.is_none());
@@ -438,15 +428,17 @@ impl PromptService {
fn upsert_pi_prompt(state: &AppState, prompt: Prompt) -> Result<(), AppError> {
let guard = lock_instruction_files()?;
let mut prompts = state.db.get_prompts(AppType::Pi.as_str())?;
let previous = prompts.insert(prompt.id.clone(), prompt.clone());
let before = state.db.get_prompts(AppType::Pi.as_str())?;
let snapshot =
PiPromptFileService::read_under_guard(&guard, PiPromptFileKind::GlobalContext)?;
reject_pi_prompt_mutation_during_drift(&before, &snapshot, &prompt.id, Some(&prompt))?;
let mut prompts = before.clone();
let previous = prompts.insert(prompt.id.clone(), prompt.clone());
let current_enabled = previous
.as_ref()
.filter(|candidate| candidate.enabled)
.or_else(|| {
prompts
before
.values()
.find(|candidate| candidate.id != prompt.id && candidate.enabled)
});
@@ -572,6 +564,150 @@ impl PromptService {
}
}
fn preferred_pi_prompt_match<'a>(
prompts: &'a IndexMap<String, Prompt>,
snapshot: &PiPromptFileSnapshot,
) -> Option<&'a str> {
if !snapshot.exists {
return None;
}
prompts
.iter()
.find_map(|(id, prompt)| {
(prompt.enabled && prompt.content == snapshot.content).then_some(id.as_str())
})
.or_else(|| {
prompts.iter().find_map(|(id, prompt)| {
(prompt.content == snapshot.content).then_some(id.as_str())
})
})
}
fn pi_library_needs_reconciliation(
prompts: &IndexMap<String, Prompt>,
snapshot: &PiPromptFileSnapshot,
) -> bool {
let persisted_enabled = prompts
.iter()
.filter_map(|(id, prompt)| prompt.enabled.then_some(id.as_str()))
.collect::<Vec<_>>();
let matched = preferred_pi_prompt_match(prompts, snapshot);
persisted_enabled != matched.into_iter().collect::<Vec<_>>()
|| (snapshot.exists && matched.is_none())
}
fn reject_pi_prompt_mutation_during_drift(
prompts: &IndexMap<String, Prompt>,
snapshot: &PiPromptFileSnapshot,
target_id: &str,
replacement: Option<&Prompt>,
) -> Result<(), AppError> {
if !pi_library_needs_reconciliation(prompts, snapshot) {
return Ok(());
}
let target_is_persisted_active = prompts.get(target_id).is_some_and(|prompt| prompt.enabled);
let target_is_live_match = preferred_pi_prompt_match(prompts, snapshot) == Some(target_id);
let replacement_claims_live = replacement.is_some_and(|prompt| {
prompt.enabled || (snapshot.exists && prompt.content == snapshot.content)
});
if target_is_persisted_active || target_is_live_match || replacement_claims_live {
return Err(AppError::Conflict(
"Pi AGENTS.md and the prompt library disagree; reconcile native truth before changing \
an active prompt"
.to_string(),
));
}
Ok(())
}
fn build_pi_reconciled_library(
original: &IndexMap<String, Prompt>,
snapshot: &PiPromptFileSnapshot,
) -> IndexMap<String, Prompt> {
let mut prompts = original.clone();
for prompt in prompts.values_mut() {
prompt.enabled = false;
}
if !snapshot.exists {
return prompts;
}
let active_id = preferred_pi_prompt_match(original, snapshot)
.map(ToOwned::to_owned)
.unwrap_or_else(|| {
let digest = format!("{:x}", Sha256::digest(snapshot.content.as_bytes()));
let base = format!("native-{digest}");
let mut id = base.clone();
let mut suffix = 1_u32;
while prompts.contains_key(&id) {
id = format!("{base}-{suffix}");
suffix += 1;
}
let timestamp = chrono::Utc::now().timestamp();
prompts.insert(
id.clone(),
Prompt {
id: id.clone(),
name: "Imported from Pi AGENTS.md".to_string(),
content: snapshot.content.clone(),
description: Some(
"Device-local native state preserved during portable import".to_string(),
),
enabled: false,
created_at: Some(timestamp),
updated_at: Some(timestamp),
},
);
id
});
prompts
.get_mut(&active_id)
.expect("selected Pi prompt is present")
.enabled = true;
prompts
}
fn restore_pi_library_after_failed_reconcile(
db: &Database,
original: &IndexMap<String, Prompt>,
cause: &AppError,
) -> Result<(), AppError> {
db.save_prompt_selection(AppType::Pi.as_str(), original)
.map_err(|restore_error| {
AppError::Config(format!(
"Pi prompt reconciliation lost its native revision ({cause}) and failed to \
restore the previous portable library: {restore_error}"
))
})
}
#[cfg(test)]
static PI_RECONCILE_AFTER_SAVE_REPLACEMENTS: std::sync::LazyLock<
std::sync::Mutex<std::collections::VecDeque<String>>,
> = std::sync::LazyLock::new(|| std::sync::Mutex::new(std::collections::VecDeque::new()));
#[cfg(test)]
fn replace_pi_agents_after_reconcile_save_for_test(path: &str) -> Result<(), AppError> {
let replacement = PI_RECONCILE_AFTER_SAVE_REPLACEMENTS
.lock()
.map_err(|error| AppError::Lock(error.to_string()))?
.pop_front();
if let Some(replacement) = replacement {
std::fs::write(path, replacement).map_err(|error| AppError::io(path, error))?;
}
Ok(())
}
#[cfg(test)]
fn replace_pi_agents_after_each_reconcile_save_for_test(
replacements: impl IntoIterator<Item = &'static str>,
) {
*PI_RECONCILE_AFTER_SAVE_REPLACEMENTS
.lock()
.expect("Pi reconcile hook lock") =
replacements.into_iter().map(ToOwned::to_owned).collect();
}
fn ensure_pi_library_projection_matches(
snapshot: &PiPromptFileSnapshot,
current_enabled: Option<&Prompt>,
@@ -641,6 +777,18 @@ mod tests {
}
}
fn prompt(id: &str, content: &str, enabled: bool, timestamp: i64) -> Prompt {
Prompt {
id: id.to_string(),
name: id.to_string(),
content: content.to_string(),
description: None,
enabled,
created_at: Some(timestamp),
updated_at: Some(timestamp),
}
}
#[test]
#[serial]
fn public_pi_import_reconciles_an_active_empty_agents_file() {
@@ -909,4 +1057,139 @@ mod tests {
.all(|prompt| !prompt.enabled));
assert!(!temp.path().join("AGENTS.md").exists());
}
#[test]
#[serial]
fn duplicate_prompt_content_prefers_the_persisted_enabled_identity() {
let temp = tempfile::tempdir().expect("tempdir");
let _restore = EnvRestore::set("PI_CODING_AGENT_DIR", temp.path());
std::fs::write(temp.path().join("AGENTS.md"), "same-content").expect("seed AGENTS.md");
let state = AppState::new(Arc::new(Database::memory().expect("database")));
state
.db
.save_prompt(
AppType::Pi.as_str(),
&prompt("first", "same-content", false, 1),
)
.expect("first duplicate");
state
.db
.save_prompt(
AppType::Pi.as_str(),
&prompt("persisted-active", "same-content", true, 2),
)
.expect("active duplicate");
let effective = PromptService::get_prompts(&state, AppType::Pi).expect("effective prompts");
assert!(!effective["first"].enabled);
assert!(effective["persisted-active"].enabled);
let status = PromptService::get_pi_library_status(&state).expect("status");
assert_eq!(
status.matched_prompt_id.as_deref(),
Some("persisted-active")
);
assert!(!status.needs_reconciliation);
}
#[test]
#[serial]
fn external_prompt_drift_blocks_deleting_or_disabling_the_live_match() {
let temp = tempfile::tempdir().expect("tempdir");
let _restore = EnvRestore::set("PI_CODING_AGENT_DIR", temp.path());
std::fs::write(temp.path().join("AGENTS.md"), "external-live").expect("seed AGENTS.md");
let state = AppState::new(Arc::new(Database::memory().expect("database")));
state
.db
.save_prompt(
AppType::Pi.as_str(),
&prompt("stale-active", "stale-content", true, 1),
)
.expect("stale prompt");
state
.db
.save_prompt(
AppType::Pi.as_str(),
&prompt("live-match", "external-live", false, 2),
)
.expect("live match");
let before = serde_json::to_value(
state
.db
.get_prompts(AppType::Pi.as_str())
.expect("before prompts"),
)
.expect("serialize before");
assert!(matches!(
PromptService::delete_prompt(&state, AppType::Pi, "live-match"),
Err(AppError::Conflict(_))
));
assert!(matches!(
PromptService::upsert_prompt(
&state,
AppType::Pi,
"live-match",
prompt("live-match", "external-live", false, 3),
),
Err(AppError::Conflict(_))
));
assert_eq!(
serde_json::to_value(
state
.db
.get_prompts(AppType::Pi.as_str())
.expect("after prompts")
)
.expect("serialize after"),
before
);
assert_eq!(
std::fs::read_to_string(temp.path().join("AGENTS.md")).expect("live remains"),
"external-live"
);
}
#[test]
#[serial]
fn repeated_external_reconcile_races_restore_the_original_library() {
let temp = tempfile::tempdir().expect("tempdir");
let _restore = EnvRestore::set("PI_CODING_AGENT_DIR", temp.path());
std::fs::write(temp.path().join("AGENTS.md"), "native-0").expect("seed AGENTS.md");
let state = AppState::new(Arc::new(Database::memory().expect("database")));
state
.db
.save_prompt(
AppType::Pi.as_str(),
&prompt("portable", "portable-content", true, 1),
)
.expect("portable prompt");
let before = serde_json::to_value(
state
.db
.get_prompts(AppType::Pi.as_str())
.expect("before prompts"),
)
.expect("serialize before");
replace_pi_agents_after_each_reconcile_save_for_test(["native-1", "native-2", "native-3"]);
assert!(matches!(
PromptService::reconcile_pi_library(&state),
Err(AppError::Conflict(_))
));
assert_eq!(
serde_json::to_value(
state
.db
.get_prompts(AppType::Pi.as_str())
.expect("restored prompts")
)
.expect("serialize restored"),
before,
"a failed reconcile must leave no imported rows or selection changes"
);
assert_eq!(
std::fs::read_to_string(temp.path().join("AGENTS.md")).expect("latest native"),
"native-3"
);
}
}