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