返回 CodeWhale
skill_state.rs
根目录 / crates / runtime / src / skill_state.rs
1 //! Persistent enable/disable state shared by runtime/API Skill catalogs.
2 //!
3 //! Backs `GET /v1/skills` (`enabled` field per skill) and
4 //! `POST /v1/skills/{name}` (toggle). Discovery tells us which Skills exist;
5 //! this store is the final exact-name activation filter shared by prompts,
6 //! tools, TUI surfaces, sub-agents, and the API. Plugin trust/enablement stays
7 //! a separate bundle lifecycle gate.
8 //!
9 //! Storage shape (TOML at `~/.codewhale/skills_state.toml`, legacy `~/.deepseek/skills_state.toml`):
10 //!
11 //! ```toml
12 //! disabled = ["skill-name-1", "skill-name-2"]
13 //! ```
14 //!
15 //! Reserved `!codewhale-skill-state:1:*` entries retain legacy vetoes and
16 //! exact enables in that same set. The leading `!` cannot be a discovered
17 //! skill name. v0.10.0 writers preserve these strings, unlike unknown TOML
18 //! fields. This preserves new-reader policy through their serialization;
19 //! it does not make old readers understand distinct Unicode identities or
20 //! old lossy/no-op toggles express the new per-skill choices. Upgrade all
21 //! runtimes sharing this directory for consistent activation controls.
22 //!
23 //! Default state when the file does not exist: empty list (everything enabled).
24 //! A present but unreadable or malformed file is an error. Callers may keep
25 //! native Skills available for recovery, but reviewed plugin Skills must stay
26 //! hidden until their exact activation state can be read authoritatively.
27
28 use std::collections::BTreeSet;
29 use std::fs::{self, OpenOptions};
30 use std::path::{Path, PathBuf};
31
32 use anyhow::{Context, Result};
33 use serde::{Deserialize, Serialize};
34
35 const STATE_FILE_NAME: &str = "skills_state.toml";
36 const MARKER_PREFIX: &str = "!codewhale-skill-state:";
37 const ENABLED_PREFIX: &str = "!codewhale-skill-state:1:enabled:";
38 const HISTORY_PREFIX: &str = "!codewhale-skill-state:1:history:";
39
40 #[derive(Debug, Clone)]
41 pub struct SkillStateStore {
42 path: PathBuf,
43 disabled: BTreeSet<String>,
44 }
45
46 #[derive(Debug, Clone, Default, Serialize, Deserialize)]
47 struct OnDiskState {
48 #[serde(default)]
49 disabled: Vec<String>,
50 }
51
52 impl SkillStateStore {
53 pub fn load_default() -> Result<Self> {
54 let path = default_state_path()?;
55 Self::load_from(path)
56 }
57
58 pub fn load_from(path: PathBuf) -> Result<Self> {
59 let disabled = load_disabled(&path)?;
60 Ok(Self { path, disabled })
61 }
62
63 pub fn is_enabled(&self, skill_name: &str) -> bool {
64 self.is_enabled_with_legacy(skill_name, None)
65 }
66
67 /// Raw exact denies win even over a retained enable (including a later
68 /// v0.10.0 toggle). Enables override only inherited legacy vetoes.
69 pub fn is_enabled_with_legacy(&self, skill_name: &str, legacy_name: Option<&str>) -> bool {
70 if self.disabled.contains(skill_name) {
71 return false;
72 }
73 if self
74 .disabled
75 .contains(&format!("{ENABLED_PREFIX}{skill_name}"))
76 {
77 return true;
78 }
79 !self
80 .disabled
81 .contains(&format!("{HISTORY_PREFIX}{skill_name}"))
82 && !legacy_name.is_some_and(|legacy| {
83 self.disabled.contains(legacy)
84 || self.disabled.contains(&format!("{HISTORY_PREFIX}{legacy}"))
85 })
86 }
87
88 pub fn set_enabled(&mut self, skill_name: &str, enabled: bool) -> Result<()> {
89 self.set_enabled_with_persist(skill_name, enabled, persist_disabled)
90 }
91
92 /// Refresh the in-memory snapshot under the same shared lock used by
93 /// other Codewhale processes. Long-running Runtime API servers call this
94 /// before listing Skills so an external toggle becomes visible without a
95 /// restart.
96 pub fn refresh(&mut self) -> Result<()> {
97 let disabled = load_disabled(&self.path)?;
98 self.disabled = disabled;
99 Ok(())
100 }
101
102 /// Raw exact denies only; inherited vetoes need discovered identity
103 /// metadata and cannot be enumerated as a list of effective skill names.
104 pub fn disabled(&self) -> Vec<String> {
105 self.disabled
106 .iter()
107 .filter(|name| !name.starts_with(MARKER_PREFIX))
108 .cloned()
109 .collect()
110 }
111
112 fn set_enabled_with_persist(
113 &mut self,
114 skill_name: &str,
115 enabled: bool,
116 persist: impl FnOnce(&Path, &BTreeSet<String>) -> Result<()>,
117 ) -> Result<()> {
118 anyhow::ensure!(
119 !skill_name.is_empty() && !skill_name.starts_with(MARKER_PREFIX),
120 "invalid skill activation identity"
121 );
122 if let Some(parent) = self
123 .path
124 .parent()
125 .filter(|path| !path.as_os_str().is_empty())
126 {
127 fs::create_dir_all(parent)
128 .with_context(|| format!("create parent dir for {}", self.path.display()))?;
129 }
130 let lock_path = state_lock_path(&self.path);
131 let lock_file = open_state_lock(&lock_path, true)?;
132 let mut lock = fd_lock::RwLock::new(lock_file);
133 let _guard = lock
134 .write()
135 .with_context(|| format!("write-lock skill state at {}", self.path.display()))?;
136
137 // Reload while holding the cross-process writer lock. Applying the
138 // requested exact-name change to this latest snapshot merges updates
139 // from other Runtime API/TUI processes instead of replacing them with
140 // the caller's possibly stale in-memory view.
141 let previous = load_disabled_unlocked(&self.path)?;
142 let mut next = previous.clone();
143 // A raw name may also govern an undiscovered legacy collision cohort.
144 // Preserve every veto before removing any, without discovery writes.
145 next.extend(
146 previous
147 .iter()
148 .filter(|name| !name.is_empty() && !name.starts_with(MARKER_PREFIX))
149 .map(|name| format!("{HISTORY_PREFIX}{name}")),
150 );
151 let exact_enable = format!("{ENABLED_PREFIX}{skill_name}");
152 if enabled {
153 next.remove(skill_name);
154 next.insert(exact_enable);
155 } else {
156 next.insert(skill_name.to_string());
157 next.insert(format!("{HISTORY_PREFIX}{skill_name}"));
158 next.remove(&exact_enable);
159 }
160 if next != previous {
161 // Disk is authoritative. Publish to memory only after the atomic
162 // write succeeds so a failed persistence attempt cannot make this
163 // process report a toggle that no other process can observe.
164 persist(&self.path, &next)?;
165 }
166 self.disabled = next;
167 Ok(())
168 }
169 }
170
171 fn default_state_path() -> Result<PathBuf> {
172 // Listing, prompt construction, and doctor are read-only. The explicit
173 // mutation path creates the parent from `persist` when needed.
174 Ok(codewhale_config::codewhale_home()
175 .context("could not resolve Codewhale state directory")?
176 .join(STATE_FILE_NAME))
177 }
178
179 fn load_disabled(path: &Path) -> Result<BTreeSet<String>> {
180 let lock_path = state_lock_path(path);
181 if path_entry_exists(&lock_path)? {
182 let lock_file = open_state_lock(&lock_path, false)?;
183 let lock = fd_lock::RwLock::new(lock_file);
184 let _guard = lock
185 .read()
186 .with_context(|| format!("read-lock skill state at {}", path.display()))?;
187 return load_disabled_unlocked(path);
188 }
189 load_disabled_unlocked(path)
190 }
191
192 fn load_disabled_unlocked(path: &Path) -> Result<BTreeSet<String>> {
193 let raw = match fs::read_to_string(path) {
194 Ok(raw) => raw,
195 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
196 return Ok(BTreeSet::new());
197 }
198 Err(error) => {
199 return Err(error).with_context(|| format!("read skill state at {}", path.display()));
200 }
201 };
202 let parsed: OnDiskState =
203 toml::from_str(&raw).with_context(|| format!("parse skill state at {}", path.display()))?;
204 for entry in &parsed.disabled {
205 if entry.starts_with(MARKER_PREFIX) {
206 let identity = entry
207 .strip_prefix(ENABLED_PREFIX)
208 .or_else(|| entry.strip_prefix(HISTORY_PREFIX));
209 anyhow::ensure!(
210 identity.is_some_and(|name| !name.is_empty() && !name.starts_with(MARKER_PREFIX)),
211 "parse skill state at {}: unsupported or malformed activation marker",
212 path.display()
213 );
214 }
215 }
216 Ok(parsed.disabled.into_iter().collect())
217 }
218
219 fn persist_disabled(path: &Path, disabled: &BTreeSet<String>) -> Result<()> {
220 let on_disk = OnDiskState {
221 disabled: disabled.iter().cloned().collect(),
222 };
223 let body = toml::to_string_pretty(&on_disk).context("serialize skill state")?;
224 codewhale_config::persistence::atomic_write(path, body.as_bytes())
225 .with_context(|| format!("atomically persist skill state at {}", path.display()))
226 }
227
228 fn state_lock_path(path: &Path) -> PathBuf {
229 let mut name = path
230 .file_name()
231 .map(|name| name.to_os_string())
232 .unwrap_or_else(|| STATE_FILE_NAME.into());
233 name.push(".lock");
234 path.with_file_name(name)
235 }
236
237 fn path_entry_exists(path: &Path) -> Result<bool> {
238 match fs::symlink_metadata(path) {
239 Ok(_) => Ok(true),
240 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
241 Err(error) => Err(error).with_context(|| format!("inspect {}", path.display())),
242 }
243 }
244
245 fn open_state_lock(path: &Path, create: bool) -> Result<fs::File> {
246 let mut options = OpenOptions::new();
247 options
248 .read(true)
249 .write(true)
250 .create(create)
251 .truncate(false);
252 #[cfg(unix)]
253 {
254 use std::os::unix::fs::OpenOptionsExt as _;
255 options
256 .mode(0o600)
257 .custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC);
258 }
259 #[cfg(windows)]
260 {
261 use std::os::windows::fs::OpenOptionsExt as _;
262 options.custom_flags(0x0020_0000); // FILE_FLAG_OPEN_REPARSE_POINT
263 }
264 let file = options
265 .open(path)
266 .with_context(|| format!("open skill state lock at {}", path.display()))?;
267 validate_state_lock(path, &file)?;
268 Ok(file)
269 }
270
271 #[cfg(unix)]
272 fn validate_state_lock(path: &Path, file: &fs::File) -> Result<()> {
273 use std::os::unix::fs::MetadataExt as _;
274
275 let metadata = file
276 .metadata()
277 .with_context(|| format!("inspect skill state lock at {}", path.display()))?;
278 anyhow::ensure!(
279 metadata.is_file() && metadata.nlink() == 1,
280 "skill state lock at {} must be one regular, non-hard-linked file",
281 path.display()
282 );
283 Ok(())
284 }
285
286 #[cfg(windows)]
287 fn validate_state_lock(path: &Path, file: &fs::File) -> Result<()> {
288 use std::os::windows::fs::MetadataExt as _;
289
290 const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400;
291 let metadata = file
292 .metadata()
293 .with_context(|| format!("inspect skill state lock at {}", path.display()))?;
294 anyhow::ensure!(
295 metadata.is_file() && metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT == 0,
296 "skill state lock at {} must be a regular, non-reparse file",
297 path.display()
298 );
299 Ok(())
300 }
301
302 #[cfg(all(not(unix), not(windows)))]
303 fn validate_state_lock(path: &Path, file: &fs::File) -> Result<()> {
304 anyhow::ensure!(
305 file.metadata()
306 .with_context(|| format!("inspect skill state lock at {}", path.display()))?
307 .is_file(),
308 "skill state lock at {} must be a regular file",
309 path.display()
310 );
311 Ok(())
312 }
313
314 #[cfg(test)]
315 mod tests {
316 use super::*;
317 use tempfile::TempDir;
318
319 fn fresh() -> (TempDir, SkillStateStore) {
320 let dir = TempDir::new().unwrap();
321 let path = dir.path().join(STATE_FILE_NAME);
322 let store = SkillStateStore::load_from(path).unwrap();
323 (dir, store)
324 }
325
326 #[test]
327 fn missing_file_defaults_to_everything_enabled() {
328 let (_dir, store) = fresh();
329 assert!(store.is_enabled("anything"));
330 assert!(store.disabled().is_empty());
331 }
332
333 #[test]
334 fn disable_then_reload_persists() {
335 let (dir, mut store) = fresh();
336 store.set_enabled("foo", false).unwrap();
337 assert!(!store.is_enabled("foo"));
338
339 let reloaded = SkillStateStore::load_from(dir.path().join(STATE_FILE_NAME)).unwrap();
340 assert!(!reloaded.is_enabled("foo"));
341 assert!(reloaded.is_enabled("bar"));
342 }
343
344 #[test]
345 fn enable_removes_from_disabled_list() {
346 let (_dir, mut store) = fresh();
347 store.set_enabled("foo", false).unwrap();
348 store.set_enabled("foo", true).unwrap();
349 assert!(store.is_enabled("foo"));
350 assert!(store.disabled().is_empty());
351 }
352
353 #[test]
354 fn explicit_enable_records_a_choice_then_repeated_toggle_is_noop() {
355 let (dir, mut store) = fresh();
356 store.set_enabled("foo", true).unwrap();
357 assert!(store.disabled().is_empty());
358 let path = dir.path().join(STATE_FILE_NAME);
359 let before = fs::read(&path).unwrap();
360 store
361 .set_enabled_with_persist("foo", true, |_, _| {
362 panic!("repeating the same choice must not rewrite the store")
363 })
364 .unwrap();
365 assert_eq!(fs::read(path).unwrap(), before);
366 }
367
368 // The released v0.10.0 serde shape and exact-set writer semantics at
369 // 1be1a703b975fc0a6c125886c761141341615a32. This intentionally does not
370 // interpret markers or use the upgraded loader/writer.
371 fn released_writer_toggle(path: &Path, name: &str, enabled: bool) -> bool {
372 #[derive(Deserialize, Serialize)]
373 struct ReleasedState {
374 #[serde(default)]
375 disabled: Vec<String>,
376 }
377 let old: ReleasedState = toml::from_str(&fs::read_to_string(path).unwrap()).unwrap();
378 let mut next: BTreeSet<String> = old.disabled.into_iter().collect();
379 let changed = if enabled {
380 next.remove(name)
381 } else {
382 next.insert(name.to_string())
383 };
384 if changed {
385 fs::write(
386 path,
387 toml::to_string_pretty(&ReleasedState {
388 disabled: next.into_iter().collect(),
389 })
390 .unwrap(),
391 )
392 .unwrap();
393 }
394 changed
395 }
396
397 #[test]
398 fn legacy_veto_and_exact_choices_survive_released_writer_serialization() {
399 for legacy in ["skill", "pdf", "demo:skill"] {
400 let (dir, mut store) = fresh();
401 let path = dir.path().join(STATE_FILE_NAME);
402 let original = format!("disabled = [\"{legacy}\"]\n");
403 fs::write(&path, &original).unwrap();
404 store.refresh().unwrap();
405 let a = format!("{legacy}-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa");
406 let b = format!("{legacy}-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb");
407 assert!(!store.is_enabled_with_legacy(&a, Some(legacy)));
408 assert!(!store.is_enabled_with_legacy(&b, Some(legacy)));
409 assert_eq!(
410 fs::read_to_string(&path).unwrap(),
411 original,
412 "reads never migrate"
413 );
414
415 store.set_enabled(&a, true).unwrap();
416 assert!(released_writer_toggle(&path, "unrelated", false));
417 store.refresh().unwrap();
418 assert!(store.is_enabled_with_legacy(&a, Some(legacy)));
419 assert!(!store.is_enabled_with_legacy(&b, Some(legacy)));
420 // Old serialization cannot erase the history when removing L.
421 assert!(released_writer_toggle(&path, legacy, true));
422 store.refresh().unwrap();
423 assert!(!store.is_enabled_with_legacy(&b, Some(legacy)));
424 assert!(!store.is_enabled(legacy));
425
426 store.set_enabled(legacy, true).unwrap();
427 assert!(
428 store.is_enabled(legacy),
429 "literal ASCII identity has its own exception"
430 );
431 assert!(!store.is_enabled_with_legacy(&b, Some(legacy)));
432 assert!(released_writer_toggle(&path, legacy, false));
433 store.refresh().unwrap();
434 assert!(
435 !store.is_enabled(legacy),
436 "later raw exact disable beats an exception"
437 );
438 // The old lossy/no-op toggle cannot express a new per-skill
439 // revocation. Preserve this limit rather than claim parity.
440 assert!(!released_writer_toggle(&path, legacy, false));
441 store.refresh().unwrap();
442 assert!(store.is_enabled_with_legacy(&a, Some(legacy)));
443
444 store.set_enabled(&a, false).unwrap();
445 released_writer_toggle(&path, legacy, true);
446 store.refresh().unwrap();
447 assert!(!store.is_enabled_with_legacy(&a, Some(legacy)));
448 assert!(!store.is_enabled_with_legacy(&b, Some(legacy)));
449 }
450 }
451
452 #[test]
453 fn malformed_or_unknown_activation_markers_preserve_bytes_and_memory() {
454 let (dir, mut store) = fresh();
455 let path = dir.path().join(STATE_FILE_NAME);
456 store.set_enabled("kept", false).unwrap();
457 for marker in [
458 "!codewhale-skill-state:1:enabled:",
459 "!codewhale-skill-state:2:enabled:kept",
460 "!codewhale-skill-state:1:unknown:kept",
461 "!codewhale-skill-state:1:history:!codewhale-skill-state:1:enabled:kept",
462 ] {
463 let bytes = format!("disabled = [\"{marker}\"]\n");
464 fs::write(&path, &bytes).unwrap();
465 assert!(store.refresh().is_err());
466 assert!(store.set_enabled("kept", true).is_err());
467 assert!(!store.is_enabled("kept"));
468 assert_eq!(fs::read_to_string(&path).unwrap(), bytes);
469 }
470 fs::write(&path, "disabled = [\"unrelated opaque entry\"]\n").unwrap();
471 store.refresh().unwrap();
472 store.set_enabled("foo", false).unwrap();
473 assert!(!store.is_enabled("unrelated opaque entry"));
474 }
475
476 #[test]
477 fn failed_exact_enable_does_not_publish_or_replace_legacy_policy() {
478 let (dir, mut store) = fresh();
479 let path = dir.path().join(STATE_FILE_NAME);
480 let original = b"disabled = [\"skill\"]\n";
481 fs::write(&path, original).unwrap();
482 store.refresh().unwrap();
483 let name = "skill-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
484 let result = store.set_enabled_with_persist(name, true, |_, _| {
485 anyhow::bail!("injected persistence failure")
486 });
487 assert!(result.is_err());
488 assert!(!store.is_enabled_with_legacy(name, Some("skill")));
489 assert_eq!(fs::read(path).unwrap(), original);
490 }
491
492 #[test]
493 fn malformed_file_fails_closed() {
494 let dir = TempDir::new().unwrap();
495 let path = dir.path().join(STATE_FILE_NAME);
496 fs::write(&path, b"this is not toml = { broken").unwrap();
497 let error = SkillStateStore::load_from(path.clone()).unwrap_err();
498 assert!(error.to_string().contains("parse skill state"));
499 assert_eq!(
500 fs::read(&path).unwrap(),
501 b"this is not toml = { broken",
502 "a malformed authority file must remain untouched for recovery"
503 );
504 }
505
506 #[test]
507 fn disabled_list_is_deterministic_order() {
508 let (_dir, mut store) = fresh();
509 store.set_enabled("zeta", false).unwrap();
510 store.set_enabled("alpha", false).unwrap();
511 store.set_enabled("mu", false).unwrap();
512 assert_eq!(
513 store.disabled(),
514 vec!["alpha".to_string(), "mu".to_string(), "zeta".to_string()]
515 );
516 }
517
518 #[test]
519 fn stale_stores_merge_independent_toggles() {
520 let dir = TempDir::new().unwrap();
521 let path = dir.path().join(STATE_FILE_NAME);
522 let mut first = SkillStateStore::load_from(path.clone()).unwrap();
523 let mut second = SkillStateStore::load_from(path.clone()).unwrap();
524
525 first.set_enabled("alpha", false).unwrap();
526 second.set_enabled("beta", false).unwrap();
527
528 let persisted = SkillStateStore::load_from(path).unwrap();
529 assert!(!persisted.is_enabled("alpha"));
530 assert!(!persisted.is_enabled("beta"));
531 }
532
533 #[test]
534 fn stale_enable_request_reloads_before_noop_decision() {
535 let dir = TempDir::new().unwrap();
536 let path = dir.path().join(STATE_FILE_NAME);
537 let mut disabler = SkillStateStore::load_from(path.clone()).unwrap();
538 let mut stale_enabler = SkillStateStore::load_from(path.clone()).unwrap();
539
540 disabler.set_enabled("alpha", false).unwrap();
541 stale_enabler.set_enabled("alpha", true).unwrap();
542
543 assert!(stale_enabler.is_enabled("alpha"));
544 assert!(
545 SkillStateStore::load_from(path)
546 .unwrap()
547 .is_enabled("alpha")
548 );
549 }
550
551 #[test]
552 fn refresh_observes_external_process_snapshot() {
553 let dir = TempDir::new().unwrap();
554 let path = dir.path().join(STATE_FILE_NAME);
555 let mut writer = SkillStateStore::load_from(path.clone()).unwrap();
556 let mut reader = SkillStateStore::load_from(path).unwrap();
557
558 writer.set_enabled("alpha", false).unwrap();
559 assert!(reader.is_enabled("alpha"));
560 reader.refresh().unwrap();
561 assert!(!reader.is_enabled("alpha"));
562 }
563
564 #[test]
565 fn failed_persist_does_not_advance_in_memory_state() {
566 let (_dir, mut store) = fresh();
567 store.set_enabled("alpha", false).unwrap();
568
569 let error = store
570 .set_enabled_with_persist("beta", false, |_, _| {
571 anyhow::bail!("injected persistence failure")
572 })
573 .unwrap_err();
574
575 assert!(error.to_string().contains("injected persistence failure"));
576 assert!(!store.is_enabled("alpha"));
577 assert!(store.is_enabled("beta"));
578 }
579
580 #[test]
581 fn cross_process_toggles_serialize_and_merge() {
582 const CHILD_PATH: &str = "CODEWHALE_TEST_SKILL_STATE_PATH";
583 const CHILD_NAME: &str = "CODEWHALE_TEST_SKILL_STATE_NAME";
584 const TEST_NAME: &str = "skill_state::tests::cross_process_toggles_serialize_and_merge";
585
586 if let (Some(path), Some(name)) =
587 (std::env::var_os(CHILD_PATH), std::env::var_os(CHILD_NAME))
588 {
589 let path = PathBuf::from(path);
590 let name = name.to_string_lossy().into_owned();
591 let mut store = SkillStateStore::load_from(path.clone()).unwrap();
592 fs::write(path.with_file_name(format!("{name}.ready")), b"ready").unwrap();
593 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
594 while ["alpha", "beta"]
595 .iter()
596 .any(|peer| !path.with_file_name(format!("{peer}.ready")).exists())
597 {
598 assert!(
599 std::time::Instant::now() < deadline,
600 "peer skill-state process did not reach the mutation barrier"
601 );
602 std::thread::sleep(std::time::Duration::from_millis(10));
603 }
604 store.set_enabled(&name, false).unwrap();
605 return;
606 }
607
608 use std::process::{Command, Stdio};
609 use wait_timeout::ChildExt as _;
610
611 let dir = TempDir::new().unwrap();
612 let path = dir.path().join(STATE_FILE_NAME);
613 let executable = std::env::current_exe().expect("current test executable");
614 let mut children = ["alpha", "beta"].map(|name| {
615 Command::new(&executable)
616 .args(["--exact", TEST_NAME, "--nocapture", "--test-threads=1"])
617 .env(CHILD_PATH, &path)
618 .env(CHILD_NAME, name)
619 .stdin(Stdio::null())
620 .stdout(Stdio::null())
621 .stderr(Stdio::null())
622 .spawn()
623 .expect("spawn isolated skill-state writer")
624 });
625 for child in &mut children {
626 let status = match child
627 .wait_timeout(std::time::Duration::from_secs(15))
628 .expect("wait for isolated skill-state writer")
629 {
630 Some(status) => status,
631 None => {
632 let _ = child.kill();
633 let _ = child.wait();
634 panic!("isolated skill-state writer timed out");
635 }
636 };
637 assert!(status.success(), "isolated skill-state writer failed");
638 }
639
640 let persisted = SkillStateStore::load_from(path).unwrap();
641 assert!(!persisted.is_enabled("alpha"));
642 assert!(!persisted.is_enabled("beta"));
643 }
644 }
645
645 lines RUST