返回 CodeWhale
automation_panel.rs
根目录 / crates / tui / src / tui / automation_panel.rs
1 //! Live scheduled-work projection for the activity band
2 //! (AUTOMATION-VISIBILITY-SPEC §2.1).
3 //!
4 //! One owner per fact: this projection is the single reader of "how much
5 //! scheduled work is live" — Active automations (`N`), runs currently
6 //! Queued|Running (`M`), and failed runs not yet acknowledged. The top
7 //! strip reads it; `background_indicator.rs` keeps owning
8 //! shells/tasks/agents and never learns about automations.
9 //!
10 //! The disk scan lives in `tui/ui/task_projection.rs`
11 //! (`refresh_automation_panel`): taken on a blocking thread, folded on the
12 //! same ~2.5 s cadence as the task panel. Everything here is a pure,
13 //! testable fold over that scan.
14 //!
15 //! The fold also detects the one transition Slice 1 can observe without an
16 //! engine change: a run this session watched go live and then settle.
17 //! Each such run is reported once (`ScanDelta::settled`) so the projection's
18 //! owner can post the typed `completed in background` / `failed` receipt
19 //! (spec §2.2) — the transcript learns about background work from the same
20 //! scan that lights the band, never from a second reader.
21
22 use std::collections::{BTreeMap, BTreeSet};
23
24 use chrono::{DateTime, Utc};
25
26 use crate::automation_manager::{
27 AutomationRecord, AutomationRunRecord, AutomationRunStatus, AutomationStatus,
28 };
29 use codewhale_localization::{Locale, MessageId, tr};
30 use codewhale_palette::ChromeInk;
31
32 /// Band glyph for the automation slot. Composed in code (locales/AGENTS.md);
33 /// the ASCII-safe projection comes from `glyphs::ascii_fallback`.
34 const SLOT_GLYPH: &str = "⏱";
35
36 /// One scan of the durable automation store — every definition plus the
37 /// newest runs of each — taken off the runtime thread and handed to
38 /// [`AutomationPanelState::fold_scan`] on the task-panel tick.
39 #[derive(Debug, Default)]
40 pub struct AutomationScan {
41 pub records: Vec<AutomationRecord>,
42 pub runs: Vec<AutomationRunRecord>,
43 }
44
45 /// Snapshot of live scheduled work, refreshed on the task-panel cadence.
46 #[derive(Debug, Clone, Default, PartialEq, Eq)]
47 pub struct AutomationPanelState {
48 /// Automations whose status is `Active`.
49 pub active_automations: usize,
50 /// Runs currently `Queued` or `Running` within the scan window.
51 pub live_runs: usize,
52 /// Ids of the runs behind `live_runs`, so the next fold can tell which
53 /// of them settled (a settle is reported once, then forgotten).
54 live_run_ids: BTreeSet<String>,
55 /// Which automation owns each live run (`run_id -> automation_id`), so
56 /// the next scan can re-fetch runs this session watched go live even
57 /// after newer runs push them past the newest-run window.
58 live_run_owners: BTreeMap<String, String>,
59 /// Failed runs (`{automation_id}/{run_id}`) observed this session and not
60 /// yet acknowledged by an automation-surface interaction. A failure, once
61 /// seen, stays unacknowledged even after it ages out of the scan window —
62 /// that is the point of the acknowledgement contract.
63 unacknowledged_failures: BTreeSet<String>,
64 /// Failures the operator already acknowledged. The ~2.5s fold re-sees
65 /// every persisted Failed run, so without this watermark it would
66 /// re-light an acknowledged failure forever. An acknowledgement lasts
67 /// until the run leaves the records (automation deleted).
68 acknowledged_failures: BTreeSet<String>,
69 }
70
71 /// How a run the session watched go live ended up.
72 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
73 pub enum SettledOutcome {
74 Completed,
75 Failed,
76 /// The run was canceled — by the operator, a cancel timeout or shutdown
77 /// (#6162). It gets a receipt so a stopped run is never silent, but it
78 /// never lights the failure demand: nothing crashed.
79 Canceled,
80 }
81
82 /// A run this session saw live that has now settled — surfaced exactly once
83 /// so the owner can post its receipt (spec §2.2). Carries the raw definition
84 /// name and error; the receipt producer display-sanitizes them.
85 #[derive(Debug, Clone, PartialEq, Eq)]
86 pub struct SettledRun {
87 pub automation_id: String,
88 pub automation_name: String,
89 pub run_id: String,
90 pub outcome: SettledOutcome,
91 /// Wall-clock `started_at → ended_at` when both are recorded.
92 pub duration_ms: Option<u64>,
93 pub error: Option<String>,
94 }
95
96 /// What one fold changed: whether the band needs a repaint, and which runs
97 /// settled since the previous fold.
98 #[derive(Debug, Default, PartialEq, Eq)]
99 pub struct ScanDelta {
100 /// Anything the band paints changed (#3757: an unchanged scan must not
101 /// force a redraw).
102 pub changed: bool,
103 pub settled: Vec<SettledRun>,
104 }
105
106 impl AutomationPanelState {
107 /// Fold one scan into the projection.
108 ///
109 /// A run failure counts only when it finished at or after session start:
110 /// the scheduler is host-durable, and without that gate every TUI launch
111 /// would re-light the band for history the operator already saw. Settled
112 /// runs are likewise gated on having been seen live by a previous fold,
113 /// so the first scan of a session never replays old receipts.
114 pub(crate) fn fold_scan(
115 &mut self,
116 records: &[AutomationRecord],
117 runs: &[AutomationRunRecord],
118 session_started_at: DateTime<Utc>,
119 ) -> ScanDelta {
120 let previous = std::mem::take(self);
121 self.active_automations = records
122 .iter()
123 .filter(|record| matches!(record.status, AutomationStatus::Active))
124 .count();
125 self.live_run_ids = runs
126 .iter()
127 .filter(|run| {
128 matches!(
129 run.status,
130 AutomationRunStatus::Queued | AutomationRunStatus::Running
131 )
132 })
133 .map(|run| run.id.clone())
134 .collect();
135 self.live_run_owners = runs
136 .iter()
137 .filter(|run| self.live_run_ids.contains(&run.id))
138 .map(|run| (run.id.clone(), run.automation_id.clone()))
139 .collect();
140 self.live_runs = self.live_run_ids.len();
141 let names: BTreeMap<&str, &str> = records
142 .iter()
143 .map(|record| (record.id.as_str(), record.name.as_str()))
144 .collect();
145 let settled =
146 runs.iter()
147 .filter(|run| previous.live_run_ids.contains(&run.id))
148 .filter_map(|run| {
149 let outcome = match run.status {
150 AutomationRunStatus::Completed => SettledOutcome::Completed,
151 AutomationRunStatus::Failed => SettledOutcome::Failed,
152 AutomationRunStatus::Canceled => SettledOutcome::Canceled,
153 AutomationRunStatus::Queued | AutomationRunStatus::Running => return None,
154 };
155 Some(SettledRun {
156 automation_id: run.automation_id.clone(),
157 automation_name: names
158 .get(run.automation_id.as_str())
159 .map_or_else(|| run.automation_id.clone(), |name| (*name).to_string()),
160 run_id: run.id.clone(),
161 outcome,
162 duration_ms: run.started_at.zip(run.ended_at).map(|(started, ended)| {
163 (ended - started).num_milliseconds().max(0) as u64
164 }),
165 error: run.error.clone(),
166 })
167 })
168 .collect();
169 // Runs of deleted automations drop out; the surviving ids keep their
170 // acknowledgement demand. The acknowledged watermark sheds deleted
171 // automations the same way so it cannot grow unbounded across a
172 // long session.
173 let automation_ids: BTreeSet<&str> =
174 records.iter().map(|record| record.id.as_str()).collect();
175 let mut unacknowledged: BTreeSet<String> = previous
176 .unacknowledged_failures
177 .iter()
178 .filter(|key| {
179 key.split_once('/')
180 .is_some_and(|(automation_id, _)| automation_ids.contains(automation_id))
181 })
182 .cloned()
183 .collect();
184 let acknowledged: BTreeSet<String> = previous
185 .acknowledged_failures
186 .iter()
187 .filter(|key| {
188 key.split_once('/')
189 .is_some_and(|(automation_id, _)| automation_ids.contains(automation_id))
190 })
191 .cloned()
192 .collect();
193 self.acknowledged_failures = acknowledged;
194 for run in runs {
195 let finished_at = run.ended_at.unwrap_or(run.created_at);
196 if matches!(run.status, AutomationRunStatus::Failed)
197 && finished_at >= session_started_at
198 {
199 let key = format!("{}/{}", run.automation_id, run.id);
200 if !self.acknowledged_failures.contains(&key) {
201 unacknowledged.insert(key);
202 }
203 }
204 }
205 self.unacknowledged_failures = unacknowledged;
206 let changed = self.active_automations != previous.active_automations
207 || self.live_runs != previous.live_runs
208 || self.unacknowledged_failures != previous.unacknowledged_failures;
209 ScanDelta { changed, settled }
210 }
211
212 /// The operator engaged with the automation surface (`/automation …`;
213 /// the Slice-2 panel hooks the same call): every failure observed so far
214 /// is acknowledged and the band ink settles. The acknowledgement rides
215 /// the watermark — the next fold re-sees the same persisted Failed runs
216 /// and must not re-light them.
217 pub fn acknowledge_failures(&mut self) {
218 self.acknowledged_failures
219 .extend(self.unacknowledged_failures.iter().cloned());
220 self.unacknowledged_failures.clear();
221 }
222
223 #[must_use]
224 pub fn has_unacknowledged_failure(&self) -> bool {
225 !self.unacknowledged_failures.is_empty()
226 }
227
228 /// Live runs this session is watching (`run_id -> automation_id`), so
229 /// the next scan can re-fetch them even after newer runs push them
230 /// past the newest-run window.
231 pub(crate) fn live_run_owners(&self) -> BTreeMap<String, String> {
232 self.live_run_owners.clone()
233 }
234
235 /// Activity-band slot text: `⏱ N scheduled` while any automation is
236 /// Active, plus `· M running` while runs are Queued|Running. Pausing an
237 /// automation does not cancel its already-enqueued runs, so the slot
238 /// stays up while either count is nonzero and vanishes only at zero —
239 /// it never becomes permanent furniture (spec §7.2).
240 #[must_use]
241 pub fn activity_slot(&self, locale: Locale) -> Option<String> {
242 if self.active_automations == 0 && self.live_runs == 0 {
243 return None;
244 }
245 if self.active_automations == 0 {
246 // Runs outlive their paused automation: show the live work,
247 // not the (zero) scheduled count.
248 return Some(format!(
249 "{SLOT_GLYPH} {} {}",
250 self.live_runs,
251 tr(locale, MessageId::AutomationRunStatusRunning)
252 ));
253 }
254 let mut slot = format!(
255 "{SLOT_GLYPH} {} {}",
256 self.active_automations,
257 tr(locale, MessageId::AutomationBandScheduled)
258 );
259 if self.live_runs > 0 {
260 slot.push_str(&format!(
261 " · {} {}",
262 self.live_runs,
263 tr(locale, MessageId::AutomationRunStatusRunning)
264 ));
265 }
266 Some(slot)
267 }
268
269 /// Compact-tier form of the same fact: `⏱ 2·1` (scheduled·running).
270 /// Chrome sheds before content on narrow terminals, so the live-work
271 /// count abbreviates instead of vanishing; still zero-suppressed on
272 /// both counts.
273 #[must_use]
274 pub fn activity_slot_compact(&self) -> Option<String> {
275 if self.active_automations == 0 && self.live_runs == 0 {
276 return None;
277 }
278 Some(if self.live_runs > 0 {
279 if self.active_automations == 0 {
280 format!("{SLOT_GLYPH} ·{}", self.live_runs)
281 } else {
282 format!(
283 "{SLOT_GLYPH} {}·{}",
284 self.active_automations, self.live_runs
285 )
286 }
287 } else {
288 format!("{SLOT_GLYPH} {}", self.active_automations)
289 })
290 }
291
292 /// Slot ink, the grammar table's existing Goal-chip rule: `Info` idle,
293 /// `Active` while a run is live, `Attention` when a run failed since last
294 /// acknowledgement. Never `Failure` — a failed report job is not a
295 /// product failure.
296 #[must_use]
297 pub fn activity_ink(&self) -> ChromeInk {
298 if self.has_unacknowledged_failure() {
299 ChromeInk::Attention
300 } else if self.live_runs > 0 {
301 ChromeInk::Active
302 } else {
303 ChromeInk::Info
304 }
305 }
306 }
307
308 #[cfg(test)]
309 mod tests {
310 use super::*;
311
312 fn record(id: &str, status: AutomationStatus) -> AutomationRecord {
313 let now = Utc::now();
314 AutomationRecord {
315 schema_version: 1,
316 execution_scope: Some(crate::task_manager::test_execution_scope("test")),
317 id: id.to_string(),
318 name: id.to_string(),
319 prompt: "prompt".to_string(),
320 rrule: "FREQ=DAILY".to_string(),
321 cwds: Vec::new(),
322 model: None,
323 model_provider: None,
324 model_provider_id: None,
325 mode: None,
326 allow_shell: None,
327 trust_mode: None,
328 auto_approve: None,
329 delivery_mode: None,
330 status,
331 created_at: now,
332 updated_at: now,
333 next_run_at: None,
334 last_run_at: None,
335 }
336 }
337
338 fn run(
339 automation_id: &str,
340 id: &str,
341 status: AutomationRunStatus,
342 ended_at: Option<DateTime<Utc>>,
343 ) -> AutomationRunRecord {
344 let now = Utc::now();
345 AutomationRunRecord {
346 schema_version: 1,
347 id: id.to_string(),
348 automation_id: automation_id.to_string(),
349 scheduled_for: now,
350 status,
351 created_at: now,
352 started_at: Some(now),
353 ended_at,
354 task_id: None,
355 thread_id: None,
356 turn_id: None,
357 error: None,
358 dispatch: None,
359 }
360 }
361
362 #[test]
363 fn activity_slot_counts_and_zero_suppression() {
364 let session_started_at = Utc::now();
365 let mut panel = AutomationPanelState::default();
366 assert_eq!(panel.activity_slot(Locale::En), None, "zero-suppressed");
367
368 panel.fold_scan(
369 &[
370 record("a1", AutomationStatus::Active),
371 record("a2", AutomationStatus::Active),
372 record("a3", AutomationStatus::Paused),
373 ],
374 &[
375 run("a1", "r1", AutomationRunStatus::Running, None),
376 run("a2", "r2", AutomationRunStatus::Completed, Some(Utc::now())),
377 ],
378 session_started_at,
379 );
380 assert_eq!(panel.active_automations, 2, "paused automation excluded");
381 assert_eq!(panel.live_runs, 1, "completed run excluded");
382 assert_eq!(
383 panel.activity_slot(Locale::En).as_deref(),
384 Some("⏱ 2 scheduled · 1 running")
385 );
386 assert_eq!(panel.activity_ink(), ChromeInk::Active);
387
388 panel.fold_scan(
389 &[
390 record("a1", AutomationStatus::Active),
391 record("a2", AutomationStatus::Active),
392 ],
393 &[],
394 session_started_at,
395 );
396 assert_eq!(
397 panel.activity_slot(Locale::En).as_deref(),
398 Some("⏱ 2 scheduled"),
399 "no live runs: the running clause drops"
400 );
401 assert_eq!(panel.activity_ink(), ChromeInk::Info);
402 }
403
404 #[test]
405 fn failure_ink_holds_until_acknowledged_and_survives_the_scan_window() {
406 let session_started_at = Utc::now();
407 let mut panel = AutomationPanelState::default();
408 panel.fold_scan(
409 &[record("a1", AutomationStatus::Active)],
410 &[run(
411 "a1",
412 "r1",
413 AutomationRunStatus::Failed,
414 Some(Utc::now()),
415 )],
416 session_started_at,
417 );
418 assert!(panel.has_unacknowledged_failure());
419 assert_eq!(panel.activity_ink(), ChromeInk::Attention);
420
421 // The failed run ages out of the scan window: the acknowledgement
422 // demand survives — a capped scan must not quietly settle the band.
423 panel.fold_scan(
424 &[record("a1", AutomationStatus::Active)],
425 &[],
426 session_started_at,
427 );
428 assert_eq!(panel.activity_ink(), ChromeInk::Attention);
429
430 panel.acknowledge_failures();
431 assert_eq!(panel.activity_ink(), ChromeInk::Info);
432
433 // The next fold re-sees the same persisted Failed run and must not
434 // re-light the acknowledged failure.
435 panel.fold_scan(
436 &[record("a1", AutomationStatus::Active)],
437 &[run(
438 "a1",
439 "r1",
440 AutomationRunStatus::Failed,
441 Some(Utc::now()),
442 )],
443 session_started_at,
444 );
445 assert!(
446 !panel.has_unacknowledged_failure(),
447 "an acknowledged failure must not re-light on the next scan"
448 );
449 assert_eq!(panel.activity_ink(), ChromeInk::Info);
450
451 // A NEW failed run is still a fresh acknowledgement demand.
452 panel.fold_scan(
453 &[record("a1", AutomationStatus::Active)],
454 &[run(
455 "a1",
456 "r2",
457 AutomationRunStatus::Failed,
458 Some(Utc::now()),
459 )],
460 session_started_at,
461 );
462 assert!(panel.has_unacknowledged_failure());
463 }
464
465 #[test]
466 fn live_runs_stay_visible_when_their_automation_is_paused() {
467 // Pausing prevents future scheduling; it does not cancel the
468 // already-enqueued run. The projection must stay up while either
469 // count is nonzero.
470 let session_started_at = Utc::now();
471 let mut panel = AutomationPanelState::default();
472 panel.fold_scan(
473 &[record("a1", AutomationStatus::Paused)],
474 &[run("a1", "r1", AutomationRunStatus::Running, None)],
475 session_started_at,
476 );
477 assert_eq!(panel.active_automations, 0);
478 assert_eq!(panel.live_runs, 1);
479 assert_eq!(
480 panel.activity_slot(Locale::En).as_deref(),
481 Some("⏱ 1 running"),
482 "a paused automation's live run stays visible"
483 );
484 assert_eq!(panel.activity_slot_compact().as_deref(), Some("⏱ ·1"));
485 assert_eq!(panel.activity_ink(), ChromeInk::Active);
486
487 // Both counts at zero: the slot vanishes.
488 panel.fold_scan(
489 &[record("a1", AutomationStatus::Paused)],
490 &[],
491 session_started_at,
492 );
493 assert_eq!(panel.activity_slot(Locale::En), None);
494 assert_eq!(panel.activity_slot_compact(), None);
495 }
496
497 #[test]
498 fn pre_session_failures_do_not_light_the_band() {
499 let session_started_at = Utc::now();
500 let old = session_started_at - chrono::Duration::hours(2);
501 let mut stale_run = run("a1", "r0", AutomationRunStatus::Failed, Some(old));
502 stale_run.created_at = old;
503 let mut panel = AutomationPanelState::default();
504 panel.fold_scan(
505 &[record("a1", AutomationStatus::Active)],
506 &[stale_run],
507 session_started_at,
508 );
509 assert!(!panel.has_unacknowledged_failure());
510 assert_eq!(panel.activity_ink(), ChromeInk::Info);
511 }
512
513 #[test]
514 fn failures_of_deleted_automations_drop_out() {
515 let session_started_at = Utc::now();
516 let mut panel = AutomationPanelState::default();
517 panel.fold_scan(
518 &[record("a1", AutomationStatus::Active)],
519 &[run(
520 "a1",
521 "r1",
522 AutomationRunStatus::Failed,
523 Some(Utc::now()),
524 )],
525 session_started_at,
526 );
527 assert!(panel.has_unacknowledged_failure());
528 panel.fold_scan(&[], &[], session_started_at);
529 assert!(
530 !panel.has_unacknowledged_failure(),
531 "deleting the automation clears its failure demand"
532 );
533 }
534
535 #[test]
536 fn fold_scan_reports_only_visible_change() {
537 let session_started_at = Utc::now();
538 let mut panel = AutomationPanelState::default();
539 assert!(
540 panel
541 .fold_scan(
542 &[record("a1", AutomationStatus::Active)],
543 &[],
544 session_started_at
545 )
546 .changed
547 );
548 assert!(
549 !panel
550 .fold_scan(
551 &[record("a1", AutomationStatus::Active)],
552 &[],
553 session_started_at
554 )
555 .changed,
556 "an unchanged scan must not force a redraw"
557 );
558 }
559
560 /// A run the session watched go live settles exactly once, carrying the
561 /// definition name and its wall-clock duration; runs that were already
562 /// finished before the session saw them never replay as receipts.
563 #[test]
564 fn runs_watched_live_settle_once_with_name_and_duration() {
565 let session_started_at = Utc::now();
566 let started = Utc::now();
567 let mut panel = AutomationPanelState::default();
568 let mut docs = record("a1", AutomationStatus::Active);
569 docs.name = "Documentation".to_string();
570 let mut finished_earlier = run("a1", "r0", AutomationRunStatus::Completed, Some(started));
571 finished_earlier.started_at = Some(started);
572
573 // First scan: one live run, one already-finished run. Nothing settles
574 // — the finished run was never seen live.
575 let mut live = run("a1", "r1", AutomationRunStatus::Running, None);
576 live.started_at = Some(started);
577 let delta = panel.fold_scan(
578 std::slice::from_ref(&docs),
579 &[live, finished_earlier.clone()],
580 session_started_at,
581 );
582 assert!(delta.changed);
583 assert!(delta.settled.is_empty(), "{:?}", delta.settled);
584 assert_eq!(panel.live_runs, 1);
585
586 // Second scan: the live run completed 42 s later.
587 let mut done = run(
588 "a1",
589 "r1",
590 AutomationRunStatus::Completed,
591 Some(started + chrono::Duration::seconds(42)),
592 );
593 done.started_at = Some(started);
594 let delta = panel.fold_scan(
595 std::slice::from_ref(&docs),
596 &[done.clone(), finished_earlier.clone()],
597 session_started_at,
598 );
599 assert_eq!(
600 delta.settled,
601 vec![SettledRun {
602 automation_id: "a1".to_string(),
603 automation_name: "Documentation".to_string(),
604 run_id: "r1".to_string(),
605 outcome: SettledOutcome::Completed,
606 duration_ms: Some(42_000),
607 error: None,
608 }]
609 );
610 assert_eq!(panel.live_runs, 0);
611 assert_eq!(panel.activity_ink(), ChromeInk::Info);
612
613 // Third scan, same picture: the settle is not reported twice.
614 let delta = panel.fold_scan(
615 std::slice::from_ref(&docs),
616 &[done, finished_earlier],
617 session_started_at,
618 );
619 assert!(!delta.changed);
620 assert!(delta.settled.is_empty(), "{:?}", delta.settled);
621 }
622
623 /// A watched run that fails settles as `Failed` with its error, and the
624 /// same fold lights the band's acknowledgement demand.
625 #[test]
626 fn a_watched_run_that_fails_settles_with_its_error_and_lights_the_band() {
627 let session_started_at = Utc::now();
628 let mut panel = AutomationPanelState::default();
629 let records = [record("a1", AutomationStatus::Active)];
630 panel.fold_scan(
631 &records,
632 &[run("a1", "r1", AutomationRunStatus::Running, None)],
633 session_started_at,
634 );
635 let mut failed = run("a1", "r1", AutomationRunStatus::Failed, Some(Utc::now()));
636 failed.error = Some("provider timeout".to_string());
637 let delta = panel.fold_scan(&records, &[failed], session_started_at);
638 assert_eq!(delta.settled.len(), 1);
639 assert_eq!(delta.settled[0].outcome, SettledOutcome::Failed);
640 assert_eq!(delta.settled[0].error.as_deref(), Some("provider timeout"));
641 assert_eq!(panel.activity_ink(), ChromeInk::Attention);
642
643 // A canceled run leaves the live set with a receipt that names the
644 // cancellation (#6162), without lighting the failure demand.
645 panel.acknowledge_failures();
646 assert!(!panel.has_unacknowledged_failure());
647 panel.fold_scan(
648 &records,
649 &[run("a1", "r2", AutomationRunStatus::Queued, None)],
650 session_started_at,
651 );
652 let mut canceled = run("a1", "r2", AutomationRunStatus::Canceled, Some(Utc::now()));
653 canceled.error = Some("canceled by request".to_string());
654 let delta = panel.fold_scan(&records, &[canceled.clone()], session_started_at);
655 assert_eq!(delta.settled.len(), 1, "{:?}", delta.settled);
656 assert_eq!(delta.settled[0].outcome, SettledOutcome::Canceled);
657 assert_eq!(delta.settled[0].run_id, "r2");
658 assert_eq!(
659 delta.settled[0].error.as_deref(),
660 Some("canceled by request")
661 );
662 assert!(
663 !panel.has_unacknowledged_failure(),
664 "a cancellation is not a failure"
665 );
666
667 // The receipt is posted once: the same picture settles nothing more.
668 let delta = panel.fold_scan(&records, &[canceled], session_started_at);
669 assert!(delta.settled.is_empty(), "{:?}", delta.settled);
670 }
671
672 /// The spec's reservation check (§6 Slice 1 accept): no automation band
673 /// ink may resolve to the theme's failure color in any selectable preset.
674 #[test]
675 fn automation_band_ink_never_resolves_to_failure_red() {
676 for theme_id in codewhale_palette::SELECTABLE_THEMES {
677 let theme = theme_id.ui_theme();
678 for ink in [ChromeInk::Info, ChromeInk::Active, ChromeInk::Attention] {
679 assert_ne!(
680 ink.color(&theme),
681 theme.error_fg,
682 "theme '{}' spends Failure red on the automation band ({ink:?})",
683 theme_id.name()
684 );
685 }
686 }
687 }
688 }
689
689 lines RUST