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 = 14;
118
119#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
121#[serde(rename_all = "lowercase")]
122pub enum HoldSource {
123 Manual,
125 Machine,
127}
128
129impl HoldSource {
130 pub fn label(self) -> &'static str {
132 match self {
133 Self::Manual => "manual",
134 Self::Machine => "machine",
135 }
136 }
137}
138
139#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
142#[serde(tag = "kind", rename_all = "lowercase")]
143pub enum Source {
144 Human,
146 Agent {
149 run: String,
151 node: String,
153 },
154 Issue {
156 number: u64,
158 repo: String,
160 },
161}
162
163pub const CHAT_NODE: &str = "chat";
166
167impl Source {
168 pub fn label(&self) -> String {
170 match self {
171 Self::Human => "human".to_owned(),
172 Self::Agent { run, node } => format!("{node}@{}", short(run)),
173 Self::Issue { number, .. } => format!("issue #{number}"),
174 }
175 }
176}
177
178#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
180#[serde(rename_all = "lowercase")]
181pub enum TaskStatus {
182 Queued,
184 Running,
186 Done,
188 Failed,
190 Held,
192 Blocked,
196 Parked,
201}
202
203impl TaskStatus {
204 pub fn runnable(self) -> bool {
206 matches!(self, Self::Queued | Self::Failed | Self::Parked)
207 }
208
209 pub fn as_str(self) -> &'static str {
211 match self {
212 Self::Queued => "queued",
213 Self::Running => "running",
214 Self::Done => "done",
215 Self::Failed => "failed",
216 Self::Held => "held",
217 Self::Blocked => "blocked",
218 Self::Parked => "parked",
219 }
220 }
221}
222
223#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
226pub struct TaskCounts {
227 pub queued: usize,
229 pub running: usize,
231 pub done: usize,
233 pub failed: usize,
235 pub held: usize,
237 pub blocked: usize,
239 pub parked: usize,
241}
242
243impl TaskCounts {
244 pub fn of(tasks: &[Task]) -> Self {
248 let mut counts = Self::default();
249 for t in tasks {
250 match t.status {
251 TaskStatus::Queued => counts.queued += 1,
252 TaskStatus::Running => counts.running += 1,
253 TaskStatus::Done => counts.done += 1,
254 TaskStatus::Failed => counts.failed += 1,
255 TaskStatus::Held => counts.held += 1,
256 TaskStatus::Blocked => counts.blocked += 1,
257 TaskStatus::Parked => counts.parked += 1,
258 }
259 }
260 counts
261 }
262}
263
264#[derive(Debug, Clone, Serialize, Deserialize)]
266#[serde(deny_unknown_fields)]
267pub struct Task {
268 pub schema: u32,
270 pub id: String,
272 pub title: String,
274 pub instruction: String,
276 pub repo: PathBuf,
278 pub source: Source,
280 #[serde(default)]
282 pub priority: i32,
283 #[serde(default)]
294 pub solo: bool,
295 pub status: TaskStatus,
297 #[serde(default)]
299 pub attempts: usize,
300 #[serde(default)]
302 pub runs: Vec<String>,
303 #[serde(default)]
305 pub last_error: Option<String>,
306 #[serde(default)]
319 pub hold_reason: Option<String>,
320 #[serde(default)]
323 pub hold_source: Option<HoldSource>,
324 #[serde(default)]
336 pub diagnostic: Option<String>,
337 #[serde(default)]
347 pub blocked_by: Vec<String>,
348 #[serde(default)]
351 pub block_reason: Option<String>,
352 #[serde(default)]
365 pub blocked_from: Option<TaskStatus>,
366 #[serde(default)]
377 pub answers: Vec<AnsweredQuestion>,
378 #[serde(default)]
384 pub triage_applied: Vec<String>,
385 #[serde(default)]
391 pub actions_applied: Vec<String>,
392 #[serde(default)]
397 pub resume_override: Option<OperatorResume>,
398 #[serde(default)]
410 pub review_branch: Option<String>,
411 #[serde(default)]
414 pub fresh_start: bool,
415 #[serde(default)]
426 pub interrupt: bool,
427 #[serde(default)]
450 pub urgent: bool,
451 #[serde(default)]
457 pub attachments: Vec<String>,
458 #[serde(default)]
462 pub followup: Option<FollowUp>,
463 #[serde(default)]
469 pub origin_chat: Option<String>,
470 #[serde(default)]
477 pub overrides: Option<RunOverrides>,
478 #[serde(default)]
485 pub review_of: Option<String>,
486 #[serde(default)]
491 pub held_at: Option<Timestamp>,
492 #[serde(default)]
496 pub park_reason: Option<String>,
497 #[serde(default)]
500 pub chat_notice: Option<ChatNotice>,
501 pub created_at: Timestamp,
503 pub updated_at: Timestamp,
505}
506
507pub(crate) fn is_done_notice_key(key: &str) -> bool {
509 key == "done"
510 || key
511 .strip_prefix("done#")
512 .is_some_and(|count| !count.is_empty() && count.bytes().all(|b| b.is_ascii_digit()))
513}
514
515#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
517#[serde(rename_all = "lowercase")]
518pub enum ChatNoticeOutcome {
519 Posted,
521 Skipped,
523}
524
525#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
527pub struct ChatNotice {
528 pub key: String,
530 pub at: Timestamp,
532 pub outcome: ChatNoticeOutcome,
534}
535
536#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
538pub struct RunOverrides {
539 #[serde(default)]
542 pub inherit_daemon_options: bool,
543 #[serde(default)]
545 pub merge: Option<String>,
546 #[serde(default)]
548 pub candidates: Option<usize>,
549 #[serde(default)]
551 pub judges: Option<usize>,
552 #[serde(default)]
554 pub reviewers: Option<usize>,
555 #[serde(default)]
557 pub review_rounds: Option<usize>,
558 #[serde(default)]
560 pub seed: Option<u64>,
561 #[serde(default)]
563 pub config: Option<PathBuf>,
564}
565
566impl RunOverrides {
567 pub fn merge_over(&mut self, other: &RunOverrides) {
569 macro_rules! take {
570 ($($f:ident),*) => {$(
571 if other.$f.is_some() {
572 self.$f.clone_from(&other.$f);
573 }
574 )*};
575 }
576 take!(
577 merge,
578 candidates,
579 judges,
580 reviewers,
581 review_rounds,
582 seed,
583 config
584 );
585 }
586
587 pub fn apply(&self, config: &mut crate::config::Config) {
589 if let Some(n) = self.candidates {
590 config.graph.implementers = n;
591 }
592 if let Some(n) = self.judges {
593 config.graph.judges = n;
594 }
595 if let Some(n) = self.reviewers {
596 config.graph.reviewers = n;
597 }
598 if let Some(n) = self.review_rounds {
599 config.graph.review_rounds = n;
600 }
601 if let Some(m) = &self.merge
602 && let Ok(mode) = crate::daemon::merge_mode(m)
603 {
604 config.merge.mode = mode;
605 }
606 if let Some(s) = self.seed {
607 config.blind.seed = Some(s);
608 }
609 }
610}
611
612#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
614pub struct FollowUp {
615 pub run: String,
617 #[serde(default)]
619 pub origin_task: Option<String>,
620 pub pr: String,
622 pub findings: Vec<String>,
624 pub generation: u32,
627}
628
629#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
632pub struct OperatorResume {
633 pub question_id: String,
635 pub at: Timestamp,
637 #[serde(default)]
640 pub conductor_rehold: Option<String>,
641 #[serde(default)]
644 pub forced: bool,
645 #[serde(default)]
652 pub pinned_run: Option<String>,
653}
654
655#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
658pub struct AnsweredQuestion {
659 pub question: String,
661 pub answer: String,
663}
664
665impl Task {
666 pub fn new(title: String, instruction: String, repo: PathBuf, source: Source) -> Self {
668 let now = Timestamp::now();
669 let origin_chat = match &source {
670 Source::Agent { run, node } if node == CHAT_NODE => Some(run.clone()),
671 _ => None,
672 };
673 Self {
674 schema: SCHEMA,
675 id: new_id(),
676 title,
677 instruction,
678 repo,
679 source,
680 priority: 0,
681 solo: false,
682 status: TaskStatus::Queued,
683 attempts: 0,
684 runs: Vec::new(),
685 last_error: None,
686 hold_reason: None,
687 hold_source: None,
688 diagnostic: None,
689 blocked_by: Vec::new(),
690 block_reason: None,
691 blocked_from: None,
692 answers: Vec::new(),
693 triage_applied: Vec::new(),
694 actions_applied: Vec::new(),
695 resume_override: None,
696 review_branch: None,
697 fresh_start: false,
698 interrupt: false,
699 urgent: false,
700 attachments: Vec::new(),
701 followup: None,
702 origin_chat,
703 overrides: None,
704 review_of: None,
705 held_at: None,
706 park_reason: None,
707 chat_notice: None,
708 created_at: now,
709 updated_at: now,
710 }
711 }
712
713 pub fn chat_talk(&self) -> Option<&str> {
716 if let Some(id) = &self.origin_chat {
717 return Some(id);
718 }
719 match &self.source {
720 Source::Agent { run, node } if node == CHAT_NODE => Some(run),
721 _ => None,
722 }
723 }
724
725 pub fn filed_by_chat(&self) -> Option<&str> {
729 match &self.source {
730 Source::Agent { run, node } if node == CHAT_NODE => Some(run),
731 _ => None,
732 }
733 }
734
735 pub fn chat_notice_key(&self) -> Option<String> {
741 match self.status {
742 TaskStatus::Done => Some("done".to_owned()),
743 TaskStatus::Held => Some(format!(
744 "held#{}",
745 self.held_at.map(|t| t.to_string()).unwrap_or_default()
746 )),
747 TaskStatus::Blocked => Some(format!(
748 "blocked#{}#{}",
749 self.blocked_by.join(","),
750 self.block_reason.as_deref().unwrap_or_default()
751 )),
752 _ => None,
753 }
754 }
755
756 pub fn chat_notice_due(&self) -> Option<String> {
760 self.filed_by_chat()?;
761 if self.schema < 13 {
762 return None;
763 }
764 let key = self.chat_notice_key()?;
765 match &self.chat_notice {
766 Some(n) if n.key == key || (key == "done" && is_done_notice_key(&n.key)) => None,
767 _ => Some(key),
768 }
769 }
770
771 pub fn short(&self) -> &str {
773 short(&self.id)
774 }
775
776 pub fn mark_triage_applied(&mut self, question_id: &str) {
778 if !self.triage_applied(question_id) {
779 self.triage_applied.push(question_id.to_owned());
780 }
781 }
782
783 pub fn action_applied(&self, question_id: &str) -> bool {
786 self.actions_applied.iter().any(|id| id == question_id)
787 }
788
789 pub fn mark_action_applied(&mut self, question_id: &str) {
791 if !self.action_applied(question_id) {
792 self.actions_applied.push(question_id.to_owned());
793 }
794 }
795
796 pub fn triage_applied(&self, question_id: &str) -> bool {
798 self.triage_applied.iter().any(|id| id == question_id)
799 }
800
801 pub fn start(&mut self, run: String) {
812 self.stamp_schema();
813 self.status = TaskStatus::Running;
814 self.attempts += 1;
815 self.runs.push(run);
816 self.last_error = None;
817 self.park_reason = None;
818 self.fresh_start = false;
819 self.interrupt = false;
820 self.resume_override = None;
822 }
823
824 pub fn link_run(&mut self, run: &str) -> bool {
829 if self.runs.iter().any(|r| r == run) {
830 return false;
831 }
832 self.runs.push(run.to_owned());
833 true
834 }
835
836 pub fn succeed(&mut self) {
846 self.stamp_schema();
847 self.status = TaskStatus::Done;
848 self.resume_override = None;
849 self.last_error = None;
850 self.park_reason = None;
851 self.hold_reason = None;
852 self.hold_source = None;
853 self.diagnostic = None;
854 self.blocked_by.clear();
855 self.block_reason = None;
856 self.blocked_from = None;
857 }
858
859 pub fn already_landed(&mut self, note: impl Into<String>) {
867 self.succeed();
868 self.attempts = self.attempts.saturating_sub(1);
869 self.last_error = Some(note.into());
870 }
871
872 pub fn superseded_attempts(&self, last_run_succeeded: bool) -> &[String] {
902 if self.status != TaskStatus::Done || !last_run_succeeded || self.runs.len() < 2 {
903 return &[];
904 }
905 &self.runs[..self.runs.len() - 1]
906 }
907
908 pub fn successor_of(&self, run: &str) -> Option<&String> {
915 let pos = self.runs.iter().position(|r| r == run)?;
916 self.runs.get(pos + 1)
917 }
918
919 pub fn earlier_attempts(&self) -> &[String] {
926 &self.runs
927 }
928
929 fn stamp_schema(&mut self) {
936 self.schema = self.schema.max(SCHEMA);
937 }
938
939 fn note_held(&mut self) {
940 self.stamp_schema();
941 if self.status != TaskStatus::Held || self.held_at.is_none() {
942 self.held_at = Some(Timestamp::now());
943 }
944 }
945
946 pub fn fail(&mut self, why: impl Into<String>, max_attempts: usize) {
962 let why = why.into();
963 self.diagnostic = None;
964 self.park_reason = None;
965 self.status = if self.attempts >= max_attempts {
966 self.note_held();
967 self.hold_source = Some(HoldSource::Machine);
968 self.hold_reason = Some(why.clone());
969 TaskStatus::Held
970 } else {
971 TaskStatus::Failed
972 };
973 self.last_error = Some(why);
974 }
975
976 pub fn stall(&mut self, why: impl Into<String>) {
986 self.last_error = Some(why.into());
987 self.diagnostic = None;
988 self.attempts = self.attempts.saturating_sub(1);
989 self.status = TaskStatus::Failed;
990 }
991
992 pub fn park(&mut self, why: impl Into<String>) {
998 self.diagnostic = None;
999 self.attempts = self.attempts.saturating_sub(1);
1000 self.status = TaskStatus::Parked;
1001 self.park_reason = Some(why.into());
1002 self.schema = self.schema.max(SCHEMA);
1005 }
1006
1007 pub fn operator_held(&self) -> bool {
1013 self.status == TaskStatus::Held && !matches!(self.hold_source, Some(HoldSource::Machine))
1014 }
1015
1016 pub fn hold_manual(&mut self, reason: Option<String>) {
1026 self.park_reason = None;
1027 self.note_held();
1028 self.status = TaskStatus::Held;
1029 if reason.is_some() {
1030 self.hold_reason = reason;
1031 }
1032 self.hold_source = Some(HoldSource::Manual);
1033 self.blocked_by.clear();
1034 self.block_reason = None;
1035 self.blocked_from = None;
1036 }
1037
1038 pub fn hold_machine(&mut self, reason: Option<String>) {
1043 self.park_reason = None;
1044 self.note_held();
1045 self.status = TaskStatus::Held;
1046 if reason.is_some() {
1047 self.hold_reason = reason;
1048 }
1049 self.hold_source = Some(HoldSource::Machine);
1050 self.blocked_by.clear();
1051 self.block_reason = None;
1052 self.blocked_from = None;
1053 }
1054
1055 pub fn block(&mut self, blocked_by: Vec<String>, reason: Option<String>) {
1065 self.stamp_schema();
1066 if self.status != TaskStatus::Blocked {
1067 self.blocked_from = Some(self.status);
1068 }
1069 self.status = TaskStatus::Blocked;
1070 self.blocked_by = blocked_by;
1071 self.block_reason = reason;
1072 }
1073
1074 pub fn unblock(&mut self, resolved_id: &str) {
1097 if self.status != TaskStatus::Blocked {
1098 return;
1099 }
1100 self.blocked_by.retain(|id| id != resolved_id);
1101 self.restore_if_unblocked();
1102 }
1103
1104 pub fn dependency_deleted(&mut self, deleted_id: &str) -> bool {
1115 if self.status != TaskStatus::Blocked || !self.blocked_by.iter().any(|b| b == deleted_id) {
1116 return false;
1117 }
1118 self.blocked_by.retain(|id| id != deleted_id);
1119 self.restore_if_unblocked();
1120 true
1121 }
1122
1123 fn restore_if_unblocked(&mut self) {
1127 if self.blocked_by.is_empty() {
1128 self.status = match self.blocked_from {
1129 Some(TaskStatus::Running) => TaskStatus::Queued,
1130 Some(other) => other,
1131 None if self.hold_reason.is_some() || self.hold_source.is_some() => {
1132 TaskStatus::Held
1133 }
1134 None => TaskStatus::Queued,
1135 };
1136 self.block_reason = None;
1137 self.blocked_from = None;
1138 }
1139 }
1140
1141 pub fn record_answer(&mut self, question: String, answer: String) {
1146 self.answers.push(AnsweredQuestion { question, answer });
1147 }
1148
1149 pub fn request_review(&mut self, branch: String) {
1153 self.release();
1154 self.review_branch = Some(branch);
1155 }
1156
1157 pub fn requeue(&mut self) {
1160 self.release();
1161 self.review_branch = None;
1163 self.fresh_start = true;
1164 }
1165
1166 pub fn hold_for_handover(&mut self, branch: Option<String>, reason: String) {
1175 if branch.is_some() {
1176 self.review_branch = branch;
1177 }
1178 self.hold_machine(Some(reason));
1179 }
1180
1181 pub fn set_priority(&mut self, priority: i32) -> Result<()> {
1190 if self.status == TaskStatus::Running {
1191 bail!(
1192 "task {} is running; its priority cannot be changed until \
1193 this attempt finishes",
1194 self.short()
1195 );
1196 }
1197 self.priority = priority;
1198 Ok(())
1199 }
1200
1201 pub fn set_interrupt(&mut self, interrupt: bool) -> Result<()> {
1216 if interrupt && !self.status.runnable() {
1217 bail!(
1218 "task {} is {}; only a queued or failed task can be marked \
1219 to interrupt",
1220 self.short(),
1221 self.status.as_str()
1222 );
1223 }
1224 self.interrupt = interrupt;
1225 Ok(())
1226 }
1227
1228 pub fn implementers(&self) -> Option<usize> {
1230 if self.solo {
1231 Some(1)
1232 } else {
1233 self.overrides.as_ref().and_then(|o| o.candidates)
1234 }
1235 }
1236
1237 pub fn change_implementers(&mut self, count: u8) -> Result<()> {
1240 self.check_implementers_editable()?;
1241 let explicit = self.overrides.as_ref().and_then(|o| o.config.as_deref());
1242 let (mut config, _) = crate::config::Config::discover(&self.repo, explicit)?;
1243 if let Some(o) = &self.overrides {
1244 o.apply(&mut config);
1245 }
1246 self.set_implementers(count, &config)
1247 }
1248
1249 fn check_implementers_editable(&self) -> Result<()> {
1250 if !matches!(self.status, TaskStatus::Queued | TaskStatus::Held) {
1251 bail!(
1252 "task {} is {}; only queued or held tasks can change implementers",
1253 self.short(),
1254 self.status.as_str()
1255 );
1256 }
1257 Ok(())
1258 }
1259
1260 pub fn set_implementers(&mut self, count: u8, config: &crate::config::Config) -> Result<()> {
1262 self.check_implementers_editable()?;
1263 crate::talk::check_implementers(count, config).map_err(anyhow::Error::msg)?;
1264 self.stamp_schema();
1265 self.solo = count == 1;
1266 let mut overrides = self.overrides.clone().unwrap_or_else(|| RunOverrides {
1267 inherit_daemon_options: true,
1268 ..Default::default()
1269 });
1270 overrides.candidates = (count > 1).then_some(usize::from(count));
1271 let mut settings = overrides.clone();
1272 settings.inherit_daemon_options = false;
1273 self.overrides = if overrides.inherit_daemon_options && settings == RunOverrides::default()
1275 {
1276 None
1277 } else {
1278 Some(overrides)
1279 };
1280 Ok(())
1281 }
1282
1283 pub fn edit(&mut self, title: String, instruction: String) -> Result<()> {
1295 if !matches!(self.status, TaskStatus::Queued | TaskStatus::Held) {
1296 bail!(
1297 "task {} is {}; only a queued or held task's instruction can \
1298 be edited",
1299 self.short(),
1300 self.status.as_str()
1301 );
1302 }
1303 self.title = title;
1304 self.instruction = instruction;
1305 Ok(())
1306 }
1307
1308 pub fn handed_off(&mut self, why: impl Into<String>) {
1328 let why = why.into();
1329 self.diagnostic = None;
1330 self.note_held();
1331 self.status = TaskStatus::Held;
1332 self.hold_source = Some(HoldSource::Machine);
1333 self.hold_reason = Some(why.clone());
1334 self.last_error = Some(why);
1335 }
1336
1337 pub fn release(&mut self) {
1341 let refused_handover = self.status == TaskStatus::Held
1342 && self.hold_source == Some(HoldSource::Machine)
1343 && self.review_branch.is_some();
1344 self.status = TaskStatus::Queued;
1345 self.held_at = None;
1346 self.attempts = 0;
1347 self.last_error = None;
1348 self.park_reason = None;
1349 self.hold_reason = None;
1352 self.hold_source = None;
1353 self.diagnostic = None;
1354 self.blocked_by.clear();
1359 self.block_reason = None;
1360 self.blocked_from = None;
1361 if !refused_handover {
1365 self.review_branch = None;
1366 }
1367 self.fresh_start = false;
1368 }
1369}
1370
1371fn copy_new(dir: &Path, src: &Path, name: &str) -> Result<(String, PathBuf)> {
1375 use std::io::ErrorKind;
1376 let (stem, ext) = match name.rfind('.') {
1377 Some(i) if i > 0 => (&name[..i], &name[i..]),
1378 _ => (name, ""),
1379 };
1380 for n in 1u32.. {
1381 let candidate = if n == 1 {
1382 name.to_owned()
1383 } else {
1384 let suffix = format!("-{n}");
1385 let room = 64usize.saturating_sub(suffix.len() + ext.len());
1386 let stem: String = stem.chars().take(room).collect();
1387 format!("{stem}{suffix}{ext}")
1388 };
1389 if !crate::ask::valid_asset_name(&candidate) {
1390 bail!("no valid attachment name is left for `{name}`");
1391 }
1392 let path = dir.join(&candidate);
1393 match std::fs::OpenOptions::new()
1394 .write(true)
1395 .create_new(true)
1396 .open(&path)
1397 {
1398 Ok(mut out) => {
1399 let copied = std::fs::File::open(src)
1400 .and_then(|mut input| std::io::copy(&mut input, &mut out));
1401 if let Err(e) = copied {
1402 drop(out);
1403 let _ = std::fs::remove_file(&path);
1404 return Err(e).with_context(|| format!("copy {}", src.display()));
1405 }
1406 return Ok((candidate, path));
1407 }
1408 Err(e) if e.kind() == ErrorKind::AlreadyExists => continue,
1409 Err(e) => return Err(e).with_context(|| format!("create {}", path.display())),
1410 }
1411 }
1412 unreachable!("the counter never runs out")
1413}
1414
1415const TASK_LOCK_STALE: std::time::Duration = std::time::Duration::from_secs(10);
1418
1419struct TaskLock {
1422 path: PathBuf,
1423 token: String,
1424}
1425
1426fn owner_token() -> String {
1429 static COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
1430 let n = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1431 let mut r = crate::rng::SplitMix64::new(crate::rng::entropy() ^ n.rotate_left(32));
1432 format!("{}-{:016x}", std::process::id(), r.next_u64())
1433}
1434
1435fn break_marker(path: &Path, token: &str) -> PathBuf {
1438 let mut h: u64 = 0xcbf29ce484222325;
1439 for b in token.bytes() {
1440 h ^= u64::from(b);
1441 h = h.wrapping_mul(0x100000001b3);
1442 }
1443 let mut name = path.as_os_str().to_owned();
1444 name.push(format!(".break-{h:016x}"));
1445 PathBuf::from(name)
1446}
1447
1448fn older_than_stale(path: &Path) -> bool {
1449 std::fs::metadata(path)
1450 .and_then(|m| m.modified())
1451 .ok()
1452 .and_then(|t| t.elapsed().ok())
1453 .is_some_and(|age| age > TASK_LOCK_STALE)
1454}
1455
1456const MAX_MARKER_DEPTH: u8 = 3;
1460
1461fn take_marker(path: &Path, token: &str, depth: u8) -> Option<(PathBuf, String)> {
1467 use std::io::Write;
1468 let marker = break_marker(path, token);
1469 for _ in 0..2 {
1470 match std::fs::OpenOptions::new()
1471 .write(true)
1472 .create_new(true)
1473 .open(&marker)
1474 {
1475 Ok(mut f) => {
1476 let mine = owner_token();
1477 if f.write_all(mine.as_bytes()).is_err() {
1478 drop(f);
1479 let _ = std::fs::remove_file(&marker);
1480 return None;
1481 }
1482 return Some((marker, mine));
1483 }
1484 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
1485 let judged = std::fs::read_to_string(&marker).ok();
1486 match judged.filter(|_| older_than_stale(&marker)) {
1487 Some(judged) if depth < MAX_MARKER_DEPTH => {
1488 remove_lock_if(&marker, &judged, true, depth + 1)?;
1489 }
1490 _ => return None,
1491 }
1492 }
1493 Err(_) => return None,
1494 }
1495 }
1496 None
1497}
1498
1499fn remove_lock_if(path: &Path, token: &str, require_stale: bool, depth: u8) -> Option<bool> {
1504 let (marker, mine) = take_marker(path, token, depth)?;
1505 let still = std::fs::read_to_string(path).is_ok_and(|c| c == token)
1506 && (!require_stale || older_than_stale(path));
1507 if still {
1508 let _ = std::fs::remove_file(path);
1509 }
1510 if depth >= MAX_MARKER_DEPTH {
1512 if std::fs::read_to_string(&marker).is_ok_and(|c| c == mine) {
1513 let _ = std::fs::remove_file(&marker);
1514 }
1515 } else {
1516 let _ = remove_lock_if(&marker, &mine, false, depth + 1);
1517 }
1518 Some(still)
1519}
1520
1521fn break_stale(path: &Path, judged: &str) -> bool {
1524 remove_lock_if(path, judged, true, 0).unwrap_or(false)
1525}
1526
1527impl Drop for TaskLock {
1528 fn drop(&mut self) {
1529 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
1530 loop {
1531 match remove_lock_if(&self.path, &self.token, false, 0) {
1532 Some(_) => return,
1533 None if std::time::Instant::now() > deadline => return,
1534 None => std::thread::sleep(std::time::Duration::from_millis(5)),
1535 }
1536 }
1537 }
1538}
1539
1540#[derive(Debug, Clone)]
1542pub struct Queue {
1543 root: PathBuf,
1544}
1545
1546impl Queue {
1547 pub fn open() -> Self {
1549 Self::at(crate::run::home().join("queue"))
1550 }
1551
1552 pub fn at(root: PathBuf) -> Self {
1555 Self { root }
1556 }
1557
1558 pub fn root(&self) -> &Path {
1560 &self.root
1561 }
1562
1563 pub fn path_of(&self, id: &str) -> PathBuf {
1565 self.root.join(format!("{id}.json"))
1566 }
1567
1568 pub fn attachments_dir(&self, id: &str) -> PathBuf {
1571 self.root.join(format!("{id}.attachments"))
1572 }
1573
1574 pub fn attach(&self, task: &mut Task, sources: &[PathBuf]) -> Result<Vec<String>> {
1585 let mut wanted = Vec::new();
1586 for src in sources {
1587 let name = src
1588 .file_name()
1589 .and_then(|n| n.to_str())
1590 .with_context(|| format!("`{}` has no usable file name", src.display()))?;
1591 if !crate::ask::valid_asset_name(name) {
1592 bail!(
1593 "attachment name `{name}` must match ^[A-Za-z0-9][A-Za-z0-9._-]{{0,63}}$ \
1594 with no `..`; rename the file and try again"
1595 );
1596 }
1597 if !src.is_file() {
1598 bail!("attachment `{}` is not a file", src.display());
1599 }
1600 wanted.push((src, name));
1601 }
1602 let dir = self.attachments_dir(&task.id);
1603 let existed = dir.is_dir();
1604 std::fs::create_dir_all(&dir).with_context(|| format!("create {}", dir.display()))?;
1605 let mut created: Vec<PathBuf> = Vec::new();
1606 let mut names = Vec::new();
1607 let mut copy_all = || -> Result<()> {
1608 for (src, name) in &wanted {
1609 let (stored, path) = copy_new(&dir, src, name)?;
1610 created.push(path);
1611 names.push(stored);
1612 }
1613 Ok(())
1614 };
1615 if let Err(e) = copy_all() {
1616 for path in &created {
1617 let _ = std::fs::remove_file(path);
1618 }
1619 if !existed {
1620 let _ = std::fs::remove_dir(&dir);
1621 }
1622 return Err(e);
1623 }
1624 task.attachments.extend(names.iter().cloned());
1625 Ok(names)
1626 }
1627
1628 pub fn attach_and_put(&self, task: &mut Task, sources: &[PathBuf]) -> Result<Vec<String>> {
1639 let dir = self.attachments_dir(&task.id);
1640 let existed = dir.is_dir();
1641 let before = task.attachments.len();
1642 let names = self.attach(task, sources)?;
1643 if let Err(e) = self.put(task) {
1644 for name in &names {
1645 let _ = std::fs::remove_file(dir.join(name));
1646 }
1647 if !existed {
1648 let _ = std::fs::remove_dir(&dir);
1649 }
1650 task.attachments.truncate(before);
1651 return Err(e);
1652 }
1653 Ok(names)
1654 }
1655
1656 pub fn attachment_paths(&self, task: &Task) -> Vec<PathBuf> {
1660 let dir = self.attachments_dir(&task.id);
1661 task.attachments
1662 .iter()
1663 .map(|n| {
1664 let p = dir.join(n);
1665 std::path::absolute(&p).unwrap_or(p)
1666 })
1667 .collect()
1668 }
1669
1670 pub fn put(&self, task: &mut Task) -> Result<()> {
1679 let _lock = self.lock_task(&task.id)?;
1680 self.put_unlocked(task)
1681 }
1682
1683 pub fn create_new(&self, task: &mut Task) -> Result<bool> {
1688 let _lock = self.lock_task(&task.id)?;
1689 if self.path_of(&task.id).exists() {
1690 return Ok(false);
1691 }
1692 self.put_unlocked(task)?;
1693 Ok(true)
1694 }
1695
1696 fn put_unlocked(&self, task: &mut Task) -> Result<()> {
1698 if let Ok(stored) = read_path(&self.path_of(&task.id)) {
1699 for run in stored.runs {
1700 if !task.runs.contains(&run) {
1701 task.runs.push(run);
1702 }
1703 }
1704 if let Some(n) = stored.chat_notice
1707 && task.chat_notice.as_ref().is_none_or(|mine| mine.at < n.at)
1708 {
1709 task.chat_notice = Some(n);
1710 }
1711 }
1712 task.updated_at = Timestamp::now();
1713 std::fs::create_dir_all(&self.root)
1714 .with_context(|| format!("create {}", self.root.display()))?;
1715 let body = serde_json::to_string_pretty(task).context("serialize task")?;
1716 let path = self.path_of(&task.id);
1717 let tmp = path.with_extension("json.tmp");
1718 std::fs::write(&tmp, &body).with_context(|| format!("write {}", tmp.display()))?;
1719 std::fs::rename(&tmp, &path).with_context(|| format!("replace {}", path.display()))?;
1720 if let (Some(notice), Some(home)) = (
1725 crate::notices::task_held(task),
1726 self.root.parent().filter(|p| !p.as_os_str().is_empty()),
1727 ) {
1728 crate::notices::raise_in(home, notice);
1729 }
1730 Ok(())
1731 }
1732
1733 pub fn modify(&self, id: &str, f: impl FnOnce(&mut Task) -> bool) -> Result<bool> {
1738 let id = self.resolve_id(id)?;
1739 let _lock = self.lock_task(&id)?;
1740 let mut task = self.get(&id)?;
1741 if !f(&mut task) {
1742 return Ok(false);
1743 }
1744 self.put_unlocked(&mut task)?;
1745 Ok(true)
1746 }
1747
1748 pub fn link_run(&self, id: &str, run: &str) -> Result<Task> {
1753 let id = self.resolve_id(id)?;
1754 let _lock = self.lock_task(&id)?;
1757 let mut task = self.get(&id)?;
1758 if task.link_run(run) {
1759 self.put_unlocked(&mut task)?;
1760 }
1761 Ok(task)
1762 }
1763
1764 fn lock_task(&self, id: &str) -> Result<TaskLock> {
1772 std::fs::create_dir_all(&self.root)
1773 .with_context(|| format!("create {}", self.root.display()))?;
1774 let path = self.root.join(format!("{id}.write-lock"));
1775 let started = std::time::Instant::now();
1776 loop {
1777 match std::fs::OpenOptions::new()
1778 .write(true)
1779 .create_new(true)
1780 .open(&path)
1781 {
1782 Ok(mut file) => {
1783 use std::io::Write;
1784 let token = owner_token();
1785 if let Err(e) = file.write_all(token.as_bytes()) {
1786 drop(file);
1787 let _ = std::fs::remove_file(&path);
1788 return Err(e).with_context(|| format!("lock {}", path.display()));
1789 }
1790 return Ok(TaskLock { path, token });
1791 }
1792 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
1793 let judged = std::fs::read_to_string(&path).ok();
1796 let broken = judged
1797 .filter(|_| older_than_stale(&path))
1798 .is_some_and(|judged| break_stale(&path, &judged));
1799 if !broken {
1803 if started.elapsed() > TASK_LOCK_STALE {
1804 bail!("could not lock task {id}");
1805 }
1806 std::thread::sleep(std::time::Duration::from_millis(15));
1807 }
1808 }
1809 Err(e)
1813 if e.kind() == std::io::ErrorKind::PermissionDenied
1814 && started.elapsed() <= TASK_LOCK_STALE =>
1815 {
1816 std::thread::sleep(std::time::Duration::from_millis(15));
1817 }
1818 Err(e) => return Err(e).with_context(|| format!("lock {}", path.display())),
1819 }
1820 }
1821 }
1822
1823 pub fn get(&self, id: &str) -> Result<Task> {
1825 let resolved = self.resolve_id(id)?;
1826 read_path(&self.path_of(&resolved))
1827 }
1828
1829 pub fn remove(&self, id: &str, in_flight: bool, questions: &Questions) -> Result<Removal> {
1861 let _ = questions;
1862 let resolved = self.resolve_id(id)?;
1863 if in_flight {
1864 bail!("task {resolved} is being run by a live daemon right now");
1865 }
1866 self.write_tombstone(&resolved)?;
1867 if let Err(e) = self.remove_record_with_attachments(&resolved, |p| std::fs::remove_file(p))
1868 {
1869 if self.path_of(&resolved).exists() {
1873 let _ = std::fs::remove_file(self.tombstone_path(&resolved));
1874 }
1875 return Err(e);
1876 }
1877 let (released, still_blocked) = self.release_dependents_of(&resolved);
1878 Ok(Removal {
1879 id: resolved,
1880 released,
1881 still_blocked,
1882 })
1883 }
1884
1885 fn tombstone_path(&self, id: &str) -> PathBuf {
1888 self.root.join(format!("{id}.removed"))
1889 }
1890
1891 fn write_tombstone(&self, id: &str) -> Result<()> {
1892 let path = self.tombstone_path(id);
1893 let tmp = self.root.join(format!("{id}.removed.tmp"));
1894 std::fs::write(&tmp, b"")
1895 .and_then(|()| std::fs::rename(&tmp, &path))
1896 .with_context(|| format!("write {}", path.display()))
1897 }
1898
1899 fn was_deleted(&self, id: &str) -> bool {
1903 self.tombstone_path(id).is_file() && !self.path_of(id).exists()
1904 }
1905
1906 pub fn apply_deleted_blockers(&self, task: &mut Task) -> Vec<String> {
1910 let deleted = deleted_blockers(self, &task.blocked_by);
1911 deleted
1912 .into_iter()
1913 .filter(|id| task.dependency_deleted(id))
1914 .collect()
1915 }
1916
1917 pub fn note_dependency_deleted(&self, dependent: &Task, deleted: &str) {
1921 let Some(home) = self.root.parent().filter(|p| !p.as_os_str().is_empty()) else {
1922 return;
1923 };
1924 let ja = crate::lang::is_japanese(&crate::lang::of_repo(&dependent.repo));
1925 let waiting = dependent.status == TaskStatus::Blocked;
1926 let message = match (ja, waiting) {
1927 (true, false) => format!(
1928 "タスク {} は、待っていた {} が削除されたため待機を解除し、元の状態に戻しました",
1929 dependent.short(),
1930 short(deleted)
1931 ),
1932 (true, true) => format!(
1933 "タスク {} は、待っていた {} が削除されたため、残りの依存を待っています",
1934 dependent.short(),
1935 short(deleted)
1936 ),
1937 (false, false) => format!(
1938 "Task {} stopped waiting on {} because it was deleted, and returned to its previous state",
1939 dependent.short(),
1940 short(deleted)
1941 ),
1942 (false, true) => format!(
1943 "Task {} stopped waiting on {} because it was deleted, and is still waiting on its other dependencies",
1944 dependent.short(),
1945 short(deleted)
1946 ),
1947 };
1948 crate::notices::raise_in(
1949 home,
1950 crate::notices::Notice::info(
1951 &format!("unblocked:{}:{}", dependent.id, deleted),
1952 message,
1953 )
1954 .link(crate::notices::Link::Task {
1955 id: dependent.id.clone(),
1956 }),
1957 );
1958 }
1959
1960 fn release_dependents_of(&self, dependency: &str) -> (Vec<String>, Vec<String>) {
1965 let (mut released, mut still_blocked) = (Vec::new(), Vec::new());
1966 for listed in self.list() {
1967 if listed.status != TaskStatus::Blocked
1968 || !listed.blocked_by.iter().any(|b| b == dependency)
1969 {
1970 continue;
1971 }
1972 let Ok(_claim) = self.claim(&listed.id) else {
1973 continue;
1974 };
1975 let Ok(mut task) = self.get(&listed.id) else {
1976 continue;
1977 };
1978 if !task.dependency_deleted(dependency) {
1979 continue;
1980 }
1981 if self.put(&mut task).is_err() {
1982 continue;
1983 }
1984 self.note_dependency_deleted(&task, dependency);
1985 if task.status == TaskStatus::Blocked {
1986 still_blocked.push(task.id.clone());
1987 } else {
1988 released.push(task.id.clone());
1989 }
1990 }
1991 (released, still_blocked)
1992 }
1993
1994 fn remove_record_with_attachments(
1998 &self,
1999 resolved: &str,
2000 remove_record: impl FnOnce(&Path) -> std::io::Result<()>,
2001 ) -> Result<()> {
2002 self.sweep_removed_attachments();
2009 let attachments = self.attachments_dir(resolved);
2010 let aside = self.root.join(format!("{resolved}.attachments.removing"));
2011 let moved = match std::fs::rename(&attachments, &aside) {
2012 Ok(()) => true,
2013 Err(e) if e.kind() == std::io::ErrorKind::NotFound => false,
2014 Err(e) => {
2015 return Err(e).with_context(|| format!("remove {}", attachments.display()));
2016 }
2017 };
2018 let path = self.path_of(resolved);
2019 if let Err(e) = remove_record(&path) {
2020 if moved {
2021 let _ = std::fs::rename(&aside, &attachments);
2022 }
2023 return Err(e).with_context(|| format!("remove {}", path.display()));
2024 }
2025 if moved {
2026 if let Err(e) = std::fs::remove_dir_all(&aside) {
2027 tracing::warn!("leftover attachments {}: {e}", aside.display());
2028 }
2029 }
2030 let lock = self.lock_path(resolved);
2031 if let Err(e) = std::fs::remove_file(&lock) {
2032 if e.kind() != std::io::ErrorKind::NotFound {
2033 return Err(e).with_context(|| format!("remove {}", lock.display()));
2034 }
2035 }
2036 Ok(())
2037 }
2038
2039 fn sweep_removed_attachments(&self) {
2045 let Ok(entries) = std::fs::read_dir(&self.root) else {
2046 return;
2047 };
2048 for entry in entries.flatten() {
2049 let name = entry.file_name();
2050 let name = name.to_string_lossy();
2051 let Some(id) = name.strip_suffix(".attachments.removing") else {
2052 continue;
2053 };
2054 if !self.path_of(id).exists() {
2057 if let Err(e) = std::fs::remove_dir_all(entry.path()) {
2058 tracing::warn!("leftover attachments {}: {e}", entry.path().display());
2059 }
2060 }
2061 }
2062 self.sweep_tombstones();
2063 }
2064
2065 fn sweep_tombstones(&self) {
2074 let Ok(entries) = std::fs::read_dir(&self.root) else {
2075 return;
2076 };
2077 let tasks = self.list();
2078 for entry in entries.flatten() {
2079 let name = entry.file_name();
2080 let name = name.to_string_lossy();
2081 let Some(id) = name.strip_suffix(".removed") else {
2082 continue;
2083 };
2084 if self.path_of(id).exists()
2085 || tasks.iter().any(|t| t.blocked_by.iter().any(|b| b == id))
2086 {
2087 continue;
2088 }
2089 let old = entry
2090 .metadata()
2091 .and_then(|m| m.modified())
2092 .ok()
2093 .and_then(|m| m.elapsed().ok())
2094 .is_some_and(|age| age >= TOMBSTONE_GRACE);
2095 if old {
2096 let _ = std::fs::remove_file(entry.path());
2097 }
2098 }
2099 }
2100
2101 fn lock_path(&self, id: &str) -> PathBuf {
2104 self.root.join(format!("{id}.lock"))
2105 }
2106
2107 pub fn list(&self) -> Vec<Task> {
2120 let mut tasks: Vec<Task> = std::fs::read_dir(&self.root)
2121 .into_iter()
2122 .flatten()
2123 .flatten()
2124 .map(|e| e.path())
2125 .filter(|p| p.extension().is_some_and(|x| x == "json"))
2126 .filter_map(|p| read_path(&p).ok())
2127 .collect();
2128 tasks.sort_unstable_by(|a, b| b.priority.cmp(&a.priority).then_with(|| b.id.cmp(&a.id)));
2129 tasks
2130 }
2131
2132 pub fn superseded(&self) -> HashMap<String, String> {
2145 let mut by = HashMap::new();
2146 for task in self.list() {
2147 for earlier in &task.runs {
2148 if let Some(later) = task.successor_of(earlier) {
2149 by.insert(earlier.clone(), later.clone());
2150 }
2151 }
2152 }
2153 by
2154 }
2155
2156 pub fn superseded_by(&self, run: &str) -> Option<String> {
2164 for task in self.list() {
2165 if task.runs.iter().any(|r| r == run) {
2166 return task.successor_of(run).cloned();
2167 }
2168 }
2169 None
2170 }
2171
2172 pub fn latest_attempt(&self, run: &str) -> Option<String> {
2183 for task in self.list() {
2184 if task.runs.iter().any(|r| r == run) {
2185 return task.runs.last().filter(|last| **last != run).cloned();
2186 }
2187 }
2188 None
2189 }
2190
2191 pub fn next_runnable(&self) -> Option<Task> {
2196 let mut runnable: Vec<Task> = self
2197 .list()
2198 .into_iter()
2199 .filter(|t| t.status.runnable())
2200 .collect();
2201 runnable.sort_unstable_by(|a, b| b.priority.cmp(&a.priority).then(a.id.cmp(&b.id)));
2202 runnable.into_iter().next()
2203 }
2204
2205 pub fn claim(&self, id: &str) -> Result<Claim> {
2212 std::fs::create_dir_all(&self.root)
2213 .with_context(|| format!("create {}", self.root.display()))?;
2214 let path = self.lock_path(id);
2215 match std::fs::OpenOptions::new()
2216 .write(true)
2217 .create_new(true)
2218 .open(&path)
2219 {
2220 Ok(mut f) => {
2221 use std::io::Write as _;
2222 let _ = writeln!(f, "{}", std::process::id());
2224 Ok(Claim { path })
2225 }
2226 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
2227 bail!("task {id} is already claimed ({} exists)", path.display())
2228 }
2229 Err(e) => Err(e).with_context(|| format!("lock {}", path.display())),
2230 }
2231 }
2232
2233 pub fn resolve_id(&self, prefix: &str) -> Result<String> {
2235 if self.path_of(prefix).is_file() {
2236 return Ok(prefix.to_owned());
2237 }
2238 let hits: Vec<String> = self
2239 .list()
2240 .into_iter()
2241 .map(|t| t.id)
2242 .filter(|id| id.starts_with(prefix) || id.ends_with(prefix))
2243 .collect();
2244 match hits.len() {
2245 1 => Ok(hits.into_iter().next().expect("exactly one hit")),
2246 0 => bail!("no task matches `{prefix}`"),
2247 _ => bail!(
2248 "`{prefix}` matches {} tasks: {}",
2249 hits.len(),
2250 hits.join(", ")
2251 ),
2252 }
2253 }
2254
2255 pub fn revision(&self) -> u64 {
2262 self.revision_excluding(&std::collections::BTreeSet::new())
2263 }
2264
2265 pub fn revision_excluding(&self, skip: &std::collections::BTreeSet<String>) -> u64 {
2268 use std::hash::{Hash as _, Hasher as _};
2269
2270 let mut entries: Vec<(String, u64)> = std::fs::read_dir(&self.root)
2271 .into_iter()
2272 .flatten()
2273 .flatten()
2274 .filter(|e| e.path().extension().is_some_and(|ext| ext == "json"))
2275 .filter(|e| {
2276 let path = e.path();
2277 !path
2278 .file_stem()
2279 .is_some_and(|stem| skip.contains(stem.to_string_lossy().as_ref()))
2280 })
2281 .filter_map(|e| {
2282 let name = e.file_name().to_string_lossy().into_owned();
2283 let mtime = e
2284 .metadata()
2285 .ok()?
2286 .modified()
2287 .ok()?
2288 .duration_since(std::time::UNIX_EPOCH)
2289 .ok()?
2290 .as_millis() as u64;
2291 Some((name, mtime))
2292 })
2293 .collect();
2294
2295 if entries.is_empty() {
2296 return 0;
2297 }
2298
2299 entries.sort_unstable();
2300 let mut hasher = std::hash::DefaultHasher::new();
2301 for (name, mtime) in &entries {
2302 name.hash(&mut hasher);
2303 mtime.hash(&mut hasher);
2304 }
2305 let h = hasher.finish();
2306 if h == 0 { 1 } else { h }
2307 }
2308}
2309
2310const TOMBSTONE_GRACE: std::time::Duration = std::time::Duration::from_secs(3600);
2313
2314#[derive(Debug, Clone)]
2316pub struct Removal {
2317 pub id: String,
2319 pub released: Vec<String>,
2323 pub still_blocked: Vec<String>,
2326}
2327
2328#[derive(Debug)]
2330pub struct Claim {
2331 path: PathBuf,
2332}
2333
2334impl Drop for Claim {
2335 fn drop(&mut self) {
2336 let _ = std::fs::remove_file(&self.path);
2337 }
2338}
2339
2340pub fn title_from(instruction: &str, max: usize) -> String {
2343 let Some(line) = first_line(instruction) else {
2344 return "(empty task)".to_owned();
2345 };
2346 if line.chars().count() <= max {
2347 return line.to_owned();
2348 }
2349 let head: String = line.chars().take(max.saturating_sub(1)).collect();
2350 format!("{head}…")
2351}
2352
2353pub(crate) fn first_line(instruction: &str) -> Option<&str> {
2359 let line = instruction
2360 .lines()
2361 .map(str::trim)
2362 .find(|l| !l.is_empty())?
2363 .trim_start_matches(['#', '-', '*', '>', ' '])
2364 .trim();
2365 (!line.is_empty()).then_some(line)
2366}
2367
2368fn read_path(path: &Path) -> Result<Task> {
2369 let body = std::fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
2370 let task: Task =
2371 serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))?;
2372 if task.schema > SCHEMA {
2378 bail!(
2379 "task {} was written by a different magi (schema {}, this build \
2380 speaks {SCHEMA})",
2381 task.id,
2382 task.schema
2383 );
2384 }
2385 Ok(task)
2386}
2387
2388pub fn missing_blockers(
2402 queue: &Queue,
2403 questions: &Questions,
2404 blocked_by: &[String],
2405) -> Vec<String> {
2406 blocked_by
2407 .iter()
2408 .filter(|id| {
2409 !queue.path_of(id).is_file()
2410 && !questions.path_of(id).is_file()
2411 && !queue.was_deleted(id)
2412 })
2413 .cloned()
2414 .collect()
2415}
2416
2417pub fn deleted_blockers(queue: &Queue, blocked_by: &[String]) -> Vec<String> {
2422 blocked_by
2423 .iter()
2424 .filter(|id| queue.was_deleted(id))
2425 .cloned()
2426 .collect()
2427}
2428
2429pub fn missing_blocker_hold_reason(blocked_by: &[String], missing: &[String]) -> String {
2441 missing_blocker_hold_reason_in(blocked_by, missing, "en")
2442}
2443
2444pub fn missing_blocker_hold_reason_in(
2446 blocked_by: &[String],
2447 missing: &[String],
2448 language: &str,
2449) -> String {
2450 if crate::lang::is_japanese(language) {
2451 format!(
2452 "{} を待っていましたが、{} はディスク上に存在しません - `magi task triage` を参照",
2453 blocked_by.join(", "),
2454 missing.join(", "),
2455 )
2456 } else {
2457 format!(
2458 "blocked on {} but {} no longer exist(s) on disk - see `magi task triage`",
2459 blocked_by.join(", "),
2460 missing.join(", "),
2461 )
2462 }
2463}
2464
2465pub fn short(id: &str) -> &str {
2467 id.split('-').next_back().unwrap_or(id)
2468}
2469
2470fn new_id() -> String {
2471 let stamp = jiff::Zoned::now().strftime("%Y%m%d-%H%M%S");
2472 let seed = crate::rng::entropy();
2473 format!("{stamp}-{:04x}", (seed ^ (seed >> 32)) & 0xffff)
2474}
2475
2476#[cfg(test)]
2477mod tests {
2478 #[test]
2479 fn merge_over_lets_the_later_choice_win_and_keeps_the_rest() {
2480 let mut base = RunOverrides {
2481 merge: Some("pr".to_owned()),
2482 candidates: Some(3),
2483 ..RunOverrides::default()
2484 };
2485 base.merge_over(&RunOverrides {
2486 merge: Some("none".to_owned()),
2487 seed: Some(7),
2488 ..RunOverrides::default()
2489 });
2490 assert_eq!(base.merge.as_deref(), Some("none"));
2491 assert_eq!(base.candidates, Some(3));
2492 assert_eq!(base.seed, Some(7));
2493 }
2494
2495 #[test]
2496 fn an_already_landed_task_is_done_with_its_attempt_refunded() {
2497 let mut t = task("relanded");
2498 t.attempts = 1;
2499 t.status = TaskStatus::Running;
2500 t.already_landed("already in main as 0e368de");
2501 assert_eq!(t.status, TaskStatus::Done);
2502 assert_eq!(t.attempts, 0);
2503 assert!(t.hold_reason.is_none());
2504 assert_eq!(t.last_error.as_deref(), Some("already in main as 0e368de"));
2505 }
2506
2507 #[test]
2508 fn missing_blocker_reason_follows_the_language() {
2509 let b = vec!["a".to_owned()];
2510 let en = missing_blocker_hold_reason_in(&b, &b, "en");
2511 assert_eq!(en, missing_blocker_hold_reason(&b, &b));
2512 assert!(en.starts_with("blocked on a"));
2513 assert!(missing_blocker_hold_reason_in(&b, &b, "ja").contains("存在しません"));
2514 assert_eq!(missing_blocker_hold_reason_in(&b, &b, "de"), en);
2515 }
2516
2517 use super::*;
2518
2519 #[test]
2520 fn triage_applied_survives_release_and_old_records_read_as_empty() {
2521 let mut t = Task::new(
2522 "t".to_owned(),
2523 "i".to_owned(),
2524 PathBuf::from("r"),
2525 Source::Human,
2526 );
2527 t.mark_triage_applied("q1");
2528 t.mark_triage_applied("q1");
2529 t.hold_machine(Some("x".to_owned()));
2530 t.release();
2531 assert_eq!(t.triage_applied, ["q1"]);
2532 assert!(t.triage_applied("q1") && !t.triage_applied("q2"));
2533
2534 let mut v = serde_json::to_value(&t).unwrap();
2535 v.as_object_mut().unwrap().remove("triage_applied");
2536 let old: Task = serde_json::from_value(v).unwrap();
2537 assert!(old.triage_applied.is_empty());
2538 }
2539
2540 #[test]
2541 fn task_counts_of_empty_is_all_zero() {
2542 assert_eq!(TaskCounts::of(&[]), TaskCounts::default());
2543 }
2544
2545 #[test]
2546 fn task_counts_of_tallies_every_status() {
2547 let mut queued = Task::new(
2548 "q".to_owned(),
2549 "i".to_owned(),
2550 PathBuf::from("."),
2551 Source::Human,
2552 );
2553 queued.status = TaskStatus::Queued;
2554 let mut running = queued.clone();
2555 running.status = TaskStatus::Running;
2556 let mut done = queued.clone();
2557 done.status = TaskStatus::Done;
2558 let mut failed = queued.clone();
2559 failed.status = TaskStatus::Failed;
2560 let mut held = queued.clone();
2561 held.status = TaskStatus::Held;
2562 let mut blocked = queued.clone();
2563 blocked.status = TaskStatus::Blocked;
2564
2565 let mut parked = queued.clone();
2566 parked.status = TaskStatus::Parked;
2567
2568 let counts = TaskCounts::of(&[
2569 queued,
2570 running,
2571 done.clone(),
2572 done,
2573 failed,
2574 held,
2575 blocked,
2576 parked,
2577 ]);
2578 assert_eq!(
2579 counts,
2580 TaskCounts {
2581 queued: 1,
2582 running: 1,
2583 done: 2,
2584 failed: 1,
2585 held: 1,
2586 blocked: 1,
2587 parked: 1,
2588 }
2589 );
2590 }
2591
2592 #[test]
2593 fn task_implementers_persist_normalized_without_losing_history() {
2594 let (_dir, q) = queue();
2595 let mut t = task("keep me");
2596 t.hold_manual(Some("waiting".into()));
2597 t.runs.push("earlier-run".into());
2598 t.attachments.push("reference.txt".into());
2599 let cfg: crate::config::Config =
2600 toml::from_str("[[agents]]\nid = 'mock'\nkind = 'command'\ncommand = ['true']\n")
2601 .unwrap();
2602 q.put(&mut t).unwrap();
2603 let original = serde_json::to_value(&t).unwrap();
2604 for n in [2, 1, 2] {
2605 t.set_implementers(n, &cfg).unwrap();
2606 q.put(&mut t).unwrap();
2607 t = q.get(&t.id).unwrap();
2608 assert_eq!(t.implementers(), Some(usize::from(n)));
2609 assert_eq!(t.solo, n == 1);
2610 if n == 1 {
2611 assert!(t.overrides.is_none());
2612 }
2613 let mut actual = serde_json::to_value(&t).unwrap();
2614 for field in ["solo", "overrides", "updated_at"] {
2615 actual[field] = original[field].clone();
2616 }
2617 assert_eq!(actual, original);
2618 }
2619 t.overrides.as_mut().unwrap().judges = Some(2);
2620 t.set_implementers(1, &cfg).unwrap();
2621 assert_eq!(t.overrides.as_ref().unwrap().judges, Some(2));
2622 assert_eq!(t.overrides.as_ref().unwrap().candidates, None);
2623 }
2624
2625 #[test]
2626 fn task_implementers_old_records_keep_inheritance_until_changed() {
2627 let mut value = serde_json::to_value(task("old task")).unwrap();
2628 value.as_object_mut().unwrap().remove("solo");
2629 value.as_object_mut().unwrap().remove("overrides");
2630 let mut old: Task = serde_json::from_value(value).unwrap();
2631 assert_eq!(old.implementers(), None);
2632 old.set_implementers(1, &crate::config::Config::default())
2633 .unwrap();
2634 assert!(old.solo);
2635 assert!(old.overrides.is_none());
2636 }
2637
2638 #[test]
2639 fn task_implementers_reject_invalid_counts_and_states() {
2640 let cfg = crate::config::Config::default();
2641 let mut t = task("unchanged");
2642 for n in [0, 4, 255] {
2643 assert!(t.set_implementers(n, &cfg).is_err());
2644 }
2645 for status in [
2646 TaskStatus::Running,
2647 TaskStatus::Done,
2648 TaskStatus::Failed,
2649 TaskStatus::Blocked,
2650 TaskStatus::Parked,
2651 ] {
2652 t.status = status;
2653 let before = serde_json::to_value(&t).unwrap();
2654 assert!(t.set_implementers(1, &cfg).is_err());
2655 assert_eq!(serde_json::to_value(&t).unwrap(), before);
2656 }
2657 }
2658
2659 fn queue() -> (tempfile::TempDir, Queue) {
2662 let dir = tempfile::tempdir().unwrap();
2663 let q = Queue::at(dir.path().join("queue"));
2664 (dir, q)
2665 }
2666
2667 #[test]
2668 fn putting_a_machine_held_task_files_a_notification_beside_the_queue() {
2669 let dir = tempfile::tempdir().unwrap();
2670 let q = Queue::at(dir.path().join("queue"));
2671 let mut t = task("held");
2672 q.put(&mut t).unwrap();
2673 assert_eq!(
2674 crate::notices::Notices::at(dir.path().join("notifications"))
2675 .list()
2676 .len(),
2677 0
2678 );
2679 t.hold_machine(Some("out of attempts".to_owned()));
2680 q.put(&mut t).unwrap();
2681 let listed = crate::notices::Notices::at(dir.path().join("notifications")).list();
2682 assert_eq!(listed.len(), 1);
2683 assert!(listed[0].message.contains("out of attempts"));
2684 }
2685
2686 fn task(title: &str) -> Task {
2687 Task::new(
2688 title.to_owned(),
2689 format!("do {title}"),
2690 PathBuf::from("."),
2691 Source::Human,
2692 )
2693 }
2694
2695 #[test]
2696 fn earlier_attempts_is_every_recorded_run_and_agrees_with_the_display() {
2697 let mut t = task("retried");
2698 assert!(
2699 t.earlier_attempts().is_empty(),
2700 "a first attempt takes nothing over"
2701 );
2702 t.runs = vec!["aaaa".to_owned(), "bbbb".to_owned()];
2703 assert_eq!(t.earlier_attempts(), ["aaaa", "bbbb"]);
2704 assert_eq!(t.successor_of("aaaa"), Some(&"bbbb".to_owned()));
2706 assert_eq!(t.successor_of("bbbb"), None);
2707 assert_eq!(t.successor_of("zzzz"), None);
2708 }
2709
2710 #[test]
2711 fn superseded_by_names_the_next_attempt_and_none_for_the_last() {
2712 let (_dir, q) = queue();
2713 let mut t = task("retried");
2714 t.runs = vec!["aaaa".to_owned(), "bbbb".to_owned(), "cccc".to_owned()];
2715 q.put(&mut t).unwrap();
2716
2717 assert_eq!(q.superseded_by("aaaa"), Some("bbbb".to_owned()));
2718 assert_eq!(q.superseded_by("bbbb"), Some("cccc".to_owned()));
2719 assert_eq!(
2720 q.superseded_by("cccc"),
2721 None,
2722 "the latest attempt replaces nothing"
2723 );
2724 assert_eq!(
2725 q.superseded_by("never-heard-of-it"),
2726 None,
2727 "a run belonging to no task on this queue is not superseded"
2728 );
2729
2730 let mut by = HashMap::new();
2731 by.insert("aaaa".to_owned(), "bbbb".to_owned());
2732 by.insert("bbbb".to_owned(), "cccc".to_owned());
2733 assert_eq!(
2734 q.superseded(),
2735 by,
2736 "the whole-map and single-run forms must agree"
2737 );
2738 }
2739
2740 #[test]
2741 fn superseded_attempts_is_empty_until_the_task_is_done() {
2742 let mut t = task("retried");
2743 t.runs = vec!["aaaa".to_owned(), "bbbb".to_owned()];
2744 t.status = TaskStatus::Failed;
2745 assert_eq!(
2746 t.superseded_attempts(true),
2747 &[] as &[String],
2748 "a task still retrying has no attempt yet that a later one made moot"
2749 );
2750
2751 t.status = TaskStatus::Running;
2752 assert_eq!(t.superseded_attempts(true), &[] as &[String]);
2753 }
2754
2755 #[test]
2756 fn superseded_attempts_names_every_run_before_the_one_that_succeeded() {
2757 let mut t = task("retried");
2758 t.runs = vec!["aaaa".to_owned(), "bbbb".to_owned(), "cccc".to_owned()];
2759 t.status = TaskStatus::Done;
2760 assert_eq!(
2761 t.superseded_attempts(true),
2762 &["aaaa".to_owned(), "bbbb".to_owned()],
2763 "cccc is the attempt whose success made the task done, and stays out"
2764 );
2765 }
2766
2767 #[test]
2768 fn superseded_attempts_is_empty_for_a_done_task_with_only_one_attempt() {
2769 let mut t = task("first try landed");
2770 t.runs = vec!["aaaa".to_owned()];
2771 t.status = TaskStatus::Done;
2772 assert_eq!(t.superseded_attempts(true), &[] as &[String]);
2773 }
2774
2775 #[test]
2776 fn superseded_attempts_is_empty_when_the_last_run_never_actually_succeeded() {
2777 let mut t = task("closed by hand after a manual merge");
2784 t.runs = vec!["aaaa".to_owned(), "bbbb".to_owned()];
2785 t.status = TaskStatus::Done;
2786 assert_eq!(
2787 t.superseded_attempts(false),
2788 &[] as &[String],
2789 "nothing here is provably why the task is done, so nothing is superseded"
2790 );
2791 }
2792
2793 #[test]
2794 fn latest_attempt_names_the_chain_s_current_head_not_just_the_next_one() {
2795 let (_dir, q) = queue();
2796 let mut t = task("retried twice");
2797 t.runs = vec!["aaaa".to_owned(), "bbbb".to_owned(), "cccc".to_owned()];
2798 q.put(&mut t).unwrap();
2799
2800 assert_eq!(
2801 q.latest_attempt("aaaa"),
2802 Some("cccc".to_owned()),
2803 "an old attempt points straight at the chain's current head, not the \
2804 next attempt in the middle of it"
2805 );
2806 assert_eq!(q.latest_attempt("bbbb"), Some("cccc".to_owned()));
2807 assert_eq!(
2808 q.latest_attempt("cccc"),
2809 None,
2810 "the latest attempt is not superseded by anything"
2811 );
2812 assert_eq!(
2813 q.latest_attempt("never-heard-of-it"),
2814 None,
2815 "a run belonging to no task on this queue is not superseded"
2816 );
2817 }
2818
2819 #[test]
2820 fn a_markdown_heading_is_the_title_not_decoration() {
2821 assert_eq!(
2826 title_from("# Rework the config loader\n\nIt re-reads it.\n", 40),
2827 "Rework the config loader"
2828 );
2829 assert_eq!(title_from("- fix the thing", 40), "fix the thing");
2830 assert_eq!(title_from("> quoted task", 40), "quoted task");
2831 assert_eq!(title_from(" \n\n", 40), "(empty task)");
2833 assert_eq!(title_from("###\n", 40), "(empty task)");
2834 }
2835
2836 #[test]
2837 fn a_long_title_is_elided_by_characters_not_bytes() {
2838 let long = "課題".repeat(30);
2840 let title = title_from(&long, 10);
2841 assert_eq!(title.chars().count(), 10);
2842 assert!(title.ends_with('…'));
2843 }
2844
2845 #[test]
2846 fn priority_wins_and_ties_break_oldest_first() {
2847 let (_dir, q) = queue();
2848 let mut a = task("first");
2849 let mut b = task("second");
2850 let mut c = task("urgent");
2851 a.id = "20260101-000001-aaaa".to_owned();
2853 b.id = "20260101-000002-bbbb".to_owned();
2854 c.id = "20260101-000003-cccc".to_owned();
2855 c.priority = 5;
2856 for t in [&mut a, &mut b, &mut c] {
2857 q.put(t).unwrap();
2858 }
2859
2860 assert_eq!(q.next_runnable().unwrap().id, c.id);
2862 c.hold_machine(None);
2863 q.put(&mut c).unwrap();
2864 assert_eq!(q.next_runnable().unwrap().id, a.id);
2866 assert_eq!(q.list().len(), 3, "b is still waiting its turn");
2867 }
2868
2869 #[test]
2870 fn a_blocked_task_never_starves_another_runnable_one() {
2871 let (_dir, q) = queue();
2872 let mut blocked = task("blocked");
2873 blocked.block(vec!["something".to_owned()], None);
2874 q.put(&mut blocked).unwrap();
2875
2876 let mut runnable = task("free to go");
2877 q.put(&mut runnable).unwrap();
2878
2879 let next = q.next_runnable().expect("a runnable task is still offered");
2880 assert_eq!(next.id, runnable.id);
2881 }
2882
2883 #[test]
2884 fn a_held_task_is_never_offered_to_the_loop() {
2885 let (_dir, q) = queue();
2886 let mut t = task("held");
2887 q.put(&mut t).unwrap();
2888 assert!(q.next_runnable().is_some());
2889
2890 t.hold_machine(None);
2891 q.put(&mut t).unwrap();
2892 assert!(
2893 q.next_runnable().is_none(),
2894 "a held task must wait for a human"
2895 );
2896
2897 t.status = TaskStatus::Failed;
2899 q.put(&mut t).unwrap();
2900 assert!(q.next_runnable().is_some());
2901 }
2902
2903 #[test]
2904 fn attempts_are_capped_and_then_the_task_is_held() {
2905 let mut t = task("doomed");
2906
2907 t.start("run-1".to_owned());
2908 t.fail("gate red", 2);
2909 assert_eq!(t.status, TaskStatus::Failed, "one attempt of two: retry");
2910
2911 t.start("run-2".to_owned());
2912 t.fail("gate red", 2);
2913 assert_eq!(
2914 t.status,
2915 TaskStatus::Held,
2916 "out of attempts: stop spending money on it"
2917 );
2918 assert_eq!(t.runs, ["run-1", "run-2"]);
2919 assert_eq!(t.last_error.as_deref(), Some("gate red"));
2920 assert_eq!(
2921 t.hold_reason.as_deref(),
2922 Some("gate red"),
2923 "the hold must say why, not leave hold_reason null next to a \
2924 populated last_error"
2925 );
2926 }
2927
2928 #[test]
2929 fn handing_off_a_task_records_a_hold_reason_too() {
2930 let mut t = task("left a pull request");
2931 t.start("run-1".to_owned());
2932 t.handed_off("run ended with a pull request open [run run-1]");
2933 assert_eq!(t.status, TaskStatus::Held);
2934 assert_eq!(t.hold_source, Some(HoldSource::Machine));
2935 assert_eq!(
2936 t.hold_reason.as_deref(),
2937 Some("run ended with a pull request open [run run-1]")
2938 );
2939 assert_eq!(t.hold_reason, t.last_error);
2940 }
2941
2942 #[test]
2943 fn a_quota_stall_is_refunded_so_the_backlog_survives_the_night() {
2944 let mut t = task("stalled by quota");
2945
2946 t.start("run-1".to_owned());
2947 assert_eq!(t.attempts, 1);
2948 t.stall("judge-1, judge-2 out of quota");
2949 assert_eq!(
2950 t.attempts, 0,
2951 "a closed quota window must not spend the task's retry budget"
2952 );
2953 assert_eq!(t.status, TaskStatus::Failed, "the loop should retry it");
2954 assert_eq!(
2955 t.last_error.as_deref(),
2956 Some("judge-1, judge-2 out of quota")
2957 );
2958
2959 for _ in 0..20 {
2962 t.start("run-n".to_owned());
2963 t.stall("still out of quota");
2964 }
2965 t.start("run-real".to_owned());
2966 t.fail("gate red", 2);
2967 assert_eq!(
2968 t.status,
2969 TaskStatus::Failed,
2970 "the first attempt that was really judged is attempt one"
2971 );
2972 }
2973
2974 #[test]
2975 fn releasing_a_held_task_gives_it_a_real_second_chance() {
2976 let mut t = task("retry me");
2977 t.start("run-1".to_owned());
2978 t.fail("gate red", 1);
2979 assert_eq!(t.status, TaskStatus::Held);
2980
2981 t.release();
2982 assert_eq!(t.status, TaskStatus::Queued);
2983 assert_eq!(t.attempts, 0);
2986 assert!(t.last_error.is_none());
2987 assert_eq!(
2988 t.runs.len(),
2989 1,
2990 "history is kept: attempts reset, evidence does not"
2991 );
2992 }
2993
2994 #[test]
2995 fn a_hold_reason_survives_and_a_release_clears_it() {
2996 let mut t = task("waiting on something else");
2997 t.hold_manual(Some(
2998 "waiting for 20260101-000000-aaaa to land first".to_owned(),
2999 ));
3000 assert_eq!(t.status, TaskStatus::Held);
3001 assert_eq!(
3002 t.hold_reason.as_deref(),
3003 Some("waiting for 20260101-000000-aaaa to land first")
3004 );
3005
3006 t.hold_manual(None);
3008 assert_eq!(
3009 t.hold_reason.as_deref(),
3010 Some("waiting for 20260101-000000-aaaa to land first"),
3011 "a bare re-hold keeps whatever a human already wrote down"
3012 );
3013
3014 let mut plain = task("no reason given");
3016 plain.hold_manual(None);
3017 assert_eq!(plain.status, TaskStatus::Held);
3018 assert!(plain.hold_reason.is_none());
3019
3020 t.release();
3021 assert_eq!(t.status, TaskStatus::Queued);
3022 assert!(
3023 t.hold_reason.is_none(),
3024 "a stale reason must not greet the next person who holds this task"
3025 );
3026 }
3027
3028 #[test]
3029 fn closing_a_held_task_as_done_clears_its_hold_reason_too() {
3030 let mut t = task("landed by hand while held");
3035 t.hold_manual(Some("waiting on 3ed9".to_owned()));
3036 assert_eq!(t.hold_reason.as_deref(), Some("waiting on 3ed9"));
3037
3038 t.succeed();
3039 assert_eq!(t.status, TaskStatus::Done);
3040 assert!(
3041 t.hold_reason.is_none(),
3042 "a done task cannot still be waiting on something"
3043 );
3044 }
3045
3046 #[test]
3047 fn holding_or_closing_a_blocked_task_clears_its_dependency_too() {
3048 let mut held = task("held straight out of blocked");
3055 held.block(
3056 vec!["20260101-000000-dead".to_owned()],
3057 Some("waiting on the migration script".to_owned()),
3058 );
3059 assert_eq!(held.status, TaskStatus::Blocked);
3060
3061 held.hold_manual(None);
3062 assert_eq!(held.status, TaskStatus::Held);
3063 assert!(
3064 held.blocked_by.is_empty(),
3065 "hold overrides the wait, same as release"
3066 );
3067 assert!(held.block_reason.is_none());
3068
3069 let mut done = task("closed straight out of blocked");
3070 done.block(
3071 vec!["20260101-000000-dead".to_owned()],
3072 Some("waiting on the migration script".to_owned()),
3073 );
3074 done.succeed();
3075 assert_eq!(done.status, TaskStatus::Done);
3076 assert!(
3077 done.blocked_by.is_empty(),
3078 "a done task cannot still be waiting on a dependency"
3079 );
3080 assert!(done.block_reason.is_none());
3081 }
3082
3083 #[test]
3084 fn a_blocked_task_is_never_offered_to_the_loop() {
3085 let mut t = task("blocked");
3086 assert!(t.status.runnable());
3087 t.block(
3088 vec!["dep-id".to_owned()],
3089 Some("waits on dep-id".to_owned()),
3090 );
3091 assert_eq!(t.status, TaskStatus::Blocked);
3092 assert!(!t.status.runnable());
3093 assert_eq!(TaskStatus::Blocked.as_str(), "blocked");
3094 }
3095
3096 #[test]
3097 fn unblocking_the_last_dependency_returns_the_task_to_queued() {
3098 let mut t = task("blocked on two");
3099 t.block(
3100 vec!["a".to_owned(), "b".to_owned()],
3101 Some("waits on a and b".to_owned()),
3102 );
3103
3104 t.unblock("a");
3105 assert_eq!(t.status, TaskStatus::Blocked, "b is still outstanding");
3106 assert_eq!(t.blocked_by, ["b"]);
3107
3108 t.unblock("b");
3109 assert_eq!(t.status, TaskStatus::Queued);
3110 assert!(t.blocked_by.is_empty());
3111 assert!(t.block_reason.is_none());
3112 }
3113
3114 #[test]
3115 fn unblocking_an_id_on_a_task_that_is_not_blocked_is_a_no_op() {
3116 let mut t = task("never blocked");
3117 t.unblock("whatever");
3118 assert_eq!(t.status, TaskStatus::Queued);
3119 }
3120
3121 #[test]
3122 fn a_held_task_blocked_on_a_question_returns_to_held_not_queued() {
3123 let mut t = task("held, then asked about");
3128 t.hold_machine(Some("out of attempts".to_owned()));
3129 assert_eq!(t.status, TaskStatus::Held);
3130
3131 t.block(vec!["q1".to_owned()], Some("what now?".to_owned()));
3132 assert_eq!(t.status, TaskStatus::Blocked);
3133
3134 t.record_answer("what now?".to_owned(), "leave it held".to_owned());
3135 t.unblock("q1");
3136 assert_eq!(t.status, TaskStatus::Held, "must restore, not requeue");
3137 assert_eq!(t.hold_reason.as_deref(), Some("out of attempts"));
3138 assert_eq!(t.hold_source, Some(HoldSource::Machine));
3139 assert!(t.blocked_from.is_none(), "consumed once restored");
3140 }
3141
3142 #[test]
3143 fn a_manually_held_task_blocked_on_a_question_returns_to_held() {
3144 let mut t = task("manually held, then asked about");
3145 t.hold_manual(Some("waiting on a dependency".to_owned()));
3146
3147 t.block(vec!["q1".to_owned()], None);
3148 t.unblock("q1");
3149
3150 assert_eq!(t.status, TaskStatus::Held);
3151 assert_eq!(t.hold_source, Some(HoldSource::Manual));
3152 }
3153
3154 #[test]
3155 fn re_blocking_an_already_blocked_task_keeps_the_original_blocked_from() {
3156 let mut t = task("held, blocked twice");
3160 t.hold_machine(None);
3161 t.block(vec!["q1".to_owned()], Some("first".to_owned()));
3162 t.block(
3163 vec!["q1".to_owned(), "q2".to_owned()],
3164 Some("second".to_owned()),
3165 );
3166
3167 t.unblock("q1");
3168 assert_eq!(t.status, TaskStatus::Blocked, "q2 still outstanding");
3169 t.unblock("q2");
3170 assert_eq!(t.status, TaskStatus::Held);
3171 }
3172
3173 #[test]
3174 fn unblocking_a_task_blocked_while_running_lands_on_queued_not_running() {
3175 let mut t = task("blocked mid-run");
3179 t.start("run-1".to_owned());
3180 assert_eq!(t.status, TaskStatus::Running);
3181
3182 t.block(vec!["q1".to_owned()], None);
3183 t.unblock("q1");
3184 assert_eq!(t.status, TaskStatus::Queued);
3185 }
3186
3187 #[test]
3188 fn a_pre_schema_4_blocked_record_with_hold_evidence_restores_to_held() {
3189 let mut t = task("legacy record, held before it was blocked");
3195 t.hold_source = Some(HoldSource::Machine);
3196 t.hold_reason = Some("legacy hold reason".to_owned());
3197 t.status = TaskStatus::Blocked;
3198 t.blocked_by = vec!["q1".to_owned()];
3199 t.blocked_from = None;
3200
3201 t.unblock("q1");
3202 assert_eq!(t.status, TaskStatus::Held);
3203 }
3204
3205 #[test]
3206 fn a_pre_schema_4_blocked_record_with_no_hold_evidence_restores_to_queued() {
3207 let mut t = task("legacy record, ordinary dependency block");
3208 t.status = TaskStatus::Blocked;
3209 t.blocked_by = vec!["dep".to_owned()];
3210 t.blocked_from = None;
3211
3212 t.unblock("dep");
3213 assert_eq!(t.status, TaskStatus::Queued);
3214 }
3215
3216 #[test]
3217 fn answering_a_question_is_recorded_and_survives_a_release() {
3218 let mut t = task("asked something");
3219 t.block(vec!["q1".to_owned()], Some("which backend?".to_owned()));
3220 t.record_answer("Which backend?".to_owned(), "SQLite".to_owned());
3221 t.unblock("q1");
3222 assert_eq!(t.status, TaskStatus::Queued);
3223 assert_eq!(t.answers.len(), 1);
3224 assert_eq!(t.answers[0].answer, "SQLite");
3225
3226 t.release();
3230 assert_eq!(t.answers.len(), 1, "the answer is not lost on release");
3231 }
3232
3233 #[test]
3234 fn a_refused_handover_keeps_the_review_branch_across_release() {
3235 let mut t = task("refused takeover");
3236 t.start("run-1".to_owned());
3237 t.hold_for_handover(Some("magi/eba2/A".to_owned()), "checked out".to_owned());
3238 assert_eq!(t.status, TaskStatus::Held);
3239 assert_eq!(t.attempts, 1);
3240 t.release();
3241 assert_eq!(t.status, TaskStatus::Queued);
3242 assert_eq!(t.attempts, 0);
3243 assert_eq!(t.review_branch.as_deref(), Some("magi/eba2/A"));
3244
3245 t.hold_for_handover(None, "again".to_owned());
3247 t.requeue();
3248 assert!(t.review_branch.is_none());
3249
3250 let mut m = task("manual");
3252 m.review_branch = Some("magi/x/A".to_owned());
3253 m.hold_manual(None);
3254 m.release();
3255 assert!(m.review_branch.is_none());
3256 }
3257
3258 #[test]
3259 fn requesting_review_requeues_the_task_and_remembers_the_branch() {
3260 let mut t = task("blocked run with a surviving branch");
3261 t.start("run-1".to_owned());
3262 t.fail("blocked with major findings", 5);
3263 assert_eq!(t.status, TaskStatus::Failed);
3264
3265 t.request_review("magi/eba2/A".to_owned());
3266 assert_eq!(t.status, TaskStatus::Queued);
3267 assert_eq!(t.attempts, 0);
3268 assert_eq!(t.review_branch.as_deref(), Some("magi/eba2/A"));
3269
3270 t.release();
3272 assert!(t.review_branch.is_none());
3273 }
3274
3275 #[test]
3276 fn conductor_requeue_but_not_an_ordinary_release_forces_a_fresh_start() {
3277 let mut t = task("retry");
3278 t.start("run-1".to_owned());
3279 t.requeue();
3280 assert!(t.fresh_start);
3281
3282 t.release();
3283 assert!(!t.fresh_start);
3284 }
3285
3286 #[test]
3287 fn priority_can_be_changed_while_queued_but_not_while_running() {
3288 let mut t = task("reprioritise me");
3289 t.set_priority(5).unwrap();
3290 assert_eq!(t.priority, 5);
3291
3292 t.start("run-1".to_owned());
3293 let err = t.set_priority(9).unwrap_err().to_string();
3294 assert!(err.contains("running"), "{err}");
3295 assert_eq!(t.priority, 5, "the rejected write must not partially apply");
3296 }
3297
3298 #[test]
3299 fn interrupt_can_be_marked_while_queued_but_not_while_running() {
3300 let mut t = task("interrupt me");
3301 assert!(!t.interrupt, "off unless asked, same as any other task");
3302
3303 t.set_interrupt(true).unwrap();
3304 assert!(t.interrupt);
3305
3306 t.start("run-1".to_owned());
3307 assert!(
3308 !t.interrupt,
3309 "the mark is one-shot: dispatching the task fulfils it, \
3310 whatever the run that follows ends up doing"
3311 );
3312 let err = t.set_interrupt(true).unwrap_err().to_string();
3313 assert!(err.contains("running"), "{err}");
3314 t.set_interrupt(false).unwrap();
3317 assert!(!t.interrupt);
3318 }
3319
3320 #[test]
3324 fn a_failed_run_does_not_leave_the_task_still_marked_to_interrupt() {
3325 let mut t = task("interrupt me");
3326 t.set_interrupt(true).unwrap();
3327 t.start("run-1".to_owned());
3328 t.fail("mock failure", 5);
3329 assert_eq!(t.status, TaskStatus::Failed);
3330 assert!(
3331 !t.interrupt,
3332 "one attempt already spent the mark; a retry is an ordinary \
3333 requeue, not a fresh interrupt request"
3334 );
3335 }
3336
3337 #[test]
3338 fn changing_priority_moves_a_task_ahead_in_the_real_queue_order() {
3339 let (_dir, q) = queue();
3340 let mut a = task("first filed");
3341 let mut b = task("second filed");
3342 a.id = "20260101-000001-aaaa".to_owned();
3343 b.id = "20260101-000002-bbbb".to_owned();
3344 q.put(&mut a).unwrap();
3345 q.put(&mut b).unwrap();
3346
3347 assert_eq!(
3348 q.next_runnable().unwrap().id,
3349 a.id,
3350 "with equal priority the older task goes first, so a burst of \
3351 new work cannot starve it"
3352 );
3353 assert_eq!(
3354 q.list()[0].id,
3355 b.id,
3356 "but the list an operator reads is newest first, the same as \
3357 before priority existed - a's turn to run does not make it the \
3358 newest task"
3359 );
3360
3361 let mut a = q.get(&a.id).unwrap();
3362 a.set_priority(10).unwrap();
3363 q.put(&mut a).unwrap();
3364
3365 assert_eq!(
3366 q.next_runnable().unwrap().id,
3367 a.id,
3368 "a raised priority must be reflected the moment it is saved"
3369 );
3370 assert_eq!(
3374 q.list()[0].id,
3375 a.id,
3376 "the raised task must sort first in the list an operator reads, \
3377 not only in next_runnable's own ordering"
3378 );
3379 }
3380
3381 #[test]
3382 fn editing_replaces_title_and_instruction_but_keeps_identity_and_history() {
3383 let mut t = Task::new(
3384 "old title".to_owned(),
3385 "old instruction".to_owned(),
3386 PathBuf::from("/repo"),
3387 Source::Agent {
3388 run: "20260101-000000-beef".to_owned(),
3389 node: "implement".to_owned(),
3390 },
3391 );
3392 let id = t.id.clone();
3393 let created_at = t.created_at;
3394 t.runs.push("20260101-000000-beef".to_owned());
3395
3396 t.edit("new title".to_owned(), "new instruction".to_owned())
3397 .unwrap();
3398
3399 assert_eq!(t.title, "new title");
3400 assert_eq!(t.instruction, "new instruction");
3401 assert_eq!(t.id, id, "editing must not mint a new id");
3402 assert_eq!(t.created_at, created_at);
3403 assert_eq!(
3404 t.source,
3405 Source::Agent {
3406 run: "20260101-000000-beef".to_owned(),
3407 node: "implement".to_owned(),
3408 },
3409 "editing must not turn agent attribution into human"
3410 );
3411 assert_eq!(t.runs, ["20260101-000000-beef"]);
3412 }
3413
3414 #[test]
3415 fn editing_is_refused_once_a_task_is_running_or_finished() {
3416 let mut running = task("in flight");
3417 running.start("run-1".to_owned());
3418 let err = running
3419 .edit("x".to_owned(), "y".to_owned())
3420 .unwrap_err()
3421 .to_string();
3422 assert!(err.contains("running"), "{err}");
3423
3424 let mut done = task("finished");
3425 done.succeed();
3426 let err = done
3427 .edit("x".to_owned(), "y".to_owned())
3428 .unwrap_err()
3429 .to_string();
3430 assert!(err.contains("done"), "{err}");
3431
3432 let mut queued = task("waiting");
3434 queued.edit("x".to_owned(), "y".to_owned()).unwrap();
3435 let mut held = task("parked");
3436 held.hold_machine(None);
3437 held.edit("x".to_owned(), "y".to_owned()).unwrap();
3438 }
3439
3440 #[test]
3441 fn a_task_recorded_without_a_hold_reason_still_reads_as_none() {
3442 let (_dir, q) = queue();
3443 let path = q.path_of("20260101-000000-aaaa");
3444 std::fs::create_dir_all(q.root()).unwrap();
3445 std::fs::write(
3446 &path,
3447 serde_json::json!({
3448 "schema": SCHEMA,
3449 "id": "20260101-000000-aaaa",
3450 "title": "from before hold reasons existed",
3451 "instruction": "from before hold reasons existed",
3452 "repo": ".",
3453 "source": { "kind": "human" },
3454 "status": "held",
3455 "created_at": Timestamp::now().to_string(),
3456 "updated_at": Timestamp::now().to_string(),
3457 })
3458 .to_string(),
3459 )
3460 .unwrap();
3461
3462 let task = q.get("20260101-000000-aaaa").expect("must still read");
3463 assert!(task.hold_reason.is_none());
3464 assert!(task.operator_held());
3465 }
3466
3467 #[test]
3468 fn a_legacy_reasoned_hold_defaults_to_operator_protection() {
3469 let (_dir, q) = queue();
3470 let path = q.path_of("20260101-000000-bbbb");
3471 std::fs::create_dir_all(q.root()).unwrap();
3472 std::fs::write(
3473 &path,
3474 serde_json::json!({
3475 "schema": 2,
3476 "id": "20260101-000000-bbbb",
3477 "title": "old manual recovery",
3478 "instruction": "old manual recovery",
3479 "repo": ".",
3480 "source": { "kind": "human" },
3481 "status": "held",
3482 "hold_reason": "active manual recovery run20260912-224242-daf5",
3483 "created_at": Timestamp::now().to_string(),
3484 "updated_at": Timestamp::now().to_string(),
3485 })
3486 .to_string(),
3487 )
3488 .unwrap();
3489
3490 let task = q.get("20260101-000000-bbbb").expect("must still read");
3491 assert_eq!(task.hold_source, None);
3492 assert!(task.operator_held());
3493 }
3494
3495 #[test]
3496 fn a_task_recorded_without_a_diagnostic_still_reads_as_none() {
3497 let (_dir, q) = queue();
3498 let path = q.path_of("20260101-000000-aaaa");
3499 std::fs::create_dir_all(q.root()).unwrap();
3500 std::fs::write(
3501 &path,
3502 serde_json::json!({
3503 "schema": SCHEMA,
3504 "id": "20260101-000000-aaaa",
3505 "title": "from before diagnostics existed",
3506 "instruction": "from before diagnostics existed",
3507 "repo": ".",
3508 "source": { "kind": "human" },
3509 "status": "held",
3510 "created_at": Timestamp::now().to_string(),
3511 "updated_at": Timestamp::now().to_string(),
3512 })
3513 .to_string(),
3514 )
3515 .unwrap();
3516
3517 let task = q.get("20260101-000000-aaaa").expect("must still read");
3518 assert!(task.diagnostic.is_none());
3519 }
3520
3521 #[test]
3522 fn a_schema_1_task_with_no_blocking_fields_still_reads() {
3523 let (_dir, q) = queue();
3527 let path = q.path_of("20260101-000000-aaaa");
3528 std::fs::create_dir_all(q.root()).unwrap();
3529 std::fs::write(
3530 &path,
3531 serde_json::json!({
3532 "schema": 1,
3533 "id": "20260101-000000-aaaa",
3534 "title": "from before blocking existed",
3535 "instruction": "from before blocking existed",
3536 "repo": ".",
3537 "source": { "kind": "human" },
3538 "status": "queued",
3539 "created_at": Timestamp::now().to_string(),
3540 "updated_at": Timestamp::now().to_string(),
3541 })
3542 .to_string(),
3543 )
3544 .unwrap();
3545
3546 let task = q.get("20260101-000000-aaaa").expect("must still read");
3547 assert!(task.blocked_by.is_empty());
3548 assert!(task.block_reason.is_none());
3549 assert!(task.answers.is_empty());
3550 assert!(task.review_branch.is_none());
3551 }
3552
3553 #[test]
3554 fn releasing_or_finishing_a_task_clears_its_stale_diagnostic() {
3555 let mut held = task("diagnosed");
3560 held.start("run-1".to_owned());
3561 held.fail("gate red", 1);
3562 held.diagnostic = Some("cargo test failed: ...".to_owned());
3563 assert_eq!(held.status, TaskStatus::Held);
3564
3565 held.release();
3566 assert!(held.diagnostic.is_none());
3567
3568 held.diagnostic = Some("cargo test failed: ...".to_owned());
3569 held.succeed();
3570 assert!(held.diagnostic.is_none());
3571 }
3572
3573 #[test]
3574 fn failing_a_task_always_clears_whatever_diagnostic_it_carried() {
3575 let mut t = task("retried");
3576 t.start("run-1".to_owned());
3577 t.diagnostic = Some("stale evidence from a previous hold".to_owned());
3578 t.fail("unrelated config error", 5);
3579 assert_eq!(t.status, TaskStatus::Failed);
3580 assert!(
3581 t.diagnostic.is_none(),
3582 "fail() must not let an old diagnostic outlive the run that produced it"
3583 );
3584 }
3585
3586 #[test]
3587 fn a_claim_is_exclusive_and_releases_on_drop() {
3588 let (_dir, q) = queue();
3589 let mut t = task("contended");
3590 q.put(&mut t).unwrap();
3591
3592 let held = q.claim(&t.id).unwrap();
3593 assert!(
3594 q.claim(&t.id).is_err(),
3595 "two daemons must not drive one task into two runs"
3596 );
3597 drop(held);
3598 assert!(q.claim(&t.id).is_ok(), "a released claim is reclaimable");
3599 }
3600
3601 #[test]
3602 fn a_round_trip_survives_disk() {
3603 let (_dir, q) = queue();
3604 let mut t = Task::new(
3605 "titled".to_owned(),
3606 "body".to_owned(),
3607 PathBuf::from("/repo"),
3608 Source::Agent {
3609 run: "20260101-000000-beef".to_owned(),
3610 node: "implement".to_owned(),
3611 },
3612 );
3613 t.priority = 3;
3614 q.put(&mut t).unwrap();
3615
3616 let back = q.get(&t.id).unwrap();
3617 assert_eq!(back.id, t.id);
3618 assert_eq!(back.priority, 3);
3619 assert_eq!(back.source.label(), "implement@beef");
3620 assert_eq!(q.get(t.short()).unwrap().id, t.id);
3622 }
3623
3624 #[test]
3625 fn an_unreadable_task_does_not_take_the_queue_down() {
3626 let (_dir, q) = queue();
3627 let mut t = task("fine");
3628 q.put(&mut t).unwrap();
3629 std::fs::write(q.root().join("broken.json"), "{ not json").unwrap();
3630
3631 let listed = q.list();
3632 assert_eq!(listed.len(), 1, "the readable task still lists");
3633 assert_eq!(listed[0].id, t.id);
3634 }
3635
3636 #[test]
3637 fn a_task_recorded_without_a_solo_field_still_reads_as_not_solo() {
3638 let (_dir, q) = queue();
3639 let path = q.path_of("20260101-000000-aaaa");
3640 std::fs::create_dir_all(q.root()).unwrap();
3641 std::fs::write(
3642 &path,
3643 serde_json::json!({
3644 "schema": SCHEMA,
3645 "id": "20260101-000000-aaaa",
3646 "title": "from before solo existed",
3647 "instruction": "from before solo existed",
3648 "repo": ".",
3649 "source": { "kind": "human" },
3650 "status": "queued",
3651 "created_at": Timestamp::now().to_string(),
3652 "updated_at": Timestamp::now().to_string(),
3653 })
3654 .to_string(),
3655 )
3656 .unwrap();
3657
3658 let task = q.get("20260101-000000-aaaa").expect("must still read");
3659 assert!(!task.solo, "a queue file with no `solo` field means false");
3660 }
3661
3662 #[test]
3663 fn a_task_recorded_without_an_urgent_field_still_reads_as_not_urgent() {
3664 let (_dir, q) = queue();
3665 let path = q.path_of("20260101-000000-bbbb");
3666 std::fs::create_dir_all(q.root()).unwrap();
3667 std::fs::write(
3668 &path,
3669 serde_json::json!({
3670 "schema": SCHEMA,
3671 "id": "20260101-000000-bbbb",
3672 "title": "from before urgent existed",
3673 "instruction": "from before urgent existed",
3674 "repo": ".",
3675 "source": { "kind": "human" },
3676 "status": "queued",
3677 "created_at": Timestamp::now().to_string(),
3678 "updated_at": Timestamp::now().to_string(),
3679 })
3680 .to_string(),
3681 )
3682 .unwrap();
3683
3684 let task = q.get("20260101-000000-bbbb").expect("must still read");
3685 assert!(
3686 !task.urgent,
3687 "a queue file with no `urgent` field means false, same as `solo`"
3688 );
3689 }
3690
3691 #[test]
3692 fn a_task_from_a_future_schema_is_refused_rather_than_guessed_at() {
3693 let (_dir, q) = queue();
3694 let mut t = task("from the future");
3695 q.put(&mut t).unwrap();
3696 let path = q.path_of(&t.id);
3697 let body = std::fs::read_to_string(&path)
3698 .unwrap()
3699 .replace(&format!("\"schema\": {SCHEMA}"), "\"schema\": 99");
3700 std::fs::write(&path, body).unwrap();
3701
3702 let err = q.get(&t.id).unwrap_err().to_string();
3703 assert!(err.contains("schema 99"), "{err}");
3704 }
3705
3706 #[test]
3707 fn revision_moves_when_the_queue_changes() {
3708 let (_dir, q) = queue();
3709 assert_eq!(q.revision(), 0, "an empty queue has no revision");
3710 let mut t = task("first");
3711 q.put(&mut t).unwrap();
3712 assert!(q.revision() > 0, "a written task moves the revision");
3713 }
3714
3715 #[test]
3716 fn revision_moves_when_deleting_an_older_task() {
3717 let (dir, q) = queue();
3718 let questions = Questions::at(dir.path().join("questions"));
3719 let mut t1 = task("older");
3720 q.put(&mut t1).unwrap();
3721 std::thread::sleep(std::time::Duration::from_millis(10));
3723 let mut t2 = task("newer");
3724 q.put(&mut t2).unwrap();
3725
3726 let rev_before = q.revision();
3727 q.remove(&t1.id, false, &questions).unwrap();
3728 let rev_after = q.revision();
3729
3730 assert_ne!(
3731 rev_before, rev_after,
3732 "deleting an older task must change the revision so other clients see the deletion"
3733 );
3734 }
3735
3736 #[test]
3737 fn removing_a_task_takes_it_out_of_the_listing() {
3738 let (dir, q) = queue();
3739 let questions = Questions::at(dir.path().join("questions"));
3740 let mut t = task("delete me");
3741 q.put(&mut t).unwrap();
3742 let removed = q.remove(t.short(), false, &questions).unwrap();
3743 assert_eq!(removed.id, t.id, "a prefix resolves before deleting");
3744 assert!(removed.released.is_empty() && removed.still_blocked.is_empty());
3745 assert!(q.list().is_empty());
3746 assert!(
3747 q.remove(&t.id, false, &questions).is_err(),
3748 "removing twice is an error"
3749 );
3750 }
3751
3752 #[test]
3753 fn removing_a_task_takes_its_stale_lock_with_it() {
3754 let (dir, q) = queue();
3755 let questions = Questions::at(dir.path().join("questions"));
3756 let mut t = task("interrupted");
3757 q.put(&mut t).unwrap();
3758
3759 let claim = q.claim(&t.id).unwrap();
3762 std::mem::forget(claim);
3763 assert!(
3764 q.claim(&t.id).is_err(),
3765 "the orphaned lock is what makes the task look claimed"
3766 );
3767
3768 let err = q.remove(&t.id, true, &questions).unwrap_err().to_string();
3770 assert!(err.contains("live daemon"), "{err}");
3771 assert!(q.get(&t.id).is_ok(), "a refused delete keeps the task");
3772
3773 q.remove(&t.id, false, &questions).unwrap();
3775 assert!(q.list().is_empty());
3776 let mut again = task("interrupted");
3777 again.id = t.id.clone();
3778 q.put(&mut again).unwrap();
3779 assert!(
3780 q.claim(&t.id).is_ok(),
3781 "a task that comes back must be claimable, which a left-behind lock would prevent"
3782 );
3783 }
3784
3785 fn notices_of(dir: &Path) -> Vec<crate::notices::Notice> {
3786 crate::notices::Notices::at(dir.join("notifications")).list()
3787 }
3788
3789 #[test]
3790 fn removing_a_sole_dependency_releases_the_dependent_without_a_hold() {
3791 let (dir, q) = queue();
3792 let questions = Questions::at(dir.path().join("questions"));
3793 let mut dep = task("dependency");
3794 q.put(&mut dep).unwrap();
3795 let mut blocked = task("waiting");
3796 blocked.block(vec![dep.id.clone()], Some("waits".to_owned()));
3797 q.put(&mut blocked).unwrap();
3798
3799 let removed = q.remove(&dep.id, false, &questions).unwrap();
3800 assert_eq!(removed.released, [blocked.id.clone()]);
3801 assert!(removed.still_blocked.is_empty());
3802
3803 let after = q.get(&blocked.id).unwrap();
3804 assert_eq!(after.status, TaskStatus::Queued);
3805 assert!(after.blocked_by.is_empty());
3806 assert!(after.block_reason.is_none());
3807 let notes = notices_of(dir.path());
3808 assert_eq!(notes.len(), 1, "{notes:?}");
3809 assert_eq!(notes[0].severity, crate::notices::Severity::Info);
3810 assert_eq!(
3811 notes[0].link,
3812 Some(crate::notices::Link::Task {
3813 id: blocked.id.clone(),
3814 })
3815 );
3816 }
3817
3818 #[test]
3819 fn removing_one_of_two_dependencies_keeps_the_other() {
3820 let (dir, q) = queue();
3821 let questions = Questions::at(dir.path().join("questions"));
3822 let mut dep = task("dependency");
3823 q.put(&mut dep).unwrap();
3824 let mut other = task("other");
3825 q.put(&mut other).unwrap();
3826 let mut blocked = task("waiting");
3827 blocked.block(
3828 vec![dep.id.clone(), other.id.clone()],
3829 Some("waits on both".to_owned()),
3830 );
3831 q.put(&mut blocked).unwrap();
3832
3833 let removed = q.remove(&dep.id, false, &questions).unwrap();
3834 assert!(removed.released.is_empty());
3835 assert_eq!(removed.still_blocked, [blocked.id.clone()]);
3836
3837 let after = q.get(&blocked.id).unwrap();
3838 assert_eq!(after.status, TaskStatus::Blocked);
3839 assert_eq!(after.blocked_by, [other.id.clone()]);
3840 assert!(missing_blockers(&q, &questions, &after.blocked_by).is_empty());
3843 }
3844
3845 #[test]
3846 fn a_dependency_deleted_before_its_dependents_were_rewritten_is_released_later() {
3847 let (dir, q) = queue();
3848 let questions = Questions::at(dir.path().join("questions"));
3849 let mut dep = task("dependency");
3850 q.put(&mut dep).unwrap();
3851 let mut blocked = task("waiting");
3852 blocked.block(vec![dep.id.clone()], None);
3853 q.put(&mut blocked).unwrap();
3854
3855 let claim = q.claim(&blocked.id).unwrap();
3857 let removed = q.remove(&dep.id, false, &questions).unwrap();
3858 assert!(removed.released.is_empty());
3859 drop(claim);
3860
3861 let mut task = q.get(&blocked.id).unwrap();
3862 assert_eq!(task.status, TaskStatus::Blocked);
3863 assert!(missing_blockers(&q, &questions, &task.blocked_by).is_empty());
3864 assert_eq!(q.apply_deleted_blockers(&mut task), [dep.id.clone()]);
3865 assert_eq!(task.status, TaskStatus::Queued);
3866 }
3867
3868 #[test]
3869 fn a_dependency_without_a_tombstone_is_still_missing() {
3870 let (dir, q) = queue();
3871 let questions = Questions::at(dir.path().join("questions"));
3872 let mut dep = task("dependency");
3873 q.put(&mut dep).unwrap();
3874 std::fs::remove_file(q.path_of(&dep.id)).unwrap();
3875 assert_eq!(
3876 missing_blockers(&q, &questions, std::slice::from_ref(&dep.id)),
3877 [dep.id.clone()]
3878 );
3879 assert!(deleted_blockers(&q, std::slice::from_ref(&dep.id)).is_empty());
3880 }
3881
3882 #[test]
3883 fn a_held_dependent_is_left_alone_by_a_dependency_deletion() {
3884 let mut t = task("held");
3885 t.hold_machine(Some("because".to_owned()));
3886 t.blocked_by = vec!["gone".to_owned()];
3887 assert!(!t.dependency_deleted("gone"));
3888 assert_eq!(t.status, TaskStatus::Held);
3889 }
3890
3891 #[test]
3892 fn a_failed_record_removal_takes_the_tombstone_back() {
3893 let (_dir, q) = queue();
3894 let mut t = task("stays");
3895 q.put(&mut t).unwrap();
3896 q.write_tombstone(&t.id).unwrap();
3897 let err = q.remove_record_with_attachments(&t.id, |_| Err(std::io::Error::other("nope")));
3898 assert!(err.is_err());
3899 assert!(!q.was_deleted(&t.id));
3902 }
3903
3904 fn source_file(dir: &Path, name: &str, body: &str) -> PathBuf {
3905 let p = dir.join(name);
3906 std::fs::write(&p, body).unwrap();
3907 p
3908 }
3909
3910 #[test]
3911 fn an_attachment_copy_survives_deleting_its_source() {
3912 let (dir, q) = queue();
3913 let src = source_file(dir.path(), "shot.png", "pixels");
3914 let mut t = task("with a picture");
3915 let names = q.attach(&mut t, std::slice::from_ref(&src)).unwrap();
3916 q.put(&mut t).unwrap();
3917 std::fs::remove_file(&src).unwrap();
3918 assert_eq!(names, ["shot.png"]);
3919 let loaded = q.get(&t.id).unwrap();
3920 let paths = q.attachment_paths(&loaded);
3921 assert_eq!(paths.len(), 1);
3922 assert_eq!(std::fs::read_to_string(&paths[0]).unwrap(), "pixels");
3923 }
3924
3925 #[test]
3926 fn attachment_names_that_could_traverse_or_are_odd_are_refused() {
3927 let (dir, q) = queue();
3928 let mut t = task("bad names");
3929 for name in ["a..b.png", ".hidden", "with space.png", "-x.png"] {
3930 let src = source_file(dir.path(), name, "x");
3931 assert!(
3932 q.attach(&mut t, &[src]).is_err(),
3933 "`{name}` must be refused"
3934 );
3935 }
3936 assert!(!crate::ask::valid_asset_name("C:foo.png"));
3940 #[cfg(not(windows))]
3941 {
3942 let src = source_file(dir.path(), "C:foo.png", "x");
3943 assert!(q.attach(&mut t, &[src]).is_err());
3944 }
3945 let long = format!("{}.png", "a".repeat(70));
3946 let src = source_file(dir.path(), &long, "x");
3947 assert!(q.attach(&mut t, &[src]).is_err());
3948 assert!(t.attachments.is_empty());
3949 assert!(!q.attachments_dir(&t.id).exists());
3950 }
3951
3952 #[test]
3953 fn a_taken_attachment_name_is_numbered_not_overwritten() {
3954 let (dir, q) = queue();
3955 let a = source_file(dir.path(), "shot.png", "one");
3956 let sub = dir.path().join("other");
3957 std::fs::create_dir_all(&sub).unwrap();
3958 let b = source_file(&sub, "shot.png", "two");
3959 let mut t = task("collision");
3960 q.attach(&mut t, &[a]).unwrap();
3961 q.attach(&mut t, &[b]).unwrap();
3962 assert_eq!(t.attachments, ["shot.png", "shot-2.png"]);
3963 let paths = q.attachment_paths(&t);
3964 assert_eq!(std::fs::read_to_string(&paths[0]).unwrap(), "one");
3965 assert_eq!(std::fs::read_to_string(&paths[1]).unwrap(), "two");
3966 }
3967
3968 #[test]
3969 fn a_renumbered_name_stays_inside_the_length_bound() {
3970 let (dir, q) = queue();
3971 let name = format!("{}.png", "a".repeat(60));
3972 assert_eq!(name.len(), 64);
3973 let a = source_file(dir.path(), &name, "one");
3974 let sub = dir.path().join("other");
3975 std::fs::create_dir_all(&sub).unwrap();
3976 let b = source_file(&sub, &name, "two");
3977 let mut t = task("long");
3978 q.attach(&mut t, &[a, b]).unwrap();
3979 assert_eq!(t.attachments.len(), 2);
3980 assert!(
3981 t.attachments
3982 .iter()
3983 .all(|n| crate::ask::valid_asset_name(n))
3984 );
3985 assert!(t.attachments[1].ends_with("-2.png"));
3986 }
3987
3988 #[test]
3989 fn a_failed_attach_keeps_existing_attachments_and_leaves_no_partial_copy() {
3990 let (dir, q) = queue();
3991 let good = source_file(dir.path(), "good.png", "ok");
3992 let mut t = task("partial");
3993 q.attach(&mut t, &[good]).unwrap();
3994 let more = source_file(dir.path(), "more.png", "ok");
3995 let missing = dir.path().join("missing.png");
3996 assert!(q.attach(&mut t, &[more, missing]).is_err());
3997 assert_eq!(t.attachments, ["good.png"]);
3998 let on_disk: Vec<_> = std::fs::read_dir(q.attachments_dir(&t.id))
3999 .unwrap()
4000 .flatten()
4001 .collect();
4002 assert_eq!(on_disk.len(), 1);
4003 }
4004
4005 fn block_put(q: &Queue, t: &Task) -> PathBuf {
4007 let tmp = q.path_of(&t.id).with_extension("json.tmp");
4008 std::fs::create_dir_all(&tmp).unwrap();
4009 tmp
4010 }
4011
4012 #[test]
4013 fn a_failed_put_leaves_no_new_attachment_directory() {
4014 let (dir, q) = queue();
4015 let mut t = task("fresh");
4016 let tmp = block_put(&q, &t);
4017 let src = source_file(dir.path(), "shot.png", "x");
4018 assert!(q.attach_and_put(&mut t, &[src]).is_err());
4019 assert!(t.attachments.is_empty());
4020 assert!(!q.attachments_dir(&t.id).exists());
4021 assert!(!q.path_of(&t.id).exists());
4022 std::fs::remove_dir(tmp).unwrap();
4023 }
4024
4025 #[test]
4026 fn a_failed_put_removes_only_the_copy_it_just_made() {
4027 let (dir, q) = queue();
4028 let mut t = task("edited");
4029 let first = source_file(dir.path(), "first.png", "1");
4030 q.attach_and_put(&mut t, &[first]).unwrap();
4031 block_put(&q, &t);
4032 let second = source_file(dir.path(), "second.png", "2");
4033 assert!(q.attach_and_put(&mut t, &[second]).is_err());
4034 assert_eq!(t.attachments, ["first.png"]);
4035 let on_disk: Vec<_> = std::fs::read_dir(q.attachments_dir(&t.id))
4036 .unwrap()
4037 .flatten()
4038 .map(|e| e.file_name().to_string_lossy().into_owned())
4039 .collect();
4040 assert_eq!(on_disk, ["first.png"]);
4041 assert_eq!(q.get(&t.id).unwrap().attachments, ["first.png"]);
4042 }
4043
4044 #[test]
4045 fn a_leftover_removing_directory_is_swept_by_the_next_removal() {
4046 let (dir, q) = queue();
4047 let questions = Questions::at(dir.path().join("questions"));
4048 let gone = task("gone");
4049 let mut other = task("other");
4050 let mut live = task("live");
4051 q.put(&mut other).unwrap();
4052 q.put(&mut live).unwrap();
4053 let orphan = q.root.join(format!("{}.attachments.removing", gone.id));
4056 std::fs::create_dir_all(&orphan).unwrap();
4057 std::fs::write(orphan.join("shot.png"), "x").unwrap();
4058 let busy = q.root.join(format!("{}.attachments.removing", live.id));
4061 std::fs::create_dir_all(&busy).unwrap();
4062
4063 q.remove(&other.id, false, &questions).unwrap();
4064 assert!(!orphan.exists(), "an orphan is swept");
4065 assert!(busy.exists(), "a removal in progress is left alone");
4066 }
4067
4068 #[test]
4069 fn a_blocked_aside_rename_fails_the_removal_and_loses_nothing() {
4070 let (dir, q) = queue();
4071 let questions = Questions::at(dir.path().join("questions"));
4072 let mut t = task("stuck");
4073 let src = source_file(dir.path(), "shot.png", "x");
4074 q.attach_and_put(&mut t, &[src]).unwrap();
4075 let aside = q.root.join(format!("{}.attachments.removing", t.id));
4078 std::fs::create_dir_all(&aside).unwrap();
4079 std::fs::write(aside.join("old.png"), "o").unwrap();
4080 assert!(q.remove(&t.id, false, &questions).is_err());
4081 assert!(q.path_of(&t.id).exists());
4082 assert!(q.attachments_dir(&t.id).join("shot.png").is_file());
4083 }
4084
4085 #[test]
4086 fn a_failed_record_removal_puts_the_attachments_back() {
4087 let (dir, q) = queue();
4088 let mut t = task("rollback");
4089 let src = source_file(dir.path(), "shot.png", "x");
4090 q.attach_and_put(&mut t, &[src]).unwrap();
4091 let err = q
4092 .remove_record_with_attachments(&t.id, |_| {
4093 Err(std::io::Error::other("injected failure"))
4094 })
4095 .unwrap_err();
4096 assert!(format!("{err:#}").contains("injected failure"));
4097 assert!(q.path_of(&t.id).exists());
4098 assert!(q.attachments_dir(&t.id).join("shot.png").is_file());
4099 assert!(
4100 !q.root
4101 .join(format!("{}.attachments.removing", t.id))
4102 .exists()
4103 );
4104 }
4105
4106 #[test]
4107 fn editing_a_task_keeps_its_attachments() {
4108 let (dir, q) = queue();
4109 let src = source_file(dir.path(), "shot.png", "x");
4110 let mut t = task("editable");
4111 q.attach(&mut t, &[src]).unwrap();
4112 t.edit("new".to_owned(), "new text".to_owned()).unwrap();
4113 q.put(&mut t).unwrap();
4114 assert_eq!(q.get(&t.id).unwrap().attachments, ["shot.png"]);
4115 }
4116
4117 #[test]
4118 fn removing_a_task_deletes_its_attachments() {
4119 let (dir, q) = queue();
4120 let questions = Questions::at(dir.path().join("questions"));
4121 let src = source_file(dir.path(), "shot.png", "x");
4122 let mut t = task("doomed");
4123 q.attach(&mut t, &[src]).unwrap();
4124 q.put(&mut t).unwrap();
4125 assert!(q.attachments_dir(&t.id).is_dir());
4126 q.remove(&t.id, false, &questions).unwrap();
4127 assert!(!q.attachments_dir(&t.id).exists());
4128 assert!(q.list().is_empty());
4129 }
4130
4131 #[test]
4132 fn a_task_written_before_attachments_still_reads() {
4133 let (_dir, q) = queue();
4134 let mut t = task("old");
4135 q.put(&mut t).unwrap();
4136 let path = q.path_of(&t.id);
4137 let mut v: serde_json::Value =
4138 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
4139 v.as_object_mut().unwrap().remove("attachments");
4140 std::fs::write(&path, v.to_string()).unwrap();
4141 assert!(q.get(&t.id).unwrap().attachments.is_empty());
4142 }
4143
4144 #[test]
4145 fn attachment_paths_are_absolute_even_when_the_root_is_relative() {
4146 let q = Queue::at(PathBuf::from("relative-queue"));
4147 let mut t = task("rel");
4148 t.attachments.push("shot.png".to_owned());
4149 let paths = q.attachment_paths(&t);
4150 assert!(paths[0].is_absolute(), "{}", paths[0].display());
4151 assert!(paths[0].ends_with(format!("{}.attachments/shot.png", t.id)));
4152 }
4153
4154 #[test]
4155 fn link_run_adds_a_run_once_and_touches_nothing_else() {
4156 let dir = tempfile::tempdir().unwrap();
4157 let queue = Queue::at(dir.path().join("queue"));
4158 let mut t = Task::new(
4159 "t".to_owned(),
4160 "do it".to_owned(),
4161 PathBuf::from("."),
4162 Source::Human,
4163 );
4164 queue.put(&mut t).unwrap();
4165 let before = queue.get(&t.id).unwrap();
4166
4167 let linked = queue.link_run(&t.id[..4], "20260930-092817-ec34").unwrap();
4168 assert_eq!(linked.runs, vec!["20260930-092817-ec34".to_owned()]);
4169 assert_eq!(linked.status, before.status);
4170 assert_eq!(linked.attempts, before.attempts);
4171 assert_eq!(linked.interrupt, before.interrupt);
4172
4173 let again = queue.link_run(&t.id, "20260930-092817-ec34").unwrap();
4174 assert_eq!(again.runs.len(), 1, "linking twice must not duplicate");
4175 assert_eq!(queue.get(&t.id).unwrap().runs.len(), 1);
4176 assert!(queue.link_run("no-such-task", "r").is_err());
4177 }
4178
4179 #[test]
4180 fn put_keeps_a_run_linked_after_the_writer_took_its_snapshot() {
4181 let dir = tempfile::tempdir().unwrap();
4182 let queue = Queue::at(dir.path().join("queue"));
4183 let mut t = Task::new(
4184 "t".to_owned(),
4185 "do it".to_owned(),
4186 PathBuf::from("."),
4187 Source::Human,
4188 );
4189 queue.put(&mut t).unwrap();
4190 let mut snapshot = queue.get(&t.id).unwrap();
4192 queue.link_run(&t.id, "20260930-092817-ec34").unwrap();
4193
4194 snapshot.start("20260930-000000-aaaa".to_owned());
4195 queue.put(&mut snapshot).unwrap();
4196
4197 let stored = queue.get(&t.id).unwrap();
4198 assert!(stored.runs.contains(&"20260930-092817-ec34".to_owned()));
4199 assert!(stored.runs.contains(&"20260930-000000-aaaa".to_owned()));
4200 assert_eq!(stored.attempts, 1);
4201 }
4202
4203 #[test]
4204 fn concurrent_links_and_daemon_saves_lose_nothing() {
4205 let dir = tempfile::tempdir().unwrap();
4206 let queue = Queue::at(dir.path().join("queue"));
4207 let mut t = Task::new(
4208 "t".to_owned(),
4209 "do it".to_owned(),
4210 PathBuf::from("."),
4211 Source::Human,
4212 );
4213 queue.put(&mut t).unwrap();
4214 let id = t.id.clone();
4215
4216 let linkers: Vec<_> = (0..4)
4217 .map(|n| {
4218 let (queue, id) = (queue.clone(), id.clone());
4219 std::thread::spawn(move || {
4220 for k in 0..10 {
4221 queue
4222 .link_run(&id, &format!("20260930-00000{n}-l{k:03}"))
4223 .unwrap();
4224 }
4225 })
4226 })
4227 .collect();
4228 let mut mine = queue.get(&id).unwrap();
4231 for k in 0..10 {
4232 mine.start(format!("20260930-000009-d{k:03}"));
4233 queue.put(&mut mine).unwrap();
4234 }
4235 for l in linkers {
4236 l.join().unwrap();
4237 }
4238
4239 let stored = queue.get(&id).unwrap();
4240 assert_eq!(stored.runs.len(), 50, "{:?}", stored.runs);
4241 assert_eq!(
4242 stored.attempts, 10,
4243 "linking never rewinds the daemon's work"
4244 );
4245 }
4246
4247 fn age_lock(path: &Path) {
4248 let f = std::fs::OpenOptions::new().write(true).open(path).unwrap();
4249 f.set_modified(std::time::SystemTime::now() - std::time::Duration::from_secs(60))
4250 .unwrap();
4251 }
4252
4253 #[test]
4254 fn concurrent_stale_takeover_yields_one_holder() {
4255 use std::sync::atomic::{AtomicUsize, Ordering};
4256 use std::sync::{Arc, Barrier};
4257 for _ in 0..5 {
4258 let dir = tempfile::tempdir().unwrap();
4259 let q = Queue::at(dir.path().to_path_buf());
4260 let lock = dir.path().join("t.write-lock");
4261 std::fs::write(&lock, "dead-0000").unwrap();
4262 age_lock(&lock);
4263 let n = 6;
4264 let barrier = Arc::new(Barrier::new(n));
4265 let (now, max) = (Arc::new(AtomicUsize::new(0)), Arc::new(AtomicUsize::new(0)));
4266 let handles: Vec<_> = (0..n)
4267 .map(|_| {
4268 let (q, b, now, max) = (q.clone(), barrier.clone(), now.clone(), max.clone());
4269 std::thread::spawn(move || {
4270 b.wait();
4271 let g = q.lock_task("t").unwrap();
4272 let held = now.fetch_add(1, Ordering::SeqCst) + 1;
4273 max.fetch_max(held, Ordering::SeqCst);
4274 std::thread::sleep(std::time::Duration::from_millis(20));
4275 now.fetch_sub(1, Ordering::SeqCst);
4276 drop(g);
4277 })
4278 })
4279 .collect();
4280 for h in handles {
4281 h.join().unwrap();
4282 }
4283 assert_eq!(max.load(Ordering::SeqCst), 1);
4284 assert!(!lock.exists());
4285 }
4286 }
4287
4288 #[test]
4289 fn dropping_a_stolen_lock_leaves_the_new_holders_lock() {
4290 let dir = tempfile::tempdir().unwrap();
4291 let q = Queue::at(dir.path().to_path_buf());
4292 let lock = dir.path().join("t.write-lock");
4293 let a = q.lock_task("t").unwrap();
4294 age_lock(&lock);
4295 let b = q.lock_task("t").unwrap();
4296 assert_ne!(a.token, b.token);
4297 drop(a);
4298 assert_eq!(std::fs::read_to_string(&lock).unwrap(), b.token);
4299 drop(b);
4300 assert!(!lock.exists());
4301 }
4302
4303 #[test]
4304 fn break_stale_leaves_a_lock_that_replaced_the_one_judged() {
4305 let dir = tempfile::tempdir().unwrap();
4306 let lock = dir.path().join("t.write-lock");
4307 std::fs::write(&lock, "new-token").unwrap();
4308 assert!(!break_stale(&lock, "old-token"));
4309 assert_eq!(std::fs::read_to_string(&lock).unwrap(), "new-token");
4310 assert!(!break_stale(&lock, "new-token"));
4312 assert!(lock.exists());
4313 age_lock(&lock);
4314 assert!(break_stale(&lock, "new-token"));
4315 assert!(!lock.exists());
4316 }
4317
4318 #[test]
4319 fn a_stale_break_marker_is_recovered_and_a_fresh_one_is_respected() {
4320 let dir = tempfile::tempdir().unwrap();
4321 let lock = dir.path().join("t.write-lock");
4322 std::fs::write(&lock, "dead-1").unwrap();
4323 age_lock(&lock);
4324 let marker = break_marker(&lock, "dead-1");
4325 std::fs::write(&marker, "crashed-remover").unwrap();
4326 assert!(!break_stale(&lock, "dead-1"));
4328 assert!(lock.exists());
4329 age_lock(&marker);
4331 assert!(break_stale(&lock, "dead-1"));
4332 assert!(!lock.exists());
4333 assert!(!marker.exists());
4334 }
4335
4336 fn chat_task() -> Task {
4337 Task::new(
4338 "t".to_owned(),
4339 "do t".to_owned(),
4340 PathBuf::from("."),
4341 Source::Agent {
4342 run: "20260902-000000-beef".to_owned(),
4343 node: CHAT_NODE.to_owned(),
4344 },
4345 )
4346 }
4347
4348 #[test]
4349 fn a_chat_notice_is_due_only_for_a_final_state_and_only_once() {
4350 let mut t = chat_task();
4351 assert_eq!(t.chat_notice_due(), None, "queued");
4352 t.start("r1".to_owned());
4353 assert_eq!(t.chat_notice_due(), None, "running");
4354 t.fail("boom", 3);
4355 assert_eq!(t.status, TaskStatus::Failed);
4356 assert_eq!(t.chat_notice_due(), None, "a retried failure is not final");
4357 t.start("r2".to_owned());
4358 t.park("upgrade");
4359 assert_eq!(t.chat_notice_due(), None, "parked");
4360 t.fail("boom", 1);
4361 assert_eq!(t.status, TaskStatus::Held);
4362 let key = t
4363 .chat_notice_due()
4364 .expect("held on the last attempt is final");
4365 t.chat_notice = Some(ChatNotice {
4366 key,
4367 at: Timestamp::now(),
4368 outcome: ChatNoticeOutcome::Posted,
4369 });
4370 assert_eq!(t.chat_notice_due(), None, "same hold, no second notice");
4371 t.release();
4372 assert_eq!(t.chat_notice_due(), None, "requeued after a release");
4373 t.start("r3".to_owned());
4374 t.succeed();
4375 assert!(t.chat_notice_due().is_some(), "done is a new final state");
4376 }
4377
4378 #[test]
4379 fn completed_chat_notices_keep_current_and_legacy_keys_after_requeue() {
4380 for key in ["done", "done#0", "done#1", "done#12"] {
4381 let mut t = chat_task();
4382 t.start("r1".to_owned());
4383 t.succeed();
4384 t.chat_notice = Some(ChatNotice {
4385 key: key.to_owned(),
4386 at: Timestamp::now(),
4387 outcome: ChatNoticeOutcome::Posted,
4388 });
4389 t.requeue();
4390 t.start("r2".to_owned());
4391 t.succeed();
4392 assert_eq!(t.chat_notice_key().as_deref(), Some("done"));
4393 assert_eq!(t.chat_notice_due(), None, "recorded key: {key}");
4394 }
4395 }
4396
4397 #[test]
4398 fn a_new_hold_after_a_release_is_a_new_cause() {
4399 let mut t = chat_task();
4400 t.hold_manual(Some("wait".to_owned()));
4401 let first = t.chat_notice_due().expect("held");
4402 t.chat_notice = Some(ChatNotice {
4403 key: first.clone(),
4404 at: Timestamp::now(),
4405 outcome: ChatNoticeOutcome::Posted,
4406 });
4407 t.hold_manual(Some("still waiting".to_owned()));
4408 assert_eq!(t.chat_notice_due(), None, "a re-hold keeps its start");
4409 t.release();
4410 std::thread::sleep(std::time::Duration::from_millis(5));
4411 t.hold_manual(None);
4412 let second = t.chat_notice_due().expect("a fresh hold");
4413 assert_ne!(first, second);
4414 }
4415
4416 #[test]
4417 fn a_chat_notice_needs_a_chat_source_and_a_current_schema() {
4418 let mut human = task("h");
4419 human.succeed();
4420 assert_eq!(human.chat_notice_due(), None);
4421 let mut inherited = task("i");
4423 inherited.origin_chat = Some("20260902-000000-beef".to_owned());
4424 inherited.succeed();
4425 assert_eq!(inherited.chat_notice_due(), None);
4426 let mut old = chat_task();
4427 old.schema = 12;
4428 old.status = TaskStatus::Done;
4429 assert_eq!(old.chat_notice_due(), None, "history is not replayed");
4430 let mut live = chat_task();
4431 live.schema = 12;
4432 live.start("r1".to_owned());
4433 live.succeed();
4434 assert!(live.chat_notice_due().is_some(), "started by this build");
4435 let mut held = chat_task();
4436 held.schema = 12;
4437 held.hold_machine(Some("config".to_owned()));
4438 assert!(held.chat_notice_due().is_some(), "held before any start");
4439 }
4440
4441 #[test]
4442 fn a_stale_snapshot_does_not_rewind_a_recorded_chat_notice() {
4443 let tmp = tempfile::tempdir().expect("tempdir");
4444 let q = Queue::at(tmp.path().join("queue"));
4445 let mut t = chat_task();
4446 t.succeed();
4447 q.put(&mut t).expect("put");
4448 let mut stale = q.get(&t.id).expect("get");
4449 let key = t.chat_notice_due().expect("due");
4450 q.modify(&t.id, |x| {
4451 x.chat_notice = Some(ChatNotice {
4452 key: key.clone(),
4453 at: Timestamp::now(),
4454 outcome: ChatNoticeOutcome::Posted,
4455 });
4456 true
4457 })
4458 .expect("modify");
4459 q.put(&mut stale).expect("stale put");
4460 assert_eq!(q.get(&t.id).expect("get").chat_notice_due(), None);
4461 }
4462}