1use std::collections::HashMap;
34use std::path::{Path, PathBuf};
35
36use anyhow::{Context, Result, bail};
37use jiff::Timestamp;
38use serde::{Deserialize, Serialize};
39
40use crate::ask::Questions;
41
42pub const SCHEMA: u32 = 15;
122
123#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
125#[serde(rename_all = "lowercase")]
126pub enum HoldSource {
127 Manual,
129 Machine,
131}
132
133impl HoldSource {
134 pub fn label(self) -> &'static str {
136 match self {
137 Self::Manual => "manual",
138 Self::Machine => "machine",
139 }
140 }
141}
142
143#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
146#[serde(tag = "kind", rename_all = "lowercase")]
147pub enum Source {
148 Human,
150 Agent {
153 run: String,
155 node: String,
157 },
158 Issue {
160 number: u64,
162 repo: String,
164 },
165}
166
167pub const CHAT_NODE: &str = "chat";
170
171impl Source {
172 pub fn label(&self) -> String {
174 match self {
175 Self::Human => "human".to_owned(),
176 Self::Agent { run, node } => format!("{node}@{}", short(run)),
177 Self::Issue { number, .. } => format!("issue #{number}"),
178 }
179 }
180}
181
182#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
184#[serde(rename_all = "lowercase")]
185pub enum TaskStatus {
186 Queued,
188 Running,
190 Done,
192 Failed,
194 Held,
196 Blocked,
200 Parked,
205}
206
207impl TaskStatus {
208 pub fn runnable(self) -> bool {
210 matches!(self, Self::Queued | Self::Failed | Self::Parked)
211 }
212
213 pub fn as_str(self) -> &'static str {
215 match self {
216 Self::Queued => "queued",
217 Self::Running => "running",
218 Self::Done => "done",
219 Self::Failed => "failed",
220 Self::Held => "held",
221 Self::Blocked => "blocked",
222 Self::Parked => "parked",
223 }
224 }
225}
226
227#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
230pub struct TaskCounts {
231 pub queued: usize,
233 pub running: usize,
235 pub done: usize,
237 pub failed: usize,
239 pub held: usize,
241 pub blocked: usize,
243 pub parked: usize,
245}
246
247impl TaskCounts {
248 pub fn of(tasks: &[Task]) -> Self {
252 let mut counts = Self::default();
253 for t in tasks {
254 match t.status {
255 TaskStatus::Queued => counts.queued += 1,
256 TaskStatus::Running => counts.running += 1,
257 TaskStatus::Done => counts.done += 1,
258 TaskStatus::Failed => counts.failed += 1,
259 TaskStatus::Held => counts.held += 1,
260 TaskStatus::Blocked => counts.blocked += 1,
261 TaskStatus::Parked => counts.parked += 1,
262 }
263 }
264 counts
265 }
266}
267
268#[derive(Debug, Clone, Serialize, Deserialize)]
270#[serde(deny_unknown_fields)]
271pub struct Task {
272 pub schema: u32,
274 pub id: String,
276 pub title: String,
278 pub instruction: String,
280 pub repo: PathBuf,
282 pub source: Source,
284 #[serde(default)]
286 pub priority: i32,
287 #[serde(default)]
298 pub solo: bool,
299 pub status: TaskStatus,
301 #[serde(default)]
303 pub attempts: usize,
304 #[serde(default)]
306 pub runs: Vec<String>,
307 #[serde(default)]
309 pub last_error: Option<String>,
310 #[serde(default)]
323 pub hold_reason: Option<String>,
324 #[serde(default)]
327 pub hold_source: Option<HoldSource>,
328 #[serde(default)]
340 pub diagnostic: Option<String>,
341 #[serde(default)]
351 pub blocked_by: Vec<String>,
352 #[serde(default)]
355 pub block_reason: Option<String>,
356 #[serde(default)]
369 pub blocked_from: Option<TaskStatus>,
370 #[serde(default)]
381 pub answers: Vec<AnsweredQuestion>,
382 #[serde(default)]
388 pub triage_applied: Vec<String>,
389 #[serde(default)]
395 pub actions_applied: Vec<String>,
396 #[serde(default)]
401 pub resume_override: Option<OperatorResume>,
402 #[serde(default)]
414 pub review_branch: Option<String>,
415 #[serde(default)]
418 pub fresh_start: bool,
419 #[serde(default)]
430 pub interrupt: bool,
431 #[serde(default)]
454 pub urgent: bool,
455 #[serde(default)]
461 pub attachments: Vec<String>,
462 #[serde(default)]
466 pub followup: Option<FollowUp>,
467 #[serde(default)]
473 pub origin_chat: Option<String>,
474 #[serde(default)]
481 pub overrides: Option<RunOverrides>,
482 #[serde(default)]
489 pub review_of: Option<String>,
490 #[serde(default)]
495 pub held_at: Option<Timestamp>,
496 #[serde(default)]
500 pub park_reason: Option<String>,
501 #[serde(default)]
504 pub chat_notice: Option<ChatNotice>,
505 #[serde(default)]
510 pub passes: Vec<TaskPass>,
511 pub created_at: Timestamp,
513 pub updated_at: Timestamp,
515}
516
517pub(crate) fn is_done_notice_key(key: &str) -> bool {
519 key == "done"
520 || key
521 .strip_prefix("done#")
522 .is_some_and(|count| !count.is_empty() && count.bytes().all(|b| b.is_ascii_digit()))
523}
524
525#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
527#[serde(rename_all = "snake_case")]
528pub enum PassCost {
529 Spent,
531 Refunded,
533}
534
535#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
537pub struct TaskPass {
538 pub run: String,
540 pub pass_index: usize,
542 pub cost: PassCost,
544 pub attempts_after: usize,
546 pub settled_at: Timestamp,
548 #[serde(default)]
550 pub parked: Option<crate::run::ParkCause>,
551}
552
553#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
555#[serde(rename_all = "lowercase")]
556pub enum ChatNoticeOutcome {
557 Posted,
559 Skipped,
561}
562
563#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
565pub struct ChatNotice {
566 pub key: String,
568 pub at: Timestamp,
570 pub outcome: ChatNoticeOutcome,
572}
573
574#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
576pub struct RunOverrides {
577 #[serde(default)]
580 pub inherit_daemon_options: bool,
581 #[serde(default)]
583 pub merge: Option<String>,
584 #[serde(default)]
586 pub candidates: Option<usize>,
587 #[serde(default)]
589 pub judges: Option<usize>,
590 #[serde(default)]
592 pub reviewers: Option<usize>,
593 #[serde(default)]
595 pub review_rounds: Option<usize>,
596 #[serde(default)]
598 pub seed: Option<u64>,
599 #[serde(default)]
601 pub config: Option<PathBuf>,
602}
603
604impl RunOverrides {
605 pub fn merge_over(&mut self, other: &RunOverrides) {
607 macro_rules! take {
608 ($($f:ident),*) => {$(
609 if other.$f.is_some() {
610 self.$f.clone_from(&other.$f);
611 }
612 )*};
613 }
614 take!(
615 merge,
616 candidates,
617 judges,
618 reviewers,
619 review_rounds,
620 seed,
621 config
622 );
623 }
624
625 pub fn apply(&self, config: &mut crate::config::Config) {
627 if let Some(n) = self.candidates {
628 config.graph.implementers = n;
629 }
630 if let Some(n) = self.judges {
631 config.graph.judges = n;
632 }
633 if let Some(n) = self.reviewers {
634 config.graph.reviewers = n;
635 }
636 if let Some(n) = self.review_rounds {
637 config.graph.review_rounds = n;
638 }
639 if let Some(m) = &self.merge
640 && let Ok(mode) = crate::daemon::merge_mode(m)
641 {
642 config.merge.mode = mode;
643 }
644 if let Some(s) = self.seed {
645 config.blind.seed = Some(s);
646 }
647 }
648}
649
650#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
652pub struct FollowUp {
653 pub run: String,
655 #[serde(default)]
657 pub origin_task: Option<String>,
658 pub pr: String,
660 pub findings: Vec<String>,
662 pub generation: u32,
665}
666
667#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
670pub struct OperatorResume {
671 pub question_id: String,
673 pub at: Timestamp,
675 #[serde(default)]
678 pub conductor_rehold: Option<String>,
679 #[serde(default)]
682 pub forced: bool,
683 #[serde(default)]
690 pub pinned_run: Option<String>,
691}
692
693#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
696pub struct AnsweredQuestion {
697 pub question: String,
699 pub answer: String,
701}
702
703impl Task {
704 pub fn new(title: String, instruction: String, repo: PathBuf, source: Source) -> Self {
706 let now = Timestamp::now();
707 let origin_chat = match &source {
708 Source::Agent { run, node } if node == CHAT_NODE => Some(run.clone()),
709 _ => None,
710 };
711 Self {
712 schema: SCHEMA,
713 id: new_id(),
714 title,
715 instruction,
716 repo,
717 source,
718 priority: 0,
719 solo: false,
720 status: TaskStatus::Queued,
721 attempts: 0,
722 runs: Vec::new(),
723 last_error: None,
724 hold_reason: None,
725 hold_source: None,
726 diagnostic: None,
727 blocked_by: Vec::new(),
728 block_reason: None,
729 blocked_from: None,
730 answers: Vec::new(),
731 triage_applied: Vec::new(),
732 actions_applied: Vec::new(),
733 resume_override: None,
734 review_branch: None,
735 fresh_start: false,
736 interrupt: false,
737 urgent: false,
738 attachments: Vec::new(),
739 followup: None,
740 origin_chat,
741 overrides: None,
742 review_of: None,
743 held_at: None,
744 park_reason: None,
745 chat_notice: None,
746 passes: Vec::new(),
747 created_at: now,
748 updated_at: now,
749 }
750 }
751
752 pub fn chat_talk(&self) -> Option<&str> {
755 if let Some(id) = &self.origin_chat {
756 return Some(id);
757 }
758 match &self.source {
759 Source::Agent { run, node } if node == CHAT_NODE => Some(run),
760 _ => None,
761 }
762 }
763
764 pub fn filed_by_chat(&self) -> Option<&str> {
768 match &self.source {
769 Source::Agent { run, node } if node == CHAT_NODE => Some(run),
770 _ => None,
771 }
772 }
773
774 pub fn chat_notice_key(&self) -> Option<String> {
780 match self.status {
781 TaskStatus::Done => Some("done".to_owned()),
782 TaskStatus::Held => Some(format!(
783 "held#{}",
784 self.held_at.map(|t| t.to_string()).unwrap_or_default()
785 )),
786 TaskStatus::Blocked => Some(format!(
787 "blocked#{}#{}",
788 self.blocked_by.join(","),
789 self.block_reason.as_deref().unwrap_or_default()
790 )),
791 _ => None,
792 }
793 }
794
795 pub fn chat_notice_due(&self) -> Option<String> {
799 self.filed_by_chat()?;
800 if self.schema < 13 {
801 return None;
802 }
803 let key = self.chat_notice_key()?;
804 match &self.chat_notice {
805 Some(n) if n.key == key || (key == "done" && is_done_notice_key(&n.key)) => None,
806 _ => Some(key),
807 }
808 }
809
810 pub fn short(&self) -> &str {
812 short(&self.id)
813 }
814
815 pub fn mark_triage_applied(&mut self, question_id: &str) {
817 if !self.triage_applied(question_id) {
818 self.triage_applied.push(question_id.to_owned());
819 }
820 }
821
822 pub fn action_applied(&self, question_id: &str) -> bool {
825 self.actions_applied.iter().any(|id| id == question_id)
826 }
827
828 pub fn mark_action_applied(&mut self, question_id: &str) {
830 if !self.action_applied(question_id) {
831 self.actions_applied.push(question_id.to_owned());
832 }
833 }
834
835 pub fn triage_applied(&self, question_id: &str) -> bool {
837 self.triage_applied.iter().any(|id| id == question_id)
838 }
839
840 pub fn start(&mut self, run: String) {
851 self.stamp_schema();
852 self.status = TaskStatus::Running;
853 self.attempts += 1;
854 self.runs.push(run);
855 self.last_error = None;
856 self.park_reason = None;
857 self.fresh_start = false;
858 self.interrupt = false;
859 self.resume_override = None;
861 }
862
863 pub fn link_run(&mut self, run: &str) -> bool {
868 if self.runs.iter().any(|r| r == run) {
869 return false;
870 }
871 self.runs.push(run.to_owned());
872 true
873 }
874
875 pub fn succeed(&mut self) {
885 self.stamp_schema();
886 self.status = TaskStatus::Done;
887 self.resume_override = None;
888 self.last_error = None;
889 self.park_reason = None;
890 self.hold_reason = None;
891 self.hold_source = None;
892 self.diagnostic = None;
893 self.blocked_by.clear();
894 self.block_reason = None;
895 self.blocked_from = None;
896 }
897
898 pub fn already_landed(&mut self, note: impl Into<String>) {
906 self.succeed();
907 self.attempts = self.attempts.saturating_sub(1);
908 self.last_error = Some(note.into());
909 }
910
911 pub fn superseded_attempts(&self, last_run_succeeded: bool) -> &[String] {
941 if self.status != TaskStatus::Done || !last_run_succeeded || self.runs.len() < 2 {
942 return &[];
943 }
944 &self.runs[..self.runs.len() - 1]
945 }
946
947 pub fn successor_of(&self, run: &str) -> Option<&String> {
955 let pos = self.runs.iter().rposition(|r| r == run)?;
956 self.runs.get(pos + 1)
957 }
958
959 pub fn earlier_attempts(&self) -> &[String] {
966 &self.runs
967 }
968
969 fn stamp_schema(&mut self) {
976 self.schema = self.schema.max(SCHEMA);
977 }
978
979 fn note_held(&mut self) {
980 self.stamp_schema();
981 if self.status != TaskStatus::Held || self.held_at.is_none() {
982 self.held_at = Some(Timestamp::now());
983 }
984 }
985
986 pub fn fail(&mut self, why: impl Into<String>, max_attempts: usize) {
1002 let why = why.into();
1003 self.diagnostic = None;
1004 self.park_reason = None;
1005 self.status = if self.attempts >= max_attempts {
1006 self.note_held();
1007 self.hold_source = Some(HoldSource::Machine);
1008 self.hold_reason = Some(why.clone());
1009 TaskStatus::Held
1010 } else {
1011 TaskStatus::Failed
1012 };
1013 self.last_error = Some(why);
1014 }
1015
1016 pub fn stall(&mut self, why: impl Into<String>) {
1026 self.last_error = Some(why.into());
1027 self.diagnostic = None;
1028 self.attempts = self.attempts.saturating_sub(1);
1029 self.status = TaskStatus::Failed;
1030 }
1031
1032 pub fn park(&mut self, why: impl Into<String>) {
1038 self.diagnostic = None;
1039 self.attempts = self.attempts.saturating_sub(1);
1040 self.status = TaskStatus::Parked;
1041 self.park_reason = Some(why.into());
1042 self.schema = self.schema.max(SCHEMA);
1045 }
1046
1047 pub fn record_pass(
1050 &mut self,
1051 run: &str,
1052 pass_index: usize,
1053 attempts_before: usize,
1054 parked: Option<crate::run::ParkCause>,
1055 ) {
1056 if self
1057 .passes
1058 .iter()
1059 .any(|p| p.run == run && p.pass_index == pass_index)
1060 {
1061 return;
1062 }
1063 let cost = if self.attempts < attempts_before {
1064 PassCost::Refunded
1065 } else {
1066 PassCost::Spent
1067 };
1068 self.passes.push(TaskPass {
1069 run: run.to_owned(),
1070 pass_index,
1071 cost,
1072 attempts_after: self.attempts,
1073 settled_at: Timestamp::now(),
1074 parked,
1075 });
1076 self.schema = self.schema.max(SCHEMA);
1077 }
1078
1079 pub fn operator_held(&self) -> bool {
1085 self.status == TaskStatus::Held && !matches!(self.hold_source, Some(HoldSource::Machine))
1086 }
1087
1088 pub fn hold_manual(&mut self, reason: Option<String>) {
1098 self.park_reason = None;
1099 self.note_held();
1100 self.status = TaskStatus::Held;
1101 if reason.is_some() {
1102 self.hold_reason = reason;
1103 }
1104 self.hold_source = Some(HoldSource::Manual);
1105 self.blocked_by.clear();
1106 self.block_reason = None;
1107 self.blocked_from = None;
1108 }
1109
1110 pub fn hold_machine(&mut self, reason: Option<String>) {
1115 self.park_reason = None;
1116 self.note_held();
1117 self.status = TaskStatus::Held;
1118 if reason.is_some() {
1119 self.hold_reason = reason;
1120 }
1121 self.hold_source = Some(HoldSource::Machine);
1122 self.blocked_by.clear();
1123 self.block_reason = None;
1124 self.blocked_from = None;
1125 }
1126
1127 pub fn block(&mut self, blocked_by: Vec<String>, reason: Option<String>) {
1137 self.stamp_schema();
1138 if self.status != TaskStatus::Blocked {
1139 self.blocked_from = Some(self.status);
1140 }
1141 self.status = TaskStatus::Blocked;
1142 self.blocked_by = blocked_by;
1143 self.block_reason = reason;
1144 }
1145
1146 pub fn unblock(&mut self, resolved_id: &str) {
1169 if self.status != TaskStatus::Blocked {
1170 return;
1171 }
1172 self.blocked_by.retain(|id| id != resolved_id);
1173 self.restore_if_unblocked();
1174 }
1175
1176 pub fn dependency_deleted(&mut self, deleted_id: &str) -> bool {
1187 if self.status != TaskStatus::Blocked || !self.blocked_by.iter().any(|b| b == deleted_id) {
1188 return false;
1189 }
1190 self.blocked_by.retain(|id| id != deleted_id);
1191 self.restore_if_unblocked();
1192 true
1193 }
1194
1195 fn restore_if_unblocked(&mut self) {
1199 if self.blocked_by.is_empty() {
1200 self.status = match self.blocked_from {
1201 Some(TaskStatus::Running) => TaskStatus::Queued,
1202 Some(other) => other,
1203 None if self.hold_reason.is_some() || self.hold_source.is_some() => {
1204 TaskStatus::Held
1205 }
1206 None => TaskStatus::Queued,
1207 };
1208 self.block_reason = None;
1209 self.blocked_from = None;
1210 }
1211 }
1212
1213 pub fn record_answer(&mut self, question: String, answer: String) {
1218 self.answers.push(AnsweredQuestion { question, answer });
1219 }
1220
1221 pub fn request_review(&mut self, branch: String) {
1225 self.release();
1226 self.review_branch = Some(branch);
1227 }
1228
1229 pub fn requeue(&mut self) {
1232 self.release();
1233 self.review_branch = None;
1235 self.fresh_start = true;
1236 }
1237
1238 pub fn hold_for_handover(&mut self, branch: Option<String>, reason: String) {
1247 if branch.is_some() {
1248 self.review_branch = branch;
1249 }
1250 self.hold_machine(Some(reason));
1251 }
1252
1253 pub fn set_priority(&mut self, priority: i32) -> Result<()> {
1262 if self.status == TaskStatus::Running {
1263 bail!(
1264 "task {} is running; its priority cannot be changed until \
1265 this attempt finishes",
1266 self.short()
1267 );
1268 }
1269 self.priority = priority;
1270 Ok(())
1271 }
1272
1273 pub fn set_interrupt(&mut self, interrupt: bool) -> Result<()> {
1288 if interrupt && !self.status.runnable() {
1289 bail!(
1290 "task {} is {}; only a queued or failed task can be marked \
1291 to interrupt",
1292 self.short(),
1293 self.status.as_str()
1294 );
1295 }
1296 self.interrupt = interrupt;
1297 Ok(())
1298 }
1299
1300 pub fn implementers(&self) -> Option<usize> {
1302 if self.solo {
1303 Some(1)
1304 } else {
1305 self.overrides.as_ref().and_then(|o| o.candidates)
1306 }
1307 }
1308
1309 pub fn change_implementers(&mut self, count: u8) -> Result<()> {
1312 self.check_implementers_editable()?;
1313 let explicit = self.overrides.as_ref().and_then(|o| o.config.as_deref());
1314 let (mut config, _) = crate::config::Config::discover(&self.repo, explicit)?;
1315 if let Some(o) = &self.overrides {
1316 o.apply(&mut config);
1317 }
1318 self.set_implementers(count, &config)
1319 }
1320
1321 fn check_implementers_editable(&self) -> Result<()> {
1322 if !matches!(self.status, TaskStatus::Queued | TaskStatus::Held) {
1323 bail!(
1324 "task {} is {}; only queued or held tasks can change implementers",
1325 self.short(),
1326 self.status.as_str()
1327 );
1328 }
1329 Ok(())
1330 }
1331
1332 pub fn set_implementers(&mut self, count: u8, config: &crate::config::Config) -> Result<()> {
1334 self.check_implementers_editable()?;
1335 crate::talk::check_implementers(count, config).map_err(anyhow::Error::msg)?;
1336 self.stamp_schema();
1337 self.solo = count == 1;
1338 let mut overrides = self.overrides.clone().unwrap_or_else(|| RunOverrides {
1339 inherit_daemon_options: true,
1340 ..Default::default()
1341 });
1342 overrides.candidates = (count > 1).then_some(usize::from(count));
1343 let mut settings = overrides.clone();
1344 settings.inherit_daemon_options = false;
1345 self.overrides = if overrides.inherit_daemon_options && settings == RunOverrides::default()
1347 {
1348 None
1349 } else {
1350 Some(overrides)
1351 };
1352 Ok(())
1353 }
1354
1355 pub fn edit(&mut self, title: String, instruction: String) -> Result<()> {
1367 if !matches!(self.status, TaskStatus::Queued | TaskStatus::Held) {
1368 bail!(
1369 "task {} is {}; only a queued or held task's instruction can \
1370 be edited",
1371 self.short(),
1372 self.status.as_str()
1373 );
1374 }
1375 self.title = title;
1376 self.instruction = instruction;
1377 Ok(())
1378 }
1379
1380 pub fn handed_off(&mut self, why: impl Into<String>) {
1400 let why = why.into();
1401 self.diagnostic = None;
1402 self.note_held();
1403 self.status = TaskStatus::Held;
1404 self.hold_source = Some(HoldSource::Machine);
1405 self.hold_reason = Some(why.clone());
1406 self.last_error = Some(why);
1407 }
1408
1409 pub fn release(&mut self) {
1413 let refused_handover = self.status == TaskStatus::Held
1414 && self.hold_source == Some(HoldSource::Machine)
1415 && self.review_branch.is_some();
1416 self.status = TaskStatus::Queued;
1417 self.held_at = None;
1418 self.attempts = 0;
1419 self.last_error = None;
1420 self.park_reason = None;
1421 self.hold_reason = None;
1424 self.hold_source = None;
1425 self.diagnostic = None;
1426 self.blocked_by.clear();
1431 self.block_reason = None;
1432 self.blocked_from = None;
1433 if !refused_handover {
1437 self.review_branch = None;
1438 }
1439 self.fresh_start = false;
1440 }
1441}
1442
1443fn copy_new(dir: &Path, src: &Path, name: &str) -> Result<(String, PathBuf)> {
1447 use std::io::ErrorKind;
1448 let (stem, ext) = match name.rfind('.') {
1449 Some(i) if i > 0 => (&name[..i], &name[i..]),
1450 _ => (name, ""),
1451 };
1452 for n in 1u32.. {
1453 let candidate = if n == 1 {
1454 name.to_owned()
1455 } else {
1456 let suffix = format!("-{n}");
1457 let room = 64usize.saturating_sub(suffix.len() + ext.len());
1458 let stem: String = stem.chars().take(room).collect();
1459 format!("{stem}{suffix}{ext}")
1460 };
1461 if !crate::ask::valid_asset_name(&candidate) {
1462 bail!("no valid attachment name is left for `{name}`");
1463 }
1464 let path = dir.join(&candidate);
1465 match std::fs::OpenOptions::new()
1466 .write(true)
1467 .create_new(true)
1468 .open(&path)
1469 {
1470 Ok(mut out) => {
1471 let copied = std::fs::File::open(src)
1472 .and_then(|mut input| std::io::copy(&mut input, &mut out));
1473 if let Err(e) = copied {
1474 drop(out);
1475 let _ = std::fs::remove_file(&path);
1476 return Err(e).with_context(|| format!("copy {}", src.display()));
1477 }
1478 return Ok((candidate, path));
1479 }
1480 Err(e) if e.kind() == ErrorKind::AlreadyExists => continue,
1481 Err(e) => return Err(e).with_context(|| format!("create {}", path.display())),
1482 }
1483 }
1484 unreachable!("the counter never runs out")
1485}
1486
1487const TASK_LOCK_STALE: std::time::Duration = std::time::Duration::from_secs(10);
1490
1491struct TaskLock {
1494 path: PathBuf,
1495 token: String,
1496}
1497
1498fn owner_token() -> String {
1501 static COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
1502 let n = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1503 let mut r = crate::rng::SplitMix64::new(crate::rng::entropy() ^ n.rotate_left(32));
1504 format!("{}-{:016x}", std::process::id(), r.next_u64())
1505}
1506
1507fn break_marker(path: &Path, token: &str) -> PathBuf {
1510 let mut h: u64 = 0xcbf29ce484222325;
1511 for b in token.bytes() {
1512 h ^= u64::from(b);
1513 h = h.wrapping_mul(0x100000001b3);
1514 }
1515 let mut name = path.as_os_str().to_owned();
1516 name.push(format!(".break-{h:016x}"));
1517 PathBuf::from(name)
1518}
1519
1520fn older_than_stale(path: &Path) -> bool {
1521 std::fs::metadata(path)
1522 .and_then(|m| m.modified())
1523 .ok()
1524 .and_then(|t| t.elapsed().ok())
1525 .is_some_and(|age| age > TASK_LOCK_STALE)
1526}
1527
1528const MAX_MARKER_DEPTH: u8 = 3;
1532
1533fn take_marker(path: &Path, token: &str, depth: u8) -> Option<(PathBuf, String)> {
1539 use std::io::Write;
1540 let marker = break_marker(path, token);
1541 for _ in 0..2 {
1542 match std::fs::OpenOptions::new()
1543 .write(true)
1544 .create_new(true)
1545 .open(&marker)
1546 {
1547 Ok(mut f) => {
1548 let mine = owner_token();
1549 if f.write_all(mine.as_bytes()).is_err() {
1550 drop(f);
1551 let _ = std::fs::remove_file(&marker);
1552 return None;
1553 }
1554 return Some((marker, mine));
1555 }
1556 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
1557 let judged = std::fs::read_to_string(&marker).ok();
1558 match judged.filter(|_| older_than_stale(&marker)) {
1559 Some(judged) if depth < MAX_MARKER_DEPTH => {
1560 remove_lock_if(&marker, &judged, true, depth + 1)?;
1561 }
1562 _ => return None,
1563 }
1564 }
1565 Err(_) => return None,
1566 }
1567 }
1568 None
1569}
1570
1571fn remove_lock_if(path: &Path, token: &str, require_stale: bool, depth: u8) -> Option<bool> {
1576 let (marker, mine) = take_marker(path, token, depth)?;
1577 let still = std::fs::read_to_string(path).is_ok_and(|c| c == token)
1578 && (!require_stale || older_than_stale(path));
1579 if still {
1580 let _ = std::fs::remove_file(path);
1581 }
1582 if depth >= MAX_MARKER_DEPTH {
1584 if std::fs::read_to_string(&marker).is_ok_and(|c| c == mine) {
1585 let _ = std::fs::remove_file(&marker);
1586 }
1587 } else {
1588 let _ = remove_lock_if(&marker, &mine, false, depth + 1);
1589 }
1590 Some(still)
1591}
1592
1593fn break_stale(path: &Path, judged: &str) -> bool {
1596 remove_lock_if(path, judged, true, 0).unwrap_or(false)
1597}
1598
1599impl Drop for TaskLock {
1600 fn drop(&mut self) {
1601 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
1602 loop {
1603 match remove_lock_if(&self.path, &self.token, false, 0) {
1604 Some(_) => return,
1605 None if std::time::Instant::now() > deadline => return,
1606 None => std::thread::sleep(std::time::Duration::from_millis(5)),
1607 }
1608 }
1609 }
1610}
1611
1612#[derive(Debug, Clone)]
1614pub struct Queue {
1615 root: PathBuf,
1616}
1617
1618impl Queue {
1619 pub fn open() -> Self {
1621 Self::at(crate::run::home().join("queue"))
1622 }
1623
1624 pub fn at(root: PathBuf) -> Self {
1627 Self { root }
1628 }
1629
1630 pub fn root(&self) -> &Path {
1632 &self.root
1633 }
1634
1635 pub fn path_of(&self, id: &str) -> PathBuf {
1637 self.root.join(format!("{id}.json"))
1638 }
1639
1640 pub fn attachments_dir(&self, id: &str) -> PathBuf {
1643 self.root.join(format!("{id}.attachments"))
1644 }
1645
1646 pub fn attach(&self, task: &mut Task, sources: &[PathBuf]) -> Result<Vec<String>> {
1657 let mut wanted = Vec::new();
1658 for src in sources {
1659 let name = src
1660 .file_name()
1661 .and_then(|n| n.to_str())
1662 .with_context(|| format!("`{}` has no usable file name", src.display()))?;
1663 if !crate::ask::valid_asset_name(name) {
1664 bail!(
1665 "attachment name `{name}` must match ^[A-Za-z0-9][A-Za-z0-9._-]{{0,63}}$ \
1666 with no `..`; rename the file and try again"
1667 );
1668 }
1669 if !src.is_file() {
1670 bail!("attachment `{}` is not a file", src.display());
1671 }
1672 wanted.push((src, name));
1673 }
1674 let dir = self.attachments_dir(&task.id);
1675 let existed = dir.is_dir();
1676 std::fs::create_dir_all(&dir).with_context(|| format!("create {}", dir.display()))?;
1677 let mut created: Vec<PathBuf> = Vec::new();
1678 let mut names = Vec::new();
1679 let mut copy_all = || -> Result<()> {
1680 for (src, name) in &wanted {
1681 let (stored, path) = copy_new(&dir, src, name)?;
1682 created.push(path);
1683 names.push(stored);
1684 }
1685 Ok(())
1686 };
1687 if let Err(e) = copy_all() {
1688 for path in &created {
1689 let _ = std::fs::remove_file(path);
1690 }
1691 if !existed {
1692 let _ = std::fs::remove_dir(&dir);
1693 }
1694 return Err(e);
1695 }
1696 task.attachments.extend(names.iter().cloned());
1697 Ok(names)
1698 }
1699
1700 pub fn attach_and_put(&self, task: &mut Task, sources: &[PathBuf]) -> Result<Vec<String>> {
1711 let dir = self.attachments_dir(&task.id);
1712 let existed = dir.is_dir();
1713 let before = task.attachments.len();
1714 let names = self.attach(task, sources)?;
1715 if let Err(e) = self.put(task) {
1716 for name in &names {
1717 let _ = std::fs::remove_file(dir.join(name));
1718 }
1719 if !existed {
1720 let _ = std::fs::remove_dir(&dir);
1721 }
1722 task.attachments.truncate(before);
1723 return Err(e);
1724 }
1725 Ok(names)
1726 }
1727
1728 pub fn attachment_paths(&self, task: &Task) -> Vec<PathBuf> {
1732 let dir = self.attachments_dir(&task.id);
1733 task.attachments
1734 .iter()
1735 .map(|n| {
1736 let p = dir.join(n);
1737 std::path::absolute(&p).unwrap_or(p)
1738 })
1739 .collect()
1740 }
1741
1742 pub fn put(&self, task: &mut Task) -> Result<()> {
1751 let _lock = self.lock_task(&task.id)?;
1752 self.put_unlocked(task)
1753 }
1754
1755 pub fn create_new(&self, task: &mut Task) -> Result<bool> {
1760 let _lock = self.lock_task(&task.id)?;
1761 if self.path_of(&task.id).exists() {
1762 return Ok(false);
1763 }
1764 self.put_unlocked(task)?;
1765 Ok(true)
1766 }
1767
1768 fn put_unlocked(&self, task: &mut Task) -> Result<()> {
1770 if let Ok(stored) = read_path(&self.path_of(&task.id)) {
1771 for run in stored.runs {
1772 if !task.runs.contains(&run) {
1773 task.runs.push(run);
1774 }
1775 }
1776 for p in stored.passes {
1777 if !task
1778 .passes
1779 .iter()
1780 .any(|q| q.run == p.run && q.pass_index == p.pass_index)
1781 {
1782 task.passes.push(p);
1783 }
1784 }
1785 if let Some(n) = stored.chat_notice
1788 && task.chat_notice.as_ref().is_none_or(|mine| mine.at < n.at)
1789 {
1790 task.chat_notice = Some(n);
1791 }
1792 }
1793 task.updated_at = Timestamp::now();
1794 std::fs::create_dir_all(&self.root)
1795 .with_context(|| format!("create {}", self.root.display()))?;
1796 let body = serde_json::to_string_pretty(task).context("serialize task")?;
1797 let path = self.path_of(&task.id);
1798 let tmp = path.with_extension("json.tmp");
1799 std::fs::write(&tmp, &body).with_context(|| format!("write {}", tmp.display()))?;
1800 std::fs::rename(&tmp, &path).with_context(|| format!("replace {}", path.display()))?;
1801 if let (Some(notice), Some(home)) = (
1806 crate::notices::task_held(task),
1807 self.root.parent().filter(|p| !p.as_os_str().is_empty()),
1808 ) {
1809 crate::notices::raise_in(home, notice);
1810 }
1811 Ok(())
1812 }
1813
1814 pub fn modify(&self, id: &str, f: impl FnOnce(&mut Task) -> bool) -> Result<bool> {
1819 let id = self.resolve_id(id)?;
1820 let _lock = self.lock_task(&id)?;
1821 let mut task = self.get(&id)?;
1822 if !f(&mut task) {
1823 return Ok(false);
1824 }
1825 self.put_unlocked(&mut task)?;
1826 Ok(true)
1827 }
1828
1829 pub fn link_run(&self, id: &str, run: &str) -> Result<Task> {
1834 let id = self.resolve_id(id)?;
1835 let _lock = self.lock_task(&id)?;
1838 let mut task = self.get(&id)?;
1839 if task.link_run(run) {
1840 self.put_unlocked(&mut task)?;
1841 }
1842 Ok(task)
1843 }
1844
1845 fn lock_task(&self, id: &str) -> Result<TaskLock> {
1853 std::fs::create_dir_all(&self.root)
1854 .with_context(|| format!("create {}", self.root.display()))?;
1855 let path = self.root.join(format!("{id}.write-lock"));
1856 let started = std::time::Instant::now();
1857 loop {
1858 match std::fs::OpenOptions::new()
1859 .write(true)
1860 .create_new(true)
1861 .open(&path)
1862 {
1863 Ok(mut file) => {
1864 use std::io::Write;
1865 let token = owner_token();
1866 if let Err(e) = file.write_all(token.as_bytes()) {
1867 drop(file);
1868 let _ = std::fs::remove_file(&path);
1869 return Err(e).with_context(|| format!("lock {}", path.display()));
1870 }
1871 return Ok(TaskLock { path, token });
1872 }
1873 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
1874 let judged = std::fs::read_to_string(&path).ok();
1877 let broken = judged
1878 .filter(|_| older_than_stale(&path))
1879 .is_some_and(|judged| break_stale(&path, &judged));
1880 if !broken {
1884 if started.elapsed() > TASK_LOCK_STALE {
1885 bail!("could not lock task {id}");
1886 }
1887 std::thread::sleep(std::time::Duration::from_millis(15));
1888 }
1889 }
1890 Err(e)
1894 if e.kind() == std::io::ErrorKind::PermissionDenied
1895 && started.elapsed() <= TASK_LOCK_STALE =>
1896 {
1897 std::thread::sleep(std::time::Duration::from_millis(15));
1898 }
1899 Err(e) => return Err(e).with_context(|| format!("lock {}", path.display())),
1900 }
1901 }
1902 }
1903
1904 pub fn get(&self, id: &str) -> Result<Task> {
1906 let resolved = self.resolve_id(id)?;
1907 read_path(&self.path_of(&resolved))
1908 }
1909
1910 pub fn remove(&self, id: &str, in_flight: bool, questions: &Questions) -> Result<Removal> {
1942 let _ = questions;
1943 let resolved = self.resolve_id(id)?;
1944 if in_flight {
1945 bail!("task {resolved} is being run by a live daemon right now");
1946 }
1947 self.write_tombstone(&resolved)?;
1948 if let Err(e) = self.remove_record_with_attachments(&resolved, |p| std::fs::remove_file(p))
1949 {
1950 if self.path_of(&resolved).exists() {
1954 let _ = std::fs::remove_file(self.tombstone_path(&resolved));
1955 }
1956 return Err(e);
1957 }
1958 let (released, still_blocked) = self.release_dependents_of(&resolved);
1959 Ok(Removal {
1960 id: resolved,
1961 released,
1962 still_blocked,
1963 })
1964 }
1965
1966 fn tombstone_path(&self, id: &str) -> PathBuf {
1969 self.root.join(format!("{id}.removed"))
1970 }
1971
1972 fn write_tombstone(&self, id: &str) -> Result<()> {
1973 let path = self.tombstone_path(id);
1974 let tmp = self.root.join(format!("{id}.removed.tmp"));
1975 std::fs::write(&tmp, b"")
1976 .and_then(|()| std::fs::rename(&tmp, &path))
1977 .with_context(|| format!("write {}", path.display()))
1978 }
1979
1980 fn was_deleted(&self, id: &str) -> bool {
1984 self.tombstone_path(id).is_file() && !self.path_of(id).exists()
1985 }
1986
1987 pub fn apply_deleted_blockers(&self, task: &mut Task) -> Vec<String> {
1991 let deleted = deleted_blockers(self, &task.blocked_by);
1992 deleted
1993 .into_iter()
1994 .filter(|id| task.dependency_deleted(id))
1995 .collect()
1996 }
1997
1998 pub fn note_dependency_deleted(&self, dependent: &Task, deleted: &str) {
2002 let Some(home) = self.root.parent().filter(|p| !p.as_os_str().is_empty()) else {
2003 return;
2004 };
2005 let ja = crate::lang::is_japanese(&crate::lang::of_repo(&dependent.repo));
2006 let waiting = dependent.status == TaskStatus::Blocked;
2007 let message = match (ja, waiting) {
2008 (true, false) => format!(
2009 "タスク {} は、待っていた {} が削除されたため待機を解除し、元の状態に戻しました",
2010 dependent.short(),
2011 short(deleted)
2012 ),
2013 (true, true) => format!(
2014 "タスク {} は、待っていた {} が削除されたため、残りの依存を待っています",
2015 dependent.short(),
2016 short(deleted)
2017 ),
2018 (false, false) => format!(
2019 "Task {} stopped waiting on {} because it was deleted, and returned to its previous state",
2020 dependent.short(),
2021 short(deleted)
2022 ),
2023 (false, true) => format!(
2024 "Task {} stopped waiting on {} because it was deleted, and is still waiting on its other dependencies",
2025 dependent.short(),
2026 short(deleted)
2027 ),
2028 };
2029 crate::notices::raise_in(
2030 home,
2031 crate::notices::Notice::info(
2032 &format!("unblocked:{}:{}", dependent.id, deleted),
2033 message,
2034 )
2035 .link(crate::notices::Link::Task {
2036 id: dependent.id.clone(),
2037 }),
2038 );
2039 }
2040
2041 fn release_dependents_of(&self, dependency: &str) -> (Vec<String>, Vec<String>) {
2046 let (mut released, mut still_blocked) = (Vec::new(), Vec::new());
2047 for listed in self.list() {
2048 if listed.status != TaskStatus::Blocked
2049 || !listed.blocked_by.iter().any(|b| b == dependency)
2050 {
2051 continue;
2052 }
2053 let Ok(_claim) = self.claim(&listed.id) else {
2054 continue;
2055 };
2056 let Ok(mut task) = self.get(&listed.id) else {
2057 continue;
2058 };
2059 if !task.dependency_deleted(dependency) {
2060 continue;
2061 }
2062 if self.put(&mut task).is_err() {
2063 continue;
2064 }
2065 self.note_dependency_deleted(&task, dependency);
2066 if task.status == TaskStatus::Blocked {
2067 still_blocked.push(task.id.clone());
2068 } else {
2069 released.push(task.id.clone());
2070 }
2071 }
2072 (released, still_blocked)
2073 }
2074
2075 fn remove_record_with_attachments(
2079 &self,
2080 resolved: &str,
2081 remove_record: impl FnOnce(&Path) -> std::io::Result<()>,
2082 ) -> Result<()> {
2083 self.sweep_removed_attachments();
2090 let attachments = self.attachments_dir(resolved);
2091 let aside = self.root.join(format!("{resolved}.attachments.removing"));
2092 let moved = match std::fs::rename(&attachments, &aside) {
2093 Ok(()) => true,
2094 Err(e) if e.kind() == std::io::ErrorKind::NotFound => false,
2095 Err(e) => {
2096 return Err(e).with_context(|| format!("remove {}", attachments.display()));
2097 }
2098 };
2099 let path = self.path_of(resolved);
2100 if let Err(e) = remove_record(&path) {
2101 if moved {
2102 let _ = std::fs::rename(&aside, &attachments);
2103 }
2104 return Err(e).with_context(|| format!("remove {}", path.display()));
2105 }
2106 if moved {
2107 if let Err(e) = std::fs::remove_dir_all(&aside) {
2108 tracing::warn!("leftover attachments {}: {e}", aside.display());
2109 }
2110 }
2111 let lock = self.lock_path(resolved);
2112 if let Err(e) = std::fs::remove_file(&lock) {
2113 if e.kind() != std::io::ErrorKind::NotFound {
2114 return Err(e).with_context(|| format!("remove {}", lock.display()));
2115 }
2116 }
2117 Ok(())
2118 }
2119
2120 fn sweep_removed_attachments(&self) {
2126 let Ok(entries) = std::fs::read_dir(&self.root) else {
2127 return;
2128 };
2129 for entry in entries.flatten() {
2130 let name = entry.file_name();
2131 let name = name.to_string_lossy();
2132 let Some(id) = name.strip_suffix(".attachments.removing") else {
2133 continue;
2134 };
2135 if !self.path_of(id).exists() {
2138 if let Err(e) = std::fs::remove_dir_all(entry.path()) {
2139 tracing::warn!("leftover attachments {}: {e}", entry.path().display());
2140 }
2141 }
2142 }
2143 self.sweep_tombstones();
2144 }
2145
2146 fn sweep_tombstones(&self) {
2155 let Ok(entries) = std::fs::read_dir(&self.root) else {
2156 return;
2157 };
2158 let tasks = self.list();
2159 for entry in entries.flatten() {
2160 let name = entry.file_name();
2161 let name = name.to_string_lossy();
2162 let Some(id) = name.strip_suffix(".removed") else {
2163 continue;
2164 };
2165 if self.path_of(id).exists()
2166 || tasks.iter().any(|t| t.blocked_by.iter().any(|b| b == id))
2167 {
2168 continue;
2169 }
2170 let old = entry
2171 .metadata()
2172 .and_then(|m| m.modified())
2173 .ok()
2174 .and_then(|m| m.elapsed().ok())
2175 .is_some_and(|age| age >= TOMBSTONE_GRACE);
2176 if old {
2177 let _ = std::fs::remove_file(entry.path());
2178 }
2179 }
2180 }
2181
2182 fn lock_path(&self, id: &str) -> PathBuf {
2185 self.root.join(format!("{id}.lock"))
2186 }
2187
2188 pub fn list(&self) -> Vec<Task> {
2201 let mut tasks: Vec<Task> = std::fs::read_dir(&self.root)
2202 .into_iter()
2203 .flatten()
2204 .flatten()
2205 .map(|e| e.path())
2206 .filter(|p| p.extension().is_some_and(|x| x == "json"))
2207 .filter_map(|p| read_path(&p).ok())
2208 .collect();
2209 tasks.sort_unstable_by(|a, b| b.priority.cmp(&a.priority).then_with(|| b.id.cmp(&a.id)));
2210 tasks
2211 }
2212
2213 pub fn superseded(&self) -> HashMap<String, String> {
2226 let mut by = HashMap::new();
2227 for task in self.list() {
2228 for earlier in &task.runs {
2229 if let Some(later) = task.successor_of(earlier) {
2230 by.insert(earlier.clone(), later.clone());
2231 }
2232 }
2233 }
2234 by
2235 }
2236
2237 pub fn superseded_by(&self, run: &str) -> Option<String> {
2245 for task in self.list() {
2246 if task.runs.iter().any(|r| r == run) {
2247 return task.successor_of(run).cloned();
2248 }
2249 }
2250 None
2251 }
2252
2253 pub fn latest_attempt(&self, run: &str) -> Option<String> {
2264 for task in self.list() {
2265 if task.runs.iter().any(|r| r == run) {
2266 return task.runs.last().filter(|last| **last != run).cloned();
2267 }
2268 }
2269 None
2270 }
2271
2272 pub fn next_runnable(&self) -> Option<Task> {
2277 let mut runnable: Vec<Task> = self
2278 .list()
2279 .into_iter()
2280 .filter(|t| t.status.runnable())
2281 .collect();
2282 runnable.sort_unstable_by(|a, b| b.priority.cmp(&a.priority).then(a.id.cmp(&b.id)));
2283 runnable.into_iter().next()
2284 }
2285
2286 pub fn claim(&self, id: &str) -> Result<Claim> {
2293 std::fs::create_dir_all(&self.root)
2294 .with_context(|| format!("create {}", self.root.display()))?;
2295 let path = self.lock_path(id);
2296 match std::fs::OpenOptions::new()
2297 .write(true)
2298 .create_new(true)
2299 .open(&path)
2300 {
2301 Ok(mut f) => {
2302 use std::io::Write as _;
2303 let _ = writeln!(f, "{}", std::process::id());
2305 Ok(Claim { path })
2306 }
2307 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
2308 bail!("task {id} is already claimed ({} exists)", path.display())
2309 }
2310 Err(e) => Err(e).with_context(|| format!("lock {}", path.display())),
2311 }
2312 }
2313
2314 pub fn resolve_id(&self, prefix: &str) -> Result<String> {
2316 if self.path_of(prefix).is_file() {
2317 return Ok(prefix.to_owned());
2318 }
2319 let hits: Vec<String> = self
2320 .list()
2321 .into_iter()
2322 .map(|t| t.id)
2323 .filter(|id| id.starts_with(prefix) || id.ends_with(prefix))
2324 .collect();
2325 match hits.len() {
2326 1 => Ok(hits.into_iter().next().expect("exactly one hit")),
2327 0 => bail!("no task matches `{prefix}`"),
2328 _ => bail!(
2329 "`{prefix}` matches {} tasks: {}",
2330 hits.len(),
2331 hits.join(", ")
2332 ),
2333 }
2334 }
2335
2336 pub fn revision(&self) -> u64 {
2343 self.revision_excluding(&std::collections::BTreeSet::new())
2344 }
2345
2346 pub fn revision_excluding(&self, skip: &std::collections::BTreeSet<String>) -> u64 {
2349 use std::hash::{Hash as _, Hasher as _};
2350
2351 let mut entries: Vec<(String, u64)> = std::fs::read_dir(&self.root)
2352 .into_iter()
2353 .flatten()
2354 .flatten()
2355 .filter(|e| e.path().extension().is_some_and(|ext| ext == "json"))
2356 .filter(|e| {
2357 let path = e.path();
2358 !path
2359 .file_stem()
2360 .is_some_and(|stem| skip.contains(stem.to_string_lossy().as_ref()))
2361 })
2362 .filter_map(|e| {
2363 let name = e.file_name().to_string_lossy().into_owned();
2364 let mtime = e
2365 .metadata()
2366 .ok()?
2367 .modified()
2368 .ok()?
2369 .duration_since(std::time::UNIX_EPOCH)
2370 .ok()?
2371 .as_millis() as u64;
2372 Some((name, mtime))
2373 })
2374 .collect();
2375
2376 if entries.is_empty() {
2377 return 0;
2378 }
2379
2380 entries.sort_unstable();
2381 let mut hasher = std::hash::DefaultHasher::new();
2382 for (name, mtime) in &entries {
2383 name.hash(&mut hasher);
2384 mtime.hash(&mut hasher);
2385 }
2386 let h = hasher.finish();
2387 if h == 0 { 1 } else { h }
2388 }
2389}
2390
2391const TOMBSTONE_GRACE: std::time::Duration = std::time::Duration::from_secs(3600);
2394
2395#[derive(Debug, Clone)]
2397pub struct Removal {
2398 pub id: String,
2400 pub released: Vec<String>,
2404 pub still_blocked: Vec<String>,
2407}
2408
2409#[derive(Debug)]
2411pub struct Claim {
2412 path: PathBuf,
2413}
2414
2415impl Drop for Claim {
2416 fn drop(&mut self) {
2417 let _ = std::fs::remove_file(&self.path);
2418 }
2419}
2420
2421pub fn title_from(instruction: &str, max: usize) -> String {
2424 let Some(line) = first_line(instruction) else {
2425 return "(empty task)".to_owned();
2426 };
2427 if line.chars().count() <= max {
2428 return line.to_owned();
2429 }
2430 let head: String = line.chars().take(max.saturating_sub(1)).collect();
2431 format!("{head}…")
2432}
2433
2434pub(crate) fn first_line(instruction: &str) -> Option<&str> {
2440 let line = instruction
2441 .lines()
2442 .map(str::trim)
2443 .find(|l| !l.is_empty())?
2444 .trim_start_matches(['#', '-', '*', '>', ' '])
2445 .trim();
2446 (!line.is_empty()).then_some(line)
2447}
2448
2449fn read_path(path: &Path) -> Result<Task> {
2450 let body = std::fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
2451 let task: Task =
2452 serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))?;
2453 if task.schema > SCHEMA {
2459 bail!(
2460 "task {} was written by a different magi (schema {}, this build \
2461 speaks {SCHEMA})",
2462 task.id,
2463 task.schema
2464 );
2465 }
2466 Ok(task)
2467}
2468
2469pub fn missing_blockers(
2483 queue: &Queue,
2484 questions: &Questions,
2485 blocked_by: &[String],
2486) -> Vec<String> {
2487 blocked_by
2488 .iter()
2489 .filter(|id| {
2490 !queue.path_of(id).is_file()
2491 && !questions.path_of(id).is_file()
2492 && !queue.was_deleted(id)
2493 })
2494 .cloned()
2495 .collect()
2496}
2497
2498pub fn deleted_blockers(queue: &Queue, blocked_by: &[String]) -> Vec<String> {
2503 blocked_by
2504 .iter()
2505 .filter(|id| queue.was_deleted(id))
2506 .cloned()
2507 .collect()
2508}
2509
2510pub fn missing_blocker_hold_reason(blocked_by: &[String], missing: &[String]) -> String {
2522 missing_blocker_hold_reason_in(blocked_by, missing, "en")
2523}
2524
2525pub fn missing_blocker_hold_reason_in(
2527 blocked_by: &[String],
2528 missing: &[String],
2529 language: &str,
2530) -> String {
2531 if crate::lang::is_japanese(language) {
2532 format!(
2533 "{} を待っていましたが、{} はディスク上に存在しません - `magi task triage` を参照",
2534 blocked_by.join(", "),
2535 missing.join(", "),
2536 )
2537 } else {
2538 format!(
2539 "blocked on {} but {} no longer exist(s) on disk - see `magi task triage`",
2540 blocked_by.join(", "),
2541 missing.join(", "),
2542 )
2543 }
2544}
2545
2546pub fn short(id: &str) -> &str {
2548 id.split('-').next_back().unwrap_or(id)
2549}
2550
2551fn new_id() -> String {
2552 let stamp = jiff::Zoned::now().strftime("%Y%m%d-%H%M%S");
2553 let seed = crate::rng::entropy();
2554 format!("{stamp}-{:04x}", (seed ^ (seed >> 32)) & 0xffff)
2555}
2556
2557#[cfg(test)]
2558mod tests {
2559 #[test]
2560 fn merge_over_lets_the_later_choice_win_and_keeps_the_rest() {
2561 let mut base = RunOverrides {
2562 merge: Some("pr".to_owned()),
2563 candidates: Some(3),
2564 ..RunOverrides::default()
2565 };
2566 base.merge_over(&RunOverrides {
2567 merge: Some("none".to_owned()),
2568 seed: Some(7),
2569 ..RunOverrides::default()
2570 });
2571 assert_eq!(base.merge.as_deref(), Some("none"));
2572 assert_eq!(base.candidates, Some(3));
2573 assert_eq!(base.seed, Some(7));
2574 }
2575
2576 #[test]
2577 fn an_already_landed_task_is_done_with_its_attempt_refunded() {
2578 let mut t = task("relanded");
2579 t.attempts = 1;
2580 t.status = TaskStatus::Running;
2581 t.already_landed("already in main as 0e368de");
2582 assert_eq!(t.status, TaskStatus::Done);
2583 assert_eq!(t.attempts, 0);
2584 assert!(t.hold_reason.is_none());
2585 assert_eq!(t.last_error.as_deref(), Some("already in main as 0e368de"));
2586 }
2587
2588 #[test]
2589 fn missing_blocker_reason_follows_the_language() {
2590 let b = vec!["a".to_owned()];
2591 let en = missing_blocker_hold_reason_in(&b, &b, "en");
2592 assert_eq!(en, missing_blocker_hold_reason(&b, &b));
2593 assert!(en.starts_with("blocked on a"));
2594 assert!(missing_blocker_hold_reason_in(&b, &b, "ja").contains("存在しません"));
2595 assert_eq!(missing_blocker_hold_reason_in(&b, &b, "de"), en);
2596 }
2597
2598 use super::*;
2599
2600 #[test]
2601 fn triage_applied_survives_release_and_old_records_read_as_empty() {
2602 let mut t = Task::new(
2603 "t".to_owned(),
2604 "i".to_owned(),
2605 PathBuf::from("r"),
2606 Source::Human,
2607 );
2608 t.mark_triage_applied("q1");
2609 t.mark_triage_applied("q1");
2610 t.hold_machine(Some("x".to_owned()));
2611 t.release();
2612 assert_eq!(t.triage_applied, ["q1"]);
2613 assert!(t.triage_applied("q1") && !t.triage_applied("q2"));
2614
2615 let mut v = serde_json::to_value(&t).unwrap();
2616 v.as_object_mut().unwrap().remove("triage_applied");
2617 let old: Task = serde_json::from_value(v).unwrap();
2618 assert!(old.triage_applied.is_empty());
2619 }
2620
2621 #[test]
2622 fn task_counts_of_empty_is_all_zero() {
2623 assert_eq!(TaskCounts::of(&[]), TaskCounts::default());
2624 }
2625
2626 #[test]
2627 fn task_counts_of_tallies_every_status() {
2628 let mut queued = Task::new(
2629 "q".to_owned(),
2630 "i".to_owned(),
2631 PathBuf::from("."),
2632 Source::Human,
2633 );
2634 queued.status = TaskStatus::Queued;
2635 let mut running = queued.clone();
2636 running.status = TaskStatus::Running;
2637 let mut done = queued.clone();
2638 done.status = TaskStatus::Done;
2639 let mut failed = queued.clone();
2640 failed.status = TaskStatus::Failed;
2641 let mut held = queued.clone();
2642 held.status = TaskStatus::Held;
2643 let mut blocked = queued.clone();
2644 blocked.status = TaskStatus::Blocked;
2645
2646 let mut parked = queued.clone();
2647 parked.status = TaskStatus::Parked;
2648
2649 let counts = TaskCounts::of(&[
2650 queued,
2651 running,
2652 done.clone(),
2653 done,
2654 failed,
2655 held,
2656 blocked,
2657 parked,
2658 ]);
2659 assert_eq!(
2660 counts,
2661 TaskCounts {
2662 queued: 1,
2663 running: 1,
2664 done: 2,
2665 failed: 1,
2666 held: 1,
2667 blocked: 1,
2668 parked: 1,
2669 }
2670 );
2671 }
2672
2673 #[test]
2674 fn task_implementers_persist_normalized_without_losing_history() {
2675 let (_dir, q) = queue();
2676 let mut t = task("keep me");
2677 t.hold_manual(Some("waiting".into()));
2678 t.runs.push("earlier-run".into());
2679 t.attachments.push("reference.txt".into());
2680 let cfg: crate::config::Config =
2681 toml::from_str("[[agents]]\nid = 'mock'\nkind = 'command'\ncommand = ['true']\n")
2682 .unwrap();
2683 q.put(&mut t).unwrap();
2684 let original = serde_json::to_value(&t).unwrap();
2685 for n in [2, 1, 2] {
2686 t.set_implementers(n, &cfg).unwrap();
2687 q.put(&mut t).unwrap();
2688 t = q.get(&t.id).unwrap();
2689 assert_eq!(t.implementers(), Some(usize::from(n)));
2690 assert_eq!(t.solo, n == 1);
2691 if n == 1 {
2692 assert!(t.overrides.is_none());
2693 }
2694 let mut actual = serde_json::to_value(&t).unwrap();
2695 for field in ["solo", "overrides", "updated_at"] {
2696 actual[field] = original[field].clone();
2697 }
2698 assert_eq!(actual, original);
2699 }
2700 t.overrides.as_mut().unwrap().judges = Some(2);
2701 t.set_implementers(1, &cfg).unwrap();
2702 assert_eq!(t.overrides.as_ref().unwrap().judges, Some(2));
2703 assert_eq!(t.overrides.as_ref().unwrap().candidates, None);
2704 }
2705
2706 #[test]
2707 fn task_implementers_old_records_keep_inheritance_until_changed() {
2708 let mut value = serde_json::to_value(task("old task")).unwrap();
2709 value.as_object_mut().unwrap().remove("solo");
2710 value.as_object_mut().unwrap().remove("overrides");
2711 let mut old: Task = serde_json::from_value(value).unwrap();
2712 assert_eq!(old.implementers(), None);
2713 old.set_implementers(1, &crate::config::Config::default())
2714 .unwrap();
2715 assert!(old.solo);
2716 assert!(old.overrides.is_none());
2717 }
2718
2719 #[test]
2720 fn task_implementers_reject_invalid_counts_and_states() {
2721 let cfg = crate::config::Config::default();
2722 let mut t = task("unchanged");
2723 for n in [0, 4, 255] {
2724 assert!(t.set_implementers(n, &cfg).is_err());
2725 }
2726 for status in [
2727 TaskStatus::Running,
2728 TaskStatus::Done,
2729 TaskStatus::Failed,
2730 TaskStatus::Blocked,
2731 TaskStatus::Parked,
2732 ] {
2733 t.status = status;
2734 let before = serde_json::to_value(&t).unwrap();
2735 assert!(t.set_implementers(1, &cfg).is_err());
2736 assert_eq!(serde_json::to_value(&t).unwrap(), before);
2737 }
2738 }
2739
2740 fn queue() -> (tempfile::TempDir, Queue) {
2743 let dir = tempfile::tempdir().unwrap();
2744 let q = Queue::at(dir.path().join("queue"));
2745 (dir, q)
2746 }
2747
2748 #[test]
2749 fn putting_a_machine_held_task_files_a_notification_beside_the_queue() {
2750 let dir = tempfile::tempdir().unwrap();
2751 let q = Queue::at(dir.path().join("queue"));
2752 let mut t = task("held");
2753 q.put(&mut t).unwrap();
2754 assert_eq!(
2755 crate::notices::Notices::at(dir.path().join("notifications"))
2756 .list()
2757 .len(),
2758 0
2759 );
2760 t.hold_machine(Some("out of attempts".to_owned()));
2761 q.put(&mut t).unwrap();
2762 let listed = crate::notices::Notices::at(dir.path().join("notifications")).list();
2763 assert_eq!(listed.len(), 1);
2764 assert!(listed[0].message.contains("out of attempts"));
2765 }
2766
2767 fn task(title: &str) -> Task {
2768 Task::new(
2769 title.to_owned(),
2770 format!("do {title}"),
2771 PathBuf::from("."),
2772 Source::Human,
2773 )
2774 }
2775
2776 #[test]
2777 fn earlier_attempts_is_every_recorded_run_and_agrees_with_the_display() {
2778 let mut t = task("retried");
2779 assert!(
2780 t.earlier_attempts().is_empty(),
2781 "a first attempt takes nothing over"
2782 );
2783 t.runs = vec!["aaaa".to_owned(), "bbbb".to_owned()];
2784 assert_eq!(t.earlier_attempts(), ["aaaa", "bbbb"]);
2785 assert_eq!(t.successor_of("aaaa"), Some(&"bbbb".to_owned()));
2787 assert_eq!(t.successor_of("bbbb"), None);
2788 assert_eq!(t.successor_of("zzzz"), None);
2789 }
2790
2791 #[test]
2792 fn resumed_runs_are_superseded_only_after_their_last_occurrence() {
2793 let (_dir, q) = queue();
2794 let mut t = task("resumed");
2795 for (runs, successor) in [
2796 (vec!["aaaa", "aaaa"], None),
2797 (vec!["aaaa", "bbbb", "aaaa"], None),
2798 (vec!["aaaa", "aaaa", "bbbb"], Some("bbbb")),
2799 (vec!["aaaa", "bbbb", "aaaa", "cccc"], Some("cccc")),
2800 ] {
2801 t.runs = runs.into_iter().map(str::to_owned).collect();
2802 q.put(&mut t).unwrap();
2803
2804 assert_eq!(t.successor_of("aaaa").map(String::as_str), successor);
2805 assert_eq!(q.superseded_by("aaaa").as_deref(), successor);
2806 assert_eq!(q.superseded().get("aaaa").map(String::as_str), successor);
2807 }
2808 }
2809
2810 #[test]
2811 fn superseded_by_names_the_next_attempt_and_none_for_the_last() {
2812 let (_dir, q) = queue();
2813 let mut t = task("retried");
2814 t.runs = vec!["aaaa".to_owned(), "bbbb".to_owned(), "cccc".to_owned()];
2815 q.put(&mut t).unwrap();
2816
2817 assert_eq!(q.superseded_by("aaaa"), Some("bbbb".to_owned()));
2818 assert_eq!(q.superseded_by("bbbb"), Some("cccc".to_owned()));
2819 assert_eq!(
2820 q.superseded_by("cccc"),
2821 None,
2822 "the latest attempt replaces nothing"
2823 );
2824 assert_eq!(
2825 q.superseded_by("never-heard-of-it"),
2826 None,
2827 "a run belonging to no task on this queue is not superseded"
2828 );
2829
2830 let mut by = HashMap::new();
2831 by.insert("aaaa".to_owned(), "bbbb".to_owned());
2832 by.insert("bbbb".to_owned(), "cccc".to_owned());
2833 assert_eq!(
2834 q.superseded(),
2835 by,
2836 "the whole-map and single-run forms must agree"
2837 );
2838 }
2839
2840 #[test]
2841 fn superseded_attempts_is_empty_until_the_task_is_done() {
2842 let mut t = task("retried");
2843 t.runs = vec!["aaaa".to_owned(), "bbbb".to_owned()];
2844 t.status = TaskStatus::Failed;
2845 assert_eq!(
2846 t.superseded_attempts(true),
2847 &[] as &[String],
2848 "a task still retrying has no attempt yet that a later one made moot"
2849 );
2850
2851 t.status = TaskStatus::Running;
2852 assert_eq!(t.superseded_attempts(true), &[] as &[String]);
2853 }
2854
2855 #[test]
2856 fn superseded_attempts_names_every_run_before_the_one_that_succeeded() {
2857 let mut t = task("retried");
2858 t.runs = vec!["aaaa".to_owned(), "bbbb".to_owned(), "cccc".to_owned()];
2859 t.status = TaskStatus::Done;
2860 assert_eq!(
2861 t.superseded_attempts(true),
2862 &["aaaa".to_owned(), "bbbb".to_owned()],
2863 "cccc is the attempt whose success made the task done, and stays out"
2864 );
2865 }
2866
2867 #[test]
2868 fn superseded_attempts_is_empty_for_a_done_task_with_only_one_attempt() {
2869 let mut t = task("first try landed");
2870 t.runs = vec!["aaaa".to_owned()];
2871 t.status = TaskStatus::Done;
2872 assert_eq!(t.superseded_attempts(true), &[] as &[String]);
2873 }
2874
2875 #[test]
2876 fn superseded_attempts_is_empty_when_the_last_run_never_actually_succeeded() {
2877 let mut t = task("closed by hand after a manual merge");
2884 t.runs = vec!["aaaa".to_owned(), "bbbb".to_owned()];
2885 t.status = TaskStatus::Done;
2886 assert_eq!(
2887 t.superseded_attempts(false),
2888 &[] as &[String],
2889 "nothing here is provably why the task is done, so nothing is superseded"
2890 );
2891 }
2892
2893 #[test]
2894 fn latest_attempt_names_the_chain_s_current_head_not_just_the_next_one() {
2895 let (_dir, q) = queue();
2896 let mut t = task("retried twice");
2897 t.runs = vec!["aaaa".to_owned(), "bbbb".to_owned(), "cccc".to_owned()];
2898 q.put(&mut t).unwrap();
2899
2900 assert_eq!(
2901 q.latest_attempt("aaaa"),
2902 Some("cccc".to_owned()),
2903 "an old attempt points straight at the chain's current head, not the \
2904 next attempt in the middle of it"
2905 );
2906 assert_eq!(q.latest_attempt("bbbb"), Some("cccc".to_owned()));
2907 assert_eq!(
2908 q.latest_attempt("cccc"),
2909 None,
2910 "the latest attempt is not superseded by anything"
2911 );
2912 assert_eq!(
2913 q.latest_attempt("never-heard-of-it"),
2914 None,
2915 "a run belonging to no task on this queue is not superseded"
2916 );
2917 }
2918
2919 #[test]
2920 fn a_markdown_heading_is_the_title_not_decoration() {
2921 assert_eq!(
2926 title_from("# Rework the config loader\n\nIt re-reads it.\n", 40),
2927 "Rework the config loader"
2928 );
2929 assert_eq!(title_from("- fix the thing", 40), "fix the thing");
2930 assert_eq!(title_from("> quoted task", 40), "quoted task");
2931 assert_eq!(title_from(" \n\n", 40), "(empty task)");
2933 assert_eq!(title_from("###\n", 40), "(empty task)");
2934 }
2935
2936 #[test]
2937 fn a_long_title_is_elided_by_characters_not_bytes() {
2938 let long = "課題".repeat(30);
2940 let title = title_from(&long, 10);
2941 assert_eq!(title.chars().count(), 10);
2942 assert!(title.ends_with('…'));
2943 }
2944
2945 #[test]
2946 fn priority_wins_and_ties_break_oldest_first() {
2947 let (_dir, q) = queue();
2948 let mut a = task("first");
2949 let mut b = task("second");
2950 let mut c = task("urgent");
2951 a.id = "20260101-000001-aaaa".to_owned();
2953 b.id = "20260101-000002-bbbb".to_owned();
2954 c.id = "20260101-000003-cccc".to_owned();
2955 c.priority = 5;
2956 for t in [&mut a, &mut b, &mut c] {
2957 q.put(t).unwrap();
2958 }
2959
2960 assert_eq!(q.next_runnable().unwrap().id, c.id);
2962 c.hold_machine(None);
2963 q.put(&mut c).unwrap();
2964 assert_eq!(q.next_runnable().unwrap().id, a.id);
2966 assert_eq!(q.list().len(), 3, "b is still waiting its turn");
2967 }
2968
2969 #[test]
2970 fn a_blocked_task_never_starves_another_runnable_one() {
2971 let (_dir, q) = queue();
2972 let mut blocked = task("blocked");
2973 blocked.block(vec!["something".to_owned()], None);
2974 q.put(&mut blocked).unwrap();
2975
2976 let mut runnable = task("free to go");
2977 q.put(&mut runnable).unwrap();
2978
2979 let next = q.next_runnable().expect("a runnable task is still offered");
2980 assert_eq!(next.id, runnable.id);
2981 }
2982
2983 #[test]
2984 fn a_held_task_is_never_offered_to_the_loop() {
2985 let (_dir, q) = queue();
2986 let mut t = task("held");
2987 q.put(&mut t).unwrap();
2988 assert!(q.next_runnable().is_some());
2989
2990 t.hold_machine(None);
2991 q.put(&mut t).unwrap();
2992 assert!(
2993 q.next_runnable().is_none(),
2994 "a held task must wait for a human"
2995 );
2996
2997 t.status = TaskStatus::Failed;
2999 q.put(&mut t).unwrap();
3000 assert!(q.next_runnable().is_some());
3001 }
3002
3003 #[test]
3004 fn attempts_are_capped_and_then_the_task_is_held() {
3005 let mut t = task("doomed");
3006
3007 t.start("run-1".to_owned());
3008 t.fail("gate red", 2);
3009 assert_eq!(t.status, TaskStatus::Failed, "one attempt of two: retry");
3010
3011 t.start("run-2".to_owned());
3012 t.fail("gate red", 2);
3013 assert_eq!(
3014 t.status,
3015 TaskStatus::Held,
3016 "out of attempts: stop spending money on it"
3017 );
3018 assert_eq!(t.runs, ["run-1", "run-2"]);
3019 assert_eq!(t.last_error.as_deref(), Some("gate red"));
3020 assert_eq!(
3021 t.hold_reason.as_deref(),
3022 Some("gate red"),
3023 "the hold must say why, not leave hold_reason null next to a \
3024 populated last_error"
3025 );
3026 }
3027
3028 #[test]
3029 fn handing_off_a_task_records_a_hold_reason_too() {
3030 let mut t = task("left a pull request");
3031 t.start("run-1".to_owned());
3032 t.handed_off("run ended with a pull request open [run run-1]");
3033 assert_eq!(t.status, TaskStatus::Held);
3034 assert_eq!(t.hold_source, Some(HoldSource::Machine));
3035 assert_eq!(
3036 t.hold_reason.as_deref(),
3037 Some("run ended with a pull request open [run run-1]")
3038 );
3039 assert_eq!(t.hold_reason, t.last_error);
3040 }
3041
3042 #[test]
3043 fn a_quota_stall_is_refunded_so_the_backlog_survives_the_night() {
3044 let mut t = task("stalled by quota");
3045
3046 t.start("run-1".to_owned());
3047 assert_eq!(t.attempts, 1);
3048 t.stall("judge-1, judge-2 out of quota");
3049 assert_eq!(
3050 t.attempts, 0,
3051 "a closed quota window must not spend the task's retry budget"
3052 );
3053 assert_eq!(t.status, TaskStatus::Failed, "the loop should retry it");
3054 assert_eq!(
3055 t.last_error.as_deref(),
3056 Some("judge-1, judge-2 out of quota")
3057 );
3058
3059 for _ in 0..20 {
3062 t.start("run-n".to_owned());
3063 t.stall("still out of quota");
3064 }
3065 t.start("run-real".to_owned());
3066 t.fail("gate red", 2);
3067 assert_eq!(
3068 t.status,
3069 TaskStatus::Failed,
3070 "the first attempt that was really judged is attempt one"
3071 );
3072 }
3073
3074 #[test]
3075 fn releasing_a_held_task_gives_it_a_real_second_chance() {
3076 let mut t = task("retry me");
3077 t.start("run-1".to_owned());
3078 t.fail("gate red", 1);
3079 assert_eq!(t.status, TaskStatus::Held);
3080
3081 t.release();
3082 assert_eq!(t.status, TaskStatus::Queued);
3083 assert_eq!(t.attempts, 0);
3086 assert!(t.last_error.is_none());
3087 assert_eq!(
3088 t.runs.len(),
3089 1,
3090 "history is kept: attempts reset, evidence does not"
3091 );
3092 }
3093
3094 #[test]
3095 fn a_hold_reason_survives_and_a_release_clears_it() {
3096 let mut t = task("waiting on something else");
3097 t.hold_manual(Some(
3098 "waiting for 20260101-000000-aaaa to land first".to_owned(),
3099 ));
3100 assert_eq!(t.status, TaskStatus::Held);
3101 assert_eq!(
3102 t.hold_reason.as_deref(),
3103 Some("waiting for 20260101-000000-aaaa to land first")
3104 );
3105
3106 t.hold_manual(None);
3108 assert_eq!(
3109 t.hold_reason.as_deref(),
3110 Some("waiting for 20260101-000000-aaaa to land first"),
3111 "a bare re-hold keeps whatever a human already wrote down"
3112 );
3113
3114 let mut plain = task("no reason given");
3116 plain.hold_manual(None);
3117 assert_eq!(plain.status, TaskStatus::Held);
3118 assert!(plain.hold_reason.is_none());
3119
3120 t.release();
3121 assert_eq!(t.status, TaskStatus::Queued);
3122 assert!(
3123 t.hold_reason.is_none(),
3124 "a stale reason must not greet the next person who holds this task"
3125 );
3126 }
3127
3128 #[test]
3129 fn closing_a_held_task_as_done_clears_its_hold_reason_too() {
3130 let mut t = task("landed by hand while held");
3135 t.hold_manual(Some("waiting on 3ed9".to_owned()));
3136 assert_eq!(t.hold_reason.as_deref(), Some("waiting on 3ed9"));
3137
3138 t.succeed();
3139 assert_eq!(t.status, TaskStatus::Done);
3140 assert!(
3141 t.hold_reason.is_none(),
3142 "a done task cannot still be waiting on something"
3143 );
3144 }
3145
3146 #[test]
3147 fn holding_or_closing_a_blocked_task_clears_its_dependency_too() {
3148 let mut held = task("held straight out of blocked");
3155 held.block(
3156 vec!["20260101-000000-dead".to_owned()],
3157 Some("waiting on the migration script".to_owned()),
3158 );
3159 assert_eq!(held.status, TaskStatus::Blocked);
3160
3161 held.hold_manual(None);
3162 assert_eq!(held.status, TaskStatus::Held);
3163 assert!(
3164 held.blocked_by.is_empty(),
3165 "hold overrides the wait, same as release"
3166 );
3167 assert!(held.block_reason.is_none());
3168
3169 let mut done = task("closed straight out of blocked");
3170 done.block(
3171 vec!["20260101-000000-dead".to_owned()],
3172 Some("waiting on the migration script".to_owned()),
3173 );
3174 done.succeed();
3175 assert_eq!(done.status, TaskStatus::Done);
3176 assert!(
3177 done.blocked_by.is_empty(),
3178 "a done task cannot still be waiting on a dependency"
3179 );
3180 assert!(done.block_reason.is_none());
3181 }
3182
3183 #[test]
3184 fn a_blocked_task_is_never_offered_to_the_loop() {
3185 let mut t = task("blocked");
3186 assert!(t.status.runnable());
3187 t.block(
3188 vec!["dep-id".to_owned()],
3189 Some("waits on dep-id".to_owned()),
3190 );
3191 assert_eq!(t.status, TaskStatus::Blocked);
3192 assert!(!t.status.runnable());
3193 assert_eq!(TaskStatus::Blocked.as_str(), "blocked");
3194 }
3195
3196 #[test]
3197 fn unblocking_the_last_dependency_returns_the_task_to_queued() {
3198 let mut t = task("blocked on two");
3199 t.block(
3200 vec!["a".to_owned(), "b".to_owned()],
3201 Some("waits on a and b".to_owned()),
3202 );
3203
3204 t.unblock("a");
3205 assert_eq!(t.status, TaskStatus::Blocked, "b is still outstanding");
3206 assert_eq!(t.blocked_by, ["b"]);
3207
3208 t.unblock("b");
3209 assert_eq!(t.status, TaskStatus::Queued);
3210 assert!(t.blocked_by.is_empty());
3211 assert!(t.block_reason.is_none());
3212 }
3213
3214 #[test]
3215 fn unblocking_an_id_on_a_task_that_is_not_blocked_is_a_no_op() {
3216 let mut t = task("never blocked");
3217 t.unblock("whatever");
3218 assert_eq!(t.status, TaskStatus::Queued);
3219 }
3220
3221 #[test]
3222 fn a_held_task_blocked_on_a_question_returns_to_held_not_queued() {
3223 let mut t = task("held, then asked about");
3228 t.hold_machine(Some("out of attempts".to_owned()));
3229 assert_eq!(t.status, TaskStatus::Held);
3230
3231 t.block(vec!["q1".to_owned()], Some("what now?".to_owned()));
3232 assert_eq!(t.status, TaskStatus::Blocked);
3233
3234 t.record_answer("what now?".to_owned(), "leave it held".to_owned());
3235 t.unblock("q1");
3236 assert_eq!(t.status, TaskStatus::Held, "must restore, not requeue");
3237 assert_eq!(t.hold_reason.as_deref(), Some("out of attempts"));
3238 assert_eq!(t.hold_source, Some(HoldSource::Machine));
3239 assert!(t.blocked_from.is_none(), "consumed once restored");
3240 }
3241
3242 #[test]
3243 fn a_manually_held_task_blocked_on_a_question_returns_to_held() {
3244 let mut t = task("manually held, then asked about");
3245 t.hold_manual(Some("waiting on a dependency".to_owned()));
3246
3247 t.block(vec!["q1".to_owned()], None);
3248 t.unblock("q1");
3249
3250 assert_eq!(t.status, TaskStatus::Held);
3251 assert_eq!(t.hold_source, Some(HoldSource::Manual));
3252 }
3253
3254 #[test]
3255 fn re_blocking_an_already_blocked_task_keeps_the_original_blocked_from() {
3256 let mut t = task("held, blocked twice");
3260 t.hold_machine(None);
3261 t.block(vec!["q1".to_owned()], Some("first".to_owned()));
3262 t.block(
3263 vec!["q1".to_owned(), "q2".to_owned()],
3264 Some("second".to_owned()),
3265 );
3266
3267 t.unblock("q1");
3268 assert_eq!(t.status, TaskStatus::Blocked, "q2 still outstanding");
3269 t.unblock("q2");
3270 assert_eq!(t.status, TaskStatus::Held);
3271 }
3272
3273 #[test]
3274 fn unblocking_a_task_blocked_while_running_lands_on_queued_not_running() {
3275 let mut t = task("blocked mid-run");
3279 t.start("run-1".to_owned());
3280 assert_eq!(t.status, TaskStatus::Running);
3281
3282 t.block(vec!["q1".to_owned()], None);
3283 t.unblock("q1");
3284 assert_eq!(t.status, TaskStatus::Queued);
3285 }
3286
3287 #[test]
3288 fn a_pre_schema_4_blocked_record_with_hold_evidence_restores_to_held() {
3289 let mut t = task("legacy record, held before it was blocked");
3295 t.hold_source = Some(HoldSource::Machine);
3296 t.hold_reason = Some("legacy hold reason".to_owned());
3297 t.status = TaskStatus::Blocked;
3298 t.blocked_by = vec!["q1".to_owned()];
3299 t.blocked_from = None;
3300
3301 t.unblock("q1");
3302 assert_eq!(t.status, TaskStatus::Held);
3303 }
3304
3305 #[test]
3306 fn a_pre_schema_4_blocked_record_with_no_hold_evidence_restores_to_queued() {
3307 let mut t = task("legacy record, ordinary dependency block");
3308 t.status = TaskStatus::Blocked;
3309 t.blocked_by = vec!["dep".to_owned()];
3310 t.blocked_from = None;
3311
3312 t.unblock("dep");
3313 assert_eq!(t.status, TaskStatus::Queued);
3314 }
3315
3316 #[test]
3317 fn answering_a_question_is_recorded_and_survives_a_release() {
3318 let mut t = task("asked something");
3319 t.block(vec!["q1".to_owned()], Some("which backend?".to_owned()));
3320 t.record_answer("Which backend?".to_owned(), "SQLite".to_owned());
3321 t.unblock("q1");
3322 assert_eq!(t.status, TaskStatus::Queued);
3323 assert_eq!(t.answers.len(), 1);
3324 assert_eq!(t.answers[0].answer, "SQLite");
3325
3326 t.release();
3330 assert_eq!(t.answers.len(), 1, "the answer is not lost on release");
3331 }
3332
3333 #[test]
3334 fn a_refused_handover_keeps_the_review_branch_across_release() {
3335 let mut t = task("refused takeover");
3336 t.start("run-1".to_owned());
3337 t.hold_for_handover(Some("magi/eba2/A".to_owned()), "checked out".to_owned());
3338 assert_eq!(t.status, TaskStatus::Held);
3339 assert_eq!(t.attempts, 1);
3340 t.release();
3341 assert_eq!(t.status, TaskStatus::Queued);
3342 assert_eq!(t.attempts, 0);
3343 assert_eq!(t.review_branch.as_deref(), Some("magi/eba2/A"));
3344
3345 t.hold_for_handover(None, "again".to_owned());
3347 t.requeue();
3348 assert!(t.review_branch.is_none());
3349
3350 let mut m = task("manual");
3352 m.review_branch = Some("magi/x/A".to_owned());
3353 m.hold_manual(None);
3354 m.release();
3355 assert!(m.review_branch.is_none());
3356 }
3357
3358 #[test]
3359 fn requesting_review_requeues_the_task_and_remembers_the_branch() {
3360 let mut t = task("blocked run with a surviving branch");
3361 t.start("run-1".to_owned());
3362 t.fail("blocked with major findings", 5);
3363 assert_eq!(t.status, TaskStatus::Failed);
3364
3365 t.request_review("magi/eba2/A".to_owned());
3366 assert_eq!(t.status, TaskStatus::Queued);
3367 assert_eq!(t.attempts, 0);
3368 assert_eq!(t.review_branch.as_deref(), Some("magi/eba2/A"));
3369
3370 t.release();
3372 assert!(t.review_branch.is_none());
3373 }
3374
3375 #[test]
3376 fn conductor_requeue_but_not_an_ordinary_release_forces_a_fresh_start() {
3377 let mut t = task("retry");
3378 t.start("run-1".to_owned());
3379 t.requeue();
3380 assert!(t.fresh_start);
3381
3382 t.release();
3383 assert!(!t.fresh_start);
3384 }
3385
3386 #[test]
3387 fn priority_can_be_changed_while_queued_but_not_while_running() {
3388 let mut t = task("reprioritise me");
3389 t.set_priority(5).unwrap();
3390 assert_eq!(t.priority, 5);
3391
3392 t.start("run-1".to_owned());
3393 let err = t.set_priority(9).unwrap_err().to_string();
3394 assert!(err.contains("running"), "{err}");
3395 assert_eq!(t.priority, 5, "the rejected write must not partially apply");
3396 }
3397
3398 #[test]
3399 fn interrupt_can_be_marked_while_queued_but_not_while_running() {
3400 let mut t = task("interrupt me");
3401 assert!(!t.interrupt, "off unless asked, same as any other task");
3402
3403 t.set_interrupt(true).unwrap();
3404 assert!(t.interrupt);
3405
3406 t.start("run-1".to_owned());
3407 assert!(
3408 !t.interrupt,
3409 "the mark is one-shot: dispatching the task fulfils it, \
3410 whatever the run that follows ends up doing"
3411 );
3412 let err = t.set_interrupt(true).unwrap_err().to_string();
3413 assert!(err.contains("running"), "{err}");
3414 t.set_interrupt(false).unwrap();
3417 assert!(!t.interrupt);
3418 }
3419
3420 #[test]
3424 fn a_failed_run_does_not_leave_the_task_still_marked_to_interrupt() {
3425 let mut t = task("interrupt me");
3426 t.set_interrupt(true).unwrap();
3427 t.start("run-1".to_owned());
3428 t.fail("mock failure", 5);
3429 assert_eq!(t.status, TaskStatus::Failed);
3430 assert!(
3431 !t.interrupt,
3432 "one attempt already spent the mark; a retry is an ordinary \
3433 requeue, not a fresh interrupt request"
3434 );
3435 }
3436
3437 #[test]
3438 fn changing_priority_moves_a_task_ahead_in_the_real_queue_order() {
3439 let (_dir, q) = queue();
3440 let mut a = task("first filed");
3441 let mut b = task("second filed");
3442 a.id = "20260101-000001-aaaa".to_owned();
3443 b.id = "20260101-000002-bbbb".to_owned();
3444 q.put(&mut a).unwrap();
3445 q.put(&mut b).unwrap();
3446
3447 assert_eq!(
3448 q.next_runnable().unwrap().id,
3449 a.id,
3450 "with equal priority the older task goes first, so a burst of \
3451 new work cannot starve it"
3452 );
3453 assert_eq!(
3454 q.list()[0].id,
3455 b.id,
3456 "but the list an operator reads is newest first, the same as \
3457 before priority existed - a's turn to run does not make it the \
3458 newest task"
3459 );
3460
3461 let mut a = q.get(&a.id).unwrap();
3462 a.set_priority(10).unwrap();
3463 q.put(&mut a).unwrap();
3464
3465 assert_eq!(
3466 q.next_runnable().unwrap().id,
3467 a.id,
3468 "a raised priority must be reflected the moment it is saved"
3469 );
3470 assert_eq!(
3474 q.list()[0].id,
3475 a.id,
3476 "the raised task must sort first in the list an operator reads, \
3477 not only in next_runnable's own ordering"
3478 );
3479 }
3480
3481 #[test]
3482 fn editing_replaces_title_and_instruction_but_keeps_identity_and_history() {
3483 let mut t = Task::new(
3484 "old title".to_owned(),
3485 "old instruction".to_owned(),
3486 PathBuf::from("/repo"),
3487 Source::Agent {
3488 run: "20260101-000000-beef".to_owned(),
3489 node: "implement".to_owned(),
3490 },
3491 );
3492 let id = t.id.clone();
3493 let created_at = t.created_at;
3494 t.runs.push("20260101-000000-beef".to_owned());
3495
3496 t.edit("new title".to_owned(), "new instruction".to_owned())
3497 .unwrap();
3498
3499 assert_eq!(t.title, "new title");
3500 assert_eq!(t.instruction, "new instruction");
3501 assert_eq!(t.id, id, "editing must not mint a new id");
3502 assert_eq!(t.created_at, created_at);
3503 assert_eq!(
3504 t.source,
3505 Source::Agent {
3506 run: "20260101-000000-beef".to_owned(),
3507 node: "implement".to_owned(),
3508 },
3509 "editing must not turn agent attribution into human"
3510 );
3511 assert_eq!(t.runs, ["20260101-000000-beef"]);
3512 }
3513
3514 #[test]
3515 fn editing_is_refused_once_a_task_is_running_or_finished() {
3516 let mut running = task("in flight");
3517 running.start("run-1".to_owned());
3518 let err = running
3519 .edit("x".to_owned(), "y".to_owned())
3520 .unwrap_err()
3521 .to_string();
3522 assert!(err.contains("running"), "{err}");
3523
3524 let mut done = task("finished");
3525 done.succeed();
3526 let err = done
3527 .edit("x".to_owned(), "y".to_owned())
3528 .unwrap_err()
3529 .to_string();
3530 assert!(err.contains("done"), "{err}");
3531
3532 let mut queued = task("waiting");
3534 queued.edit("x".to_owned(), "y".to_owned()).unwrap();
3535 let mut held = task("parked");
3536 held.hold_machine(None);
3537 held.edit("x".to_owned(), "y".to_owned()).unwrap();
3538 }
3539
3540 #[test]
3541 fn a_task_recorded_without_a_hold_reason_still_reads_as_none() {
3542 let (_dir, q) = queue();
3543 let path = q.path_of("20260101-000000-aaaa");
3544 std::fs::create_dir_all(q.root()).unwrap();
3545 std::fs::write(
3546 &path,
3547 serde_json::json!({
3548 "schema": SCHEMA,
3549 "id": "20260101-000000-aaaa",
3550 "title": "from before hold reasons existed",
3551 "instruction": "from before hold reasons existed",
3552 "repo": ".",
3553 "source": { "kind": "human" },
3554 "status": "held",
3555 "created_at": Timestamp::now().to_string(),
3556 "updated_at": Timestamp::now().to_string(),
3557 })
3558 .to_string(),
3559 )
3560 .unwrap();
3561
3562 let task = q.get("20260101-000000-aaaa").expect("must still read");
3563 assert!(task.hold_reason.is_none());
3564 assert!(task.operator_held());
3565 }
3566
3567 #[test]
3568 fn a_legacy_reasoned_hold_defaults_to_operator_protection() {
3569 let (_dir, q) = queue();
3570 let path = q.path_of("20260101-000000-bbbb");
3571 std::fs::create_dir_all(q.root()).unwrap();
3572 std::fs::write(
3573 &path,
3574 serde_json::json!({
3575 "schema": 2,
3576 "id": "20260101-000000-bbbb",
3577 "title": "old manual recovery",
3578 "instruction": "old manual recovery",
3579 "repo": ".",
3580 "source": { "kind": "human" },
3581 "status": "held",
3582 "hold_reason": "active manual recovery run20260912-224242-daf5",
3583 "created_at": Timestamp::now().to_string(),
3584 "updated_at": Timestamp::now().to_string(),
3585 })
3586 .to_string(),
3587 )
3588 .unwrap();
3589
3590 let task = q.get("20260101-000000-bbbb").expect("must still read");
3591 assert_eq!(task.hold_source, None);
3592 assert!(task.operator_held());
3593 }
3594
3595 #[test]
3596 fn a_task_recorded_without_a_diagnostic_still_reads_as_none() {
3597 let (_dir, q) = queue();
3598 let path = q.path_of("20260101-000000-aaaa");
3599 std::fs::create_dir_all(q.root()).unwrap();
3600 std::fs::write(
3601 &path,
3602 serde_json::json!({
3603 "schema": SCHEMA,
3604 "id": "20260101-000000-aaaa",
3605 "title": "from before diagnostics existed",
3606 "instruction": "from before diagnostics existed",
3607 "repo": ".",
3608 "source": { "kind": "human" },
3609 "status": "held",
3610 "created_at": Timestamp::now().to_string(),
3611 "updated_at": Timestamp::now().to_string(),
3612 })
3613 .to_string(),
3614 )
3615 .unwrap();
3616
3617 let task = q.get("20260101-000000-aaaa").expect("must still read");
3618 assert!(task.diagnostic.is_none());
3619 }
3620
3621 #[test]
3622 fn a_schema_1_task_with_no_blocking_fields_still_reads() {
3623 let (_dir, q) = queue();
3627 let path = q.path_of("20260101-000000-aaaa");
3628 std::fs::create_dir_all(q.root()).unwrap();
3629 std::fs::write(
3630 &path,
3631 serde_json::json!({
3632 "schema": 1,
3633 "id": "20260101-000000-aaaa",
3634 "title": "from before blocking existed",
3635 "instruction": "from before blocking existed",
3636 "repo": ".",
3637 "source": { "kind": "human" },
3638 "status": "queued",
3639 "created_at": Timestamp::now().to_string(),
3640 "updated_at": Timestamp::now().to_string(),
3641 })
3642 .to_string(),
3643 )
3644 .unwrap();
3645
3646 let task = q.get("20260101-000000-aaaa").expect("must still read");
3647 assert!(task.blocked_by.is_empty());
3648 assert!(task.block_reason.is_none());
3649 assert!(task.answers.is_empty());
3650 assert!(task.review_branch.is_none());
3651 }
3652
3653 #[test]
3654 fn releasing_or_finishing_a_task_clears_its_stale_diagnostic() {
3655 let mut held = task("diagnosed");
3660 held.start("run-1".to_owned());
3661 held.fail("gate red", 1);
3662 held.diagnostic = Some("cargo test failed: ...".to_owned());
3663 assert_eq!(held.status, TaskStatus::Held);
3664
3665 held.release();
3666 assert!(held.diagnostic.is_none());
3667
3668 held.diagnostic = Some("cargo test failed: ...".to_owned());
3669 held.succeed();
3670 assert!(held.diagnostic.is_none());
3671 }
3672
3673 #[test]
3674 fn failing_a_task_always_clears_whatever_diagnostic_it_carried() {
3675 let mut t = task("retried");
3676 t.start("run-1".to_owned());
3677 t.diagnostic = Some("stale evidence from a previous hold".to_owned());
3678 t.fail("unrelated config error", 5);
3679 assert_eq!(t.status, TaskStatus::Failed);
3680 assert!(
3681 t.diagnostic.is_none(),
3682 "fail() must not let an old diagnostic outlive the run that produced it"
3683 );
3684 }
3685
3686 #[test]
3687 fn a_claim_is_exclusive_and_releases_on_drop() {
3688 let (_dir, q) = queue();
3689 let mut t = task("contended");
3690 q.put(&mut t).unwrap();
3691
3692 let held = q.claim(&t.id).unwrap();
3693 assert!(
3694 q.claim(&t.id).is_err(),
3695 "two daemons must not drive one task into two runs"
3696 );
3697 drop(held);
3698 assert!(q.claim(&t.id).is_ok(), "a released claim is reclaimable");
3699 }
3700
3701 #[test]
3702 fn a_round_trip_survives_disk() {
3703 let (_dir, q) = queue();
3704 let mut t = Task::new(
3705 "titled".to_owned(),
3706 "body".to_owned(),
3707 PathBuf::from("/repo"),
3708 Source::Agent {
3709 run: "20260101-000000-beef".to_owned(),
3710 node: "implement".to_owned(),
3711 },
3712 );
3713 t.priority = 3;
3714 q.put(&mut t).unwrap();
3715
3716 let back = q.get(&t.id).unwrap();
3717 assert_eq!(back.id, t.id);
3718 assert_eq!(back.priority, 3);
3719 assert_eq!(back.source.label(), "implement@beef");
3720 assert_eq!(q.get(t.short()).unwrap().id, t.id);
3722 }
3723
3724 #[test]
3725 fn an_unreadable_task_does_not_take_the_queue_down() {
3726 let (_dir, q) = queue();
3727 let mut t = task("fine");
3728 q.put(&mut t).unwrap();
3729 std::fs::write(q.root().join("broken.json"), "{ not json").unwrap();
3730
3731 let listed = q.list();
3732 assert_eq!(listed.len(), 1, "the readable task still lists");
3733 assert_eq!(listed[0].id, t.id);
3734 }
3735
3736 #[test]
3737 fn a_task_recorded_without_a_solo_field_still_reads_as_not_solo() {
3738 let (_dir, q) = queue();
3739 let path = q.path_of("20260101-000000-aaaa");
3740 std::fs::create_dir_all(q.root()).unwrap();
3741 std::fs::write(
3742 &path,
3743 serde_json::json!({
3744 "schema": SCHEMA,
3745 "id": "20260101-000000-aaaa",
3746 "title": "from before solo existed",
3747 "instruction": "from before solo existed",
3748 "repo": ".",
3749 "source": { "kind": "human" },
3750 "status": "queued",
3751 "created_at": Timestamp::now().to_string(),
3752 "updated_at": Timestamp::now().to_string(),
3753 })
3754 .to_string(),
3755 )
3756 .unwrap();
3757
3758 let task = q.get("20260101-000000-aaaa").expect("must still read");
3759 assert!(!task.solo, "a queue file with no `solo` field means false");
3760 }
3761
3762 #[test]
3763 fn a_task_recorded_without_an_urgent_field_still_reads_as_not_urgent() {
3764 let (_dir, q) = queue();
3765 let path = q.path_of("20260101-000000-bbbb");
3766 std::fs::create_dir_all(q.root()).unwrap();
3767 std::fs::write(
3768 &path,
3769 serde_json::json!({
3770 "schema": SCHEMA,
3771 "id": "20260101-000000-bbbb",
3772 "title": "from before urgent existed",
3773 "instruction": "from before urgent existed",
3774 "repo": ".",
3775 "source": { "kind": "human" },
3776 "status": "queued",
3777 "created_at": Timestamp::now().to_string(),
3778 "updated_at": Timestamp::now().to_string(),
3779 })
3780 .to_string(),
3781 )
3782 .unwrap();
3783
3784 let task = q.get("20260101-000000-bbbb").expect("must still read");
3785 assert!(
3786 !task.urgent,
3787 "a queue file with no `urgent` field means false, same as `solo`"
3788 );
3789 }
3790
3791 #[test]
3792 fn a_task_from_a_future_schema_is_refused_rather_than_guessed_at() {
3793 let (_dir, q) = queue();
3794 let mut t = task("from the future");
3795 q.put(&mut t).unwrap();
3796 let path = q.path_of(&t.id);
3797 let body = std::fs::read_to_string(&path)
3798 .unwrap()
3799 .replace(&format!("\"schema\": {SCHEMA}"), "\"schema\": 99");
3800 std::fs::write(&path, body).unwrap();
3801
3802 let err = q.get(&t.id).unwrap_err().to_string();
3803 assert!(err.contains("schema 99"), "{err}");
3804 }
3805
3806 #[test]
3807 fn revision_moves_when_the_queue_changes() {
3808 let (_dir, q) = queue();
3809 assert_eq!(q.revision(), 0, "an empty queue has no revision");
3810 let mut t = task("first");
3811 q.put(&mut t).unwrap();
3812 assert!(q.revision() > 0, "a written task moves the revision");
3813 }
3814
3815 #[test]
3816 fn revision_moves_when_deleting_an_older_task() {
3817 let (dir, q) = queue();
3818 let questions = Questions::at(dir.path().join("questions"));
3819 let mut t1 = task("older");
3820 q.put(&mut t1).unwrap();
3821 std::thread::sleep(std::time::Duration::from_millis(10));
3823 let mut t2 = task("newer");
3824 q.put(&mut t2).unwrap();
3825
3826 let rev_before = q.revision();
3827 q.remove(&t1.id, false, &questions).unwrap();
3828 let rev_after = q.revision();
3829
3830 assert_ne!(
3831 rev_before, rev_after,
3832 "deleting an older task must change the revision so other clients see the deletion"
3833 );
3834 }
3835
3836 #[test]
3837 fn removing_a_task_takes_it_out_of_the_listing() {
3838 let (dir, q) = queue();
3839 let questions = Questions::at(dir.path().join("questions"));
3840 let mut t = task("delete me");
3841 q.put(&mut t).unwrap();
3842 let removed = q.remove(t.short(), false, &questions).unwrap();
3843 assert_eq!(removed.id, t.id, "a prefix resolves before deleting");
3844 assert!(removed.released.is_empty() && removed.still_blocked.is_empty());
3845 assert!(q.list().is_empty());
3846 assert!(
3847 q.remove(&t.id, false, &questions).is_err(),
3848 "removing twice is an error"
3849 );
3850 }
3851
3852 #[test]
3853 fn removing_a_task_takes_its_stale_lock_with_it() {
3854 let (dir, q) = queue();
3855 let questions = Questions::at(dir.path().join("questions"));
3856 let mut t = task("interrupted");
3857 q.put(&mut t).unwrap();
3858
3859 let claim = q.claim(&t.id).unwrap();
3862 std::mem::forget(claim);
3863 assert!(
3864 q.claim(&t.id).is_err(),
3865 "the orphaned lock is what makes the task look claimed"
3866 );
3867
3868 let err = q.remove(&t.id, true, &questions).unwrap_err().to_string();
3870 assert!(err.contains("live daemon"), "{err}");
3871 assert!(q.get(&t.id).is_ok(), "a refused delete keeps the task");
3872
3873 q.remove(&t.id, false, &questions).unwrap();
3875 assert!(q.list().is_empty());
3876 let mut again = task("interrupted");
3877 again.id = t.id.clone();
3878 q.put(&mut again).unwrap();
3879 assert!(
3880 q.claim(&t.id).is_ok(),
3881 "a task that comes back must be claimable, which a left-behind lock would prevent"
3882 );
3883 }
3884
3885 fn notices_of(dir: &Path) -> Vec<crate::notices::Notice> {
3886 crate::notices::Notices::at(dir.join("notifications")).list()
3887 }
3888
3889 #[test]
3890 fn removing_a_sole_dependency_releases_the_dependent_without_a_hold() {
3891 let (dir, q) = queue();
3892 let questions = Questions::at(dir.path().join("questions"));
3893 let mut dep = task("dependency");
3894 q.put(&mut dep).unwrap();
3895 let mut blocked = task("waiting");
3896 blocked.block(vec![dep.id.clone()], Some("waits".to_owned()));
3897 q.put(&mut blocked).unwrap();
3898
3899 let removed = q.remove(&dep.id, false, &questions).unwrap();
3900 assert_eq!(removed.released, [blocked.id.clone()]);
3901 assert!(removed.still_blocked.is_empty());
3902
3903 let after = q.get(&blocked.id).unwrap();
3904 assert_eq!(after.status, TaskStatus::Queued);
3905 assert!(after.blocked_by.is_empty());
3906 assert!(after.block_reason.is_none());
3907 let notes = notices_of(dir.path());
3908 assert_eq!(notes.len(), 1, "{notes:?}");
3909 assert_eq!(notes[0].severity, crate::notices::Severity::Info);
3910 assert_eq!(
3911 notes[0].link,
3912 Some(crate::notices::Link::Task {
3913 id: blocked.id.clone(),
3914 })
3915 );
3916 }
3917
3918 #[test]
3919 fn removing_one_of_two_dependencies_keeps_the_other() {
3920 let (dir, q) = queue();
3921 let questions = Questions::at(dir.path().join("questions"));
3922 let mut dep = task("dependency");
3923 q.put(&mut dep).unwrap();
3924 let mut other = task("other");
3925 q.put(&mut other).unwrap();
3926 let mut blocked = task("waiting");
3927 blocked.block(
3928 vec![dep.id.clone(), other.id.clone()],
3929 Some("waits on both".to_owned()),
3930 );
3931 q.put(&mut blocked).unwrap();
3932
3933 let removed = q.remove(&dep.id, false, &questions).unwrap();
3934 assert!(removed.released.is_empty());
3935 assert_eq!(removed.still_blocked, [blocked.id.clone()]);
3936
3937 let after = q.get(&blocked.id).unwrap();
3938 assert_eq!(after.status, TaskStatus::Blocked);
3939 assert_eq!(after.blocked_by, [other.id.clone()]);
3940 assert!(missing_blockers(&q, &questions, &after.blocked_by).is_empty());
3943 }
3944
3945 #[test]
3946 fn a_dependency_deleted_before_its_dependents_were_rewritten_is_released_later() {
3947 let (dir, q) = queue();
3948 let questions = Questions::at(dir.path().join("questions"));
3949 let mut dep = task("dependency");
3950 q.put(&mut dep).unwrap();
3951 let mut blocked = task("waiting");
3952 blocked.block(vec![dep.id.clone()], None);
3953 q.put(&mut blocked).unwrap();
3954
3955 let claim = q.claim(&blocked.id).unwrap();
3957 let removed = q.remove(&dep.id, false, &questions).unwrap();
3958 assert!(removed.released.is_empty());
3959 drop(claim);
3960
3961 let mut task = q.get(&blocked.id).unwrap();
3962 assert_eq!(task.status, TaskStatus::Blocked);
3963 assert!(missing_blockers(&q, &questions, &task.blocked_by).is_empty());
3964 assert_eq!(q.apply_deleted_blockers(&mut task), [dep.id.clone()]);
3965 assert_eq!(task.status, TaskStatus::Queued);
3966 }
3967
3968 #[test]
3969 fn a_dependency_without_a_tombstone_is_still_missing() {
3970 let (dir, q) = queue();
3971 let questions = Questions::at(dir.path().join("questions"));
3972 let mut dep = task("dependency");
3973 q.put(&mut dep).unwrap();
3974 std::fs::remove_file(q.path_of(&dep.id)).unwrap();
3975 assert_eq!(
3976 missing_blockers(&q, &questions, std::slice::from_ref(&dep.id)),
3977 [dep.id.clone()]
3978 );
3979 assert!(deleted_blockers(&q, std::slice::from_ref(&dep.id)).is_empty());
3980 }
3981
3982 #[test]
3983 fn a_held_dependent_is_left_alone_by_a_dependency_deletion() {
3984 let mut t = task("held");
3985 t.hold_machine(Some("because".to_owned()));
3986 t.blocked_by = vec!["gone".to_owned()];
3987 assert!(!t.dependency_deleted("gone"));
3988 assert_eq!(t.status, TaskStatus::Held);
3989 }
3990
3991 #[test]
3992 fn a_failed_record_removal_takes_the_tombstone_back() {
3993 let (_dir, q) = queue();
3994 let mut t = task("stays");
3995 q.put(&mut t).unwrap();
3996 q.write_tombstone(&t.id).unwrap();
3997 let err = q.remove_record_with_attachments(&t.id, |_| Err(std::io::Error::other("nope")));
3998 assert!(err.is_err());
3999 assert!(!q.was_deleted(&t.id));
4002 }
4003
4004 fn source_file(dir: &Path, name: &str, body: &str) -> PathBuf {
4005 let p = dir.join(name);
4006 std::fs::write(&p, body).unwrap();
4007 p
4008 }
4009
4010 #[test]
4011 fn an_attachment_copy_survives_deleting_its_source() {
4012 let (dir, q) = queue();
4013 let src = source_file(dir.path(), "shot.png", "pixels");
4014 let mut t = task("with a picture");
4015 let names = q.attach(&mut t, std::slice::from_ref(&src)).unwrap();
4016 q.put(&mut t).unwrap();
4017 std::fs::remove_file(&src).unwrap();
4018 assert_eq!(names, ["shot.png"]);
4019 let loaded = q.get(&t.id).unwrap();
4020 let paths = q.attachment_paths(&loaded);
4021 assert_eq!(paths.len(), 1);
4022 assert_eq!(std::fs::read_to_string(&paths[0]).unwrap(), "pixels");
4023 }
4024
4025 #[test]
4026 fn attachment_names_that_could_traverse_or_are_odd_are_refused() {
4027 let (dir, q) = queue();
4028 let mut t = task("bad names");
4029 for name in ["a..b.png", ".hidden", "with space.png", "-x.png"] {
4030 let src = source_file(dir.path(), name, "x");
4031 assert!(
4032 q.attach(&mut t, &[src]).is_err(),
4033 "`{name}` must be refused"
4034 );
4035 }
4036 assert!(!crate::ask::valid_asset_name("C:foo.png"));
4040 #[cfg(not(windows))]
4041 {
4042 let src = source_file(dir.path(), "C:foo.png", "x");
4043 assert!(q.attach(&mut t, &[src]).is_err());
4044 }
4045 let long = format!("{}.png", "a".repeat(70));
4046 let src = source_file(dir.path(), &long, "x");
4047 assert!(q.attach(&mut t, &[src]).is_err());
4048 assert!(t.attachments.is_empty());
4049 assert!(!q.attachments_dir(&t.id).exists());
4050 }
4051
4052 #[test]
4053 fn a_taken_attachment_name_is_numbered_not_overwritten() {
4054 let (dir, q) = queue();
4055 let a = source_file(dir.path(), "shot.png", "one");
4056 let sub = dir.path().join("other");
4057 std::fs::create_dir_all(&sub).unwrap();
4058 let b = source_file(&sub, "shot.png", "two");
4059 let mut t = task("collision");
4060 q.attach(&mut t, &[a]).unwrap();
4061 q.attach(&mut t, &[b]).unwrap();
4062 assert_eq!(t.attachments, ["shot.png", "shot-2.png"]);
4063 let paths = q.attachment_paths(&t);
4064 assert_eq!(std::fs::read_to_string(&paths[0]).unwrap(), "one");
4065 assert_eq!(std::fs::read_to_string(&paths[1]).unwrap(), "two");
4066 }
4067
4068 #[test]
4069 fn a_renumbered_name_stays_inside_the_length_bound() {
4070 let (dir, q) = queue();
4071 let name = format!("{}.png", "a".repeat(60));
4072 assert_eq!(name.len(), 64);
4073 let a = source_file(dir.path(), &name, "one");
4074 let sub = dir.path().join("other");
4075 std::fs::create_dir_all(&sub).unwrap();
4076 let b = source_file(&sub, &name, "two");
4077 let mut t = task("long");
4078 q.attach(&mut t, &[a, b]).unwrap();
4079 assert_eq!(t.attachments.len(), 2);
4080 assert!(
4081 t.attachments
4082 .iter()
4083 .all(|n| crate::ask::valid_asset_name(n))
4084 );
4085 assert!(t.attachments[1].ends_with("-2.png"));
4086 }
4087
4088 #[test]
4089 fn a_failed_attach_keeps_existing_attachments_and_leaves_no_partial_copy() {
4090 let (dir, q) = queue();
4091 let good = source_file(dir.path(), "good.png", "ok");
4092 let mut t = task("partial");
4093 q.attach(&mut t, &[good]).unwrap();
4094 let more = source_file(dir.path(), "more.png", "ok");
4095 let missing = dir.path().join("missing.png");
4096 assert!(q.attach(&mut t, &[more, missing]).is_err());
4097 assert_eq!(t.attachments, ["good.png"]);
4098 let on_disk: Vec<_> = std::fs::read_dir(q.attachments_dir(&t.id))
4099 .unwrap()
4100 .flatten()
4101 .collect();
4102 assert_eq!(on_disk.len(), 1);
4103 }
4104
4105 fn block_put(q: &Queue, t: &Task) -> PathBuf {
4107 let tmp = q.path_of(&t.id).with_extension("json.tmp");
4108 std::fs::create_dir_all(&tmp).unwrap();
4109 tmp
4110 }
4111
4112 #[test]
4113 fn a_failed_put_leaves_no_new_attachment_directory() {
4114 let (dir, q) = queue();
4115 let mut t = task("fresh");
4116 let tmp = block_put(&q, &t);
4117 let src = source_file(dir.path(), "shot.png", "x");
4118 assert!(q.attach_and_put(&mut t, &[src]).is_err());
4119 assert!(t.attachments.is_empty());
4120 assert!(!q.attachments_dir(&t.id).exists());
4121 assert!(!q.path_of(&t.id).exists());
4122 std::fs::remove_dir(tmp).unwrap();
4123 }
4124
4125 #[test]
4126 fn a_failed_put_removes_only_the_copy_it_just_made() {
4127 let (dir, q) = queue();
4128 let mut t = task("edited");
4129 let first = source_file(dir.path(), "first.png", "1");
4130 q.attach_and_put(&mut t, &[first]).unwrap();
4131 block_put(&q, &t);
4132 let second = source_file(dir.path(), "second.png", "2");
4133 assert!(q.attach_and_put(&mut t, &[second]).is_err());
4134 assert_eq!(t.attachments, ["first.png"]);
4135 let on_disk: Vec<_> = std::fs::read_dir(q.attachments_dir(&t.id))
4136 .unwrap()
4137 .flatten()
4138 .map(|e| e.file_name().to_string_lossy().into_owned())
4139 .collect();
4140 assert_eq!(on_disk, ["first.png"]);
4141 assert_eq!(q.get(&t.id).unwrap().attachments, ["first.png"]);
4142 }
4143
4144 #[test]
4145 fn a_leftover_removing_directory_is_swept_by_the_next_removal() {
4146 let (dir, q) = queue();
4147 let questions = Questions::at(dir.path().join("questions"));
4148 let gone = task("gone");
4149 let mut other = task("other");
4150 let mut live = task("live");
4151 q.put(&mut other).unwrap();
4152 q.put(&mut live).unwrap();
4153 let orphan = q.root.join(format!("{}.attachments.removing", gone.id));
4156 std::fs::create_dir_all(&orphan).unwrap();
4157 std::fs::write(orphan.join("shot.png"), "x").unwrap();
4158 let busy = q.root.join(format!("{}.attachments.removing", live.id));
4161 std::fs::create_dir_all(&busy).unwrap();
4162
4163 q.remove(&other.id, false, &questions).unwrap();
4164 assert!(!orphan.exists(), "an orphan is swept");
4165 assert!(busy.exists(), "a removal in progress is left alone");
4166 }
4167
4168 #[test]
4169 fn a_blocked_aside_rename_fails_the_removal_and_loses_nothing() {
4170 let (dir, q) = queue();
4171 let questions = Questions::at(dir.path().join("questions"));
4172 let mut t = task("stuck");
4173 let src = source_file(dir.path(), "shot.png", "x");
4174 q.attach_and_put(&mut t, &[src]).unwrap();
4175 let aside = q.root.join(format!("{}.attachments.removing", t.id));
4178 std::fs::create_dir_all(&aside).unwrap();
4179 std::fs::write(aside.join("old.png"), "o").unwrap();
4180 assert!(q.remove(&t.id, false, &questions).is_err());
4181 assert!(q.path_of(&t.id).exists());
4182 assert!(q.attachments_dir(&t.id).join("shot.png").is_file());
4183 }
4184
4185 #[test]
4186 fn a_failed_record_removal_puts_the_attachments_back() {
4187 let (dir, q) = queue();
4188 let mut t = task("rollback");
4189 let src = source_file(dir.path(), "shot.png", "x");
4190 q.attach_and_put(&mut t, &[src]).unwrap();
4191 let err = q
4192 .remove_record_with_attachments(&t.id, |_| {
4193 Err(std::io::Error::other("injected failure"))
4194 })
4195 .unwrap_err();
4196 assert!(format!("{err:#}").contains("injected failure"));
4197 assert!(q.path_of(&t.id).exists());
4198 assert!(q.attachments_dir(&t.id).join("shot.png").is_file());
4199 assert!(
4200 !q.root
4201 .join(format!("{}.attachments.removing", t.id))
4202 .exists()
4203 );
4204 }
4205
4206 #[test]
4207 fn editing_a_task_keeps_its_attachments() {
4208 let (dir, q) = queue();
4209 let src = source_file(dir.path(), "shot.png", "x");
4210 let mut t = task("editable");
4211 q.attach(&mut t, &[src]).unwrap();
4212 t.edit("new".to_owned(), "new text".to_owned()).unwrap();
4213 q.put(&mut t).unwrap();
4214 assert_eq!(q.get(&t.id).unwrap().attachments, ["shot.png"]);
4215 }
4216
4217 #[test]
4218 fn removing_a_task_deletes_its_attachments() {
4219 let (dir, q) = queue();
4220 let questions = Questions::at(dir.path().join("questions"));
4221 let src = source_file(dir.path(), "shot.png", "x");
4222 let mut t = task("doomed");
4223 q.attach(&mut t, &[src]).unwrap();
4224 q.put(&mut t).unwrap();
4225 assert!(q.attachments_dir(&t.id).is_dir());
4226 q.remove(&t.id, false, &questions).unwrap();
4227 assert!(!q.attachments_dir(&t.id).exists());
4228 assert!(q.list().is_empty());
4229 }
4230
4231 #[test]
4232 fn a_task_written_before_attachments_still_reads() {
4233 let (_dir, q) = queue();
4234 let mut t = task("old");
4235 q.put(&mut t).unwrap();
4236 let path = q.path_of(&t.id);
4237 let mut v: serde_json::Value =
4238 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
4239 v.as_object_mut().unwrap().remove("attachments");
4240 std::fs::write(&path, v.to_string()).unwrap();
4241 assert!(q.get(&t.id).unwrap().attachments.is_empty());
4242 }
4243
4244 #[test]
4245 fn attachment_paths_are_absolute_even_when_the_root_is_relative() {
4246 let q = Queue::at(PathBuf::from("relative-queue"));
4247 let mut t = task("rel");
4248 t.attachments.push("shot.png".to_owned());
4249 let paths = q.attachment_paths(&t);
4250 assert!(paths[0].is_absolute(), "{}", paths[0].display());
4251 assert!(paths[0].ends_with(format!("{}.attachments/shot.png", t.id)));
4252 }
4253
4254 #[test]
4255 fn link_run_adds_a_run_once_and_touches_nothing_else() {
4256 let dir = tempfile::tempdir().unwrap();
4257 let queue = Queue::at(dir.path().join("queue"));
4258 let mut t = Task::new(
4259 "t".to_owned(),
4260 "do it".to_owned(),
4261 PathBuf::from("."),
4262 Source::Human,
4263 );
4264 queue.put(&mut t).unwrap();
4265 let before = queue.get(&t.id).unwrap();
4266
4267 let linked = queue.link_run(&t.id[..4], "20260930-092817-ec34").unwrap();
4268 assert_eq!(linked.runs, vec!["20260930-092817-ec34".to_owned()]);
4269 assert_eq!(linked.status, before.status);
4270 assert_eq!(linked.attempts, before.attempts);
4271 assert_eq!(linked.interrupt, before.interrupt);
4272
4273 let again = queue.link_run(&t.id, "20260930-092817-ec34").unwrap();
4274 assert_eq!(again.runs.len(), 1, "linking twice must not duplicate");
4275 assert_eq!(queue.get(&t.id).unwrap().runs.len(), 1);
4276 assert!(queue.link_run("no-such-task", "r").is_err());
4277 }
4278
4279 #[test]
4280 fn put_keeps_a_run_linked_after_the_writer_took_its_snapshot() {
4281 let dir = tempfile::tempdir().unwrap();
4282 let queue = Queue::at(dir.path().join("queue"));
4283 let mut t = Task::new(
4284 "t".to_owned(),
4285 "do it".to_owned(),
4286 PathBuf::from("."),
4287 Source::Human,
4288 );
4289 queue.put(&mut t).unwrap();
4290 let mut snapshot = queue.get(&t.id).unwrap();
4292 queue.link_run(&t.id, "20260930-092817-ec34").unwrap();
4293
4294 snapshot.start("20260930-000000-aaaa".to_owned());
4295 queue.put(&mut snapshot).unwrap();
4296
4297 let stored = queue.get(&t.id).unwrap();
4298 assert!(stored.runs.contains(&"20260930-092817-ec34".to_owned()));
4299 assert!(stored.runs.contains(&"20260930-000000-aaaa".to_owned()));
4300 assert_eq!(stored.attempts, 1);
4301 }
4302
4303 #[test]
4304 fn concurrent_links_and_daemon_saves_lose_nothing() {
4305 let dir = tempfile::tempdir().unwrap();
4306 let queue = Queue::at(dir.path().join("queue"));
4307 let mut t = Task::new(
4308 "t".to_owned(),
4309 "do it".to_owned(),
4310 PathBuf::from("."),
4311 Source::Human,
4312 );
4313 queue.put(&mut t).unwrap();
4314 let id = t.id.clone();
4315
4316 let linkers: Vec<_> = (0..4)
4317 .map(|n| {
4318 let (queue, id) = (queue.clone(), id.clone());
4319 std::thread::spawn(move || {
4320 for k in 0..10 {
4321 queue
4322 .link_run(&id, &format!("20260930-00000{n}-l{k:03}"))
4323 .unwrap();
4324 }
4325 })
4326 })
4327 .collect();
4328 let mut mine = queue.get(&id).unwrap();
4331 for k in 0..10 {
4332 mine.start(format!("20260930-000009-d{k:03}"));
4333 queue.put(&mut mine).unwrap();
4334 }
4335 for l in linkers {
4336 l.join().unwrap();
4337 }
4338
4339 let stored = queue.get(&id).unwrap();
4340 assert_eq!(stored.runs.len(), 50, "{:?}", stored.runs);
4341 assert_eq!(
4342 stored.attempts, 10,
4343 "linking never rewinds the daemon's work"
4344 );
4345 }
4346
4347 fn age_lock(path: &Path) {
4348 let f = std::fs::OpenOptions::new().write(true).open(path).unwrap();
4349 f.set_modified(std::time::SystemTime::now() - std::time::Duration::from_secs(60))
4350 .unwrap();
4351 }
4352
4353 #[test]
4354 fn concurrent_stale_takeover_yields_one_holder() {
4355 use std::sync::atomic::{AtomicUsize, Ordering};
4356 use std::sync::{Arc, Barrier};
4357 for _ in 0..5 {
4358 let dir = tempfile::tempdir().unwrap();
4359 let q = Queue::at(dir.path().to_path_buf());
4360 let lock = dir.path().join("t.write-lock");
4361 std::fs::write(&lock, "dead-0000").unwrap();
4362 age_lock(&lock);
4363 let n = 6;
4364 let barrier = Arc::new(Barrier::new(n));
4365 let (now, max) = (Arc::new(AtomicUsize::new(0)), Arc::new(AtomicUsize::new(0)));
4366 let handles: Vec<_> = (0..n)
4367 .map(|_| {
4368 let (q, b, now, max) = (q.clone(), barrier.clone(), now.clone(), max.clone());
4369 std::thread::spawn(move || {
4370 b.wait();
4371 let g = q.lock_task("t").unwrap();
4372 let held = now.fetch_add(1, Ordering::SeqCst) + 1;
4373 max.fetch_max(held, Ordering::SeqCst);
4374 std::thread::sleep(std::time::Duration::from_millis(20));
4375 now.fetch_sub(1, Ordering::SeqCst);
4376 drop(g);
4377 })
4378 })
4379 .collect();
4380 for h in handles {
4381 h.join().unwrap();
4382 }
4383 assert_eq!(max.load(Ordering::SeqCst), 1);
4384 assert!(!lock.exists());
4385 }
4386 }
4387
4388 #[test]
4389 fn dropping_a_stolen_lock_leaves_the_new_holders_lock() {
4390 let dir = tempfile::tempdir().unwrap();
4391 let q = Queue::at(dir.path().to_path_buf());
4392 let lock = dir.path().join("t.write-lock");
4393 let a = q.lock_task("t").unwrap();
4394 age_lock(&lock);
4395 let b = q.lock_task("t").unwrap();
4396 assert_ne!(a.token, b.token);
4397 drop(a);
4398 assert_eq!(std::fs::read_to_string(&lock).unwrap(), b.token);
4399 drop(b);
4400 assert!(!lock.exists());
4401 }
4402
4403 #[test]
4404 fn break_stale_leaves_a_lock_that_replaced_the_one_judged() {
4405 let dir = tempfile::tempdir().unwrap();
4406 let lock = dir.path().join("t.write-lock");
4407 std::fs::write(&lock, "new-token").unwrap();
4408 assert!(!break_stale(&lock, "old-token"));
4409 assert_eq!(std::fs::read_to_string(&lock).unwrap(), "new-token");
4410 assert!(!break_stale(&lock, "new-token"));
4412 assert!(lock.exists());
4413 age_lock(&lock);
4414 assert!(break_stale(&lock, "new-token"));
4415 assert!(!lock.exists());
4416 }
4417
4418 #[test]
4419 fn a_stale_break_marker_is_recovered_and_a_fresh_one_is_respected() {
4420 let dir = tempfile::tempdir().unwrap();
4421 let lock = dir.path().join("t.write-lock");
4422 std::fs::write(&lock, "dead-1").unwrap();
4423 age_lock(&lock);
4424 let marker = break_marker(&lock, "dead-1");
4425 std::fs::write(&marker, "crashed-remover").unwrap();
4426 assert!(!break_stale(&lock, "dead-1"));
4428 assert!(lock.exists());
4429 age_lock(&marker);
4431 assert!(break_stale(&lock, "dead-1"));
4432 assert!(!lock.exists());
4433 assert!(!marker.exists());
4434 }
4435
4436 fn chat_task() -> Task {
4437 Task::new(
4438 "t".to_owned(),
4439 "do t".to_owned(),
4440 PathBuf::from("."),
4441 Source::Agent {
4442 run: "20260902-000000-beef".to_owned(),
4443 node: CHAT_NODE.to_owned(),
4444 },
4445 )
4446 }
4447
4448 #[test]
4449 fn a_chat_notice_is_due_only_for_a_final_state_and_only_once() {
4450 let mut t = chat_task();
4451 assert_eq!(t.chat_notice_due(), None, "queued");
4452 t.start("r1".to_owned());
4453 assert_eq!(t.chat_notice_due(), None, "running");
4454 t.fail("boom", 3);
4455 assert_eq!(t.status, TaskStatus::Failed);
4456 assert_eq!(t.chat_notice_due(), None, "a retried failure is not final");
4457 t.start("r2".to_owned());
4458 t.park("upgrade");
4459 assert_eq!(t.chat_notice_due(), None, "parked");
4460 t.fail("boom", 1);
4461 assert_eq!(t.status, TaskStatus::Held);
4462 let key = t
4463 .chat_notice_due()
4464 .expect("held on the last attempt is final");
4465 t.chat_notice = Some(ChatNotice {
4466 key,
4467 at: Timestamp::now(),
4468 outcome: ChatNoticeOutcome::Posted,
4469 });
4470 assert_eq!(t.chat_notice_due(), None, "same hold, no second notice");
4471 t.release();
4472 assert_eq!(t.chat_notice_due(), None, "requeued after a release");
4473 t.start("r3".to_owned());
4474 t.succeed();
4475 assert!(t.chat_notice_due().is_some(), "done is a new final state");
4476 }
4477
4478 #[test]
4479 fn completed_chat_notices_keep_current_and_legacy_keys_after_requeue() {
4480 for key in ["done", "done#0", "done#1", "done#12"] {
4481 let mut t = chat_task();
4482 t.start("r1".to_owned());
4483 t.succeed();
4484 t.chat_notice = Some(ChatNotice {
4485 key: key.to_owned(),
4486 at: Timestamp::now(),
4487 outcome: ChatNoticeOutcome::Posted,
4488 });
4489 t.requeue();
4490 t.start("r2".to_owned());
4491 t.succeed();
4492 assert_eq!(t.chat_notice_key().as_deref(), Some("done"));
4493 assert_eq!(t.chat_notice_due(), None, "recorded key: {key}");
4494 }
4495 }
4496
4497 #[test]
4498 fn a_new_hold_after_a_release_is_a_new_cause() {
4499 let mut t = chat_task();
4500 t.hold_manual(Some("wait".to_owned()));
4501 let first = t.chat_notice_due().expect("held");
4502 t.chat_notice = Some(ChatNotice {
4503 key: first.clone(),
4504 at: Timestamp::now(),
4505 outcome: ChatNoticeOutcome::Posted,
4506 });
4507 t.hold_manual(Some("still waiting".to_owned()));
4508 assert_eq!(t.chat_notice_due(), None, "a re-hold keeps its start");
4509 t.release();
4510 std::thread::sleep(std::time::Duration::from_millis(5));
4511 t.hold_manual(None);
4512 let second = t.chat_notice_due().expect("a fresh hold");
4513 assert_ne!(first, second);
4514 }
4515
4516 #[test]
4517 fn a_chat_notice_needs_a_chat_source_and_a_current_schema() {
4518 let mut human = task("h");
4519 human.succeed();
4520 assert_eq!(human.chat_notice_due(), None);
4521 let mut inherited = task("i");
4523 inherited.origin_chat = Some("20260902-000000-beef".to_owned());
4524 inherited.succeed();
4525 assert_eq!(inherited.chat_notice_due(), None);
4526 let mut old = chat_task();
4527 old.schema = 12;
4528 old.status = TaskStatus::Done;
4529 assert_eq!(old.chat_notice_due(), None, "history is not replayed");
4530 let mut live = chat_task();
4531 live.schema = 12;
4532 live.start("r1".to_owned());
4533 live.succeed();
4534 assert!(live.chat_notice_due().is_some(), "started by this build");
4535 let mut held = chat_task();
4536 held.schema = 12;
4537 held.hold_machine(Some("config".to_owned()));
4538 assert!(held.chat_notice_due().is_some(), "held before any start");
4539 }
4540
4541 #[test]
4542 fn a_stale_snapshot_does_not_rewind_a_recorded_chat_notice() {
4543 let tmp = tempfile::tempdir().expect("tempdir");
4544 let q = Queue::at(tmp.path().join("queue"));
4545 let mut t = chat_task();
4546 t.succeed();
4547 q.put(&mut t).expect("put");
4548 let mut stale = q.get(&t.id).expect("get");
4549 let key = t.chat_notice_due().expect("due");
4550 q.modify(&t.id, |x| {
4551 x.chat_notice = Some(ChatNotice {
4552 key: key.clone(),
4553 at: Timestamp::now(),
4554 outcome: ChatNoticeOutcome::Posted,
4555 });
4556 true
4557 })
4558 .expect("modify");
4559 q.put(&mut stale).expect("stale put");
4560 assert_eq!(q.get(&t.id).expect("get").chat_notice_due(), None);
4561 }
4562}