1use std::path::{Path, PathBuf};
84use std::time::Duration;
85
86use jiff::Timestamp;
87
88use crate::ask::{Question, QuestionStatus, Questions};
89use crate::config::Config;
90use crate::disk;
91use crate::queue::{HoldSource, OperatorResume, Queue, Task, TaskStatus};
92
93pub const NODE: &str = "triage";
96
97pub const DEPS_NODE: &str = "triage-deps";
101
102const SEAT: &str = "triage";
107
108const MANUAL_STALE_AFTER: Duration = Duration::from_secs(7 * 24 * 60 * 60);
120
121struct Wording {
125 lang: &'static str,
126 resume: &'static str,
127 wait: &'static str,
128 discard: &'static str,
129 resume_now: &'static str,
130 keep_held: &'static str,
131}
132
133const EN: Wording = Wording {
134 lang: "en",
135 resume: "resume it",
136 wait: "not yet",
137 discard: "discard it",
138 resume_now: "resume it",
139 keep_held: "keep it held",
140};
141
142const JA: Wording = Wording {
143 lang: "ja",
144 resume: "再開してよい",
145 wait: "まだ待って",
146 discard: "捨ててよい",
147 resume_now: "再開する",
148 keep_held: "まだ止めておく",
149};
150
151fn wording(language: &str) -> &'static Wording {
154 let l = language.trim();
155 if l.eq_ignore_ascii_case("ja")
156 || l.eq_ignore_ascii_case("jp")
157 || l.eq_ignore_ascii_case("japanese")
158 || l.eq_ignore_ascii_case("日本語")
159 {
160 &JA
161 } else {
162 &EN
163 }
164}
165
166impl Wording {
167 fn choices3(&self) -> Vec<String> {
168 vec![
169 self.resume.to_owned(),
170 self.wait.to_owned(),
171 self.discard.to_owned(),
172 ]
173 }
174
175 fn choices2(&self) -> Vec<String> {
176 vec![self.resume_now.to_owned(), self.keep_held.to_owned()]
177 }
178
179 fn source_label(&self, source: Option<HoldSource>) -> &'static str {
180 match (self.lang, source) {
181 ("ja", Some(HoldSource::Machine)) => "machine(機械による自動保留)",
182 ("ja", Some(HoldSource::Manual)) => "manual(操作者による手動保留)",
183 ("ja", None) => "unknown(schema 3 未満の旧レコード、または理由未記録)",
184 (_, Some(HoldSource::Machine)) => "machine (automatic recovery hold)",
185 (_, Some(HoldSource::Manual)) => "manual (an operator held this)",
186 (_, None) => "unknown (pre-schema-3 record, or never recorded)",
187 }
188 }
189
190 fn detail(&self, task: &Task, why: &str) -> String {
199 let none = if self.lang == "ja" {
200 "(記録なし)"
201 } else {
202 "(none recorded)"
203 };
204 let reason = task
205 .hold_reason
206 .as_deref()
207 .or(task.last_error.as_deref())
208 .unwrap_or(none);
209 format!(
210 "task: {} ({})\ntitle: {}\nhold source: {}\nhold reason: {reason}\n\n{why}",
211 task.id,
212 task.short(),
213 task.title,
214 self.source_label(task.hold_source),
215 )
216 }
217
218 fn summary_machine_unknown(&self, task: &Task) -> String {
219 if self.lang == "ja" {
220 format!("保留タスク {} の再開可否を判断してください", task.short())
221 } else {
222 format!("decide whether to resume held task {}", task.short())
223 }
224 }
225
226 fn why_machine(&self) -> &'static str {
227 if self.lang == "ja" {
228 "機械的な保留(machine hold)ですが、原因がすでに解消しているかを自動では判断できませんでした。"
229 } else {
230 "This is a machine hold, but whether its cause has resolved could not be \
231 checked automatically."
232 }
233 }
234
235 fn summary_conductor_override(&self, task: &Task) -> String {
236 if self.lang == "ja" {
237 format!(
238 "再開と回答済みのタスク {} を conductor が再び保留しました",
239 task.short()
240 )
241 } else {
242 format!(
243 "task {} was resumed at your word, but the conductor held it again",
244 task.short()
245 )
246 }
247 }
248
249 fn why_conductor_override(&self, o: &OperatorResume) -> String {
250 let reason = o.conductor_rehold.as_deref().unwrap_or_default();
251 if self.lang == "ja" {
252 format!(
253 "{} に再開と回答済みですが、conductor が再び hold しました。conductor の理由: \
254 {reason}\n\n強制再キューを選ぶと、以後 conductor はこのタスクを hold できません。",
255 o.at
256 )
257 } else {
258 format!(
259 "You answered \"resume\" at {}, but the conductor held the task again. \
260 Its reason: {reason}\n\nForcing a requeue stops the conductor from \
261 holding this task again.",
262 o.at
263 )
264 }
265 }
266
267 fn choices_conductor_override(&self) -> Vec<String> {
269 if self.lang == "ja" {
270 vec![
271 "強制再キュー(conductor は再 hold 不可)".to_owned(),
272 "手動 hold のまま".to_owned(),
273 "捨ててよい".to_owned(),
274 ]
275 } else {
276 vec![
277 "force requeue (conductor must not hold again)".to_owned(),
278 "keep held (manual)".to_owned(),
279 "discard".to_owned(),
280 ]
281 }
282 }
283
284 fn summary_legacy(&self, task: &Task) -> String {
285 if self.lang == "ja" {
286 format!(
287 "hold_source が不明な保留タスク {} を確認してください",
288 task.short()
289 )
290 } else {
291 format!(
292 "held task {} has no recorded hold source - please take a look",
293 task.short()
294 )
295 }
296 }
297
298 fn why_legacy(&self) -> &'static str {
299 if self.lang == "ja" {
300 "hold_source が記録されていません。schema 3 より前のレコードか、理由が記録されなかった \
301 holdです。人が意図して止めたのか、クラッシュや強制再起動で宙に浮いただけなのか、\
302 このデータからは区別できません。"
303 } else {
304 "No hold_source was recorded - either a pre-schema-3 record, or a hold whose \
305 reason was never written down. Whether this was a deliberate hold or the \
306 leftover of a crash cannot be told from the data alone."
307 }
308 }
309
310 fn summary_manual_stale(&self, task: &Task, days: i64) -> String {
311 if self.lang == "ja" {
312 format!(
313 "{days}日間 保留されたままの手動保留タスク {} を確認してください",
314 task.short()
315 )
316 } else {
317 format!(
318 "held task {} has been on a manual hold for {days} day(s)",
319 task.short()
320 )
321 }
322 }
323
324 fn why_manual(&self) -> &'static str {
325 if self.lang == "ja" {
326 "操作者が明示的に止めた保留ですが、長期間そのままになっています。まだ止めておくか、\
327 再開するか教えてください。"
328 } else {
329 "An operator held this on purpose, but it has sat untouched for a while. Say \
330 whether to keep holding it or resume it."
331 }
332 }
333}
334
335#[derive(Debug, Clone, Copy, PartialEq, Eq)]
337enum Bucket {
338 MachineUnknown,
340 ConductorOverride,
343 Legacy,
345 ManualStale,
347}
348
349#[derive(Debug, Clone, Default)]
351pub struct Report {
352 pub resumed: Vec<String>,
355 pub asked: Vec<String>,
357 pub answered: Vec<String>,
359 pub quarantined: Vec<String>,
364}
365
366impl Report {
367 pub fn is_empty(&self) -> bool {
370 self.resumed.is_empty()
371 && self.asked.is_empty()
372 && self.answered.is_empty()
373 && self.quarantined.is_empty()
374 }
375}
376
377fn repo_for(task: &Task) -> PathBuf {
381 if task.repo.as_os_str().is_empty() {
382 PathBuf::from(".")
383 } else {
384 task.repo.clone()
385 }
386}
387
388fn is_disk_hold(task: &Task) -> bool {
399 task.hold_reason.as_deref().is_some_and(|r| {
400 r.starts_with("not enough free space to start a run:")
401 || r.starts_with("could not measure free space on ")
402 })
403}
404
405fn machine_cause_resolved(task: &Task, cfg: &Config) -> Option<bool> {
410 if !is_disk_hold(task) {
411 return None;
412 }
413 let min = cfg.disk.min_free_bytes;
414 if min == 0 {
415 return Some(true);
418 }
419 let free = disk::free_bytes(&repo_for(task)).ok()?;
420 Some(disk::gate(free, min).is_none())
421}
422
423fn manual_is_stale(task: &Task, now: Timestamp) -> bool {
427 now.as_second() - task.updated_at.as_second() > MANUAL_STALE_AFTER.as_secs() as i64
428}
429
430fn marker_for(q: &Question) -> String {
433 format!("[triage:{}]", q.short())
434}
435
436fn already_applied(task: &Task, q: &Question) -> bool {
439 if task.triage_applied(&q.id) {
440 return true;
441 }
442 let marker = marker_for(q);
445 task.hold_reason
446 .as_deref()
447 .is_some_and(|r| r.contains(marker.as_str()))
448}
449
450fn latest_triage_question(questions: &Questions, task_id: &str) -> Option<Question> {
455 latest_question(questions, NODE, task_id)
456}
457
458fn latest_question(questions: &Questions, node: &str, task_id: &str) -> Option<Question> {
460 questions
461 .list()
462 .into_iter()
463 .filter(|q| q.node == node && q.run == task_id)
464 .max_by(|a, b| a.asked_at.cmp(&b.asked_at).then_with(|| a.id.cmp(&b.id)))
467}
468
469#[derive(Debug, Clone, Copy, PartialEq, Eq)]
472enum AnswerAction {
473 Resume,
477 Discard,
480 KeepHeld,
485}
486
487fn interpret_answer(q: &Question) -> AnswerAction {
500 let resolution = q.resolution().unwrap_or_default();
501 match q.choices.iter().position(|c| *c == resolution) {
502 Some(0) => AnswerAction::Resume,
503 Some(2) => AnswerAction::Discard,
504 _ => AnswerAction::KeepHeld,
505 }
506}
507
508fn keep_held_note(task: &Task, q: &Question, resolution: &str) -> String {
513 let marker = format!("{} operator: {resolution}", marker_for(q));
514 match task.hold_reason.as_deref() {
515 Some(existing) if !existing.is_empty() => format!("{existing}\n{marker}"),
516 _ => marker,
517 }
518}
519
520fn file_question(
524 questions: &Questions,
525 task: &Task,
526 bucket: Bucket,
527 w: &Wording,
528 now: Timestamp,
529) -> Option<Question> {
530 let (summary, why, choices) = match bucket {
531 Bucket::MachineUnknown => (
532 w.summary_machine_unknown(task),
533 w.why_machine().to_owned(),
534 w.choices3(),
535 ),
536 Bucket::ConductorOverride => (
537 w.summary_conductor_override(task),
538 task.resume_override
539 .as_ref()
540 .map(|o| w.why_conductor_override(o))
541 .unwrap_or_default(),
542 w.choices_conductor_override(),
543 ),
544 Bucket::Legacy => (
545 w.summary_legacy(task),
546 w.why_legacy().to_owned(),
547 w.choices3(),
548 ),
549 Bucket::ManualStale => {
550 let days = (now.as_second() - task.updated_at.as_second()) / (24 * 60 * 60);
551 (
552 w.summary_manual_stale(task, days),
553 w.why_manual().to_owned(),
554 w.choices2(),
555 )
556 }
557 };
558 let mut q = Question::new(
559 task.id.clone(),
560 NODE.to_owned(),
561 SEAT.to_owned(),
562 summary,
563 w.detail(task, &why),
564 choices,
565 );
566 questions.put(&mut q).ok()?;
567 Some(q)
568}
569
570fn quarantine_orphaned_blocked(queue: &Queue, questions: &Questions) -> Vec<String> {
588 let mut quarantined = Vec::new();
589 for listed in queue.list() {
590 if listed.status != TaskStatus::Blocked || listed.blocked_by.is_empty() {
591 continue;
592 }
593 let Ok(_claim) = queue.claim(&listed.id) else {
594 continue;
595 };
596 let Ok(mut task) = queue.get(&listed.id) else {
597 continue;
598 };
599 if task.status != TaskStatus::Blocked {
600 continue;
601 }
602 let missing = crate::queue::missing_blockers(queue, questions, &task.blocked_by);
603 if missing.is_empty() {
604 continue;
605 }
606 task.hold_machine(Some(crate::queue::missing_blocker_hold_reason(
607 &task.blocked_by,
608 &missing,
609 )));
610 if queue.put(&mut task).is_ok() {
611 quarantined.push(task.id.clone());
612 }
613 }
614 quarantined
615}
616
617#[derive(Debug, Clone, Copy, PartialEq, Eq)]
620enum DepsAction {
621 Release,
622 Discard,
623 Detach,
624 Nothing,
626}
627
628fn interpret_deps_answer(q: &Question) -> DepsAction {
629 let resolution = q.resolution().unwrap_or_default();
630 match q.choices.iter().position(|c| *c == resolution) {
631 Some(0) => DepsAction::Release,
632 Some(1) => DepsAction::Discard,
633 Some(2) => DepsAction::Detach,
634 _ => DepsAction::Nothing,
635 }
636}
637
638fn deps_texts(w: &Wording, root: &Task, dependants: &[&Task]) -> (String, String, Vec<String>) {
640 let ja = w.lang == "ja";
641 let reason = root
642 .hold_reason
643 .as_deref()
644 .or(root.last_error.as_deref())
645 .unwrap_or(if ja {
646 "(記録なし)"
647 } else {
648 "(none recorded)"
649 });
650 let list: String = dependants
651 .iter()
652 .map(|t| format!("- {} {}\n", t.short(), t.title))
653 .collect();
654 if ja {
655 (
656 format!(
657 "{} ({}) が {} 件のタスクを止めています",
658 root.short(),
659 root.status.as_str(),
660 dependants.len()
661 ),
662 format!(
663 "task: {} ({})\ntitle: {}\n状態: {}\n理由: {reason}\n\n\
664 このタスクは自動では実行されないため、依存している次のタスクは永遠に待ち続けます:\n{list}",
665 root.id,
666 root.short(),
667 root.title,
668 root.status.as_str()
669 ),
670 vec![
671 "依存先を再開する".to_owned(),
672 "依存先を捨てる(依存タスクは切り離して実行)".to_owned(),
673 "依存タスクを切り離す(依存先はそのまま)".to_owned(),
674 ],
675 )
676 } else {
677 (
678 format!(
679 "{} ({}) is freezing {} blocked task(s)",
680 root.short(),
681 root.status.as_str(),
682 dependants.len()
683 ),
684 format!(
685 "task: {} ({})\ntitle: {}\nstatus: {}\nreason: {reason}\n\n\
686 Nothing in the loop will ever run this task, so these dependants wait \
687 forever:\n{list}",
688 root.id,
689 root.short(),
690 root.title,
691 root.status.as_str()
692 ),
693 vec![
694 "release the dependency".to_owned(),
695 "discard the dependency (dependants are detached and run)".to_owned(),
696 "detach the dependants (the dependency stays as it is)".to_owned(),
697 ],
698 )
699 }
700}
701
702fn detach_dependants(queue: &Queue, root: &str) {
706 for listed in queue.list() {
707 if listed.status != TaskStatus::Blocked || !listed.blocked_by.iter().any(|b| b == root) {
708 continue;
709 }
710 let Ok(_claim) = queue.claim(&listed.id) else {
711 continue;
712 };
713 let Ok(mut t) = queue.get(&listed.id) else {
714 continue;
715 };
716 if t.status != TaskStatus::Blocked {
717 continue;
718 }
719 t.unblock(root);
720 let _ = queue.put(&mut t);
721 }
722}
723
724fn apply_deps_answer(queue: &Queue, questions: &Questions, q: &Question, now: Timestamp) -> bool {
728 let Ok(_claim) = queue.claim(&q.run) else {
729 return false;
730 };
731 let Ok(mut root) = queue.get(&q.run) else {
732 return false;
733 };
734 if root.triage_applied(&q.id) {
735 return false;
736 }
737 match interpret_deps_answer(q) {
738 DepsAction::Release => {
739 if root.status == TaskStatus::Running {
740 return false;
741 }
742 root.release();
743 root.resume_override = Some(OperatorResume {
744 question_id: q.id.clone(),
745 at: now,
746 conductor_rehold: None,
747 forced: false,
748 });
749 }
750 DepsAction::Discard => {
751 detach_dependants(queue, &root.id);
752 return queue.remove(&root.id, false, questions).is_ok();
753 }
754 DepsAction::Detach => detach_dependants(queue, &root.id),
755 DepsAction::Nothing => return false,
760 }
761 root.mark_triage_applied(&q.id);
762 queue.put(&mut root).is_ok()
763}
764
765fn ask_about_stuck_roots(
774 queue: &Queue,
775 questions: &Questions,
776 config_override: Option<&Path>,
777 now: Timestamp,
778 report: &mut Report,
779) {
780 for q in questions.list() {
781 if q.node == DEPS_NODE
782 && q.status == QuestionStatus::Answered
783 && apply_deps_answer(queue, questions, &q, now)
784 {
785 report.answered.push(q.run.clone());
786 }
787 }
788
789 let all = questions.list();
790 let inv = crate::blockers::Inventory::new(queue.list(), &all);
791 let mut frozen: std::collections::BTreeMap<String, Vec<String>> = Default::default();
792 for (id, roots) in inv.stuck() {
793 for root in roots {
794 if root != id {
795 frozen.entry(root).or_default().push(id.clone());
796 }
797 }
798 }
799 for mut q in all
803 .iter()
804 .filter(|q| q.node == DEPS_NODE && q.status.open())
805 .cloned()
806 {
807 if !frozen.contains_key(&q.run) {
808 q.abandon("nothing waits on this task anymore");
809 let _ = questions.put(&mut q);
810 }
811 }
812
813 for (root_id, dependant_ids) in &frozen {
814 let Some(root) = inv.task(root_id) else {
815 continue;
816 };
817 let dependants: Vec<&Task> = dependant_ids.iter().filter_map(|d| inv.task(d)).collect();
818 let latest = all
819 .iter()
820 .filter(|q| q.node == DEPS_NODE && q.run == *root_id)
821 .max_by(|a, b| a.asked_at.cmp(&b.asked_at).then_with(|| a.id.cmp(&b.id)));
822 match latest {
823 Some(q) if q.status.open() => continue,
824 Some(q) if q.status == QuestionStatus::Answered && !root.triage_applied(&q.id) => {
825 continue;
826 }
827 Some(q) if q.status == QuestionStatus::Abandoned => {
828 let changed = root.updated_at > q.asked_at
829 || dependants.iter().any(|t| t.updated_at > q.asked_at);
830 if !changed {
831 continue;
832 }
833 }
834 _ => {}
835 }
836 if pending_for(questions, root) {
837 continue;
838 }
839 let cfg = Config::discover(&repo_for(root), config_override)
840 .ok()
841 .map(|(c, _)| c);
842 let w = wording(cfg.as_ref().map_or("en", |c| c.graph.language.as_str()));
843 let (summary, detail, choices) = deps_texts(w, root, &dependants);
844 let mut question = Question::new(
845 root.id.clone(),
846 DEPS_NODE.to_owned(),
847 SEAT.to_owned(),
848 summary,
849 detail,
850 choices,
851 );
852 if questions.put(&mut question).is_ok() {
853 report.asked.push(root.id.clone());
854 }
855 }
856}
857
858pub fn run_once(
876 queue: &Queue,
877 questions: &Questions,
878 config_override: Option<&Path>,
879 now: Timestamp,
880) -> Report {
881 let mut report = Report {
882 quarantined: quarantine_orphaned_blocked(queue, questions),
883 ..Report::default()
884 };
885 ask_about_stuck_roots(queue, questions, config_override, now, &mut report);
886 for listed in queue.list() {
887 if listed.status != TaskStatus::Held {
888 continue;
889 }
890 let Ok(_claim) = queue.claim(&listed.id) else {
891 continue;
892 };
893 let Ok(mut task) = queue.get(&listed.id) else {
894 continue;
895 };
896 if task.status != TaskStatus::Held {
900 continue;
901 }
902 if deps_pending(questions, &task) {
906 continue;
907 }
908
909 let cfg = Config::discover(&repo_for(&task), config_override)
910 .ok()
911 .map(|(c, _)| c);
912 let w = wording(cfg.as_ref().map_or("en", |c| c.graph.language.as_str()));
913
914 if let Some(q) = latest_triage_question(questions, &task.id) {
915 if q.status.open() {
916 continue;
918 }
919 if q.status == QuestionStatus::Answered && !already_applied(&task, &q) {
920 match interpret_answer(&q) {
921 AnswerAction::Resume => {
922 let contradicted = task
927 .resume_override
928 .as_ref()
929 .is_some_and(|o| o.conductor_rehold.is_some());
930 let record = match task.resume_override.take() {
931 Some(mut o) if contradicted => {
932 o.forced = true;
933 o
934 }
935 _ => OperatorResume {
936 question_id: q.id.clone(),
937 at: now,
938 conductor_rehold: None,
939 forced: false,
940 },
941 };
942 task.release();
943 task.resume_override = Some(record);
944 task.mark_triage_applied(&q.id);
945 if queue.put(&mut task).is_ok() {
946 report.answered.push(task.id.clone());
947 }
948 }
949 AnswerAction::Discard => {
950 if queue.remove(&task.id, false, questions).is_ok() {
951 report.answered.push(task.id.clone());
952 }
953 }
954 AnswerAction::KeepHeld => {
955 let resolution = q.resolution().unwrap_or_default();
956 let note = keep_held_note(&task, &q, &resolution);
957 task.hold_manual(Some(note));
958 task.mark_triage_applied(&q.id);
959 if queue.put(&mut task).is_ok() {
960 report.answered.push(task.id.clone());
961 }
962 }
963 }
964 continue;
965 }
966 }
972
973 match task.hold_source {
974 Some(HoldSource::Machine) => {
975 let overridden = task
976 .resume_override
977 .as_ref()
978 .is_some_and(|o| o.conductor_rehold.is_some() && !o.forced);
979 if overridden {
980 if file_question(questions, &task, Bucket::ConductorOverride, w, now).is_some()
981 {
982 report.asked.push(task.id.clone());
983 }
984 } else if cfg.as_ref().and_then(|c| machine_cause_resolved(&task, c)) == Some(true)
985 {
986 task.release();
987 if queue.put(&mut task).is_ok() {
988 report.resumed.push(task.id.clone());
989 }
990 } else if file_question(questions, &task, Bucket::MachineUnknown, w, now).is_some()
991 {
992 report.asked.push(task.id.clone());
993 }
994 }
995 None => {
996 if file_question(questions, &task, Bucket::Legacy, w, now).is_some() {
997 report.asked.push(task.id.clone());
998 }
999 }
1000 Some(HoldSource::Manual) => {
1001 if manual_is_stale(&task, now)
1002 && file_question(questions, &task, Bucket::ManualStale, w, now).is_some()
1003 {
1004 report.asked.push(task.id.clone());
1005 }
1006 }
1007 }
1008 }
1009 report
1010}
1011
1012pub fn open_question_for(questions: &Questions, task_id: &str) -> Option<Question> {
1017 latest_triage_question(questions, task_id).filter(|q| q.status.open())
1018}
1019
1020pub fn pending_for(questions: &Questions, task: &Task) -> bool {
1034 let own = match latest_triage_question(questions, &task.id) {
1035 Some(q) if q.status.open() => true,
1036 Some(q) if q.status == QuestionStatus::Answered => !already_applied(task, &q),
1037 _ => false,
1038 };
1039 own || deps_pending(questions, task)
1040}
1041
1042fn deps_pending(questions: &Questions, task: &Task) -> bool {
1046 match latest_question(questions, DEPS_NODE, &task.id) {
1047 Some(q) if q.status.open() => true,
1048 Some(q) if q.status == QuestionStatus::Answered => !task.triage_applied(&q.id),
1049 _ => false,
1050 }
1051}
1052
1053pub fn open_task_ids(questions: &Questions) -> std::collections::BTreeSet<String> {
1058 questions
1059 .list()
1060 .into_iter()
1061 .filter(|q| (q.node == NODE || q.node == DEPS_NODE) && q.status.open())
1062 .map(|q| q.run)
1063 .collect()
1064}
1065
1066#[cfg(test)]
1067mod tests {
1068 use super::*;
1069 use crate::ask::Answer;
1070 use crate::queue::Source;
1071 use jiff::SignedDuration;
1072
1073 fn store() -> (tempfile::TempDir, Queue, Questions) {
1074 let dir = tempfile::tempdir().unwrap();
1075 let q = Queue::at(dir.path().join("queue"));
1076 let s = Questions::at(dir.path().join("questions"));
1077 (dir, q, s)
1078 }
1079
1080 fn task(title: &str, repo: PathBuf) -> Task {
1081 Task::new(title.to_owned(), format!("do {title}"), repo, Source::Human)
1082 }
1083
1084 fn gate_disabled_config(dir: &std::path::Path) -> PathBuf {
1088 let config = dir.join("magi.toml");
1089 std::fs::write(&config, "[disk]\nmin_free_bytes = 0\n").unwrap();
1090 config
1091 }
1092
1093 #[test]
1094 fn a_resolved_machine_hold_is_requeued_automatically() {
1095 let (dir, q, questions) = store();
1096 let config = gate_disabled_config(dir.path());
1097 let mut t = task("disk pressure", dir.path().join("repo"));
1098 t.hold_machine(Some(
1099 "not enough free space to start a run: 10 bytes free, 100 required by \
1100 `[disk] min_free_bytes`"
1101 .to_owned(),
1102 ));
1103 q.put(&mut t).unwrap();
1104
1105 let report = run_once(&q, &questions, Some(&config), Timestamp::now());
1106 assert_eq!(report.resumed, [t.id.clone()]);
1107 assert!(report.asked.is_empty());
1108
1109 let back = q.get(&t.id).unwrap();
1110 assert_eq!(back.status, TaskStatus::Queued);
1111 assert!(back.hold_source.is_none());
1112 assert!(questions.list().is_empty(), "nothing needed asking");
1113 }
1114
1115 #[test]
1116 fn a_machine_hold_with_no_recognised_cause_gets_one_question_not_two() {
1117 let (dir, q, questions) = store();
1118 let mut t = task("gate went red", dir.path().join("repo"));
1119 t.hold_machine(Some("gate red".to_owned()));
1120 q.put(&mut t).unwrap();
1121
1122 let first = run_once(&q, &questions, None, Timestamp::now());
1123 assert_eq!(first.asked, [t.id.clone()]);
1124 assert!(first.resumed.is_empty());
1125
1126 let open: Vec<_> = questions
1127 .list()
1128 .into_iter()
1129 .filter(|q| q.status.open())
1130 .collect();
1131 assert_eq!(open.len(), 1);
1132 assert_eq!(open[0].run, t.id);
1133 assert_eq!(open[0].node, NODE);
1134 assert_eq!(open[0].choices.len(), 3);
1135
1136 let second = run_once(&q, &questions, None, Timestamp::now());
1138 assert!(second.asked.is_empty());
1139 assert_eq!(
1140 questions
1141 .list()
1142 .into_iter()
1143 .filter(|q| q.status.open())
1144 .count(),
1145 1
1146 );
1147 }
1148
1149 #[test]
1150 fn a_legacy_hold_with_no_recorded_source_gets_exactly_one_question() {
1151 let (dir, q, questions) = store();
1152 let mut t = task("schema 1 record", dir.path().join("repo"));
1153 t.status = TaskStatus::Held;
1154 assert!(t.hold_source.is_none(), "the case this test is about");
1155 q.put(&mut t).unwrap();
1156
1157 let first = run_once(&q, &questions, None, Timestamp::now());
1158 assert_eq!(first.asked, [t.id.clone()]);
1159
1160 let second = run_once(&q, &questions, None, Timestamp::now());
1161 assert!(
1162 second.asked.is_empty(),
1163 "the same legacy hold must not be asked about twice"
1164 );
1165 assert_eq!(
1166 questions
1167 .list()
1168 .into_iter()
1169 .filter(|q| q.status.open())
1170 .count(),
1171 1
1172 );
1173 }
1174
1175 #[test]
1176 fn a_manual_hold_is_never_auto_resumed() {
1177 let (dir, q, questions) = store();
1178 let mut t = task("operator stopped this", dir.path().join("repo"));
1179 t.hold_manual(Some("waiting on a decision".to_owned()));
1180 q.put(&mut t).unwrap();
1181
1182 let report = run_once(&q, &questions, None, Timestamp::now());
1183 assert!(report.resumed.is_empty());
1184 assert!(report.asked.is_empty());
1186
1187 let back = q.get(&t.id).unwrap();
1188 assert_eq!(back.status, TaskStatus::Held);
1189 assert_eq!(back.hold_source, Some(HoldSource::Manual));
1190 assert!(questions.list().is_empty());
1191 }
1192
1193 #[test]
1194 fn a_blocked_task_on_a_deleted_dependency_is_held_and_asked_about_in_one_pass() {
1195 let (dir, q, questions) = store();
1200 let mut still_going = task("still valid", dir.path().join("repo"));
1201 q.put(&mut still_going).unwrap();
1202
1203 let mut t = task("orphaned", dir.path().join("repo"));
1204 t.block(
1205 vec!["20260101-000000-gone".to_owned(), still_going.id.clone()],
1206 Some("waits on both".to_owned()),
1207 );
1208 q.put(&mut t).unwrap();
1209
1210 let report = run_once(&q, &questions, None, Timestamp::now());
1211 assert_eq!(report.quarantined, [t.id.clone()]);
1212 assert_eq!(
1213 report.asked,
1214 [t.id.clone()],
1215 "the fresh machine hold must earn a question in the same pass"
1216 );
1217
1218 let after = q.get(&t.id).unwrap();
1219 assert_eq!(after.status, TaskStatus::Held);
1220 assert_eq!(after.hold_source, Some(HoldSource::Machine));
1221 assert!(after.blocked_by.is_empty());
1222
1223 assert!(!is_disk_hold(&after));
1226
1227 let open: Vec<_> = questions
1228 .list()
1229 .into_iter()
1230 .filter(|q| q.status.open())
1231 .collect();
1232 assert_eq!(open.len(), 1);
1233 assert_eq!(open[0].run, t.id);
1234
1235 let second = run_once(&q, &questions, None, Timestamp::now());
1237 assert!(second.quarantined.is_empty());
1238 assert!(second.asked.is_empty());
1239 }
1240
1241 fn stuck_chain(q: &Queue, dir: &std::path::Path) -> (Task, Task, Task) {
1244 let mut root = task("root", dir.join("repo"));
1245 root.hold_manual(Some("waiting".to_owned()));
1246 q.put(&mut root).unwrap();
1247 let mut mid = task("mid", dir.join("repo"));
1248 mid.block(vec![root.id.clone()], None);
1249 q.put(&mut mid).unwrap();
1250 let mut leaf = task("leaf", dir.join("repo"));
1251 leaf.block(vec![mid.id.clone()], None);
1252 q.put(&mut leaf).unwrap();
1253 (root, mid, leaf)
1254 }
1255
1256 fn deps_questions(questions: &Questions) -> Vec<Question> {
1257 questions
1258 .list()
1259 .into_iter()
1260 .filter(|q| q.node == DEPS_NODE)
1261 .collect()
1262 }
1263
1264 fn answer_deps(questions: &Questions, choice: usize) {
1265 let mut asked = deps_questions(questions).remove(0);
1266 let c = asked.choices[choice].clone();
1267 asked.answer(Answer::Choice(c)).unwrap();
1268 questions.put(&mut asked).unwrap();
1269 }
1270
1271 #[test]
1272 fn a_stuck_chain_earns_one_question_naming_every_dependant_and_is_not_reasked() {
1273 let (dir, q, questions) = store();
1274 let (root, mid, leaf) = stuck_chain(&q, dir.path());
1275
1276 let first = run_once(&q, &questions, None, Timestamp::now());
1277 assert_eq!(first.asked, std::slice::from_ref(&root.id));
1278 let asked = deps_questions(&questions);
1279 assert_eq!(asked.len(), 1, "one question per root, not per dependant");
1280 assert_eq!(asked[0].run, root.id);
1281 assert_eq!(asked[0].choices.len(), 3);
1282 assert!(asked[0].detail.contains(mid.short()), "{}", asked[0].detail);
1283 assert!(
1284 asked[0].detail.contains(leaf.short()),
1285 "{}",
1286 asked[0].detail
1287 );
1288
1289 let second = run_once(&q, &questions, None, Timestamp::now());
1290 assert!(second.asked.is_empty());
1291 assert_eq!(deps_questions(&questions).len(), 1);
1292 assert!(pending_for(&questions, &q.get(&root.id).unwrap()));
1293 }
1294
1295 #[test]
1296 fn a_stuck_root_with_a_machine_hold_gets_only_the_dependency_question() {
1297 let (dir, q, questions) = store();
1298 let mut root = task("root", dir.path().join("repo"));
1299 root.hold_machine(Some("gate red".to_owned()));
1300 q.put(&mut root).unwrap();
1301 let mut dep = task("dep", dir.path().join("repo"));
1302 dep.block(vec![root.id.clone()], None);
1303 q.put(&mut dep).unwrap();
1304
1305 run_once(&q, &questions, None, Timestamp::now());
1306 run_once(&q, &questions, None, Timestamp::now());
1307 assert_eq!(deps_questions(&questions).len(), 1);
1308 assert_eq!(
1309 questions.list().len(),
1310 1,
1311 "no second question about the hold"
1312 );
1313 }
1314
1315 #[test]
1316 fn a_dependency_that_can_still_run_asks_nothing() {
1317 let (dir, q, questions) = store();
1318 let mut live = task("live", dir.path().join("repo"));
1319 q.put(&mut live).unwrap();
1320 let mut dep = task("dep", dir.path().join("repo"));
1321 dep.block(vec![live.id.clone()], None);
1322 q.put(&mut dep).unwrap();
1323 let report = run_once(&q, &questions, None, Timestamp::now());
1324 assert!(report.asked.is_empty());
1325 assert!(questions.list().is_empty());
1326 }
1327
1328 #[test]
1329 fn answering_release_requeues_the_root_and_is_not_reasked() {
1330 let (dir, q, questions) = store();
1331 let (root, _mid, _leaf) = stuck_chain(&q, dir.path());
1332 run_once(&q, &questions, None, Timestamp::now());
1333 answer_deps(&questions, 0);
1334
1335 let report = run_once(&q, &questions, None, Timestamp::now());
1336 assert_eq!(report.answered, std::slice::from_ref(&root.id));
1337 assert!(report.asked.is_empty(), "applying must not re-ask");
1338 assert_eq!(q.get(&root.id).unwrap().status, TaskStatus::Queued);
1339 let again = run_once(&q, &questions, None, Timestamp::now());
1340 assert!(again.asked.is_empty() && again.answered.is_empty());
1341 assert_eq!(deps_questions(&questions).len(), 1);
1342 }
1343
1344 #[test]
1345 fn answering_detach_frees_the_direct_dependant_and_keeps_the_root() {
1346 let (dir, q, questions) = store();
1347 let (root, mid, leaf) = stuck_chain(&q, dir.path());
1348 run_once(&q, &questions, None, Timestamp::now());
1349 answer_deps(&questions, 2);
1350
1351 let report = run_once(&q, &questions, None, Timestamp::now());
1352 assert!(report.asked.is_empty());
1353 assert_eq!(q.get(&root.id).unwrap().status, TaskStatus::Held);
1354 assert_eq!(q.get(&mid.id).unwrap().status, TaskStatus::Queued);
1355 let leaf = q.get(&leaf.id).unwrap();
1356 assert_eq!(leaf.status, TaskStatus::Blocked, "still waits on mid");
1357 assert_eq!(leaf.blocked_by, std::slice::from_ref(&mid.id));
1358 }
1359
1360 #[test]
1361 fn a_dependant_that_can_progress_through_another_blocker_is_not_stuck() {
1362 let (dir, q, questions) = store();
1363 let (root, mid, _leaf) = stuck_chain(&q, dir.path());
1364 let mut live = task("live", dir.path().join("repo"));
1365 q.put(&mut live).unwrap();
1366 let mut both = q.get(&mid.id).unwrap();
1367 both.block(vec![root.id.clone(), live.id.clone()], None);
1368 q.put(&mut both).unwrap();
1369 assert!(
1371 run_once(&q, &questions, None, Timestamp::now())
1372 .asked
1373 .is_empty()
1374 );
1375 }
1376
1377 #[test]
1378 fn answering_discard_detaches_then_removes_the_root() {
1379 let (dir, q, questions) = store();
1380 let (root, mid, _leaf) = stuck_chain(&q, dir.path());
1381 run_once(&q, &questions, None, Timestamp::now());
1382 answer_deps(&questions, 1);
1383
1384 let report = run_once(&q, &questions, None, Timestamp::now());
1385 assert!(q.get(&root.id).is_err());
1386 assert_eq!(q.get(&mid.id).unwrap().status, TaskStatus::Queued);
1387 assert!(
1388 report.asked.is_empty(),
1389 "no per-dependant quarantine question"
1390 );
1391 assert!(report.quarantined.is_empty());
1392 }
1393
1394 #[test]
1395 fn an_unmatched_deps_answer_changes_nothing_and_is_not_reasked() {
1396 let (dir, q, questions) = store();
1397 let (root, mid, _leaf) = stuck_chain(&q, dir.path());
1398 run_once(&q, &questions, None, Timestamp::now());
1399 let mut asked = deps_questions(&questions).remove(0);
1400 asked.choices.clear();
1401 asked.answer(Answer::Text("dunno".to_owned())).unwrap();
1402 questions.put(&mut asked).unwrap();
1403
1404 for _ in 0..2 {
1405 let report = run_once(&q, &questions, None, Timestamp::now());
1406 assert!(report.asked.is_empty() && report.answered.is_empty());
1407 }
1408 assert_eq!(deps_questions(&questions).len(), 1);
1409 assert_eq!(q.get(&root.id).unwrap().status, TaskStatus::Held);
1410 assert_eq!(q.get(&mid.id).unwrap().status, TaskStatus::Blocked);
1411 }
1412
1413 #[test]
1414 fn a_dependency_cycle_terminates_and_is_asked_about_once() {
1415 let (dir, q, questions) = store();
1416 let mut a = task("a", dir.path().join("repo"));
1417 let mut b = task("b", dir.path().join("repo"));
1418 a.block(vec![b.id.clone()], None);
1419 b.block(vec![a.id.clone()], None);
1420 q.put(&mut a).unwrap();
1421 q.put(&mut b).unwrap();
1422
1423 let first = run_once(&q, &questions, None, Timestamp::now());
1424 assert_eq!(first.asked.len(), 1);
1425 let second = run_once(&q, &questions, None, Timestamp::now());
1426 assert!(second.asked.is_empty());
1427 assert_eq!(deps_questions(&questions).len(), 1);
1428 }
1429
1430 #[test]
1431 fn a_missing_dependency_is_still_quarantined_not_asked_about_as_stuck() {
1432 let (dir, q, questions) = store();
1433 let mut t = task("orphan", dir.path().join("repo"));
1434 t.block(vec!["20260101-000000-gone".to_owned()], None);
1435 q.put(&mut t).unwrap();
1436 let report = run_once(&q, &questions, None, Timestamp::now());
1437 assert_eq!(report.quarantined, [t.id.clone()]);
1438 assert!(deps_questions(&questions).is_empty());
1439 }
1440
1441 #[test]
1442 fn a_stale_manual_hold_earns_a_two_choice_question() {
1443 let (dir, q, questions) = store();
1444 let mut t = task("been sitting a while", dir.path().join("repo"));
1445 t.hold_manual(Some("waiting on a decision".to_owned()));
1446 q.put(&mut t).unwrap();
1447 let mut back = q.get(&t.id).unwrap();
1449 back.updated_at = Timestamp::now() - SignedDuration::new(8 * 24 * 60 * 60, 0);
1450 std::fs::write(
1451 q.path_of(&back.id),
1452 serde_json::to_string_pretty(&back).unwrap(),
1453 )
1454 .unwrap();
1455
1456 let report = run_once(&q, &questions, None, Timestamp::now());
1457 assert_eq!(report.asked, [t.id.clone()]);
1458 let open: Vec<_> = questions
1459 .list()
1460 .into_iter()
1461 .filter(|q| q.status.open())
1462 .collect();
1463 assert_eq!(open.len(), 1);
1464 assert_eq!(open[0].choices.len(), 2);
1465 }
1466
1467 #[test]
1468 fn answering_resume_releases_the_task() {
1469 let (dir, q, questions) = store();
1470 let mut t = task("gate went red", dir.path().join("repo"));
1471 t.hold_machine(Some("gate red".to_owned()));
1472 q.put(&mut t).unwrap();
1473 run_once(&q, &questions, None, Timestamp::now());
1474
1475 let mut asked = questions
1476 .list()
1477 .into_iter()
1478 .find(|q| q.run == t.id)
1479 .unwrap();
1480 asked.answer(Answer::Choice(EN.resume.to_owned())).unwrap();
1481 questions.put(&mut asked).unwrap();
1482
1483 let report = run_once(&q, &questions, None, Timestamp::now());
1484 assert_eq!(report.answered, [t.id.clone()]);
1485 let back = q.get(&t.id).unwrap();
1486 assert_eq!(back.status, TaskStatus::Queued);
1487 assert!(back.hold_source.is_none());
1488 }
1489
1490 fn resumed_then_failed(q: &Queue, questions: &Questions, dir: &std::path::Path) -> Task {
1492 let mut t = task("gate went red", dir.join("repo"));
1493 t.hold_machine(Some("gate red".to_owned()));
1494 q.put(&mut t).unwrap();
1495 run_once(q, questions, None, Timestamp::now());
1496 let mut asked = questions
1497 .list()
1498 .into_iter()
1499 .find(|q| q.run == t.id)
1500 .unwrap();
1501 asked.answer(Answer::Choice(EN.resume.to_owned())).unwrap();
1502 questions.put(&mut asked).unwrap();
1503
1504 let report = run_once(q, questions, None, Timestamp::now());
1505 assert_eq!(report.answered, [t.id.clone()]);
1506 let mut back = q.get(&t.id).unwrap();
1507 assert_eq!(back.status, TaskStatus::Queued);
1508 assert_eq!(back.attempts, 0);
1509
1510 back.start("run-1".to_owned());
1511 back.fail("rebase conflict", 1);
1512 assert_eq!(back.status, TaskStatus::Held);
1513 q.put(&mut back).unwrap();
1514 back
1515 }
1516
1517 #[test]
1518 fn a_resume_answer_is_applied_once_and_a_new_machine_hold_is_asked_about() {
1519 let (dir, q, questions) = store();
1520 let t = resumed_then_failed(&q, &questions, dir.path());
1521
1522 let report = run_once(&q, &questions, None, Timestamp::now());
1523 assert!(report.answered.is_empty(), "the old answer must not replay");
1524 assert_eq!(report.asked, std::slice::from_ref(&t.id));
1525 let back = q.get(&t.id).unwrap();
1526 assert_eq!(back.status, TaskStatus::Held);
1527 assert_eq!(
1528 questions
1529 .list()
1530 .into_iter()
1531 .filter(|q| q.status.open())
1532 .count(),
1533 1
1534 );
1535 }
1536
1537 #[test]
1538 fn a_manual_hold_placed_after_a_resume_is_not_undone_by_the_old_answer() {
1539 let (dir, q, questions) = store();
1540 let mut t = resumed_then_failed(&q, &questions, dir.path());
1541 t.hold_manual(Some("operator stopped this".to_owned()));
1542 q.put(&mut t).unwrap();
1543 let before = questions.list().len();
1544
1545 let report = run_once(&q, &questions, None, Timestamp::now());
1546 assert!(report.answered.is_empty());
1547 assert!(report.asked.is_empty());
1548 let back = q.get(&t.id).unwrap();
1549 assert_eq!(back.status, TaskStatus::Held);
1550 assert_eq!(back.hold_source, Some(HoldSource::Manual));
1551 assert_eq!(questions.list().len(), before);
1552 }
1553
1554 #[test]
1555 fn answering_not_yet_keeps_it_held_as_a_manual_hold_and_does_not_reapply() {
1556 let (dir, q, questions) = store();
1557 let mut t = task("gate went red", dir.path().join("repo"));
1558 t.hold_machine(Some("gate red".to_owned()));
1559 q.put(&mut t).unwrap();
1560 run_once(&q, &questions, None, Timestamp::now());
1561
1562 let mut asked = questions
1563 .list()
1564 .into_iter()
1565 .find(|q| q.run == t.id)
1566 .unwrap();
1567 asked.answer(Answer::Choice(EN.wait.to_owned())).unwrap();
1568 questions.put(&mut asked).unwrap();
1569
1570 let report = run_once(&q, &questions, None, Timestamp::now());
1571 assert_eq!(report.answered, [t.id.clone()]);
1572 let back = q.get(&t.id).unwrap();
1573 assert_eq!(back.status, TaskStatus::Held);
1574 assert_eq!(back.hold_source, Some(HoldSource::Manual));
1575 assert!(
1576 back.hold_reason
1577 .as_deref()
1578 .is_some_and(|r| r.contains("gate red")),
1579 "the original cause must survive a \"not yet\" answer, not just the \
1580 triage marker: {:?}",
1581 back.hold_reason
1582 );
1583
1584 let third = run_once(&q, &questions, None, Timestamp::now());
1588 assert!(third.answered.is_empty());
1589 assert!(third.asked.is_empty());
1590 }
1591
1592 #[test]
1593 fn answering_discard_removes_the_task_entirely() {
1594 let (dir, q, questions) = store();
1595 let mut t = task("gate went red", dir.path().join("repo"));
1596 t.hold_machine(Some("gate red".to_owned()));
1597 q.put(&mut t).unwrap();
1598 run_once(&q, &questions, None, Timestamp::now());
1599
1600 let mut asked = questions
1601 .list()
1602 .into_iter()
1603 .find(|q| q.run == t.id)
1604 .unwrap();
1605 asked.answer(Answer::Choice(EN.discard.to_owned())).unwrap();
1606 questions.put(&mut asked).unwrap();
1607
1608 let report = run_once(&q, &questions, None, Timestamp::now());
1609 assert_eq!(report.answered, [t.id.clone()]);
1610 assert!(
1611 q.get(&t.id).is_err(),
1612 "\"discard it\" (捨ててよい) must actually discard the task, not \
1613 just leave it sitting held forever"
1614 );
1615 }
1616
1617 #[test]
1618 fn an_answer_is_read_by_its_position_in_choices_not_by_the_callers_current_language() {
1619 let (dir, q, questions) = store();
1621 let ja_config = dir.path().join("ja.toml");
1622 std::fs::write(&ja_config, "[graph]\nlanguage = \"ja\"\n").unwrap();
1623 let mut t = task("gate went red", dir.path().join("repo"));
1624 t.hold_machine(Some("gate red".to_owned()));
1625 q.put(&mut t).unwrap();
1626 run_once(&q, &questions, Some(&ja_config), Timestamp::now());
1627
1628 let mut asked = questions
1629 .list()
1630 .into_iter()
1631 .find(|q| q.run == t.id)
1632 .unwrap();
1633 assert_eq!(asked.choices[0], JA.resume, "filed in Japanese");
1634 asked.answer(Answer::Choice(JA.resume.to_owned())).unwrap();
1635 questions.put(&mut asked).unwrap();
1636
1637 let en_config = dir.path().join("en.toml");
1642 std::fs::write(&en_config, "[graph]\nlanguage = \"en\"\n").unwrap();
1643 let report = run_once(&q, &questions, Some(&en_config), Timestamp::now());
1644 assert_eq!(report.answered, [t.id.clone()]);
1645 let back = q.get(&t.id).unwrap();
1646 assert_eq!(
1647 back.status,
1648 TaskStatus::Queued,
1649 "a resume answer must resume the task regardless of which \
1650 language it is read back in"
1651 );
1652 }
1653
1654 #[test]
1655 fn a_question_falls_back_to_last_error_when_hold_reason_was_never_set() {
1656 let (dir, q, questions) = store();
1660 let mut t = task("kept failing the gate", dir.path().join("repo"));
1661 t.start("run-1".to_owned());
1662 t.fail("gate red three times running", 1);
1663 assert_eq!(t.status, TaskStatus::Held);
1664 assert!(t.hold_reason.is_none(), "the case this test is about");
1665 q.put(&mut t).unwrap();
1666
1667 run_once(&q, &questions, None, Timestamp::now());
1668 let asked = questions
1669 .list()
1670 .into_iter()
1671 .find(|q| q.run == t.id)
1672 .unwrap();
1673 assert!(
1674 asked.detail.contains("gate red three times running"),
1675 "the question must surface `last_error` when there is no \
1676 `hold_reason` to show instead: {}",
1677 asked.detail
1678 );
1679 }
1680
1681 #[test]
1682 fn open_question_for_and_open_task_ids_reflect_only_what_is_still_waiting() {
1683 let (dir, q, questions) = store();
1684 let mut t = task("gate went red", dir.path().join("repo"));
1685 t.hold_machine(Some("gate red".to_owned()));
1686 q.put(&mut t).unwrap();
1687
1688 assert!(open_question_for(&questions, &t.id).is_none());
1689 assert!(!open_task_ids(&questions).contains(&t.id));
1690
1691 run_once(&q, &questions, None, Timestamp::now());
1692 assert!(open_question_for(&questions, &t.id).is_some());
1693 assert!(open_task_ids(&questions).contains(&t.id));
1694
1695 let mut asked = questions
1696 .list()
1697 .into_iter()
1698 .find(|q| q.run == t.id)
1699 .unwrap();
1700 asked.answer(Answer::Choice(EN.resume.to_owned())).unwrap();
1701 questions.put(&mut asked).unwrap();
1702
1703 assert!(
1704 open_question_for(&questions, &t.id).is_none(),
1705 "an answered question is no longer open"
1706 );
1707 assert!(!open_task_ids(&questions).contains(&t.id));
1708 }
1709
1710 #[test]
1711 fn pending_for_stays_true_between_an_answer_and_the_next_run_once_pass() {
1712 let (dir, q, questions) = store();
1718 let mut t = task("gate went red", dir.path().join("repo"));
1719 t.hold_machine(Some("gate red".to_owned()));
1720 q.put(&mut t).unwrap();
1721
1722 assert!(!pending_for(&questions, &t));
1723
1724 run_once(&q, &questions, None, Timestamp::now());
1725 let held = q.get(&t.id).unwrap();
1726 assert!(pending_for(&questions, &held), "still waiting on an answer");
1727
1728 let mut asked = questions
1729 .list()
1730 .into_iter()
1731 .find(|q| q.run == t.id)
1732 .unwrap();
1733 asked.answer(Answer::Choice(EN.wait.to_owned())).unwrap();
1734 questions.put(&mut asked).unwrap();
1735 assert!(!asked.status.open());
1736
1737 let still_held = q.get(&t.id).unwrap();
1739 assert!(
1740 pending_for(&questions, &still_held),
1741 "answered but not yet applied is still pending"
1742 );
1743
1744 run_once(&q, &questions, None, Timestamp::now());
1745 let after = q.get(&t.id).unwrap();
1746 assert!(
1747 !pending_for(&questions, &after),
1748 "the answer is applied now, nothing left pending"
1749 );
1750 }
1751}