1use std::collections::HashMap;
34use std::num::NonZeroUsize;
35use std::path::{Path, PathBuf};
36
37use anyhow::{Context, Result, bail};
38use jiff::Timestamp;
39use serde::{Deserialize, Serialize};
40
41use crate::ask::Questions;
42use crate::id_reserve::{IdSource, path_exists};
43
44pub const SCHEMA: u32 = 20;
135
136pub(crate) fn dispatch_order(a: &Task, b: &Task) -> std::cmp::Ordering {
142 match (a.upgrade_resume_key(), b.upgrade_resume_key()) {
143 (Some(a), Some(b)) => a.cmp(&b),
144 (Some(_), None) => std::cmp::Ordering::Less,
145 (None, Some(_)) => std::cmp::Ordering::Greater,
146 (None, None) => b.priority.cmp(&a.priority).then(a.id.cmp(&b.id)),
147 }
148}
149
150#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
152#[serde(rename_all = "lowercase")]
153pub enum HoldSource {
154 Manual,
156 Machine,
158}
159
160impl HoldSource {
161 pub fn label(self) -> &'static str {
163 match self {
164 Self::Manual => "manual",
165 Self::Machine => "machine",
166 }
167 }
168}
169
170#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
173#[serde(tag = "kind", rename_all = "lowercase")]
174pub enum Source {
175 Human,
177 Agent {
180 run: String,
182 node: String,
184 },
185 Issue {
187 number: u64,
189 repo: String,
191 },
192}
193
194pub const CHAT_NODE: &str = "chat";
197
198impl Source {
199 pub fn label(&self) -> String {
201 match self {
202 Self::Human => "human".to_owned(),
203 Self::Agent { run, node } => format!("{node}@{}", short(run)),
204 Self::Issue { number, .. } => format!("issue #{number}"),
205 }
206 }
207}
208
209#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
211#[serde(rename_all = "lowercase")]
212pub enum TaskStatus {
213 Queued,
215 Running,
217 Done,
219 Failed,
221 Held,
223 Blocked,
227 Parked,
232}
233
234impl TaskStatus {
235 pub fn runnable(self) -> bool {
237 matches!(self, Self::Queued | Self::Failed | Self::Parked)
238 }
239
240 pub fn as_str(self) -> &'static str {
242 match self {
243 Self::Queued => "queued",
244 Self::Running => "running",
245 Self::Done => "done",
246 Self::Failed => "failed",
247 Self::Held => "held",
248 Self::Blocked => "blocked",
249 Self::Parked => "parked",
250 }
251 }
252}
253
254#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
257pub struct TaskCounts {
258 pub queued: usize,
260 pub running: usize,
262 pub done: usize,
264 pub failed: usize,
266 pub held: usize,
268 pub blocked: usize,
270 pub parked: usize,
272}
273
274impl TaskCounts {
275 pub fn of(tasks: &[Task]) -> Self {
279 Self::of_statuses(tasks.iter().map(|task| task.status))
280 }
281
282 pub(crate) fn of_statuses(statuses: impl IntoIterator<Item = TaskStatus>) -> Self {
284 let mut counts = Self::default();
285 for status in statuses {
286 match status {
287 TaskStatus::Queued => counts.queued += 1,
288 TaskStatus::Running => counts.running += 1,
289 TaskStatus::Done => counts.done += 1,
290 TaskStatus::Failed => counts.failed += 1,
291 TaskStatus::Held => counts.held += 1,
292 TaskStatus::Blocked => counts.blocked += 1,
293 TaskStatus::Parked => counts.parked += 1,
294 }
295 }
296 counts
297 }
298}
299
300#[derive(Debug, Clone, Serialize, Deserialize)]
302#[serde(deny_unknown_fields)]
303pub struct Task {
304 pub schema: u32,
306 pub id: String,
308 pub title: String,
310 pub instruction: String,
312 #[serde(default, skip_serializing_if = "crate::refs::Sources::is_empty")]
314 pub sources: crate::refs::Sources,
315 pub repo: PathBuf,
317 pub source: Source,
319 #[serde(default)]
321 pub chat: Option<String>,
322 #[serde(default)]
324 pub priority: i32,
325 #[serde(default)]
336 pub solo: bool,
337 pub status: TaskStatus,
339 #[serde(default)]
341 pub attempts: usize,
342 #[serde(default)]
344 pub attempt_limit: Option<NonZeroUsize>,
345 #[serde(default)]
348 pub runs: Vec<String>,
349 #[serde(default)]
351 pub last_error: Option<String>,
352 #[serde(default)]
365 pub hold_reason: Option<String>,
366 #[serde(default)]
369 pub hold_source: Option<HoldSource>,
370 #[serde(default)]
382 pub diagnostic: Option<String>,
383 #[serde(default)]
393 pub blocked_by: Vec<String>,
394 #[serde(default)]
397 pub block_reason: Option<String>,
398 #[serde(default)]
411 pub blocked_from: Option<TaskStatus>,
412 #[serde(default)]
414 pub blocked_at: Option<Timestamp>,
415 #[serde(default)]
426 pub answers: Vec<AnsweredQuestion>,
427 #[serde(default)]
433 pub triage_applied: Vec<String>,
434 #[serde(default)]
440 pub actions_applied: Vec<String>,
441 #[serde(default)]
446 pub resume_override: Option<OperatorResume>,
447 #[serde(default)]
450 pub resumption: Option<Resumption>,
451 #[serde(default)]
453 pub resumption_history: Vec<Resumption>,
454 #[serde(default)]
456 pub operator_decided_at: Option<Timestamp>,
457 #[serde(default)]
469 pub review_branch: Option<String>,
470 #[serde(default)]
472 pub review_instruction: Option<String>,
473 #[serde(default)]
475 pub pr_recovery: Option<crate::pr_recovery::Selection>,
476 #[serde(default)]
479 pub fresh_start: bool,
480 #[serde(default)]
482 pub replacement: Option<crate::merge_decision::Replacement>,
483 #[serde(default)]
494 pub interrupt: bool,
495 #[serde(default)]
518 pub urgent: bool,
519 #[serde(skip)]
524 pub(crate) unfiled: bool,
525 #[serde(default)]
531 pub attachments: Vec<String>,
532 #[serde(default)]
536 pub followup: Option<FollowUp>,
537 #[serde(default)]
543 pub origin_chat: Option<String>,
544 #[serde(default)]
551 pub overrides: Option<RunOverrides>,
552 #[serde(default)]
559 pub review_of: Option<String>,
560 #[serde(default)]
565 pub held_at: Option<Timestamp>,
566 #[serde(default)]
570 pub park_reason: Option<String>,
571 #[serde(default)]
574 pub chat_notice: Option<ChatNotice>,
575 #[serde(default)]
577 pub chat_notices: Vec<String>,
578 #[serde(default)]
581 pub hold_notice_cause: Option<NoticeCause>,
582 #[serde(default)]
587 pub passes: Vec<TaskPass>,
588 #[serde(default)]
592 pub dispatched_at: Option<Timestamp>,
593 #[serde(default)]
595 pub recovery_budget: Option<RecoveryBudget>,
596 #[serde(default)]
599 pub recovery_admission: Option<RecoveryAdmission>,
600 #[serde(default)]
603 pub recovery_generations: Vec<RecoveryBudget>,
604 pub created_at: Timestamp,
606 pub updated_at: Timestamp,
608}
609
610#[derive(Debug, Clone, Serialize, Deserialize)]
612pub struct RecoveryAdmission {
613 pub base: String,
615 pub inputs: String,
617}
618
619#[derive(Debug, Clone, Serialize, Deserialize)]
622pub struct RecoveryBudget {
623 pub instruction: String,
625 pub base: String,
627 pub inputs: String,
629 pub stage: String,
631 pub failures: usize,
633 #[serde(default)]
636 pub progress: Option<String>,
637}
638
639fn recovery_progress(state: &crate::run::RunState) -> Option<String> {
640 use crate::proc::Quiet as _;
641 use std::hash::{Hash, Hasher};
642 let mut patches = Vec::new();
643 for candidate in state.candidates.iter().filter(|c| c.commits > 0) {
644 let output = std::process::Command::new("git")
645 .current_dir(&state.repo)
646 .args(["diff", "--binary", &state.base_commit, &candidate.branch])
647 .quiet()
648 .output()
649 .ok()?;
650 if !output.status.success() {
651 return None;
652 }
653 patches.push(output.stdout);
654 }
655 patches.sort();
656 patches.dedup();
657 let mut hash = std::collections::hash_map::DefaultHasher::new();
658 patches.hash(&mut hash);
659 Some(format!("{:x}", hash.finish()))
660}
661
662pub(crate) fn recovery_inputs(sources: &crate::refs::Sources) -> String {
666 serde_json::to_string(
667 &sources
668 .start_from
669 .iter()
670 .map(|i| ("start", &i.token, &i.sha))
671 .chain(
672 sources
673 .pick_commits
674 .iter()
675 .map(|i| ("pick", &i.token, &i.sha)),
676 )
677 .collect::<Vec<_>>(),
678 )
679 .unwrap_or_default()
680}
681
682#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
684pub struct NoticeCause {
685 pub stage: String,
687 #[serde(default, skip_serializing_if = "Option::is_none")]
690 pub target: Option<String>,
691 pub run: Option<String>,
693 pub pass: Option<usize>,
695}
696
697pub(crate) fn is_done_notice_key(key: &str) -> bool {
699 key == "done"
700 || key
701 .strip_prefix("done#")
702 .is_some_and(|count| !count.is_empty() && count.bytes().all(|b| b.is_ascii_digit()))
703}
704
705#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
707#[serde(rename_all = "snake_case")]
708pub enum PassCost {
709 Spent,
711 Refunded,
713}
714
715#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
717pub struct TaskPass {
718 pub run: String,
720 pub pass_index: usize,
722 pub cost: PassCost,
724 pub attempts_after: usize,
726 #[serde(default)]
728 pub started_at: Option<Timestamp>,
729 pub settled_at: Timestamp,
731 #[serde(default)]
733 pub parked: Option<crate::run::ParkCause>,
734}
735
736#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
738#[serde(rename_all = "lowercase")]
739pub enum ChatNoticeOutcome {
740 Posted,
742 Skipped,
744}
745
746#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
748pub struct ChatNotice {
749 pub key: String,
751 pub at: Timestamp,
753 pub outcome: ChatNoticeOutcome,
755}
756
757#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
759pub struct RunOverrides {
760 #[serde(default)]
763 pub inherit_daemon_options: bool,
764 #[serde(default)]
766 pub merge: Option<String>,
767 #[serde(default)]
769 pub candidates: Option<usize>,
770 #[serde(default)]
772 pub judges: Option<usize>,
773 #[serde(default)]
775 pub reviewers: Option<usize>,
776 #[serde(default)]
778 pub review_rounds: Option<usize>,
779 #[serde(default)]
781 pub seed: Option<u64>,
782 #[serde(default)]
784 pub config: Option<PathBuf>,
785}
786
787impl RunOverrides {
788 pub fn merge_over(&mut self, other: &RunOverrides) {
790 macro_rules! take {
791 ($($f:ident),*) => {$(
792 if other.$f.is_some() {
793 self.$f.clone_from(&other.$f);
794 }
795 )*};
796 }
797 take!(
798 merge,
799 candidates,
800 judges,
801 reviewers,
802 review_rounds,
803 seed,
804 config
805 );
806 }
807
808 pub fn apply(&self, config: &mut crate::config::Config) {
810 if let Some(n) = self.candidates {
811 config.graph.implementers = n;
812 }
813 if let Some(n) = self.judges {
814 config.graph.judges = n;
815 }
816 if let Some(n) = self.reviewers {
817 config.graph.reviewers = n;
818 }
819 if let Some(n) = self.review_rounds {
820 config.graph.review_rounds = n;
821 }
822 if let Some(m) = &self.merge
823 && let Ok(mode) = crate::daemon::merge_mode(m)
824 {
825 config.merge.mode = mode;
826 }
827 if let Some(s) = self.seed {
828 config.blind.seed = Some(s);
829 }
830 }
831}
832
833#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
835pub struct FollowUp {
836 pub run: String,
838 #[serde(default)]
840 pub origin_task: Option<String>,
841 pub pr: String,
843 pub findings: Vec<String>,
845 pub generation: u32,
848}
849
850#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
852pub struct Resumption {
853 pub generation: u64,
855 pub source: String,
857 pub question_id: Option<String>,
859 pub at: Timestamp,
861 pub instruction: String,
863 pub answers_through: usize,
865 pub runs_through: usize,
868 pub cleared_reason: Option<String>,
870 #[serde(default)]
872 pub blocking_evidence: Vec<String>,
873}
874
875#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
878pub struct OperatorResume {
879 pub question_id: String,
881 pub at: Timestamp,
883 #[serde(default)]
886 pub conductor_rehold: Option<String>,
887 #[serde(default)]
890 pub forced: bool,
891 #[serde(default)]
898 pub pinned_run: Option<String>,
899}
900
901#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
904pub struct AnsweredQuestion {
905 pub question: String,
907 pub answer: String,
909}
910
911impl Task {
912 pub fn new(title: String, instruction: String, repo: PathBuf, source: Source) -> Self {
914 let now = Timestamp::now();
915 let origin_chat = match &source {
916 Source::Agent { run, node } if node == CHAT_NODE => Some(run.clone()),
917 _ => None,
918 };
919 Self {
920 schema: SCHEMA,
921 id: new_id(),
922 title,
923 instruction,
924 sources: crate::refs::Sources::default(),
925 repo,
926 source,
927 chat: None,
928 priority: 0,
929 solo: false,
930 status: TaskStatus::Queued,
931 attempts: 0,
932 attempt_limit: None,
933 runs: Vec::new(),
934 last_error: None,
935 hold_reason: None,
936 hold_source: None,
937 diagnostic: None,
938 blocked_by: Vec::new(),
939 block_reason: None,
940 blocked_from: None,
941 blocked_at: None,
942 answers: Vec::new(),
943 triage_applied: Vec::new(),
944 actions_applied: Vec::new(),
945 resume_override: None,
946 resumption: None,
947 resumption_history: Vec::new(),
948 operator_decided_at: None,
949 review_branch: None,
950 review_instruction: None,
951 pr_recovery: None,
952 fresh_start: false,
953 replacement: None,
954 interrupt: false,
955 urgent: false,
956 unfiled: true,
957 attachments: Vec::new(),
958 followup: None,
959 origin_chat,
960 overrides: None,
961 review_of: None,
962 held_at: None,
963 park_reason: None,
964 chat_notice: None,
965 chat_notices: Vec::new(),
966 hold_notice_cause: None,
967 passes: Vec::new(),
968 dispatched_at: None,
969 recovery_budget: None,
970 recovery_admission: None,
971 recovery_generations: Vec::new(),
972 created_at: now,
973 updated_at: now,
974 }
975 }
976
977 pub fn chat_talk(&self) -> Option<&str> {
980 if let Some(id) = &self.origin_chat {
981 return Some(id);
982 }
983 match &self.source {
984 Source::Agent { run, node } if node == CHAT_NODE => Some(run),
985 _ => None,
986 }
987 }
988
989 pub fn filed_by_chat(&self) -> Option<&str> {
993 match &self.source {
994 Source::Agent { run, node } if node == CHAT_NODE => Some(run),
995 _ => None,
996 }
997 }
998
999 pub fn chat_notice_key(&self) -> Option<String> {
1002 match self.status {
1003 TaskStatus::Done => Some("done".to_owned()),
1004 TaskStatus::Held => {
1005 let cause = self
1006 .hold_notice_cause
1007 .clone()
1008 .unwrap_or_else(|| self.unresolved_hold_cause());
1009 Some(format!(
1010 "cause:{}",
1011 serde_json::to_string(&cause).expect("cause")
1012 ))
1013 }
1014 TaskStatus::Blocked => {
1015 let ids: std::collections::BTreeSet<_> = self.blocked_by.iter().collect();
1016 let evidence = self
1019 .hold_notice_cause
1020 .as_ref()
1021 .filter(|c| c.stage.starts_with("conductor-evidence:"));
1022 Some(format!(
1023 "dependencies:{}",
1024 serde_json::to_string(&(ids, evidence)).expect("ids")
1025 ))
1026 }
1027 _ => None,
1028 }
1029 }
1030
1031 fn unresolved_hold_cause(&self) -> NoticeCause {
1032 NoticeCause {
1033 stage: "hold".into(),
1034 target: None,
1035 run: self.runs.last().cloned(),
1036 pass: None,
1037 }
1038 }
1039
1040 pub(crate) fn legacy_chat_notice_key(&self) -> Option<String> {
1042 let key = match self.status {
1043 TaskStatus::Held if self.hold_notice_cause.is_none() => Some(format!(
1044 "held#{}",
1045 self.held_at.map(|t| t.to_string()).unwrap_or_default()
1046 )),
1047 TaskStatus::Blocked => Some(format!(
1048 "blocked#{}#{}",
1049 self.blocked_by.join(","),
1050 self.block_reason.as_deref().unwrap_or_default()
1051 )),
1052 _ => None,
1053 };
1054 key.filter(|key| self.legacy_notice_matches(key))
1055 }
1056
1057 pub(crate) fn legacy_notice_matches(&self, key: &str) -> bool {
1058 match self.status {
1059 TaskStatus::Held if self.hold_notice_cause.is_none() => key
1060 .strip_prefix("held#")
1061 .is_some_and(|time| time.is_empty() || time.parse::<Timestamp>().is_ok()),
1062 TaskStatus::Blocked
1063 if self
1064 .hold_notice_cause
1065 .as_ref()
1066 .is_none_or(|c| !c.stage.starts_with("conductor-evidence:")) =>
1067 {
1068 key.strip_prefix("blocked#").is_some_and(|tail| {
1069 let Some((ids, _)) = tail.split_once('#') else {
1070 return false;
1071 };
1072 let old: std::collections::BTreeSet<_> =
1073 ids.split(',').filter(|id| !id.is_empty()).collect();
1074 let current: std::collections::BTreeSet<_> =
1075 self.blocked_by.iter().map(String::as_str).collect();
1076 old == current
1077 })
1078 }
1079 _ => false,
1080 }
1081 }
1082
1083 fn import_chat_notice(&mut self) {
1084 if let Some(n) = &self.chat_notice {
1085 let key = if is_done_notice_key(&n.key) {
1086 "done".to_owned()
1087 } else if self.legacy_notice_matches(&n.key) {
1088 self.chat_notice_key().unwrap_or_else(|| n.key.clone())
1089 } else {
1090 n.key.clone()
1091 };
1092 if !self.chat_notices.contains(&key) {
1093 self.chat_notices.push(key);
1094 }
1095 }
1096 }
1097
1098 pub(crate) fn notice_cause(&mut self, stage: &str, run: Option<&str>, pass: Option<usize>) {
1101 self.import_chat_notice();
1102 self.hold_notice_cause = Some(NoticeCause {
1103 stage: stage.to_owned(),
1104 target: None,
1105 run: run.map(str::to_owned),
1106 pass,
1107 });
1108 }
1109
1110 pub(crate) fn startup_notice_cause(&mut self, stage: &str) {
1113 let recovery_only = matches!(
1114 stage,
1115 "startup-source" | "startup-candidates" | "startup-public-head"
1116 );
1117 match self.hold_notice_cause.as_ref() {
1118 Some(cause) if recovery_only && !cause.stage.starts_with("startup-") => return,
1119 None if recovery_only
1120 && self
1121 .chat_notice
1122 .as_ref()
1123 .is_some_and(|n| !is_done_notice_key(&n.key)) =>
1124 {
1125 return;
1126 }
1127 _ => {}
1128 }
1129 self.notice_cause(stage, None, None);
1130 }
1131
1132 pub(crate) fn fail_startup(
1135 &mut self,
1136 stage: &str,
1137 why: impl Into<String>,
1138 max_attempts: usize,
1139 ) {
1140 let unresolved = self.hold_notice_cause.clone();
1141 self.fail(why, max_attempts);
1142 self.hold_notice_cause = unresolved;
1143 self.startup_notice_cause(stage);
1144 }
1145
1146 pub(crate) fn fail_startup_target(
1148 &mut self,
1149 stage: &str,
1150 target: String,
1151 why: impl Into<String>,
1152 max_attempts: usize,
1153 ) {
1154 self.fail_startup(stage, why, max_attempts);
1155 if let Some(cause) = self.hold_notice_cause.as_mut() {
1156 cause.target = Some(target);
1157 }
1158 }
1159
1160 pub fn chat_notice_due(&self) -> Option<String> {
1162 self.filed_by_chat()?;
1163 if self.schema < 13 {
1164 return None;
1165 }
1166 let key = self.chat_notice_key()?;
1167 if self.chat_notices.contains(&key) {
1168 return None;
1169 }
1170 match &self.chat_notice {
1171 Some(n)
1172 if n.key == key
1173 || (key == "done" && is_done_notice_key(&n.key))
1174 || self.legacy_notice_matches(&n.key) =>
1175 {
1176 None
1177 }
1178 _ => Some(key),
1179 }
1180 }
1181
1182 pub fn short(&self) -> &str {
1184 short(&self.id)
1185 }
1186
1187 pub fn mark_triage_applied(&mut self, question_id: &str) {
1189 if !self.triage_applied(question_id) {
1190 self.triage_applied.push(question_id.to_owned());
1191 }
1192 }
1193
1194 pub fn action_applied(&self, question_id: &str) -> bool {
1197 self.actions_applied.iter().any(|id| id == question_id)
1198 }
1199
1200 pub fn mark_action_applied(&mut self, question_id: &str) {
1202 if !self.action_applied(question_id) {
1203 self.actions_applied.push(question_id.to_owned());
1204 }
1205 }
1206
1207 pub fn triage_applied(&self, question_id: &str) -> bool {
1209 self.triage_applied.iter().any(|id| id == question_id)
1210 }
1211
1212 pub fn start(&mut self, run: String) {
1225 self.stamp_schema();
1226 self.status = TaskStatus::Running;
1227 self.attempts += 1;
1228 self.runs.retain(|id| id != &run);
1229 self.runs.push(run);
1230 self.last_error = None;
1231 self.park_reason = None;
1232 self.fresh_start = false;
1233 self.interrupt = false;
1234 self.resume_override = None;
1236 }
1237
1238 pub fn link_run(&mut self, run: &str) -> bool {
1243 if self.runs.iter().any(|r| r == run) {
1244 return false;
1245 }
1246 self.runs.push(run.to_owned());
1247 true
1248 }
1249
1250 pub fn succeed(&mut self) {
1260 self.stamp_schema();
1261 self.status = TaskStatus::Done;
1262 self.resume_override = None;
1263 self.last_error = None;
1264 self.park_reason = None;
1265 self.hold_reason = None;
1266 self.hold_source = None;
1267 self.diagnostic = None;
1268 self.blocked_by.clear();
1269 self.block_reason = None;
1270 self.blocked_from = None;
1271 }
1272
1273 pub fn already_landed(&mut self, note: impl Into<String>) {
1281 self.succeed();
1282 self.attempts = self.attempts.saturating_sub(1);
1283 self.last_error = Some(note.into());
1284 }
1285
1286 pub fn superseded_attempts(&self, last_run_succeeded: bool) -> &[String] {
1316 if self.status != TaskStatus::Done || !last_run_succeeded || self.runs.len() < 2 {
1317 return &[];
1318 }
1319 &self.runs[..self.runs.len() - 1]
1320 }
1321
1322 pub fn successor_of(&self, run: &str) -> Option<&String> {
1330 let pos = self.runs.iter().rposition(|r| r == run)?;
1331 self.runs.get(pos + 1)
1332 }
1333
1334 pub fn earlier_attempts(&self) -> &[String] {
1341 &self.runs
1342 }
1343
1344 fn stamp_schema(&mut self) {
1351 self.schema = self.schema.max(SCHEMA);
1352 }
1353
1354 fn note_held(&mut self) {
1355 self.stamp_schema();
1356 let continuing = self.status == TaskStatus::Held
1357 || (self.status == TaskStatus::Blocked
1358 && (self.blocked_from == Some(TaskStatus::Held)
1359 || (self.blocked_from.is_none()
1360 && (self.hold_reason.is_some() || self.hold_source.is_some()))));
1361 if !continuing || self.held_at.is_none() {
1362 self.held_at = Some(Timestamp::now());
1363 }
1364 }
1365
1366 pub fn fail(&mut self, why: impl Into<String>, max_attempts: usize) {
1382 let why = why.into();
1383 if self.status == TaskStatus::Running {
1384 let run = self.runs.last().cloned();
1385 self.notice_cause("run-failed", run.as_deref(), Some(self.attempts));
1386 }
1387 self.diagnostic = None;
1388 self.park_reason = None;
1389 self.status = if self.attempts >= self.effective_attempt_limit(max_attempts) {
1390 self.note_held();
1391 self.hold_source = Some(HoldSource::Machine);
1392 self.hold_reason = Some(why.clone());
1393 TaskStatus::Held
1394 } else {
1395 TaskStatus::Failed
1396 };
1397 self.last_error = Some(why);
1398 }
1399
1400 pub fn stall(&mut self, why: impl Into<String>) {
1410 self.last_error = Some(why.into());
1411 self.diagnostic = None;
1412 self.attempts = self.attempts.saturating_sub(1);
1413 self.status = TaskStatus::Failed;
1414 }
1415
1416 pub fn park(&mut self, why: impl Into<String>) {
1422 self.diagnostic = None;
1423 self.attempts = self.attempts.saturating_sub(1);
1424 self.status = TaskStatus::Parked;
1425 self.park_reason = Some(why.into());
1426 self.schema = self.schema.max(SCHEMA);
1429 }
1430
1431 pub fn record_pass(
1433 &mut self,
1434 run: &str,
1435 pass_index: usize,
1436 attempts_before: usize,
1437 parked: Option<crate::run::ParkCause>,
1438 ) {
1439 self.record_pass_at(run, pass_index, None, attempts_before, parked);
1440 }
1441
1442 pub fn record_pass_at(
1445 &mut self,
1446 run: &str,
1447 pass_index: usize,
1448 started_at: Option<Timestamp>,
1449 attempts_before: usize,
1450 parked: Option<crate::run::ParkCause>,
1451 ) {
1452 if self
1453 .passes
1454 .iter()
1455 .any(|p| p.run == run && p.pass_index == pass_index)
1456 {
1457 return;
1458 }
1459 let cost = if self.attempts < attempts_before {
1460 PassCost::Refunded
1461 } else {
1462 PassCost::Spent
1463 };
1464 self.passes.push(TaskPass {
1465 run: run.to_owned(),
1466 pass_index,
1467 started_at: self.dispatched_at.or(started_at),
1468 cost,
1469 attempts_after: self.attempts,
1470 settled_at: Timestamp::now(),
1471 parked,
1472 });
1473 self.schema = self.schema.max(SCHEMA);
1474 }
1475
1476 pub(crate) fn upgrade_resume_key(&self) -> Option<(Timestamp, &str)> {
1479 if self.status != TaskStatus::Parked || self.fresh_start {
1480 return None;
1481 }
1482 let pass = self.passes.last()?;
1483 if self.runs.last() != Some(&pass.run)
1484 || !matches!(pass.parked, Some(crate::run::ParkCause::Upgrade { .. }))
1485 {
1486 return None;
1487 }
1488 Some((pass.started_at.unwrap_or(pass.settled_at), &self.id))
1491 }
1492
1493 pub fn operator_held(&self) -> bool {
1499 self.status == TaskStatus::Held && !matches!(self.hold_source, Some(HoldSource::Machine))
1500 }
1501
1502 pub fn hold_manual(&mut self, reason: Option<String>) {
1512 self.operator_decided_at = Some(Timestamp::now());
1514 self.hold_unrequested_retry(reason);
1515 }
1516
1517 pub(crate) fn hold_unrequested_retry(&mut self, reason: Option<String>) {
1520 self.park_reason = None;
1521 self.note_held();
1522 self.status = TaskStatus::Held;
1523 if reason.is_some() {
1524 self.hold_reason = reason;
1525 }
1526 self.hold_source = Some(HoldSource::Manual);
1527 self.blocked_by.clear();
1528 self.block_reason = None;
1529 self.blocked_from = None;
1530 }
1531
1532 pub fn hold_machine(&mut self, reason: Option<String>) {
1537 self.park_reason = None;
1538 self.note_held();
1539 self.status = TaskStatus::Held;
1540 if reason.is_some() {
1541 self.hold_reason = reason;
1542 }
1543 self.hold_source = Some(HoldSource::Machine);
1544 self.blocked_by.clear();
1545 self.block_reason = None;
1546 self.blocked_from = None;
1547 }
1548
1549 pub fn block(&mut self, blocked_by: Vec<String>, reason: Option<String>) {
1559 self.stamp_schema();
1560 let before: std::collections::BTreeSet<_> = self.blocked_by.iter().collect();
1561 let after: std::collections::BTreeSet<_> = blocked_by.iter().collect();
1562 if self.status != TaskStatus::Blocked || before != after {
1563 self.blocked_at = Some(Timestamp::now());
1564 }
1565 if self.status != TaskStatus::Blocked {
1566 self.blocked_from = Some(self.status);
1567 }
1568 self.status = TaskStatus::Blocked;
1569 self.blocked_by = blocked_by;
1570 self.block_reason = reason;
1571 }
1572
1573 pub fn unblock(&mut self, resolved_id: &str) {
1596 if self.status != TaskStatus::Blocked {
1597 return;
1598 }
1599 self.blocked_by.retain(|id| id != resolved_id);
1600 self.restore_if_unblocked();
1601 }
1602
1603 pub fn dependency_deleted(&mut self, deleted_id: &str) -> bool {
1614 if self.status != TaskStatus::Blocked || !self.blocked_by.iter().any(|b| b == deleted_id) {
1615 return false;
1616 }
1617 self.blocked_by.retain(|id| id != deleted_id);
1618 self.restore_if_unblocked();
1619 true
1620 }
1621
1622 fn restore_if_unblocked(&mut self) {
1626 if self.blocked_by.is_empty() {
1627 self.status = match self.blocked_from {
1628 Some(TaskStatus::Running) => TaskStatus::Queued,
1629 Some(other) => other,
1630 None if self.hold_reason.is_some() || self.hold_source.is_some() => {
1631 TaskStatus::Held
1632 }
1633 None => TaskStatus::Queued,
1634 };
1635 self.block_reason = None;
1636 self.blocked_from = None;
1637 }
1638 }
1639
1640 pub fn record_answer(&mut self, question: String, answer: String) {
1645 self.answers.push(AnsweredQuestion { question, answer });
1646 }
1647
1648 pub fn request_review(&mut self, branch: String) {
1652 self.release();
1653 self.review_branch = Some(branch);
1654 self.review_instruction = Some(self.instruction.clone());
1655 }
1656
1657 pub fn requeue(&mut self) {
1660 self.release();
1661 self.review_branch = None;
1663 self.review_instruction = None;
1664 self.fresh_start = true;
1665 }
1666
1667 pub fn hold_for_handover(&mut self, branch: Option<String>, reason: String) {
1676 if branch.is_some() {
1677 self.review_branch = branch;
1678 self.review_instruction = Some(self.instruction.clone());
1679 }
1680 self.hold_machine(Some(reason));
1681 }
1682
1683 pub fn set_urgent(&mut self, urgent: bool) -> Result<()> {
1685 if !matches!(self.status, TaskStatus::Queued | TaskStatus::Held) {
1686 bail!(
1687 "task {} is {}; urgent can only change while queued or held; \
1688 wait for the active attempt to finish and requeue or hold the task first",
1689 self.short(),
1690 self.status.as_str()
1691 );
1692 }
1693 self.urgent = urgent;
1694 Ok(())
1695 }
1696
1697 pub fn set_priority(&mut self, priority: i32) -> Result<()> {
1706 if self.status == TaskStatus::Running {
1707 bail!(
1708 "task {} is running; its priority cannot be changed until \
1709 this attempt finishes",
1710 self.short()
1711 );
1712 }
1713 self.priority = priority;
1714 Ok(())
1715 }
1716
1717 pub fn set_interrupt(&mut self, interrupt: bool) -> Result<()> {
1732 if interrupt && !self.status.runnable() {
1733 bail!(
1734 "task {} is {}; only a queued or failed task can be marked \
1735 to interrupt",
1736 self.short(),
1737 self.status.as_str()
1738 );
1739 }
1740 self.interrupt = interrupt;
1741 Ok(())
1742 }
1743
1744 pub fn effective_attempt_limit(&self, daemon_limit: usize) -> usize {
1746 self.attempt_limit.map_or(daemon_limit, NonZeroUsize::get)
1747 }
1748
1749 pub fn change_attempt_limit(&mut self, limit: Option<NonZeroUsize>) -> Result<()> {
1752 if !matches!(self.status, TaskStatus::Queued | TaskStatus::Held) {
1753 bail!("only queued or held tasks can change the attempt limit");
1754 }
1755 self.stamp_schema();
1756 self.attempt_limit = limit;
1757 Ok(())
1758 }
1759
1760 pub fn implementers(&self) -> Option<usize> {
1762 if self.solo {
1763 Some(1)
1764 } else {
1765 self.overrides.as_ref().and_then(|o| o.candidates)
1766 }
1767 }
1768
1769 pub fn change_implementers(&mut self, count: u8) -> Result<()> {
1772 self.check_implementers_editable()?;
1773 let explicit = self.overrides.as_ref().and_then(|o| o.config.as_deref());
1774 let (mut config, _) = crate::config::Config::discover(&self.repo, explicit)?;
1775 if let Some(o) = &self.overrides {
1776 o.apply(&mut config);
1777 }
1778 self.set_implementers(count, &config)
1779 }
1780
1781 fn check_implementers_editable(&self) -> Result<()> {
1782 if !matches!(self.status, TaskStatus::Queued | TaskStatus::Held) {
1783 bail!(
1784 "task {} is {}; only queued or held tasks can change implementers",
1785 self.short(),
1786 self.status.as_str()
1787 );
1788 }
1789 Ok(())
1790 }
1791
1792 pub fn set_implementers(&mut self, count: u8, config: &crate::config::Config) -> Result<()> {
1794 self.check_implementers_editable()?;
1795 crate::talk::check_implementers(count, config).map_err(anyhow::Error::msg)?;
1796 self.stamp_schema();
1797 self.solo = count == 1;
1798 let mut overrides = self.overrides.clone().unwrap_or_else(|| RunOverrides {
1799 inherit_daemon_options: true,
1800 ..Default::default()
1801 });
1802 overrides.candidates = (count > 1).then_some(usize::from(count));
1803 let mut settings = overrides.clone();
1804 settings.inherit_daemon_options = false;
1805 self.overrides = if overrides.inherit_daemon_options && settings == RunOverrides::default()
1807 {
1808 None
1809 } else {
1810 Some(overrides)
1811 };
1812 Ok(())
1813 }
1814
1815 pub fn edit(&mut self, title: String, instruction: String) -> Result<()> {
1827 if !matches!(self.status, TaskStatus::Queued | TaskStatus::Held) {
1828 bail!(
1829 "task {} is {}; only a queued or held task's instruction can \
1830 be edited",
1831 self.short(),
1832 self.status.as_str()
1833 );
1834 }
1835 if self.review_branch.is_some() && self.review_instruction.is_none() {
1838 self.review_instruction = Some(self.instruction.clone());
1839 }
1840 if self.instruction != instruction {
1841 self.resume_override = None;
1844 }
1845 self.title = title;
1846 self.instruction = instruction;
1847 Ok(())
1848 }
1849
1850 pub fn handed_off(&mut self, why: impl Into<String>) {
1870 let why = why.into();
1871 self.diagnostic = None;
1872 self.note_held();
1873 self.status = TaskStatus::Held;
1874 self.hold_source = Some(HoldSource::Machine);
1875 self.hold_reason = Some(why.clone());
1876 self.last_error = Some(why);
1877 }
1878
1879 pub fn release_operator(&mut self, source: &str, question_id: Option<&str>) {
1881 self.recovery_budget = None;
1882 self.recovery_generations.clear();
1883 self.recovery_admission = None;
1884 if self
1885 .resumption
1886 .as_ref()
1887 .is_some_and(|r| r.instruction != self.instruction)
1888 {
1889 self.resume_override = None;
1892 }
1893 let approval = Resumption {
1894 generation: self.resumption.as_ref().map_or(1, |r| r.generation + 1),
1895 source: source.to_owned(),
1896 question_id: question_id.map(str::to_owned),
1897 at: Timestamp::now(),
1898 instruction: self.instruction.clone(),
1899 answers_through: self.answers.len(),
1900 runs_through: self.runs.len(),
1901 blocking_evidence: Vec::new(),
1902 cleared_reason: self
1903 .hold_reason
1904 .clone()
1905 .or_else(|| self.block_reason.clone()),
1906 };
1907 self.release();
1908 self.operator_decided_at = Some(approval.at);
1909 if let Some(previous) = self.resumption.replace(approval) {
1910 self.resumption_history.push(previous);
1911 }
1912 }
1913
1914 pub(crate) fn recovery_failed(&mut self, state: &crate::run::RunState) {
1916 let (base, inputs) = self.recovery_admission.as_ref().map_or_else(
1917 || (state.base_commit.clone(), recovery_inputs(&state.sources)),
1918 |a| (a.base.clone(), a.inputs.clone()),
1919 );
1920 let progress = recovery_progress(state);
1921 let stage = if state.prep_failure.is_some() {
1922 "preparation"
1923 } else {
1924 "execution"
1925 };
1926 let matches = |b: &RecoveryBudget| {
1927 b.instruction == self.instruction
1928 && b.base == base
1929 && b.inputs == inputs
1930 && (progress.is_none() || b.progress == progress)
1931 };
1932 if !self.recovery_budget.as_ref().is_some_and(matches) {
1933 let previous = self
1934 .recovery_generations
1935 .iter()
1936 .position(matches)
1937 .map(|index| self.recovery_generations.remove(index));
1938 if let Some(old) = self.recovery_budget.take() {
1939 self.recovery_generations.push(old);
1940 }
1941 self.recovery_budget = previous;
1942 }
1943 if let Some(budget) = &mut self.recovery_budget {
1944 budget.failures = budget.failures.saturating_add(1);
1945 budget.stage = stage.to_owned();
1946 } else {
1947 self.recovery_budget = Some(RecoveryBudget {
1948 instruction: self.instruction.clone(),
1949 base,
1950 inputs,
1951 stage: stage.to_owned(),
1952 failures: 1,
1953 progress,
1954 });
1955 }
1956 }
1957
1958 pub(crate) fn recovery_exhausted(
1959 &self,
1960 base: &str,
1961 inputs: &str,
1962 limit: usize,
1963 ) -> Option<String> {
1964 let same_input = |b: &&RecoveryBudget| {
1965 b.instruction == self.instruction && b.base == base && b.inputs == inputs
1966 };
1967 let budget = self
1968 .recovery_budget
1969 .as_ref()
1970 .filter(same_input)
1971 .or_else(|| self.recovery_generations.iter().rev().find(same_input))?;
1972 (budget.instruction == self.instruction && budget.base == base && budget.inputs == inputs
1973 && budget.failures >= limit.max(1)).then(|| format!(
1974 "[recovery-budget] {} failed passes against unchanged inputs ({}). Inspect the retained recovery sources and correct the cause, change the inputs, or explicitly release the task to approve another generation. Automatic requeue does not reset this budget.",
1975 budget.failures, budget.stage))
1976 }
1977
1978 pub fn release(&mut self) {
1982 self.import_chat_notice();
1985 if self.hold_notice_cause.is_none()
1986 && matches!(self.status, TaskStatus::Held | TaskStatus::Blocked)
1987 {
1988 self.hold_notice_cause = Some(self.unresolved_hold_cause());
1989 }
1990 let refused_handover = self.status == TaskStatus::Held
1991 && self.hold_source == Some(HoldSource::Machine)
1992 && self.review_branch.is_some()
1993 && self
1994 .review_instruction
1995 .as_ref()
1996 .is_none_or(|i| *i == self.instruction);
1997 self.status = TaskStatus::Queued;
1998 self.held_at = None;
1999 self.attempts = 0;
2000 self.last_error = None;
2001 self.park_reason = None;
2002 self.hold_reason = None;
2005 self.hold_source = None;
2006 self.diagnostic = None;
2007 self.blocked_by.clear();
2012 self.block_reason = None;
2013 self.blocked_from = None;
2014 if !refused_handover {
2018 self.review_branch = None;
2019 self.review_instruction = None;
2020 }
2021 self.fresh_start = false;
2022 }
2023}
2024
2025fn copy_new(dir: &Path, src: &Path, name: &str) -> Result<(String, PathBuf)> {
2029 use std::io::ErrorKind;
2030 let (stem, ext) = match name.rfind('.') {
2031 Some(i) if i > 0 => (&name[..i], &name[i..]),
2032 _ => (name, ""),
2033 };
2034 for n in 1u32.. {
2035 let candidate = if n == 1 {
2036 name.to_owned()
2037 } else {
2038 let suffix = format!("-{n}");
2039 let room = 64usize.saturating_sub(suffix.len() + ext.len());
2040 let stem: String = stem.chars().take(room).collect();
2041 format!("{stem}{suffix}{ext}")
2042 };
2043 if !crate::ask::valid_asset_name(&candidate) {
2044 bail!("no valid attachment name is left for `{name}`");
2045 }
2046 let path = dir.join(&candidate);
2047 match std::fs::OpenOptions::new()
2048 .write(true)
2049 .create_new(true)
2050 .open(&path)
2051 {
2052 Ok(mut out) => {
2053 let copied = std::fs::File::open(src)
2054 .and_then(|mut input| std::io::copy(&mut input, &mut out));
2055 if let Err(e) = copied {
2056 drop(out);
2057 let _ = std::fs::remove_file(&path);
2058 return Err(e).with_context(|| format!("copy {}", src.display()));
2059 }
2060 return Ok((candidate, path));
2061 }
2062 Err(e) if e.kind() == ErrorKind::AlreadyExists => continue,
2063 Err(e) => return Err(e).with_context(|| format!("create {}", path.display())),
2064 }
2065 }
2066 unreachable!("the counter never runs out")
2067}
2068
2069const TASK_LOCK_STALE: std::time::Duration = std::time::Duration::from_secs(10);
2072
2073pub(crate) struct TaskLock {
2076 path: PathBuf,
2077 token: String,
2078}
2079
2080fn owner_token() -> String {
2083 static COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
2084 let n = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
2085 let mut r = crate::rng::SplitMix64::new(crate::rng::entropy() ^ n.rotate_left(32));
2086 format!("{}-{:016x}", std::process::id(), r.next_u64())
2087}
2088
2089fn break_marker(path: &Path, token: &str) -> PathBuf {
2092 let mut h: u64 = 0xcbf29ce484222325;
2093 for b in token.bytes() {
2094 h ^= u64::from(b);
2095 h = h.wrapping_mul(0x100000001b3);
2096 }
2097 let mut name = path.as_os_str().to_owned();
2098 name.push(format!(".break-{h:016x}"));
2099 PathBuf::from(name)
2100}
2101
2102fn older_than_stale(path: &Path) -> bool {
2103 std::fs::metadata(path)
2104 .and_then(|m| m.modified())
2105 .ok()
2106 .and_then(|t| t.elapsed().ok())
2107 .is_some_and(|age| age > TASK_LOCK_STALE)
2108}
2109
2110fn writer_may_be_gone(token: &str) -> bool {
2111 let Some(pid) = token
2112 .split_once('-')
2113 .and_then(|(pid, _)| pid.parse::<u32>().ok())
2114 else {
2115 return true;
2118 };
2119 if pid == std::process::id() {
2120 return false;
2121 }
2122 crate::proc::pid_status(pid) == Some(false)
2124}
2125
2126const MAX_MARKER_DEPTH: u8 = 3;
2130
2131fn take_marker(
2137 path: &Path,
2138 token: &str,
2139 depth: u8,
2140 retry: crate::lock_file::Retry,
2141) -> Option<(PathBuf, String)> {
2142 use std::io::Write;
2143 let marker = break_marker(path, token);
2144 for _ in 0..2 {
2145 match retry.create(&marker) {
2146 Ok(mut f) => {
2147 let mine = owner_token();
2148 if f.write_all(mine.as_bytes()).is_err() {
2149 drop(f);
2150 let _ = std::fs::remove_file(&marker);
2151 return None;
2152 }
2153 return Some((marker, mine));
2154 }
2155 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
2156 let judged = std::fs::read_to_string(&marker).ok();
2157 match judged.filter(|_| older_than_stale(&marker)) {
2158 Some(judged) if depth < MAX_MARKER_DEPTH => {
2159 remove_lock_if(&marker, &judged, true, depth + 1, retry)?;
2160 }
2161 _ => return None,
2162 }
2163 }
2164 Err(_) => return None,
2165 }
2166 }
2167 None
2168}
2169
2170fn remove_lock_if(
2175 path: &Path,
2176 token: &str,
2177 require_stale: bool,
2178 depth: u8,
2179 retry: crate::lock_file::Retry,
2180) -> Option<bool> {
2181 let (marker, mine) = take_marker(path, token, depth, retry)?;
2182 let still = std::fs::read_to_string(path).is_ok_and(|c| c == token)
2183 && (!require_stale || older_than_stale(path) && writer_may_be_gone(token));
2184 if still {
2185 let _ = std::fs::remove_file(path);
2186 }
2187 if depth >= MAX_MARKER_DEPTH {
2189 if std::fs::read_to_string(&marker).is_ok_and(|c| c == mine) {
2190 let _ = std::fs::remove_file(&marker);
2191 }
2192 } else {
2193 let _ = remove_lock_if(&marker, &mine, false, depth + 1, retry);
2194 }
2195 Some(still)
2196}
2197
2198#[cfg(test)]
2201fn break_stale(path: &Path, judged: &str) -> bool {
2202 break_stale_with(path, judged, crate::lock_file::Retry::new(None))
2203}
2204
2205fn break_stale_with(path: &Path, judged: &str, retry: crate::lock_file::Retry) -> bool {
2206 remove_lock_if(path, judged, true, 0, retry).unwrap_or(false)
2207}
2208
2209impl Drop for TaskLock {
2210 fn drop(&mut self) {
2211 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
2212 let retry = crate::lock_file::Retry::new(Some(deadline));
2213 loop {
2214 match remove_lock_if(&self.path, &self.token, false, 0, retry) {
2215 Some(_) => return,
2216 None if std::time::Instant::now() > deadline => return,
2217 None => std::thread::sleep(std::time::Duration::from_millis(5)),
2218 }
2219 }
2220 }
2221}
2222
2223#[derive(Debug, Clone)]
2225pub struct Queue {
2226 root: PathBuf,
2227 ids: IdSource,
2228}
2229
2230fn validate_attachments(sources: &[PathBuf]) -> Result<Vec<(&PathBuf, &str)>> {
2231 let mut wanted = Vec::new();
2232 for src in sources {
2233 let name = src
2234 .file_name()
2235 .and_then(|n| n.to_str())
2236 .with_context(|| format!("`{}` has no usable file name", src.display()))?;
2237 if !crate::ask::valid_asset_name(name) {
2238 bail!(
2239 "attachment name `{name}` must match ^[A-Za-z0-9][A-Za-z0-9._-]{{0,63}}$ \
2240 with no `..`; rename the file and try again"
2241 );
2242 }
2243 if !src.is_file() {
2244 bail!("attachment `{}` is not a file", src.display());
2245 }
2246 wanted.push((src, name));
2247 }
2248 Ok(wanted)
2249}
2250
2251impl Queue {
2252 pub fn open() -> Self {
2254 Self::at(crate::run::home().join("queue"))
2255 }
2256
2257 pub fn at(root: PathBuf) -> Self {
2260 Self {
2261 root,
2262 ids: IdSource::new(new_id),
2263 }
2264 }
2265
2266 #[doc(hidden)]
2269 pub fn with_id_source(mut self, f: impl Fn() -> String + Send + Sync + 'static) -> Self {
2270 self.ids = IdSource::custom(f);
2271 self
2272 }
2273
2274 fn file_with(
2285 &self,
2286 task: &mut Task,
2287 hook: impl FnMut(&mut Task) -> Result<bool>,
2288 undo: impl FnMut(&mut Task),
2289 ) -> Result<()> {
2290 self.reserve_id(task, hook, undo, true)
2291 }
2292
2293 fn reserve_id(
2301 &self,
2302 task: &mut Task,
2303 mut hook: impl FnMut(&mut Task) -> Result<bool>,
2304 mut undo: impl FnMut(&mut Task),
2305 store: bool,
2306 ) -> Result<()> {
2307 let redraw_first = self.ids.is_custom() && task.attachments.is_empty();
2308 let initial = task.id.clone();
2309 self.ids
2310 .reserve((!redraw_first).then_some(initial.as_str()), "task", |id| {
2311 task.id = id.to_owned();
2312 let _lock = self.lock_task(&task.id)?;
2313 if path_exists(&self.path_of(&task.id))? || !hook(task)? {
2314 return Ok(false);
2315 }
2316 if store {
2317 if let Err(e) = self.write_record(task) {
2318 undo(task);
2319 return Err(e);
2320 }
2321 task.unfiled = false;
2322 }
2323 Ok(true)
2324 })?;
2325 Ok(())
2326 }
2327
2328 fn id_in_use(&self, id: &str, check_attachments: bool) -> Result<bool> {
2333 if path_exists(&self.lock_path(id))? {
2334 return Ok(true);
2335 }
2336 Ok(check_attachments && path_exists(&self.attachments_dir(id))?)
2337 }
2338
2339 fn plain_filing_hook(&self, task: &Task) -> Result<bool> {
2343 Ok(!self.id_in_use(&task.id, task.attachments.is_empty())?)
2344 }
2345
2346 pub fn file_claimed(&self, task: &mut Task) -> Result<Claim> {
2349 let mut claim = None;
2350 self.file_with(
2351 task,
2352 |t| {
2353 if self.id_in_use(&t.id, true)? {
2354 return Ok(false);
2355 }
2356 claim = Some(self.claim(&t.id)?);
2357 Ok(true)
2358 },
2359 |_| {},
2360 )?;
2361 claim.context("claim missing after filing")
2362 }
2363
2364 pub fn root(&self) -> &Path {
2366 &self.root
2367 }
2368
2369 pub fn path_of(&self, id: &str) -> PathBuf {
2371 self.root.join(format!("{id}.json"))
2372 }
2373
2374 pub fn attachments_dir(&self, id: &str) -> PathBuf {
2377 self.root.join(format!("{id}.attachments"))
2378 }
2379
2380 pub fn attach(&self, task: &mut Task, sources: &[PathBuf]) -> Result<Vec<String>> {
2391 let wanted = validate_attachments(sources)?;
2392 if task.unfiled && task.attachments.is_empty() {
2393 let names = std::cell::RefCell::new(Vec::new());
2396 self.reserve_id(
2397 task,
2398 |t| {
2399 if self.id_in_use(&t.id, true)? {
2400 return Ok(false);
2401 }
2402 *names.borrow_mut() = self.copy_attachments(t, &wanted)?;
2403 Ok(true)
2404 },
2405 |_| {},
2406 false,
2407 )?;
2408 return Ok(names.into_inner());
2409 }
2410 self.copy_attachments(task, &wanted)
2411 }
2412
2413 fn copy_attachments(
2415 &self,
2416 task: &mut Task,
2417 wanted: &[(&PathBuf, &str)],
2418 ) -> Result<Vec<String>> {
2419 let dir = self.attachments_dir(&task.id);
2420 let existed = dir.is_dir();
2421 std::fs::create_dir_all(&dir).with_context(|| format!("create {}", dir.display()))?;
2422 let mut created: Vec<PathBuf> = Vec::new();
2423 let mut names = Vec::new();
2424 let mut copy_all = || -> Result<()> {
2425 for (src, name) in wanted {
2426 let (stored, path) = copy_new(&dir, src, name)?;
2427 created.push(path);
2428 names.push(stored);
2429 }
2430 Ok(())
2431 };
2432 if let Err(e) = copy_all() {
2433 for path in &created {
2434 let _ = std::fs::remove_file(path);
2435 }
2436 if !existed {
2437 let _ = std::fs::remove_dir(&dir);
2438 }
2439 return Err(e);
2440 }
2441 task.attachments.extend(names.iter().cloned());
2442 Ok(names)
2443 }
2444
2445 pub fn attach_and_put(&self, task: &mut Task, sources: &[PathBuf]) -> Result<Vec<String>> {
2456 if task.unfiled && task.attachments.is_empty() {
2457 return self.attach_and_file(task, sources);
2458 }
2459 let dir = self.attachments_dir(&task.id);
2460 let existed = dir.is_dir();
2461 let before = task.attachments.len();
2462 let names = self.attach(task, sources)?;
2463 if let Err(e) = self.put(task) {
2464 for name in &names {
2465 let _ = std::fs::remove_file(dir.join(name));
2466 }
2467 if !existed {
2468 let _ = std::fs::remove_dir(&dir);
2469 }
2470 task.attachments.truncate(before);
2471 return Err(e);
2472 }
2473 Ok(names)
2474 }
2475
2476 fn attach_and_file(&self, task: &mut Task, sources: &[PathBuf]) -> Result<Vec<String>> {
2480 let wanted = validate_attachments(sources)?;
2481 let before = task.attachments.len();
2482 let names = std::cell::RefCell::new(Vec::new());
2483 let result = self.file_with(
2484 task,
2485 |t| {
2486 if self.id_in_use(&t.id, true)? {
2487 return Ok(false);
2488 }
2489 *names.borrow_mut() = self.copy_attachments(t, &wanted)?;
2490 Ok(true)
2491 },
2492 |t| {
2493 let dir = self.attachments_dir(&t.id);
2494 for name in names.borrow().iter() {
2495 let _ = std::fs::remove_file(dir.join(name));
2496 }
2497 let _ = std::fs::remove_dir(&dir);
2499 t.attachments.truncate(before);
2500 },
2501 );
2502 result.map(|()| names.into_inner())
2503 }
2504
2505 pub fn attachment_paths(&self, task: &Task) -> Vec<PathBuf> {
2509 let dir = self.attachments_dir(&task.id);
2510 task.attachments
2511 .iter()
2512 .map(|n| {
2513 let p = dir.join(n);
2514 std::path::absolute(&p).unwrap_or(p)
2515 })
2516 .collect()
2517 }
2518
2519 pub fn put(&self, task: &mut Task) -> Result<()> {
2528 if task.unfiled {
2529 return self.file_with(task, |t| self.plain_filing_hook(t), |_| {});
2531 }
2532 let _lock = self.lock_task(&task.id)?;
2533 self.put_unlocked(task)
2534 }
2535
2536 pub fn create_new(&self, task: &mut Task) -> Result<bool> {
2541 let _lock = self.lock_task(&task.id)?;
2542 if path_exists(&self.path_of(&task.id))? {
2543 return Ok(false);
2544 }
2545 self.write_record(task)?;
2546 task.unfiled = false;
2547 Ok(true)
2548 }
2549
2550 fn put_unlocked(&self, task: &mut Task) -> Result<()> {
2552 self.merge_stored(task);
2553 self.write_record(task)
2554 }
2555
2556 fn merge_stored(&self, task: &mut Task) {
2558 if let Ok(mut stored) = read_path(&self.path_of(&task.id)) {
2559 stored.import_chat_notice();
2560 if task.chat.is_none() {
2563 task.chat = stored.chat.clone();
2564 }
2565 for key in stored.chat_notices {
2566 if !task.chat_notices.contains(&key) {
2567 task.chat_notices.push(key);
2568 }
2569 }
2570 for run in stored.runs {
2571 if !task.runs.contains(&run) {
2572 task.runs.push(run);
2573 }
2574 }
2575 for p in stored.passes {
2576 if !task
2577 .passes
2578 .iter()
2579 .any(|q| q.run == p.run && q.pass_index == p.pass_index)
2580 {
2581 task.passes.push(p);
2582 }
2583 }
2584 if let Some(n) = stored.chat_notice
2587 && task.chat_notice.as_ref().is_none_or(|mine| mine.at < n.at)
2588 {
2589 task.chat_notice = Some(n);
2590 }
2591 }
2592 }
2593
2594 fn write_record(&self, task: &mut Task) -> Result<()> {
2596 task.import_chat_notice();
2597 task.updated_at = Timestamp::now();
2598 std::fs::create_dir_all(&self.root)
2599 .with_context(|| format!("create {}", self.root.display()))?;
2600 let body = serde_json::to_string_pretty(task).context("serialize task")?;
2601 let path = self.path_of(&task.id);
2602 let tmp = path.with_extension("json.tmp");
2603 std::fs::write(&tmp, &body).with_context(|| format!("write {}", tmp.display()))?;
2604 std::fs::rename(&tmp, &path).with_context(|| format!("replace {}", path.display()))?;
2605 if let (Some(notice), Some(home)) = (
2610 crate::notices::task_held(task),
2611 self.root.parent().filter(|p| !p.as_os_str().is_empty()),
2612 ) {
2613 crate::notices::raise_in(home, notice);
2614 }
2615 Ok(())
2616 }
2617
2618 pub fn modify(&self, id: &str, f: impl FnOnce(&mut Task) -> bool) -> Result<bool> {
2623 let id = self.resolve_id(id)?;
2624 let _lock = self.lock_task(&id)?;
2625 let mut task = self.get(&id)?;
2626 if !f(&mut task) {
2627 return Ok(false);
2628 }
2629 self.put_unlocked(&mut task)?;
2630 Ok(true)
2631 }
2632
2633 pub fn link_run(&self, id: &str, run: &str) -> Result<Task> {
2638 let id = self.resolve_id(id)?;
2639 let _lock = self.lock_task(&id)?;
2642 let mut task = self.get(&id)?;
2643 if task.link_run(run) {
2644 self.put_unlocked(&mut task)?;
2645 }
2646 Ok(task)
2647 }
2648
2649 pub(crate) fn lock_task(&self, id: &str) -> Result<TaskLock> {
2657 std::fs::create_dir_all(&self.root)
2658 .with_context(|| format!("create {}", self.root.display()))?;
2659 let path = self.root.join(format!("{id}.write-lock"));
2660 let started = std::time::Instant::now();
2661 let retry = crate::lock_file::Retry::task_write(started + TASK_LOCK_STALE);
2662 let marker_retry = crate::lock_file::Retry::new(Some(started + TASK_LOCK_STALE));
2663 loop {
2664 match retry.create(&path) {
2665 Ok(mut file) => {
2666 use std::io::Write;
2667 let token = owner_token();
2668 if let Err(e) = file.write_all(token.as_bytes()) {
2669 drop(file);
2670 let _ = std::fs::remove_file(&path);
2671 return Err(e).with_context(|| format!("lock {}", path.display()));
2672 }
2673 return Ok(TaskLock { path, token });
2674 }
2675 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
2676 let judged = std::fs::read_to_string(&path).ok();
2679 let broken = judged
2680 .filter(|_| older_than_stale(&path))
2681 .is_some_and(|judged| break_stale_with(&path, &judged, marker_retry));
2682 if !broken {
2686 if started.elapsed() > TASK_LOCK_STALE {
2687 bail!("could not lock task {id}");
2688 }
2689 std::thread::sleep(std::time::Duration::from_millis(15));
2690 }
2691 }
2692 Err(e) => return Err(e).with_context(|| format!("lock {}", path.display())),
2693 }
2694 }
2695 }
2696
2697 pub fn get(&self, id: &str) -> Result<Task> {
2699 let resolved = self.resolve_id(id)?;
2700 read_path(&self.path_of(&resolved))
2701 }
2702
2703 pub fn remove(&self, id: &str, in_flight: bool, questions: &Questions) -> Result<Removal> {
2735 let resolved = self.resolve_id(id)?;
2736 if in_flight {
2737 bail!("task {resolved} is being run by a live daemon right now");
2738 }
2739 self.write_tombstone(&resolved)?;
2740 if let Err(e) = self.remove_record_with_attachments(&resolved, |p| std::fs::remove_file(p))
2741 {
2742 if self.path_of(&resolved).exists() {
2746 let _ = std::fs::remove_file(self.tombstone_path(&resolved));
2747 }
2748 return Err(e);
2749 }
2750 let (released, still_blocked) = self.release_dependents_of(&resolved, questions);
2751 Ok(Removal {
2752 id: resolved,
2753 released,
2754 still_blocked,
2755 })
2756 }
2757
2758 fn tombstone_path(&self, id: &str) -> PathBuf {
2761 self.root.join(format!("{id}.removed"))
2762 }
2763
2764 fn write_tombstone(&self, id: &str) -> Result<()> {
2765 let path = self.tombstone_path(id);
2766 let tmp = self.root.join(format!("{id}.removed.tmp"));
2767 std::fs::write(&tmp, b"")
2768 .and_then(|()| std::fs::rename(&tmp, &path))
2769 .with_context(|| format!("write {}", path.display()))
2770 }
2771
2772 fn was_deleted(&self, id: &str) -> bool {
2776 self.tombstone_path(id).is_file() && !self.path_of(id).exists()
2777 }
2778
2779 pub fn apply_deleted_blockers(&self, task: &mut Task, questions: &Questions) -> Vec<String> {
2783 let deleted = deleted_blockers(self, questions, &task.blocked_by);
2784 deleted
2785 .into_iter()
2786 .filter(|id| task.dependency_deleted(id))
2787 .collect()
2788 }
2789
2790 pub fn note_dependency_deleted(&self, dependent: &Task, deleted: &str) {
2794 let Some(home) = self.root.parent().filter(|p| !p.as_os_str().is_empty()) else {
2795 return;
2796 };
2797 let ja = crate::lang::is_japanese(&crate::lang::of_repo(&dependent.repo));
2798 let waiting = dependent.status == TaskStatus::Blocked;
2799 let message = match (ja, waiting) {
2800 (true, false) => format!(
2801 "タスク {} は、待っていた {} が削除されたため待機を解除し、元の状態に戻しました",
2802 dependent.short(),
2803 short(deleted)
2804 ),
2805 (true, true) => format!(
2806 "タスク {} は、待っていた {} が削除されたため、残りの依存を待っています",
2807 dependent.short(),
2808 short(deleted)
2809 ),
2810 (false, false) => format!(
2811 "Task {} stopped waiting on {} because it was deleted, and returned to its previous state",
2812 dependent.short(),
2813 short(deleted)
2814 ),
2815 (false, true) => format!(
2816 "Task {} stopped waiting on {} because it was deleted, and is still waiting on its other dependencies",
2817 dependent.short(),
2818 short(deleted)
2819 ),
2820 };
2821 crate::notices::raise_in(
2822 home,
2823 crate::notices::Notice::info(
2824 &format!("unblocked:{}:{}", dependent.id, deleted),
2825 message,
2826 )
2827 .link(crate::notices::Link::Task {
2828 id: dependent.id.clone(),
2829 }),
2830 );
2831 }
2832
2833 fn release_dependents_of(
2838 &self,
2839 dependency: &str,
2840 questions: &Questions,
2841 ) -> (Vec<String>, Vec<String>) {
2842 let (mut released, mut still_blocked) = (Vec::new(), Vec::new());
2843 for listed in self.list() {
2844 if listed.status != TaskStatus::Blocked || !listed.blocked_by.iter().any(|b| {
2845 matches!(crate::queue::dependency(self, questions, b), Dependency::Deleted(id) if id == dependency)
2846 }) {
2847 continue;
2848 }
2849 let Ok(_claim) = self.claim(&listed.id) else {
2850 continue;
2851 };
2852 let Ok(mut task) = self.get(&listed.id) else {
2853 continue;
2854 };
2855 let references: Vec<String> = task.blocked_by.iter().filter(|id| {
2856 matches!(crate::queue::dependency(self, questions, id), Dependency::Deleted(full) if full == dependency)
2857 }).cloned().collect();
2858 if references.is_empty() {
2859 continue;
2860 }
2861 for reference in references {
2862 task.dependency_deleted(&reference);
2863 }
2864 if self.put(&mut task).is_err() {
2865 continue;
2866 }
2867 self.note_dependency_deleted(&task, dependency);
2868 if task.status == TaskStatus::Blocked {
2869 still_blocked.push(task.id.clone());
2870 } else {
2871 released.push(task.id.clone());
2872 }
2873 }
2874 (released, still_blocked)
2875 }
2876
2877 fn remove_record_with_attachments(
2881 &self,
2882 resolved: &str,
2883 remove_record: impl FnOnce(&Path) -> std::io::Result<()>,
2884 ) -> Result<()> {
2885 self.sweep_removed_attachments();
2892 let attachments = self.attachments_dir(resolved);
2893 let aside = self.root.join(format!("{resolved}.attachments.removing"));
2894 let moved = match std::fs::rename(&attachments, &aside) {
2895 Ok(()) => true,
2896 Err(e) if e.kind() == std::io::ErrorKind::NotFound => false,
2897 Err(e) => {
2898 return Err(e).with_context(|| format!("remove {}", attachments.display()));
2899 }
2900 };
2901 let path = self.path_of(resolved);
2902 if let Err(e) = remove_record(&path) {
2903 if moved {
2904 let _ = std::fs::rename(&aside, &attachments);
2905 }
2906 return Err(e).with_context(|| format!("remove {}", path.display()));
2907 }
2908 if moved {
2909 if let Err(e) = std::fs::remove_dir_all(&aside) {
2910 tracing::warn!("leftover attachments {}: {e}", aside.display());
2911 }
2912 }
2913 let lock = self.lock_path(resolved);
2914 if let Err(e) = std::fs::remove_file(&lock) {
2915 if e.kind() != std::io::ErrorKind::NotFound {
2916 return Err(e).with_context(|| format!("remove {}", lock.display()));
2917 }
2918 }
2919 Ok(())
2920 }
2921
2922 fn sweep_removed_attachments(&self) {
2928 let Ok(entries) = std::fs::read_dir(&self.root) else {
2929 return;
2930 };
2931 for entry in entries.flatten() {
2932 let name = entry.file_name();
2933 let name = name.to_string_lossy();
2934 let Some(id) = name.strip_suffix(".attachments.removing") else {
2935 continue;
2936 };
2937 if !self.path_of(id).exists() {
2940 if let Err(e) = std::fs::remove_dir_all(entry.path()) {
2941 tracing::warn!("leftover attachments {}: {e}", entry.path().display());
2942 }
2943 }
2944 }
2945 self.sweep_tombstones();
2946 }
2947
2948 fn sweep_tombstones(&self) {
2957 let Ok(entries) = std::fs::read_dir(&self.root) else {
2958 return;
2959 };
2960 let tasks = self.list();
2961 for entry in entries.flatten() {
2962 let name = entry.file_name();
2963 let name = name.to_string_lossy();
2964 let Some(id) = name.strip_suffix(".removed") else {
2965 continue;
2966 };
2967 if self.path_of(id).exists()
2968 || tasks.iter().any(|t| {
2969 t.blocked_by
2970 .iter()
2971 .any(|b| id.starts_with(b) || id.ends_with(b))
2972 })
2973 {
2974 continue;
2975 }
2976 let old = entry
2977 .metadata()
2978 .and_then(|m| m.modified())
2979 .ok()
2980 .and_then(|m| m.elapsed().ok())
2981 .is_some_and(|age| age >= TOMBSTONE_GRACE);
2982 if old {
2983 let _ = std::fs::remove_file(entry.path());
2984 }
2985 }
2986 }
2987
2988 fn lock_path(&self, id: &str) -> PathBuf {
2991 self.root.join(format!("{id}.lock"))
2992 }
2993
2994 pub fn list(&self) -> Vec<Task> {
3007 let mut tasks: Vec<Task> = std::fs::read_dir(&self.root)
3008 .into_iter()
3009 .flatten()
3010 .flatten()
3011 .map(|e| e.path())
3012 .filter(|p| p.extension().is_some_and(|x| x == "json"))
3013 .filter_map(|p| read_path(&p).ok())
3014 .collect();
3015 tasks.sort_unstable_by(|a, b| b.priority.cmp(&a.priority).then_with(|| b.id.cmp(&a.id)));
3016 tasks
3017 }
3018
3019 pub fn unreadable(&self) -> Vec<PathBuf> {
3023 std::fs::read_dir(&self.root)
3024 .into_iter()
3025 .flatten()
3026 .flatten()
3027 .map(|e| e.path())
3028 .filter(|p| p.extension().is_some_and(|x| x == "json"))
3029 .filter(|p| read_path(p).is_err())
3030 .collect()
3031 }
3032
3033 pub fn superseded(&self) -> HashMap<String, String> {
3046 let mut by = HashMap::new();
3047 for task in self.list() {
3048 for earlier in &task.runs {
3049 if let Some(later) = task.successor_of(earlier) {
3050 by.insert(earlier.clone(), later.clone());
3051 }
3052 }
3053 }
3054 by
3055 }
3056
3057 pub fn superseded_by(&self, run: &str) -> Option<String> {
3065 for task in self.list() {
3066 if task.runs.iter().any(|r| r == run) {
3067 return task.successor_of(run).cloned();
3068 }
3069 }
3070 None
3071 }
3072
3073 pub fn latest_attempt(&self, run: &str) -> Option<String> {
3084 for task in self.list() {
3085 if task.runs.iter().any(|r| r == run) {
3086 return task.runs.last().filter(|last| **last != run).cloned();
3087 }
3088 }
3089 None
3090 }
3091
3092 pub fn next_runnable(&self) -> Option<Task> {
3097 let mut runnable: Vec<Task> = self
3098 .list()
3099 .into_iter()
3100 .filter(|t| t.status.runnable())
3101 .collect();
3102 runnable.sort_unstable_by(dispatch_order);
3103 runnable.into_iter().next()
3104 }
3105
3106 pub fn claim(&self, id: &str) -> Result<Claim> {
3113 std::fs::create_dir_all(&self.root)
3114 .with_context(|| format!("create {}", self.root.display()))?;
3115 let path = self.lock_path(id);
3116 let retry = crate::lock_file::Retry::new(None);
3117 match retry.create(&path) {
3118 Ok(mut f) => {
3119 use std::io::Write as _;
3120 let _ = writeln!(f, "{}", std::process::id());
3122 Ok(Claim { path })
3123 }
3124 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
3125 bail!("task {id} is already claimed ({} exists)", path.display())
3126 }
3127 Err(e) => Err(e).with_context(|| format!("lock {}", path.display())),
3128 }
3129 }
3130
3131 pub fn resolve_id(&self, prefix: &str) -> Result<String> {
3133 if !prefix.is_empty()
3134 && prefix != "."
3135 && prefix != ".."
3136 && !prefix.contains(['/', '\\'])
3137 && self.path_of(prefix).is_file()
3138 {
3139 return Ok(prefix.to_owned());
3140 }
3141 let ids = record_ids(&self.root, "json")?;
3142 match resolve_reference(prefix, ids.iter().map(String::as_str)) {
3143 Reference::Found(id) => Ok(id),
3144 Reference::Missing => bail!("no task matches `{prefix}`"),
3145 Reference::Ambiguous => bail!("`{prefix}` matches multiple tasks"),
3146 }
3147 }
3148
3149 pub fn revision(&self) -> u64 {
3156 self.revision_excluding(&std::collections::BTreeSet::new())
3157 }
3158
3159 pub fn revision_excluding(&self, skip: &std::collections::BTreeSet<String>) -> u64 {
3162 use std::hash::{Hash as _, Hasher as _};
3163
3164 let mut entries: Vec<(String, u64)> = std::fs::read_dir(&self.root)
3165 .into_iter()
3166 .flatten()
3167 .flatten()
3168 .filter(|e| e.path().extension().is_some_and(|ext| ext == "json"))
3169 .filter(|e| {
3170 let path = e.path();
3171 !path
3172 .file_stem()
3173 .is_some_and(|stem| skip.contains(stem.to_string_lossy().as_ref()))
3174 })
3175 .filter_map(|e| {
3176 let name = e.file_name().to_string_lossy().into_owned();
3177 let mtime = e
3178 .metadata()
3179 .ok()?
3180 .modified()
3181 .ok()?
3182 .duration_since(std::time::UNIX_EPOCH)
3183 .ok()?
3184 .as_millis() as u64;
3185 Some((name, mtime))
3186 })
3187 .collect();
3188
3189 if entries.is_empty() {
3190 return 0;
3191 }
3192
3193 entries.sort_unstable();
3194 let mut hasher = std::hash::DefaultHasher::new();
3195 for (name, mtime) in &entries {
3196 name.hash(&mut hasher);
3197 mtime.hash(&mut hasher);
3198 }
3199 let h = hasher.finish();
3200 if h == 0 { 1 } else { h }
3201 }
3202}
3203
3204const TOMBSTONE_GRACE: std::time::Duration = std::time::Duration::from_secs(3600);
3207
3208#[derive(Debug, Clone)]
3210pub struct Removal {
3211 pub id: String,
3213 pub released: Vec<String>,
3217 pub still_blocked: Vec<String>,
3220}
3221
3222#[derive(Debug)]
3224pub struct Claim {
3225 path: PathBuf,
3226}
3227
3228impl Drop for Claim {
3229 fn drop(&mut self) {
3230 let _ = std::fs::remove_file(&self.path);
3231 }
3232}
3233
3234pub fn title_from(instruction: &str, max: usize) -> String {
3237 let Some(line) = first_line(instruction) else {
3238 return "(empty task)".to_owned();
3239 };
3240 if line.chars().count() <= max {
3241 return line.to_owned();
3242 }
3243 let head: String = line.chars().take(max.saturating_sub(1)).collect();
3244 format!("{head}…")
3245}
3246
3247pub(crate) fn first_line(instruction: &str) -> Option<&str> {
3253 let line = instruction
3254 .lines()
3255 .map(str::trim)
3256 .find(|l| !l.is_empty())?
3257 .trim_start_matches(['#', '-', '*', '>', ' '])
3258 .trim();
3259 (!line.is_empty()).then_some(line)
3260}
3261
3262fn read_path(path: &Path) -> Result<Task> {
3263 let body = std::fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
3264 let task: Task =
3265 serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))?;
3266 if task.schema > SCHEMA {
3272 bail!(
3273 "task {} was written by a different magi (schema {}, this build \
3274 speaks {SCHEMA})",
3275 task.id,
3276 task.schema
3277 );
3278 }
3279 Ok(task)
3280}
3281
3282#[derive(Debug, PartialEq, Eq)]
3285pub(crate) enum Reference {
3286 Found(String),
3287 Missing,
3288 Ambiguous,
3289}
3290
3291pub(crate) fn resolve_reference<'a>(id: &str, ids: impl Iterator<Item = &'a str>) -> Reference {
3292 let mut hits = Vec::new();
3293 for candidate in ids {
3294 if candidate == id {
3295 return Reference::Found(candidate.to_owned());
3296 }
3297 if candidate.starts_with(id) || candidate.ends_with(id) {
3298 hits.push(candidate);
3299 }
3300 }
3301 match hits.as_slice() {
3302 [] => Reference::Missing,
3303 [one] => Reference::Found((*one).to_owned()),
3304 _ => Reference::Ambiguous,
3305 }
3306}
3307
3308pub(crate) fn record_ids(root: &Path, extension: &str) -> Result<Vec<String>> {
3311 let entries = match std::fs::read_dir(root) {
3312 Ok(entries) => entries,
3313 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
3314 Err(e) => return Err(e.into()),
3315 };
3316 let mut ids = Vec::new();
3317 for entry in entries {
3318 let path = entry?.path();
3319 if path.extension().is_some_and(|e| e == extension)
3320 && let Some(id) = path.file_stem().and_then(|s| s.to_str())
3321 {
3322 ids.push(id.to_owned());
3323 }
3324 }
3325 Ok(ids)
3326}
3327
3328#[derive(Debug, PartialEq, Eq)]
3331pub enum Dependency {
3332 Task(String),
3334 Question(String),
3336 Deleted(String),
3338 Missing,
3340 Ambiguous,
3342 Unknown,
3344}
3345
3346pub fn dependency(queue: &Queue, questions: &Questions, id: &str) -> Dependency {
3348 let (Ok(tasks), Ok(qs), Ok(removed)) = (
3349 record_ids(queue.root(), "json"),
3350 record_ids(questions.root(), "json"),
3351 record_ids(queue.root(), "removed"),
3352 ) else {
3353 return Dependency::Unknown;
3354 };
3355 let mut ids = tasks
3358 .iter()
3359 .chain(qs.iter())
3360 .chain(removed.iter())
3361 .cloned()
3362 .collect::<Vec<_>>();
3363 ids.sort();
3364 ids.dedup();
3365 match resolve_reference(id, ids.iter().map(String::as_str)) {
3366 Reference::Found(id) if tasks.contains(&id) => Dependency::Task(id),
3367 Reference::Found(id) if qs.contains(&id) => Dependency::Question(id),
3368 Reference::Found(id) if queue.was_deleted(&id) => Dependency::Deleted(id),
3369 Reference::Found(_) => Dependency::Unknown,
3370 Reference::Missing => Dependency::Missing,
3371 Reference::Ambiguous => Dependency::Ambiguous,
3372 }
3373}
3374
3375pub fn normalize_blockers(queue: &Queue, questions: &Questions, ids: &mut [String]) {
3377 for id in ids {
3378 if let Dependency::Task(full) | Dependency::Question(full) | Dependency::Deleted(full) =
3379 dependency(queue, questions, id)
3380 {
3381 *id = full;
3382 }
3383 }
3384}
3385
3386pub fn dependency_facts(queue: &Queue, questions: &Questions) -> String {
3389 let inventory = crate::blockers::Inventory::load(queue, questions);
3390 let mut text = String::from(
3391 "Current authoritative queue (historical missing-task claims may be stale; unreadable or ambiguous references must keep waiting):\n",
3392 );
3393 match record_ids(queue.root(), "json") {
3394 Ok(ids) => {
3395 for id in ids {
3396 match queue.get(&id) {
3397 Ok(task) => text.push_str(&format!(
3398 "{}: {}; blocked_by={:?}; chain={:?}\n",
3399 task.id,
3400 task.status.as_str(),
3401 task.blocked_by,
3402 inventory.waits_on(&task)
3403 )),
3404 Err(_) => text.push_str(&format!("{id}: exists, state unreadable\n")),
3405 }
3406 }
3407 }
3408 Err(_) => text.push_str("Queue inventory unreadable: existence is unknown.\n"),
3409 }
3410 text
3411}
3412
3413pub fn missing_blockers(
3415 queue: &Queue,
3416 questions: &Questions,
3417 blocked_by: &[String],
3418) -> Vec<String> {
3419 blocked_by
3420 .iter()
3421 .filter(|id| dependency(queue, questions, id) == Dependency::Missing)
3422 .cloned()
3423 .collect()
3424}
3425
3426pub fn deleted_blockers(
3429 queue: &Queue,
3430 questions: &Questions,
3431 blocked_by: &[String],
3432) -> Vec<String> {
3433 blocked_by
3434 .iter()
3435 .filter(|id| matches!(dependency(queue, questions, id), Dependency::Deleted(_)))
3436 .cloned()
3437 .collect()
3438}
3439
3440pub fn missing_blocker_hold_reason(blocked_by: &[String], missing: &[String]) -> String {
3452 missing_blocker_hold_reason_in(blocked_by, missing, "en")
3453}
3454
3455pub fn missing_blocker_hold_reason_in(
3457 blocked_by: &[String],
3458 missing: &[String],
3459 language: &str,
3460) -> String {
3461 if crate::lang::is_japanese(language) {
3462 format!(
3463 "{} を待っていましたが、{} はディスク上に存在しません - `magi task triage` を参照",
3464 blocked_by.join(", "),
3465 missing.join(", "),
3466 )
3467 } else {
3468 format!(
3469 "blocked on {} but {} no longer exist(s) on disk - see `magi task triage`",
3470 blocked_by.join(", "),
3471 missing.join(", "),
3472 )
3473 }
3474}
3475
3476pub fn short(id: &str) -> &str {
3478 id.split('-').next_back().unwrap_or(id)
3479}
3480
3481fn new_id() -> String {
3482 let stamp = jiff::Zoned::now().strftime("%Y%m%d-%H%M%S");
3483 let seed = crate::rng::entropy();
3484 format!("{stamp}-{:04x}", (seed ^ (seed >> 32)) & 0xffff)
3485}
3486
3487#[cfg(test)]
3488mod tests {
3489 #[test]
3490 fn explicit_resumption_is_not_invented_for_legacy_or_automatic_release() {
3491 let t = Task::new(
3492 "retry".into(),
3493 "do it".into(),
3494 PathBuf::from("."),
3495 Source::Human,
3496 );
3497 let mut value = serde_json::to_value(&t).unwrap();
3498 for field in ["resumption", "resumption_history", "operator_decided_at"] {
3499 value.as_object_mut().unwrap().remove(field);
3500 }
3501 value["schema"] = serde_json::json!(15);
3502 let mut legacy: Task = serde_json::from_value(value).unwrap();
3503 legacy.hold_machine(Some("automatic retry".into()));
3504 legacy.release();
3505 assert!(legacy.resumption.is_none());
3506 assert!(legacy.resumption_history.is_empty());
3507 assert!(legacy.operator_decided_at.is_none());
3508 legacy.release_operator("cli", None);
3509 legacy.start("run".into());
3510 let reloaded: Task =
3511 serde_json::from_str(&serde_json::to_string(&legacy).unwrap()).unwrap();
3512 assert!(reloaded.resume_override.is_none());
3513 assert_eq!(reloaded.resumption.as_ref().unwrap().generation, 1);
3514 }
3515
3516 #[test]
3517 fn merge_over_lets_the_later_choice_win_and_keeps_the_rest() {
3518 let mut base = RunOverrides {
3519 merge: Some("pr".to_owned()),
3520 candidates: Some(3),
3521 ..RunOverrides::default()
3522 };
3523 base.merge_over(&RunOverrides {
3524 merge: Some("none".to_owned()),
3525 seed: Some(7),
3526 ..RunOverrides::default()
3527 });
3528 assert_eq!(base.merge.as_deref(), Some("none"));
3529 assert_eq!(base.candidates, Some(3));
3530 assert_eq!(base.seed, Some(7));
3531 }
3532
3533 #[test]
3534 fn an_already_landed_task_is_done_with_its_attempt_refunded() {
3535 let mut t = task("relanded");
3536 t.attempts = 1;
3537 t.status = TaskStatus::Running;
3538 t.already_landed("already in main as 0e368de");
3539 assert_eq!(t.status, TaskStatus::Done);
3540 assert_eq!(t.attempts, 0);
3541 assert!(t.hold_reason.is_none());
3542 assert_eq!(t.last_error.as_deref(), Some("already in main as 0e368de"));
3543 }
3544
3545 #[test]
3546 fn missing_blocker_reason_follows_the_language() {
3547 let b = vec!["a".to_owned()];
3548 let en = missing_blocker_hold_reason_in(&b, &b, "en");
3549 assert_eq!(en, missing_blocker_hold_reason(&b, &b));
3550 assert!(en.starts_with("blocked on a"));
3551 assert!(missing_blocker_hold_reason_in(&b, &b, "ja").contains("存在しません"));
3552 assert_eq!(missing_blocker_hold_reason_in(&b, &b, "de"), en);
3553 }
3554
3555 use super::*;
3556
3557 #[test]
3558 fn triage_applied_survives_release_and_old_records_read_as_empty() {
3559 let mut t = Task::new(
3560 "t".to_owned(),
3561 "i".to_owned(),
3562 PathBuf::from("r"),
3563 Source::Human,
3564 );
3565 t.mark_triage_applied("q1");
3566 t.mark_triage_applied("q1");
3567 t.hold_machine(Some("x".to_owned()));
3568 t.release();
3569 assert_eq!(t.triage_applied, ["q1"]);
3570 assert!(t.triage_applied("q1") && !t.triage_applied("q2"));
3571
3572 let mut v = serde_json::to_value(&t).unwrap();
3573 v.as_object_mut().unwrap().remove("triage_applied");
3574 let old: Task = serde_json::from_value(v).unwrap();
3575 assert!(old.triage_applied.is_empty());
3576 }
3577
3578 #[test]
3579 fn task_counts_of_empty_is_all_zero() {
3580 assert_eq!(TaskCounts::of(&[]), TaskCounts::default());
3581 }
3582
3583 #[test]
3584 fn task_counts_of_tallies_every_status() {
3585 let mut queued = Task::new(
3586 "q".to_owned(),
3587 "i".to_owned(),
3588 PathBuf::from("."),
3589 Source::Human,
3590 );
3591 queued.status = TaskStatus::Queued;
3592 let mut running = queued.clone();
3593 running.status = TaskStatus::Running;
3594 let mut done = queued.clone();
3595 done.status = TaskStatus::Done;
3596 let mut failed = queued.clone();
3597 failed.status = TaskStatus::Failed;
3598 let mut held = queued.clone();
3599 held.status = TaskStatus::Held;
3600 let mut blocked = queued.clone();
3601 blocked.status = TaskStatus::Blocked;
3602
3603 let mut parked = queued.clone();
3604 parked.status = TaskStatus::Parked;
3605
3606 let counts = TaskCounts::of(&[
3607 queued,
3608 running,
3609 done.clone(),
3610 done,
3611 failed,
3612 held,
3613 blocked,
3614 parked,
3615 ]);
3616 assert_eq!(
3617 counts,
3618 TaskCounts {
3619 queued: 1,
3620 running: 1,
3621 done: 2,
3622 failed: 1,
3623 held: 1,
3624 blocked: 1,
3625 parked: 1,
3626 }
3627 );
3628 }
3629
3630 #[test]
3631 fn task_implementers_persist_normalized_without_losing_history() {
3632 let (_dir, q) = queue();
3633 let mut t = task("keep me");
3634 t.hold_manual(Some("waiting".into()));
3635 t.runs.push("earlier-run".into());
3636 t.attachments.push("reference.txt".into());
3637 let cfg: crate::config::Config =
3638 toml::from_str("[[agents]]\nid = 'mock'\nkind = 'command'\ncommand = ['true']\n")
3639 .unwrap();
3640 q.put(&mut t).unwrap();
3641 let original = serde_json::to_value(&t).unwrap();
3642 for n in [2, 1, 2] {
3643 t.set_implementers(n, &cfg).unwrap();
3644 q.put(&mut t).unwrap();
3645 t = q.get(&t.id).unwrap();
3646 assert_eq!(t.implementers(), Some(usize::from(n)));
3647 assert_eq!(t.solo, n == 1);
3648 if n == 1 {
3649 assert!(t.overrides.is_none());
3650 }
3651 let mut actual = serde_json::to_value(&t).unwrap();
3652 for field in ["solo", "overrides", "updated_at"] {
3653 actual[field] = original[field].clone();
3654 }
3655 assert_eq!(actual, original);
3656 }
3657 t.overrides.as_mut().unwrap().judges = Some(2);
3658 t.set_implementers(1, &cfg).unwrap();
3659 assert_eq!(t.overrides.as_ref().unwrap().judges, Some(2));
3660 assert_eq!(t.overrides.as_ref().unwrap().candidates, None);
3661 }
3662
3663 #[test]
3664 fn task_implementers_old_records_keep_inheritance_until_changed() {
3665 let mut value = serde_json::to_value(task("old task")).unwrap();
3666 value.as_object_mut().unwrap().remove("solo");
3667 value.as_object_mut().unwrap().remove("overrides");
3668 let mut old: Task = serde_json::from_value(value).unwrap();
3669 assert_eq!(old.implementers(), None);
3670 old.set_implementers(1, &crate::config::Config::default())
3671 .unwrap();
3672 assert!(old.solo);
3673 assert!(old.overrides.is_none());
3674 }
3675
3676 #[test]
3677 fn task_implementers_reject_invalid_counts_and_states() {
3678 let cfg = crate::config::Config::default();
3679 let mut t = task("unchanged");
3680 for n in [0, 4, 255] {
3681 assert!(t.set_implementers(n, &cfg).is_err());
3682 }
3683 for status in [
3684 TaskStatus::Running,
3685 TaskStatus::Done,
3686 TaskStatus::Failed,
3687 TaskStatus::Blocked,
3688 TaskStatus::Parked,
3689 ] {
3690 t.status = status;
3691 let before = serde_json::to_value(&t).unwrap();
3692 assert!(t.set_implementers(1, &cfg).is_err());
3693 assert_eq!(serde_json::to_value(&t).unwrap(), before);
3694 }
3695 }
3696
3697 fn queue() -> (tempfile::TempDir, Queue) {
3700 let dir = tempfile::tempdir().unwrap();
3701 let q = Queue::at(dir.path().join("queue"));
3702 (dir, q)
3703 }
3704
3705 #[test]
3706 fn a_stale_write_does_not_drop_the_recorded_task_chat() {
3707 let (_dir, q) = queue();
3708 let mut stale = task("chat");
3709 q.put(&mut stale).unwrap();
3710 let mut fresh = q.get(&stale.id).unwrap();
3711 fresh.chat = Some("talk-1".to_owned());
3712 q.put(&mut fresh).unwrap();
3713 q.put(&mut stale).unwrap();
3714 assert_eq!(q.get(&stale.id).unwrap().chat.as_deref(), Some("talk-1"));
3715 }
3716
3717 #[test]
3718 fn putting_a_machine_held_task_files_a_notification_beside_the_queue() {
3719 let dir = tempfile::tempdir().unwrap();
3720 let q = Queue::at(dir.path().join("queue"));
3721 let mut t = task("held");
3722 q.put(&mut t).unwrap();
3723 assert_eq!(
3724 crate::notices::Notices::at(dir.path().join("notifications"))
3725 .list()
3726 .len(),
3727 0
3728 );
3729 t.hold_machine(Some("out of attempts".to_owned()));
3730 q.put(&mut t).unwrap();
3731 let listed = crate::notices::Notices::at(dir.path().join("notifications")).list();
3732 assert_eq!(listed.len(), 1);
3733 assert!(listed[0].message.contains("out of attempts"));
3734 }
3735
3736 fn task(title: &str) -> Task {
3737 Task::new(
3738 title.to_owned(),
3739 format!("do {title}"),
3740 PathBuf::from("."),
3741 Source::Human,
3742 )
3743 }
3744
3745 #[test]
3746 fn attempt_limit_inherits_old_records_and_controls_failure() {
3747 let mut t = task("budget");
3748 let mut old = serde_json::to_value(&t).unwrap();
3749 old.as_object_mut().unwrap().remove("attempt_limit");
3750 old["schema"] = serde_json::json!(15);
3751 let legacy: Task = serde_json::from_value(old).unwrap();
3752 assert_eq!(legacy.attempt_limit, None);
3753 assert_eq!(legacy.effective_attempt_limit(2), 2);
3754 t.attempts = 2;
3755 t.change_attempt_limit(NonZeroUsize::new(3)).unwrap();
3756 t.fail("retry", 1);
3757 assert_eq!(t.status, TaskStatus::Failed);
3758 t.attempts = 3;
3759 t.fail("stop", 10);
3760 assert_eq!(t.status, TaskStatus::Held);
3761 t.change_attempt_limit(None).unwrap();
3762 assert_eq!(t.status, TaskStatus::Held);
3763 assert_eq!(t.attempts, 3);
3764 t.fail("inherit", 4);
3765 assert_eq!(t.status, TaskStatus::Failed);
3766 for status in [
3767 TaskStatus::Running,
3768 TaskStatus::Done,
3769 TaskStatus::Failed,
3770 TaskStatus::Blocked,
3771 TaskStatus::Parked,
3772 ] {
3773 t.status = status;
3774 assert!(t.change_attempt_limit(NonZeroUsize::new(1)).is_err());
3775 assert_eq!(t.attempt_limit, None);
3776 }
3777 }
3778
3779 #[test]
3780 fn earlier_attempts_is_every_recorded_run_and_agrees_with_the_display() {
3781 let mut t = task("retried");
3782 assert!(
3783 t.earlier_attempts().is_empty(),
3784 "a first attempt takes nothing over"
3785 );
3786 t.runs = vec!["aaaa".to_owned(), "bbbb".to_owned()];
3787 assert_eq!(t.earlier_attempts(), ["aaaa", "bbbb"]);
3788 assert_eq!(t.successor_of("aaaa"), Some(&"bbbb".to_owned()));
3790 assert_eq!(t.successor_of("bbbb"), None);
3791 assert_eq!(t.successor_of("zzzz"), None);
3792 }
3793
3794 #[test]
3795 fn resumed_runs_are_superseded_only_after_their_last_occurrence() {
3796 let (_dir, q) = queue();
3797 let mut t = task("resumed");
3798 for (runs, successor) in [
3799 (vec!["aaaa", "aaaa"], None),
3800 (vec!["aaaa", "bbbb", "aaaa"], None),
3801 (vec!["aaaa", "aaaa", "bbbb"], Some("bbbb")),
3802 (vec!["aaaa", "bbbb", "aaaa", "cccc"], Some("cccc")),
3803 ] {
3804 t.runs = runs.into_iter().map(str::to_owned).collect();
3805 q.put(&mut t).unwrap();
3806
3807 assert_eq!(t.successor_of("aaaa").map(String::as_str), successor);
3808 assert_eq!(q.superseded_by("aaaa").as_deref(), successor);
3809 assert_eq!(q.superseded().get("aaaa").map(String::as_str), successor);
3810 }
3811 }
3812
3813 #[test]
3814 fn superseded_by_names_the_next_attempt_and_none_for_the_last() {
3815 let (_dir, q) = queue();
3816 let mut t = task("retried");
3817 t.runs = vec!["aaaa".to_owned(), "bbbb".to_owned(), "cccc".to_owned()];
3818 q.put(&mut t).unwrap();
3819
3820 assert_eq!(q.superseded_by("aaaa"), Some("bbbb".to_owned()));
3821 assert_eq!(q.superseded_by("bbbb"), Some("cccc".to_owned()));
3822 assert_eq!(
3823 q.superseded_by("cccc"),
3824 None,
3825 "the latest attempt replaces nothing"
3826 );
3827 assert_eq!(
3828 q.superseded_by("never-heard-of-it"),
3829 None,
3830 "a run belonging to no task on this queue is not superseded"
3831 );
3832
3833 let mut by = HashMap::new();
3834 by.insert("aaaa".to_owned(), "bbbb".to_owned());
3835 by.insert("bbbb".to_owned(), "cccc".to_owned());
3836 assert_eq!(
3837 q.superseded(),
3838 by,
3839 "the whole-map and single-run forms must agree"
3840 );
3841 }
3842
3843 #[test]
3844 fn superseded_attempts_is_empty_until_the_task_is_done() {
3845 let mut t = task("retried");
3846 t.runs = vec!["aaaa".to_owned(), "bbbb".to_owned()];
3847 t.status = TaskStatus::Failed;
3848 assert_eq!(
3849 t.superseded_attempts(true),
3850 &[] as &[String],
3851 "a task still retrying has no attempt yet that a later one made moot"
3852 );
3853
3854 t.status = TaskStatus::Running;
3855 assert_eq!(t.superseded_attempts(true), &[] as &[String]);
3856 }
3857
3858 #[test]
3859 fn superseded_attempts_names_every_run_before_the_one_that_succeeded() {
3860 let mut t = task("retried");
3861 t.runs = vec!["aaaa".to_owned(), "bbbb".to_owned(), "cccc".to_owned()];
3862 t.status = TaskStatus::Done;
3863 assert_eq!(
3864 t.superseded_attempts(true),
3865 &["aaaa".to_owned(), "bbbb".to_owned()],
3866 "cccc is the attempt whose success made the task done, and stays out"
3867 );
3868 }
3869
3870 #[test]
3871 fn superseded_attempts_is_empty_for_a_done_task_with_only_one_attempt() {
3872 let mut t = task("first try landed");
3873 t.runs = vec!["aaaa".to_owned()];
3874 t.status = TaskStatus::Done;
3875 assert_eq!(t.superseded_attempts(true), &[] as &[String]);
3876 }
3877
3878 #[test]
3879 fn superseded_attempts_is_empty_when_the_last_run_never_actually_succeeded() {
3880 let mut t = task("closed by hand after a manual merge");
3887 t.runs = vec!["aaaa".to_owned(), "bbbb".to_owned()];
3888 t.status = TaskStatus::Done;
3889 assert_eq!(
3890 t.superseded_attempts(false),
3891 &[] as &[String],
3892 "nothing here is provably why the task is done, so nothing is superseded"
3893 );
3894 }
3895
3896 #[test]
3897 fn latest_attempt_names_the_chain_s_current_head_not_just_the_next_one() {
3898 let (_dir, q) = queue();
3899 let mut t = task("retried twice");
3900 t.runs = vec!["aaaa".to_owned(), "bbbb".to_owned(), "cccc".to_owned()];
3901 q.put(&mut t).unwrap();
3902
3903 assert_eq!(
3904 q.latest_attempt("aaaa"),
3905 Some("cccc".to_owned()),
3906 "an old attempt points straight at the chain's current head, not the \
3907 next attempt in the middle of it"
3908 );
3909 assert_eq!(q.latest_attempt("bbbb"), Some("cccc".to_owned()));
3910 assert_eq!(
3911 q.latest_attempt("cccc"),
3912 None,
3913 "the latest attempt is not superseded by anything"
3914 );
3915 assert_eq!(
3916 q.latest_attempt("never-heard-of-it"),
3917 None,
3918 "a run belonging to no task on this queue is not superseded"
3919 );
3920 }
3921
3922 #[test]
3923 fn a_markdown_heading_is_the_title_not_decoration() {
3924 assert_eq!(
3929 title_from("# Rework the config loader\n\nIt re-reads it.\n", 40),
3930 "Rework the config loader"
3931 );
3932 assert_eq!(title_from("- fix the thing", 40), "fix the thing");
3933 assert_eq!(title_from("> quoted task", 40), "quoted task");
3934 assert_eq!(title_from(" \n\n", 40), "(empty task)");
3936 assert_eq!(title_from("###\n", 40), "(empty task)");
3937 }
3938
3939 #[test]
3940 fn a_long_title_is_elided_by_characters_not_bytes() {
3941 let long = "課題".repeat(30);
3943 let title = title_from(&long, 10);
3944 assert_eq!(title.chars().count(), 10);
3945 assert!(title.ends_with('…'));
3946 }
3947
3948 #[test]
3949 fn priority_wins_and_ties_break_oldest_first() {
3950 let (_dir, q) = queue();
3951 let mut a = task("first");
3952 let mut b = task("second");
3953 let mut c = task("urgent");
3954 a.id = "20260101-000001-aaaa".to_owned();
3956 b.id = "20260101-000002-bbbb".to_owned();
3957 c.id = "20260101-000003-cccc".to_owned();
3958 c.priority = 5;
3959 for t in [&mut a, &mut b, &mut c] {
3960 q.put(t).unwrap();
3961 }
3962
3963 assert_eq!(q.next_runnable().unwrap().id, c.id);
3965 c.hold_machine(None);
3966 q.put(&mut c).unwrap();
3967 assert_eq!(q.next_runnable().unwrap().id, a.id);
3969 assert_eq!(q.list().len(), 3, "b is still waiting its turn");
3970 }
3971
3972 #[test]
3973 fn a_blocked_task_never_starves_another_runnable_one() {
3974 let (_dir, q) = queue();
3975 let mut blocked = task("blocked");
3976 blocked.block(vec!["something".to_owned()], None);
3977 q.put(&mut blocked).unwrap();
3978
3979 let mut runnable = task("free to go");
3980 q.put(&mut runnable).unwrap();
3981
3982 let next = q.next_runnable().expect("a runnable task is still offered");
3983 assert_eq!(next.id, runnable.id);
3984 }
3985
3986 #[test]
3987 fn a_held_task_is_never_offered_to_the_loop() {
3988 let (_dir, q) = queue();
3989 let mut t = task("held");
3990 q.put(&mut t).unwrap();
3991 assert!(q.next_runnable().is_some());
3992
3993 t.hold_machine(None);
3994 q.put(&mut t).unwrap();
3995 assert!(
3996 q.next_runnable().is_none(),
3997 "a held task must wait for a human"
3998 );
3999
4000 t.status = TaskStatus::Failed;
4002 q.put(&mut t).unwrap();
4003 assert!(q.next_runnable().is_some());
4004 }
4005
4006 #[test]
4007 fn attempts_are_capped_and_then_the_task_is_held() {
4008 let mut t = task("doomed");
4009
4010 t.start("run-1".to_owned());
4011 t.fail("gate red", 2);
4012 assert_eq!(t.status, TaskStatus::Failed, "one attempt of two: retry");
4013
4014 t.start("run-2".to_owned());
4015 t.fail("gate red", 2);
4016 assert_eq!(
4017 t.status,
4018 TaskStatus::Held,
4019 "out of attempts: stop spending money on it"
4020 );
4021 assert_eq!(t.runs, ["run-1", "run-2"]);
4022 assert_eq!(t.last_error.as_deref(), Some("gate red"));
4023 assert_eq!(
4024 t.hold_reason.as_deref(),
4025 Some("gate red"),
4026 "the hold must say why, not leave hold_reason null next to a \
4027 populated last_error"
4028 );
4029 }
4030
4031 #[test]
4032 fn handing_off_a_task_records_a_hold_reason_too() {
4033 let mut t = task("left a pull request");
4034 t.start("run-1".to_owned());
4035 t.handed_off("run ended with a pull request open [run run-1]");
4036 assert_eq!(t.status, TaskStatus::Held);
4037 assert_eq!(t.hold_source, Some(HoldSource::Machine));
4038 assert_eq!(
4039 t.hold_reason.as_deref(),
4040 Some("run ended with a pull request open [run run-1]")
4041 );
4042 assert_eq!(t.hold_reason, t.last_error);
4043 }
4044
4045 #[test]
4046 fn a_quota_stall_is_refunded_so_the_backlog_survives_the_night() {
4047 let mut t = task("stalled by quota");
4048
4049 t.start("run-1".to_owned());
4050 assert_eq!(t.attempts, 1);
4051 t.stall("judge-1, judge-2 out of quota");
4052 assert_eq!(
4053 t.attempts, 0,
4054 "a closed quota window must not spend the task's retry budget"
4055 );
4056 assert_eq!(t.status, TaskStatus::Failed, "the loop should retry it");
4057 assert_eq!(
4058 t.last_error.as_deref(),
4059 Some("judge-1, judge-2 out of quota")
4060 );
4061
4062 for _ in 0..20 {
4065 t.start("run-n".to_owned());
4066 t.stall("still out of quota");
4067 }
4068 t.start("run-real".to_owned());
4069 t.fail("gate red", 2);
4070 assert_eq!(
4071 t.status,
4072 TaskStatus::Failed,
4073 "the first attempt that was really judged is attempt one"
4074 );
4075 }
4076
4077 #[test]
4078 fn releasing_a_held_task_gives_it_a_real_second_chance() {
4079 let mut t = task("retry me");
4080 t.start("run-1".to_owned());
4081 t.fail("gate red", 1);
4082 assert_eq!(t.status, TaskStatus::Held);
4083
4084 t.release();
4085 assert_eq!(t.status, TaskStatus::Queued);
4086 assert_eq!(t.attempts, 0);
4089 assert!(t.last_error.is_none());
4090 assert_eq!(
4091 t.runs.len(),
4092 1,
4093 "history is kept: attempts reset, evidence does not"
4094 );
4095 }
4096
4097 #[test]
4098 fn a_hold_reason_survives_and_a_release_clears_it() {
4099 let mut t = task("waiting on something else");
4100 t.hold_manual(Some(
4101 "waiting for 20260101-000000-aaaa to land first".to_owned(),
4102 ));
4103 assert_eq!(t.status, TaskStatus::Held);
4104 assert_eq!(
4105 t.hold_reason.as_deref(),
4106 Some("waiting for 20260101-000000-aaaa to land first")
4107 );
4108
4109 t.hold_manual(None);
4111 assert_eq!(
4112 t.hold_reason.as_deref(),
4113 Some("waiting for 20260101-000000-aaaa to land first"),
4114 "a bare re-hold keeps whatever a human already wrote down"
4115 );
4116
4117 let mut plain = task("no reason given");
4119 plain.hold_manual(None);
4120 assert_eq!(plain.status, TaskStatus::Held);
4121 assert!(plain.hold_reason.is_none());
4122
4123 t.release();
4124 assert_eq!(t.status, TaskStatus::Queued);
4125 assert!(
4126 t.hold_reason.is_none(),
4127 "a stale reason must not greet the next person who holds this task"
4128 );
4129 }
4130
4131 #[test]
4132 fn closing_a_held_task_as_done_clears_its_hold_reason_too() {
4133 let mut t = task("landed by hand while held");
4138 t.hold_manual(Some("waiting on 3ed9".to_owned()));
4139 assert_eq!(t.hold_reason.as_deref(), Some("waiting on 3ed9"));
4140
4141 t.succeed();
4142 assert_eq!(t.status, TaskStatus::Done);
4143 assert!(
4144 t.hold_reason.is_none(),
4145 "a done task cannot still be waiting on something"
4146 );
4147 }
4148
4149 #[test]
4150 fn holding_or_closing_a_blocked_task_clears_its_dependency_too() {
4151 let mut held = task("held straight out of blocked");
4158 held.block(
4159 vec!["20260101-000000-dead".to_owned()],
4160 Some("waiting on the migration script".to_owned()),
4161 );
4162 assert_eq!(held.status, TaskStatus::Blocked);
4163
4164 held.hold_manual(None);
4165 assert_eq!(held.status, TaskStatus::Held);
4166 assert!(
4167 held.blocked_by.is_empty(),
4168 "hold overrides the wait, same as release"
4169 );
4170 assert!(held.block_reason.is_none());
4171
4172 let mut done = task("closed straight out of blocked");
4173 done.block(
4174 vec!["20260101-000000-dead".to_owned()],
4175 Some("waiting on the migration script".to_owned()),
4176 );
4177 done.succeed();
4178 assert_eq!(done.status, TaskStatus::Done);
4179 assert!(
4180 done.blocked_by.is_empty(),
4181 "a done task cannot still be waiting on a dependency"
4182 );
4183 assert!(done.block_reason.is_none());
4184 }
4185
4186 #[test]
4187 fn a_blocked_task_is_never_offered_to_the_loop() {
4188 let mut t = task("blocked");
4189 assert!(t.status.runnable());
4190 t.block(
4191 vec!["dep-id".to_owned()],
4192 Some("waits on dep-id".to_owned()),
4193 );
4194 assert_eq!(t.status, TaskStatus::Blocked);
4195 assert!(!t.status.runnable());
4196 assert_eq!(TaskStatus::Blocked.as_str(), "blocked");
4197 }
4198
4199 #[test]
4200 fn unblocking_the_last_dependency_returns_the_task_to_queued() {
4201 let mut t = task("blocked on two");
4202 t.block(
4203 vec!["a".to_owned(), "b".to_owned()],
4204 Some("waits on a and b".to_owned()),
4205 );
4206
4207 t.unblock("a");
4208 assert_eq!(t.status, TaskStatus::Blocked, "b is still outstanding");
4209 assert_eq!(t.blocked_by, ["b"]);
4210
4211 t.unblock("b");
4212 assert_eq!(t.status, TaskStatus::Queued);
4213 assert!(t.blocked_by.is_empty());
4214 assert!(t.block_reason.is_none());
4215 }
4216
4217 #[test]
4218 fn unblocking_an_id_on_a_task_that_is_not_blocked_is_a_no_op() {
4219 let mut t = task("never blocked");
4220 t.unblock("whatever");
4221 assert_eq!(t.status, TaskStatus::Queued);
4222 }
4223
4224 #[test]
4225 fn a_held_task_blocked_on_a_question_returns_to_held_not_queued() {
4226 let mut t = task("held, then asked about");
4231 t.hold_machine(Some("out of attempts".to_owned()));
4232 assert_eq!(t.status, TaskStatus::Held);
4233
4234 t.block(vec!["q1".to_owned()], Some("what now?".to_owned()));
4235 assert_eq!(t.status, TaskStatus::Blocked);
4236
4237 t.record_answer("what now?".to_owned(), "leave it held".to_owned());
4238 t.unblock("q1");
4239 assert_eq!(t.status, TaskStatus::Held, "must restore, not requeue");
4240 assert_eq!(t.hold_reason.as_deref(), Some("out of attempts"));
4241 assert_eq!(t.hold_source, Some(HoldSource::Machine));
4242 assert!(t.blocked_from.is_none(), "consumed once restored");
4243 }
4244
4245 #[test]
4246 fn a_manually_held_task_blocked_on_a_question_returns_to_held() {
4247 let mut t = task("manually held, then asked about");
4248 t.hold_manual(Some("waiting on a dependency".to_owned()));
4249
4250 t.block(vec!["q1".to_owned()], None);
4251 t.unblock("q1");
4252
4253 assert_eq!(t.status, TaskStatus::Held);
4254 assert_eq!(t.hold_source, Some(HoldSource::Manual));
4255 }
4256
4257 #[test]
4258 fn re_blocking_an_already_blocked_task_keeps_the_original_blocked_from() {
4259 let mut t = task("held, blocked twice");
4263 t.hold_machine(None);
4264 t.block(vec!["q1".to_owned()], Some("first".to_owned()));
4265 t.block(
4266 vec!["q1".to_owned(), "q2".to_owned()],
4267 Some("second".to_owned()),
4268 );
4269
4270 t.unblock("q1");
4271 assert_eq!(t.status, TaskStatus::Blocked, "q2 still outstanding");
4272 t.unblock("q2");
4273 assert_eq!(t.status, TaskStatus::Held);
4274 }
4275
4276 #[test]
4277 fn unblocking_a_task_blocked_while_running_lands_on_queued_not_running() {
4278 let mut t = task("blocked mid-run");
4282 t.start("run-1".to_owned());
4283 assert_eq!(t.status, TaskStatus::Running);
4284
4285 t.block(vec!["q1".to_owned()], None);
4286 t.unblock("q1");
4287 assert_eq!(t.status, TaskStatus::Queued);
4288 }
4289
4290 #[test]
4291 fn a_pre_schema_4_blocked_record_with_hold_evidence_restores_to_held() {
4292 let mut t = task("legacy record, held before it was blocked");
4298 t.hold_source = Some(HoldSource::Machine);
4299 t.hold_reason = Some("legacy hold reason".to_owned());
4300 t.status = TaskStatus::Blocked;
4301 t.blocked_by = vec!["q1".to_owned()];
4302 t.blocked_from = None;
4303
4304 t.unblock("q1");
4305 assert_eq!(t.status, TaskStatus::Held);
4306 }
4307
4308 #[test]
4309 fn a_pre_schema_4_blocked_record_with_no_hold_evidence_restores_to_queued() {
4310 let mut t = task("legacy record, ordinary dependency block");
4311 t.status = TaskStatus::Blocked;
4312 t.blocked_by = vec!["dep".to_owned()];
4313 t.blocked_from = None;
4314
4315 t.unblock("dep");
4316 assert_eq!(t.status, TaskStatus::Queued);
4317 }
4318
4319 #[test]
4320 fn answering_a_question_is_recorded_and_survives_a_release() {
4321 let mut t = task("asked something");
4322 t.block(vec!["q1".to_owned()], Some("which backend?".to_owned()));
4323 t.record_answer("Which backend?".to_owned(), "SQLite".to_owned());
4324 t.unblock("q1");
4325 assert_eq!(t.status, TaskStatus::Queued);
4326 assert_eq!(t.answers.len(), 1);
4327 assert_eq!(t.answers[0].answer, "SQLite");
4328
4329 t.release();
4333 assert_eq!(t.answers.len(), 1, "the answer is not lost on release");
4334 }
4335
4336 #[test]
4337 fn resumption_review_branch_requires_unchanged_authored_criteria() {
4338 for source in ["cli", "web"] {
4339 let mut t = task("review old candidate");
4340 t.request_review("candidate-A".into());
4341 t.hold_for_handover(Some("candidate-A".into()), "handover refused".into());
4342 let mut unchanged = t.clone();
4343 unchanged.release_operator(source, None);
4344 assert_eq!(unchanged.review_branch.as_deref(), Some("candidate-A"));
4345 t.edit(
4346 "review approved head".into(),
4347 "Reuse measured PR head B; fix Clippy; review".into(),
4348 )
4349 .unwrap();
4350 t.release_operator(source, None);
4351 assert!(t.review_branch.is_none());
4352 assert!(t.review_instruction.is_none());
4353 let mut legacy = unchanged;
4355 legacy.review_instruction = None;
4356 legacy.hold_for_handover(None, "still checked out".into());
4357 legacy
4358 .edit("new plan".into(), "Reuse measured PR head B".into())
4359 .unwrap();
4360 legacy.release_operator(source, None);
4361 assert!(legacy.review_branch.is_none());
4362 }
4363 }
4364
4365 #[test]
4366 fn a_refused_handover_keeps_the_review_branch_across_release() {
4367 let mut t = task("refused takeover");
4368 t.start("run-1".to_owned());
4369 t.hold_for_handover(Some("magi/eba2/A".to_owned()), "checked out".to_owned());
4370 assert_eq!(t.status, TaskStatus::Held);
4371 assert_eq!(t.attempts, 1);
4372 t.release();
4373 assert_eq!(t.status, TaskStatus::Queued);
4374 assert_eq!(t.attempts, 0);
4375 assert_eq!(t.review_branch.as_deref(), Some("magi/eba2/A"));
4376
4377 t.hold_for_handover(None, "again".to_owned());
4379 t.requeue();
4380 assert!(t.review_branch.is_none());
4381
4382 let mut m = task("manual");
4384 m.review_branch = Some("magi/x/A".to_owned());
4385 m.hold_manual(None);
4386 m.release();
4387 assert!(m.review_branch.is_none());
4388 }
4389
4390 #[test]
4391 fn requesting_review_requeues_the_task_and_remembers_the_branch() {
4392 let mut t = task("blocked run with a surviving branch");
4393 t.start("run-1".to_owned());
4394 t.fail("blocked with major findings", 5);
4395 assert_eq!(t.status, TaskStatus::Failed);
4396
4397 t.request_review("magi/eba2/A".to_owned());
4398 assert_eq!(t.status, TaskStatus::Queued);
4399 assert_eq!(t.attempts, 0);
4400 assert_eq!(t.review_branch.as_deref(), Some("magi/eba2/A"));
4401
4402 t.release();
4404 assert!(t.review_branch.is_none());
4405 }
4406
4407 #[test]
4408 fn conductor_requeue_but_not_an_ordinary_release_forces_a_fresh_start() {
4409 let mut t = task("retry");
4410 t.start("run-1".to_owned());
4411 t.requeue();
4412 assert!(t.fresh_start);
4413
4414 t.release();
4415 assert!(!t.fresh_start);
4416 }
4417
4418 #[test]
4419 fn urgent_only_changes_queued_or_held_and_preserves_settings() {
4420 for status in [
4421 TaskStatus::Queued,
4422 TaskStatus::Held,
4423 TaskStatus::Running,
4424 TaskStatus::Done,
4425 TaskStatus::Failed,
4426 TaskStatus::Blocked,
4427 TaskStatus::Parked,
4428 ] {
4429 let mut t = task("urgent settings");
4430 t.status = status;
4431 t.attempts = 2;
4432 t.hold_reason = Some("waiting".into());
4433 t.priority = 7;
4434 t.interrupt = true;
4435 let before = serde_json::to_value(&t).unwrap();
4436 for urgent in [true, true, false, false] {
4437 let result = t.set_urgent(urgent);
4438 let mut expected = before.clone();
4439 if matches!(status, TaskStatus::Queued | TaskStatus::Held) {
4440 result.unwrap();
4441 expected["urgent"] = urgent.into();
4442 } else {
4443 let error = result.unwrap_err().to_string();
4444 assert!(error.contains(status.as_str()), "{error}");
4445 assert!(error.contains("queued or held"), "{error}");
4446 }
4447 assert_eq!(serde_json::to_value(&t).unwrap(), expected);
4448 }
4449 }
4450 }
4451
4452 #[test]
4453 fn priority_can_be_changed_while_queued_but_not_while_running() {
4454 let mut t = task("reprioritise me");
4455 t.set_priority(5).unwrap();
4456 assert_eq!(t.priority, 5);
4457
4458 t.start("run-1".to_owned());
4459 let err = t.set_priority(9).unwrap_err().to_string();
4460 assert!(err.contains("running"), "{err}");
4461 assert_eq!(t.priority, 5, "the rejected write must not partially apply");
4462 }
4463
4464 #[test]
4465 fn interrupt_can_be_marked_while_queued_but_not_while_running() {
4466 let mut t = task("interrupt me");
4467 assert!(!t.interrupt, "off unless asked, same as any other task");
4468
4469 t.set_interrupt(true).unwrap();
4470 assert!(t.interrupt);
4471
4472 t.start("run-1".to_owned());
4473 assert!(
4474 !t.interrupt,
4475 "the mark is one-shot: dispatching the task fulfils it, \
4476 whatever the run that follows ends up doing"
4477 );
4478 let err = t.set_interrupt(true).unwrap_err().to_string();
4479 assert!(err.contains("running"), "{err}");
4480 t.set_interrupt(false).unwrap();
4483 assert!(!t.interrupt);
4484 }
4485
4486 #[test]
4490 fn a_failed_run_does_not_leave_the_task_still_marked_to_interrupt() {
4491 let mut t = task("interrupt me");
4492 t.set_interrupt(true).unwrap();
4493 t.start("run-1".to_owned());
4494 t.fail("mock failure", 5);
4495 assert_eq!(t.status, TaskStatus::Failed);
4496 assert!(
4497 !t.interrupt,
4498 "one attempt already spent the mark; a retry is an ordinary \
4499 requeue, not a fresh interrupt request"
4500 );
4501 }
4502
4503 #[test]
4504 fn changing_priority_moves_a_task_ahead_in_the_real_queue_order() {
4505 let (_dir, q) = queue();
4506 let mut a = task("first filed");
4507 let mut b = task("second filed");
4508 a.id = "20260101-000001-aaaa".to_owned();
4509 b.id = "20260101-000002-bbbb".to_owned();
4510 q.put(&mut a).unwrap();
4511 q.put(&mut b).unwrap();
4512
4513 assert_eq!(
4514 q.next_runnable().unwrap().id,
4515 a.id,
4516 "with equal priority the older task goes first, so a burst of \
4517 new work cannot starve it"
4518 );
4519 assert_eq!(
4520 q.list()[0].id,
4521 b.id,
4522 "but the list an operator reads is newest first, the same as \
4523 before priority existed - a's turn to run does not make it the \
4524 newest task"
4525 );
4526
4527 let mut a = q.get(&a.id).unwrap();
4528 a.set_priority(10).unwrap();
4529 q.put(&mut a).unwrap();
4530
4531 assert_eq!(
4532 q.next_runnable().unwrap().id,
4533 a.id,
4534 "a raised priority must be reflected the moment it is saved"
4535 );
4536 assert_eq!(
4540 q.list()[0].id,
4541 a.id,
4542 "the raised task must sort first in the list an operator reads, \
4543 not only in next_runnable's own ordering"
4544 );
4545 }
4546
4547 #[test]
4548 fn editing_replaces_title_and_instruction_but_keeps_identity_and_history() {
4549 let mut t = Task::new(
4550 "old title".to_owned(),
4551 "old instruction".to_owned(),
4552 PathBuf::from("/repo"),
4553 Source::Agent {
4554 run: "20260101-000000-beef".to_owned(),
4555 node: "implement".to_owned(),
4556 },
4557 );
4558 let id = t.id.clone();
4559 let created_at = t.created_at;
4560 t.runs.push("20260101-000000-beef".to_owned());
4561
4562 t.edit("new title".to_owned(), "new instruction".to_owned())
4563 .unwrap();
4564
4565 assert_eq!(t.title, "new title");
4566 assert_eq!(t.instruction, "new instruction");
4567 assert_eq!(t.id, id, "editing must not mint a new id");
4568 assert_eq!(t.created_at, created_at);
4569 assert_eq!(
4570 t.source,
4571 Source::Agent {
4572 run: "20260101-000000-beef".to_owned(),
4573 node: "implement".to_owned(),
4574 },
4575 "editing must not turn agent attribution into human"
4576 );
4577 assert_eq!(t.runs, ["20260101-000000-beef"]);
4578 }
4579
4580 #[test]
4581 fn editing_is_refused_once_a_task_is_running_or_finished() {
4582 let mut running = task("in flight");
4583 running.start("run-1".to_owned());
4584 let err = running
4585 .edit("x".to_owned(), "y".to_owned())
4586 .unwrap_err()
4587 .to_string();
4588 assert!(err.contains("running"), "{err}");
4589
4590 let mut done = task("finished");
4591 done.succeed();
4592 let err = done
4593 .edit("x".to_owned(), "y".to_owned())
4594 .unwrap_err()
4595 .to_string();
4596 assert!(err.contains("done"), "{err}");
4597
4598 let mut queued = task("waiting");
4600 queued.edit("x".to_owned(), "y".to_owned()).unwrap();
4601 let mut held = task("parked");
4602 held.hold_machine(None);
4603 held.edit("x".to_owned(), "y".to_owned()).unwrap();
4604 }
4605
4606 #[test]
4607 fn a_task_recorded_without_a_hold_reason_still_reads_as_none() {
4608 let (_dir, q) = queue();
4609 let path = q.path_of("20260101-000000-aaaa");
4610 std::fs::create_dir_all(q.root()).unwrap();
4611 std::fs::write(
4612 &path,
4613 serde_json::json!({
4614 "schema": SCHEMA,
4615 "id": "20260101-000000-aaaa",
4616 "title": "from before hold reasons existed",
4617 "instruction": "from before hold reasons existed",
4618 "repo": ".",
4619 "source": { "kind": "human" },
4620 "status": "held",
4621 "created_at": Timestamp::now().to_string(),
4622 "updated_at": Timestamp::now().to_string(),
4623 })
4624 .to_string(),
4625 )
4626 .unwrap();
4627
4628 let task = q.get("20260101-000000-aaaa").expect("must still read");
4629 assert!(task.hold_reason.is_none());
4630 assert!(task.operator_held());
4631 }
4632
4633 #[test]
4634 fn a_legacy_reasoned_hold_defaults_to_operator_protection() {
4635 let (_dir, q) = queue();
4636 let path = q.path_of("20260101-000000-bbbb");
4637 std::fs::create_dir_all(q.root()).unwrap();
4638 std::fs::write(
4639 &path,
4640 serde_json::json!({
4641 "schema": 2,
4642 "id": "20260101-000000-bbbb",
4643 "title": "old manual recovery",
4644 "instruction": "old manual recovery",
4645 "repo": ".",
4646 "source": { "kind": "human" },
4647 "status": "held",
4648 "hold_reason": "active manual recovery run20260912-224242-daf5",
4649 "created_at": Timestamp::now().to_string(),
4650 "updated_at": Timestamp::now().to_string(),
4651 })
4652 .to_string(),
4653 )
4654 .unwrap();
4655
4656 let task = q.get("20260101-000000-bbbb").expect("must still read");
4657 assert_eq!(task.hold_source, None);
4658 assert!(task.operator_held());
4659 }
4660
4661 #[test]
4662 fn a_task_recorded_without_a_diagnostic_still_reads_as_none() {
4663 let (_dir, q) = queue();
4664 let path = q.path_of("20260101-000000-aaaa");
4665 std::fs::create_dir_all(q.root()).unwrap();
4666 std::fs::write(
4667 &path,
4668 serde_json::json!({
4669 "schema": SCHEMA,
4670 "id": "20260101-000000-aaaa",
4671 "title": "from before diagnostics existed",
4672 "instruction": "from before diagnostics existed",
4673 "repo": ".",
4674 "source": { "kind": "human" },
4675 "status": "held",
4676 "created_at": Timestamp::now().to_string(),
4677 "updated_at": Timestamp::now().to_string(),
4678 })
4679 .to_string(),
4680 )
4681 .unwrap();
4682
4683 let task = q.get("20260101-000000-aaaa").expect("must still read");
4684 assert!(task.diagnostic.is_none());
4685 }
4686
4687 #[test]
4688 fn a_schema_1_task_with_no_blocking_fields_still_reads() {
4689 let (_dir, q) = queue();
4693 let path = q.path_of("20260101-000000-aaaa");
4694 std::fs::create_dir_all(q.root()).unwrap();
4695 std::fs::write(
4696 &path,
4697 serde_json::json!({
4698 "schema": 1,
4699 "id": "20260101-000000-aaaa",
4700 "title": "from before blocking existed",
4701 "instruction": "from before blocking existed",
4702 "repo": ".",
4703 "source": { "kind": "human" },
4704 "status": "queued",
4705 "created_at": Timestamp::now().to_string(),
4706 "updated_at": Timestamp::now().to_string(),
4707 })
4708 .to_string(),
4709 )
4710 .unwrap();
4711
4712 let task = q.get("20260101-000000-aaaa").expect("must still read");
4713 assert!(task.blocked_by.is_empty());
4714 assert!(task.block_reason.is_none());
4715 assert!(task.answers.is_empty());
4716 assert!(task.review_branch.is_none());
4717 }
4718
4719 #[test]
4720 fn releasing_or_finishing_a_task_clears_its_stale_diagnostic() {
4721 let mut held = task("diagnosed");
4726 held.start("run-1".to_owned());
4727 held.fail("gate red", 1);
4728 held.diagnostic = Some("cargo test failed: ...".to_owned());
4729 assert_eq!(held.status, TaskStatus::Held);
4730
4731 held.release();
4732 assert!(held.diagnostic.is_none());
4733
4734 held.diagnostic = Some("cargo test failed: ...".to_owned());
4735 held.succeed();
4736 assert!(held.diagnostic.is_none());
4737 }
4738
4739 #[test]
4740 fn failing_a_task_always_clears_whatever_diagnostic_it_carried() {
4741 let mut t = task("retried");
4742 t.start("run-1".to_owned());
4743 t.diagnostic = Some("stale evidence from a previous hold".to_owned());
4744 t.fail("unrelated config error", 5);
4745 assert_eq!(t.status, TaskStatus::Failed);
4746 assert!(
4747 t.diagnostic.is_none(),
4748 "fail() must not let an old diagnostic outlive the run that produced it"
4749 );
4750 }
4751
4752 #[test]
4753 fn a_claim_is_exclusive_and_releases_on_drop() {
4754 let (_dir, q) = queue();
4755 let mut t = task("contended");
4756 q.put(&mut t).unwrap();
4757
4758 let held = q.claim(&t.id).unwrap();
4759 assert!(
4760 q.claim(&t.id).is_err(),
4761 "two daemons must not drive one task into two runs"
4762 );
4763 drop(held);
4764 assert!(q.claim(&t.id).is_ok(), "a released claim is reclaimable");
4765 }
4766
4767 #[test]
4768 fn a_round_trip_survives_disk() {
4769 let (_dir, q) = queue();
4770 let mut t = Task::new(
4771 "titled".to_owned(),
4772 "body".to_owned(),
4773 PathBuf::from("/repo"),
4774 Source::Agent {
4775 run: "20260101-000000-beef".to_owned(),
4776 node: "implement".to_owned(),
4777 },
4778 );
4779 t.priority = 3;
4780 q.put(&mut t).unwrap();
4781
4782 let back = q.get(&t.id).unwrap();
4783 assert_eq!(back.id, t.id);
4784 assert_eq!(back.priority, 3);
4785 assert_eq!(back.source.label(), "implement@beef");
4786 assert_eq!(q.get(t.short()).unwrap().id, t.id);
4788 }
4789
4790 #[test]
4791 fn an_unreadable_task_does_not_take_the_queue_down() {
4792 let (_dir, q) = queue();
4793 let mut t = task("fine");
4794 q.put(&mut t).unwrap();
4795 std::fs::write(q.root().join("broken.json"), "{ not json").unwrap();
4796
4797 let listed = q.list();
4798 assert_eq!(listed.len(), 1, "the readable task still lists");
4799 assert_eq!(listed[0].id, t.id);
4800 }
4801
4802 #[test]
4803 fn a_task_recorded_without_a_solo_field_still_reads_as_not_solo() {
4804 let (_dir, q) = queue();
4805 let path = q.path_of("20260101-000000-aaaa");
4806 std::fs::create_dir_all(q.root()).unwrap();
4807 std::fs::write(
4808 &path,
4809 serde_json::json!({
4810 "schema": SCHEMA,
4811 "id": "20260101-000000-aaaa",
4812 "title": "from before solo existed",
4813 "instruction": "from before solo existed",
4814 "repo": ".",
4815 "source": { "kind": "human" },
4816 "status": "queued",
4817 "created_at": Timestamp::now().to_string(),
4818 "updated_at": Timestamp::now().to_string(),
4819 })
4820 .to_string(),
4821 )
4822 .unwrap();
4823
4824 let task = q.get("20260101-000000-aaaa").expect("must still read");
4825 assert!(!task.solo, "a queue file with no `solo` field means false");
4826 }
4827
4828 #[test]
4829 fn a_task_recorded_without_an_urgent_field_still_reads_as_not_urgent() {
4830 let (_dir, q) = queue();
4831 let path = q.path_of("20260101-000000-bbbb");
4832 std::fs::create_dir_all(q.root()).unwrap();
4833 std::fs::write(
4834 &path,
4835 serde_json::json!({
4836 "schema": SCHEMA,
4837 "id": "20260101-000000-bbbb",
4838 "title": "from before urgent existed",
4839 "instruction": "from before urgent existed",
4840 "repo": ".",
4841 "source": { "kind": "human" },
4842 "status": "queued",
4843 "created_at": Timestamp::now().to_string(),
4844 "updated_at": Timestamp::now().to_string(),
4845 })
4846 .to_string(),
4847 )
4848 .unwrap();
4849
4850 let task = q.get("20260101-000000-bbbb").expect("must still read");
4851 assert!(
4852 !task.urgent,
4853 "a queue file with no `urgent` field means false, same as `solo`"
4854 );
4855 }
4856
4857 #[test]
4858 fn a_task_from_a_future_schema_is_refused_rather_than_guessed_at() {
4859 let (_dir, q) = queue();
4860 let mut t = task("from the future");
4861 q.put(&mut t).unwrap();
4862 let path = q.path_of(&t.id);
4863 let body = std::fs::read_to_string(&path)
4864 .unwrap()
4865 .replace(&format!("\"schema\": {SCHEMA}"), "\"schema\": 99");
4866 std::fs::write(&path, body).unwrap();
4867
4868 let err = q.get(&t.id).unwrap_err().to_string();
4869 assert!(err.contains("schema 99"), "{err}");
4870 }
4871
4872 #[test]
4873 fn revision_moves_when_the_queue_changes() {
4874 let (_dir, q) = queue();
4875 assert_eq!(q.revision(), 0, "an empty queue has no revision");
4876 let mut t = task("first");
4877 q.put(&mut t).unwrap();
4878 assert!(q.revision() > 0, "a written task moves the revision");
4879 }
4880
4881 #[test]
4882 fn revision_moves_when_deleting_an_older_task() {
4883 let (dir, q) = queue();
4884 let questions = Questions::at(dir.path().join("questions"));
4885 let mut t1 = task("older");
4886 q.put(&mut t1).unwrap();
4887 std::thread::sleep(std::time::Duration::from_millis(10));
4889 let mut t2 = task("newer");
4890 q.put(&mut t2).unwrap();
4891
4892 let rev_before = q.revision();
4893 q.remove(&t1.id, false, &questions).unwrap();
4894 let rev_after = q.revision();
4895
4896 assert_ne!(
4897 rev_before, rev_after,
4898 "deleting an older task must change the revision so other clients see the deletion"
4899 );
4900 }
4901
4902 #[test]
4903 fn removing_a_task_takes_it_out_of_the_listing() {
4904 let (dir, q) = queue();
4905 let questions = Questions::at(dir.path().join("questions"));
4906 let mut t = task("delete me");
4907 q.put(&mut t).unwrap();
4908 let removed = q.remove(t.short(), false, &questions).unwrap();
4909 assert_eq!(removed.id, t.id, "a prefix resolves before deleting");
4910 assert!(removed.released.is_empty() && removed.still_blocked.is_empty());
4911 assert!(q.list().is_empty());
4912 assert!(
4913 q.remove(&t.id, false, &questions).is_err(),
4914 "removing twice is an error"
4915 );
4916 }
4917
4918 #[test]
4919 fn removing_a_task_takes_its_stale_lock_with_it() {
4920 let (dir, q) = queue();
4921 let questions = Questions::at(dir.path().join("questions"));
4922 let mut t = task("interrupted");
4923 q.put(&mut t).unwrap();
4924
4925 let claim = q.claim(&t.id).unwrap();
4928 std::mem::forget(claim);
4929 assert!(
4930 q.claim(&t.id).is_err(),
4931 "the orphaned lock is what makes the task look claimed"
4932 );
4933
4934 let err = q.remove(&t.id, true, &questions).unwrap_err().to_string();
4936 assert!(err.contains("live daemon"), "{err}");
4937 assert!(q.get(&t.id).is_ok(), "a refused delete keeps the task");
4938
4939 q.remove(&t.id, false, &questions).unwrap();
4941 assert!(q.list().is_empty());
4942 let mut again = task("interrupted");
4943 again.id = t.id.clone();
4944 q.put(&mut again).unwrap();
4945 assert!(
4946 q.claim(&t.id).is_ok(),
4947 "a task that comes back must be claimable, which a left-behind lock would prevent"
4948 );
4949 }
4950
4951 fn notices_of(dir: &Path) -> Vec<crate::notices::Notice> {
4952 crate::notices::Notices::at(dir.join("notifications")).list()
4953 }
4954
4955 #[test]
4956 fn removing_a_sole_dependency_releases_the_dependent_without_a_hold() {
4957 let (dir, q) = queue();
4958 let questions = Questions::at(dir.path().join("questions"));
4959 let mut dep = task("dependency");
4960 q.put(&mut dep).unwrap();
4961 let mut blocked = task("waiting");
4962 blocked.block(vec![dep.id.clone()], Some("waits".to_owned()));
4963 q.put(&mut blocked).unwrap();
4964
4965 let removed = q.remove(&dep.id, false, &questions).unwrap();
4966 assert_eq!(removed.released, [blocked.id.clone()]);
4967 assert!(removed.still_blocked.is_empty());
4968
4969 let after = q.get(&blocked.id).unwrap();
4970 assert_eq!(after.status, TaskStatus::Queued);
4971 assert!(after.blocked_by.is_empty());
4972 assert!(after.block_reason.is_none());
4973 let notes = notices_of(dir.path());
4974 assert_eq!(notes.len(), 1, "{notes:?}");
4975 assert_eq!(notes[0].severity, crate::notices::Severity::Info);
4976 assert_eq!(
4977 notes[0].link,
4978 Some(crate::notices::Link::Task {
4979 id: blocked.id.clone(),
4980 })
4981 );
4982 }
4983
4984 #[test]
4985 fn removing_one_of_two_dependencies_keeps_the_other() {
4986 let (dir, q) = queue();
4987 let questions = Questions::at(dir.path().join("questions"));
4988 let mut dep = task("dependency");
4989 q.put(&mut dep).unwrap();
4990 let mut other = task("other");
4991 q.put(&mut other).unwrap();
4992 let mut blocked = task("waiting");
4993 blocked.block(
4994 vec![dep.id.clone(), other.id.clone()],
4995 Some("waits on both".to_owned()),
4996 );
4997 q.put(&mut blocked).unwrap();
4998
4999 let removed = q.remove(&dep.id, false, &questions).unwrap();
5000 assert!(removed.released.is_empty());
5001 assert_eq!(removed.still_blocked, [blocked.id.clone()]);
5002
5003 let after = q.get(&blocked.id).unwrap();
5004 assert_eq!(after.status, TaskStatus::Blocked);
5005 assert_eq!(after.blocked_by, [other.id.clone()]);
5006 assert!(missing_blockers(&q, &questions, &after.blocked_by).is_empty());
5009 }
5010
5011 #[test]
5012 fn a_dependency_deleted_before_its_dependents_were_rewritten_is_released_later() {
5013 let (dir, q) = queue();
5014 let questions = Questions::at(dir.path().join("questions"));
5015 let mut dep = task("dependency");
5016 q.put(&mut dep).unwrap();
5017 let mut blocked = task("waiting");
5018 blocked.block(vec![dep.id.clone()], None);
5019 q.put(&mut blocked).unwrap();
5020
5021 let claim = q.claim(&blocked.id).unwrap();
5023 let removed = q.remove(&dep.id, false, &questions).unwrap();
5024 assert!(removed.released.is_empty());
5025 drop(claim);
5026
5027 let mut task = q.get(&blocked.id).unwrap();
5028 assert_eq!(task.status, TaskStatus::Blocked);
5029 assert!(missing_blockers(&q, &questions, &task.blocked_by).is_empty());
5030 assert_eq!(
5031 q.apply_deleted_blockers(&mut task, &questions),
5032 [dep.id.clone()]
5033 );
5034 assert_eq!(task.status, TaskStatus::Queued);
5035 }
5036
5037 #[test]
5038 fn dependency_resolution_short_deleted_reference_releases_only_its_waiters() {
5039 let (dir, q) = queue();
5040 let questions = Questions::at(dir.path().join("questions"));
5041 let mut dep = task("dependency");
5042 dep.id = "20261013-193611-ca5b".into();
5043 q.put(&mut dep).unwrap();
5044 let mut waiting = task("waiting");
5045 waiting.block(vec!["ca5b".into()], None);
5046 q.put(&mut waiting).unwrap();
5047 q.remove(&dep.id, false, &questions).unwrap();
5048 assert_eq!(q.get(&waiting.id).unwrap().status, TaskStatus::Queued);
5049 assert_eq!(
5050 dependency(&q, &questions, "ca5b"),
5051 Dependency::Deleted(dep.id)
5052 );
5053 }
5054
5055 #[test]
5056 fn dependency_resolution_preserves_cross_store_ambiguity_and_exact_ids() {
5057 let (dir, queue) = queue();
5058 let questions = Questions::at(dir.path().join("questions"));
5059 let mut dep = task("dependency");
5060 dep.id = "20261013-193611-ca5b".into();
5061 queue.put(&mut dep).unwrap();
5062 std::fs::create_dir_all(questions.root()).unwrap();
5063 let question_id = "20261014-193611-ca5b";
5064 std::fs::write(questions.path_of(question_id), "unreadable question").unwrap();
5065 assert_eq!(questions.resolve_id("ca5b").unwrap(), question_id);
5066 assert_eq!(
5067 dependency(&queue, &questions, "ca5b"),
5068 Dependency::Ambiguous
5069 );
5070 assert_eq!(
5071 dependency(&queue, &questions, &dep.id),
5072 Dependency::Task(dep.id)
5073 );
5074 assert_eq!(
5075 dependency(&queue, &questions, question_id),
5076 Dependency::Question(question_id.into())
5077 );
5078 assert_eq!(
5079 dependency(&queue, &questions, "missing"),
5080 Dependency::Missing
5081 );
5082 }
5083
5084 #[test]
5085 fn dependency_resolution_directory_errors_are_unknown_not_missing() {
5086 let dir = tempfile::tempdir().unwrap();
5087 let q = Queue::at(dir.path().join("not-a-directory"));
5088 std::fs::write(q.root(), "file").unwrap();
5089 let questions = Questions::at(dir.path().join("questions"));
5090 assert_eq!(dependency(&q, &questions, "ca5b"), Dependency::Unknown);
5091 assert!(missing_blockers(&q, &questions, &["ca5b".into()]).is_empty());
5092 }
5093
5094 #[test]
5095 fn a_dependency_without_a_tombstone_is_still_missing() {
5096 let (dir, q) = queue();
5097 let questions = Questions::at(dir.path().join("questions"));
5098 let mut dep = task("dependency");
5099 q.put(&mut dep).unwrap();
5100 std::fs::remove_file(q.path_of(&dep.id)).unwrap();
5101 assert_eq!(
5102 missing_blockers(&q, &questions, std::slice::from_ref(&dep.id)),
5103 [dep.id.clone()]
5104 );
5105 assert!(deleted_blockers(&q, &questions, std::slice::from_ref(&dep.id)).is_empty());
5106 }
5107
5108 #[test]
5109 fn a_held_dependent_is_left_alone_by_a_dependency_deletion() {
5110 let mut t = task("held");
5111 t.hold_machine(Some("because".to_owned()));
5112 t.blocked_by = vec!["gone".to_owned()];
5113 assert!(!t.dependency_deleted("gone"));
5114 assert_eq!(t.status, TaskStatus::Held);
5115 }
5116
5117 #[test]
5118 fn a_failed_record_removal_takes_the_tombstone_back() {
5119 let (_dir, q) = queue();
5120 let mut t = task("stays");
5121 q.put(&mut t).unwrap();
5122 q.write_tombstone(&t.id).unwrap();
5123 let err = q.remove_record_with_attachments(&t.id, |_| Err(std::io::Error::other("nope")));
5124 assert!(err.is_err());
5125 assert!(!q.was_deleted(&t.id));
5128 }
5129
5130 fn source_file(dir: &Path, name: &str, body: &str) -> PathBuf {
5131 let p = dir.join(name);
5132 std::fs::write(&p, body).unwrap();
5133 p
5134 }
5135
5136 #[test]
5137 fn an_attachment_copy_survives_deleting_its_source() {
5138 let (dir, q) = queue();
5139 let src = source_file(dir.path(), "shot.png", "pixels");
5140 let mut t = task("with a picture");
5141 let names = q.attach(&mut t, std::slice::from_ref(&src)).unwrap();
5142 q.put(&mut t).unwrap();
5143 std::fs::remove_file(&src).unwrap();
5144 assert_eq!(names, ["shot.png"]);
5145 let loaded = q.get(&t.id).unwrap();
5146 let paths = q.attachment_paths(&loaded);
5147 assert_eq!(paths.len(), 1);
5148 assert_eq!(std::fs::read_to_string(&paths[0]).unwrap(), "pixels");
5149 }
5150
5151 #[test]
5152 fn attachment_names_that_could_traverse_or_are_odd_are_refused() {
5153 let (dir, q) = queue();
5154 let mut t = task("bad names");
5155 for name in ["a..b.png", ".hidden", "with space.png", "-x.png"] {
5156 let src = source_file(dir.path(), name, "x");
5157 assert!(
5158 q.attach(&mut t, &[src]).is_err(),
5159 "`{name}` must be refused"
5160 );
5161 }
5162 assert!(!crate::ask::valid_asset_name("C:foo.png"));
5166 #[cfg(not(windows))]
5167 {
5168 let src = source_file(dir.path(), "C:foo.png", "x");
5169 assert!(q.attach(&mut t, &[src]).is_err());
5170 }
5171 let long = format!("{}.png", "a".repeat(70));
5172 let src = source_file(dir.path(), &long, "x");
5173 assert!(q.attach(&mut t, &[src]).is_err());
5174 assert!(t.attachments.is_empty());
5175 assert!(!q.attachments_dir(&t.id).exists());
5176 }
5177
5178 #[test]
5179 fn a_taken_attachment_name_is_numbered_not_overwritten() {
5180 let (dir, q) = queue();
5181 let a = source_file(dir.path(), "shot.png", "one");
5182 let sub = dir.path().join("other");
5183 std::fs::create_dir_all(&sub).unwrap();
5184 let b = source_file(&sub, "shot.png", "two");
5185 let mut t = task("collision");
5186 q.attach(&mut t, &[a]).unwrap();
5187 q.attach(&mut t, &[b]).unwrap();
5188 assert_eq!(t.attachments, ["shot.png", "shot-2.png"]);
5189 let paths = q.attachment_paths(&t);
5190 assert_eq!(std::fs::read_to_string(&paths[0]).unwrap(), "one");
5191 assert_eq!(std::fs::read_to_string(&paths[1]).unwrap(), "two");
5192 }
5193
5194 #[test]
5195 fn a_renumbered_name_stays_inside_the_length_bound() {
5196 let (dir, q) = queue();
5197 let name = format!("{}.png", "a".repeat(60));
5198 assert_eq!(name.len(), 64);
5199 let a = source_file(dir.path(), &name, "one");
5200 let sub = dir.path().join("other");
5201 std::fs::create_dir_all(&sub).unwrap();
5202 let b = source_file(&sub, &name, "two");
5203 let mut t = task("long");
5204 q.attach(&mut t, &[a, b]).unwrap();
5205 assert_eq!(t.attachments.len(), 2);
5206 assert!(
5207 t.attachments
5208 .iter()
5209 .all(|n| crate::ask::valid_asset_name(n))
5210 );
5211 assert!(t.attachments[1].ends_with("-2.png"));
5212 }
5213
5214 #[test]
5215 fn a_failed_attach_keeps_existing_attachments_and_leaves_no_partial_copy() {
5216 let (dir, q) = queue();
5217 let good = source_file(dir.path(), "good.png", "ok");
5218 let mut t = task("partial");
5219 q.attach(&mut t, &[good]).unwrap();
5220 let more = source_file(dir.path(), "more.png", "ok");
5221 let missing = dir.path().join("missing.png");
5222 assert!(q.attach(&mut t, &[more, missing]).is_err());
5223 assert_eq!(t.attachments, ["good.png"]);
5224 let on_disk: Vec<_> = std::fs::read_dir(q.attachments_dir(&t.id))
5225 .unwrap()
5226 .flatten()
5227 .collect();
5228 assert_eq!(on_disk.len(), 1);
5229 }
5230
5231 fn block_put(q: &Queue, t: &Task) -> PathBuf {
5233 let tmp = q.path_of(&t.id).with_extension("json.tmp");
5234 std::fs::create_dir_all(&tmp).unwrap();
5235 tmp
5236 }
5237
5238 #[test]
5239 fn a_failed_put_leaves_no_new_attachment_directory() {
5240 let (dir, q) = queue();
5241 let mut t = task("fresh");
5242 let tmp = block_put(&q, &t);
5243 let src = source_file(dir.path(), "shot.png", "x");
5244 assert!(q.attach_and_put(&mut t, &[src]).is_err());
5245 assert!(t.attachments.is_empty());
5246 assert!(!q.attachments_dir(&t.id).exists());
5247 assert!(!q.path_of(&t.id).exists());
5248 std::fs::remove_dir(tmp).unwrap();
5249 }
5250
5251 #[test]
5252 fn a_failed_put_removes_only_the_copy_it_just_made() {
5253 let (dir, q) = queue();
5254 let mut t = task("edited");
5255 let first = source_file(dir.path(), "first.png", "1");
5256 q.attach_and_put(&mut t, &[first]).unwrap();
5257 block_put(&q, &t);
5258 let second = source_file(dir.path(), "second.png", "2");
5259 assert!(q.attach_and_put(&mut t, &[second]).is_err());
5260 assert_eq!(t.attachments, ["first.png"]);
5261 let on_disk: Vec<_> = std::fs::read_dir(q.attachments_dir(&t.id))
5262 .unwrap()
5263 .flatten()
5264 .map(|e| e.file_name().to_string_lossy().into_owned())
5265 .collect();
5266 assert_eq!(on_disk, ["first.png"]);
5267 assert_eq!(q.get(&t.id).unwrap().attachments, ["first.png"]);
5268 }
5269
5270 #[test]
5271 fn a_leftover_removing_directory_is_swept_by_the_next_removal() {
5272 let (dir, q) = queue();
5273 let questions = Questions::at(dir.path().join("questions"));
5274 let gone = task("gone");
5275 let mut other = task("other");
5276 let mut live = task("live");
5277 q.put(&mut other).unwrap();
5278 q.put(&mut live).unwrap();
5279 let orphan = q.root.join(format!("{}.attachments.removing", gone.id));
5282 std::fs::create_dir_all(&orphan).unwrap();
5283 std::fs::write(orphan.join("shot.png"), "x").unwrap();
5284 let busy = q.root.join(format!("{}.attachments.removing", live.id));
5287 std::fs::create_dir_all(&busy).unwrap();
5288
5289 q.remove(&other.id, false, &questions).unwrap();
5290 assert!(!orphan.exists(), "an orphan is swept");
5291 assert!(busy.exists(), "a removal in progress is left alone");
5292 }
5293
5294 #[test]
5295 fn a_blocked_aside_rename_fails_the_removal_and_loses_nothing() {
5296 let (dir, q) = queue();
5297 let questions = Questions::at(dir.path().join("questions"));
5298 let mut t = task("stuck");
5299 let src = source_file(dir.path(), "shot.png", "x");
5300 q.attach_and_put(&mut t, &[src]).unwrap();
5301 let aside = q.root.join(format!("{}.attachments.removing", t.id));
5304 std::fs::create_dir_all(&aside).unwrap();
5305 std::fs::write(aside.join("old.png"), "o").unwrap();
5306 assert!(q.remove(&t.id, false, &questions).is_err());
5307 assert!(q.path_of(&t.id).exists());
5308 assert!(q.attachments_dir(&t.id).join("shot.png").is_file());
5309 }
5310
5311 #[test]
5312 fn a_failed_record_removal_puts_the_attachments_back() {
5313 let (dir, q) = queue();
5314 let mut t = task("rollback");
5315 let src = source_file(dir.path(), "shot.png", "x");
5316 q.attach_and_put(&mut t, &[src]).unwrap();
5317 let err = q
5318 .remove_record_with_attachments(&t.id, |_| {
5319 Err(std::io::Error::other("injected failure"))
5320 })
5321 .unwrap_err();
5322 assert!(format!("{err:#}").contains("injected failure"));
5323 assert!(q.path_of(&t.id).exists());
5324 assert!(q.attachments_dir(&t.id).join("shot.png").is_file());
5325 assert!(
5326 !q.root
5327 .join(format!("{}.attachments.removing", t.id))
5328 .exists()
5329 );
5330 }
5331
5332 #[test]
5333 fn editing_a_task_keeps_its_attachments() {
5334 let (dir, q) = queue();
5335 let src = source_file(dir.path(), "shot.png", "x");
5336 let mut t = task("editable");
5337 q.attach(&mut t, &[src]).unwrap();
5338 t.edit("new".to_owned(), "new text".to_owned()).unwrap();
5339 q.put(&mut t).unwrap();
5340 assert_eq!(q.get(&t.id).unwrap().attachments, ["shot.png"]);
5341 }
5342
5343 #[test]
5344 fn removing_a_task_deletes_its_attachments() {
5345 let (dir, q) = queue();
5346 let questions = Questions::at(dir.path().join("questions"));
5347 let src = source_file(dir.path(), "shot.png", "x");
5348 let mut t = task("doomed");
5349 q.attach(&mut t, &[src]).unwrap();
5350 q.put(&mut t).unwrap();
5351 assert!(q.attachments_dir(&t.id).is_dir());
5352 q.remove(&t.id, false, &questions).unwrap();
5353 assert!(!q.attachments_dir(&t.id).exists());
5354 assert!(q.list().is_empty());
5355 }
5356
5357 #[test]
5358 fn a_task_written_before_attachments_still_reads() {
5359 let (_dir, q) = queue();
5360 let mut t = task("old");
5361 q.put(&mut t).unwrap();
5362 let path = q.path_of(&t.id);
5363 let mut v: serde_json::Value =
5364 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
5365 v.as_object_mut().unwrap().remove("attachments");
5366 std::fs::write(&path, v.to_string()).unwrap();
5367 assert!(q.get(&t.id).unwrap().attachments.is_empty());
5368 }
5369
5370 #[test]
5371 fn attachment_paths_are_absolute_even_when_the_root_is_relative() {
5372 let q = Queue::at(PathBuf::from("relative-queue"));
5373 let mut t = task("rel");
5374 t.attachments.push("shot.png".to_owned());
5375 let paths = q.attachment_paths(&t);
5376 assert!(paths[0].is_absolute(), "{}", paths[0].display());
5377 assert!(paths[0].ends_with(format!("{}.attachments/shot.png", t.id)));
5378 }
5379
5380 fn upgrade_parked(id: &str, started: &str) -> Task {
5381 let mut t = task(id);
5382 t.id = id.into();
5383 t.start(format!("run-{id}"));
5384 t.park("upgrade");
5385 t.record_pass_at(
5386 &format!("run-{id}"),
5387 0,
5388 Some(started.parse().unwrap()),
5389 1,
5390 Some(crate::run::ParkCause::Upgrade {
5391 from: "old".into(),
5392 to: None,
5393 }),
5394 );
5395 t
5396 }
5397
5398 #[test]
5399 fn upgrade_resume_order_follows_dispatch_claim_not_graph_start() {
5400 let stamp = |id: &str, dispatched: &str, started: &str| {
5402 let mut t = task(id);
5403 t.id = id.into();
5404 t.start(format!("run-{id}"));
5405 t.park("upgrade");
5406 t.dispatched_at = Some(dispatched.parse().unwrap());
5407 t.record_pass_at(
5408 &format!("run-{id}"),
5409 0,
5410 Some(started.parse().unwrap()),
5411 1,
5412 Some(crate::run::ParkCause::Upgrade {
5413 from: "old".into(),
5414 to: None,
5415 }),
5416 );
5417 t
5418 };
5419 let a = stamp("a", "2026-10-22T00:00:01Z", "2026-10-22T00:00:09Z");
5420 let b = stamp("b", "2026-10-22T00:00:02Z", "2026-10-22T00:00:03Z");
5421 assert!(dispatch_order(&a, &b).is_lt());
5422 }
5423
5424 #[test]
5425 fn upgrade_resume_order_survives_queue_reload_and_reversed_settlement() {
5426 let (_dir, q) = queue();
5427 let mut later = upgrade_parked("later", "2026-10-22T00:00:02Z");
5428 let mut earlier = upgrade_parked("earlier", "2026-10-22T00:00:01Z");
5429 earlier.priority = -10;
5430 let mut fresh = task("fresh");
5431 fresh.id = "newer".into();
5432 fresh.priority = 100;
5433 for t in [&mut fresh, &mut later, &mut earlier] {
5434 q.put(t).unwrap();
5435 }
5436 let reloaded = Queue::at(q.root().to_owned());
5437 let mut tasks = reloaded.list();
5438 tasks.sort_unstable_by(dispatch_order);
5439 assert_eq!(
5440 tasks.iter().map(|t| t.id.as_str()).collect::<Vec<_>>(),
5441 vec!["earlier", "later", "newer"]
5442 );
5443 assert_eq!(reloaded.next_runnable().unwrap().id, "earlier");
5444 assert_eq!(reloaded.get("earlier").unwrap().attempts, 0);
5445 }
5446
5447 #[test]
5448 fn upgrade_resume_legacy_order_uses_settlement_and_id() {
5449 let mut a = upgrade_parked("a", "2026-10-22T00:00:02Z");
5450 let mut b = upgrade_parked("b", "2026-10-22T00:00:01Z");
5451 for t in [&mut a, &mut b] {
5452 let mut value = serde_json::to_value(&*t).unwrap();
5453 value["passes"][0]
5454 .as_object_mut()
5455 .unwrap()
5456 .remove("started_at");
5457 *t = serde_json::from_value(value).unwrap();
5458 }
5459 assert!(dispatch_order(&a, &b).is_lt());
5460 b.passes[0].settled_at = a.passes[0].settled_at;
5461 assert!(dispatch_order(&a, &b).is_lt());
5462 }
5463
5464 #[test]
5465 fn upgrade_resume_key_excludes_other_parks_and_explicit_task_changes() {
5466 let original = upgrade_parked("a", "2026-10-22T00:00:01Z");
5467 assert!(original.upgrade_resume_key().is_some());
5468 for cause in [
5469 crate::run::ParkCause::Operator,
5470 crate::run::ParkCause::Question,
5471 crate::run::ParkCause::Interrupt {
5472 reason: "interrupt".into(),
5473 },
5474 crate::run::ParkCause::Unspecified,
5475 ] {
5476 let mut t = original.clone();
5477 t.passes[0].parked = Some(cause);
5478 assert!(t.upgrade_resume_key().is_none());
5479 let mut fresh = task("fresh");
5480 fresh.priority = 1;
5481 assert!(dispatch_order(&fresh, &t).is_lt());
5482 }
5483 let mut t = original.clone();
5484 t.release();
5485 assert!(t.upgrade_resume_key().is_none());
5486 t.edit("edited".into(), "new instruction".into()).unwrap();
5487 assert!(t.upgrade_resume_key().is_none());
5488 let mut t = original.clone();
5489 t.requeue();
5490 t.status = TaskStatus::Parked;
5491 assert!(t.upgrade_resume_key().is_none());
5492 let mut t = original.clone();
5493 t.start("different-run".into());
5494 t.park("no upgrade");
5495 assert!(t.upgrade_resume_key().is_none());
5496 }
5497
5498 #[test]
5499 fn explicitly_resumed_older_run_becomes_current_without_duplicate_history() {
5500 let dir = tempfile::tempdir().unwrap();
5501 let q = Queue::at(dir.path().join("queue"));
5502 let mut t = task("resume");
5503 t.start("A".into());
5504 t.record_pass("A", 0, 1, None);
5505 t.start("B".into());
5506 q.put(&mut t).unwrap();
5507 assert_eq!(t.runs, vec!["A", "B"]);
5508 t.start("A".into());
5509 t.record_pass("A", 1, 3, None);
5510 q.put(&mut t).unwrap();
5511 let saved = q.get(&t.id).unwrap();
5512 assert_eq!(saved.runs, vec!["B", "A"]);
5513 assert_eq!(saved.runs.last().map(String::as_str), Some("A"));
5514 assert_eq!(saved.attempts, 3);
5515 assert_eq!(saved.passes.len(), 2);
5516 assert_eq!(saved.passes[0].run, "A");
5517 assert_eq!(saved.passes[1].run, "A");
5518 }
5519
5520 #[test]
5521 fn repeated_start_records_one_run_and_separate_passes() {
5522 let mut t = task("resume");
5523 t.start("same-run".into());
5524 t.record_pass("same-run", 0, 1, None);
5525 t.start("same-run".into());
5526 t.record_pass("same-run", 1, 2, None);
5527 assert_eq!(t.runs, vec!["same-run"]);
5528 assert_eq!(t.attempts, 2);
5529 assert_eq!(t.passes.len(), 2);
5530 }
5531
5532 #[test]
5533 fn link_run_adds_a_run_once_and_touches_nothing_else() {
5534 let dir = tempfile::tempdir().unwrap();
5535 let queue = Queue::at(dir.path().join("queue"));
5536 let mut t = Task::new(
5537 "t".to_owned(),
5538 "do it".to_owned(),
5539 PathBuf::from("."),
5540 Source::Human,
5541 );
5542 queue.put(&mut t).unwrap();
5543 let before = queue.get(&t.id).unwrap();
5544
5545 let linked = queue.link_run(&t.id[..4], "20260930-092817-ec34").unwrap();
5546 assert_eq!(linked.runs, vec!["20260930-092817-ec34".to_owned()]);
5547 assert_eq!(linked.status, before.status);
5548 assert_eq!(linked.attempts, before.attempts);
5549 assert_eq!(linked.interrupt, before.interrupt);
5550
5551 let again = queue.link_run(&t.id, "20260930-092817-ec34").unwrap();
5552 assert_eq!(again.runs.len(), 1, "linking twice must not duplicate");
5553 assert_eq!(queue.get(&t.id).unwrap().runs.len(), 1);
5554 assert!(queue.link_run("no-such-task", "r").is_err());
5555 }
5556
5557 #[test]
5558 fn put_keeps_a_run_linked_after_the_writer_took_its_snapshot() {
5559 let dir = tempfile::tempdir().unwrap();
5560 let queue = Queue::at(dir.path().join("queue"));
5561 let mut t = Task::new(
5562 "t".to_owned(),
5563 "do it".to_owned(),
5564 PathBuf::from("."),
5565 Source::Human,
5566 );
5567 queue.put(&mut t).unwrap();
5568 let mut snapshot = queue.get(&t.id).unwrap();
5570 queue.link_run(&t.id, "20260930-092817-ec34").unwrap();
5571
5572 snapshot.start("20260930-000000-aaaa".to_owned());
5573 queue.put(&mut snapshot).unwrap();
5574
5575 let stored = queue.get(&t.id).unwrap();
5576 assert!(stored.runs.contains(&"20260930-092817-ec34".to_owned()));
5577 assert!(stored.runs.contains(&"20260930-000000-aaaa".to_owned()));
5578 assert_eq!(stored.attempts, 1);
5579 }
5580
5581 #[test]
5582 fn concurrent_links_and_daemon_saves_lose_nothing() {
5583 let dir = tempfile::tempdir().unwrap();
5584 let queue = Queue::at(dir.path().join("queue"));
5585 let mut t = Task::new(
5586 "t".to_owned(),
5587 "do it".to_owned(),
5588 PathBuf::from("."),
5589 Source::Human,
5590 );
5591 queue.put(&mut t).unwrap();
5592 let id = t.id.clone();
5593
5594 let linkers: Vec<_> = (0..4)
5595 .map(|n| {
5596 let (queue, id) = (queue.clone(), id.clone());
5597 std::thread::spawn(move || {
5598 for k in 0..10 {
5599 queue
5600 .link_run(&id, &format!("20260930-00000{n}-l{k:03}"))
5601 .unwrap();
5602 }
5603 })
5604 })
5605 .collect();
5606 let mut mine = queue.get(&id).unwrap();
5609 for k in 0..10 {
5610 mine.start(format!("20260930-000009-d{k:03}"));
5611 queue.put(&mut mine).unwrap();
5612 }
5613 for l in linkers {
5614 l.join().unwrap();
5615 }
5616
5617 let stored = queue.get(&id).unwrap();
5618 assert_eq!(stored.runs.len(), 50, "{:?}", stored.runs);
5619 assert_eq!(
5620 stored.attempts, 10,
5621 "linking never rewinds the daemon's work"
5622 );
5623 }
5624
5625 fn age_lock(path: &Path) {
5626 let f = std::fs::OpenOptions::new().write(true).open(path).unwrap();
5627 f.set_modified(std::time::SystemTime::now() - std::time::Duration::from_secs(60))
5628 .unwrap();
5629 }
5630
5631 #[test]
5632 fn concurrent_stale_takeover_yields_one_holder() {
5633 use std::sync::atomic::{AtomicUsize, Ordering};
5634 use std::sync::{Arc, Barrier};
5635 for _ in 0..5 {
5636 let dir = tempfile::tempdir().unwrap();
5637 let q = Queue::at(dir.path().to_path_buf());
5638 let lock = dir.path().join("t.write-lock");
5639 std::fs::write(&lock, "dead-0000").unwrap();
5640 age_lock(&lock);
5641 let n = 6;
5642 let barrier = Arc::new(Barrier::new(n));
5643 let (now, max) = (Arc::new(AtomicUsize::new(0)), Arc::new(AtomicUsize::new(0)));
5644 let handles: Vec<_> = (0..n)
5645 .map(|_| {
5646 let (q, b, now, max) = (q.clone(), barrier.clone(), now.clone(), max.clone());
5647 std::thread::spawn(move || {
5648 b.wait();
5649 let g = q.lock_task("t").unwrap();
5650 let held = now.fetch_add(1, Ordering::SeqCst) + 1;
5651 max.fetch_max(held, Ordering::SeqCst);
5652 std::thread::sleep(std::time::Duration::from_millis(20));
5653 now.fetch_sub(1, Ordering::SeqCst);
5654 drop(g);
5655 })
5656 })
5657 .collect();
5658 for h in handles {
5659 h.join().unwrap();
5660 }
5661 assert_eq!(max.load(Ordering::SeqCst), 1);
5662 assert!(!lock.exists());
5663 }
5664 }
5665
5666 #[test]
5667 fn dropping_a_replaced_lock_leaves_the_new_holders_token() {
5668 let dir = tempfile::tempdir().unwrap();
5669 let q = Queue::at(dir.path().to_path_buf());
5670 let lock = dir.path().join("t.write-lock");
5671 let a = q.lock_task("t").unwrap();
5672 let replacement = owner_token();
5675 std::fs::write(&lock, &replacement).unwrap();
5676 drop(a);
5677 assert_eq!(std::fs::read_to_string(&lock).unwrap(), replacement);
5678 }
5679
5680 #[test]
5681 fn aged_live_task_lock_excludes_updates_until_retirement_releases_it() {
5682 let dir = tempfile::tempdir().unwrap();
5683 let q = Queue::at(dir.path().to_path_buf());
5684 let held = q.lock_task("t").unwrap();
5685 let path = dir.path().join("t.write-lock");
5686 age_lock(&path);
5687 assert!(!break_stale(&path, &held.token));
5688 let (started_tx, started_rx) = std::sync::mpsc::channel();
5689 let (acquired_tx, acquired_rx) = std::sync::mpsc::channel();
5690 let updater = std::thread::spawn(move || {
5691 started_tx.send(()).unwrap();
5692 let _write = q.lock_task("t").unwrap();
5693 acquired_tx.send(()).unwrap();
5694 });
5695 started_rx.recv().unwrap();
5696 assert!(
5697 acquired_rx
5698 .recv_timeout(std::time::Duration::from_millis(100))
5699 .is_err()
5700 );
5701 assert_eq!(std::fs::read_to_string(&path).unwrap(), held.token);
5702 drop(held);
5703 acquired_rx
5704 .recv_timeout(std::time::Duration::from_secs(2))
5705 .unwrap();
5706 updater.join().unwrap();
5707 assert!(!path.exists());
5708 }
5709
5710 #[test]
5711 fn break_stale_leaves_a_lock_that_replaced_the_one_judged() {
5712 let dir = tempfile::tempdir().unwrap();
5713 let lock = dir.path().join("t.write-lock");
5714 std::fs::write(&lock, "new-token").unwrap();
5715 assert!(!break_stale(&lock, "old-token"));
5716 assert_eq!(std::fs::read_to_string(&lock).unwrap(), "new-token");
5717 assert!(!break_stale(&lock, "new-token"));
5719 assert!(lock.exists());
5720 age_lock(&lock);
5721 assert!(break_stale(&lock, "new-token"));
5722 assert!(!lock.exists());
5723 }
5724
5725 #[test]
5726 fn a_stale_break_marker_is_recovered_and_a_fresh_one_is_respected() {
5727 let dir = tempfile::tempdir().unwrap();
5728 let lock = dir.path().join("t.write-lock");
5729 std::fs::write(&lock, "dead-1").unwrap();
5730 age_lock(&lock);
5731 let marker = break_marker(&lock, "dead-1");
5732 std::fs::write(&marker, "crashed-remover").unwrap();
5733 assert!(!break_stale(&lock, "dead-1"));
5735 assert!(lock.exists());
5736 age_lock(&marker);
5738 assert!(break_stale(&lock, "dead-1"));
5739 assert!(!lock.exists());
5740 assert!(!marker.exists());
5741 }
5742
5743 fn chat_task() -> Task {
5744 Task::new(
5745 "t".to_owned(),
5746 "do t".to_owned(),
5747 PathBuf::from("."),
5748 Source::Agent {
5749 run: "20260902-000000-beef".to_owned(),
5750 node: CHAT_NODE.to_owned(),
5751 },
5752 )
5753 }
5754
5755 #[test]
5756 fn a_blocked_hold_keeps_its_anchor_but_a_new_run_starts_a_new_hold() {
5757 let mut t = chat_task();
5758 t.hold_machine(Some("waiting".into()));
5759 let old: Timestamp = "2020-01-01T00:00:00Z".parse().unwrap();
5760 t.held_at = Some(old);
5761 t.block(vec!["decision".into()], None);
5762 t.hold_machine(Some("same waiting decision".into()));
5763 assert_eq!(t.held_at, Some(old));
5764 t.start("real-new-run".into());
5765 t.fail("new failure", 1);
5766 assert_ne!(
5767 t.held_at,
5768 Some(old),
5769 "old answers cannot approve a new hold"
5770 );
5771 }
5772
5773 #[test]
5774 fn legacy_delivery_survives_upgrade_and_a_no_progress_run_link() {
5775 let (_dir, queue) = queue();
5776 let mut t = chat_task();
5777 t.start("old-run".into());
5778 t.hold_machine(Some("operator is considering recovery".into()));
5779 t.chat_notice = Some(ChatNotice {
5780 key: format!("held#{}", t.held_at.unwrap()),
5781 at: Timestamp::now(),
5782 outcome: ChatNoticeOutcome::Posted,
5783 });
5784 let mut raw = serde_json::to_value(&t).unwrap();
5785 raw.as_object_mut().unwrap().remove("chat_notices");
5786 raw.as_object_mut().unwrap().remove("hold_notice_cause");
5787 std::fs::create_dir_all(queue.path_of(&t.id).parent().unwrap()).unwrap();
5788 std::fs::write(queue.path_of(&t.id), serde_json::to_string(&raw).unwrap()).unwrap();
5789 assert_eq!(queue.get(&t.id).unwrap().chat_notice_due(), None);
5790 queue
5791 .modify(&t.id, |task| {
5792 task.requeue();
5793 task.link_run("unprepared-run");
5794 task.startup_notice_cause("startup-source");
5795 task.fail("same unresolved preflight", 0);
5796 true
5797 })
5798 .unwrap();
5799 assert_eq!(queue.get(&t.id).unwrap().chat_notice_due(), None);
5800 queue
5801 .modify(&t.id, |task| {
5802 task.succeed();
5803 true
5804 })
5805 .unwrap();
5806 assert_eq!(
5807 queue.get(&t.id).unwrap().chat_notice_due().as_deref(),
5808 Some("done")
5809 );
5810 }
5811
5812 #[test]
5813 fn a_dependency_wait_does_not_change_when_released_and_blocked_again() {
5814 let mut t = chat_task();
5815 t.block(vec!["a".into(), "b".into()], Some("waiting".into()));
5816 let key = t.chat_notice_key().unwrap();
5817 t.chat_notices.push(key);
5818 t.release();
5819 t.block(
5820 vec!["b".into(), "a".into(), "a".into()],
5821 Some("still waiting on prerequisites".into()),
5822 );
5823 assert_eq!(t.chat_notice_due(), None);
5824 t.block(
5825 vec!["new-question".into()],
5826 Some("different decision".into()),
5827 );
5828 assert!(t.chat_notice_due().is_some());
5829 }
5830
5831 #[test]
5832 fn running_startup_failures_preserve_stage_and_report_distinct_blockers() {
5833 let mut t = chat_task();
5834 t.start("existing-run".into());
5835 t.fail("implementation failed", 1);
5836 let failed = t.chat_notice_key().unwrap();
5837 t.chat_notices.push(failed.clone());
5838 t.release_operator("test", None);
5839 t.start("existing-run".into());
5840 t.fail_startup("startup-source", "same unresolved recovery source", 1);
5841 assert_eq!(
5842 t.chat_notice_due(),
5843 None,
5844 "no-progress recovery stays quiet"
5845 );
5846 t.release_operator("test", None);
5847 t.start("existing-run".into());
5848 t.fail_startup("startup-attachments", "attachment missing", 1);
5849 let attachments = t.chat_notice_due().expect("new attachment blocker");
5850 assert_ne!(attachments, failed);
5851 assert_eq!(
5852 t.hold_notice_cause.as_ref().unwrap().stage,
5853 "startup-attachments"
5854 );
5855 t.chat_notices.push(attachments);
5856 t.release_operator("test", None);
5857 t.start("existing-run".into());
5858 t.fail_startup(
5859 "startup-attachments",
5860 "same missing attachment, reworded",
5861 1,
5862 );
5863 assert_eq!(
5864 t.chat_notice_due(),
5865 None,
5866 "same stage after attempt counter reset"
5867 );
5868 t.release_operator("test", None);
5869 t.start("existing-run".into());
5870 t.fail_startup("startup-config", "different preflight stage", 1);
5871 assert!(t.chat_notice_due().is_some());
5872 assert_eq!(
5873 t.hold_notice_cause.as_ref().unwrap().stage,
5874 "startup-config"
5875 );
5876 }
5877
5878 #[test]
5879 fn structured_causes_preserve_no_progress_retries_and_report_new_passes() {
5880 let mut t = chat_task();
5881 t.hold_machine(Some("startup source missing".into()));
5882 t.startup_notice_cause("startup-source");
5883 let first = t.chat_notice_key().unwrap();
5884 t.chat_notices.push(first.clone());
5885 t.release_operator("test", None);
5886 t.startup_notice_cause("startup-source");
5887 t.hold_machine(Some("same source cannot be read".into()));
5888 assert_eq!(t.chat_notice_due(), None);
5889 t.startup_notice_cause("startup-config");
5890 assert!(t.chat_notice_due().is_some(), "different startup stage");
5891 t.notice_cause("failed", Some("existing-run"), Some(0));
5892 let run_failure = t.chat_notice_key().unwrap();
5893 t.chat_notices.push(run_failure.clone());
5894 t.release_operator("test", None);
5895 t.startup_notice_cause("startup-source");
5896 t.hold_machine(Some("no new work happened".into()));
5897 assert_eq!(t.chat_notice_due(), None);
5898 t.notice_cause("failed", Some("existing-run"), Some(1));
5899 assert!(t.chat_notice_due().is_some(), "a real resumed pass failed");
5900 assert_ne!(t.chat_notice_key().unwrap(), run_failure);
5901 }
5902
5903 #[test]
5904 fn legacy_hold_baseline_does_not_replay_or_hide_new_failures_and_completion() {
5905 let mut t = chat_task();
5906 t.hold_machine(Some("still waiting".into()));
5907 t.chat_notice = Some(ChatNotice {
5908 key: "held#2026-10-14T00:00:00Z".into(),
5909 at: Timestamp::now(),
5910 outcome: ChatNoticeOutcome::Posted,
5911 });
5912 assert_eq!(t.chat_notice_due(), None, "old hold baseline");
5913 t.import_chat_notice();
5914 t.notice_cause("failed", Some("new-run"), Some(0));
5915 assert!(t.chat_notice_due().is_some(), "new producer evidence");
5916 t.succeed();
5917 assert_eq!(t.chat_notice_due().as_deref(), Some("done"));
5918 }
5919
5920 #[test]
5921 fn a_chat_notice_is_due_only_for_a_final_state_and_only_once() {
5922 let mut t = chat_task();
5923 assert_eq!(t.chat_notice_due(), None, "queued");
5924 t.start("r1".to_owned());
5925 assert_eq!(t.chat_notice_due(), None, "running");
5926 t.fail("boom", 3);
5927 assert_eq!(t.status, TaskStatus::Failed);
5928 assert_eq!(t.chat_notice_due(), None, "a retried failure is not final");
5929 t.start("r2".to_owned());
5930 t.park("upgrade");
5931 assert_eq!(t.chat_notice_due(), None, "parked");
5932 t.fail("boom", 1);
5933 assert_eq!(t.status, TaskStatus::Held);
5934 let key = t
5935 .chat_notice_due()
5936 .expect("held on the last attempt is final");
5937 t.chat_notice = Some(ChatNotice {
5938 key,
5939 at: Timestamp::now(),
5940 outcome: ChatNoticeOutcome::Posted,
5941 });
5942 assert_eq!(t.chat_notice_due(), None, "same hold, no second notice");
5943 t.release();
5944 assert_eq!(t.chat_notice_due(), None, "requeued after a release");
5945 t.start("r3".to_owned());
5946 t.succeed();
5947 assert!(t.chat_notice_due().is_some(), "done is a new final state");
5948 }
5949
5950 #[test]
5951 fn completed_chat_notices_keep_current_and_legacy_keys_after_requeue() {
5952 for key in ["done", "done#0", "done#1", "done#12"] {
5953 let mut t = chat_task();
5954 t.start("r1".to_owned());
5955 t.succeed();
5956 t.chat_notice = Some(ChatNotice {
5957 key: key.to_owned(),
5958 at: Timestamp::now(),
5959 outcome: ChatNoticeOutcome::Posted,
5960 });
5961 t.requeue();
5962 t.start("r2".to_owned());
5963 t.succeed();
5964 assert_eq!(t.chat_notice_key().as_deref(), Some("done"));
5965 assert_eq!(t.chat_notice_due(), None, "recorded key: {key}");
5966 }
5967 }
5968
5969 #[test]
5970 fn a_release_without_progress_keeps_the_unresolved_notice_cause() {
5971 let mut t = chat_task();
5972 t.hold_manual(Some("wait".to_owned()));
5973 let first = t.chat_notice_due().expect("held");
5974 t.chat_notice = Some(ChatNotice {
5975 key: first.clone(),
5976 at: Timestamp::now(),
5977 outcome: ChatNoticeOutcome::Posted,
5978 });
5979 t.hold_manual(Some("still waiting".to_owned()));
5980 assert_eq!(t.chat_notice_due(), None, "a re-hold keeps its start");
5981 t.release();
5982 std::thread::sleep(std::time::Duration::from_millis(5));
5983 t.hold_manual(None);
5984 assert_eq!(t.chat_notice_due(), None, "no progress, same cause");
5985 }
5986
5987 #[test]
5988 fn a_chat_notice_needs_a_chat_source_and_a_current_schema() {
5989 let mut human = task("h");
5990 human.succeed();
5991 assert_eq!(human.chat_notice_due(), None);
5992 let mut inherited = task("i");
5994 inherited.origin_chat = Some("20260902-000000-beef".to_owned());
5995 inherited.succeed();
5996 assert_eq!(inherited.chat_notice_due(), None);
5997 let mut old = chat_task();
5998 old.schema = 12;
5999 old.status = TaskStatus::Done;
6000 assert_eq!(old.chat_notice_due(), None, "history is not replayed");
6001 let mut live = chat_task();
6002 live.schema = 12;
6003 live.start("r1".to_owned());
6004 live.succeed();
6005 assert!(live.chat_notice_due().is_some(), "started by this build");
6006 let mut held = chat_task();
6007 held.schema = 12;
6008 held.hold_machine(Some("config".to_owned()));
6009 assert!(held.chat_notice_due().is_some(), "held before any start");
6010 }
6011
6012 #[test]
6013 fn a_stale_snapshot_does_not_rewind_a_recorded_chat_notice() {
6014 let tmp = tempfile::tempdir().expect("tempdir");
6015 let q = Queue::at(tmp.path().join("queue"));
6016 let mut t = chat_task();
6017 t.succeed();
6018 q.put(&mut t).expect("put");
6019 let mut stale = q.get(&t.id).expect("get");
6020 let key = t.chat_notice_due().expect("due");
6021 q.modify(&t.id, |x| {
6022 x.chat_notice = Some(ChatNotice {
6023 key: key.clone(),
6024 at: Timestamp::now(),
6025 outcome: ChatNoticeOutcome::Posted,
6026 });
6027 true
6028 })
6029 .expect("modify");
6030 q.put(&mut stale).expect("stale put");
6031 assert_eq!(q.get(&t.id).expect("get").chat_notice_due(), None);
6032 }
6033
6034 #[test]
6035 fn empty_sources_are_omitted_so_older_builds_still_read_the_task() {
6036 let mut t = task("plain");
6039 let value = serde_json::to_value(&t).unwrap();
6040 assert!(value.get("sources").is_none(), "{value}");
6041 t.sources.pick_commits.push(crate::refs::Input {
6042 token: "abc1234".into(),
6043 sha: "a".repeat(40),
6044 provenance: crate::refs::Provenance::ExplicitCli,
6045 });
6046 let value = serde_json::to_value(&t).unwrap();
6047 assert_eq!(value["sources"]["pick_commits"][0]["sha"], "a".repeat(40));
6048 let restored: Task = serde_json::from_value(value).unwrap();
6049 assert_eq!(restored.sources, t.sources);
6050 }
6051
6052 fn queue_with_ids(dir: &Path, ids: &[&str]) -> Queue {
6054 let ids: Vec<String> = ids.iter().map(|s| (*s).to_owned()).collect();
6055 let n = std::sync::atomic::AtomicUsize::new(0);
6056 Queue::at(dir.join("queue")).with_id_source(move || {
6057 let i = n.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
6058 ids.get(i)
6059 .cloned()
6060 .unwrap_or_else(|| format!("20260101-000000-f{i:03x}"))
6061 })
6062 }
6063
6064 const TAKEN: &str = "20260101-000000-aaaa";
6065
6066 #[test]
6067 fn a_new_task_never_merges_into_a_record_with_its_candidate_id() {
6068 let dir = tempfile::tempdir().unwrap();
6069 let q = queue_with_ids(dir.path(), &[TAKEN, TAKEN]);
6070 let mut first = task("first");
6071 first.start("run-1".into());
6072 first.succeed();
6073 q.put(&mut first).unwrap();
6074 assert_eq!(first.id, TAKEN);
6075 let stored_before = q.get(&first.id).unwrap();
6076 assert!(!stored_before.runs.is_empty());
6077
6078 let mut second = task("second");
6079 q.put(&mut second).unwrap();
6080
6081 assert_ne!(second.id, first.id);
6082 assert!(second.runs.is_empty());
6083 assert!(second.passes.is_empty());
6084 assert!(second.chat_notices.is_empty());
6085 assert!(second.chat_notice.is_none());
6086 assert_eq!(second.chat_notice_due(), task("fresh").chat_notice_due());
6087 let a = q.get(&first.id).unwrap();
6088 let b = q.get(&second.id).unwrap();
6089 assert_eq!(a.title, "first");
6090 assert_eq!(a.runs, stored_before.runs);
6091 assert_eq!(a.passes, stored_before.passes);
6092 assert_eq!(a.chat_notices, stored_before.chat_notices);
6093 assert_eq!(a.chat_notice, stored_before.chat_notice);
6094 assert_eq!(a.chat_notice_due(), stored_before.chat_notice_due());
6095 assert_eq!(b.title, "second");
6096 assert!(b.runs.is_empty());
6097 assert!(b.passes.is_empty());
6098 assert!(b.chat_notice.is_none());
6099
6100 let mut again = q.get(&second.id).unwrap();
6102 again.title = "second, edited".into();
6103 q.put(&mut again).unwrap();
6104 assert_eq!(q.get(&second.id).unwrap().title, "second, edited");
6105 assert_eq!(q.list().len(), 2);
6106 }
6107
6108 #[test]
6109 fn a_new_task_skips_an_id_with_an_orphaned_claim_or_attachments() {
6110 let dir = tempfile::tempdir().unwrap();
6111 let q = queue_with_ids(dir.path(), &["orphan-lock", "orphan-dir", "free"]);
6112 std::fs::create_dir_all(q.root()).unwrap();
6113 std::fs::write(q.root().join("orphan-lock.lock"), "x").unwrap();
6114 std::fs::create_dir_all(q.attachments_dir("orphan-dir")).unwrap();
6115 let mut t = task("t");
6116 q.put(&mut t).unwrap();
6117 assert_eq!(t.id, "free");
6118 }
6119
6120 #[test]
6121 fn a_claimed_new_task_holds_the_claim_on_the_reserved_id() {
6122 let dir = tempfile::tempdir().unwrap();
6123 let q = queue_with_ids(dir.path(), &[TAKEN, TAKEN]);
6124 let mut first = task("first");
6125 q.put(&mut first).unwrap();
6126 let mut second = task("second");
6127 let claim = q.file_claimed(&mut second).unwrap();
6128 assert_ne!(second.id, first.id);
6129 assert!(q.claim(&second.id).is_err());
6130 assert!(q.claim(&first.id).is_ok(), "the first task was not claimed");
6131 drop(claim);
6132 }
6133
6134 #[test]
6135 fn an_attached_new_task_skips_a_taken_id() {
6136 let dir = tempfile::tempdir().unwrap();
6137 let q = queue_with_ids(dir.path(), &[TAKEN, TAKEN]);
6138 let mut first = task("first");
6139 q.put(&mut first).unwrap();
6140 let src = source_file(dir.path(), "shot.png", "x");
6141 let mut second = task("second");
6142 q.attach_and_put(&mut second, &[src]).unwrap();
6143 assert_ne!(second.id, first.id);
6144 assert!(!q.attachments_dir(&first.id).exists());
6145 assert_eq!(q.attachment_paths(&second).len(), 1);
6146 assert!(q.attachment_paths(&second)[0].is_file());
6147 }
6148
6149 #[test]
6150 fn attaching_before_putting_a_new_task_reserves_the_id() {
6151 let dir = tempfile::tempdir().unwrap();
6152 let q = queue_with_ids(dir.path(), &[TAKEN, TAKEN]);
6153 let mut first = task("first");
6154 q.put(&mut first).unwrap();
6155 let src = source_file(dir.path(), "shot.png", "x");
6156 let mut second = task("second");
6157 q.attach(&mut second, &[src]).unwrap();
6158 assert_ne!(second.id, first.id);
6159 assert!(q.attachments_dir(&second.id).is_dir());
6160 q.put(&mut second).unwrap();
6161 assert_eq!(q.get(&second.id).unwrap().attachments.len(), 1);
6162 assert_eq!(q.get(&first.id).unwrap().title, "first");
6163 assert!(q.get(&first.id).unwrap().attachments.is_empty());
6164 }
6165
6166 #[test]
6167 fn create_new_keeps_its_already_exists_meaning() {
6168 let dir = tempfile::tempdir().unwrap();
6169 let q = Queue::at(dir.path().join("queue"));
6170 let mut a = task("a");
6171 a.id = "fixed-id".into();
6172 assert!(q.create_new(&mut a).unwrap());
6173 let mut b = task("b");
6174 b.id = "fixed-id".into();
6175 assert!(!q.create_new(&mut b).unwrap());
6176 assert_eq!(q.get("fixed-id").unwrap().title, "a");
6177 }
6178
6179 #[test]
6180 fn filing_gives_up_when_no_id_is_ever_free() {
6181 let dir = tempfile::tempdir().unwrap();
6182 let q = Queue::at(dir.path().join("queue")).with_id_source(|| TAKEN.to_owned());
6183 let mut first = task("first");
6184 q.put(&mut first).unwrap();
6185 let mut second = task("second");
6186 assert!(q.put(&mut second).is_err());
6187 assert_eq!(q.get(&first.id).unwrap().title, "first");
6188 }
6189}