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 pinned_run: None,
749 });
750 }
751 DepsAction::Discard => {
752 detach_dependants(queue, &root.id);
753 return queue.remove(&root.id, false, questions).is_ok();
754 }
755 DepsAction::Detach => detach_dependants(queue, &root.id),
756 DepsAction::Nothing => return false,
761 }
762 root.mark_triage_applied(&q.id);
763 queue.put(&mut root).is_ok()
764}
765
766fn ask_about_stuck_roots(
775 queue: &Queue,
776 questions: &Questions,
777 config_override: Option<&Path>,
778 now: Timestamp,
779 report: &mut Report,
780) {
781 for q in questions.list() {
782 if q.node == DEPS_NODE
783 && q.status == QuestionStatus::Answered
784 && apply_deps_answer(queue, questions, &q, now)
785 {
786 report.answered.push(q.run.clone());
787 }
788 }
789
790 let all = questions.list();
791 let inv = crate::blockers::Inventory::new(queue.list(), &all);
792 let mut frozen: std::collections::BTreeMap<String, Vec<String>> = Default::default();
793 for (id, roots) in inv.stuck() {
794 for root in roots {
795 if root != id {
796 frozen.entry(root).or_default().push(id.clone());
797 }
798 }
799 }
800 for mut q in all
804 .iter()
805 .filter(|q| q.node == DEPS_NODE && q.status.open())
806 .cloned()
807 {
808 if !frozen.contains_key(&q.run) {
809 q.abandon("nothing waits on this task anymore");
810 let _ = questions.put(&mut q);
811 }
812 }
813
814 for (root_id, dependant_ids) in &frozen {
815 let Some(root) = inv.task(root_id) else {
816 continue;
817 };
818 let dependants: Vec<&Task> = dependant_ids.iter().filter_map(|d| inv.task(d)).collect();
819 let latest = all
820 .iter()
821 .filter(|q| q.node == DEPS_NODE && q.run == *root_id)
822 .max_by(|a, b| a.asked_at.cmp(&b.asked_at).then_with(|| a.id.cmp(&b.id)));
823 match latest {
824 Some(q) if q.status.open() => continue,
825 Some(q) if q.status == QuestionStatus::Answered && !root.triage_applied(&q.id) => {
826 continue;
827 }
828 Some(q) if q.status == QuestionStatus::Abandoned => {
829 let changed = root.updated_at > q.asked_at
830 || dependants.iter().any(|t| t.updated_at > q.asked_at);
831 if !changed {
832 continue;
833 }
834 }
835 _ => {}
836 }
837 if pending_for(questions, root) {
838 continue;
839 }
840 let cfg = Config::discover(&repo_for(root), config_override)
841 .ok()
842 .map(|(c, _)| c);
843 let w = wording(cfg.as_ref().map_or("en", |c| c.graph.language.as_str()));
844 let (summary, detail, choices) = deps_texts(w, root, &dependants);
845 let mut question = Question::new(
846 root.id.clone(),
847 DEPS_NODE.to_owned(),
848 SEAT.to_owned(),
849 summary,
850 detail,
851 choices,
852 );
853 if questions.put(&mut question).is_ok() {
854 report.asked.push(root.id.clone());
855 }
856 }
857}
858
859pub fn run_once(
877 queue: &Queue,
878 questions: &Questions,
879 config_override: Option<&Path>,
880 now: Timestamp,
881) -> Report {
882 let mut report = Report {
883 quarantined: quarantine_orphaned_blocked(queue, questions),
884 ..Report::default()
885 };
886 ask_about_stuck_roots(queue, questions, config_override, now, &mut report);
887 for listed in queue.list() {
888 if listed.status != TaskStatus::Held {
889 continue;
890 }
891 let Ok(_claim) = queue.claim(&listed.id) else {
892 continue;
893 };
894 let Ok(mut task) = queue.get(&listed.id) else {
895 continue;
896 };
897 if task.status != TaskStatus::Held {
901 continue;
902 }
903 if deps_pending(questions, &task) {
907 continue;
908 }
909
910 let cfg = Config::discover(&repo_for(&task), config_override)
911 .ok()
912 .map(|(c, _)| c);
913 let w = wording(cfg.as_ref().map_or("en", |c| c.graph.language.as_str()));
914
915 if let Some(q) = latest_triage_question(questions, &task.id) {
916 if q.status.open() {
917 continue;
919 }
920 if q.status == QuestionStatus::Answered && !already_applied(&task, &q) {
921 match interpret_answer(&q) {
922 AnswerAction::Resume => {
923 let contradicted = task
928 .resume_override
929 .as_ref()
930 .is_some_and(|o| o.conductor_rehold.is_some());
931 let record = match task.resume_override.take() {
932 Some(mut o) if contradicted => {
933 o.forced = true;
934 o
935 }
936 _ => OperatorResume {
937 question_id: q.id.clone(),
938 at: now,
939 conductor_rehold: None,
940 forced: false,
941 pinned_run: None,
942 },
943 };
944 task.release();
945 task.resume_override = Some(record);
946 task.mark_triage_applied(&q.id);
947 if queue.put(&mut task).is_ok() {
948 report.answered.push(task.id.clone());
949 }
950 }
951 AnswerAction::Discard => {
952 if queue.remove(&task.id, false, questions).is_ok() {
953 report.answered.push(task.id.clone());
954 }
955 }
956 AnswerAction::KeepHeld => {
957 let resolution = q.resolution().unwrap_or_default();
958 let note = keep_held_note(&task, &q, &resolution);
959 task.hold_manual(Some(note));
960 task.mark_triage_applied(&q.id);
961 if queue.put(&mut task).is_ok() {
962 report.answered.push(task.id.clone());
963 }
964 }
965 }
966 continue;
967 }
968 }
974
975 match task.hold_source {
976 Some(HoldSource::Machine) => {
977 let overridden = task
978 .resume_override
979 .as_ref()
980 .is_some_and(|o| o.conductor_rehold.is_some() && !o.forced);
981 if overridden {
982 if file_question(questions, &task, Bucket::ConductorOverride, w, now).is_some()
983 {
984 report.asked.push(task.id.clone());
985 }
986 } else if cfg.as_ref().and_then(|c| machine_cause_resolved(&task, c)) == Some(true)
987 {
988 task.release();
989 if queue.put(&mut task).is_ok() {
990 report.resumed.push(task.id.clone());
991 }
992 } else if file_question(questions, &task, Bucket::MachineUnknown, w, now).is_some()
993 {
994 report.asked.push(task.id.clone());
995 }
996 }
997 None => {
998 if file_question(questions, &task, Bucket::Legacy, w, now).is_some() {
999 report.asked.push(task.id.clone());
1000 }
1001 }
1002 Some(HoldSource::Manual) => {
1003 if manual_is_stale(&task, now)
1004 && file_question(questions, &task, Bucket::ManualStale, w, now).is_some()
1005 {
1006 report.asked.push(task.id.clone());
1007 }
1008 }
1009 }
1010 }
1011 report
1012}
1013
1014pub fn open_question_for(questions: &Questions, task_id: &str) -> Option<Question> {
1019 latest_triage_question(questions, task_id).filter(|q| q.status.open())
1020}
1021
1022pub fn pending_for(questions: &Questions, task: &Task) -> bool {
1036 let own = match latest_triage_question(questions, &task.id) {
1037 Some(q) if q.status.open() => true,
1038 Some(q) if q.status == QuestionStatus::Answered => !already_applied(task, &q),
1039 _ => false,
1040 };
1041 own || deps_pending(questions, task)
1042}
1043
1044fn deps_pending(questions: &Questions, task: &Task) -> bool {
1048 match latest_question(questions, DEPS_NODE, &task.id) {
1049 Some(q) if q.status.open() => true,
1050 Some(q) if q.status == QuestionStatus::Answered => !task.triage_applied(&q.id),
1051 _ => false,
1052 }
1053}
1054
1055pub fn open_task_ids(questions: &Questions) -> std::collections::BTreeSet<String> {
1060 questions
1061 .list()
1062 .into_iter()
1063 .filter(|q| (q.node == NODE || q.node == DEPS_NODE) && q.status.open())
1064 .map(|q| q.run)
1065 .collect()
1066}
1067
1068#[cfg(test)]
1069mod tests {
1070 use super::*;
1071 use crate::ask::Answer;
1072 use crate::queue::Source;
1073 use jiff::SignedDuration;
1074
1075 fn store() -> (tempfile::TempDir, Queue, Questions) {
1076 let dir = tempfile::tempdir().unwrap();
1077 let q = Queue::at(dir.path().join("queue"));
1078 let s = Questions::at(dir.path().join("questions"));
1079 (dir, q, s)
1080 }
1081
1082 fn task(title: &str, repo: PathBuf) -> Task {
1083 Task::new(title.to_owned(), format!("do {title}"), repo, Source::Human)
1084 }
1085
1086 fn gate_disabled_config(dir: &std::path::Path) -> PathBuf {
1090 let config = dir.join("magi.toml");
1091 std::fs::write(&config, "[disk]\nmin_free_bytes = 0\n").unwrap();
1092 config
1093 }
1094
1095 #[test]
1096 fn a_resolved_machine_hold_is_requeued_automatically() {
1097 let (dir, q, questions) = store();
1098 let config = gate_disabled_config(dir.path());
1099 let mut t = task("disk pressure", dir.path().join("repo"));
1100 t.hold_machine(Some(
1101 "not enough free space to start a run: 10 bytes free, 100 required by \
1102 `[disk] min_free_bytes`"
1103 .to_owned(),
1104 ));
1105 q.put(&mut t).unwrap();
1106
1107 let report = run_once(&q, &questions, Some(&config), Timestamp::now());
1108 assert_eq!(report.resumed, [t.id.clone()]);
1109 assert!(report.asked.is_empty());
1110
1111 let back = q.get(&t.id).unwrap();
1112 assert_eq!(back.status, TaskStatus::Queued);
1113 assert!(back.hold_source.is_none());
1114 assert!(questions.list().is_empty(), "nothing needed asking");
1115 }
1116
1117 #[test]
1118 fn a_machine_hold_with_no_recognised_cause_gets_one_question_not_two() {
1119 let (dir, q, questions) = store();
1120 let mut t = task("gate went red", dir.path().join("repo"));
1121 t.hold_machine(Some("gate red".to_owned()));
1122 q.put(&mut t).unwrap();
1123
1124 let first = run_once(&q, &questions, None, Timestamp::now());
1125 assert_eq!(first.asked, [t.id.clone()]);
1126 assert!(first.resumed.is_empty());
1127
1128 let open: Vec<_> = questions
1129 .list()
1130 .into_iter()
1131 .filter(|q| q.status.open())
1132 .collect();
1133 assert_eq!(open.len(), 1);
1134 assert_eq!(open[0].run, t.id);
1135 assert_eq!(open[0].node, NODE);
1136 assert_eq!(open[0].choices.len(), 3);
1137
1138 let second = run_once(&q, &questions, None, Timestamp::now());
1140 assert!(second.asked.is_empty());
1141 assert_eq!(
1142 questions
1143 .list()
1144 .into_iter()
1145 .filter(|q| q.status.open())
1146 .count(),
1147 1
1148 );
1149 }
1150
1151 #[test]
1152 fn a_legacy_hold_with_no_recorded_source_gets_exactly_one_question() {
1153 let (dir, q, questions) = store();
1154 let mut t = task("schema 1 record", dir.path().join("repo"));
1155 t.status = TaskStatus::Held;
1156 assert!(t.hold_source.is_none(), "the case this test is about");
1157 q.put(&mut t).unwrap();
1158
1159 let first = run_once(&q, &questions, None, Timestamp::now());
1160 assert_eq!(first.asked, [t.id.clone()]);
1161
1162 let second = run_once(&q, &questions, None, Timestamp::now());
1163 assert!(
1164 second.asked.is_empty(),
1165 "the same legacy hold must not be asked about twice"
1166 );
1167 assert_eq!(
1168 questions
1169 .list()
1170 .into_iter()
1171 .filter(|q| q.status.open())
1172 .count(),
1173 1
1174 );
1175 }
1176
1177 #[test]
1178 fn a_manual_hold_is_never_auto_resumed() {
1179 let (dir, q, questions) = store();
1180 let mut t = task("operator stopped this", dir.path().join("repo"));
1181 t.hold_manual(Some("waiting on a decision".to_owned()));
1182 q.put(&mut t).unwrap();
1183
1184 let report = run_once(&q, &questions, None, Timestamp::now());
1185 assert!(report.resumed.is_empty());
1186 assert!(report.asked.is_empty());
1188
1189 let back = q.get(&t.id).unwrap();
1190 assert_eq!(back.status, TaskStatus::Held);
1191 assert_eq!(back.hold_source, Some(HoldSource::Manual));
1192 assert!(questions.list().is_empty());
1193 }
1194
1195 #[test]
1196 fn a_blocked_task_on_a_deleted_dependency_is_held_and_asked_about_in_one_pass() {
1197 let (dir, q, questions) = store();
1202 let mut still_going = task("still valid", dir.path().join("repo"));
1203 q.put(&mut still_going).unwrap();
1204
1205 let mut t = task("orphaned", dir.path().join("repo"));
1206 t.block(
1207 vec!["20260101-000000-gone".to_owned(), still_going.id.clone()],
1208 Some("waits on both".to_owned()),
1209 );
1210 q.put(&mut t).unwrap();
1211
1212 let report = run_once(&q, &questions, None, Timestamp::now());
1213 assert_eq!(report.quarantined, [t.id.clone()]);
1214 assert_eq!(
1215 report.asked,
1216 [t.id.clone()],
1217 "the fresh machine hold must earn a question in the same pass"
1218 );
1219
1220 let after = q.get(&t.id).unwrap();
1221 assert_eq!(after.status, TaskStatus::Held);
1222 assert_eq!(after.hold_source, Some(HoldSource::Machine));
1223 assert!(after.blocked_by.is_empty());
1224
1225 assert!(!is_disk_hold(&after));
1228
1229 let open: Vec<_> = questions
1230 .list()
1231 .into_iter()
1232 .filter(|q| q.status.open())
1233 .collect();
1234 assert_eq!(open.len(), 1);
1235 assert_eq!(open[0].run, t.id);
1236
1237 let second = run_once(&q, &questions, None, Timestamp::now());
1239 assert!(second.quarantined.is_empty());
1240 assert!(second.asked.is_empty());
1241 }
1242
1243 fn stuck_chain(q: &Queue, dir: &std::path::Path) -> (Task, Task, Task) {
1246 let mut root = task("root", dir.join("repo"));
1247 root.hold_manual(Some("waiting".to_owned()));
1248 q.put(&mut root).unwrap();
1249 let mut mid = task("mid", dir.join("repo"));
1250 mid.block(vec![root.id.clone()], None);
1251 q.put(&mut mid).unwrap();
1252 let mut leaf = task("leaf", dir.join("repo"));
1253 leaf.block(vec![mid.id.clone()], None);
1254 q.put(&mut leaf).unwrap();
1255 (root, mid, leaf)
1256 }
1257
1258 fn deps_questions(questions: &Questions) -> Vec<Question> {
1259 questions
1260 .list()
1261 .into_iter()
1262 .filter(|q| q.node == DEPS_NODE)
1263 .collect()
1264 }
1265
1266 fn answer_deps(questions: &Questions, choice: usize) {
1267 let mut asked = deps_questions(questions).remove(0);
1268 let c = asked.choices[choice].clone();
1269 asked.answer(Answer::Choice(c)).unwrap();
1270 questions.put(&mut asked).unwrap();
1271 }
1272
1273 #[test]
1274 fn a_stuck_chain_earns_one_question_naming_every_dependant_and_is_not_reasked() {
1275 let (dir, q, questions) = store();
1276 let (root, mid, leaf) = stuck_chain(&q, dir.path());
1277
1278 let first = run_once(&q, &questions, None, Timestamp::now());
1279 assert_eq!(first.asked, std::slice::from_ref(&root.id));
1280 let asked = deps_questions(&questions);
1281 assert_eq!(asked.len(), 1, "one question per root, not per dependant");
1282 assert_eq!(asked[0].run, root.id);
1283 assert_eq!(asked[0].choices.len(), 3);
1284 assert!(asked[0].detail.contains(mid.short()), "{}", asked[0].detail);
1285 assert!(
1286 asked[0].detail.contains(leaf.short()),
1287 "{}",
1288 asked[0].detail
1289 );
1290
1291 let second = run_once(&q, &questions, None, Timestamp::now());
1292 assert!(second.asked.is_empty());
1293 assert_eq!(deps_questions(&questions).len(), 1);
1294 assert!(pending_for(&questions, &q.get(&root.id).unwrap()));
1295 }
1296
1297 #[test]
1298 fn a_stuck_root_with_a_machine_hold_gets_only_the_dependency_question() {
1299 let (dir, q, questions) = store();
1300 let mut root = task("root", dir.path().join("repo"));
1301 root.hold_machine(Some("gate red".to_owned()));
1302 q.put(&mut root).unwrap();
1303 let mut dep = task("dep", dir.path().join("repo"));
1304 dep.block(vec![root.id.clone()], None);
1305 q.put(&mut dep).unwrap();
1306
1307 run_once(&q, &questions, None, Timestamp::now());
1308 run_once(&q, &questions, None, Timestamp::now());
1309 assert_eq!(deps_questions(&questions).len(), 1);
1310 assert_eq!(
1311 questions.list().len(),
1312 1,
1313 "no second question about the hold"
1314 );
1315 }
1316
1317 #[test]
1318 fn a_dependency_that_can_still_run_asks_nothing() {
1319 let (dir, q, questions) = store();
1320 let mut live = task("live", dir.path().join("repo"));
1321 q.put(&mut live).unwrap();
1322 let mut dep = task("dep", dir.path().join("repo"));
1323 dep.block(vec![live.id.clone()], None);
1324 q.put(&mut dep).unwrap();
1325 let report = run_once(&q, &questions, None, Timestamp::now());
1326 assert!(report.asked.is_empty());
1327 assert!(questions.list().is_empty());
1328 }
1329
1330 #[test]
1331 fn answering_release_requeues_the_root_and_is_not_reasked() {
1332 let (dir, q, questions) = store();
1333 let (root, _mid, _leaf) = stuck_chain(&q, dir.path());
1334 run_once(&q, &questions, None, Timestamp::now());
1335 answer_deps(&questions, 0);
1336
1337 let report = run_once(&q, &questions, None, Timestamp::now());
1338 assert_eq!(report.answered, std::slice::from_ref(&root.id));
1339 assert!(report.asked.is_empty(), "applying must not re-ask");
1340 assert_eq!(q.get(&root.id).unwrap().status, TaskStatus::Queued);
1341 let again = run_once(&q, &questions, None, Timestamp::now());
1342 assert!(again.asked.is_empty() && again.answered.is_empty());
1343 assert_eq!(deps_questions(&questions).len(), 1);
1344 }
1345
1346 #[test]
1347 fn answering_detach_frees_the_direct_dependant_and_keeps_the_root() {
1348 let (dir, q, questions) = store();
1349 let (root, mid, leaf) = stuck_chain(&q, dir.path());
1350 run_once(&q, &questions, None, Timestamp::now());
1351 answer_deps(&questions, 2);
1352
1353 let report = run_once(&q, &questions, None, Timestamp::now());
1354 assert!(report.asked.is_empty());
1355 assert_eq!(q.get(&root.id).unwrap().status, TaskStatus::Held);
1356 assert_eq!(q.get(&mid.id).unwrap().status, TaskStatus::Queued);
1357 let leaf = q.get(&leaf.id).unwrap();
1358 assert_eq!(leaf.status, TaskStatus::Blocked, "still waits on mid");
1359 assert_eq!(leaf.blocked_by, std::slice::from_ref(&mid.id));
1360 }
1361
1362 #[test]
1363 fn a_dependant_that_can_progress_through_another_blocker_is_not_stuck() {
1364 let (dir, q, questions) = store();
1365 let (root, mid, _leaf) = stuck_chain(&q, dir.path());
1366 let mut live = task("live", dir.path().join("repo"));
1367 q.put(&mut live).unwrap();
1368 let mut both = q.get(&mid.id).unwrap();
1369 both.block(vec![root.id.clone(), live.id.clone()], None);
1370 q.put(&mut both).unwrap();
1371 assert!(
1373 run_once(&q, &questions, None, Timestamp::now())
1374 .asked
1375 .is_empty()
1376 );
1377 }
1378
1379 #[test]
1380 fn answering_discard_detaches_then_removes_the_root() {
1381 let (dir, q, questions) = store();
1382 let (root, mid, _leaf) = stuck_chain(&q, dir.path());
1383 run_once(&q, &questions, None, Timestamp::now());
1384 answer_deps(&questions, 1);
1385
1386 let report = run_once(&q, &questions, None, Timestamp::now());
1387 assert!(q.get(&root.id).is_err());
1388 assert_eq!(q.get(&mid.id).unwrap().status, TaskStatus::Queued);
1389 assert!(
1390 report.asked.is_empty(),
1391 "no per-dependant quarantine question"
1392 );
1393 assert!(report.quarantined.is_empty());
1394 }
1395
1396 #[test]
1397 fn an_unmatched_deps_answer_changes_nothing_and_is_not_reasked() {
1398 let (dir, q, questions) = store();
1399 let (root, mid, _leaf) = stuck_chain(&q, dir.path());
1400 run_once(&q, &questions, None, Timestamp::now());
1401 let mut asked = deps_questions(&questions).remove(0);
1402 asked.choices.clear();
1403 asked.answer(Answer::Text("dunno".to_owned())).unwrap();
1404 questions.put(&mut asked).unwrap();
1405
1406 for _ in 0..2 {
1407 let report = run_once(&q, &questions, None, Timestamp::now());
1408 assert!(report.asked.is_empty() && report.answered.is_empty());
1409 }
1410 assert_eq!(deps_questions(&questions).len(), 1);
1411 assert_eq!(q.get(&root.id).unwrap().status, TaskStatus::Held);
1412 assert_eq!(q.get(&mid.id).unwrap().status, TaskStatus::Blocked);
1413 }
1414
1415 #[test]
1416 fn a_dependency_cycle_terminates_and_is_asked_about_once() {
1417 let (dir, q, questions) = store();
1418 let mut a = task("a", dir.path().join("repo"));
1419 let mut b = task("b", dir.path().join("repo"));
1420 a.block(vec![b.id.clone()], None);
1421 b.block(vec![a.id.clone()], None);
1422 q.put(&mut a).unwrap();
1423 q.put(&mut b).unwrap();
1424
1425 let first = run_once(&q, &questions, None, Timestamp::now());
1426 assert_eq!(first.asked.len(), 1);
1427 let second = run_once(&q, &questions, None, Timestamp::now());
1428 assert!(second.asked.is_empty());
1429 assert_eq!(deps_questions(&questions).len(), 1);
1430 }
1431
1432 #[test]
1433 fn a_missing_dependency_is_still_quarantined_not_asked_about_as_stuck() {
1434 let (dir, q, questions) = store();
1435 let mut t = task("orphan", dir.path().join("repo"));
1436 t.block(vec!["20260101-000000-gone".to_owned()], None);
1437 q.put(&mut t).unwrap();
1438 let report = run_once(&q, &questions, None, Timestamp::now());
1439 assert_eq!(report.quarantined, [t.id.clone()]);
1440 assert!(deps_questions(&questions).is_empty());
1441 }
1442
1443 #[test]
1444 fn a_stale_manual_hold_earns_a_two_choice_question() {
1445 let (dir, q, questions) = store();
1446 let mut t = task("been sitting a while", dir.path().join("repo"));
1447 t.hold_manual(Some("waiting on a decision".to_owned()));
1448 q.put(&mut t).unwrap();
1449 let mut back = q.get(&t.id).unwrap();
1451 back.updated_at = Timestamp::now() - SignedDuration::new(8 * 24 * 60 * 60, 0);
1452 std::fs::write(
1453 q.path_of(&back.id),
1454 serde_json::to_string_pretty(&back).unwrap(),
1455 )
1456 .unwrap();
1457
1458 let report = run_once(&q, &questions, None, Timestamp::now());
1459 assert_eq!(report.asked, [t.id.clone()]);
1460 let open: Vec<_> = questions
1461 .list()
1462 .into_iter()
1463 .filter(|q| q.status.open())
1464 .collect();
1465 assert_eq!(open.len(), 1);
1466 assert_eq!(open[0].choices.len(), 2);
1467 }
1468
1469 #[test]
1470 fn answering_resume_releases_the_task() {
1471 let (dir, q, questions) = store();
1472 let mut t = task("gate went red", dir.path().join("repo"));
1473 t.hold_machine(Some("gate red".to_owned()));
1474 q.put(&mut t).unwrap();
1475 run_once(&q, &questions, None, Timestamp::now());
1476
1477 let mut asked = questions
1478 .list()
1479 .into_iter()
1480 .find(|q| q.run == t.id)
1481 .unwrap();
1482 asked.answer(Answer::Choice(EN.resume.to_owned())).unwrap();
1483 questions.put(&mut asked).unwrap();
1484
1485 let report = run_once(&q, &questions, None, Timestamp::now());
1486 assert_eq!(report.answered, [t.id.clone()]);
1487 let back = q.get(&t.id).unwrap();
1488 assert_eq!(back.status, TaskStatus::Queued);
1489 assert!(back.hold_source.is_none());
1490 }
1491
1492 fn resumed_then_failed(q: &Queue, questions: &Questions, dir: &std::path::Path) -> Task {
1494 let mut t = task("gate went red", dir.join("repo"));
1495 t.hold_machine(Some("gate red".to_owned()));
1496 q.put(&mut t).unwrap();
1497 run_once(q, questions, None, Timestamp::now());
1498 let mut asked = questions
1499 .list()
1500 .into_iter()
1501 .find(|q| q.run == t.id)
1502 .unwrap();
1503 asked.answer(Answer::Choice(EN.resume.to_owned())).unwrap();
1504 questions.put(&mut asked).unwrap();
1505
1506 let report = run_once(q, questions, None, Timestamp::now());
1507 assert_eq!(report.answered, [t.id.clone()]);
1508 let mut back = q.get(&t.id).unwrap();
1509 assert_eq!(back.status, TaskStatus::Queued);
1510 assert_eq!(back.attempts, 0);
1511
1512 back.start("run-1".to_owned());
1513 back.fail("rebase conflict", 1);
1514 assert_eq!(back.status, TaskStatus::Held);
1515 q.put(&mut back).unwrap();
1516 back
1517 }
1518
1519 #[test]
1520 fn a_resume_answer_is_applied_once_and_a_new_machine_hold_is_asked_about() {
1521 let (dir, q, questions) = store();
1522 let t = resumed_then_failed(&q, &questions, dir.path());
1523
1524 let report = run_once(&q, &questions, None, Timestamp::now());
1525 assert!(report.answered.is_empty(), "the old answer must not replay");
1526 assert_eq!(report.asked, std::slice::from_ref(&t.id));
1527 let back = q.get(&t.id).unwrap();
1528 assert_eq!(back.status, TaskStatus::Held);
1529 assert_eq!(
1530 questions
1531 .list()
1532 .into_iter()
1533 .filter(|q| q.status.open())
1534 .count(),
1535 1
1536 );
1537 }
1538
1539 #[test]
1540 fn a_manual_hold_placed_after_a_resume_is_not_undone_by_the_old_answer() {
1541 let (dir, q, questions) = store();
1542 let mut t = resumed_then_failed(&q, &questions, dir.path());
1543 t.hold_manual(Some("operator stopped this".to_owned()));
1544 q.put(&mut t).unwrap();
1545 let before = questions.list().len();
1546
1547 let report = run_once(&q, &questions, None, Timestamp::now());
1548 assert!(report.answered.is_empty());
1549 assert!(report.asked.is_empty());
1550 let back = q.get(&t.id).unwrap();
1551 assert_eq!(back.status, TaskStatus::Held);
1552 assert_eq!(back.hold_source, Some(HoldSource::Manual));
1553 assert_eq!(questions.list().len(), before);
1554 }
1555
1556 #[test]
1557 fn answering_not_yet_keeps_it_held_as_a_manual_hold_and_does_not_reapply() {
1558 let (dir, q, questions) = store();
1559 let mut t = task("gate went red", dir.path().join("repo"));
1560 t.hold_machine(Some("gate red".to_owned()));
1561 q.put(&mut t).unwrap();
1562 run_once(&q, &questions, None, Timestamp::now());
1563
1564 let mut asked = questions
1565 .list()
1566 .into_iter()
1567 .find(|q| q.run == t.id)
1568 .unwrap();
1569 asked.answer(Answer::Choice(EN.wait.to_owned())).unwrap();
1570 questions.put(&mut asked).unwrap();
1571
1572 let report = run_once(&q, &questions, None, Timestamp::now());
1573 assert_eq!(report.answered, [t.id.clone()]);
1574 let back = q.get(&t.id).unwrap();
1575 assert_eq!(back.status, TaskStatus::Held);
1576 assert_eq!(back.hold_source, Some(HoldSource::Manual));
1577 assert!(
1578 back.hold_reason
1579 .as_deref()
1580 .is_some_and(|r| r.contains("gate red")),
1581 "the original cause must survive a \"not yet\" answer, not just the \
1582 triage marker: {:?}",
1583 back.hold_reason
1584 );
1585
1586 let third = run_once(&q, &questions, None, Timestamp::now());
1590 assert!(third.answered.is_empty());
1591 assert!(third.asked.is_empty());
1592 }
1593
1594 #[test]
1595 fn answering_discard_removes_the_task_entirely() {
1596 let (dir, q, questions) = store();
1597 let mut t = task("gate went red", dir.path().join("repo"));
1598 t.hold_machine(Some("gate red".to_owned()));
1599 q.put(&mut t).unwrap();
1600 run_once(&q, &questions, None, Timestamp::now());
1601
1602 let mut asked = questions
1603 .list()
1604 .into_iter()
1605 .find(|q| q.run == t.id)
1606 .unwrap();
1607 asked.answer(Answer::Choice(EN.discard.to_owned())).unwrap();
1608 questions.put(&mut asked).unwrap();
1609
1610 let report = run_once(&q, &questions, None, Timestamp::now());
1611 assert_eq!(report.answered, [t.id.clone()]);
1612 assert!(
1613 q.get(&t.id).is_err(),
1614 "\"discard it\" (捨ててよい) must actually discard the task, not \
1615 just leave it sitting held forever"
1616 );
1617 }
1618
1619 #[test]
1620 fn an_answer_is_read_by_its_position_in_choices_not_by_the_callers_current_language() {
1621 let (dir, q, questions) = store();
1623 let ja_config = dir.path().join("ja.toml");
1624 std::fs::write(&ja_config, "[graph]\nlanguage = \"ja\"\n").unwrap();
1625 let mut t = task("gate went red", dir.path().join("repo"));
1626 t.hold_machine(Some("gate red".to_owned()));
1627 q.put(&mut t).unwrap();
1628 run_once(&q, &questions, Some(&ja_config), Timestamp::now());
1629
1630 let mut asked = questions
1631 .list()
1632 .into_iter()
1633 .find(|q| q.run == t.id)
1634 .unwrap();
1635 assert_eq!(asked.choices[0], JA.resume, "filed in Japanese");
1636 asked.answer(Answer::Choice(JA.resume.to_owned())).unwrap();
1637 questions.put(&mut asked).unwrap();
1638
1639 let en_config = dir.path().join("en.toml");
1644 std::fs::write(&en_config, "[graph]\nlanguage = \"en\"\n").unwrap();
1645 let report = run_once(&q, &questions, Some(&en_config), Timestamp::now());
1646 assert_eq!(report.answered, [t.id.clone()]);
1647 let back = q.get(&t.id).unwrap();
1648 assert_eq!(
1649 back.status,
1650 TaskStatus::Queued,
1651 "a resume answer must resume the task regardless of which \
1652 language it is read back in"
1653 );
1654 }
1655
1656 #[test]
1657 fn a_question_falls_back_to_last_error_when_hold_reason_was_never_set() {
1658 let (dir, q, questions) = store();
1663 let mut t = task("kept failing the gate", dir.path().join("repo"));
1664 t.start("run-1".to_owned());
1665 t.fail("gate red three times running", 1);
1666 assert_eq!(t.status, TaskStatus::Held);
1667 t.hold_reason = None; q.put(&mut t).unwrap();
1669
1670 run_once(&q, &questions, None, Timestamp::now());
1671 let asked = questions
1672 .list()
1673 .into_iter()
1674 .find(|q| q.run == t.id)
1675 .unwrap();
1676 assert!(
1677 asked.detail.contains("gate red three times running"),
1678 "the question must surface `last_error` when there is no \
1679 `hold_reason` to show instead: {}",
1680 asked.detail
1681 );
1682 }
1683
1684 #[test]
1685 fn open_question_for_and_open_task_ids_reflect_only_what_is_still_waiting() {
1686 let (dir, q, questions) = store();
1687 let mut t = task("gate went red", dir.path().join("repo"));
1688 t.hold_machine(Some("gate red".to_owned()));
1689 q.put(&mut t).unwrap();
1690
1691 assert!(open_question_for(&questions, &t.id).is_none());
1692 assert!(!open_task_ids(&questions).contains(&t.id));
1693
1694 run_once(&q, &questions, None, Timestamp::now());
1695 assert!(open_question_for(&questions, &t.id).is_some());
1696 assert!(open_task_ids(&questions).contains(&t.id));
1697
1698 let mut asked = questions
1699 .list()
1700 .into_iter()
1701 .find(|q| q.run == t.id)
1702 .unwrap();
1703 asked.answer(Answer::Choice(EN.resume.to_owned())).unwrap();
1704 questions.put(&mut asked).unwrap();
1705
1706 assert!(
1707 open_question_for(&questions, &t.id).is_none(),
1708 "an answered question is no longer open"
1709 );
1710 assert!(!open_task_ids(&questions).contains(&t.id));
1711 }
1712
1713 #[test]
1714 fn pending_for_stays_true_between_an_answer_and_the_next_run_once_pass() {
1715 let (dir, q, questions) = store();
1721 let mut t = task("gate went red", dir.path().join("repo"));
1722 t.hold_machine(Some("gate red".to_owned()));
1723 q.put(&mut t).unwrap();
1724
1725 assert!(!pending_for(&questions, &t));
1726
1727 run_once(&q, &questions, None, Timestamp::now());
1728 let held = q.get(&t.id).unwrap();
1729 assert!(pending_for(&questions, &held), "still waiting on an answer");
1730
1731 let mut asked = questions
1732 .list()
1733 .into_iter()
1734 .find(|q| q.run == t.id)
1735 .unwrap();
1736 asked.answer(Answer::Choice(EN.wait.to_owned())).unwrap();
1737 questions.put(&mut asked).unwrap();
1738 assert!(!asked.status.open());
1739
1740 let still_held = q.get(&t.id).unwrap();
1742 assert!(
1743 pending_for(&questions, &still_held),
1744 "answered but not yet applied is still pending"
1745 );
1746
1747 run_once(&q, &questions, None, Timestamp::now());
1748 let after = q.get(&t.id).unwrap();
1749 assert!(
1750 !pending_for(&questions, &after),
1751 "the answer is applied now, nothing left pending"
1752 );
1753 }
1754}