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
97const SEAT: &str = "triage";
102
103const MANUAL_STALE_AFTER: Duration = Duration::from_secs(7 * 24 * 60 * 60);
115
116struct Wording {
120 lang: &'static str,
121 resume: &'static str,
122 wait: &'static str,
123 discard: &'static str,
124 resume_now: &'static str,
125 keep_held: &'static str,
126}
127
128const EN: Wording = Wording {
129 lang: "en",
130 resume: "resume it",
131 wait: "not yet",
132 discard: "discard it",
133 resume_now: "resume it",
134 keep_held: "keep it held",
135};
136
137const JA: Wording = Wording {
138 lang: "ja",
139 resume: "再開してよい",
140 wait: "まだ待って",
141 discard: "捨ててよい",
142 resume_now: "再開する",
143 keep_held: "まだ止めておく",
144};
145
146fn wording(language: &str) -> &'static Wording {
149 let l = language.trim();
150 if l.eq_ignore_ascii_case("ja")
151 || l.eq_ignore_ascii_case("jp")
152 || l.eq_ignore_ascii_case("japanese")
153 || l.eq_ignore_ascii_case("日本語")
154 {
155 &JA
156 } else {
157 &EN
158 }
159}
160
161impl Wording {
162 fn choices3(&self) -> Vec<String> {
163 vec![
164 self.resume.to_owned(),
165 self.wait.to_owned(),
166 self.discard.to_owned(),
167 ]
168 }
169
170 fn choices2(&self) -> Vec<String> {
171 vec![self.resume_now.to_owned(), self.keep_held.to_owned()]
172 }
173
174 fn source_label(&self, source: Option<HoldSource>) -> &'static str {
175 match (self.lang, source) {
176 ("ja", Some(HoldSource::Machine)) => "machine(機械による自動保留)",
177 ("ja", Some(HoldSource::Manual)) => "manual(操作者による手動保留)",
178 ("ja", None) => "unknown(schema 3 未満の旧レコード、または理由未記録)",
179 (_, Some(HoldSource::Machine)) => "machine (automatic recovery hold)",
180 (_, Some(HoldSource::Manual)) => "manual (an operator held this)",
181 (_, None) => "unknown (pre-schema-3 record, or never recorded)",
182 }
183 }
184
185 fn detail(&self, task: &Task, why: &str) -> String {
194 let none = if self.lang == "ja" {
195 "(記録なし)"
196 } else {
197 "(none recorded)"
198 };
199 let reason = task
200 .hold_reason
201 .as_deref()
202 .or(task.last_error.as_deref())
203 .unwrap_or(none);
204 format!(
205 "task: {} ({})\ntitle: {}\nhold source: {}\nhold reason: {reason}\n\n{why}",
206 task.id,
207 task.short(),
208 task.title,
209 self.source_label(task.hold_source),
210 )
211 }
212
213 fn summary_machine_unknown(&self, task: &Task) -> String {
214 if self.lang == "ja" {
215 format!("保留タスク {} の再開可否を判断してください", task.short())
216 } else {
217 format!("decide whether to resume held task {}", task.short())
218 }
219 }
220
221 fn why_machine(&self) -> &'static str {
222 if self.lang == "ja" {
223 "機械的な保留(machine hold)ですが、原因がすでに解消しているかを自動では判断できませんでした。"
224 } else {
225 "This is a machine hold, but whether its cause has resolved could not be \
226 checked automatically."
227 }
228 }
229
230 fn summary_conductor_override(&self, task: &Task) -> String {
231 if self.lang == "ja" {
232 format!(
233 "再開と回答済みのタスク {} を conductor が再び保留しました",
234 task.short()
235 )
236 } else {
237 format!(
238 "task {} was resumed at your word, but the conductor held it again",
239 task.short()
240 )
241 }
242 }
243
244 fn why_conductor_override(&self, o: &OperatorResume) -> String {
245 let reason = o.conductor_rehold.as_deref().unwrap_or_default();
246 if self.lang == "ja" {
247 format!(
248 "{} に再開と回答済みですが、conductor が再び hold しました。conductor の理由: \
249 {reason}\n\n強制再キューを選ぶと、以後 conductor はこのタスクを hold できません。",
250 o.at
251 )
252 } else {
253 format!(
254 "You answered \"resume\" at {}, but the conductor held the task again. \
255 Its reason: {reason}\n\nForcing a requeue stops the conductor from \
256 holding this task again.",
257 o.at
258 )
259 }
260 }
261
262 fn choices_conductor_override(&self) -> Vec<String> {
264 if self.lang == "ja" {
265 vec![
266 "強制再キュー(conductor は再 hold 不可)".to_owned(),
267 "手動 hold のまま".to_owned(),
268 "捨ててよい".to_owned(),
269 ]
270 } else {
271 vec![
272 "force requeue (conductor must not hold again)".to_owned(),
273 "keep held (manual)".to_owned(),
274 "discard".to_owned(),
275 ]
276 }
277 }
278
279 fn summary_legacy(&self, task: &Task) -> String {
280 if self.lang == "ja" {
281 format!(
282 "hold_source が不明な保留タスク {} を確認してください",
283 task.short()
284 )
285 } else {
286 format!(
287 "held task {} has no recorded hold source - please take a look",
288 task.short()
289 )
290 }
291 }
292
293 fn why_legacy(&self) -> &'static str {
294 if self.lang == "ja" {
295 "hold_source が記録されていません。schema 3 より前のレコードか、理由が記録されなかった \
296 holdです。人が意図して止めたのか、クラッシュや強制再起動で宙に浮いただけなのか、\
297 このデータからは区別できません。"
298 } else {
299 "No hold_source was recorded - either a pre-schema-3 record, or a hold whose \
300 reason was never written down. Whether this was a deliberate hold or the \
301 leftover of a crash cannot be told from the data alone."
302 }
303 }
304
305 fn summary_manual_stale(&self, task: &Task, days: i64) -> String {
306 if self.lang == "ja" {
307 format!(
308 "{days}日間 保留されたままの手動保留タスク {} を確認してください",
309 task.short()
310 )
311 } else {
312 format!(
313 "held task {} has been on a manual hold for {days} day(s)",
314 task.short()
315 )
316 }
317 }
318
319 fn why_manual(&self) -> &'static str {
320 if self.lang == "ja" {
321 "操作者が明示的に止めた保留ですが、長期間そのままになっています。まだ止めておくか、\
322 再開するか教えてください。"
323 } else {
324 "An operator held this on purpose, but it has sat untouched for a while. Say \
325 whether to keep holding it or resume it."
326 }
327 }
328}
329
330#[derive(Debug, Clone, Copy, PartialEq, Eq)]
332enum Bucket {
333 MachineUnknown,
335 ConductorOverride,
338 Legacy,
340 ManualStale,
342}
343
344#[derive(Debug, Clone, Default)]
346pub struct Report {
347 pub resumed: Vec<String>,
350 pub asked: Vec<String>,
352 pub answered: Vec<String>,
354 pub quarantined: Vec<String>,
359}
360
361impl Report {
362 pub fn is_empty(&self) -> bool {
365 self.resumed.is_empty()
366 && self.asked.is_empty()
367 && self.answered.is_empty()
368 && self.quarantined.is_empty()
369 }
370}
371
372fn repo_for(task: &Task) -> PathBuf {
376 if task.repo.as_os_str().is_empty() {
377 PathBuf::from(".")
378 } else {
379 task.repo.clone()
380 }
381}
382
383fn is_disk_hold(task: &Task) -> bool {
394 task.hold_reason.as_deref().is_some_and(|r| {
395 r.starts_with("not enough free space to start a run:")
396 || r.starts_with("could not measure free space on ")
397 })
398}
399
400fn machine_cause_resolved(task: &Task, cfg: &Config) -> Option<bool> {
405 if !is_disk_hold(task) {
406 return None;
407 }
408 let min = cfg.disk.min_free_bytes;
409 if min == 0 {
410 return Some(true);
413 }
414 let free = disk::free_bytes(&repo_for(task)).ok()?;
415 Some(disk::gate(free, min).is_none())
416}
417
418fn manual_is_stale(task: &Task, now: Timestamp) -> bool {
422 now.as_second() - task.updated_at.as_second() > MANUAL_STALE_AFTER.as_secs() as i64
423}
424
425fn marker_for(q: &Question) -> String {
428 format!("[triage:{}]", q.short())
429}
430
431fn already_applied(task: &Task, q: &Question) -> bool {
434 if task.triage_applied(&q.id) {
435 return true;
436 }
437 let marker = marker_for(q);
440 task.hold_reason
441 .as_deref()
442 .is_some_and(|r| r.contains(marker.as_str()))
443}
444
445fn latest_triage_question(questions: &Questions, task_id: &str) -> Option<Question> {
450 questions
451 .list()
452 .into_iter()
453 .filter(|q| q.node == NODE && q.run == task_id)
454 .max_by(|a, b| a.asked_at.cmp(&b.asked_at).then_with(|| a.id.cmp(&b.id)))
457}
458
459#[derive(Debug, Clone, Copy, PartialEq, Eq)]
462enum AnswerAction {
463 Resume,
467 Discard,
470 KeepHeld,
475}
476
477fn interpret_answer(q: &Question) -> AnswerAction {
490 let resolution = q.resolution().unwrap_or_default();
491 match q.choices.iter().position(|c| *c == resolution) {
492 Some(0) => AnswerAction::Resume,
493 Some(2) => AnswerAction::Discard,
494 _ => AnswerAction::KeepHeld,
495 }
496}
497
498fn keep_held_note(task: &Task, q: &Question, resolution: &str) -> String {
503 let marker = format!("{} operator: {resolution}", marker_for(q));
504 match task.hold_reason.as_deref() {
505 Some(existing) if !existing.is_empty() => format!("{existing}\n{marker}"),
506 _ => marker,
507 }
508}
509
510fn file_question(
514 questions: &Questions,
515 task: &Task,
516 bucket: Bucket,
517 w: &Wording,
518 now: Timestamp,
519) -> Option<Question> {
520 let (summary, why, choices) = match bucket {
521 Bucket::MachineUnknown => (
522 w.summary_machine_unknown(task),
523 w.why_machine().to_owned(),
524 w.choices3(),
525 ),
526 Bucket::ConductorOverride => (
527 w.summary_conductor_override(task),
528 task.resume_override
529 .as_ref()
530 .map(|o| w.why_conductor_override(o))
531 .unwrap_or_default(),
532 w.choices_conductor_override(),
533 ),
534 Bucket::Legacy => (
535 w.summary_legacy(task),
536 w.why_legacy().to_owned(),
537 w.choices3(),
538 ),
539 Bucket::ManualStale => {
540 let days = (now.as_second() - task.updated_at.as_second()) / (24 * 60 * 60);
541 (
542 w.summary_manual_stale(task, days),
543 w.why_manual().to_owned(),
544 w.choices2(),
545 )
546 }
547 };
548 let mut q = Question::new(
549 task.id.clone(),
550 NODE.to_owned(),
551 SEAT.to_owned(),
552 summary,
553 w.detail(task, &why),
554 choices,
555 );
556 questions.put(&mut q).ok()?;
557 Some(q)
558}
559
560fn quarantine_orphaned_blocked(queue: &Queue, questions: &Questions) -> Vec<String> {
578 let mut quarantined = Vec::new();
579 for listed in queue.list() {
580 if listed.status != TaskStatus::Blocked || listed.blocked_by.is_empty() {
581 continue;
582 }
583 let Ok(_claim) = queue.claim(&listed.id) else {
584 continue;
585 };
586 let Ok(mut task) = queue.get(&listed.id) else {
587 continue;
588 };
589 if task.status != TaskStatus::Blocked {
590 continue;
591 }
592 let missing = crate::queue::missing_blockers(queue, questions, &task.blocked_by);
593 if missing.is_empty() {
594 continue;
595 }
596 task.hold_machine(Some(crate::queue::missing_blocker_hold_reason(
597 &task.blocked_by,
598 &missing,
599 )));
600 if queue.put(&mut task).is_ok() {
601 quarantined.push(task.id.clone());
602 }
603 }
604 quarantined
605}
606
607pub fn run_once(
625 queue: &Queue,
626 questions: &Questions,
627 config_override: Option<&Path>,
628 now: Timestamp,
629) -> Report {
630 let mut report = Report {
631 quarantined: quarantine_orphaned_blocked(queue, questions),
632 ..Report::default()
633 };
634 for listed in queue.list() {
635 if listed.status != TaskStatus::Held {
636 continue;
637 }
638 let Ok(_claim) = queue.claim(&listed.id) else {
639 continue;
640 };
641 let Ok(mut task) = queue.get(&listed.id) else {
642 continue;
643 };
644 if task.status != TaskStatus::Held {
648 continue;
649 }
650
651 let cfg = Config::discover(&repo_for(&task), config_override)
652 .ok()
653 .map(|(c, _)| c);
654 let w = wording(cfg.as_ref().map_or("en", |c| c.graph.language.as_str()));
655
656 if let Some(q) = latest_triage_question(questions, &task.id) {
657 if q.status.open() {
658 continue;
660 }
661 if q.status == QuestionStatus::Answered && !already_applied(&task, &q) {
662 match interpret_answer(&q) {
663 AnswerAction::Resume => {
664 let contradicted = task
669 .resume_override
670 .as_ref()
671 .is_some_and(|o| o.conductor_rehold.is_some());
672 let record = match task.resume_override.take() {
673 Some(mut o) if contradicted => {
674 o.forced = true;
675 o
676 }
677 _ => OperatorResume {
678 question_id: q.id.clone(),
679 at: now,
680 conductor_rehold: None,
681 forced: false,
682 },
683 };
684 task.release();
685 task.resume_override = Some(record);
686 task.mark_triage_applied(&q.id);
687 if queue.put(&mut task).is_ok() {
688 report.answered.push(task.id.clone());
689 }
690 }
691 AnswerAction::Discard => {
692 if queue.remove(&task.id, false, questions).is_ok() {
693 report.answered.push(task.id.clone());
694 }
695 }
696 AnswerAction::KeepHeld => {
697 let resolution = q.resolution().unwrap_or_default();
698 let note = keep_held_note(&task, &q, &resolution);
699 task.hold_manual(Some(note));
700 task.mark_triage_applied(&q.id);
701 if queue.put(&mut task).is_ok() {
702 report.answered.push(task.id.clone());
703 }
704 }
705 }
706 continue;
707 }
708 }
714
715 match task.hold_source {
716 Some(HoldSource::Machine) => {
717 let overridden = task
718 .resume_override
719 .as_ref()
720 .is_some_and(|o| o.conductor_rehold.is_some() && !o.forced);
721 if overridden {
722 if file_question(questions, &task, Bucket::ConductorOverride, w, now).is_some()
723 {
724 report.asked.push(task.id.clone());
725 }
726 } else if cfg.as_ref().and_then(|c| machine_cause_resolved(&task, c)) == Some(true)
727 {
728 task.release();
729 if queue.put(&mut task).is_ok() {
730 report.resumed.push(task.id.clone());
731 }
732 } else if file_question(questions, &task, Bucket::MachineUnknown, w, now).is_some()
733 {
734 report.asked.push(task.id.clone());
735 }
736 }
737 None => {
738 if file_question(questions, &task, Bucket::Legacy, w, now).is_some() {
739 report.asked.push(task.id.clone());
740 }
741 }
742 Some(HoldSource::Manual) => {
743 if manual_is_stale(&task, now)
744 && file_question(questions, &task, Bucket::ManualStale, w, now).is_some()
745 {
746 report.asked.push(task.id.clone());
747 }
748 }
749 }
750 }
751 report
752}
753
754pub fn open_question_for(questions: &Questions, task_id: &str) -> Option<Question> {
759 latest_triage_question(questions, task_id).filter(|q| q.status.open())
760}
761
762pub fn pending_for(questions: &Questions, task: &Task) -> bool {
776 match latest_triage_question(questions, &task.id) {
777 Some(q) if q.status.open() => true,
778 Some(q) if q.status == QuestionStatus::Answered => !already_applied(task, &q),
779 _ => false,
780 }
781}
782
783pub fn open_task_ids(questions: &Questions) -> std::collections::BTreeSet<String> {
788 questions
789 .list()
790 .into_iter()
791 .filter(|q| q.node == NODE && q.status.open())
792 .map(|q| q.run)
793 .collect()
794}
795
796#[cfg(test)]
797mod tests {
798 use super::*;
799 use crate::ask::Answer;
800 use crate::queue::Source;
801 use jiff::SignedDuration;
802
803 fn store() -> (tempfile::TempDir, Queue, Questions) {
804 let dir = tempfile::tempdir().unwrap();
805 let q = Queue::at(dir.path().join("queue"));
806 let s = Questions::at(dir.path().join("questions"));
807 (dir, q, s)
808 }
809
810 fn task(title: &str, repo: PathBuf) -> Task {
811 Task::new(title.to_owned(), format!("do {title}"), repo, Source::Human)
812 }
813
814 fn gate_disabled_config(dir: &std::path::Path) -> PathBuf {
818 let config = dir.join("magi.toml");
819 std::fs::write(&config, "[disk]\nmin_free_bytes = 0\n").unwrap();
820 config
821 }
822
823 #[test]
824 fn a_resolved_machine_hold_is_requeued_automatically() {
825 let (dir, q, questions) = store();
826 let config = gate_disabled_config(dir.path());
827 let mut t = task("disk pressure", dir.path().join("repo"));
828 t.hold_machine(Some(
829 "not enough free space to start a run: 10 bytes free, 100 required by \
830 `[disk] min_free_bytes`"
831 .to_owned(),
832 ));
833 q.put(&mut t).unwrap();
834
835 let report = run_once(&q, &questions, Some(&config), Timestamp::now());
836 assert_eq!(report.resumed, [t.id.clone()]);
837 assert!(report.asked.is_empty());
838
839 let back = q.get(&t.id).unwrap();
840 assert_eq!(back.status, TaskStatus::Queued);
841 assert!(back.hold_source.is_none());
842 assert!(questions.list().is_empty(), "nothing needed asking");
843 }
844
845 #[test]
846 fn a_machine_hold_with_no_recognised_cause_gets_one_question_not_two() {
847 let (dir, q, questions) = store();
848 let mut t = task("gate went red", dir.path().join("repo"));
849 t.hold_machine(Some("gate red".to_owned()));
850 q.put(&mut t).unwrap();
851
852 let first = run_once(&q, &questions, None, Timestamp::now());
853 assert_eq!(first.asked, [t.id.clone()]);
854 assert!(first.resumed.is_empty());
855
856 let open: Vec<_> = questions
857 .list()
858 .into_iter()
859 .filter(|q| q.status.open())
860 .collect();
861 assert_eq!(open.len(), 1);
862 assert_eq!(open[0].run, t.id);
863 assert_eq!(open[0].node, NODE);
864 assert_eq!(open[0].choices.len(), 3);
865
866 let second = run_once(&q, &questions, None, Timestamp::now());
868 assert!(second.asked.is_empty());
869 assert_eq!(
870 questions
871 .list()
872 .into_iter()
873 .filter(|q| q.status.open())
874 .count(),
875 1
876 );
877 }
878
879 #[test]
880 fn a_legacy_hold_with_no_recorded_source_gets_exactly_one_question() {
881 let (dir, q, questions) = store();
882 let mut t = task("schema 1 record", dir.path().join("repo"));
883 t.status = TaskStatus::Held;
884 assert!(t.hold_source.is_none(), "the case this test is about");
885 q.put(&mut t).unwrap();
886
887 let first = run_once(&q, &questions, None, Timestamp::now());
888 assert_eq!(first.asked, [t.id.clone()]);
889
890 let second = run_once(&q, &questions, None, Timestamp::now());
891 assert!(
892 second.asked.is_empty(),
893 "the same legacy hold must not be asked about twice"
894 );
895 assert_eq!(
896 questions
897 .list()
898 .into_iter()
899 .filter(|q| q.status.open())
900 .count(),
901 1
902 );
903 }
904
905 #[test]
906 fn a_manual_hold_is_never_auto_resumed() {
907 let (dir, q, questions) = store();
908 let mut t = task("operator stopped this", dir.path().join("repo"));
909 t.hold_manual(Some("waiting on a decision".to_owned()));
910 q.put(&mut t).unwrap();
911
912 let report = run_once(&q, &questions, None, Timestamp::now());
913 assert!(report.resumed.is_empty());
914 assert!(report.asked.is_empty());
916
917 let back = q.get(&t.id).unwrap();
918 assert_eq!(back.status, TaskStatus::Held);
919 assert_eq!(back.hold_source, Some(HoldSource::Manual));
920 assert!(questions.list().is_empty());
921 }
922
923 #[test]
924 fn a_blocked_task_on_a_deleted_dependency_is_held_and_asked_about_in_one_pass() {
925 let (dir, q, questions) = store();
930 let mut still_going = task("still valid", dir.path().join("repo"));
931 q.put(&mut still_going).unwrap();
932
933 let mut t = task("orphaned", dir.path().join("repo"));
934 t.block(
935 vec!["20260101-000000-gone".to_owned(), still_going.id.clone()],
936 Some("waits on both".to_owned()),
937 );
938 q.put(&mut t).unwrap();
939
940 let report = run_once(&q, &questions, None, Timestamp::now());
941 assert_eq!(report.quarantined, [t.id.clone()]);
942 assert_eq!(
943 report.asked,
944 [t.id.clone()],
945 "the fresh machine hold must earn a question in the same pass"
946 );
947
948 let after = q.get(&t.id).unwrap();
949 assert_eq!(after.status, TaskStatus::Held);
950 assert_eq!(after.hold_source, Some(HoldSource::Machine));
951 assert!(after.blocked_by.is_empty());
952
953 assert!(!is_disk_hold(&after));
956
957 let open: Vec<_> = questions
958 .list()
959 .into_iter()
960 .filter(|q| q.status.open())
961 .collect();
962 assert_eq!(open.len(), 1);
963 assert_eq!(open[0].run, t.id);
964
965 let second = run_once(&q, &questions, None, Timestamp::now());
967 assert!(second.quarantined.is_empty());
968 assert!(second.asked.is_empty());
969 }
970
971 #[test]
972 fn a_stale_manual_hold_earns_a_two_choice_question() {
973 let (dir, q, questions) = store();
974 let mut t = task("been sitting a while", dir.path().join("repo"));
975 t.hold_manual(Some("waiting on a decision".to_owned()));
976 q.put(&mut t).unwrap();
977 let mut back = q.get(&t.id).unwrap();
979 back.updated_at = Timestamp::now() - SignedDuration::new(8 * 24 * 60 * 60, 0);
980 std::fs::write(
981 q.path_of(&back.id),
982 serde_json::to_string_pretty(&back).unwrap(),
983 )
984 .unwrap();
985
986 let report = run_once(&q, &questions, None, Timestamp::now());
987 assert_eq!(report.asked, [t.id.clone()]);
988 let open: Vec<_> = questions
989 .list()
990 .into_iter()
991 .filter(|q| q.status.open())
992 .collect();
993 assert_eq!(open.len(), 1);
994 assert_eq!(open[0].choices.len(), 2);
995 }
996
997 #[test]
998 fn answering_resume_releases_the_task() {
999 let (dir, q, questions) = store();
1000 let mut t = task("gate went red", dir.path().join("repo"));
1001 t.hold_machine(Some("gate red".to_owned()));
1002 q.put(&mut t).unwrap();
1003 run_once(&q, &questions, None, Timestamp::now());
1004
1005 let mut asked = questions
1006 .list()
1007 .into_iter()
1008 .find(|q| q.run == t.id)
1009 .unwrap();
1010 asked.answer(Answer::Choice(EN.resume.to_owned())).unwrap();
1011 questions.put(&mut asked).unwrap();
1012
1013 let report = run_once(&q, &questions, None, Timestamp::now());
1014 assert_eq!(report.answered, [t.id.clone()]);
1015 let back = q.get(&t.id).unwrap();
1016 assert_eq!(back.status, TaskStatus::Queued);
1017 assert!(back.hold_source.is_none());
1018 }
1019
1020 fn resumed_then_failed(q: &Queue, questions: &Questions, dir: &std::path::Path) -> Task {
1022 let mut t = task("gate went red", dir.join("repo"));
1023 t.hold_machine(Some("gate red".to_owned()));
1024 q.put(&mut t).unwrap();
1025 run_once(q, questions, None, Timestamp::now());
1026 let mut asked = questions
1027 .list()
1028 .into_iter()
1029 .find(|q| q.run == t.id)
1030 .unwrap();
1031 asked.answer(Answer::Choice(EN.resume.to_owned())).unwrap();
1032 questions.put(&mut asked).unwrap();
1033
1034 let report = run_once(q, questions, None, Timestamp::now());
1035 assert_eq!(report.answered, [t.id.clone()]);
1036 let mut back = q.get(&t.id).unwrap();
1037 assert_eq!(back.status, TaskStatus::Queued);
1038 assert_eq!(back.attempts, 0);
1039
1040 back.start("run-1".to_owned());
1041 back.fail("rebase conflict", 1);
1042 assert_eq!(back.status, TaskStatus::Held);
1043 q.put(&mut back).unwrap();
1044 back
1045 }
1046
1047 #[test]
1048 fn a_resume_answer_is_applied_once_and_a_new_machine_hold_is_asked_about() {
1049 let (dir, q, questions) = store();
1050 let t = resumed_then_failed(&q, &questions, dir.path());
1051
1052 let report = run_once(&q, &questions, None, Timestamp::now());
1053 assert!(report.answered.is_empty(), "the old answer must not replay");
1054 assert_eq!(report.asked, std::slice::from_ref(&t.id));
1055 let back = q.get(&t.id).unwrap();
1056 assert_eq!(back.status, TaskStatus::Held);
1057 assert_eq!(
1058 questions
1059 .list()
1060 .into_iter()
1061 .filter(|q| q.status.open())
1062 .count(),
1063 1
1064 );
1065 }
1066
1067 #[test]
1068 fn a_manual_hold_placed_after_a_resume_is_not_undone_by_the_old_answer() {
1069 let (dir, q, questions) = store();
1070 let mut t = resumed_then_failed(&q, &questions, dir.path());
1071 t.hold_manual(Some("operator stopped this".to_owned()));
1072 q.put(&mut t).unwrap();
1073 let before = questions.list().len();
1074
1075 let report = run_once(&q, &questions, None, Timestamp::now());
1076 assert!(report.answered.is_empty());
1077 assert!(report.asked.is_empty());
1078 let back = q.get(&t.id).unwrap();
1079 assert_eq!(back.status, TaskStatus::Held);
1080 assert_eq!(back.hold_source, Some(HoldSource::Manual));
1081 assert_eq!(questions.list().len(), before);
1082 }
1083
1084 #[test]
1085 fn answering_not_yet_keeps_it_held_as_a_manual_hold_and_does_not_reapply() {
1086 let (dir, q, questions) = store();
1087 let mut t = task("gate went red", dir.path().join("repo"));
1088 t.hold_machine(Some("gate red".to_owned()));
1089 q.put(&mut t).unwrap();
1090 run_once(&q, &questions, None, Timestamp::now());
1091
1092 let mut asked = questions
1093 .list()
1094 .into_iter()
1095 .find(|q| q.run == t.id)
1096 .unwrap();
1097 asked.answer(Answer::Choice(EN.wait.to_owned())).unwrap();
1098 questions.put(&mut asked).unwrap();
1099
1100 let report = run_once(&q, &questions, None, Timestamp::now());
1101 assert_eq!(report.answered, [t.id.clone()]);
1102 let back = q.get(&t.id).unwrap();
1103 assert_eq!(back.status, TaskStatus::Held);
1104 assert_eq!(back.hold_source, Some(HoldSource::Manual));
1105 assert!(
1106 back.hold_reason
1107 .as_deref()
1108 .is_some_and(|r| r.contains("gate red")),
1109 "the original cause must survive a \"not yet\" answer, not just the \
1110 triage marker: {:?}",
1111 back.hold_reason
1112 );
1113
1114 let third = run_once(&q, &questions, None, Timestamp::now());
1118 assert!(third.answered.is_empty());
1119 assert!(third.asked.is_empty());
1120 }
1121
1122 #[test]
1123 fn answering_discard_removes_the_task_entirely() {
1124 let (dir, q, questions) = store();
1125 let mut t = task("gate went red", dir.path().join("repo"));
1126 t.hold_machine(Some("gate red".to_owned()));
1127 q.put(&mut t).unwrap();
1128 run_once(&q, &questions, None, Timestamp::now());
1129
1130 let mut asked = questions
1131 .list()
1132 .into_iter()
1133 .find(|q| q.run == t.id)
1134 .unwrap();
1135 asked.answer(Answer::Choice(EN.discard.to_owned())).unwrap();
1136 questions.put(&mut asked).unwrap();
1137
1138 let report = run_once(&q, &questions, None, Timestamp::now());
1139 assert_eq!(report.answered, [t.id.clone()]);
1140 assert!(
1141 q.get(&t.id).is_err(),
1142 "\"discard it\" (捨ててよい) must actually discard the task, not \
1143 just leave it sitting held forever"
1144 );
1145 }
1146
1147 #[test]
1148 fn an_answer_is_read_by_its_position_in_choices_not_by_the_callers_current_language() {
1149 let (dir, q, questions) = store();
1151 let ja_config = dir.path().join("ja.toml");
1152 std::fs::write(&ja_config, "[graph]\nlanguage = \"ja\"\n").unwrap();
1153 let mut t = task("gate went red", dir.path().join("repo"));
1154 t.hold_machine(Some("gate red".to_owned()));
1155 q.put(&mut t).unwrap();
1156 run_once(&q, &questions, Some(&ja_config), Timestamp::now());
1157
1158 let mut asked = questions
1159 .list()
1160 .into_iter()
1161 .find(|q| q.run == t.id)
1162 .unwrap();
1163 assert_eq!(asked.choices[0], JA.resume, "filed in Japanese");
1164 asked.answer(Answer::Choice(JA.resume.to_owned())).unwrap();
1165 questions.put(&mut asked).unwrap();
1166
1167 let en_config = dir.path().join("en.toml");
1172 std::fs::write(&en_config, "[graph]\nlanguage = \"en\"\n").unwrap();
1173 let report = run_once(&q, &questions, Some(&en_config), Timestamp::now());
1174 assert_eq!(report.answered, [t.id.clone()]);
1175 let back = q.get(&t.id).unwrap();
1176 assert_eq!(
1177 back.status,
1178 TaskStatus::Queued,
1179 "a resume answer must resume the task regardless of which \
1180 language it is read back in"
1181 );
1182 }
1183
1184 #[test]
1185 fn a_question_falls_back_to_last_error_when_hold_reason_was_never_set() {
1186 let (dir, q, questions) = store();
1190 let mut t = task("kept failing the gate", dir.path().join("repo"));
1191 t.start("run-1".to_owned());
1192 t.fail("gate red three times running", 1);
1193 assert_eq!(t.status, TaskStatus::Held);
1194 assert!(t.hold_reason.is_none(), "the case this test is about");
1195 q.put(&mut t).unwrap();
1196
1197 run_once(&q, &questions, None, Timestamp::now());
1198 let asked = questions
1199 .list()
1200 .into_iter()
1201 .find(|q| q.run == t.id)
1202 .unwrap();
1203 assert!(
1204 asked.detail.contains("gate red three times running"),
1205 "the question must surface `last_error` when there is no \
1206 `hold_reason` to show instead: {}",
1207 asked.detail
1208 );
1209 }
1210
1211 #[test]
1212 fn open_question_for_and_open_task_ids_reflect_only_what_is_still_waiting() {
1213 let (dir, q, questions) = store();
1214 let mut t = task("gate went red", dir.path().join("repo"));
1215 t.hold_machine(Some("gate red".to_owned()));
1216 q.put(&mut t).unwrap();
1217
1218 assert!(open_question_for(&questions, &t.id).is_none());
1219 assert!(!open_task_ids(&questions).contains(&t.id));
1220
1221 run_once(&q, &questions, None, Timestamp::now());
1222 assert!(open_question_for(&questions, &t.id).is_some());
1223 assert!(open_task_ids(&questions).contains(&t.id));
1224
1225 let mut asked = questions
1226 .list()
1227 .into_iter()
1228 .find(|q| q.run == t.id)
1229 .unwrap();
1230 asked.answer(Answer::Choice(EN.resume.to_owned())).unwrap();
1231 questions.put(&mut asked).unwrap();
1232
1233 assert!(
1234 open_question_for(&questions, &t.id).is_none(),
1235 "an answered question is no longer open"
1236 );
1237 assert!(!open_task_ids(&questions).contains(&t.id));
1238 }
1239
1240 #[test]
1241 fn pending_for_stays_true_between_an_answer_and_the_next_run_once_pass() {
1242 let (dir, q, questions) = store();
1248 let mut t = task("gate went red", dir.path().join("repo"));
1249 t.hold_machine(Some("gate red".to_owned()));
1250 q.put(&mut t).unwrap();
1251
1252 assert!(!pending_for(&questions, &t));
1253
1254 run_once(&q, &questions, None, Timestamp::now());
1255 let held = q.get(&t.id).unwrap();
1256 assert!(pending_for(&questions, &held), "still waiting on an answer");
1257
1258 let mut asked = questions
1259 .list()
1260 .into_iter()
1261 .find(|q| q.run == t.id)
1262 .unwrap();
1263 asked.answer(Answer::Choice(EN.wait.to_owned())).unwrap();
1264 questions.put(&mut asked).unwrap();
1265 assert!(!asked.status.open());
1266
1267 let still_held = q.get(&t.id).unwrap();
1269 assert!(
1270 pending_for(&questions, &still_held),
1271 "answered but not yet applied is still pending"
1272 );
1273
1274 run_once(&q, &questions, None, Timestamp::now());
1275 let after = q.get(&t.id).unwrap();
1276 assert!(
1277 !pending_for(&questions, &after),
1278 "the answer is applied now, nothing left pending"
1279 );
1280 }
1281}