Root cause of 'model+effort switch works on Mac, not on Windows': the switch APPLIES in-session (the dispatch/apply chain is platform-identical, verified adversarially) but its persistence never sticks on Windows. Every tmp+rename atomic write except auth/storage.rs committed with a bare fs::rename, and Windows MoveFileExW(REPLACE_EXISTING) fails with a sharing violation whenever AV/search-indexer/cloud-sync transiently holds the destination open. Consequences: [models].default never persisted (next launch = original model), the session summary's current model never persisted (resume = original model), and the models cache went silently stale (all its write errors were swallowed). - New kigi_shell_base::util::fs::replace_file — THE commit step for tmp+rename: plain rename on Unix; on Windows delete-first + two short backoffs (the pattern auth/storage.rs shipped first), tmp cleaned on failure, error always returned. Windows branch type-checked against x86_64-pc-windows-msvc. - Adopted at every replace site: config.toml (save_config / atomic_write_string / mcp saves), models cache (plus unique tmp suffixes and tracing::warn on failure — writes were fully silent), session storage (summary/current-model, jsonl, plan/signals/ announcement/goal/graph state), auth.json, active-sessions registry, prompt history, claude/kimi import, campaigns state, goal artifacts. Directory-move renames (worktree pool, corrupt-file backups) keep plain rename — their destinations don't pre-exist. Verified: kigi-shell + kigi-shell-base 5318 tests green, clippy clean, msvc-target check of the new cfg(windows) code clean.
744 lines
30 KiB
Rust
744 lines
30 KiB
Rust
//! Campaign dismiss state, remote cache, and effective-config overlay.
|
|
//!
|
|
//! Design, invariants, and the "adding a second governed field" recipe are
|
|
//! documented alongside this module.
|
|
|
|
use std::collections::HashSet;
|
|
use std::path::Path;
|
|
use std::sync::RwLock;
|
|
use std::sync::atomic::{AtomicU64, Ordering};
|
|
|
|
use kigi_config::campaigns::{
|
|
CampaignEntry, filter_active_campaigns, ids_touching_paths, merge_campaign_entries,
|
|
};
|
|
use kigi_config::config_override::{PatchPath, patch_touches_any};
|
|
use kigi_config::{
|
|
CampaignsState, ConfigLayers, campaigns_state_path, load_dismissed_ids_from_home,
|
|
user_kigi_home,
|
|
};
|
|
use kigi_config_types::{CampaignOverride, RemoteSettings};
|
|
|
|
/// FIFO cap on persisted dismissed ids; evicting the oldest can re-nudge for a
|
|
/// still-live campaign after a user dismisses more than this over the CLI's life.
|
|
const MAX_DISMISSED_IDS: usize = 32;
|
|
|
|
static DISMISS_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
|
static DISMISS_TMP_NONCE: AtomicU64 = AtomicU64::new(0);
|
|
|
|
static REMOTE_CAMPAIGN_CACHE: RwLock<Vec<CampaignEntry>> = RwLock::new(Vec::new());
|
|
|
|
/// Seed the process-global remote campaign cache. A `None` settings value (e.g.
|
|
/// a failed fetch) is a no-op so it can't clobber a previously-seeded cache;
|
|
/// `Some` with zero campaigns legitimately clears it (campaigns withdrawn).
|
|
pub fn set_remote_campaigns_from_settings(remote: Option<&RemoteSettings>) {
|
|
let Some(remote) = remote else {
|
|
return;
|
|
};
|
|
set_remote_campaigns(remote_campaigns_from_settings(Some(remote)));
|
|
}
|
|
|
|
fn set_remote_campaigns(entries: Vec<CampaignEntry>) {
|
|
if let Ok(mut g) = REMOTE_CAMPAIGN_CACHE.write() {
|
|
*g = entries;
|
|
}
|
|
}
|
|
|
|
fn cached_remote_campaigns() -> Vec<CampaignEntry> {
|
|
REMOTE_CAMPAIGN_CACHE
|
|
.read()
|
|
.map(|g| g.clone())
|
|
.unwrap_or_default()
|
|
}
|
|
|
|
/// Fail-open dismissed campaign ids from `campaigns_state.json`.
|
|
pub fn load_dismissed_ids() -> HashSet<String> {
|
|
load_dismissed_ids_from_home()
|
|
}
|
|
|
|
pub fn dismiss_campaign_ids(ids: impl IntoIterator<Item = String>) {
|
|
let Some(home) = user_kigi_home() else {
|
|
return;
|
|
};
|
|
if let Err(e) = dismiss_campaign_ids_at(&home, ids) {
|
|
tracing::warn!(error = %e, "campaigns: failed to persist dismiss state");
|
|
}
|
|
}
|
|
|
|
/// Append `ids` to the dismissed set and write `campaigns_state.json` atomically
|
|
/// (temp + rename). Corrupt prior state is renamed aside, not discarded.
|
|
fn dismiss_campaign_ids_at(
|
|
home: &Path,
|
|
ids: impl IntoIterator<Item = String>,
|
|
) -> std::io::Result<()> {
|
|
use fs2::FileExt as _;
|
|
let _guard = DISMISS_LOCK.lock().unwrap_or_else(|p| p.into_inner());
|
|
let path = campaigns_state_path(home);
|
|
// Cross-process advisory lock over the read-modify-write: in leader mode
|
|
// several kigi processes share `$KIGI_SHARE_DIR`; the in-process mutex alone would
|
|
// let them lose-update the set. Best-effort; a lock failure still proceeds.
|
|
let lock = std::fs::OpenOptions::new()
|
|
.create(true)
|
|
.truncate(false)
|
|
.write(true)
|
|
.open(path.with_extension("json.lock"));
|
|
if let Ok(ref f) = lock {
|
|
let _ = f.lock_exclusive();
|
|
}
|
|
let mut ordered = match std::fs::read_to_string(&path) {
|
|
Ok(contents) => match serde_json::from_str::<CampaignsState>(&contents) {
|
|
Ok(s) => s.dismissed_ids,
|
|
Err(e) => {
|
|
let _ = std::fs::rename(&path, path.with_extension("json.corrupt"));
|
|
tracing::warn!(error = %e, "campaigns: corrupt dismiss state; renamed aside");
|
|
Vec::new()
|
|
}
|
|
},
|
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Vec::new(),
|
|
Err(e) => return Err(e),
|
|
};
|
|
let mut seen: HashSet<String> = ordered.iter().cloned().collect();
|
|
for id in ids {
|
|
if id.is_empty() || !seen.insert(id.clone()) {
|
|
continue;
|
|
}
|
|
ordered.push(id);
|
|
}
|
|
if ordered.len() > MAX_DISMISSED_IDS {
|
|
let drop_n = ordered.len() - MAX_DISMISSED_IDS;
|
|
ordered.drain(..drop_n);
|
|
}
|
|
let json = serde_json::to_string(&CampaignsState {
|
|
dismissed_ids: ordered,
|
|
})
|
|
.map_err(std::io::Error::other)?;
|
|
let nonce = DISMISS_TMP_NONCE.fetch_add(1, Ordering::Relaxed);
|
|
let tmp = path.with_extension(format!("json.{}.{}.tmp", std::process::id(), nonce));
|
|
std::fs::write(&tmp, &json)?;
|
|
crate::util::fs::replace_file(&tmp, &path)
|
|
}
|
|
|
|
/// `KIGI_CAMPAIGNS_OVERRIDE` JSON array replaces all sources (`[]` = none; beats
|
|
/// kill switch). Invalid JSON also resolves to none: the var's intent is "replace
|
|
/// campaigns with exactly this", so a typo must not silently fall back to the
|
|
/// real sources it was meant to replace.
|
|
pub fn campaigns_override() -> Option<Vec<CampaignEntry>> {
|
|
let json = std::env::var("KIGI_CAMPAIGNS_OVERRIDE").ok()?;
|
|
match serde_json::from_str::<Vec<CampaignOverride>>(&json) {
|
|
Ok(list) => Some(
|
|
list.into_iter()
|
|
.filter_map(remote_campaign_to_entry)
|
|
.collect(),
|
|
),
|
|
Err(e) => {
|
|
tracing::warn!(error = %e, "invalid KIGI_CAMPAIGNS_OVERRIDE JSON; suppressing all campaigns");
|
|
Some(Vec::new())
|
|
}
|
|
}
|
|
}
|
|
|
|
fn remote_campaign_to_entry(c: CampaignOverride) -> Option<CampaignEntry> {
|
|
let id = c.id.as_deref()?.trim();
|
|
if id.is_empty() {
|
|
return None;
|
|
}
|
|
let id = id.to_owned();
|
|
// Full-power patch (any field); no allowlist filtering — requirements
|
|
// precedence is restored by `ConfigLayers::apply_campaign_overrides`.
|
|
let patch = match toml::Value::try_from(serde_json::Value::Object(c.patch)) {
|
|
Ok(toml::Value::Table(t)) => t,
|
|
Ok(_) => return None,
|
|
Err(e) => {
|
|
tracing::warn!(error = %e, %id, "campaigns: invalid remote patch; ignoring");
|
|
return None;
|
|
}
|
|
};
|
|
if patch.is_empty() {
|
|
return None;
|
|
}
|
|
Some(CampaignEntry { id, patch })
|
|
}
|
|
|
|
pub fn remote_campaigns_from_settings(remote: Option<&RemoteSettings>) -> Vec<CampaignEntry> {
|
|
remote
|
|
.map(|rs| {
|
|
rs.campaigns
|
|
.iter()
|
|
.cloned()
|
|
.filter_map(remote_campaign_to_entry)
|
|
.collect()
|
|
})
|
|
.unwrap_or_default()
|
|
}
|
|
|
|
/// The single campaign-resolution path: `KIGI_CAMPAIGNS_OVERRIDE` (replaces all
|
|
/// sources and beats the kill switch) → kill switch → layer+remote merge →
|
|
/// dismiss. `base` is the pre-campaign effective config, used only for the
|
|
/// kill-switch check.
|
|
pub fn resolve_active_campaigns_from_layers(
|
|
layers: &ConfigLayers,
|
|
base: &toml::Value,
|
|
remote_entries: &[CampaignEntry],
|
|
dismissed: &HashSet<String>,
|
|
) -> Vec<CampaignEntry> {
|
|
if let Some(over) = campaigns_override() {
|
|
return filter_active_campaigns(over, dismissed);
|
|
}
|
|
layers.resolve_campaigns(base, remote_entries, dismissed)
|
|
}
|
|
|
|
/// Campaigns eligible for dismissal when the user persists a choice (loads
|
|
/// layers + remote cache + dismiss state).
|
|
///
|
|
/// Unlike the apply path this deliberately **ignores the kill switch**:
|
|
/// dismissing a suppressed campaign is harmless, while skipping the dismissal
|
|
/// lets a later re-enabled campaign override a choice the user already made
|
|
/// ("user pick wins, forever"). A layer-load failure likewise falls back to
|
|
/// the remote cache instead of failing closed — remote campaigns still get
|
|
/// dismissed on that path, though disk-layer campaigns can be missed until
|
|
/// the transient failure clears (they re-dismiss on the next pick).
|
|
fn resolve_dismissable_campaigns() -> Vec<CampaignEntry> {
|
|
let dismissed = load_dismissed_ids();
|
|
if let Some(over) = campaigns_override() {
|
|
return filter_active_campaigns(over, &dismissed);
|
|
}
|
|
let remote_entries = cached_remote_campaigns();
|
|
match ConfigLayers::load() {
|
|
Ok(layers) => filter_active_campaigns(
|
|
merge_campaign_entries(&layers.campaign_source_slices(&remote_entries)),
|
|
&dismissed,
|
|
),
|
|
Err(e) => {
|
|
tracing::warn!(error = %e, "campaigns: layer load failed; dismiss bookkeeping using remote cache only");
|
|
filter_active_campaigns(remote_entries, &dismissed)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Effective config with remote/override-aware campaign overlay
|
|
/// (base → resolve [override/kill/merge/dismiss] → apply), one `ConfigLayers::load`.
|
|
pub fn load_effective_config() -> std::io::Result<toml::Value> {
|
|
let layers = ConfigLayers::load()?;
|
|
let dismissed = load_dismissed_ids();
|
|
let remote = cached_remote_campaigns();
|
|
let mut effective = layers.effective_config_base();
|
|
let active = resolve_active_campaigns_from_layers(&layers, &effective, &remote, &dismissed);
|
|
layers.apply_campaign_overrides(&mut effective, &active);
|
|
Ok(effective)
|
|
}
|
|
|
|
/// Effective config with **disk campaigns only** — no remote cache, no
|
|
/// `KIGI_CAMPAIGNS_OVERRIDE`. For one-shot CLI entrypoints that never fetch
|
|
/// remote settings: calling [`load_effective_config`] there would silently
|
|
/// resolve against a never-seeded cache, so the divergence is named instead
|
|
/// of implied (mirrors `ConfigLayers::effective_config_disk_only`).
|
|
pub fn load_effective_config_disk_only() -> std::io::Result<toml::Value> {
|
|
Ok(ConfigLayers::load()?.effective_config_disk_only())
|
|
}
|
|
|
|
/// Read the value at `path` from an effective-config tree.
|
|
fn read_path(tree: &toml::Value, path: PatchPath) -> Option<toml::Value> {
|
|
let mut cur = tree;
|
|
for key in path {
|
|
cur = cur.get(*key)?;
|
|
}
|
|
Some(cur.clone())
|
|
}
|
|
|
|
fn as_string(v: Option<toml::Value>) -> Option<String> {
|
|
v.and_then(|v| v.as_str().map(str::to_owned))
|
|
}
|
|
|
|
/// Resolved campaign state for one [`CampaignField`] after the overlay.
|
|
struct CampaignFieldValue {
|
|
/// Effective value (campaign value if it won, else the merged base value).
|
|
value: Option<toml::Value>,
|
|
/// Whether an active campaign actually changed the effective value.
|
|
driven: bool,
|
|
/// Pre-campaign value to recover to; `Some` only when `driven` and the base had one.
|
|
recovery: Option<toml::Value>,
|
|
}
|
|
|
|
/// A config field a campaign may temporarily override until the user sets it.
|
|
/// `apply_campaign_fields` drives every [`CAMPAIGN_FIELDS`] entry, so the resolve
|
|
/// pass is one row here. A field still needs its runtime state, a `persist_*`
|
|
/// writer through [`persist_user_choice`], and any field-specific reaction (e.g.
|
|
/// the model catalog-miss/live-session handling in `agent::models`).
|
|
struct CampaignField {
|
|
/// Path into the effective config; also the dismiss key shared with the writer.
|
|
path: PatchPath,
|
|
/// Store the resolved value, flag, and recovery onto the agent config.
|
|
store: fn(&mut crate::agent::config::Config, CampaignFieldValue),
|
|
/// Clear the campaign-driven flag + recovery (value untouched). Used when
|
|
/// resolution fails so the runtime state is defined (fail closed, matching
|
|
/// the apply path) instead of stale.
|
|
reset: fn(&mut crate::agent::config::Config),
|
|
}
|
|
|
|
/// Path of the `models.default` campaign field, shared by the registry row and
|
|
/// its dismiss writer so the two can't drift.
|
|
const MODELS_DEFAULT_PATH: PatchPath = &["models", "default"];
|
|
|
|
const CAMPAIGN_FIELDS: &[CampaignField] = &[CampaignField {
|
|
path: MODELS_DEFAULT_PATH,
|
|
store: |cfg, r| {
|
|
cfg.models.default = as_string(r.value);
|
|
cfg.models.default_is_campaign_driven = r.driven;
|
|
cfg.models.pre_campaign_default = as_string(r.recovery);
|
|
},
|
|
reset: |cfg| {
|
|
cfg.models.default_is_campaign_driven = false;
|
|
cfg.models.pre_campaign_default = None;
|
|
},
|
|
}];
|
|
|
|
/// Resolve each [`CAMPAIGN_FIELDS`] entry's value, campaign-driven flag, and
|
|
/// recovery value from the campaign overlay and store them onto `cfg`. Pure given
|
|
/// the resolved `base`/`effective`/`active`; the I/O lives in [`sync_campaign_fields`].
|
|
fn apply_campaign_fields(
|
|
cfg: &mut crate::agent::config::Config,
|
|
base: &toml::Value,
|
|
effective: &toml::Value,
|
|
active: &[CampaignEntry],
|
|
) {
|
|
for field in CAMPAIGN_FIELDS {
|
|
let value = read_path(effective, field.path);
|
|
let base_value = read_path(base, field.path);
|
|
// A campaign only *drives* a field when it actually changed the effective
|
|
// value: requirements are re-merged after campaigns, so an admin pin wins
|
|
// and the campaign patch is a no-op (don't flag it).
|
|
let driven = value != base_value
|
|
&& active
|
|
.iter()
|
|
.any(|e| patch_touches_any(&e.patch, &[field.path]));
|
|
let recovery = if driven { base_value } else { None };
|
|
(field.store)(
|
|
cfg,
|
|
CampaignFieldValue {
|
|
value,
|
|
driven,
|
|
recovery,
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Seed the remote cache, set every [`CAMPAIGN_FIELDS`] entry (value + flag +
|
|
/// recovery) from the campaign overlay, then re-apply requirements so admin pins win.
|
|
pub fn sync_campaign_fields(cfg: &mut crate::agent::config::Config) {
|
|
let remote = remote_campaigns_from_settings(cfg.remote_settings.as_ref());
|
|
// Seed the process-global cache from the parse we already did (skip on `None`
|
|
// so a failed fetch can't clobber a previously-seeded cache).
|
|
if cfg.remote_settings.is_some() {
|
|
set_remote_campaigns(remote.clone());
|
|
}
|
|
let Ok(layers) = ConfigLayers::load() else {
|
|
// Fail closed like the apply path: leave the field values as loaded but
|
|
// clear the campaign-driven flags/recovery so they can't go stale (a
|
|
// stale flag would mislabel a user value as campaign-driven, or vice
|
|
// versa disarm the live-session guard for a campaign value).
|
|
tracing::warn!("campaigns: config layer load failed; clearing campaign-driven field state");
|
|
for field in CAMPAIGN_FIELDS {
|
|
(field.reset)(cfg);
|
|
}
|
|
return;
|
|
};
|
|
let dismissed = load_dismissed_ids();
|
|
let base = layers.effective_config_base();
|
|
let active = resolve_active_campaigns_from_layers(&layers, &base, &remote, &dismissed);
|
|
let mut effective = base.clone();
|
|
layers.apply_campaign_overrides(&mut effective, &active);
|
|
apply_campaign_fields(cfg, &base, &effective, &active);
|
|
let _ = crate::config::apply_requirements(cfg);
|
|
}
|
|
|
|
/// Dismiss any active campaign whose patch touches `path`, then persist the
|
|
/// setting via `update_config`. The single field-keyed chokepoint, so a new
|
|
/// campaign-governable field is one call here with no per-field dismiss wiring.
|
|
///
|
|
/// Dismiss is recorded **before** the config write so a crash between the two
|
|
/// can't leave the campaign active over the user's just-saved value (re-nudge).
|
|
/// A dismiss-then-failed-write leaves the dismiss standing (fail-toward-no-nudge).
|
|
pub async fn persist_user_choice(
|
|
path: PatchPath,
|
|
write: impl FnOnce(&mut super::mcp::Config),
|
|
) -> anyhow::Result<()> {
|
|
// Config-layer reads + the flock'd read-modify-write are blocking I/O;
|
|
// keep them off the async worker. Awaited before the config write so the
|
|
// dismiss-before-write ordering above holds. A panicked/cancelled dismiss
|
|
// task must NOT abort the user's write: bookkeeping failure is logged and
|
|
// the write proceeds (the campaign may re-nudge; the pick is never lost).
|
|
let dismissed = tokio::task::spawn_blocking(move || {
|
|
let ids = ids_touching_paths(&resolve_dismissable_campaigns(), &[path]);
|
|
if !ids.is_empty() {
|
|
tracing::info!(
|
|
?ids,
|
|
?path,
|
|
"campaigns: dismissed after the user set the field"
|
|
);
|
|
dismiss_campaign_ids(ids);
|
|
}
|
|
})
|
|
.await;
|
|
if let Err(e) = dismissed {
|
|
tracing::warn!(error = %e, "campaigns: dismiss bookkeeping task failed; persisting the choice anyway");
|
|
}
|
|
super::persist::update_config(write).await
|
|
}
|
|
|
|
/// Persist the default model (+ optional reasoning effort) through
|
|
/// [`persist_user_choice`], so picking a model dismisses a campaign nudging
|
|
/// `models.default`. `None` clears the field.
|
|
pub async fn persist_models_default(
|
|
value: Option<String>,
|
|
reasoning_effort: Option<kigi_sampling_types::ReasoningEffort>,
|
|
) -> anyhow::Result<()> {
|
|
let s = value.unwrap_or_default();
|
|
if s.len() > super::settings_writes::MAX_DEFAULT_MODEL_LEN {
|
|
anyhow::bail!(
|
|
"model name too long ({} > {} bytes)",
|
|
s.len(),
|
|
super::settings_writes::MAX_DEFAULT_MODEL_LEN
|
|
);
|
|
}
|
|
persist_user_choice(MODELS_DEFAULT_PATH, move |cfg| {
|
|
cfg.models.default = if s.is_empty() { None } else { Some(s) };
|
|
if let Some(effort) = reasoning_effort {
|
|
cfg.models.default_reasoning_effort = Some(effort);
|
|
}
|
|
})
|
|
.await
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use kigi_config::ConfigLayers;
|
|
use kigi_test_support::EnvGuard;
|
|
use serial_test::serial;
|
|
use tempfile::tempdir;
|
|
|
|
fn models_default_patch(default: &str) -> toml::Table {
|
|
let mut models = toml::map::Map::new();
|
|
models.insert("default".into(), toml::Value::String(default.into()));
|
|
let mut t = toml::map::Map::new();
|
|
t.insert("models".into(), toml::Value::Table(models));
|
|
t
|
|
}
|
|
|
|
/// `KIGI_CAMPAIGNS_OVERRIDE` applies despite the kill switch; without it the
|
|
/// kill switch (`features.campaigns = false`) wins.
|
|
#[test]
|
|
#[serial]
|
|
fn override_beats_kill_switch() {
|
|
let base: toml::Value = toml::from_str("[features]\ncampaigns = false\n").unwrap();
|
|
let layers = ConfigLayers::default();
|
|
|
|
{
|
|
let _env = EnvGuard::set(
|
|
"KIGI_CAMPAIGNS_OVERRIDE",
|
|
r#"[{"id":"c","models":{"default":"m"}}]"#,
|
|
);
|
|
let active = resolve_active_campaigns_from_layers(&layers, &base, &[], &HashSet::new());
|
|
assert_eq!(active.len(), 1, "override must apply despite kill switch");
|
|
assert_eq!(active[0].id, "c");
|
|
assert_eq!(active[0].patch["models"]["default"].as_str(), Some("m"));
|
|
}
|
|
|
|
// Same disabled base, override now unset → kill switch suppresses all.
|
|
let _env = EnvGuard::unset("KIGI_CAMPAIGNS_OVERRIDE");
|
|
let active = resolve_active_campaigns_from_layers(&layers, &base, &[], &HashSet::new());
|
|
assert!(
|
|
active.is_empty(),
|
|
"kill switch wins when override is absent"
|
|
);
|
|
}
|
|
|
|
/// Invalid `KIGI_CAMPAIGNS_OVERRIDE` JSON fails toward *no campaigns*: the
|
|
/// var's intent is "replace campaigns with exactly this", so a typo must not
|
|
/// silently re-enable the layer/remote campaigns it was meant to replace.
|
|
#[test]
|
|
#[serial]
|
|
fn invalid_override_json_suppresses_all_campaigns() {
|
|
let _env = EnvGuard::set("KIGI_CAMPAIGNS_OVERRIDE", "{ not json");
|
|
|
|
let mut layers = ConfigLayers::default();
|
|
layers.campaigns.user = vec![CampaignEntry {
|
|
id: "from-layer".into(),
|
|
patch: models_default_patch("layer-model"),
|
|
}];
|
|
let remote = vec![CampaignEntry {
|
|
id: "from-remote".into(),
|
|
patch: models_default_patch("remote-model"),
|
|
}];
|
|
let base = toml::Value::Table(Default::default());
|
|
|
|
let active = resolve_active_campaigns_from_layers(&layers, &base, &remote, &HashSet::new());
|
|
assert!(
|
|
active.is_empty(),
|
|
"an invalid override must suppress all campaigns, not fall back to real sources"
|
|
);
|
|
}
|
|
|
|
/// Dismiss bookkeeping deliberately ignores the kill switch: a model pick
|
|
/// made while `KIGI_CAMPAIGNS=0` must still record the dismissal, or a
|
|
/// later re-enabled campaign would override the user's explicit choice.
|
|
#[test]
|
|
#[serial]
|
|
fn dismiss_resolution_ignores_kill_switch() {
|
|
let _over = EnvGuard::unset("KIGI_CAMPAIGNS_OVERRIDE");
|
|
let _kill = EnvGuard::set("KIGI_CAMPAIGNS", "0");
|
|
|
|
let mut patch = serde_json::Map::new();
|
|
patch.insert("models".into(), serde_json::json!({ "default": "m" }));
|
|
let rs = RemoteSettings {
|
|
campaigns: vec![CampaignOverride {
|
|
id: Some("dismiss-during-kill-switch".into()),
|
|
patch,
|
|
}],
|
|
..Default::default()
|
|
};
|
|
set_remote_campaigns_from_settings(Some(&rs));
|
|
|
|
let resolved = resolve_dismissable_campaigns();
|
|
// Clear the process-global cache before asserting so a failure can't
|
|
// leak state into sibling tests.
|
|
set_remote_campaigns_from_settings(Some(&RemoteSettings::default()));
|
|
assert!(
|
|
resolved
|
|
.iter()
|
|
.any(|c| c.id == "dismiss-during-kill-switch"),
|
|
"kill switch must not hide a campaign from dismiss bookkeeping"
|
|
);
|
|
}
|
|
|
|
/// `KIGI_CAMPAIGNS_OVERRIDE="[]"` replaces all sources with nothing — even
|
|
/// layer + remote campaigns resolve to empty.
|
|
#[test]
|
|
#[serial]
|
|
fn override_empty_means_none() {
|
|
let _env = EnvGuard::set("KIGI_CAMPAIGNS_OVERRIDE", "[]");
|
|
|
|
let mut layers = ConfigLayers::default();
|
|
layers.campaigns.user = vec![CampaignEntry {
|
|
id: "from-layer".into(),
|
|
patch: models_default_patch("layer-model"),
|
|
}];
|
|
let remote = vec![CampaignEntry {
|
|
id: "from-remote".into(),
|
|
patch: models_default_patch("remote-model"),
|
|
}];
|
|
let base = toml::Value::Table(Default::default());
|
|
|
|
let active = resolve_active_campaigns_from_layers(&layers, &base, &remote, &HashSet::new());
|
|
assert!(
|
|
active.is_empty(),
|
|
"empty override replaces all sources, yielding no campaigns"
|
|
);
|
|
}
|
|
|
|
/// Contract: `persist_user_choice(["models","default"], ..)` dismisses only
|
|
/// campaigns that touch that path, never a sibling-field campaign. The full
|
|
/// wiring (set_default_model -> persist -> dismiss) is covered end to end by
|
|
/// the pager `pty_e2e` campaign test.
|
|
#[test]
|
|
fn models_default_persist_targets_only_model_campaigns() {
|
|
let model_campaign = CampaignEntry {
|
|
id: "release".into(),
|
|
patch: models_default_patch("new-model"),
|
|
};
|
|
let other_campaign = CampaignEntry {
|
|
id: "other".into(),
|
|
patch: toml::from_str::<toml::Table>("[features]\nweb_fetch = true\n").unwrap(),
|
|
};
|
|
let path: &[PatchPath] = &[&["models", "default"]];
|
|
let ids = ids_touching_paths(&[model_campaign, other_campaign], path);
|
|
assert_eq!(ids, vec!["release".to_string()]);
|
|
}
|
|
|
|
/// `apply_campaign_fields` flags a field campaign-driven only when the campaign
|
|
/// actually changed the effective value: a campaign win sets the flag + recovery,
|
|
/// but a requirements win (effective == base) does not (and stores no recovery).
|
|
#[test]
|
|
fn campaign_field_flags_campaign_win_not_requirements_win() {
|
|
let active = vec![CampaignEntry {
|
|
id: "release".into(),
|
|
patch: models_default_patch("campaign-model"),
|
|
}];
|
|
let base: toml::Value = toml::from_str("[models]\ndefault = \"base-model\"\n").unwrap();
|
|
|
|
// Campaign won the effective default.
|
|
let mut cfg = crate::agent::config::Config::default();
|
|
let won: toml::Value = toml::from_str("[models]\ndefault = \"campaign-model\"\n").unwrap();
|
|
apply_campaign_fields(&mut cfg, &base, &won, &active);
|
|
assert_eq!(cfg.models.default.as_deref(), Some("campaign-model"));
|
|
assert!(cfg.models.default_is_campaign_driven);
|
|
assert_eq!(
|
|
cfg.models.pre_campaign_default.as_deref(),
|
|
Some("base-model")
|
|
);
|
|
|
|
// Requirements re-merge clobbered the campaign back to the base value.
|
|
let mut cfg = crate::agent::config::Config::default();
|
|
apply_campaign_fields(&mut cfg, &base, &base, &active);
|
|
assert_eq!(cfg.models.default.as_deref(), Some("base-model"));
|
|
assert!(!cfg.models.default_is_campaign_driven);
|
|
assert_eq!(cfg.models.pre_campaign_default, None);
|
|
|
|
// No active campaign touching the field: never driven.
|
|
let mut cfg = crate::agent::config::Config::default();
|
|
apply_campaign_fields(&mut cfg, &base, &won, &[]);
|
|
assert!(!cfg.models.default_is_campaign_driven);
|
|
assert_eq!(cfg.models.pre_campaign_default, None);
|
|
}
|
|
|
|
/// Fix: in leader mode the pager seeds the remote-campaign cache so the
|
|
/// dismiss path (which runs in the pager process) can see remote campaigns.
|
|
/// Verify a seeded remote campaign round-trips into the dismiss-id set.
|
|
#[test]
|
|
#[serial]
|
|
fn seeded_remote_campaign_is_visible_to_dismiss() {
|
|
let _env = EnvGuard::unset("KIGI_CAMPAIGNS_OVERRIDE");
|
|
let mut patch = serde_json::Map::new();
|
|
patch.insert("models".into(), serde_json::json!({ "default": "m" }));
|
|
let rs = RemoteSettings {
|
|
campaigns: vec![CampaignOverride {
|
|
id: Some("remote-1".into()),
|
|
patch,
|
|
}],
|
|
..Default::default()
|
|
};
|
|
set_remote_campaigns_from_settings(Some(&rs));
|
|
let cached = cached_remote_campaigns();
|
|
assert!(cached.iter().any(|c| c.id == "remote-1"));
|
|
|
|
let path: &[PatchPath] = &[&["models", "default"]];
|
|
assert_eq!(
|
|
ids_touching_paths(&cached, path),
|
|
vec!["remote-1".to_string()]
|
|
);
|
|
|
|
// Clear the process-global cache so other tests aren't affected.
|
|
set_remote_campaigns_from_settings(Some(&RemoteSettings::default()));
|
|
}
|
|
|
|
/// An override-supplied campaign whose id is already dismissed is dropped.
|
|
#[test]
|
|
#[serial]
|
|
fn dismissed_id_is_dropped_from_override() {
|
|
let _env = EnvGuard::set(
|
|
"KIGI_CAMPAIGNS_OVERRIDE",
|
|
r#"[{"id":"seen","models":{"default":"m"}}]"#,
|
|
);
|
|
let layers = ConfigLayers::default();
|
|
let base = toml::Value::Table(Default::default());
|
|
let dismissed: HashSet<String> = ["seen".to_owned()].into_iter().collect();
|
|
let active = resolve_active_campaigns_from_layers(&layers, &base, &[], &dismissed);
|
|
assert!(active.is_empty(), "a dismissed id must not re-apply");
|
|
}
|
|
|
|
/// Corrupt `campaigns_state.json` is preserved as `*.json.corrupt`, the new
|
|
/// dismiss still lands, and the cap drops the oldest ids.
|
|
#[test]
|
|
fn dismiss_persists_handles_corrupt_and_caps() {
|
|
let home = tempdir().unwrap();
|
|
std::fs::write(campaigns_state_path(home.path()), "{ not json").unwrap();
|
|
dismiss_campaign_ids_at(home.path(), ["new-id".to_owned()]).unwrap();
|
|
assert!(
|
|
home.path().join("campaigns_state.json.corrupt").exists(),
|
|
"corrupt state must be renamed aside, not discarded"
|
|
);
|
|
|
|
dismiss_campaign_ids_at(home.path(), (0..40).map(|i| format!("id-{i}"))).unwrap();
|
|
let contents = std::fs::read_to_string(campaigns_state_path(home.path())).unwrap();
|
|
let set: HashSet<String> = serde_json::from_str::<CampaignsState>(&contents)
|
|
.unwrap()
|
|
.dismissed_ids
|
|
.into_iter()
|
|
.collect();
|
|
assert_eq!(set.len(), MAX_DISMISSED_IDS);
|
|
assert!(set.contains("id-39"));
|
|
assert!(!set.contains("new-id"), "oldest ids evicted past the cap");
|
|
}
|
|
|
|
/// A remote campaign's flattened JSON patch becomes a full TOML patch (any
|
|
/// field), and an id-less entry is dropped.
|
|
#[test]
|
|
fn remote_campaign_to_entry_builds_full_patch() {
|
|
let mut patch = serde_json::Map::new();
|
|
patch.insert(
|
|
"models".into(),
|
|
serde_json::json!({ "default": "remote-model" }),
|
|
);
|
|
patch.insert("features".into(), serde_json::json!({ "web_fetch": true }));
|
|
let entry = remote_campaign_to_entry(CampaignOverride {
|
|
id: Some("r1".into()),
|
|
patch,
|
|
})
|
|
.expect("entry with id + patch survives");
|
|
assert_eq!(
|
|
entry.patch["models"]["default"].as_str(),
|
|
Some("remote-model")
|
|
);
|
|
assert_eq!(entry.patch["features"]["web_fetch"].as_bool(), Some(true));
|
|
|
|
let no_id = CampaignOverride {
|
|
id: None,
|
|
patch: {
|
|
let mut p = serde_json::Map::new();
|
|
p.insert("models".into(), serde_json::json!({ "default": "x" }));
|
|
p
|
|
},
|
|
};
|
|
assert!(remote_campaign_to_entry(no_id).is_none());
|
|
}
|
|
|
|
/// The remote JSON shape accepts `campaign_id` as an alias for `id`, matching
|
|
/// the TOML `CampaignMeta` contract so the two sides can't drift. The id key
|
|
/// (either spelling) must be *consumed*, never leak into the flattened patch —
|
|
/// a leaked key would deep-merge junk into every effective config.
|
|
#[test]
|
|
fn campaign_id_json_alias_is_accepted_and_does_not_leak_into_patch() {
|
|
for raw in [
|
|
r#"[{"campaign_id":"r1","models":{"default":"m"}}]"#,
|
|
r#"[{"id":"r1","models":{"default":"m"}}]"#,
|
|
] {
|
|
let list: Vec<CampaignOverride> = serde_json::from_str(raw).unwrap();
|
|
let entry = remote_campaign_to_entry(list.into_iter().next().unwrap())
|
|
.expect("entry with id survives");
|
|
assert_eq!(entry.id, "r1");
|
|
assert!(
|
|
entry.patch.get("id").is_none() && entry.patch.get("campaign_id").is_none(),
|
|
"id keys must not leak into the patch: {raw}"
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Every registry row's `reset` clears the campaign-driven runtime state a
|
|
/// prior `store` set (used on the resolution-failure path so flags can't go
|
|
/// stale).
|
|
#[test]
|
|
fn campaign_field_reset_clears_driven_state() {
|
|
let mut cfg = crate::agent::config::Config::default();
|
|
for field in CAMPAIGN_FIELDS {
|
|
(field.store)(
|
|
&mut cfg,
|
|
CampaignFieldValue {
|
|
value: Some(toml::Value::String("campaign-model".into())),
|
|
driven: true,
|
|
recovery: Some(toml::Value::String("base-model".into())),
|
|
},
|
|
);
|
|
}
|
|
assert!(cfg.models.default_is_campaign_driven);
|
|
assert!(cfg.models.pre_campaign_default.is_some());
|
|
|
|
for field in CAMPAIGN_FIELDS {
|
|
(field.reset)(&mut cfg);
|
|
}
|
|
assert!(!cfg.models.default_is_campaign_driven);
|
|
assert_eq!(cfg.models.pre_campaign_default, None);
|
|
// The field *value* is left as loaded; reset only clears the metadata.
|
|
assert_eq!(cfg.models.default.as_deref(), Some("campaign-model"));
|
|
}
|
|
}
|