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;
42
43pub const SCHEMA: u32 = 18;
130
131#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
133#[serde(rename_all = "lowercase")]
134pub enum HoldSource {
135 Manual,
137 Machine,
139}
140
141impl HoldSource {
142 pub fn label(self) -> &'static str {
144 match self {
145 Self::Manual => "manual",
146 Self::Machine => "machine",
147 }
148 }
149}
150
151#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
154#[serde(tag = "kind", rename_all = "lowercase")]
155pub enum Source {
156 Human,
158 Agent {
161 run: String,
163 node: String,
165 },
166 Issue {
168 number: u64,
170 repo: String,
172 },
173}
174
175pub const CHAT_NODE: &str = "chat";
178
179impl Source {
180 pub fn label(&self) -> String {
182 match self {
183 Self::Human => "human".to_owned(),
184 Self::Agent { run, node } => format!("{node}@{}", short(run)),
185 Self::Issue { number, .. } => format!("issue #{number}"),
186 }
187 }
188}
189
190#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
192#[serde(rename_all = "lowercase")]
193pub enum TaskStatus {
194 Queued,
196 Running,
198 Done,
200 Failed,
202 Held,
204 Blocked,
208 Parked,
213}
214
215impl TaskStatus {
216 pub fn runnable(self) -> bool {
218 matches!(self, Self::Queued | Self::Failed | Self::Parked)
219 }
220
221 pub fn as_str(self) -> &'static str {
223 match self {
224 Self::Queued => "queued",
225 Self::Running => "running",
226 Self::Done => "done",
227 Self::Failed => "failed",
228 Self::Held => "held",
229 Self::Blocked => "blocked",
230 Self::Parked => "parked",
231 }
232 }
233}
234
235#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
238pub struct TaskCounts {
239 pub queued: usize,
241 pub running: usize,
243 pub done: usize,
245 pub failed: usize,
247 pub held: usize,
249 pub blocked: usize,
251 pub parked: usize,
253}
254
255impl TaskCounts {
256 pub fn of(tasks: &[Task]) -> Self {
260 let mut counts = Self::default();
261 for t in tasks {
262 match t.status {
263 TaskStatus::Queued => counts.queued += 1,
264 TaskStatus::Running => counts.running += 1,
265 TaskStatus::Done => counts.done += 1,
266 TaskStatus::Failed => counts.failed += 1,
267 TaskStatus::Held => counts.held += 1,
268 TaskStatus::Blocked => counts.blocked += 1,
269 TaskStatus::Parked => counts.parked += 1,
270 }
271 }
272 counts
273 }
274}
275
276#[derive(Debug, Clone, Serialize, Deserialize)]
278#[serde(deny_unknown_fields)]
279pub struct Task {
280 pub schema: u32,
282 pub id: String,
284 pub title: String,
286 pub instruction: String,
288 pub repo: PathBuf,
290 pub source: Source,
292 #[serde(default)]
294 pub priority: i32,
295 #[serde(default)]
306 pub solo: bool,
307 pub status: TaskStatus,
309 #[serde(default)]
311 pub attempts: usize,
312 #[serde(default)]
314 pub attempt_limit: Option<NonZeroUsize>,
315 #[serde(default)]
318 pub runs: Vec<String>,
319 #[serde(default)]
321 pub last_error: Option<String>,
322 #[serde(default)]
335 pub hold_reason: Option<String>,
336 #[serde(default)]
339 pub hold_source: Option<HoldSource>,
340 #[serde(default)]
352 pub diagnostic: Option<String>,
353 #[serde(default)]
363 pub blocked_by: Vec<String>,
364 #[serde(default)]
367 pub block_reason: Option<String>,
368 #[serde(default)]
381 pub blocked_from: Option<TaskStatus>,
382 #[serde(default)]
384 pub blocked_at: Option<Timestamp>,
385 #[serde(default)]
396 pub answers: Vec<AnsweredQuestion>,
397 #[serde(default)]
403 pub triage_applied: Vec<String>,
404 #[serde(default)]
410 pub actions_applied: Vec<String>,
411 #[serde(default)]
416 pub resume_override: Option<OperatorResume>,
417 #[serde(default)]
420 pub resumption: Option<Resumption>,
421 #[serde(default)]
423 pub resumption_history: Vec<Resumption>,
424 #[serde(default)]
426 pub operator_decided_at: Option<Timestamp>,
427 #[serde(default)]
439 pub review_branch: Option<String>,
440 #[serde(default)]
442 pub review_instruction: Option<String>,
443 #[serde(default)]
445 pub pr_recovery: Option<crate::pr_recovery::Selection>,
446 #[serde(default)]
449 pub fresh_start: bool,
450 #[serde(default)]
461 pub interrupt: bool,
462 #[serde(default)]
485 pub urgent: bool,
486 #[serde(default)]
492 pub attachments: Vec<String>,
493 #[serde(default)]
497 pub followup: Option<FollowUp>,
498 #[serde(default)]
504 pub origin_chat: Option<String>,
505 #[serde(default)]
512 pub overrides: Option<RunOverrides>,
513 #[serde(default)]
520 pub review_of: Option<String>,
521 #[serde(default)]
526 pub held_at: Option<Timestamp>,
527 #[serde(default)]
531 pub park_reason: Option<String>,
532 #[serde(default)]
535 pub chat_notice: Option<ChatNotice>,
536 #[serde(default)]
538 pub chat_notices: Vec<String>,
539 #[serde(default)]
542 pub hold_notice_cause: Option<NoticeCause>,
543 #[serde(default)]
548 pub passes: Vec<TaskPass>,
549 pub created_at: Timestamp,
551 pub updated_at: Timestamp,
553}
554
555#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
557pub struct NoticeCause {
558 pub stage: String,
560 #[serde(default, skip_serializing_if = "Option::is_none")]
563 pub target: Option<String>,
564 pub run: Option<String>,
566 pub pass: Option<usize>,
568}
569
570pub(crate) fn is_done_notice_key(key: &str) -> bool {
572 key == "done"
573 || key
574 .strip_prefix("done#")
575 .is_some_and(|count| !count.is_empty() && count.bytes().all(|b| b.is_ascii_digit()))
576}
577
578#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
580#[serde(rename_all = "snake_case")]
581pub enum PassCost {
582 Spent,
584 Refunded,
586}
587
588#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
590pub struct TaskPass {
591 pub run: String,
593 pub pass_index: usize,
595 pub cost: PassCost,
597 pub attempts_after: usize,
599 pub settled_at: Timestamp,
601 #[serde(default)]
603 pub parked: Option<crate::run::ParkCause>,
604}
605
606#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
608#[serde(rename_all = "lowercase")]
609pub enum ChatNoticeOutcome {
610 Posted,
612 Skipped,
614}
615
616#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
618pub struct ChatNotice {
619 pub key: String,
621 pub at: Timestamp,
623 pub outcome: ChatNoticeOutcome,
625}
626
627#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
629pub struct RunOverrides {
630 #[serde(default)]
633 pub inherit_daemon_options: bool,
634 #[serde(default)]
636 pub merge: Option<String>,
637 #[serde(default)]
639 pub candidates: Option<usize>,
640 #[serde(default)]
642 pub judges: Option<usize>,
643 #[serde(default)]
645 pub reviewers: Option<usize>,
646 #[serde(default)]
648 pub review_rounds: Option<usize>,
649 #[serde(default)]
651 pub seed: Option<u64>,
652 #[serde(default)]
654 pub config: Option<PathBuf>,
655}
656
657impl RunOverrides {
658 pub fn merge_over(&mut self, other: &RunOverrides) {
660 macro_rules! take {
661 ($($f:ident),*) => {$(
662 if other.$f.is_some() {
663 self.$f.clone_from(&other.$f);
664 }
665 )*};
666 }
667 take!(
668 merge,
669 candidates,
670 judges,
671 reviewers,
672 review_rounds,
673 seed,
674 config
675 );
676 }
677
678 pub fn apply(&self, config: &mut crate::config::Config) {
680 if let Some(n) = self.candidates {
681 config.graph.implementers = n;
682 }
683 if let Some(n) = self.judges {
684 config.graph.judges = n;
685 }
686 if let Some(n) = self.reviewers {
687 config.graph.reviewers = n;
688 }
689 if let Some(n) = self.review_rounds {
690 config.graph.review_rounds = n;
691 }
692 if let Some(m) = &self.merge
693 && let Ok(mode) = crate::daemon::merge_mode(m)
694 {
695 config.merge.mode = mode;
696 }
697 if let Some(s) = self.seed {
698 config.blind.seed = Some(s);
699 }
700 }
701}
702
703#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
705pub struct FollowUp {
706 pub run: String,
708 #[serde(default)]
710 pub origin_task: Option<String>,
711 pub pr: String,
713 pub findings: Vec<String>,
715 pub generation: u32,
718}
719
720#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
722pub struct Resumption {
723 pub generation: u64,
725 pub source: String,
727 pub question_id: Option<String>,
729 pub at: Timestamp,
731 pub instruction: String,
733 pub answers_through: usize,
735 pub runs_through: usize,
738 pub cleared_reason: Option<String>,
740 #[serde(default)]
742 pub blocking_evidence: Vec<String>,
743}
744
745#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
748pub struct OperatorResume {
749 pub question_id: String,
751 pub at: Timestamp,
753 #[serde(default)]
756 pub conductor_rehold: Option<String>,
757 #[serde(default)]
760 pub forced: bool,
761 #[serde(default)]
768 pub pinned_run: Option<String>,
769}
770
771#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
774pub struct AnsweredQuestion {
775 pub question: String,
777 pub answer: String,
779}
780
781impl Task {
782 pub fn new(title: String, instruction: String, repo: PathBuf, source: Source) -> Self {
784 let now = Timestamp::now();
785 let origin_chat = match &source {
786 Source::Agent { run, node } if node == CHAT_NODE => Some(run.clone()),
787 _ => None,
788 };
789 Self {
790 schema: SCHEMA,
791 id: new_id(),
792 title,
793 instruction,
794 repo,
795 source,
796 priority: 0,
797 solo: false,
798 status: TaskStatus::Queued,
799 attempts: 0,
800 attempt_limit: None,
801 runs: Vec::new(),
802 last_error: None,
803 hold_reason: None,
804 hold_source: None,
805 diagnostic: None,
806 blocked_by: Vec::new(),
807 block_reason: None,
808 blocked_from: None,
809 blocked_at: None,
810 answers: Vec::new(),
811 triage_applied: Vec::new(),
812 actions_applied: Vec::new(),
813 resume_override: None,
814 resumption: None,
815 resumption_history: Vec::new(),
816 operator_decided_at: None,
817 review_branch: None,
818 review_instruction: None,
819 pr_recovery: None,
820 fresh_start: false,
821 interrupt: false,
822 urgent: false,
823 attachments: Vec::new(),
824 followup: None,
825 origin_chat,
826 overrides: None,
827 review_of: None,
828 held_at: None,
829 park_reason: None,
830 chat_notice: None,
831 chat_notices: Vec::new(),
832 hold_notice_cause: None,
833 passes: Vec::new(),
834 created_at: now,
835 updated_at: now,
836 }
837 }
838
839 pub fn chat_talk(&self) -> Option<&str> {
842 if let Some(id) = &self.origin_chat {
843 return Some(id);
844 }
845 match &self.source {
846 Source::Agent { run, node } if node == CHAT_NODE => Some(run),
847 _ => None,
848 }
849 }
850
851 pub fn filed_by_chat(&self) -> Option<&str> {
855 match &self.source {
856 Source::Agent { run, node } if node == CHAT_NODE => Some(run),
857 _ => None,
858 }
859 }
860
861 pub fn chat_notice_key(&self) -> Option<String> {
864 match self.status {
865 TaskStatus::Done => Some("done".to_owned()),
866 TaskStatus::Held => {
867 let cause = self
868 .hold_notice_cause
869 .clone()
870 .unwrap_or_else(|| self.unresolved_hold_cause());
871 Some(format!(
872 "cause:{}",
873 serde_json::to_string(&cause).expect("cause")
874 ))
875 }
876 TaskStatus::Blocked => {
877 let ids: std::collections::BTreeSet<_> = self.blocked_by.iter().collect();
878 let evidence = self
881 .hold_notice_cause
882 .as_ref()
883 .filter(|c| c.stage.starts_with("conductor-evidence:"));
884 Some(format!(
885 "dependencies:{}",
886 serde_json::to_string(&(ids, evidence)).expect("ids")
887 ))
888 }
889 _ => None,
890 }
891 }
892
893 fn unresolved_hold_cause(&self) -> NoticeCause {
894 NoticeCause {
895 stage: "hold".into(),
896 target: None,
897 run: self.runs.last().cloned(),
898 pass: None,
899 }
900 }
901
902 pub(crate) fn legacy_chat_notice_key(&self) -> Option<String> {
904 let key = match self.status {
905 TaskStatus::Held if self.hold_notice_cause.is_none() => Some(format!(
906 "held#{}",
907 self.held_at.map(|t| t.to_string()).unwrap_or_default()
908 )),
909 TaskStatus::Blocked => Some(format!(
910 "blocked#{}#{}",
911 self.blocked_by.join(","),
912 self.block_reason.as_deref().unwrap_or_default()
913 )),
914 _ => None,
915 };
916 key.filter(|key| self.legacy_notice_matches(key))
917 }
918
919 pub(crate) fn legacy_notice_matches(&self, key: &str) -> bool {
920 match self.status {
921 TaskStatus::Held if self.hold_notice_cause.is_none() => key
922 .strip_prefix("held#")
923 .is_some_and(|time| time.is_empty() || time.parse::<Timestamp>().is_ok()),
924 TaskStatus::Blocked
925 if self
926 .hold_notice_cause
927 .as_ref()
928 .is_none_or(|c| !c.stage.starts_with("conductor-evidence:")) =>
929 {
930 key.strip_prefix("blocked#").is_some_and(|tail| {
931 let Some((ids, _)) = tail.split_once('#') else {
932 return false;
933 };
934 let old: std::collections::BTreeSet<_> =
935 ids.split(',').filter(|id| !id.is_empty()).collect();
936 let current: std::collections::BTreeSet<_> =
937 self.blocked_by.iter().map(String::as_str).collect();
938 old == current
939 })
940 }
941 _ => false,
942 }
943 }
944
945 fn import_chat_notice(&mut self) {
946 if let Some(n) = &self.chat_notice {
947 let key = if is_done_notice_key(&n.key) {
948 "done".to_owned()
949 } else if self.legacy_notice_matches(&n.key) {
950 self.chat_notice_key().unwrap_or_else(|| n.key.clone())
951 } else {
952 n.key.clone()
953 };
954 if !self.chat_notices.contains(&key) {
955 self.chat_notices.push(key);
956 }
957 }
958 }
959
960 pub(crate) fn notice_cause(&mut self, stage: &str, run: Option<&str>, pass: Option<usize>) {
963 self.import_chat_notice();
964 self.hold_notice_cause = Some(NoticeCause {
965 stage: stage.to_owned(),
966 target: None,
967 run: run.map(str::to_owned),
968 pass,
969 });
970 }
971
972 pub(crate) fn startup_notice_cause(&mut self, stage: &str) {
975 let recovery_only = matches!(
976 stage,
977 "startup-source" | "startup-candidates" | "startup-public-head"
978 );
979 match self.hold_notice_cause.as_ref() {
980 Some(cause) if recovery_only && !cause.stage.starts_with("startup-") => return,
981 None if recovery_only
982 && self
983 .chat_notice
984 .as_ref()
985 .is_some_and(|n| !is_done_notice_key(&n.key)) =>
986 {
987 return;
988 }
989 _ => {}
990 }
991 self.notice_cause(stage, None, None);
992 }
993
994 pub(crate) fn fail_startup(
997 &mut self,
998 stage: &str,
999 why: impl Into<String>,
1000 max_attempts: usize,
1001 ) {
1002 let unresolved = self.hold_notice_cause.clone();
1003 self.fail(why, max_attempts);
1004 self.hold_notice_cause = unresolved;
1005 self.startup_notice_cause(stage);
1006 }
1007
1008 pub(crate) fn fail_startup_target(
1010 &mut self,
1011 stage: &str,
1012 target: String,
1013 why: impl Into<String>,
1014 max_attempts: usize,
1015 ) {
1016 self.fail_startup(stage, why, max_attempts);
1017 if let Some(cause) = self.hold_notice_cause.as_mut() {
1018 cause.target = Some(target);
1019 }
1020 }
1021
1022 pub fn chat_notice_due(&self) -> Option<String> {
1024 self.filed_by_chat()?;
1025 if self.schema < 13 {
1026 return None;
1027 }
1028 let key = self.chat_notice_key()?;
1029 if self.chat_notices.contains(&key) {
1030 return None;
1031 }
1032 match &self.chat_notice {
1033 Some(n)
1034 if n.key == key
1035 || (key == "done" && is_done_notice_key(&n.key))
1036 || self.legacy_notice_matches(&n.key) =>
1037 {
1038 None
1039 }
1040 _ => Some(key),
1041 }
1042 }
1043
1044 pub fn short(&self) -> &str {
1046 short(&self.id)
1047 }
1048
1049 pub fn mark_triage_applied(&mut self, question_id: &str) {
1051 if !self.triage_applied(question_id) {
1052 self.triage_applied.push(question_id.to_owned());
1053 }
1054 }
1055
1056 pub fn action_applied(&self, question_id: &str) -> bool {
1059 self.actions_applied.iter().any(|id| id == question_id)
1060 }
1061
1062 pub fn mark_action_applied(&mut self, question_id: &str) {
1064 if !self.action_applied(question_id) {
1065 self.actions_applied.push(question_id.to_owned());
1066 }
1067 }
1068
1069 pub fn triage_applied(&self, question_id: &str) -> bool {
1071 self.triage_applied.iter().any(|id| id == question_id)
1072 }
1073
1074 pub fn start(&mut self, run: String) {
1087 self.stamp_schema();
1088 self.status = TaskStatus::Running;
1089 self.attempts += 1;
1090 self.runs.retain(|id| id != &run);
1091 self.runs.push(run);
1092 self.last_error = None;
1093 self.park_reason = None;
1094 self.fresh_start = false;
1095 self.interrupt = false;
1096 self.resume_override = None;
1098 }
1099
1100 pub fn link_run(&mut self, run: &str) -> bool {
1105 if self.runs.iter().any(|r| r == run) {
1106 return false;
1107 }
1108 self.runs.push(run.to_owned());
1109 true
1110 }
1111
1112 pub fn succeed(&mut self) {
1122 self.stamp_schema();
1123 self.status = TaskStatus::Done;
1124 self.resume_override = None;
1125 self.last_error = None;
1126 self.park_reason = None;
1127 self.hold_reason = None;
1128 self.hold_source = None;
1129 self.diagnostic = None;
1130 self.blocked_by.clear();
1131 self.block_reason = None;
1132 self.blocked_from = None;
1133 }
1134
1135 pub fn already_landed(&mut self, note: impl Into<String>) {
1143 self.succeed();
1144 self.attempts = self.attempts.saturating_sub(1);
1145 self.last_error = Some(note.into());
1146 }
1147
1148 pub fn superseded_attempts(&self, last_run_succeeded: bool) -> &[String] {
1178 if self.status != TaskStatus::Done || !last_run_succeeded || self.runs.len() < 2 {
1179 return &[];
1180 }
1181 &self.runs[..self.runs.len() - 1]
1182 }
1183
1184 pub fn successor_of(&self, run: &str) -> Option<&String> {
1192 let pos = self.runs.iter().rposition(|r| r == run)?;
1193 self.runs.get(pos + 1)
1194 }
1195
1196 pub fn earlier_attempts(&self) -> &[String] {
1203 &self.runs
1204 }
1205
1206 fn stamp_schema(&mut self) {
1213 self.schema = self.schema.max(SCHEMA);
1214 }
1215
1216 fn note_held(&mut self) {
1217 self.stamp_schema();
1218 let continuing = self.status == TaskStatus::Held
1219 || (self.status == TaskStatus::Blocked
1220 && (self.blocked_from == Some(TaskStatus::Held)
1221 || (self.blocked_from.is_none()
1222 && (self.hold_reason.is_some() || self.hold_source.is_some()))));
1223 if !continuing || self.held_at.is_none() {
1224 self.held_at = Some(Timestamp::now());
1225 }
1226 }
1227
1228 pub fn fail(&mut self, why: impl Into<String>, max_attempts: usize) {
1244 let why = why.into();
1245 if self.status == TaskStatus::Running {
1246 let run = self.runs.last().cloned();
1247 self.notice_cause("run-failed", run.as_deref(), Some(self.attempts));
1248 }
1249 self.diagnostic = None;
1250 self.park_reason = None;
1251 self.status = if self.attempts >= self.effective_attempt_limit(max_attempts) {
1252 self.note_held();
1253 self.hold_source = Some(HoldSource::Machine);
1254 self.hold_reason = Some(why.clone());
1255 TaskStatus::Held
1256 } else {
1257 TaskStatus::Failed
1258 };
1259 self.last_error = Some(why);
1260 }
1261
1262 pub fn stall(&mut self, why: impl Into<String>) {
1272 self.last_error = Some(why.into());
1273 self.diagnostic = None;
1274 self.attempts = self.attempts.saturating_sub(1);
1275 self.status = TaskStatus::Failed;
1276 }
1277
1278 pub fn park(&mut self, why: impl Into<String>) {
1284 self.diagnostic = None;
1285 self.attempts = self.attempts.saturating_sub(1);
1286 self.status = TaskStatus::Parked;
1287 self.park_reason = Some(why.into());
1288 self.schema = self.schema.max(SCHEMA);
1291 }
1292
1293 pub fn record_pass(
1296 &mut self,
1297 run: &str,
1298 pass_index: usize,
1299 attempts_before: usize,
1300 parked: Option<crate::run::ParkCause>,
1301 ) {
1302 if self
1303 .passes
1304 .iter()
1305 .any(|p| p.run == run && p.pass_index == pass_index)
1306 {
1307 return;
1308 }
1309 let cost = if self.attempts < attempts_before {
1310 PassCost::Refunded
1311 } else {
1312 PassCost::Spent
1313 };
1314 self.passes.push(TaskPass {
1315 run: run.to_owned(),
1316 pass_index,
1317 cost,
1318 attempts_after: self.attempts,
1319 settled_at: Timestamp::now(),
1320 parked,
1321 });
1322 self.schema = self.schema.max(SCHEMA);
1323 }
1324
1325 pub fn operator_held(&self) -> bool {
1331 self.status == TaskStatus::Held && !matches!(self.hold_source, Some(HoldSource::Machine))
1332 }
1333
1334 pub fn hold_manual(&mut self, reason: Option<String>) {
1344 self.operator_decided_at = Some(Timestamp::now());
1346 self.hold_unrequested_retry(reason);
1347 }
1348
1349 pub(crate) fn hold_unrequested_retry(&mut self, reason: Option<String>) {
1352 self.park_reason = None;
1353 self.note_held();
1354 self.status = TaskStatus::Held;
1355 if reason.is_some() {
1356 self.hold_reason = reason;
1357 }
1358 self.hold_source = Some(HoldSource::Manual);
1359 self.blocked_by.clear();
1360 self.block_reason = None;
1361 self.blocked_from = None;
1362 }
1363
1364 pub fn hold_machine(&mut self, reason: Option<String>) {
1369 self.park_reason = None;
1370 self.note_held();
1371 self.status = TaskStatus::Held;
1372 if reason.is_some() {
1373 self.hold_reason = reason;
1374 }
1375 self.hold_source = Some(HoldSource::Machine);
1376 self.blocked_by.clear();
1377 self.block_reason = None;
1378 self.blocked_from = None;
1379 }
1380
1381 pub fn block(&mut self, blocked_by: Vec<String>, reason: Option<String>) {
1391 self.stamp_schema();
1392 let before: std::collections::BTreeSet<_> = self.blocked_by.iter().collect();
1393 let after: std::collections::BTreeSet<_> = blocked_by.iter().collect();
1394 if self.status != TaskStatus::Blocked || before != after {
1395 self.blocked_at = Some(Timestamp::now());
1396 }
1397 if self.status != TaskStatus::Blocked {
1398 self.blocked_from = Some(self.status);
1399 }
1400 self.status = TaskStatus::Blocked;
1401 self.blocked_by = blocked_by;
1402 self.block_reason = reason;
1403 }
1404
1405 pub fn unblock(&mut self, resolved_id: &str) {
1428 if self.status != TaskStatus::Blocked {
1429 return;
1430 }
1431 self.blocked_by.retain(|id| id != resolved_id);
1432 self.restore_if_unblocked();
1433 }
1434
1435 pub fn dependency_deleted(&mut self, deleted_id: &str) -> bool {
1446 if self.status != TaskStatus::Blocked || !self.blocked_by.iter().any(|b| b == deleted_id) {
1447 return false;
1448 }
1449 self.blocked_by.retain(|id| id != deleted_id);
1450 self.restore_if_unblocked();
1451 true
1452 }
1453
1454 fn restore_if_unblocked(&mut self) {
1458 if self.blocked_by.is_empty() {
1459 self.status = match self.blocked_from {
1460 Some(TaskStatus::Running) => TaskStatus::Queued,
1461 Some(other) => other,
1462 None if self.hold_reason.is_some() || self.hold_source.is_some() => {
1463 TaskStatus::Held
1464 }
1465 None => TaskStatus::Queued,
1466 };
1467 self.block_reason = None;
1468 self.blocked_from = None;
1469 }
1470 }
1471
1472 pub fn record_answer(&mut self, question: String, answer: String) {
1477 self.answers.push(AnsweredQuestion { question, answer });
1478 }
1479
1480 pub fn request_review(&mut self, branch: String) {
1484 self.release();
1485 self.review_branch = Some(branch);
1486 self.review_instruction = Some(self.instruction.clone());
1487 }
1488
1489 pub fn requeue(&mut self) {
1492 self.release();
1493 self.review_branch = None;
1495 self.review_instruction = None;
1496 self.fresh_start = true;
1497 }
1498
1499 pub fn hold_for_handover(&mut self, branch: Option<String>, reason: String) {
1508 if branch.is_some() {
1509 self.review_branch = branch;
1510 self.review_instruction = Some(self.instruction.clone());
1511 }
1512 self.hold_machine(Some(reason));
1513 }
1514
1515 pub fn set_urgent(&mut self, urgent: bool) -> Result<()> {
1517 if !matches!(self.status, TaskStatus::Queued | TaskStatus::Held) {
1518 bail!(
1519 "task {} is {}; urgent can only change while queued or held; \
1520 wait for the active attempt to finish and requeue or hold the task first",
1521 self.short(),
1522 self.status.as_str()
1523 );
1524 }
1525 self.urgent = urgent;
1526 Ok(())
1527 }
1528
1529 pub fn set_priority(&mut self, priority: i32) -> Result<()> {
1538 if self.status == TaskStatus::Running {
1539 bail!(
1540 "task {} is running; its priority cannot be changed until \
1541 this attempt finishes",
1542 self.short()
1543 );
1544 }
1545 self.priority = priority;
1546 Ok(())
1547 }
1548
1549 pub fn set_interrupt(&mut self, interrupt: bool) -> Result<()> {
1564 if interrupt && !self.status.runnable() {
1565 bail!(
1566 "task {} is {}; only a queued or failed task can be marked \
1567 to interrupt",
1568 self.short(),
1569 self.status.as_str()
1570 );
1571 }
1572 self.interrupt = interrupt;
1573 Ok(())
1574 }
1575
1576 pub fn effective_attempt_limit(&self, daemon_limit: usize) -> usize {
1578 self.attempt_limit.map_or(daemon_limit, NonZeroUsize::get)
1579 }
1580
1581 pub fn change_attempt_limit(&mut self, limit: Option<NonZeroUsize>) -> Result<()> {
1584 if !matches!(self.status, TaskStatus::Queued | TaskStatus::Held) {
1585 bail!("only queued or held tasks can change the attempt limit");
1586 }
1587 self.stamp_schema();
1588 self.attempt_limit = limit;
1589 Ok(())
1590 }
1591
1592 pub fn implementers(&self) -> Option<usize> {
1594 if self.solo {
1595 Some(1)
1596 } else {
1597 self.overrides.as_ref().and_then(|o| o.candidates)
1598 }
1599 }
1600
1601 pub fn change_implementers(&mut self, count: u8) -> Result<()> {
1604 self.check_implementers_editable()?;
1605 let explicit = self.overrides.as_ref().and_then(|o| o.config.as_deref());
1606 let (mut config, _) = crate::config::Config::discover(&self.repo, explicit)?;
1607 if let Some(o) = &self.overrides {
1608 o.apply(&mut config);
1609 }
1610 self.set_implementers(count, &config)
1611 }
1612
1613 fn check_implementers_editable(&self) -> Result<()> {
1614 if !matches!(self.status, TaskStatus::Queued | TaskStatus::Held) {
1615 bail!(
1616 "task {} is {}; only queued or held tasks can change implementers",
1617 self.short(),
1618 self.status.as_str()
1619 );
1620 }
1621 Ok(())
1622 }
1623
1624 pub fn set_implementers(&mut self, count: u8, config: &crate::config::Config) -> Result<()> {
1626 self.check_implementers_editable()?;
1627 crate::talk::check_implementers(count, config).map_err(anyhow::Error::msg)?;
1628 self.stamp_schema();
1629 self.solo = count == 1;
1630 let mut overrides = self.overrides.clone().unwrap_or_else(|| RunOverrides {
1631 inherit_daemon_options: true,
1632 ..Default::default()
1633 });
1634 overrides.candidates = (count > 1).then_some(usize::from(count));
1635 let mut settings = overrides.clone();
1636 settings.inherit_daemon_options = false;
1637 self.overrides = if overrides.inherit_daemon_options && settings == RunOverrides::default()
1639 {
1640 None
1641 } else {
1642 Some(overrides)
1643 };
1644 Ok(())
1645 }
1646
1647 pub fn edit(&mut self, title: String, instruction: String) -> Result<()> {
1659 if !matches!(self.status, TaskStatus::Queued | TaskStatus::Held) {
1660 bail!(
1661 "task {} is {}; only a queued or held task's instruction can \
1662 be edited",
1663 self.short(),
1664 self.status.as_str()
1665 );
1666 }
1667 if self.review_branch.is_some() && self.review_instruction.is_none() {
1670 self.review_instruction = Some(self.instruction.clone());
1671 }
1672 if self.instruction != instruction {
1673 self.resume_override = None;
1676 }
1677 self.title = title;
1678 self.instruction = instruction;
1679 Ok(())
1680 }
1681
1682 pub fn handed_off(&mut self, why: impl Into<String>) {
1702 let why = why.into();
1703 self.diagnostic = None;
1704 self.note_held();
1705 self.status = TaskStatus::Held;
1706 self.hold_source = Some(HoldSource::Machine);
1707 self.hold_reason = Some(why.clone());
1708 self.last_error = Some(why);
1709 }
1710
1711 pub fn release_operator(&mut self, source: &str, question_id: Option<&str>) {
1713 if self
1714 .resumption
1715 .as_ref()
1716 .is_some_and(|r| r.instruction != self.instruction)
1717 {
1718 self.resume_override = None;
1721 }
1722 let approval = Resumption {
1723 generation: self.resumption.as_ref().map_or(1, |r| r.generation + 1),
1724 source: source.to_owned(),
1725 question_id: question_id.map(str::to_owned),
1726 at: Timestamp::now(),
1727 instruction: self.instruction.clone(),
1728 answers_through: self.answers.len(),
1729 runs_through: self.runs.len(),
1730 blocking_evidence: Vec::new(),
1731 cleared_reason: self
1732 .hold_reason
1733 .clone()
1734 .or_else(|| self.block_reason.clone()),
1735 };
1736 self.release();
1737 self.operator_decided_at = Some(approval.at);
1738 if let Some(previous) = self.resumption.replace(approval) {
1739 self.resumption_history.push(previous);
1740 }
1741 }
1742
1743 pub fn release(&mut self) {
1747 self.import_chat_notice();
1750 if self.hold_notice_cause.is_none()
1751 && matches!(self.status, TaskStatus::Held | TaskStatus::Blocked)
1752 {
1753 self.hold_notice_cause = Some(self.unresolved_hold_cause());
1754 }
1755 let refused_handover = self.status == TaskStatus::Held
1756 && self.hold_source == Some(HoldSource::Machine)
1757 && self.review_branch.is_some()
1758 && self
1759 .review_instruction
1760 .as_ref()
1761 .is_none_or(|i| *i == self.instruction);
1762 self.status = TaskStatus::Queued;
1763 self.held_at = None;
1764 self.attempts = 0;
1765 self.last_error = None;
1766 self.park_reason = None;
1767 self.hold_reason = None;
1770 self.hold_source = None;
1771 self.diagnostic = None;
1772 self.blocked_by.clear();
1777 self.block_reason = None;
1778 self.blocked_from = None;
1779 if !refused_handover {
1783 self.review_branch = None;
1784 self.review_instruction = None;
1785 }
1786 self.fresh_start = false;
1787 }
1788}
1789
1790fn copy_new(dir: &Path, src: &Path, name: &str) -> Result<(String, PathBuf)> {
1794 use std::io::ErrorKind;
1795 let (stem, ext) = match name.rfind('.') {
1796 Some(i) if i > 0 => (&name[..i], &name[i..]),
1797 _ => (name, ""),
1798 };
1799 for n in 1u32.. {
1800 let candidate = if n == 1 {
1801 name.to_owned()
1802 } else {
1803 let suffix = format!("-{n}");
1804 let room = 64usize.saturating_sub(suffix.len() + ext.len());
1805 let stem: String = stem.chars().take(room).collect();
1806 format!("{stem}{suffix}{ext}")
1807 };
1808 if !crate::ask::valid_asset_name(&candidate) {
1809 bail!("no valid attachment name is left for `{name}`");
1810 }
1811 let path = dir.join(&candidate);
1812 match std::fs::OpenOptions::new()
1813 .write(true)
1814 .create_new(true)
1815 .open(&path)
1816 {
1817 Ok(mut out) => {
1818 let copied = std::fs::File::open(src)
1819 .and_then(|mut input| std::io::copy(&mut input, &mut out));
1820 if let Err(e) = copied {
1821 drop(out);
1822 let _ = std::fs::remove_file(&path);
1823 return Err(e).with_context(|| format!("copy {}", src.display()));
1824 }
1825 return Ok((candidate, path));
1826 }
1827 Err(e) if e.kind() == ErrorKind::AlreadyExists => continue,
1828 Err(e) => return Err(e).with_context(|| format!("create {}", path.display())),
1829 }
1830 }
1831 unreachable!("the counter never runs out")
1832}
1833
1834const TASK_LOCK_STALE: std::time::Duration = std::time::Duration::from_secs(10);
1837
1838struct TaskLock {
1841 path: PathBuf,
1842 token: String,
1843}
1844
1845fn owner_token() -> String {
1848 static COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
1849 let n = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1850 let mut r = crate::rng::SplitMix64::new(crate::rng::entropy() ^ n.rotate_left(32));
1851 format!("{}-{:016x}", std::process::id(), r.next_u64())
1852}
1853
1854fn break_marker(path: &Path, token: &str) -> PathBuf {
1857 let mut h: u64 = 0xcbf29ce484222325;
1858 for b in token.bytes() {
1859 h ^= u64::from(b);
1860 h = h.wrapping_mul(0x100000001b3);
1861 }
1862 let mut name = path.as_os_str().to_owned();
1863 name.push(format!(".break-{h:016x}"));
1864 PathBuf::from(name)
1865}
1866
1867fn older_than_stale(path: &Path) -> bool {
1868 std::fs::metadata(path)
1869 .and_then(|m| m.modified())
1870 .ok()
1871 .and_then(|t| t.elapsed().ok())
1872 .is_some_and(|age| age > TASK_LOCK_STALE)
1873}
1874
1875const MAX_MARKER_DEPTH: u8 = 3;
1879
1880fn take_marker(path: &Path, token: &str, depth: u8) -> Option<(PathBuf, String)> {
1886 use std::io::Write;
1887 let marker = break_marker(path, token);
1888 for _ in 0..2 {
1889 match std::fs::OpenOptions::new()
1890 .write(true)
1891 .create_new(true)
1892 .open(&marker)
1893 {
1894 Ok(mut f) => {
1895 let mine = owner_token();
1896 if f.write_all(mine.as_bytes()).is_err() {
1897 drop(f);
1898 let _ = std::fs::remove_file(&marker);
1899 return None;
1900 }
1901 return Some((marker, mine));
1902 }
1903 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
1904 let judged = std::fs::read_to_string(&marker).ok();
1905 match judged.filter(|_| older_than_stale(&marker)) {
1906 Some(judged) if depth < MAX_MARKER_DEPTH => {
1907 remove_lock_if(&marker, &judged, true, depth + 1)?;
1908 }
1909 _ => return None,
1910 }
1911 }
1912 Err(_) => return None,
1913 }
1914 }
1915 None
1916}
1917
1918fn remove_lock_if(path: &Path, token: &str, require_stale: bool, depth: u8) -> Option<bool> {
1923 let (marker, mine) = take_marker(path, token, depth)?;
1924 let still = std::fs::read_to_string(path).is_ok_and(|c| c == token)
1925 && (!require_stale || older_than_stale(path));
1926 if still {
1927 let _ = std::fs::remove_file(path);
1928 }
1929 if depth >= MAX_MARKER_DEPTH {
1931 if std::fs::read_to_string(&marker).is_ok_and(|c| c == mine) {
1932 let _ = std::fs::remove_file(&marker);
1933 }
1934 } else {
1935 let _ = remove_lock_if(&marker, &mine, false, depth + 1);
1936 }
1937 Some(still)
1938}
1939
1940fn break_stale(path: &Path, judged: &str) -> bool {
1943 remove_lock_if(path, judged, true, 0).unwrap_or(false)
1944}
1945
1946impl Drop for TaskLock {
1947 fn drop(&mut self) {
1948 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
1949 loop {
1950 match remove_lock_if(&self.path, &self.token, false, 0) {
1951 Some(_) => return,
1952 None if std::time::Instant::now() > deadline => return,
1953 None => std::thread::sleep(std::time::Duration::from_millis(5)),
1954 }
1955 }
1956 }
1957}
1958
1959#[derive(Debug, Clone)]
1961pub struct Queue {
1962 root: PathBuf,
1963}
1964
1965impl Queue {
1966 pub fn open() -> Self {
1968 Self::at(crate::run::home().join("queue"))
1969 }
1970
1971 pub fn at(root: PathBuf) -> Self {
1974 Self { root }
1975 }
1976
1977 pub fn root(&self) -> &Path {
1979 &self.root
1980 }
1981
1982 pub fn path_of(&self, id: &str) -> PathBuf {
1984 self.root.join(format!("{id}.json"))
1985 }
1986
1987 pub fn attachments_dir(&self, id: &str) -> PathBuf {
1990 self.root.join(format!("{id}.attachments"))
1991 }
1992
1993 pub fn attach(&self, task: &mut Task, sources: &[PathBuf]) -> Result<Vec<String>> {
2004 let mut wanted = Vec::new();
2005 for src in sources {
2006 let name = src
2007 .file_name()
2008 .and_then(|n| n.to_str())
2009 .with_context(|| format!("`{}` has no usable file name", src.display()))?;
2010 if !crate::ask::valid_asset_name(name) {
2011 bail!(
2012 "attachment name `{name}` must match ^[A-Za-z0-9][A-Za-z0-9._-]{{0,63}}$ \
2013 with no `..`; rename the file and try again"
2014 );
2015 }
2016 if !src.is_file() {
2017 bail!("attachment `{}` is not a file", src.display());
2018 }
2019 wanted.push((src, name));
2020 }
2021 let dir = self.attachments_dir(&task.id);
2022 let existed = dir.is_dir();
2023 std::fs::create_dir_all(&dir).with_context(|| format!("create {}", dir.display()))?;
2024 let mut created: Vec<PathBuf> = Vec::new();
2025 let mut names = Vec::new();
2026 let mut copy_all = || -> Result<()> {
2027 for (src, name) in &wanted {
2028 let (stored, path) = copy_new(&dir, src, name)?;
2029 created.push(path);
2030 names.push(stored);
2031 }
2032 Ok(())
2033 };
2034 if let Err(e) = copy_all() {
2035 for path in &created {
2036 let _ = std::fs::remove_file(path);
2037 }
2038 if !existed {
2039 let _ = std::fs::remove_dir(&dir);
2040 }
2041 return Err(e);
2042 }
2043 task.attachments.extend(names.iter().cloned());
2044 Ok(names)
2045 }
2046
2047 pub fn attach_and_put(&self, task: &mut Task, sources: &[PathBuf]) -> Result<Vec<String>> {
2058 let dir = self.attachments_dir(&task.id);
2059 let existed = dir.is_dir();
2060 let before = task.attachments.len();
2061 let names = self.attach(task, sources)?;
2062 if let Err(e) = self.put(task) {
2063 for name in &names {
2064 let _ = std::fs::remove_file(dir.join(name));
2065 }
2066 if !existed {
2067 let _ = std::fs::remove_dir(&dir);
2068 }
2069 task.attachments.truncate(before);
2070 return Err(e);
2071 }
2072 Ok(names)
2073 }
2074
2075 pub fn attachment_paths(&self, task: &Task) -> Vec<PathBuf> {
2079 let dir = self.attachments_dir(&task.id);
2080 task.attachments
2081 .iter()
2082 .map(|n| {
2083 let p = dir.join(n);
2084 std::path::absolute(&p).unwrap_or(p)
2085 })
2086 .collect()
2087 }
2088
2089 pub fn put(&self, task: &mut Task) -> Result<()> {
2098 let _lock = self.lock_task(&task.id)?;
2099 self.put_unlocked(task)
2100 }
2101
2102 pub fn create_new(&self, task: &mut Task) -> Result<bool> {
2107 let _lock = self.lock_task(&task.id)?;
2108 if self.path_of(&task.id).exists() {
2109 return Ok(false);
2110 }
2111 self.put_unlocked(task)?;
2112 Ok(true)
2113 }
2114
2115 fn put_unlocked(&self, task: &mut Task) -> Result<()> {
2117 if let Ok(mut stored) = read_path(&self.path_of(&task.id)) {
2118 stored.import_chat_notice();
2119 for key in stored.chat_notices {
2120 if !task.chat_notices.contains(&key) {
2121 task.chat_notices.push(key);
2122 }
2123 }
2124 for run in stored.runs {
2125 if !task.runs.contains(&run) {
2126 task.runs.push(run);
2127 }
2128 }
2129 for p in stored.passes {
2130 if !task
2131 .passes
2132 .iter()
2133 .any(|q| q.run == p.run && q.pass_index == p.pass_index)
2134 {
2135 task.passes.push(p);
2136 }
2137 }
2138 if let Some(n) = stored.chat_notice
2141 && task.chat_notice.as_ref().is_none_or(|mine| mine.at < n.at)
2142 {
2143 task.chat_notice = Some(n);
2144 }
2145 }
2146 task.import_chat_notice();
2147 task.updated_at = Timestamp::now();
2148 std::fs::create_dir_all(&self.root)
2149 .with_context(|| format!("create {}", self.root.display()))?;
2150 let body = serde_json::to_string_pretty(task).context("serialize task")?;
2151 let path = self.path_of(&task.id);
2152 let tmp = path.with_extension("json.tmp");
2153 std::fs::write(&tmp, &body).with_context(|| format!("write {}", tmp.display()))?;
2154 std::fs::rename(&tmp, &path).with_context(|| format!("replace {}", path.display()))?;
2155 if let (Some(notice), Some(home)) = (
2160 crate::notices::task_held(task),
2161 self.root.parent().filter(|p| !p.as_os_str().is_empty()),
2162 ) {
2163 crate::notices::raise_in(home, notice);
2164 }
2165 Ok(())
2166 }
2167
2168 pub fn modify(&self, id: &str, f: impl FnOnce(&mut Task) -> bool) -> Result<bool> {
2173 let id = self.resolve_id(id)?;
2174 let _lock = self.lock_task(&id)?;
2175 let mut task = self.get(&id)?;
2176 if !f(&mut task) {
2177 return Ok(false);
2178 }
2179 self.put_unlocked(&mut task)?;
2180 Ok(true)
2181 }
2182
2183 pub fn link_run(&self, id: &str, run: &str) -> Result<Task> {
2188 let id = self.resolve_id(id)?;
2189 let _lock = self.lock_task(&id)?;
2192 let mut task = self.get(&id)?;
2193 if task.link_run(run) {
2194 self.put_unlocked(&mut task)?;
2195 }
2196 Ok(task)
2197 }
2198
2199 fn lock_task(&self, id: &str) -> Result<TaskLock> {
2207 std::fs::create_dir_all(&self.root)
2208 .with_context(|| format!("create {}", self.root.display()))?;
2209 let path = self.root.join(format!("{id}.write-lock"));
2210 let started = std::time::Instant::now();
2211 loop {
2212 match std::fs::OpenOptions::new()
2213 .write(true)
2214 .create_new(true)
2215 .open(&path)
2216 {
2217 Ok(mut file) => {
2218 use std::io::Write;
2219 let token = owner_token();
2220 if let Err(e) = file.write_all(token.as_bytes()) {
2221 drop(file);
2222 let _ = std::fs::remove_file(&path);
2223 return Err(e).with_context(|| format!("lock {}", path.display()));
2224 }
2225 return Ok(TaskLock { path, token });
2226 }
2227 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
2228 let judged = std::fs::read_to_string(&path).ok();
2231 let broken = judged
2232 .filter(|_| older_than_stale(&path))
2233 .is_some_and(|judged| break_stale(&path, &judged));
2234 if !broken {
2238 if started.elapsed() > TASK_LOCK_STALE {
2239 bail!("could not lock task {id}");
2240 }
2241 std::thread::sleep(std::time::Duration::from_millis(15));
2242 }
2243 }
2244 Err(e)
2248 if e.kind() == std::io::ErrorKind::PermissionDenied
2249 && started.elapsed() <= TASK_LOCK_STALE =>
2250 {
2251 std::thread::sleep(std::time::Duration::from_millis(15));
2252 }
2253 Err(e) => return Err(e).with_context(|| format!("lock {}", path.display())),
2254 }
2255 }
2256 }
2257
2258 pub fn get(&self, id: &str) -> Result<Task> {
2260 let resolved = self.resolve_id(id)?;
2261 read_path(&self.path_of(&resolved))
2262 }
2263
2264 pub fn remove(&self, id: &str, in_flight: bool, questions: &Questions) -> Result<Removal> {
2296 let resolved = self.resolve_id(id)?;
2297 if in_flight {
2298 bail!("task {resolved} is being run by a live daemon right now");
2299 }
2300 self.write_tombstone(&resolved)?;
2301 if let Err(e) = self.remove_record_with_attachments(&resolved, |p| std::fs::remove_file(p))
2302 {
2303 if self.path_of(&resolved).exists() {
2307 let _ = std::fs::remove_file(self.tombstone_path(&resolved));
2308 }
2309 return Err(e);
2310 }
2311 let (released, still_blocked) = self.release_dependents_of(&resolved, questions);
2312 Ok(Removal {
2313 id: resolved,
2314 released,
2315 still_blocked,
2316 })
2317 }
2318
2319 fn tombstone_path(&self, id: &str) -> PathBuf {
2322 self.root.join(format!("{id}.removed"))
2323 }
2324
2325 fn write_tombstone(&self, id: &str) -> Result<()> {
2326 let path = self.tombstone_path(id);
2327 let tmp = self.root.join(format!("{id}.removed.tmp"));
2328 std::fs::write(&tmp, b"")
2329 .and_then(|()| std::fs::rename(&tmp, &path))
2330 .with_context(|| format!("write {}", path.display()))
2331 }
2332
2333 fn was_deleted(&self, id: &str) -> bool {
2337 self.tombstone_path(id).is_file() && !self.path_of(id).exists()
2338 }
2339
2340 pub fn apply_deleted_blockers(&self, task: &mut Task, questions: &Questions) -> Vec<String> {
2344 let deleted = deleted_blockers(self, questions, &task.blocked_by);
2345 deleted
2346 .into_iter()
2347 .filter(|id| task.dependency_deleted(id))
2348 .collect()
2349 }
2350
2351 pub fn note_dependency_deleted(&self, dependent: &Task, deleted: &str) {
2355 let Some(home) = self.root.parent().filter(|p| !p.as_os_str().is_empty()) else {
2356 return;
2357 };
2358 let ja = crate::lang::is_japanese(&crate::lang::of_repo(&dependent.repo));
2359 let waiting = dependent.status == TaskStatus::Blocked;
2360 let message = match (ja, waiting) {
2361 (true, false) => format!(
2362 "タスク {} は、待っていた {} が削除されたため待機を解除し、元の状態に戻しました",
2363 dependent.short(),
2364 short(deleted)
2365 ),
2366 (true, true) => format!(
2367 "タスク {} は、待っていた {} が削除されたため、残りの依存を待っています",
2368 dependent.short(),
2369 short(deleted)
2370 ),
2371 (false, false) => format!(
2372 "Task {} stopped waiting on {} because it was deleted, and returned to its previous state",
2373 dependent.short(),
2374 short(deleted)
2375 ),
2376 (false, true) => format!(
2377 "Task {} stopped waiting on {} because it was deleted, and is still waiting on its other dependencies",
2378 dependent.short(),
2379 short(deleted)
2380 ),
2381 };
2382 crate::notices::raise_in(
2383 home,
2384 crate::notices::Notice::info(
2385 &format!("unblocked:{}:{}", dependent.id, deleted),
2386 message,
2387 )
2388 .link(crate::notices::Link::Task {
2389 id: dependent.id.clone(),
2390 }),
2391 );
2392 }
2393
2394 fn release_dependents_of(
2399 &self,
2400 dependency: &str,
2401 questions: &Questions,
2402 ) -> (Vec<String>, Vec<String>) {
2403 let (mut released, mut still_blocked) = (Vec::new(), Vec::new());
2404 for listed in self.list() {
2405 if listed.status != TaskStatus::Blocked || !listed.blocked_by.iter().any(|b| {
2406 matches!(crate::queue::dependency(self, questions, b), Dependency::Deleted(id) if id == dependency)
2407 }) {
2408 continue;
2409 }
2410 let Ok(_claim) = self.claim(&listed.id) else {
2411 continue;
2412 };
2413 let Ok(mut task) = self.get(&listed.id) else {
2414 continue;
2415 };
2416 let references: Vec<String> = task.blocked_by.iter().filter(|id| {
2417 matches!(crate::queue::dependency(self, questions, id), Dependency::Deleted(full) if full == dependency)
2418 }).cloned().collect();
2419 if references.is_empty() {
2420 continue;
2421 }
2422 for reference in references {
2423 task.dependency_deleted(&reference);
2424 }
2425 if self.put(&mut task).is_err() {
2426 continue;
2427 }
2428 self.note_dependency_deleted(&task, dependency);
2429 if task.status == TaskStatus::Blocked {
2430 still_blocked.push(task.id.clone());
2431 } else {
2432 released.push(task.id.clone());
2433 }
2434 }
2435 (released, still_blocked)
2436 }
2437
2438 fn remove_record_with_attachments(
2442 &self,
2443 resolved: &str,
2444 remove_record: impl FnOnce(&Path) -> std::io::Result<()>,
2445 ) -> Result<()> {
2446 self.sweep_removed_attachments();
2453 let attachments = self.attachments_dir(resolved);
2454 let aside = self.root.join(format!("{resolved}.attachments.removing"));
2455 let moved = match std::fs::rename(&attachments, &aside) {
2456 Ok(()) => true,
2457 Err(e) if e.kind() == std::io::ErrorKind::NotFound => false,
2458 Err(e) => {
2459 return Err(e).with_context(|| format!("remove {}", attachments.display()));
2460 }
2461 };
2462 let path = self.path_of(resolved);
2463 if let Err(e) = remove_record(&path) {
2464 if moved {
2465 let _ = std::fs::rename(&aside, &attachments);
2466 }
2467 return Err(e).with_context(|| format!("remove {}", path.display()));
2468 }
2469 if moved {
2470 if let Err(e) = std::fs::remove_dir_all(&aside) {
2471 tracing::warn!("leftover attachments {}: {e}", aside.display());
2472 }
2473 }
2474 let lock = self.lock_path(resolved);
2475 if let Err(e) = std::fs::remove_file(&lock) {
2476 if e.kind() != std::io::ErrorKind::NotFound {
2477 return Err(e).with_context(|| format!("remove {}", lock.display()));
2478 }
2479 }
2480 Ok(())
2481 }
2482
2483 fn sweep_removed_attachments(&self) {
2489 let Ok(entries) = std::fs::read_dir(&self.root) else {
2490 return;
2491 };
2492 for entry in entries.flatten() {
2493 let name = entry.file_name();
2494 let name = name.to_string_lossy();
2495 let Some(id) = name.strip_suffix(".attachments.removing") else {
2496 continue;
2497 };
2498 if !self.path_of(id).exists() {
2501 if let Err(e) = std::fs::remove_dir_all(entry.path()) {
2502 tracing::warn!("leftover attachments {}: {e}", entry.path().display());
2503 }
2504 }
2505 }
2506 self.sweep_tombstones();
2507 }
2508
2509 fn sweep_tombstones(&self) {
2518 let Ok(entries) = std::fs::read_dir(&self.root) else {
2519 return;
2520 };
2521 let tasks = self.list();
2522 for entry in entries.flatten() {
2523 let name = entry.file_name();
2524 let name = name.to_string_lossy();
2525 let Some(id) = name.strip_suffix(".removed") else {
2526 continue;
2527 };
2528 if self.path_of(id).exists()
2529 || tasks.iter().any(|t| {
2530 t.blocked_by
2531 .iter()
2532 .any(|b| id.starts_with(b) || id.ends_with(b))
2533 })
2534 {
2535 continue;
2536 }
2537 let old = entry
2538 .metadata()
2539 .and_then(|m| m.modified())
2540 .ok()
2541 .and_then(|m| m.elapsed().ok())
2542 .is_some_and(|age| age >= TOMBSTONE_GRACE);
2543 if old {
2544 let _ = std::fs::remove_file(entry.path());
2545 }
2546 }
2547 }
2548
2549 fn lock_path(&self, id: &str) -> PathBuf {
2552 self.root.join(format!("{id}.lock"))
2553 }
2554
2555 pub fn list(&self) -> Vec<Task> {
2568 let mut tasks: Vec<Task> = std::fs::read_dir(&self.root)
2569 .into_iter()
2570 .flatten()
2571 .flatten()
2572 .map(|e| e.path())
2573 .filter(|p| p.extension().is_some_and(|x| x == "json"))
2574 .filter_map(|p| read_path(&p).ok())
2575 .collect();
2576 tasks.sort_unstable_by(|a, b| b.priority.cmp(&a.priority).then_with(|| b.id.cmp(&a.id)));
2577 tasks
2578 }
2579
2580 pub fn superseded(&self) -> HashMap<String, String> {
2593 let mut by = HashMap::new();
2594 for task in self.list() {
2595 for earlier in &task.runs {
2596 if let Some(later) = task.successor_of(earlier) {
2597 by.insert(earlier.clone(), later.clone());
2598 }
2599 }
2600 }
2601 by
2602 }
2603
2604 pub fn superseded_by(&self, run: &str) -> Option<String> {
2612 for task in self.list() {
2613 if task.runs.iter().any(|r| r == run) {
2614 return task.successor_of(run).cloned();
2615 }
2616 }
2617 None
2618 }
2619
2620 pub fn latest_attempt(&self, run: &str) -> Option<String> {
2631 for task in self.list() {
2632 if task.runs.iter().any(|r| r == run) {
2633 return task.runs.last().filter(|last| **last != run).cloned();
2634 }
2635 }
2636 None
2637 }
2638
2639 pub fn next_runnable(&self) -> Option<Task> {
2644 let mut runnable: Vec<Task> = self
2645 .list()
2646 .into_iter()
2647 .filter(|t| t.status.runnable())
2648 .collect();
2649 runnable.sort_unstable_by(|a, b| b.priority.cmp(&a.priority).then(a.id.cmp(&b.id)));
2650 runnable.into_iter().next()
2651 }
2652
2653 pub fn claim(&self, id: &str) -> Result<Claim> {
2660 std::fs::create_dir_all(&self.root)
2661 .with_context(|| format!("create {}", self.root.display()))?;
2662 let path = self.lock_path(id);
2663 match std::fs::OpenOptions::new()
2664 .write(true)
2665 .create_new(true)
2666 .open(&path)
2667 {
2668 Ok(mut f) => {
2669 use std::io::Write as _;
2670 let _ = writeln!(f, "{}", std::process::id());
2672 Ok(Claim { path })
2673 }
2674 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
2675 bail!("task {id} is already claimed ({} exists)", path.display())
2676 }
2677 Err(e) => Err(e).with_context(|| format!("lock {}", path.display())),
2678 }
2679 }
2680
2681 pub fn resolve_id(&self, prefix: &str) -> Result<String> {
2683 let ids = record_ids(&self.root, "json")?;
2684 match resolve_reference(prefix, ids.iter().map(String::as_str)) {
2685 Reference::Found(id) => Ok(id),
2686 Reference::Missing => bail!("no task matches `{prefix}`"),
2687 Reference::Ambiguous => bail!("`{prefix}` matches multiple tasks"),
2688 }
2689 }
2690
2691 pub fn revision(&self) -> u64 {
2698 self.revision_excluding(&std::collections::BTreeSet::new())
2699 }
2700
2701 pub fn revision_excluding(&self, skip: &std::collections::BTreeSet<String>) -> u64 {
2704 use std::hash::{Hash as _, Hasher as _};
2705
2706 let mut entries: Vec<(String, u64)> = std::fs::read_dir(&self.root)
2707 .into_iter()
2708 .flatten()
2709 .flatten()
2710 .filter(|e| e.path().extension().is_some_and(|ext| ext == "json"))
2711 .filter(|e| {
2712 let path = e.path();
2713 !path
2714 .file_stem()
2715 .is_some_and(|stem| skip.contains(stem.to_string_lossy().as_ref()))
2716 })
2717 .filter_map(|e| {
2718 let name = e.file_name().to_string_lossy().into_owned();
2719 let mtime = e
2720 .metadata()
2721 .ok()?
2722 .modified()
2723 .ok()?
2724 .duration_since(std::time::UNIX_EPOCH)
2725 .ok()?
2726 .as_millis() as u64;
2727 Some((name, mtime))
2728 })
2729 .collect();
2730
2731 if entries.is_empty() {
2732 return 0;
2733 }
2734
2735 entries.sort_unstable();
2736 let mut hasher = std::hash::DefaultHasher::new();
2737 for (name, mtime) in &entries {
2738 name.hash(&mut hasher);
2739 mtime.hash(&mut hasher);
2740 }
2741 let h = hasher.finish();
2742 if h == 0 { 1 } else { h }
2743 }
2744}
2745
2746const TOMBSTONE_GRACE: std::time::Duration = std::time::Duration::from_secs(3600);
2749
2750#[derive(Debug, Clone)]
2752pub struct Removal {
2753 pub id: String,
2755 pub released: Vec<String>,
2759 pub still_blocked: Vec<String>,
2762}
2763
2764#[derive(Debug)]
2766pub struct Claim {
2767 path: PathBuf,
2768}
2769
2770impl Drop for Claim {
2771 fn drop(&mut self) {
2772 let _ = std::fs::remove_file(&self.path);
2773 }
2774}
2775
2776pub fn title_from(instruction: &str, max: usize) -> String {
2779 let Some(line) = first_line(instruction) else {
2780 return "(empty task)".to_owned();
2781 };
2782 if line.chars().count() <= max {
2783 return line.to_owned();
2784 }
2785 let head: String = line.chars().take(max.saturating_sub(1)).collect();
2786 format!("{head}…")
2787}
2788
2789pub(crate) fn first_line(instruction: &str) -> Option<&str> {
2795 let line = instruction
2796 .lines()
2797 .map(str::trim)
2798 .find(|l| !l.is_empty())?
2799 .trim_start_matches(['#', '-', '*', '>', ' '])
2800 .trim();
2801 (!line.is_empty()).then_some(line)
2802}
2803
2804fn read_path(path: &Path) -> Result<Task> {
2805 let body = std::fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
2806 let task: Task =
2807 serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))?;
2808 if task.schema > SCHEMA {
2814 bail!(
2815 "task {} was written by a different magi (schema {}, this build \
2816 speaks {SCHEMA})",
2817 task.id,
2818 task.schema
2819 );
2820 }
2821 Ok(task)
2822}
2823
2824#[derive(Debug, PartialEq, Eq)]
2827pub(crate) enum Reference {
2828 Found(String),
2829 Missing,
2830 Ambiguous,
2831}
2832
2833pub(crate) fn resolve_reference<'a>(id: &str, ids: impl Iterator<Item = &'a str>) -> Reference {
2834 let mut hits = Vec::new();
2835 for candidate in ids {
2836 if candidate == id {
2837 return Reference::Found(candidate.to_owned());
2838 }
2839 if candidate.starts_with(id) || candidate.ends_with(id) {
2840 hits.push(candidate);
2841 }
2842 }
2843 match hits.as_slice() {
2844 [] => Reference::Missing,
2845 [one] => Reference::Found((*one).to_owned()),
2846 _ => Reference::Ambiguous,
2847 }
2848}
2849
2850pub(crate) fn record_ids(root: &Path, extension: &str) -> Result<Vec<String>> {
2853 let entries = match std::fs::read_dir(root) {
2854 Ok(entries) => entries,
2855 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
2856 Err(e) => return Err(e.into()),
2857 };
2858 let mut ids = Vec::new();
2859 for entry in entries {
2860 let path = entry?.path();
2861 if path.extension().is_some_and(|e| e == extension)
2862 && let Some(id) = path.file_stem().and_then(|s| s.to_str())
2863 {
2864 ids.push(id.to_owned());
2865 }
2866 }
2867 Ok(ids)
2868}
2869
2870#[derive(Debug, PartialEq, Eq)]
2873pub enum Dependency {
2874 Task(String),
2876 Question(String),
2878 Deleted(String),
2880 Missing,
2882 Ambiguous,
2884 Unknown,
2886}
2887
2888pub fn dependency(queue: &Queue, questions: &Questions, id: &str) -> Dependency {
2890 let (Ok(tasks), Ok(qs), Ok(removed)) = (
2891 record_ids(queue.root(), "json"),
2892 record_ids(questions.root(), "json"),
2893 record_ids(queue.root(), "removed"),
2894 ) else {
2895 return Dependency::Unknown;
2896 };
2897 let mut ids = tasks
2900 .iter()
2901 .chain(qs.iter())
2902 .chain(removed.iter())
2903 .cloned()
2904 .collect::<Vec<_>>();
2905 ids.sort();
2906 ids.dedup();
2907 match resolve_reference(id, ids.iter().map(String::as_str)) {
2908 Reference::Found(id) if tasks.contains(&id) => Dependency::Task(id),
2909 Reference::Found(id) if qs.contains(&id) => Dependency::Question(id),
2910 Reference::Found(id) if queue.was_deleted(&id) => Dependency::Deleted(id),
2911 Reference::Found(_) => Dependency::Unknown,
2912 Reference::Missing => Dependency::Missing,
2913 Reference::Ambiguous => Dependency::Ambiguous,
2914 }
2915}
2916
2917pub fn normalize_blockers(queue: &Queue, questions: &Questions, ids: &mut [String]) {
2919 for id in ids {
2920 if let Dependency::Task(full) | Dependency::Question(full) | Dependency::Deleted(full) =
2921 dependency(queue, questions, id)
2922 {
2923 *id = full;
2924 }
2925 }
2926}
2927
2928pub fn dependency_facts(queue: &Queue, questions: &Questions) -> String {
2931 let inventory = crate::blockers::Inventory::load(queue, questions);
2932 let mut text = String::from(
2933 "Current authoritative queue (historical missing-task claims may be stale; unreadable or ambiguous references must keep waiting):\n",
2934 );
2935 match record_ids(queue.root(), "json") {
2936 Ok(ids) => {
2937 for id in ids {
2938 match queue.get(&id) {
2939 Ok(task) => text.push_str(&format!(
2940 "{}: {}; blocked_by={:?}; chain={:?}\n",
2941 task.id,
2942 task.status.as_str(),
2943 task.blocked_by,
2944 inventory.waits_on(&task)
2945 )),
2946 Err(_) => text.push_str(&format!("{id}: exists, state unreadable\n")),
2947 }
2948 }
2949 }
2950 Err(_) => text.push_str("Queue inventory unreadable: existence is unknown.\n"),
2951 }
2952 text
2953}
2954
2955pub fn missing_blockers(
2957 queue: &Queue,
2958 questions: &Questions,
2959 blocked_by: &[String],
2960) -> Vec<String> {
2961 blocked_by
2962 .iter()
2963 .filter(|id| dependency(queue, questions, id) == Dependency::Missing)
2964 .cloned()
2965 .collect()
2966}
2967
2968pub fn deleted_blockers(
2971 queue: &Queue,
2972 questions: &Questions,
2973 blocked_by: &[String],
2974) -> Vec<String> {
2975 blocked_by
2976 .iter()
2977 .filter(|id| matches!(dependency(queue, questions, id), Dependency::Deleted(_)))
2978 .cloned()
2979 .collect()
2980}
2981
2982pub fn missing_blocker_hold_reason(blocked_by: &[String], missing: &[String]) -> String {
2994 missing_blocker_hold_reason_in(blocked_by, missing, "en")
2995}
2996
2997pub fn missing_blocker_hold_reason_in(
2999 blocked_by: &[String],
3000 missing: &[String],
3001 language: &str,
3002) -> String {
3003 if crate::lang::is_japanese(language) {
3004 format!(
3005 "{} を待っていましたが、{} はディスク上に存在しません - `magi task triage` を参照",
3006 blocked_by.join(", "),
3007 missing.join(", "),
3008 )
3009 } else {
3010 format!(
3011 "blocked on {} but {} no longer exist(s) on disk - see `magi task triage`",
3012 blocked_by.join(", "),
3013 missing.join(", "),
3014 )
3015 }
3016}
3017
3018pub fn short(id: &str) -> &str {
3020 id.split('-').next_back().unwrap_or(id)
3021}
3022
3023fn new_id() -> String {
3024 let stamp = jiff::Zoned::now().strftime("%Y%m%d-%H%M%S");
3025 let seed = crate::rng::entropy();
3026 format!("{stamp}-{:04x}", (seed ^ (seed >> 32)) & 0xffff)
3027}
3028
3029#[cfg(test)]
3030mod tests {
3031 #[test]
3032 fn explicit_resumption_is_not_invented_for_legacy_or_automatic_release() {
3033 let t = Task::new(
3034 "retry".into(),
3035 "do it".into(),
3036 PathBuf::from("."),
3037 Source::Human,
3038 );
3039 let mut value = serde_json::to_value(&t).unwrap();
3040 for field in ["resumption", "resumption_history", "operator_decided_at"] {
3041 value.as_object_mut().unwrap().remove(field);
3042 }
3043 value["schema"] = serde_json::json!(15);
3044 let mut legacy: Task = serde_json::from_value(value).unwrap();
3045 legacy.hold_machine(Some("automatic retry".into()));
3046 legacy.release();
3047 assert!(legacy.resumption.is_none());
3048 assert!(legacy.resumption_history.is_empty());
3049 assert!(legacy.operator_decided_at.is_none());
3050 legacy.release_operator("cli", None);
3051 legacy.start("run".into());
3052 let reloaded: Task =
3053 serde_json::from_str(&serde_json::to_string(&legacy).unwrap()).unwrap();
3054 assert!(reloaded.resume_override.is_none());
3055 assert_eq!(reloaded.resumption.as_ref().unwrap().generation, 1);
3056 }
3057
3058 #[test]
3059 fn merge_over_lets_the_later_choice_win_and_keeps_the_rest() {
3060 let mut base = RunOverrides {
3061 merge: Some("pr".to_owned()),
3062 candidates: Some(3),
3063 ..RunOverrides::default()
3064 };
3065 base.merge_over(&RunOverrides {
3066 merge: Some("none".to_owned()),
3067 seed: Some(7),
3068 ..RunOverrides::default()
3069 });
3070 assert_eq!(base.merge.as_deref(), Some("none"));
3071 assert_eq!(base.candidates, Some(3));
3072 assert_eq!(base.seed, Some(7));
3073 }
3074
3075 #[test]
3076 fn an_already_landed_task_is_done_with_its_attempt_refunded() {
3077 let mut t = task("relanded");
3078 t.attempts = 1;
3079 t.status = TaskStatus::Running;
3080 t.already_landed("already in main as 0e368de");
3081 assert_eq!(t.status, TaskStatus::Done);
3082 assert_eq!(t.attempts, 0);
3083 assert!(t.hold_reason.is_none());
3084 assert_eq!(t.last_error.as_deref(), Some("already in main as 0e368de"));
3085 }
3086
3087 #[test]
3088 fn missing_blocker_reason_follows_the_language() {
3089 let b = vec!["a".to_owned()];
3090 let en = missing_blocker_hold_reason_in(&b, &b, "en");
3091 assert_eq!(en, missing_blocker_hold_reason(&b, &b));
3092 assert!(en.starts_with("blocked on a"));
3093 assert!(missing_blocker_hold_reason_in(&b, &b, "ja").contains("存在しません"));
3094 assert_eq!(missing_blocker_hold_reason_in(&b, &b, "de"), en);
3095 }
3096
3097 use super::*;
3098
3099 #[test]
3100 fn triage_applied_survives_release_and_old_records_read_as_empty() {
3101 let mut t = Task::new(
3102 "t".to_owned(),
3103 "i".to_owned(),
3104 PathBuf::from("r"),
3105 Source::Human,
3106 );
3107 t.mark_triage_applied("q1");
3108 t.mark_triage_applied("q1");
3109 t.hold_machine(Some("x".to_owned()));
3110 t.release();
3111 assert_eq!(t.triage_applied, ["q1"]);
3112 assert!(t.triage_applied("q1") && !t.triage_applied("q2"));
3113
3114 let mut v = serde_json::to_value(&t).unwrap();
3115 v.as_object_mut().unwrap().remove("triage_applied");
3116 let old: Task = serde_json::from_value(v).unwrap();
3117 assert!(old.triage_applied.is_empty());
3118 }
3119
3120 #[test]
3121 fn task_counts_of_empty_is_all_zero() {
3122 assert_eq!(TaskCounts::of(&[]), TaskCounts::default());
3123 }
3124
3125 #[test]
3126 fn task_counts_of_tallies_every_status() {
3127 let mut queued = Task::new(
3128 "q".to_owned(),
3129 "i".to_owned(),
3130 PathBuf::from("."),
3131 Source::Human,
3132 );
3133 queued.status = TaskStatus::Queued;
3134 let mut running = queued.clone();
3135 running.status = TaskStatus::Running;
3136 let mut done = queued.clone();
3137 done.status = TaskStatus::Done;
3138 let mut failed = queued.clone();
3139 failed.status = TaskStatus::Failed;
3140 let mut held = queued.clone();
3141 held.status = TaskStatus::Held;
3142 let mut blocked = queued.clone();
3143 blocked.status = TaskStatus::Blocked;
3144
3145 let mut parked = queued.clone();
3146 parked.status = TaskStatus::Parked;
3147
3148 let counts = TaskCounts::of(&[
3149 queued,
3150 running,
3151 done.clone(),
3152 done,
3153 failed,
3154 held,
3155 blocked,
3156 parked,
3157 ]);
3158 assert_eq!(
3159 counts,
3160 TaskCounts {
3161 queued: 1,
3162 running: 1,
3163 done: 2,
3164 failed: 1,
3165 held: 1,
3166 blocked: 1,
3167 parked: 1,
3168 }
3169 );
3170 }
3171
3172 #[test]
3173 fn task_implementers_persist_normalized_without_losing_history() {
3174 let (_dir, q) = queue();
3175 let mut t = task("keep me");
3176 t.hold_manual(Some("waiting".into()));
3177 t.runs.push("earlier-run".into());
3178 t.attachments.push("reference.txt".into());
3179 let cfg: crate::config::Config =
3180 toml::from_str("[[agents]]\nid = 'mock'\nkind = 'command'\ncommand = ['true']\n")
3181 .unwrap();
3182 q.put(&mut t).unwrap();
3183 let original = serde_json::to_value(&t).unwrap();
3184 for n in [2, 1, 2] {
3185 t.set_implementers(n, &cfg).unwrap();
3186 q.put(&mut t).unwrap();
3187 t = q.get(&t.id).unwrap();
3188 assert_eq!(t.implementers(), Some(usize::from(n)));
3189 assert_eq!(t.solo, n == 1);
3190 if n == 1 {
3191 assert!(t.overrides.is_none());
3192 }
3193 let mut actual = serde_json::to_value(&t).unwrap();
3194 for field in ["solo", "overrides", "updated_at"] {
3195 actual[field] = original[field].clone();
3196 }
3197 assert_eq!(actual, original);
3198 }
3199 t.overrides.as_mut().unwrap().judges = Some(2);
3200 t.set_implementers(1, &cfg).unwrap();
3201 assert_eq!(t.overrides.as_ref().unwrap().judges, Some(2));
3202 assert_eq!(t.overrides.as_ref().unwrap().candidates, None);
3203 }
3204
3205 #[test]
3206 fn task_implementers_old_records_keep_inheritance_until_changed() {
3207 let mut value = serde_json::to_value(task("old task")).unwrap();
3208 value.as_object_mut().unwrap().remove("solo");
3209 value.as_object_mut().unwrap().remove("overrides");
3210 let mut old: Task = serde_json::from_value(value).unwrap();
3211 assert_eq!(old.implementers(), None);
3212 old.set_implementers(1, &crate::config::Config::default())
3213 .unwrap();
3214 assert!(old.solo);
3215 assert!(old.overrides.is_none());
3216 }
3217
3218 #[test]
3219 fn task_implementers_reject_invalid_counts_and_states() {
3220 let cfg = crate::config::Config::default();
3221 let mut t = task("unchanged");
3222 for n in [0, 4, 255] {
3223 assert!(t.set_implementers(n, &cfg).is_err());
3224 }
3225 for status in [
3226 TaskStatus::Running,
3227 TaskStatus::Done,
3228 TaskStatus::Failed,
3229 TaskStatus::Blocked,
3230 TaskStatus::Parked,
3231 ] {
3232 t.status = status;
3233 let before = serde_json::to_value(&t).unwrap();
3234 assert!(t.set_implementers(1, &cfg).is_err());
3235 assert_eq!(serde_json::to_value(&t).unwrap(), before);
3236 }
3237 }
3238
3239 fn queue() -> (tempfile::TempDir, Queue) {
3242 let dir = tempfile::tempdir().unwrap();
3243 let q = Queue::at(dir.path().join("queue"));
3244 (dir, q)
3245 }
3246
3247 #[test]
3248 fn putting_a_machine_held_task_files_a_notification_beside_the_queue() {
3249 let dir = tempfile::tempdir().unwrap();
3250 let q = Queue::at(dir.path().join("queue"));
3251 let mut t = task("held");
3252 q.put(&mut t).unwrap();
3253 assert_eq!(
3254 crate::notices::Notices::at(dir.path().join("notifications"))
3255 .list()
3256 .len(),
3257 0
3258 );
3259 t.hold_machine(Some("out of attempts".to_owned()));
3260 q.put(&mut t).unwrap();
3261 let listed = crate::notices::Notices::at(dir.path().join("notifications")).list();
3262 assert_eq!(listed.len(), 1);
3263 assert!(listed[0].message.contains("out of attempts"));
3264 }
3265
3266 fn task(title: &str) -> Task {
3267 Task::new(
3268 title.to_owned(),
3269 format!("do {title}"),
3270 PathBuf::from("."),
3271 Source::Human,
3272 )
3273 }
3274
3275 #[test]
3276 fn attempt_limit_inherits_old_records_and_controls_failure() {
3277 let mut t = task("budget");
3278 let mut old = serde_json::to_value(&t).unwrap();
3279 old.as_object_mut().unwrap().remove("attempt_limit");
3280 old["schema"] = serde_json::json!(15);
3281 let legacy: Task = serde_json::from_value(old).unwrap();
3282 assert_eq!(legacy.attempt_limit, None);
3283 assert_eq!(legacy.effective_attempt_limit(2), 2);
3284 t.attempts = 2;
3285 t.change_attempt_limit(NonZeroUsize::new(3)).unwrap();
3286 t.fail("retry", 1);
3287 assert_eq!(t.status, TaskStatus::Failed);
3288 t.attempts = 3;
3289 t.fail("stop", 10);
3290 assert_eq!(t.status, TaskStatus::Held);
3291 t.change_attempt_limit(None).unwrap();
3292 assert_eq!(t.status, TaskStatus::Held);
3293 assert_eq!(t.attempts, 3);
3294 t.fail("inherit", 4);
3295 assert_eq!(t.status, TaskStatus::Failed);
3296 for status in [
3297 TaskStatus::Running,
3298 TaskStatus::Done,
3299 TaskStatus::Failed,
3300 TaskStatus::Blocked,
3301 TaskStatus::Parked,
3302 ] {
3303 t.status = status;
3304 assert!(t.change_attempt_limit(NonZeroUsize::new(1)).is_err());
3305 assert_eq!(t.attempt_limit, None);
3306 }
3307 }
3308
3309 #[test]
3310 fn earlier_attempts_is_every_recorded_run_and_agrees_with_the_display() {
3311 let mut t = task("retried");
3312 assert!(
3313 t.earlier_attempts().is_empty(),
3314 "a first attempt takes nothing over"
3315 );
3316 t.runs = vec!["aaaa".to_owned(), "bbbb".to_owned()];
3317 assert_eq!(t.earlier_attempts(), ["aaaa", "bbbb"]);
3318 assert_eq!(t.successor_of("aaaa"), Some(&"bbbb".to_owned()));
3320 assert_eq!(t.successor_of("bbbb"), None);
3321 assert_eq!(t.successor_of("zzzz"), None);
3322 }
3323
3324 #[test]
3325 fn resumed_runs_are_superseded_only_after_their_last_occurrence() {
3326 let (_dir, q) = queue();
3327 let mut t = task("resumed");
3328 for (runs, successor) in [
3329 (vec!["aaaa", "aaaa"], None),
3330 (vec!["aaaa", "bbbb", "aaaa"], None),
3331 (vec!["aaaa", "aaaa", "bbbb"], Some("bbbb")),
3332 (vec!["aaaa", "bbbb", "aaaa", "cccc"], Some("cccc")),
3333 ] {
3334 t.runs = runs.into_iter().map(str::to_owned).collect();
3335 q.put(&mut t).unwrap();
3336
3337 assert_eq!(t.successor_of("aaaa").map(String::as_str), successor);
3338 assert_eq!(q.superseded_by("aaaa").as_deref(), successor);
3339 assert_eq!(q.superseded().get("aaaa").map(String::as_str), successor);
3340 }
3341 }
3342
3343 #[test]
3344 fn superseded_by_names_the_next_attempt_and_none_for_the_last() {
3345 let (_dir, q) = queue();
3346 let mut t = task("retried");
3347 t.runs = vec!["aaaa".to_owned(), "bbbb".to_owned(), "cccc".to_owned()];
3348 q.put(&mut t).unwrap();
3349
3350 assert_eq!(q.superseded_by("aaaa"), Some("bbbb".to_owned()));
3351 assert_eq!(q.superseded_by("bbbb"), Some("cccc".to_owned()));
3352 assert_eq!(
3353 q.superseded_by("cccc"),
3354 None,
3355 "the latest attempt replaces nothing"
3356 );
3357 assert_eq!(
3358 q.superseded_by("never-heard-of-it"),
3359 None,
3360 "a run belonging to no task on this queue is not superseded"
3361 );
3362
3363 let mut by = HashMap::new();
3364 by.insert("aaaa".to_owned(), "bbbb".to_owned());
3365 by.insert("bbbb".to_owned(), "cccc".to_owned());
3366 assert_eq!(
3367 q.superseded(),
3368 by,
3369 "the whole-map and single-run forms must agree"
3370 );
3371 }
3372
3373 #[test]
3374 fn superseded_attempts_is_empty_until_the_task_is_done() {
3375 let mut t = task("retried");
3376 t.runs = vec!["aaaa".to_owned(), "bbbb".to_owned()];
3377 t.status = TaskStatus::Failed;
3378 assert_eq!(
3379 t.superseded_attempts(true),
3380 &[] as &[String],
3381 "a task still retrying has no attempt yet that a later one made moot"
3382 );
3383
3384 t.status = TaskStatus::Running;
3385 assert_eq!(t.superseded_attempts(true), &[] as &[String]);
3386 }
3387
3388 #[test]
3389 fn superseded_attempts_names_every_run_before_the_one_that_succeeded() {
3390 let mut t = task("retried");
3391 t.runs = vec!["aaaa".to_owned(), "bbbb".to_owned(), "cccc".to_owned()];
3392 t.status = TaskStatus::Done;
3393 assert_eq!(
3394 t.superseded_attempts(true),
3395 &["aaaa".to_owned(), "bbbb".to_owned()],
3396 "cccc is the attempt whose success made the task done, and stays out"
3397 );
3398 }
3399
3400 #[test]
3401 fn superseded_attempts_is_empty_for_a_done_task_with_only_one_attempt() {
3402 let mut t = task("first try landed");
3403 t.runs = vec!["aaaa".to_owned()];
3404 t.status = TaskStatus::Done;
3405 assert_eq!(t.superseded_attempts(true), &[] as &[String]);
3406 }
3407
3408 #[test]
3409 fn superseded_attempts_is_empty_when_the_last_run_never_actually_succeeded() {
3410 let mut t = task("closed by hand after a manual merge");
3417 t.runs = vec!["aaaa".to_owned(), "bbbb".to_owned()];
3418 t.status = TaskStatus::Done;
3419 assert_eq!(
3420 t.superseded_attempts(false),
3421 &[] as &[String],
3422 "nothing here is provably why the task is done, so nothing is superseded"
3423 );
3424 }
3425
3426 #[test]
3427 fn latest_attempt_names_the_chain_s_current_head_not_just_the_next_one() {
3428 let (_dir, q) = queue();
3429 let mut t = task("retried twice");
3430 t.runs = vec!["aaaa".to_owned(), "bbbb".to_owned(), "cccc".to_owned()];
3431 q.put(&mut t).unwrap();
3432
3433 assert_eq!(
3434 q.latest_attempt("aaaa"),
3435 Some("cccc".to_owned()),
3436 "an old attempt points straight at the chain's current head, not the \
3437 next attempt in the middle of it"
3438 );
3439 assert_eq!(q.latest_attempt("bbbb"), Some("cccc".to_owned()));
3440 assert_eq!(
3441 q.latest_attempt("cccc"),
3442 None,
3443 "the latest attempt is not superseded by anything"
3444 );
3445 assert_eq!(
3446 q.latest_attempt("never-heard-of-it"),
3447 None,
3448 "a run belonging to no task on this queue is not superseded"
3449 );
3450 }
3451
3452 #[test]
3453 fn a_markdown_heading_is_the_title_not_decoration() {
3454 assert_eq!(
3459 title_from("# Rework the config loader\n\nIt re-reads it.\n", 40),
3460 "Rework the config loader"
3461 );
3462 assert_eq!(title_from("- fix the thing", 40), "fix the thing");
3463 assert_eq!(title_from("> quoted task", 40), "quoted task");
3464 assert_eq!(title_from(" \n\n", 40), "(empty task)");
3466 assert_eq!(title_from("###\n", 40), "(empty task)");
3467 }
3468
3469 #[test]
3470 fn a_long_title_is_elided_by_characters_not_bytes() {
3471 let long = "課題".repeat(30);
3473 let title = title_from(&long, 10);
3474 assert_eq!(title.chars().count(), 10);
3475 assert!(title.ends_with('…'));
3476 }
3477
3478 #[test]
3479 fn priority_wins_and_ties_break_oldest_first() {
3480 let (_dir, q) = queue();
3481 let mut a = task("first");
3482 let mut b = task("second");
3483 let mut c = task("urgent");
3484 a.id = "20260101-000001-aaaa".to_owned();
3486 b.id = "20260101-000002-bbbb".to_owned();
3487 c.id = "20260101-000003-cccc".to_owned();
3488 c.priority = 5;
3489 for t in [&mut a, &mut b, &mut c] {
3490 q.put(t).unwrap();
3491 }
3492
3493 assert_eq!(q.next_runnable().unwrap().id, c.id);
3495 c.hold_machine(None);
3496 q.put(&mut c).unwrap();
3497 assert_eq!(q.next_runnable().unwrap().id, a.id);
3499 assert_eq!(q.list().len(), 3, "b is still waiting its turn");
3500 }
3501
3502 #[test]
3503 fn a_blocked_task_never_starves_another_runnable_one() {
3504 let (_dir, q) = queue();
3505 let mut blocked = task("blocked");
3506 blocked.block(vec!["something".to_owned()], None);
3507 q.put(&mut blocked).unwrap();
3508
3509 let mut runnable = task("free to go");
3510 q.put(&mut runnable).unwrap();
3511
3512 let next = q.next_runnable().expect("a runnable task is still offered");
3513 assert_eq!(next.id, runnable.id);
3514 }
3515
3516 #[test]
3517 fn a_held_task_is_never_offered_to_the_loop() {
3518 let (_dir, q) = queue();
3519 let mut t = task("held");
3520 q.put(&mut t).unwrap();
3521 assert!(q.next_runnable().is_some());
3522
3523 t.hold_machine(None);
3524 q.put(&mut t).unwrap();
3525 assert!(
3526 q.next_runnable().is_none(),
3527 "a held task must wait for a human"
3528 );
3529
3530 t.status = TaskStatus::Failed;
3532 q.put(&mut t).unwrap();
3533 assert!(q.next_runnable().is_some());
3534 }
3535
3536 #[test]
3537 fn attempts_are_capped_and_then_the_task_is_held() {
3538 let mut t = task("doomed");
3539
3540 t.start("run-1".to_owned());
3541 t.fail("gate red", 2);
3542 assert_eq!(t.status, TaskStatus::Failed, "one attempt of two: retry");
3543
3544 t.start("run-2".to_owned());
3545 t.fail("gate red", 2);
3546 assert_eq!(
3547 t.status,
3548 TaskStatus::Held,
3549 "out of attempts: stop spending money on it"
3550 );
3551 assert_eq!(t.runs, ["run-1", "run-2"]);
3552 assert_eq!(t.last_error.as_deref(), Some("gate red"));
3553 assert_eq!(
3554 t.hold_reason.as_deref(),
3555 Some("gate red"),
3556 "the hold must say why, not leave hold_reason null next to a \
3557 populated last_error"
3558 );
3559 }
3560
3561 #[test]
3562 fn handing_off_a_task_records_a_hold_reason_too() {
3563 let mut t = task("left a pull request");
3564 t.start("run-1".to_owned());
3565 t.handed_off("run ended with a pull request open [run run-1]");
3566 assert_eq!(t.status, TaskStatus::Held);
3567 assert_eq!(t.hold_source, Some(HoldSource::Machine));
3568 assert_eq!(
3569 t.hold_reason.as_deref(),
3570 Some("run ended with a pull request open [run run-1]")
3571 );
3572 assert_eq!(t.hold_reason, t.last_error);
3573 }
3574
3575 #[test]
3576 fn a_quota_stall_is_refunded_so_the_backlog_survives_the_night() {
3577 let mut t = task("stalled by quota");
3578
3579 t.start("run-1".to_owned());
3580 assert_eq!(t.attempts, 1);
3581 t.stall("judge-1, judge-2 out of quota");
3582 assert_eq!(
3583 t.attempts, 0,
3584 "a closed quota window must not spend the task's retry budget"
3585 );
3586 assert_eq!(t.status, TaskStatus::Failed, "the loop should retry it");
3587 assert_eq!(
3588 t.last_error.as_deref(),
3589 Some("judge-1, judge-2 out of quota")
3590 );
3591
3592 for _ in 0..20 {
3595 t.start("run-n".to_owned());
3596 t.stall("still out of quota");
3597 }
3598 t.start("run-real".to_owned());
3599 t.fail("gate red", 2);
3600 assert_eq!(
3601 t.status,
3602 TaskStatus::Failed,
3603 "the first attempt that was really judged is attempt one"
3604 );
3605 }
3606
3607 #[test]
3608 fn releasing_a_held_task_gives_it_a_real_second_chance() {
3609 let mut t = task("retry me");
3610 t.start("run-1".to_owned());
3611 t.fail("gate red", 1);
3612 assert_eq!(t.status, TaskStatus::Held);
3613
3614 t.release();
3615 assert_eq!(t.status, TaskStatus::Queued);
3616 assert_eq!(t.attempts, 0);
3619 assert!(t.last_error.is_none());
3620 assert_eq!(
3621 t.runs.len(),
3622 1,
3623 "history is kept: attempts reset, evidence does not"
3624 );
3625 }
3626
3627 #[test]
3628 fn a_hold_reason_survives_and_a_release_clears_it() {
3629 let mut t = task("waiting on something else");
3630 t.hold_manual(Some(
3631 "waiting for 20260101-000000-aaaa to land first".to_owned(),
3632 ));
3633 assert_eq!(t.status, TaskStatus::Held);
3634 assert_eq!(
3635 t.hold_reason.as_deref(),
3636 Some("waiting for 20260101-000000-aaaa to land first")
3637 );
3638
3639 t.hold_manual(None);
3641 assert_eq!(
3642 t.hold_reason.as_deref(),
3643 Some("waiting for 20260101-000000-aaaa to land first"),
3644 "a bare re-hold keeps whatever a human already wrote down"
3645 );
3646
3647 let mut plain = task("no reason given");
3649 plain.hold_manual(None);
3650 assert_eq!(plain.status, TaskStatus::Held);
3651 assert!(plain.hold_reason.is_none());
3652
3653 t.release();
3654 assert_eq!(t.status, TaskStatus::Queued);
3655 assert!(
3656 t.hold_reason.is_none(),
3657 "a stale reason must not greet the next person who holds this task"
3658 );
3659 }
3660
3661 #[test]
3662 fn closing_a_held_task_as_done_clears_its_hold_reason_too() {
3663 let mut t = task("landed by hand while held");
3668 t.hold_manual(Some("waiting on 3ed9".to_owned()));
3669 assert_eq!(t.hold_reason.as_deref(), Some("waiting on 3ed9"));
3670
3671 t.succeed();
3672 assert_eq!(t.status, TaskStatus::Done);
3673 assert!(
3674 t.hold_reason.is_none(),
3675 "a done task cannot still be waiting on something"
3676 );
3677 }
3678
3679 #[test]
3680 fn holding_or_closing_a_blocked_task_clears_its_dependency_too() {
3681 let mut held = task("held straight out of blocked");
3688 held.block(
3689 vec!["20260101-000000-dead".to_owned()],
3690 Some("waiting on the migration script".to_owned()),
3691 );
3692 assert_eq!(held.status, TaskStatus::Blocked);
3693
3694 held.hold_manual(None);
3695 assert_eq!(held.status, TaskStatus::Held);
3696 assert!(
3697 held.blocked_by.is_empty(),
3698 "hold overrides the wait, same as release"
3699 );
3700 assert!(held.block_reason.is_none());
3701
3702 let mut done = task("closed straight out of blocked");
3703 done.block(
3704 vec!["20260101-000000-dead".to_owned()],
3705 Some("waiting on the migration script".to_owned()),
3706 );
3707 done.succeed();
3708 assert_eq!(done.status, TaskStatus::Done);
3709 assert!(
3710 done.blocked_by.is_empty(),
3711 "a done task cannot still be waiting on a dependency"
3712 );
3713 assert!(done.block_reason.is_none());
3714 }
3715
3716 #[test]
3717 fn a_blocked_task_is_never_offered_to_the_loop() {
3718 let mut t = task("blocked");
3719 assert!(t.status.runnable());
3720 t.block(
3721 vec!["dep-id".to_owned()],
3722 Some("waits on dep-id".to_owned()),
3723 );
3724 assert_eq!(t.status, TaskStatus::Blocked);
3725 assert!(!t.status.runnable());
3726 assert_eq!(TaskStatus::Blocked.as_str(), "blocked");
3727 }
3728
3729 #[test]
3730 fn unblocking_the_last_dependency_returns_the_task_to_queued() {
3731 let mut t = task("blocked on two");
3732 t.block(
3733 vec!["a".to_owned(), "b".to_owned()],
3734 Some("waits on a and b".to_owned()),
3735 );
3736
3737 t.unblock("a");
3738 assert_eq!(t.status, TaskStatus::Blocked, "b is still outstanding");
3739 assert_eq!(t.blocked_by, ["b"]);
3740
3741 t.unblock("b");
3742 assert_eq!(t.status, TaskStatus::Queued);
3743 assert!(t.blocked_by.is_empty());
3744 assert!(t.block_reason.is_none());
3745 }
3746
3747 #[test]
3748 fn unblocking_an_id_on_a_task_that_is_not_blocked_is_a_no_op() {
3749 let mut t = task("never blocked");
3750 t.unblock("whatever");
3751 assert_eq!(t.status, TaskStatus::Queued);
3752 }
3753
3754 #[test]
3755 fn a_held_task_blocked_on_a_question_returns_to_held_not_queued() {
3756 let mut t = task("held, then asked about");
3761 t.hold_machine(Some("out of attempts".to_owned()));
3762 assert_eq!(t.status, TaskStatus::Held);
3763
3764 t.block(vec!["q1".to_owned()], Some("what now?".to_owned()));
3765 assert_eq!(t.status, TaskStatus::Blocked);
3766
3767 t.record_answer("what now?".to_owned(), "leave it held".to_owned());
3768 t.unblock("q1");
3769 assert_eq!(t.status, TaskStatus::Held, "must restore, not requeue");
3770 assert_eq!(t.hold_reason.as_deref(), Some("out of attempts"));
3771 assert_eq!(t.hold_source, Some(HoldSource::Machine));
3772 assert!(t.blocked_from.is_none(), "consumed once restored");
3773 }
3774
3775 #[test]
3776 fn a_manually_held_task_blocked_on_a_question_returns_to_held() {
3777 let mut t = task("manually held, then asked about");
3778 t.hold_manual(Some("waiting on a dependency".to_owned()));
3779
3780 t.block(vec!["q1".to_owned()], None);
3781 t.unblock("q1");
3782
3783 assert_eq!(t.status, TaskStatus::Held);
3784 assert_eq!(t.hold_source, Some(HoldSource::Manual));
3785 }
3786
3787 #[test]
3788 fn re_blocking_an_already_blocked_task_keeps_the_original_blocked_from() {
3789 let mut t = task("held, blocked twice");
3793 t.hold_machine(None);
3794 t.block(vec!["q1".to_owned()], Some("first".to_owned()));
3795 t.block(
3796 vec!["q1".to_owned(), "q2".to_owned()],
3797 Some("second".to_owned()),
3798 );
3799
3800 t.unblock("q1");
3801 assert_eq!(t.status, TaskStatus::Blocked, "q2 still outstanding");
3802 t.unblock("q2");
3803 assert_eq!(t.status, TaskStatus::Held);
3804 }
3805
3806 #[test]
3807 fn unblocking_a_task_blocked_while_running_lands_on_queued_not_running() {
3808 let mut t = task("blocked mid-run");
3812 t.start("run-1".to_owned());
3813 assert_eq!(t.status, TaskStatus::Running);
3814
3815 t.block(vec!["q1".to_owned()], None);
3816 t.unblock("q1");
3817 assert_eq!(t.status, TaskStatus::Queued);
3818 }
3819
3820 #[test]
3821 fn a_pre_schema_4_blocked_record_with_hold_evidence_restores_to_held() {
3822 let mut t = task("legacy record, held before it was blocked");
3828 t.hold_source = Some(HoldSource::Machine);
3829 t.hold_reason = Some("legacy hold reason".to_owned());
3830 t.status = TaskStatus::Blocked;
3831 t.blocked_by = vec!["q1".to_owned()];
3832 t.blocked_from = None;
3833
3834 t.unblock("q1");
3835 assert_eq!(t.status, TaskStatus::Held);
3836 }
3837
3838 #[test]
3839 fn a_pre_schema_4_blocked_record_with_no_hold_evidence_restores_to_queued() {
3840 let mut t = task("legacy record, ordinary dependency block");
3841 t.status = TaskStatus::Blocked;
3842 t.blocked_by = vec!["dep".to_owned()];
3843 t.blocked_from = None;
3844
3845 t.unblock("dep");
3846 assert_eq!(t.status, TaskStatus::Queued);
3847 }
3848
3849 #[test]
3850 fn answering_a_question_is_recorded_and_survives_a_release() {
3851 let mut t = task("asked something");
3852 t.block(vec!["q1".to_owned()], Some("which backend?".to_owned()));
3853 t.record_answer("Which backend?".to_owned(), "SQLite".to_owned());
3854 t.unblock("q1");
3855 assert_eq!(t.status, TaskStatus::Queued);
3856 assert_eq!(t.answers.len(), 1);
3857 assert_eq!(t.answers[0].answer, "SQLite");
3858
3859 t.release();
3863 assert_eq!(t.answers.len(), 1, "the answer is not lost on release");
3864 }
3865
3866 #[test]
3867 fn resumption_review_branch_requires_unchanged_authored_criteria() {
3868 for source in ["cli", "web"] {
3869 let mut t = task("review old candidate");
3870 t.request_review("candidate-A".into());
3871 t.hold_for_handover(Some("candidate-A".into()), "handover refused".into());
3872 let mut unchanged = t.clone();
3873 unchanged.release_operator(source, None);
3874 assert_eq!(unchanged.review_branch.as_deref(), Some("candidate-A"));
3875 t.edit(
3876 "review approved head".into(),
3877 "Reuse measured PR head B; fix Clippy; review".into(),
3878 )
3879 .unwrap();
3880 t.release_operator(source, None);
3881 assert!(t.review_branch.is_none());
3882 assert!(t.review_instruction.is_none());
3883 let mut legacy = unchanged;
3885 legacy.review_instruction = None;
3886 legacy.hold_for_handover(None, "still checked out".into());
3887 legacy
3888 .edit("new plan".into(), "Reuse measured PR head B".into())
3889 .unwrap();
3890 legacy.release_operator(source, None);
3891 assert!(legacy.review_branch.is_none());
3892 }
3893 }
3894
3895 #[test]
3896 fn a_refused_handover_keeps_the_review_branch_across_release() {
3897 let mut t = task("refused takeover");
3898 t.start("run-1".to_owned());
3899 t.hold_for_handover(Some("magi/eba2/A".to_owned()), "checked out".to_owned());
3900 assert_eq!(t.status, TaskStatus::Held);
3901 assert_eq!(t.attempts, 1);
3902 t.release();
3903 assert_eq!(t.status, TaskStatus::Queued);
3904 assert_eq!(t.attempts, 0);
3905 assert_eq!(t.review_branch.as_deref(), Some("magi/eba2/A"));
3906
3907 t.hold_for_handover(None, "again".to_owned());
3909 t.requeue();
3910 assert!(t.review_branch.is_none());
3911
3912 let mut m = task("manual");
3914 m.review_branch = Some("magi/x/A".to_owned());
3915 m.hold_manual(None);
3916 m.release();
3917 assert!(m.review_branch.is_none());
3918 }
3919
3920 #[test]
3921 fn requesting_review_requeues_the_task_and_remembers_the_branch() {
3922 let mut t = task("blocked run with a surviving branch");
3923 t.start("run-1".to_owned());
3924 t.fail("blocked with major findings", 5);
3925 assert_eq!(t.status, TaskStatus::Failed);
3926
3927 t.request_review("magi/eba2/A".to_owned());
3928 assert_eq!(t.status, TaskStatus::Queued);
3929 assert_eq!(t.attempts, 0);
3930 assert_eq!(t.review_branch.as_deref(), Some("magi/eba2/A"));
3931
3932 t.release();
3934 assert!(t.review_branch.is_none());
3935 }
3936
3937 #[test]
3938 fn conductor_requeue_but_not_an_ordinary_release_forces_a_fresh_start() {
3939 let mut t = task("retry");
3940 t.start("run-1".to_owned());
3941 t.requeue();
3942 assert!(t.fresh_start);
3943
3944 t.release();
3945 assert!(!t.fresh_start);
3946 }
3947
3948 #[test]
3949 fn urgent_only_changes_queued_or_held_and_preserves_settings() {
3950 for status in [
3951 TaskStatus::Queued,
3952 TaskStatus::Held,
3953 TaskStatus::Running,
3954 TaskStatus::Done,
3955 TaskStatus::Failed,
3956 TaskStatus::Blocked,
3957 TaskStatus::Parked,
3958 ] {
3959 let mut t = task("urgent settings");
3960 t.status = status;
3961 t.attempts = 2;
3962 t.hold_reason = Some("waiting".into());
3963 t.priority = 7;
3964 t.interrupt = true;
3965 let before = serde_json::to_value(&t).unwrap();
3966 for urgent in [true, true, false, false] {
3967 let result = t.set_urgent(urgent);
3968 let mut expected = before.clone();
3969 if matches!(status, TaskStatus::Queued | TaskStatus::Held) {
3970 result.unwrap();
3971 expected["urgent"] = urgent.into();
3972 } else {
3973 let error = result.unwrap_err().to_string();
3974 assert!(error.contains(status.as_str()), "{error}");
3975 assert!(error.contains("queued or held"), "{error}");
3976 }
3977 assert_eq!(serde_json::to_value(&t).unwrap(), expected);
3978 }
3979 }
3980 }
3981
3982 #[test]
3983 fn priority_can_be_changed_while_queued_but_not_while_running() {
3984 let mut t = task("reprioritise me");
3985 t.set_priority(5).unwrap();
3986 assert_eq!(t.priority, 5);
3987
3988 t.start("run-1".to_owned());
3989 let err = t.set_priority(9).unwrap_err().to_string();
3990 assert!(err.contains("running"), "{err}");
3991 assert_eq!(t.priority, 5, "the rejected write must not partially apply");
3992 }
3993
3994 #[test]
3995 fn interrupt_can_be_marked_while_queued_but_not_while_running() {
3996 let mut t = task("interrupt me");
3997 assert!(!t.interrupt, "off unless asked, same as any other task");
3998
3999 t.set_interrupt(true).unwrap();
4000 assert!(t.interrupt);
4001
4002 t.start("run-1".to_owned());
4003 assert!(
4004 !t.interrupt,
4005 "the mark is one-shot: dispatching the task fulfils it, \
4006 whatever the run that follows ends up doing"
4007 );
4008 let err = t.set_interrupt(true).unwrap_err().to_string();
4009 assert!(err.contains("running"), "{err}");
4010 t.set_interrupt(false).unwrap();
4013 assert!(!t.interrupt);
4014 }
4015
4016 #[test]
4020 fn a_failed_run_does_not_leave_the_task_still_marked_to_interrupt() {
4021 let mut t = task("interrupt me");
4022 t.set_interrupt(true).unwrap();
4023 t.start("run-1".to_owned());
4024 t.fail("mock failure", 5);
4025 assert_eq!(t.status, TaskStatus::Failed);
4026 assert!(
4027 !t.interrupt,
4028 "one attempt already spent the mark; a retry is an ordinary \
4029 requeue, not a fresh interrupt request"
4030 );
4031 }
4032
4033 #[test]
4034 fn changing_priority_moves_a_task_ahead_in_the_real_queue_order() {
4035 let (_dir, q) = queue();
4036 let mut a = task("first filed");
4037 let mut b = task("second filed");
4038 a.id = "20260101-000001-aaaa".to_owned();
4039 b.id = "20260101-000002-bbbb".to_owned();
4040 q.put(&mut a).unwrap();
4041 q.put(&mut b).unwrap();
4042
4043 assert_eq!(
4044 q.next_runnable().unwrap().id,
4045 a.id,
4046 "with equal priority the older task goes first, so a burst of \
4047 new work cannot starve it"
4048 );
4049 assert_eq!(
4050 q.list()[0].id,
4051 b.id,
4052 "but the list an operator reads is newest first, the same as \
4053 before priority existed - a's turn to run does not make it the \
4054 newest task"
4055 );
4056
4057 let mut a = q.get(&a.id).unwrap();
4058 a.set_priority(10).unwrap();
4059 q.put(&mut a).unwrap();
4060
4061 assert_eq!(
4062 q.next_runnable().unwrap().id,
4063 a.id,
4064 "a raised priority must be reflected the moment it is saved"
4065 );
4066 assert_eq!(
4070 q.list()[0].id,
4071 a.id,
4072 "the raised task must sort first in the list an operator reads, \
4073 not only in next_runnable's own ordering"
4074 );
4075 }
4076
4077 #[test]
4078 fn editing_replaces_title_and_instruction_but_keeps_identity_and_history() {
4079 let mut t = Task::new(
4080 "old title".to_owned(),
4081 "old instruction".to_owned(),
4082 PathBuf::from("/repo"),
4083 Source::Agent {
4084 run: "20260101-000000-beef".to_owned(),
4085 node: "implement".to_owned(),
4086 },
4087 );
4088 let id = t.id.clone();
4089 let created_at = t.created_at;
4090 t.runs.push("20260101-000000-beef".to_owned());
4091
4092 t.edit("new title".to_owned(), "new instruction".to_owned())
4093 .unwrap();
4094
4095 assert_eq!(t.title, "new title");
4096 assert_eq!(t.instruction, "new instruction");
4097 assert_eq!(t.id, id, "editing must not mint a new id");
4098 assert_eq!(t.created_at, created_at);
4099 assert_eq!(
4100 t.source,
4101 Source::Agent {
4102 run: "20260101-000000-beef".to_owned(),
4103 node: "implement".to_owned(),
4104 },
4105 "editing must not turn agent attribution into human"
4106 );
4107 assert_eq!(t.runs, ["20260101-000000-beef"]);
4108 }
4109
4110 #[test]
4111 fn editing_is_refused_once_a_task_is_running_or_finished() {
4112 let mut running = task("in flight");
4113 running.start("run-1".to_owned());
4114 let err = running
4115 .edit("x".to_owned(), "y".to_owned())
4116 .unwrap_err()
4117 .to_string();
4118 assert!(err.contains("running"), "{err}");
4119
4120 let mut done = task("finished");
4121 done.succeed();
4122 let err = done
4123 .edit("x".to_owned(), "y".to_owned())
4124 .unwrap_err()
4125 .to_string();
4126 assert!(err.contains("done"), "{err}");
4127
4128 let mut queued = task("waiting");
4130 queued.edit("x".to_owned(), "y".to_owned()).unwrap();
4131 let mut held = task("parked");
4132 held.hold_machine(None);
4133 held.edit("x".to_owned(), "y".to_owned()).unwrap();
4134 }
4135
4136 #[test]
4137 fn a_task_recorded_without_a_hold_reason_still_reads_as_none() {
4138 let (_dir, q) = queue();
4139 let path = q.path_of("20260101-000000-aaaa");
4140 std::fs::create_dir_all(q.root()).unwrap();
4141 std::fs::write(
4142 &path,
4143 serde_json::json!({
4144 "schema": SCHEMA,
4145 "id": "20260101-000000-aaaa",
4146 "title": "from before hold reasons existed",
4147 "instruction": "from before hold reasons existed",
4148 "repo": ".",
4149 "source": { "kind": "human" },
4150 "status": "held",
4151 "created_at": Timestamp::now().to_string(),
4152 "updated_at": Timestamp::now().to_string(),
4153 })
4154 .to_string(),
4155 )
4156 .unwrap();
4157
4158 let task = q.get("20260101-000000-aaaa").expect("must still read");
4159 assert!(task.hold_reason.is_none());
4160 assert!(task.operator_held());
4161 }
4162
4163 #[test]
4164 fn a_legacy_reasoned_hold_defaults_to_operator_protection() {
4165 let (_dir, q) = queue();
4166 let path = q.path_of("20260101-000000-bbbb");
4167 std::fs::create_dir_all(q.root()).unwrap();
4168 std::fs::write(
4169 &path,
4170 serde_json::json!({
4171 "schema": 2,
4172 "id": "20260101-000000-bbbb",
4173 "title": "old manual recovery",
4174 "instruction": "old manual recovery",
4175 "repo": ".",
4176 "source": { "kind": "human" },
4177 "status": "held",
4178 "hold_reason": "active manual recovery run20260912-224242-daf5",
4179 "created_at": Timestamp::now().to_string(),
4180 "updated_at": Timestamp::now().to_string(),
4181 })
4182 .to_string(),
4183 )
4184 .unwrap();
4185
4186 let task = q.get("20260101-000000-bbbb").expect("must still read");
4187 assert_eq!(task.hold_source, None);
4188 assert!(task.operator_held());
4189 }
4190
4191 #[test]
4192 fn a_task_recorded_without_a_diagnostic_still_reads_as_none() {
4193 let (_dir, q) = queue();
4194 let path = q.path_of("20260101-000000-aaaa");
4195 std::fs::create_dir_all(q.root()).unwrap();
4196 std::fs::write(
4197 &path,
4198 serde_json::json!({
4199 "schema": SCHEMA,
4200 "id": "20260101-000000-aaaa",
4201 "title": "from before diagnostics existed",
4202 "instruction": "from before diagnostics existed",
4203 "repo": ".",
4204 "source": { "kind": "human" },
4205 "status": "held",
4206 "created_at": Timestamp::now().to_string(),
4207 "updated_at": Timestamp::now().to_string(),
4208 })
4209 .to_string(),
4210 )
4211 .unwrap();
4212
4213 let task = q.get("20260101-000000-aaaa").expect("must still read");
4214 assert!(task.diagnostic.is_none());
4215 }
4216
4217 #[test]
4218 fn a_schema_1_task_with_no_blocking_fields_still_reads() {
4219 let (_dir, q) = queue();
4223 let path = q.path_of("20260101-000000-aaaa");
4224 std::fs::create_dir_all(q.root()).unwrap();
4225 std::fs::write(
4226 &path,
4227 serde_json::json!({
4228 "schema": 1,
4229 "id": "20260101-000000-aaaa",
4230 "title": "from before blocking existed",
4231 "instruction": "from before blocking existed",
4232 "repo": ".",
4233 "source": { "kind": "human" },
4234 "status": "queued",
4235 "created_at": Timestamp::now().to_string(),
4236 "updated_at": Timestamp::now().to_string(),
4237 })
4238 .to_string(),
4239 )
4240 .unwrap();
4241
4242 let task = q.get("20260101-000000-aaaa").expect("must still read");
4243 assert!(task.blocked_by.is_empty());
4244 assert!(task.block_reason.is_none());
4245 assert!(task.answers.is_empty());
4246 assert!(task.review_branch.is_none());
4247 }
4248
4249 #[test]
4250 fn releasing_or_finishing_a_task_clears_its_stale_diagnostic() {
4251 let mut held = task("diagnosed");
4256 held.start("run-1".to_owned());
4257 held.fail("gate red", 1);
4258 held.diagnostic = Some("cargo test failed: ...".to_owned());
4259 assert_eq!(held.status, TaskStatus::Held);
4260
4261 held.release();
4262 assert!(held.diagnostic.is_none());
4263
4264 held.diagnostic = Some("cargo test failed: ...".to_owned());
4265 held.succeed();
4266 assert!(held.diagnostic.is_none());
4267 }
4268
4269 #[test]
4270 fn failing_a_task_always_clears_whatever_diagnostic_it_carried() {
4271 let mut t = task("retried");
4272 t.start("run-1".to_owned());
4273 t.diagnostic = Some("stale evidence from a previous hold".to_owned());
4274 t.fail("unrelated config error", 5);
4275 assert_eq!(t.status, TaskStatus::Failed);
4276 assert!(
4277 t.diagnostic.is_none(),
4278 "fail() must not let an old diagnostic outlive the run that produced it"
4279 );
4280 }
4281
4282 #[test]
4283 fn a_claim_is_exclusive_and_releases_on_drop() {
4284 let (_dir, q) = queue();
4285 let mut t = task("contended");
4286 q.put(&mut t).unwrap();
4287
4288 let held = q.claim(&t.id).unwrap();
4289 assert!(
4290 q.claim(&t.id).is_err(),
4291 "two daemons must not drive one task into two runs"
4292 );
4293 drop(held);
4294 assert!(q.claim(&t.id).is_ok(), "a released claim is reclaimable");
4295 }
4296
4297 #[test]
4298 fn a_round_trip_survives_disk() {
4299 let (_dir, q) = queue();
4300 let mut t = Task::new(
4301 "titled".to_owned(),
4302 "body".to_owned(),
4303 PathBuf::from("/repo"),
4304 Source::Agent {
4305 run: "20260101-000000-beef".to_owned(),
4306 node: "implement".to_owned(),
4307 },
4308 );
4309 t.priority = 3;
4310 q.put(&mut t).unwrap();
4311
4312 let back = q.get(&t.id).unwrap();
4313 assert_eq!(back.id, t.id);
4314 assert_eq!(back.priority, 3);
4315 assert_eq!(back.source.label(), "implement@beef");
4316 assert_eq!(q.get(t.short()).unwrap().id, t.id);
4318 }
4319
4320 #[test]
4321 fn an_unreadable_task_does_not_take_the_queue_down() {
4322 let (_dir, q) = queue();
4323 let mut t = task("fine");
4324 q.put(&mut t).unwrap();
4325 std::fs::write(q.root().join("broken.json"), "{ not json").unwrap();
4326
4327 let listed = q.list();
4328 assert_eq!(listed.len(), 1, "the readable task still lists");
4329 assert_eq!(listed[0].id, t.id);
4330 }
4331
4332 #[test]
4333 fn a_task_recorded_without_a_solo_field_still_reads_as_not_solo() {
4334 let (_dir, q) = queue();
4335 let path = q.path_of("20260101-000000-aaaa");
4336 std::fs::create_dir_all(q.root()).unwrap();
4337 std::fs::write(
4338 &path,
4339 serde_json::json!({
4340 "schema": SCHEMA,
4341 "id": "20260101-000000-aaaa",
4342 "title": "from before solo existed",
4343 "instruction": "from before solo existed",
4344 "repo": ".",
4345 "source": { "kind": "human" },
4346 "status": "queued",
4347 "created_at": Timestamp::now().to_string(),
4348 "updated_at": Timestamp::now().to_string(),
4349 })
4350 .to_string(),
4351 )
4352 .unwrap();
4353
4354 let task = q.get("20260101-000000-aaaa").expect("must still read");
4355 assert!(!task.solo, "a queue file with no `solo` field means false");
4356 }
4357
4358 #[test]
4359 fn a_task_recorded_without_an_urgent_field_still_reads_as_not_urgent() {
4360 let (_dir, q) = queue();
4361 let path = q.path_of("20260101-000000-bbbb");
4362 std::fs::create_dir_all(q.root()).unwrap();
4363 std::fs::write(
4364 &path,
4365 serde_json::json!({
4366 "schema": SCHEMA,
4367 "id": "20260101-000000-bbbb",
4368 "title": "from before urgent existed",
4369 "instruction": "from before urgent existed",
4370 "repo": ".",
4371 "source": { "kind": "human" },
4372 "status": "queued",
4373 "created_at": Timestamp::now().to_string(),
4374 "updated_at": Timestamp::now().to_string(),
4375 })
4376 .to_string(),
4377 )
4378 .unwrap();
4379
4380 let task = q.get("20260101-000000-bbbb").expect("must still read");
4381 assert!(
4382 !task.urgent,
4383 "a queue file with no `urgent` field means false, same as `solo`"
4384 );
4385 }
4386
4387 #[test]
4388 fn a_task_from_a_future_schema_is_refused_rather_than_guessed_at() {
4389 let (_dir, q) = queue();
4390 let mut t = task("from the future");
4391 q.put(&mut t).unwrap();
4392 let path = q.path_of(&t.id);
4393 let body = std::fs::read_to_string(&path)
4394 .unwrap()
4395 .replace(&format!("\"schema\": {SCHEMA}"), "\"schema\": 99");
4396 std::fs::write(&path, body).unwrap();
4397
4398 let err = q.get(&t.id).unwrap_err().to_string();
4399 assert!(err.contains("schema 99"), "{err}");
4400 }
4401
4402 #[test]
4403 fn revision_moves_when_the_queue_changes() {
4404 let (_dir, q) = queue();
4405 assert_eq!(q.revision(), 0, "an empty queue has no revision");
4406 let mut t = task("first");
4407 q.put(&mut t).unwrap();
4408 assert!(q.revision() > 0, "a written task moves the revision");
4409 }
4410
4411 #[test]
4412 fn revision_moves_when_deleting_an_older_task() {
4413 let (dir, q) = queue();
4414 let questions = Questions::at(dir.path().join("questions"));
4415 let mut t1 = task("older");
4416 q.put(&mut t1).unwrap();
4417 std::thread::sleep(std::time::Duration::from_millis(10));
4419 let mut t2 = task("newer");
4420 q.put(&mut t2).unwrap();
4421
4422 let rev_before = q.revision();
4423 q.remove(&t1.id, false, &questions).unwrap();
4424 let rev_after = q.revision();
4425
4426 assert_ne!(
4427 rev_before, rev_after,
4428 "deleting an older task must change the revision so other clients see the deletion"
4429 );
4430 }
4431
4432 #[test]
4433 fn removing_a_task_takes_it_out_of_the_listing() {
4434 let (dir, q) = queue();
4435 let questions = Questions::at(dir.path().join("questions"));
4436 let mut t = task("delete me");
4437 q.put(&mut t).unwrap();
4438 let removed = q.remove(t.short(), false, &questions).unwrap();
4439 assert_eq!(removed.id, t.id, "a prefix resolves before deleting");
4440 assert!(removed.released.is_empty() && removed.still_blocked.is_empty());
4441 assert!(q.list().is_empty());
4442 assert!(
4443 q.remove(&t.id, false, &questions).is_err(),
4444 "removing twice is an error"
4445 );
4446 }
4447
4448 #[test]
4449 fn removing_a_task_takes_its_stale_lock_with_it() {
4450 let (dir, q) = queue();
4451 let questions = Questions::at(dir.path().join("questions"));
4452 let mut t = task("interrupted");
4453 q.put(&mut t).unwrap();
4454
4455 let claim = q.claim(&t.id).unwrap();
4458 std::mem::forget(claim);
4459 assert!(
4460 q.claim(&t.id).is_err(),
4461 "the orphaned lock is what makes the task look claimed"
4462 );
4463
4464 let err = q.remove(&t.id, true, &questions).unwrap_err().to_string();
4466 assert!(err.contains("live daemon"), "{err}");
4467 assert!(q.get(&t.id).is_ok(), "a refused delete keeps the task");
4468
4469 q.remove(&t.id, false, &questions).unwrap();
4471 assert!(q.list().is_empty());
4472 let mut again = task("interrupted");
4473 again.id = t.id.clone();
4474 q.put(&mut again).unwrap();
4475 assert!(
4476 q.claim(&t.id).is_ok(),
4477 "a task that comes back must be claimable, which a left-behind lock would prevent"
4478 );
4479 }
4480
4481 fn notices_of(dir: &Path) -> Vec<crate::notices::Notice> {
4482 crate::notices::Notices::at(dir.join("notifications")).list()
4483 }
4484
4485 #[test]
4486 fn removing_a_sole_dependency_releases_the_dependent_without_a_hold() {
4487 let (dir, q) = queue();
4488 let questions = Questions::at(dir.path().join("questions"));
4489 let mut dep = task("dependency");
4490 q.put(&mut dep).unwrap();
4491 let mut blocked = task("waiting");
4492 blocked.block(vec![dep.id.clone()], Some("waits".to_owned()));
4493 q.put(&mut blocked).unwrap();
4494
4495 let removed = q.remove(&dep.id, false, &questions).unwrap();
4496 assert_eq!(removed.released, [blocked.id.clone()]);
4497 assert!(removed.still_blocked.is_empty());
4498
4499 let after = q.get(&blocked.id).unwrap();
4500 assert_eq!(after.status, TaskStatus::Queued);
4501 assert!(after.blocked_by.is_empty());
4502 assert!(after.block_reason.is_none());
4503 let notes = notices_of(dir.path());
4504 assert_eq!(notes.len(), 1, "{notes:?}");
4505 assert_eq!(notes[0].severity, crate::notices::Severity::Info);
4506 assert_eq!(
4507 notes[0].link,
4508 Some(crate::notices::Link::Task {
4509 id: blocked.id.clone(),
4510 })
4511 );
4512 }
4513
4514 #[test]
4515 fn removing_one_of_two_dependencies_keeps_the_other() {
4516 let (dir, q) = queue();
4517 let questions = Questions::at(dir.path().join("questions"));
4518 let mut dep = task("dependency");
4519 q.put(&mut dep).unwrap();
4520 let mut other = task("other");
4521 q.put(&mut other).unwrap();
4522 let mut blocked = task("waiting");
4523 blocked.block(
4524 vec![dep.id.clone(), other.id.clone()],
4525 Some("waits on both".to_owned()),
4526 );
4527 q.put(&mut blocked).unwrap();
4528
4529 let removed = q.remove(&dep.id, false, &questions).unwrap();
4530 assert!(removed.released.is_empty());
4531 assert_eq!(removed.still_blocked, [blocked.id.clone()]);
4532
4533 let after = q.get(&blocked.id).unwrap();
4534 assert_eq!(after.status, TaskStatus::Blocked);
4535 assert_eq!(after.blocked_by, [other.id.clone()]);
4536 assert!(missing_blockers(&q, &questions, &after.blocked_by).is_empty());
4539 }
4540
4541 #[test]
4542 fn a_dependency_deleted_before_its_dependents_were_rewritten_is_released_later() {
4543 let (dir, q) = queue();
4544 let questions = Questions::at(dir.path().join("questions"));
4545 let mut dep = task("dependency");
4546 q.put(&mut dep).unwrap();
4547 let mut blocked = task("waiting");
4548 blocked.block(vec![dep.id.clone()], None);
4549 q.put(&mut blocked).unwrap();
4550
4551 let claim = q.claim(&blocked.id).unwrap();
4553 let removed = q.remove(&dep.id, false, &questions).unwrap();
4554 assert!(removed.released.is_empty());
4555 drop(claim);
4556
4557 let mut task = q.get(&blocked.id).unwrap();
4558 assert_eq!(task.status, TaskStatus::Blocked);
4559 assert!(missing_blockers(&q, &questions, &task.blocked_by).is_empty());
4560 assert_eq!(
4561 q.apply_deleted_blockers(&mut task, &questions),
4562 [dep.id.clone()]
4563 );
4564 assert_eq!(task.status, TaskStatus::Queued);
4565 }
4566
4567 #[test]
4568 fn dependency_resolution_short_deleted_reference_releases_only_its_waiters() {
4569 let (dir, q) = queue();
4570 let questions = Questions::at(dir.path().join("questions"));
4571 let mut dep = task("dependency");
4572 dep.id = "20261013-193611-ca5b".into();
4573 q.put(&mut dep).unwrap();
4574 let mut waiting = task("waiting");
4575 waiting.block(vec!["ca5b".into()], None);
4576 q.put(&mut waiting).unwrap();
4577 q.remove(&dep.id, false, &questions).unwrap();
4578 assert_eq!(q.get(&waiting.id).unwrap().status, TaskStatus::Queued);
4579 assert_eq!(
4580 dependency(&q, &questions, "ca5b"),
4581 Dependency::Deleted(dep.id)
4582 );
4583 }
4584
4585 #[test]
4586 fn dependency_resolution_preserves_cross_store_ambiguity_and_exact_ids() {
4587 let (dir, queue) = queue();
4588 let questions = Questions::at(dir.path().join("questions"));
4589 let mut dep = task("dependency");
4590 dep.id = "20261013-193611-ca5b".into();
4591 queue.put(&mut dep).unwrap();
4592 std::fs::create_dir_all(questions.root()).unwrap();
4593 let question_id = "20261014-193611-ca5b";
4594 std::fs::write(questions.path_of(question_id), "unreadable question").unwrap();
4595 assert_eq!(questions.resolve_id("ca5b").unwrap(), question_id);
4596 assert_eq!(
4597 dependency(&queue, &questions, "ca5b"),
4598 Dependency::Ambiguous
4599 );
4600 assert_eq!(
4601 dependency(&queue, &questions, &dep.id),
4602 Dependency::Task(dep.id)
4603 );
4604 assert_eq!(
4605 dependency(&queue, &questions, question_id),
4606 Dependency::Question(question_id.into())
4607 );
4608 assert_eq!(
4609 dependency(&queue, &questions, "missing"),
4610 Dependency::Missing
4611 );
4612 }
4613
4614 #[test]
4615 fn dependency_resolution_directory_errors_are_unknown_not_missing() {
4616 let dir = tempfile::tempdir().unwrap();
4617 let q = Queue::at(dir.path().join("not-a-directory"));
4618 std::fs::write(q.root(), "file").unwrap();
4619 let questions = Questions::at(dir.path().join("questions"));
4620 assert_eq!(dependency(&q, &questions, "ca5b"), Dependency::Unknown);
4621 assert!(missing_blockers(&q, &questions, &["ca5b".into()]).is_empty());
4622 }
4623
4624 #[test]
4625 fn a_dependency_without_a_tombstone_is_still_missing() {
4626 let (dir, q) = queue();
4627 let questions = Questions::at(dir.path().join("questions"));
4628 let mut dep = task("dependency");
4629 q.put(&mut dep).unwrap();
4630 std::fs::remove_file(q.path_of(&dep.id)).unwrap();
4631 assert_eq!(
4632 missing_blockers(&q, &questions, std::slice::from_ref(&dep.id)),
4633 [dep.id.clone()]
4634 );
4635 assert!(deleted_blockers(&q, &questions, std::slice::from_ref(&dep.id)).is_empty());
4636 }
4637
4638 #[test]
4639 fn a_held_dependent_is_left_alone_by_a_dependency_deletion() {
4640 let mut t = task("held");
4641 t.hold_machine(Some("because".to_owned()));
4642 t.blocked_by = vec!["gone".to_owned()];
4643 assert!(!t.dependency_deleted("gone"));
4644 assert_eq!(t.status, TaskStatus::Held);
4645 }
4646
4647 #[test]
4648 fn a_failed_record_removal_takes_the_tombstone_back() {
4649 let (_dir, q) = queue();
4650 let mut t = task("stays");
4651 q.put(&mut t).unwrap();
4652 q.write_tombstone(&t.id).unwrap();
4653 let err = q.remove_record_with_attachments(&t.id, |_| Err(std::io::Error::other("nope")));
4654 assert!(err.is_err());
4655 assert!(!q.was_deleted(&t.id));
4658 }
4659
4660 fn source_file(dir: &Path, name: &str, body: &str) -> PathBuf {
4661 let p = dir.join(name);
4662 std::fs::write(&p, body).unwrap();
4663 p
4664 }
4665
4666 #[test]
4667 fn an_attachment_copy_survives_deleting_its_source() {
4668 let (dir, q) = queue();
4669 let src = source_file(dir.path(), "shot.png", "pixels");
4670 let mut t = task("with a picture");
4671 let names = q.attach(&mut t, std::slice::from_ref(&src)).unwrap();
4672 q.put(&mut t).unwrap();
4673 std::fs::remove_file(&src).unwrap();
4674 assert_eq!(names, ["shot.png"]);
4675 let loaded = q.get(&t.id).unwrap();
4676 let paths = q.attachment_paths(&loaded);
4677 assert_eq!(paths.len(), 1);
4678 assert_eq!(std::fs::read_to_string(&paths[0]).unwrap(), "pixels");
4679 }
4680
4681 #[test]
4682 fn attachment_names_that_could_traverse_or_are_odd_are_refused() {
4683 let (dir, q) = queue();
4684 let mut t = task("bad names");
4685 for name in ["a..b.png", ".hidden", "with space.png", "-x.png"] {
4686 let src = source_file(dir.path(), name, "x");
4687 assert!(
4688 q.attach(&mut t, &[src]).is_err(),
4689 "`{name}` must be refused"
4690 );
4691 }
4692 assert!(!crate::ask::valid_asset_name("C:foo.png"));
4696 #[cfg(not(windows))]
4697 {
4698 let src = source_file(dir.path(), "C:foo.png", "x");
4699 assert!(q.attach(&mut t, &[src]).is_err());
4700 }
4701 let long = format!("{}.png", "a".repeat(70));
4702 let src = source_file(dir.path(), &long, "x");
4703 assert!(q.attach(&mut t, &[src]).is_err());
4704 assert!(t.attachments.is_empty());
4705 assert!(!q.attachments_dir(&t.id).exists());
4706 }
4707
4708 #[test]
4709 fn a_taken_attachment_name_is_numbered_not_overwritten() {
4710 let (dir, q) = queue();
4711 let a = source_file(dir.path(), "shot.png", "one");
4712 let sub = dir.path().join("other");
4713 std::fs::create_dir_all(&sub).unwrap();
4714 let b = source_file(&sub, "shot.png", "two");
4715 let mut t = task("collision");
4716 q.attach(&mut t, &[a]).unwrap();
4717 q.attach(&mut t, &[b]).unwrap();
4718 assert_eq!(t.attachments, ["shot.png", "shot-2.png"]);
4719 let paths = q.attachment_paths(&t);
4720 assert_eq!(std::fs::read_to_string(&paths[0]).unwrap(), "one");
4721 assert_eq!(std::fs::read_to_string(&paths[1]).unwrap(), "two");
4722 }
4723
4724 #[test]
4725 fn a_renumbered_name_stays_inside_the_length_bound() {
4726 let (dir, q) = queue();
4727 let name = format!("{}.png", "a".repeat(60));
4728 assert_eq!(name.len(), 64);
4729 let a = source_file(dir.path(), &name, "one");
4730 let sub = dir.path().join("other");
4731 std::fs::create_dir_all(&sub).unwrap();
4732 let b = source_file(&sub, &name, "two");
4733 let mut t = task("long");
4734 q.attach(&mut t, &[a, b]).unwrap();
4735 assert_eq!(t.attachments.len(), 2);
4736 assert!(
4737 t.attachments
4738 .iter()
4739 .all(|n| crate::ask::valid_asset_name(n))
4740 );
4741 assert!(t.attachments[1].ends_with("-2.png"));
4742 }
4743
4744 #[test]
4745 fn a_failed_attach_keeps_existing_attachments_and_leaves_no_partial_copy() {
4746 let (dir, q) = queue();
4747 let good = source_file(dir.path(), "good.png", "ok");
4748 let mut t = task("partial");
4749 q.attach(&mut t, &[good]).unwrap();
4750 let more = source_file(dir.path(), "more.png", "ok");
4751 let missing = dir.path().join("missing.png");
4752 assert!(q.attach(&mut t, &[more, missing]).is_err());
4753 assert_eq!(t.attachments, ["good.png"]);
4754 let on_disk: Vec<_> = std::fs::read_dir(q.attachments_dir(&t.id))
4755 .unwrap()
4756 .flatten()
4757 .collect();
4758 assert_eq!(on_disk.len(), 1);
4759 }
4760
4761 fn block_put(q: &Queue, t: &Task) -> PathBuf {
4763 let tmp = q.path_of(&t.id).with_extension("json.tmp");
4764 std::fs::create_dir_all(&tmp).unwrap();
4765 tmp
4766 }
4767
4768 #[test]
4769 fn a_failed_put_leaves_no_new_attachment_directory() {
4770 let (dir, q) = queue();
4771 let mut t = task("fresh");
4772 let tmp = block_put(&q, &t);
4773 let src = source_file(dir.path(), "shot.png", "x");
4774 assert!(q.attach_and_put(&mut t, &[src]).is_err());
4775 assert!(t.attachments.is_empty());
4776 assert!(!q.attachments_dir(&t.id).exists());
4777 assert!(!q.path_of(&t.id).exists());
4778 std::fs::remove_dir(tmp).unwrap();
4779 }
4780
4781 #[test]
4782 fn a_failed_put_removes_only_the_copy_it_just_made() {
4783 let (dir, q) = queue();
4784 let mut t = task("edited");
4785 let first = source_file(dir.path(), "first.png", "1");
4786 q.attach_and_put(&mut t, &[first]).unwrap();
4787 block_put(&q, &t);
4788 let second = source_file(dir.path(), "second.png", "2");
4789 assert!(q.attach_and_put(&mut t, &[second]).is_err());
4790 assert_eq!(t.attachments, ["first.png"]);
4791 let on_disk: Vec<_> = std::fs::read_dir(q.attachments_dir(&t.id))
4792 .unwrap()
4793 .flatten()
4794 .map(|e| e.file_name().to_string_lossy().into_owned())
4795 .collect();
4796 assert_eq!(on_disk, ["first.png"]);
4797 assert_eq!(q.get(&t.id).unwrap().attachments, ["first.png"]);
4798 }
4799
4800 #[test]
4801 fn a_leftover_removing_directory_is_swept_by_the_next_removal() {
4802 let (dir, q) = queue();
4803 let questions = Questions::at(dir.path().join("questions"));
4804 let gone = task("gone");
4805 let mut other = task("other");
4806 let mut live = task("live");
4807 q.put(&mut other).unwrap();
4808 q.put(&mut live).unwrap();
4809 let orphan = q.root.join(format!("{}.attachments.removing", gone.id));
4812 std::fs::create_dir_all(&orphan).unwrap();
4813 std::fs::write(orphan.join("shot.png"), "x").unwrap();
4814 let busy = q.root.join(format!("{}.attachments.removing", live.id));
4817 std::fs::create_dir_all(&busy).unwrap();
4818
4819 q.remove(&other.id, false, &questions).unwrap();
4820 assert!(!orphan.exists(), "an orphan is swept");
4821 assert!(busy.exists(), "a removal in progress is left alone");
4822 }
4823
4824 #[test]
4825 fn a_blocked_aside_rename_fails_the_removal_and_loses_nothing() {
4826 let (dir, q) = queue();
4827 let questions = Questions::at(dir.path().join("questions"));
4828 let mut t = task("stuck");
4829 let src = source_file(dir.path(), "shot.png", "x");
4830 q.attach_and_put(&mut t, &[src]).unwrap();
4831 let aside = q.root.join(format!("{}.attachments.removing", t.id));
4834 std::fs::create_dir_all(&aside).unwrap();
4835 std::fs::write(aside.join("old.png"), "o").unwrap();
4836 assert!(q.remove(&t.id, false, &questions).is_err());
4837 assert!(q.path_of(&t.id).exists());
4838 assert!(q.attachments_dir(&t.id).join("shot.png").is_file());
4839 }
4840
4841 #[test]
4842 fn a_failed_record_removal_puts_the_attachments_back() {
4843 let (dir, q) = queue();
4844 let mut t = task("rollback");
4845 let src = source_file(dir.path(), "shot.png", "x");
4846 q.attach_and_put(&mut t, &[src]).unwrap();
4847 let err = q
4848 .remove_record_with_attachments(&t.id, |_| {
4849 Err(std::io::Error::other("injected failure"))
4850 })
4851 .unwrap_err();
4852 assert!(format!("{err:#}").contains("injected failure"));
4853 assert!(q.path_of(&t.id).exists());
4854 assert!(q.attachments_dir(&t.id).join("shot.png").is_file());
4855 assert!(
4856 !q.root
4857 .join(format!("{}.attachments.removing", t.id))
4858 .exists()
4859 );
4860 }
4861
4862 #[test]
4863 fn editing_a_task_keeps_its_attachments() {
4864 let (dir, q) = queue();
4865 let src = source_file(dir.path(), "shot.png", "x");
4866 let mut t = task("editable");
4867 q.attach(&mut t, &[src]).unwrap();
4868 t.edit("new".to_owned(), "new text".to_owned()).unwrap();
4869 q.put(&mut t).unwrap();
4870 assert_eq!(q.get(&t.id).unwrap().attachments, ["shot.png"]);
4871 }
4872
4873 #[test]
4874 fn removing_a_task_deletes_its_attachments() {
4875 let (dir, q) = queue();
4876 let questions = Questions::at(dir.path().join("questions"));
4877 let src = source_file(dir.path(), "shot.png", "x");
4878 let mut t = task("doomed");
4879 q.attach(&mut t, &[src]).unwrap();
4880 q.put(&mut t).unwrap();
4881 assert!(q.attachments_dir(&t.id).is_dir());
4882 q.remove(&t.id, false, &questions).unwrap();
4883 assert!(!q.attachments_dir(&t.id).exists());
4884 assert!(q.list().is_empty());
4885 }
4886
4887 #[test]
4888 fn a_task_written_before_attachments_still_reads() {
4889 let (_dir, q) = queue();
4890 let mut t = task("old");
4891 q.put(&mut t).unwrap();
4892 let path = q.path_of(&t.id);
4893 let mut v: serde_json::Value =
4894 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
4895 v.as_object_mut().unwrap().remove("attachments");
4896 std::fs::write(&path, v.to_string()).unwrap();
4897 assert!(q.get(&t.id).unwrap().attachments.is_empty());
4898 }
4899
4900 #[test]
4901 fn attachment_paths_are_absolute_even_when_the_root_is_relative() {
4902 let q = Queue::at(PathBuf::from("relative-queue"));
4903 let mut t = task("rel");
4904 t.attachments.push("shot.png".to_owned());
4905 let paths = q.attachment_paths(&t);
4906 assert!(paths[0].is_absolute(), "{}", paths[0].display());
4907 assert!(paths[0].ends_with(format!("{}.attachments/shot.png", t.id)));
4908 }
4909
4910 #[test]
4911 fn explicitly_resumed_older_run_becomes_current_without_duplicate_history() {
4912 let dir = tempfile::tempdir().unwrap();
4913 let q = Queue::at(dir.path().join("queue"));
4914 let mut t = task("resume");
4915 t.start("A".into());
4916 t.record_pass("A", 0, 1, None);
4917 t.start("B".into());
4918 q.put(&mut t).unwrap();
4919 assert_eq!(t.runs, vec!["A", "B"]);
4920 t.start("A".into());
4921 t.record_pass("A", 1, 3, None);
4922 q.put(&mut t).unwrap();
4923 let saved = q.get(&t.id).unwrap();
4924 assert_eq!(saved.runs, vec!["B", "A"]);
4925 assert_eq!(saved.runs.last().map(String::as_str), Some("A"));
4926 assert_eq!(saved.attempts, 3);
4927 assert_eq!(saved.passes.len(), 2);
4928 assert_eq!(saved.passes[0].run, "A");
4929 assert_eq!(saved.passes[1].run, "A");
4930 }
4931
4932 #[test]
4933 fn repeated_start_records_one_run_and_separate_passes() {
4934 let mut t = task("resume");
4935 t.start("same-run".into());
4936 t.record_pass("same-run", 0, 1, None);
4937 t.start("same-run".into());
4938 t.record_pass("same-run", 1, 2, None);
4939 assert_eq!(t.runs, vec!["same-run"]);
4940 assert_eq!(t.attempts, 2);
4941 assert_eq!(t.passes.len(), 2);
4942 }
4943
4944 #[test]
4945 fn link_run_adds_a_run_once_and_touches_nothing_else() {
4946 let dir = tempfile::tempdir().unwrap();
4947 let queue = Queue::at(dir.path().join("queue"));
4948 let mut t = Task::new(
4949 "t".to_owned(),
4950 "do it".to_owned(),
4951 PathBuf::from("."),
4952 Source::Human,
4953 );
4954 queue.put(&mut t).unwrap();
4955 let before = queue.get(&t.id).unwrap();
4956
4957 let linked = queue.link_run(&t.id[..4], "20260930-092817-ec34").unwrap();
4958 assert_eq!(linked.runs, vec!["20260930-092817-ec34".to_owned()]);
4959 assert_eq!(linked.status, before.status);
4960 assert_eq!(linked.attempts, before.attempts);
4961 assert_eq!(linked.interrupt, before.interrupt);
4962
4963 let again = queue.link_run(&t.id, "20260930-092817-ec34").unwrap();
4964 assert_eq!(again.runs.len(), 1, "linking twice must not duplicate");
4965 assert_eq!(queue.get(&t.id).unwrap().runs.len(), 1);
4966 assert!(queue.link_run("no-such-task", "r").is_err());
4967 }
4968
4969 #[test]
4970 fn put_keeps_a_run_linked_after_the_writer_took_its_snapshot() {
4971 let dir = tempfile::tempdir().unwrap();
4972 let queue = Queue::at(dir.path().join("queue"));
4973 let mut t = Task::new(
4974 "t".to_owned(),
4975 "do it".to_owned(),
4976 PathBuf::from("."),
4977 Source::Human,
4978 );
4979 queue.put(&mut t).unwrap();
4980 let mut snapshot = queue.get(&t.id).unwrap();
4982 queue.link_run(&t.id, "20260930-092817-ec34").unwrap();
4983
4984 snapshot.start("20260930-000000-aaaa".to_owned());
4985 queue.put(&mut snapshot).unwrap();
4986
4987 let stored = queue.get(&t.id).unwrap();
4988 assert!(stored.runs.contains(&"20260930-092817-ec34".to_owned()));
4989 assert!(stored.runs.contains(&"20260930-000000-aaaa".to_owned()));
4990 assert_eq!(stored.attempts, 1);
4991 }
4992
4993 #[test]
4994 fn concurrent_links_and_daemon_saves_lose_nothing() {
4995 let dir = tempfile::tempdir().unwrap();
4996 let queue = Queue::at(dir.path().join("queue"));
4997 let mut t = Task::new(
4998 "t".to_owned(),
4999 "do it".to_owned(),
5000 PathBuf::from("."),
5001 Source::Human,
5002 );
5003 queue.put(&mut t).unwrap();
5004 let id = t.id.clone();
5005
5006 let linkers: Vec<_> = (0..4)
5007 .map(|n| {
5008 let (queue, id) = (queue.clone(), id.clone());
5009 std::thread::spawn(move || {
5010 for k in 0..10 {
5011 queue
5012 .link_run(&id, &format!("20260930-00000{n}-l{k:03}"))
5013 .unwrap();
5014 }
5015 })
5016 })
5017 .collect();
5018 let mut mine = queue.get(&id).unwrap();
5021 for k in 0..10 {
5022 mine.start(format!("20260930-000009-d{k:03}"));
5023 queue.put(&mut mine).unwrap();
5024 }
5025 for l in linkers {
5026 l.join().unwrap();
5027 }
5028
5029 let stored = queue.get(&id).unwrap();
5030 assert_eq!(stored.runs.len(), 50, "{:?}", stored.runs);
5031 assert_eq!(
5032 stored.attempts, 10,
5033 "linking never rewinds the daemon's work"
5034 );
5035 }
5036
5037 fn age_lock(path: &Path) {
5038 let f = std::fs::OpenOptions::new().write(true).open(path).unwrap();
5039 f.set_modified(std::time::SystemTime::now() - std::time::Duration::from_secs(60))
5040 .unwrap();
5041 }
5042
5043 #[test]
5044 fn concurrent_stale_takeover_yields_one_holder() {
5045 use std::sync::atomic::{AtomicUsize, Ordering};
5046 use std::sync::{Arc, Barrier};
5047 for _ in 0..5 {
5048 let dir = tempfile::tempdir().unwrap();
5049 let q = Queue::at(dir.path().to_path_buf());
5050 let lock = dir.path().join("t.write-lock");
5051 std::fs::write(&lock, "dead-0000").unwrap();
5052 age_lock(&lock);
5053 let n = 6;
5054 let barrier = Arc::new(Barrier::new(n));
5055 let (now, max) = (Arc::new(AtomicUsize::new(0)), Arc::new(AtomicUsize::new(0)));
5056 let handles: Vec<_> = (0..n)
5057 .map(|_| {
5058 let (q, b, now, max) = (q.clone(), barrier.clone(), now.clone(), max.clone());
5059 std::thread::spawn(move || {
5060 b.wait();
5061 let g = q.lock_task("t").unwrap();
5062 let held = now.fetch_add(1, Ordering::SeqCst) + 1;
5063 max.fetch_max(held, Ordering::SeqCst);
5064 std::thread::sleep(std::time::Duration::from_millis(20));
5065 now.fetch_sub(1, Ordering::SeqCst);
5066 drop(g);
5067 })
5068 })
5069 .collect();
5070 for h in handles {
5071 h.join().unwrap();
5072 }
5073 assert_eq!(max.load(Ordering::SeqCst), 1);
5074 assert!(!lock.exists());
5075 }
5076 }
5077
5078 #[test]
5079 fn dropping_a_stolen_lock_leaves_the_new_holders_lock() {
5080 let dir = tempfile::tempdir().unwrap();
5081 let q = Queue::at(dir.path().to_path_buf());
5082 let lock = dir.path().join("t.write-lock");
5083 let a = q.lock_task("t").unwrap();
5084 age_lock(&lock);
5085 let b = q.lock_task("t").unwrap();
5086 assert_ne!(a.token, b.token);
5087 drop(a);
5088 assert_eq!(std::fs::read_to_string(&lock).unwrap(), b.token);
5089 drop(b);
5090 assert!(!lock.exists());
5091 }
5092
5093 #[test]
5094 fn break_stale_leaves_a_lock_that_replaced_the_one_judged() {
5095 let dir = tempfile::tempdir().unwrap();
5096 let lock = dir.path().join("t.write-lock");
5097 std::fs::write(&lock, "new-token").unwrap();
5098 assert!(!break_stale(&lock, "old-token"));
5099 assert_eq!(std::fs::read_to_string(&lock).unwrap(), "new-token");
5100 assert!(!break_stale(&lock, "new-token"));
5102 assert!(lock.exists());
5103 age_lock(&lock);
5104 assert!(break_stale(&lock, "new-token"));
5105 assert!(!lock.exists());
5106 }
5107
5108 #[test]
5109 fn a_stale_break_marker_is_recovered_and_a_fresh_one_is_respected() {
5110 let dir = tempfile::tempdir().unwrap();
5111 let lock = dir.path().join("t.write-lock");
5112 std::fs::write(&lock, "dead-1").unwrap();
5113 age_lock(&lock);
5114 let marker = break_marker(&lock, "dead-1");
5115 std::fs::write(&marker, "crashed-remover").unwrap();
5116 assert!(!break_stale(&lock, "dead-1"));
5118 assert!(lock.exists());
5119 age_lock(&marker);
5121 assert!(break_stale(&lock, "dead-1"));
5122 assert!(!lock.exists());
5123 assert!(!marker.exists());
5124 }
5125
5126 fn chat_task() -> Task {
5127 Task::new(
5128 "t".to_owned(),
5129 "do t".to_owned(),
5130 PathBuf::from("."),
5131 Source::Agent {
5132 run: "20260902-000000-beef".to_owned(),
5133 node: CHAT_NODE.to_owned(),
5134 },
5135 )
5136 }
5137
5138 #[test]
5139 fn a_blocked_hold_keeps_its_anchor_but_a_new_run_starts_a_new_hold() {
5140 let mut t = chat_task();
5141 t.hold_machine(Some("waiting".into()));
5142 let old: Timestamp = "2020-01-01T00:00:00Z".parse().unwrap();
5143 t.held_at = Some(old);
5144 t.block(vec!["decision".into()], None);
5145 t.hold_machine(Some("same waiting decision".into()));
5146 assert_eq!(t.held_at, Some(old));
5147 t.start("real-new-run".into());
5148 t.fail("new failure", 1);
5149 assert_ne!(
5150 t.held_at,
5151 Some(old),
5152 "old answers cannot approve a new hold"
5153 );
5154 }
5155
5156 #[test]
5157 fn legacy_delivery_survives_upgrade_and_a_no_progress_run_link() {
5158 let (_dir, queue) = queue();
5159 let mut t = chat_task();
5160 t.start("old-run".into());
5161 t.hold_machine(Some("operator is considering recovery".into()));
5162 t.chat_notice = Some(ChatNotice {
5163 key: format!("held#{}", t.held_at.unwrap()),
5164 at: Timestamp::now(),
5165 outcome: ChatNoticeOutcome::Posted,
5166 });
5167 let mut raw = serde_json::to_value(&t).unwrap();
5168 raw.as_object_mut().unwrap().remove("chat_notices");
5169 raw.as_object_mut().unwrap().remove("hold_notice_cause");
5170 std::fs::create_dir_all(queue.path_of(&t.id).parent().unwrap()).unwrap();
5171 std::fs::write(queue.path_of(&t.id), serde_json::to_string(&raw).unwrap()).unwrap();
5172 assert_eq!(queue.get(&t.id).unwrap().chat_notice_due(), None);
5173 queue
5174 .modify(&t.id, |task| {
5175 task.requeue();
5176 task.link_run("unprepared-run");
5177 task.startup_notice_cause("startup-source");
5178 task.fail("same unresolved preflight", 0);
5179 true
5180 })
5181 .unwrap();
5182 assert_eq!(queue.get(&t.id).unwrap().chat_notice_due(), None);
5183 queue
5184 .modify(&t.id, |task| {
5185 task.succeed();
5186 true
5187 })
5188 .unwrap();
5189 assert_eq!(
5190 queue.get(&t.id).unwrap().chat_notice_due().as_deref(),
5191 Some("done")
5192 );
5193 }
5194
5195 #[test]
5196 fn a_dependency_wait_does_not_change_when_released_and_blocked_again() {
5197 let mut t = chat_task();
5198 t.block(vec!["a".into(), "b".into()], Some("waiting".into()));
5199 let key = t.chat_notice_key().unwrap();
5200 t.chat_notices.push(key);
5201 t.release();
5202 t.block(
5203 vec!["b".into(), "a".into(), "a".into()],
5204 Some("still waiting on prerequisites".into()),
5205 );
5206 assert_eq!(t.chat_notice_due(), None);
5207 t.block(
5208 vec!["new-question".into()],
5209 Some("different decision".into()),
5210 );
5211 assert!(t.chat_notice_due().is_some());
5212 }
5213
5214 #[test]
5215 fn running_startup_failures_preserve_stage_and_report_distinct_blockers() {
5216 let mut t = chat_task();
5217 t.start("existing-run".into());
5218 t.fail("implementation failed", 1);
5219 let failed = t.chat_notice_key().unwrap();
5220 t.chat_notices.push(failed.clone());
5221 t.release_operator("test", None);
5222 t.start("existing-run".into());
5223 t.fail_startup("startup-source", "same unresolved recovery source", 1);
5224 assert_eq!(
5225 t.chat_notice_due(),
5226 None,
5227 "no-progress recovery stays quiet"
5228 );
5229 t.release_operator("test", None);
5230 t.start("existing-run".into());
5231 t.fail_startup("startup-attachments", "attachment missing", 1);
5232 let attachments = t.chat_notice_due().expect("new attachment blocker");
5233 assert_ne!(attachments, failed);
5234 assert_eq!(
5235 t.hold_notice_cause.as_ref().unwrap().stage,
5236 "startup-attachments"
5237 );
5238 t.chat_notices.push(attachments);
5239 t.release_operator("test", None);
5240 t.start("existing-run".into());
5241 t.fail_startup(
5242 "startup-attachments",
5243 "same missing attachment, reworded",
5244 1,
5245 );
5246 assert_eq!(
5247 t.chat_notice_due(),
5248 None,
5249 "same stage after attempt counter reset"
5250 );
5251 t.release_operator("test", None);
5252 t.start("existing-run".into());
5253 t.fail_startup("startup-config", "different preflight stage", 1);
5254 assert!(t.chat_notice_due().is_some());
5255 assert_eq!(
5256 t.hold_notice_cause.as_ref().unwrap().stage,
5257 "startup-config"
5258 );
5259 }
5260
5261 #[test]
5262 fn structured_causes_preserve_no_progress_retries_and_report_new_passes() {
5263 let mut t = chat_task();
5264 t.hold_machine(Some("startup source missing".into()));
5265 t.startup_notice_cause("startup-source");
5266 let first = t.chat_notice_key().unwrap();
5267 t.chat_notices.push(first.clone());
5268 t.release_operator("test", None);
5269 t.startup_notice_cause("startup-source");
5270 t.hold_machine(Some("same source cannot be read".into()));
5271 assert_eq!(t.chat_notice_due(), None);
5272 t.startup_notice_cause("startup-config");
5273 assert!(t.chat_notice_due().is_some(), "different startup stage");
5274 t.notice_cause("failed", Some("existing-run"), Some(0));
5275 let run_failure = t.chat_notice_key().unwrap();
5276 t.chat_notices.push(run_failure.clone());
5277 t.release_operator("test", None);
5278 t.startup_notice_cause("startup-source");
5279 t.hold_machine(Some("no new work happened".into()));
5280 assert_eq!(t.chat_notice_due(), None);
5281 t.notice_cause("failed", Some("existing-run"), Some(1));
5282 assert!(t.chat_notice_due().is_some(), "a real resumed pass failed");
5283 assert_ne!(t.chat_notice_key().unwrap(), run_failure);
5284 }
5285
5286 #[test]
5287 fn legacy_hold_baseline_does_not_replay_or_hide_new_failures_and_completion() {
5288 let mut t = chat_task();
5289 t.hold_machine(Some("still waiting".into()));
5290 t.chat_notice = Some(ChatNotice {
5291 key: "held#2026-10-14T00:00:00Z".into(),
5292 at: Timestamp::now(),
5293 outcome: ChatNoticeOutcome::Posted,
5294 });
5295 assert_eq!(t.chat_notice_due(), None, "old hold baseline");
5296 t.import_chat_notice();
5297 t.notice_cause("failed", Some("new-run"), Some(0));
5298 assert!(t.chat_notice_due().is_some(), "new producer evidence");
5299 t.succeed();
5300 assert_eq!(t.chat_notice_due().as_deref(), Some("done"));
5301 }
5302
5303 #[test]
5304 fn a_chat_notice_is_due_only_for_a_final_state_and_only_once() {
5305 let mut t = chat_task();
5306 assert_eq!(t.chat_notice_due(), None, "queued");
5307 t.start("r1".to_owned());
5308 assert_eq!(t.chat_notice_due(), None, "running");
5309 t.fail("boom", 3);
5310 assert_eq!(t.status, TaskStatus::Failed);
5311 assert_eq!(t.chat_notice_due(), None, "a retried failure is not final");
5312 t.start("r2".to_owned());
5313 t.park("upgrade");
5314 assert_eq!(t.chat_notice_due(), None, "parked");
5315 t.fail("boom", 1);
5316 assert_eq!(t.status, TaskStatus::Held);
5317 let key = t
5318 .chat_notice_due()
5319 .expect("held on the last attempt is final");
5320 t.chat_notice = Some(ChatNotice {
5321 key,
5322 at: Timestamp::now(),
5323 outcome: ChatNoticeOutcome::Posted,
5324 });
5325 assert_eq!(t.chat_notice_due(), None, "same hold, no second notice");
5326 t.release();
5327 assert_eq!(t.chat_notice_due(), None, "requeued after a release");
5328 t.start("r3".to_owned());
5329 t.succeed();
5330 assert!(t.chat_notice_due().is_some(), "done is a new final state");
5331 }
5332
5333 #[test]
5334 fn completed_chat_notices_keep_current_and_legacy_keys_after_requeue() {
5335 for key in ["done", "done#0", "done#1", "done#12"] {
5336 let mut t = chat_task();
5337 t.start("r1".to_owned());
5338 t.succeed();
5339 t.chat_notice = Some(ChatNotice {
5340 key: key.to_owned(),
5341 at: Timestamp::now(),
5342 outcome: ChatNoticeOutcome::Posted,
5343 });
5344 t.requeue();
5345 t.start("r2".to_owned());
5346 t.succeed();
5347 assert_eq!(t.chat_notice_key().as_deref(), Some("done"));
5348 assert_eq!(t.chat_notice_due(), None, "recorded key: {key}");
5349 }
5350 }
5351
5352 #[test]
5353 fn a_release_without_progress_keeps_the_unresolved_notice_cause() {
5354 let mut t = chat_task();
5355 t.hold_manual(Some("wait".to_owned()));
5356 let first = t.chat_notice_due().expect("held");
5357 t.chat_notice = Some(ChatNotice {
5358 key: first.clone(),
5359 at: Timestamp::now(),
5360 outcome: ChatNoticeOutcome::Posted,
5361 });
5362 t.hold_manual(Some("still waiting".to_owned()));
5363 assert_eq!(t.chat_notice_due(), None, "a re-hold keeps its start");
5364 t.release();
5365 std::thread::sleep(std::time::Duration::from_millis(5));
5366 t.hold_manual(None);
5367 assert_eq!(t.chat_notice_due(), None, "no progress, same cause");
5368 }
5369
5370 #[test]
5371 fn a_chat_notice_needs_a_chat_source_and_a_current_schema() {
5372 let mut human = task("h");
5373 human.succeed();
5374 assert_eq!(human.chat_notice_due(), None);
5375 let mut inherited = task("i");
5377 inherited.origin_chat = Some("20260902-000000-beef".to_owned());
5378 inherited.succeed();
5379 assert_eq!(inherited.chat_notice_due(), None);
5380 let mut old = chat_task();
5381 old.schema = 12;
5382 old.status = TaskStatus::Done;
5383 assert_eq!(old.chat_notice_due(), None, "history is not replayed");
5384 let mut live = chat_task();
5385 live.schema = 12;
5386 live.start("r1".to_owned());
5387 live.succeed();
5388 assert!(live.chat_notice_due().is_some(), "started by this build");
5389 let mut held = chat_task();
5390 held.schema = 12;
5391 held.hold_machine(Some("config".to_owned()));
5392 assert!(held.chat_notice_due().is_some(), "held before any start");
5393 }
5394
5395 #[test]
5396 fn a_stale_snapshot_does_not_rewind_a_recorded_chat_notice() {
5397 let tmp = tempfile::tempdir().expect("tempdir");
5398 let q = Queue::at(tmp.path().join("queue"));
5399 let mut t = chat_task();
5400 t.succeed();
5401 q.put(&mut t).expect("put");
5402 let mut stale = q.get(&t.id).expect("get");
5403 let key = t.chat_notice_due().expect("due");
5404 q.modify(&t.id, |x| {
5405 x.chat_notice = Some(ChatNotice {
5406 key: key.clone(),
5407 at: Timestamp::now(),
5408 outcome: ChatNoticeOutcome::Posted,
5409 });
5410 true
5411 })
5412 .expect("modify");
5413 q.put(&mut stale).expect("stale put");
5414 assert_eq!(q.get(&t.id).expect("get").chat_notice_due(), None);
5415 }
5416}