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 Self::of_statuses(tasks.iter().map(|task| task.status))
261 }
262
263 pub(crate) fn of_statuses(statuses: impl IntoIterator<Item = TaskStatus>) -> Self {
265 let mut counts = Self::default();
266 for status in statuses {
267 match status {
268 TaskStatus::Queued => counts.queued += 1,
269 TaskStatus::Running => counts.running += 1,
270 TaskStatus::Done => counts.done += 1,
271 TaskStatus::Failed => counts.failed += 1,
272 TaskStatus::Held => counts.held += 1,
273 TaskStatus::Blocked => counts.blocked += 1,
274 TaskStatus::Parked => counts.parked += 1,
275 }
276 }
277 counts
278 }
279}
280
281#[derive(Debug, Clone, Serialize, Deserialize)]
283#[serde(deny_unknown_fields)]
284pub struct Task {
285 pub schema: u32,
287 pub id: String,
289 pub title: String,
291 pub instruction: String,
293 pub repo: PathBuf,
295 pub source: Source,
297 #[serde(default)]
299 pub priority: i32,
300 #[serde(default)]
311 pub solo: bool,
312 pub status: TaskStatus,
314 #[serde(default)]
316 pub attempts: usize,
317 #[serde(default)]
319 pub attempt_limit: Option<NonZeroUsize>,
320 #[serde(default)]
323 pub runs: Vec<String>,
324 #[serde(default)]
326 pub last_error: Option<String>,
327 #[serde(default)]
340 pub hold_reason: Option<String>,
341 #[serde(default)]
344 pub hold_source: Option<HoldSource>,
345 #[serde(default)]
357 pub diagnostic: Option<String>,
358 #[serde(default)]
368 pub blocked_by: Vec<String>,
369 #[serde(default)]
372 pub block_reason: Option<String>,
373 #[serde(default)]
386 pub blocked_from: Option<TaskStatus>,
387 #[serde(default)]
389 pub blocked_at: Option<Timestamp>,
390 #[serde(default)]
401 pub answers: Vec<AnsweredQuestion>,
402 #[serde(default)]
408 pub triage_applied: Vec<String>,
409 #[serde(default)]
415 pub actions_applied: Vec<String>,
416 #[serde(default)]
421 pub resume_override: Option<OperatorResume>,
422 #[serde(default)]
425 pub resumption: Option<Resumption>,
426 #[serde(default)]
428 pub resumption_history: Vec<Resumption>,
429 #[serde(default)]
431 pub operator_decided_at: Option<Timestamp>,
432 #[serde(default)]
444 pub review_branch: Option<String>,
445 #[serde(default)]
447 pub review_instruction: Option<String>,
448 #[serde(default)]
450 pub pr_recovery: Option<crate::pr_recovery::Selection>,
451 #[serde(default)]
454 pub fresh_start: bool,
455 #[serde(default)]
457 pub replacement: Option<crate::merge_decision::Replacement>,
458 #[serde(default)]
469 pub interrupt: bool,
470 #[serde(default)]
493 pub urgent: bool,
494 #[serde(default)]
500 pub attachments: Vec<String>,
501 #[serde(default)]
505 pub followup: Option<FollowUp>,
506 #[serde(default)]
512 pub origin_chat: Option<String>,
513 #[serde(default)]
520 pub overrides: Option<RunOverrides>,
521 #[serde(default)]
528 pub review_of: Option<String>,
529 #[serde(default)]
534 pub held_at: Option<Timestamp>,
535 #[serde(default)]
539 pub park_reason: Option<String>,
540 #[serde(default)]
543 pub chat_notice: Option<ChatNotice>,
544 #[serde(default)]
546 pub chat_notices: Vec<String>,
547 #[serde(default)]
550 pub hold_notice_cause: Option<NoticeCause>,
551 #[serde(default)]
556 pub passes: Vec<TaskPass>,
557 pub created_at: Timestamp,
559 pub updated_at: Timestamp,
561}
562
563#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
565pub struct NoticeCause {
566 pub stage: String,
568 #[serde(default, skip_serializing_if = "Option::is_none")]
571 pub target: Option<String>,
572 pub run: Option<String>,
574 pub pass: Option<usize>,
576}
577
578pub(crate) fn is_done_notice_key(key: &str) -> bool {
580 key == "done"
581 || key
582 .strip_prefix("done#")
583 .is_some_and(|count| !count.is_empty() && count.bytes().all(|b| b.is_ascii_digit()))
584}
585
586#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
588#[serde(rename_all = "snake_case")]
589pub enum PassCost {
590 Spent,
592 Refunded,
594}
595
596#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
598pub struct TaskPass {
599 pub run: String,
601 pub pass_index: usize,
603 pub cost: PassCost,
605 pub attempts_after: usize,
607 pub settled_at: Timestamp,
609 #[serde(default)]
611 pub parked: Option<crate::run::ParkCause>,
612}
613
614#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
616#[serde(rename_all = "lowercase")]
617pub enum ChatNoticeOutcome {
618 Posted,
620 Skipped,
622}
623
624#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
626pub struct ChatNotice {
627 pub key: String,
629 pub at: Timestamp,
631 pub outcome: ChatNoticeOutcome,
633}
634
635#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
637pub struct RunOverrides {
638 #[serde(default)]
641 pub inherit_daemon_options: bool,
642 #[serde(default)]
644 pub merge: Option<String>,
645 #[serde(default)]
647 pub candidates: Option<usize>,
648 #[serde(default)]
650 pub judges: Option<usize>,
651 #[serde(default)]
653 pub reviewers: Option<usize>,
654 #[serde(default)]
656 pub review_rounds: Option<usize>,
657 #[serde(default)]
659 pub seed: Option<u64>,
660 #[serde(default)]
662 pub config: Option<PathBuf>,
663}
664
665impl RunOverrides {
666 pub fn merge_over(&mut self, other: &RunOverrides) {
668 macro_rules! take {
669 ($($f:ident),*) => {$(
670 if other.$f.is_some() {
671 self.$f.clone_from(&other.$f);
672 }
673 )*};
674 }
675 take!(
676 merge,
677 candidates,
678 judges,
679 reviewers,
680 review_rounds,
681 seed,
682 config
683 );
684 }
685
686 pub fn apply(&self, config: &mut crate::config::Config) {
688 if let Some(n) = self.candidates {
689 config.graph.implementers = n;
690 }
691 if let Some(n) = self.judges {
692 config.graph.judges = n;
693 }
694 if let Some(n) = self.reviewers {
695 config.graph.reviewers = n;
696 }
697 if let Some(n) = self.review_rounds {
698 config.graph.review_rounds = n;
699 }
700 if let Some(m) = &self.merge
701 && let Ok(mode) = crate::daemon::merge_mode(m)
702 {
703 config.merge.mode = mode;
704 }
705 if let Some(s) = self.seed {
706 config.blind.seed = Some(s);
707 }
708 }
709}
710
711#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
713pub struct FollowUp {
714 pub run: String,
716 #[serde(default)]
718 pub origin_task: Option<String>,
719 pub pr: String,
721 pub findings: Vec<String>,
723 pub generation: u32,
726}
727
728#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
730pub struct Resumption {
731 pub generation: u64,
733 pub source: String,
735 pub question_id: Option<String>,
737 pub at: Timestamp,
739 pub instruction: String,
741 pub answers_through: usize,
743 pub runs_through: usize,
746 pub cleared_reason: Option<String>,
748 #[serde(default)]
750 pub blocking_evidence: Vec<String>,
751}
752
753#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
756pub struct OperatorResume {
757 pub question_id: String,
759 pub at: Timestamp,
761 #[serde(default)]
764 pub conductor_rehold: Option<String>,
765 #[serde(default)]
768 pub forced: bool,
769 #[serde(default)]
776 pub pinned_run: Option<String>,
777}
778
779#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
782pub struct AnsweredQuestion {
783 pub question: String,
785 pub answer: String,
787}
788
789impl Task {
790 pub fn new(title: String, instruction: String, repo: PathBuf, source: Source) -> Self {
792 let now = Timestamp::now();
793 let origin_chat = match &source {
794 Source::Agent { run, node } if node == CHAT_NODE => Some(run.clone()),
795 _ => None,
796 };
797 Self {
798 schema: SCHEMA,
799 id: new_id(),
800 title,
801 instruction,
802 repo,
803 source,
804 priority: 0,
805 solo: false,
806 status: TaskStatus::Queued,
807 attempts: 0,
808 attempt_limit: None,
809 runs: Vec::new(),
810 last_error: None,
811 hold_reason: None,
812 hold_source: None,
813 diagnostic: None,
814 blocked_by: Vec::new(),
815 block_reason: None,
816 blocked_from: None,
817 blocked_at: None,
818 answers: Vec::new(),
819 triage_applied: Vec::new(),
820 actions_applied: Vec::new(),
821 resume_override: None,
822 resumption: None,
823 resumption_history: Vec::new(),
824 operator_decided_at: None,
825 review_branch: None,
826 review_instruction: None,
827 pr_recovery: None,
828 fresh_start: false,
829 replacement: None,
830 interrupt: false,
831 urgent: false,
832 attachments: Vec::new(),
833 followup: None,
834 origin_chat,
835 overrides: None,
836 review_of: None,
837 held_at: None,
838 park_reason: None,
839 chat_notice: None,
840 chat_notices: Vec::new(),
841 hold_notice_cause: None,
842 passes: Vec::new(),
843 created_at: now,
844 updated_at: now,
845 }
846 }
847
848 pub fn chat_talk(&self) -> Option<&str> {
851 if let Some(id) = &self.origin_chat {
852 return Some(id);
853 }
854 match &self.source {
855 Source::Agent { run, node } if node == CHAT_NODE => Some(run),
856 _ => None,
857 }
858 }
859
860 pub fn filed_by_chat(&self) -> Option<&str> {
864 match &self.source {
865 Source::Agent { run, node } if node == CHAT_NODE => Some(run),
866 _ => None,
867 }
868 }
869
870 pub fn chat_notice_key(&self) -> Option<String> {
873 match self.status {
874 TaskStatus::Done => Some("done".to_owned()),
875 TaskStatus::Held => {
876 let cause = self
877 .hold_notice_cause
878 .clone()
879 .unwrap_or_else(|| self.unresolved_hold_cause());
880 Some(format!(
881 "cause:{}",
882 serde_json::to_string(&cause).expect("cause")
883 ))
884 }
885 TaskStatus::Blocked => {
886 let ids: std::collections::BTreeSet<_> = self.blocked_by.iter().collect();
887 let evidence = self
890 .hold_notice_cause
891 .as_ref()
892 .filter(|c| c.stage.starts_with("conductor-evidence:"));
893 Some(format!(
894 "dependencies:{}",
895 serde_json::to_string(&(ids, evidence)).expect("ids")
896 ))
897 }
898 _ => None,
899 }
900 }
901
902 fn unresolved_hold_cause(&self) -> NoticeCause {
903 NoticeCause {
904 stage: "hold".into(),
905 target: None,
906 run: self.runs.last().cloned(),
907 pass: None,
908 }
909 }
910
911 pub(crate) fn legacy_chat_notice_key(&self) -> Option<String> {
913 let key = match self.status {
914 TaskStatus::Held if self.hold_notice_cause.is_none() => Some(format!(
915 "held#{}",
916 self.held_at.map(|t| t.to_string()).unwrap_or_default()
917 )),
918 TaskStatus::Blocked => Some(format!(
919 "blocked#{}#{}",
920 self.blocked_by.join(","),
921 self.block_reason.as_deref().unwrap_or_default()
922 )),
923 _ => None,
924 };
925 key.filter(|key| self.legacy_notice_matches(key))
926 }
927
928 pub(crate) fn legacy_notice_matches(&self, key: &str) -> bool {
929 match self.status {
930 TaskStatus::Held if self.hold_notice_cause.is_none() => key
931 .strip_prefix("held#")
932 .is_some_and(|time| time.is_empty() || time.parse::<Timestamp>().is_ok()),
933 TaskStatus::Blocked
934 if self
935 .hold_notice_cause
936 .as_ref()
937 .is_none_or(|c| !c.stage.starts_with("conductor-evidence:")) =>
938 {
939 key.strip_prefix("blocked#").is_some_and(|tail| {
940 let Some((ids, _)) = tail.split_once('#') else {
941 return false;
942 };
943 let old: std::collections::BTreeSet<_> =
944 ids.split(',').filter(|id| !id.is_empty()).collect();
945 let current: std::collections::BTreeSet<_> =
946 self.blocked_by.iter().map(String::as_str).collect();
947 old == current
948 })
949 }
950 _ => false,
951 }
952 }
953
954 fn import_chat_notice(&mut self) {
955 if let Some(n) = &self.chat_notice {
956 let key = if is_done_notice_key(&n.key) {
957 "done".to_owned()
958 } else if self.legacy_notice_matches(&n.key) {
959 self.chat_notice_key().unwrap_or_else(|| n.key.clone())
960 } else {
961 n.key.clone()
962 };
963 if !self.chat_notices.contains(&key) {
964 self.chat_notices.push(key);
965 }
966 }
967 }
968
969 pub(crate) fn notice_cause(&mut self, stage: &str, run: Option<&str>, pass: Option<usize>) {
972 self.import_chat_notice();
973 self.hold_notice_cause = Some(NoticeCause {
974 stage: stage.to_owned(),
975 target: None,
976 run: run.map(str::to_owned),
977 pass,
978 });
979 }
980
981 pub(crate) fn startup_notice_cause(&mut self, stage: &str) {
984 let recovery_only = matches!(
985 stage,
986 "startup-source" | "startup-candidates" | "startup-public-head"
987 );
988 match self.hold_notice_cause.as_ref() {
989 Some(cause) if recovery_only && !cause.stage.starts_with("startup-") => return,
990 None if recovery_only
991 && self
992 .chat_notice
993 .as_ref()
994 .is_some_and(|n| !is_done_notice_key(&n.key)) =>
995 {
996 return;
997 }
998 _ => {}
999 }
1000 self.notice_cause(stage, None, None);
1001 }
1002
1003 pub(crate) fn fail_startup(
1006 &mut self,
1007 stage: &str,
1008 why: impl Into<String>,
1009 max_attempts: usize,
1010 ) {
1011 let unresolved = self.hold_notice_cause.clone();
1012 self.fail(why, max_attempts);
1013 self.hold_notice_cause = unresolved;
1014 self.startup_notice_cause(stage);
1015 }
1016
1017 pub(crate) fn fail_startup_target(
1019 &mut self,
1020 stage: &str,
1021 target: String,
1022 why: impl Into<String>,
1023 max_attempts: usize,
1024 ) {
1025 self.fail_startup(stage, why, max_attempts);
1026 if let Some(cause) = self.hold_notice_cause.as_mut() {
1027 cause.target = Some(target);
1028 }
1029 }
1030
1031 pub fn chat_notice_due(&self) -> Option<String> {
1033 self.filed_by_chat()?;
1034 if self.schema < 13 {
1035 return None;
1036 }
1037 let key = self.chat_notice_key()?;
1038 if self.chat_notices.contains(&key) {
1039 return None;
1040 }
1041 match &self.chat_notice {
1042 Some(n)
1043 if n.key == key
1044 || (key == "done" && is_done_notice_key(&n.key))
1045 || self.legacy_notice_matches(&n.key) =>
1046 {
1047 None
1048 }
1049 _ => Some(key),
1050 }
1051 }
1052
1053 pub fn short(&self) -> &str {
1055 short(&self.id)
1056 }
1057
1058 pub fn mark_triage_applied(&mut self, question_id: &str) {
1060 if !self.triage_applied(question_id) {
1061 self.triage_applied.push(question_id.to_owned());
1062 }
1063 }
1064
1065 pub fn action_applied(&self, question_id: &str) -> bool {
1068 self.actions_applied.iter().any(|id| id == question_id)
1069 }
1070
1071 pub fn mark_action_applied(&mut self, question_id: &str) {
1073 if !self.action_applied(question_id) {
1074 self.actions_applied.push(question_id.to_owned());
1075 }
1076 }
1077
1078 pub fn triage_applied(&self, question_id: &str) -> bool {
1080 self.triage_applied.iter().any(|id| id == question_id)
1081 }
1082
1083 pub fn start(&mut self, run: String) {
1096 self.stamp_schema();
1097 self.status = TaskStatus::Running;
1098 self.attempts += 1;
1099 self.runs.retain(|id| id != &run);
1100 self.runs.push(run);
1101 self.last_error = None;
1102 self.park_reason = None;
1103 self.fresh_start = false;
1104 self.interrupt = false;
1105 self.resume_override = None;
1107 }
1108
1109 pub fn link_run(&mut self, run: &str) -> bool {
1114 if self.runs.iter().any(|r| r == run) {
1115 return false;
1116 }
1117 self.runs.push(run.to_owned());
1118 true
1119 }
1120
1121 pub fn succeed(&mut self) {
1131 self.stamp_schema();
1132 self.status = TaskStatus::Done;
1133 self.resume_override = None;
1134 self.last_error = None;
1135 self.park_reason = None;
1136 self.hold_reason = None;
1137 self.hold_source = None;
1138 self.diagnostic = None;
1139 self.blocked_by.clear();
1140 self.block_reason = None;
1141 self.blocked_from = None;
1142 }
1143
1144 pub fn already_landed(&mut self, note: impl Into<String>) {
1152 self.succeed();
1153 self.attempts = self.attempts.saturating_sub(1);
1154 self.last_error = Some(note.into());
1155 }
1156
1157 pub fn superseded_attempts(&self, last_run_succeeded: bool) -> &[String] {
1187 if self.status != TaskStatus::Done || !last_run_succeeded || self.runs.len() < 2 {
1188 return &[];
1189 }
1190 &self.runs[..self.runs.len() - 1]
1191 }
1192
1193 pub fn successor_of(&self, run: &str) -> Option<&String> {
1201 let pos = self.runs.iter().rposition(|r| r == run)?;
1202 self.runs.get(pos + 1)
1203 }
1204
1205 pub fn earlier_attempts(&self) -> &[String] {
1212 &self.runs
1213 }
1214
1215 fn stamp_schema(&mut self) {
1222 self.schema = self.schema.max(SCHEMA);
1223 }
1224
1225 fn note_held(&mut self) {
1226 self.stamp_schema();
1227 let continuing = self.status == TaskStatus::Held
1228 || (self.status == TaskStatus::Blocked
1229 && (self.blocked_from == Some(TaskStatus::Held)
1230 || (self.blocked_from.is_none()
1231 && (self.hold_reason.is_some() || self.hold_source.is_some()))));
1232 if !continuing || self.held_at.is_none() {
1233 self.held_at = Some(Timestamp::now());
1234 }
1235 }
1236
1237 pub fn fail(&mut self, why: impl Into<String>, max_attempts: usize) {
1253 let why = why.into();
1254 if self.status == TaskStatus::Running {
1255 let run = self.runs.last().cloned();
1256 self.notice_cause("run-failed", run.as_deref(), Some(self.attempts));
1257 }
1258 self.diagnostic = None;
1259 self.park_reason = None;
1260 self.status = if self.attempts >= self.effective_attempt_limit(max_attempts) {
1261 self.note_held();
1262 self.hold_source = Some(HoldSource::Machine);
1263 self.hold_reason = Some(why.clone());
1264 TaskStatus::Held
1265 } else {
1266 TaskStatus::Failed
1267 };
1268 self.last_error = Some(why);
1269 }
1270
1271 pub fn stall(&mut self, why: impl Into<String>) {
1281 self.last_error = Some(why.into());
1282 self.diagnostic = None;
1283 self.attempts = self.attempts.saturating_sub(1);
1284 self.status = TaskStatus::Failed;
1285 }
1286
1287 pub fn park(&mut self, why: impl Into<String>) {
1293 self.diagnostic = None;
1294 self.attempts = self.attempts.saturating_sub(1);
1295 self.status = TaskStatus::Parked;
1296 self.park_reason = Some(why.into());
1297 self.schema = self.schema.max(SCHEMA);
1300 }
1301
1302 pub fn record_pass(
1305 &mut self,
1306 run: &str,
1307 pass_index: usize,
1308 attempts_before: usize,
1309 parked: Option<crate::run::ParkCause>,
1310 ) {
1311 if self
1312 .passes
1313 .iter()
1314 .any(|p| p.run == run && p.pass_index == pass_index)
1315 {
1316 return;
1317 }
1318 let cost = if self.attempts < attempts_before {
1319 PassCost::Refunded
1320 } else {
1321 PassCost::Spent
1322 };
1323 self.passes.push(TaskPass {
1324 run: run.to_owned(),
1325 pass_index,
1326 cost,
1327 attempts_after: self.attempts,
1328 settled_at: Timestamp::now(),
1329 parked,
1330 });
1331 self.schema = self.schema.max(SCHEMA);
1332 }
1333
1334 pub fn operator_held(&self) -> bool {
1340 self.status == TaskStatus::Held && !matches!(self.hold_source, Some(HoldSource::Machine))
1341 }
1342
1343 pub fn hold_manual(&mut self, reason: Option<String>) {
1353 self.operator_decided_at = Some(Timestamp::now());
1355 self.hold_unrequested_retry(reason);
1356 }
1357
1358 pub(crate) fn hold_unrequested_retry(&mut self, reason: Option<String>) {
1361 self.park_reason = None;
1362 self.note_held();
1363 self.status = TaskStatus::Held;
1364 if reason.is_some() {
1365 self.hold_reason = reason;
1366 }
1367 self.hold_source = Some(HoldSource::Manual);
1368 self.blocked_by.clear();
1369 self.block_reason = None;
1370 self.blocked_from = None;
1371 }
1372
1373 pub fn hold_machine(&mut self, reason: Option<String>) {
1378 self.park_reason = None;
1379 self.note_held();
1380 self.status = TaskStatus::Held;
1381 if reason.is_some() {
1382 self.hold_reason = reason;
1383 }
1384 self.hold_source = Some(HoldSource::Machine);
1385 self.blocked_by.clear();
1386 self.block_reason = None;
1387 self.blocked_from = None;
1388 }
1389
1390 pub fn block(&mut self, blocked_by: Vec<String>, reason: Option<String>) {
1400 self.stamp_schema();
1401 let before: std::collections::BTreeSet<_> = self.blocked_by.iter().collect();
1402 let after: std::collections::BTreeSet<_> = blocked_by.iter().collect();
1403 if self.status != TaskStatus::Blocked || before != after {
1404 self.blocked_at = Some(Timestamp::now());
1405 }
1406 if self.status != TaskStatus::Blocked {
1407 self.blocked_from = Some(self.status);
1408 }
1409 self.status = TaskStatus::Blocked;
1410 self.blocked_by = blocked_by;
1411 self.block_reason = reason;
1412 }
1413
1414 pub fn unblock(&mut self, resolved_id: &str) {
1437 if self.status != TaskStatus::Blocked {
1438 return;
1439 }
1440 self.blocked_by.retain(|id| id != resolved_id);
1441 self.restore_if_unblocked();
1442 }
1443
1444 pub fn dependency_deleted(&mut self, deleted_id: &str) -> bool {
1455 if self.status != TaskStatus::Blocked || !self.blocked_by.iter().any(|b| b == deleted_id) {
1456 return false;
1457 }
1458 self.blocked_by.retain(|id| id != deleted_id);
1459 self.restore_if_unblocked();
1460 true
1461 }
1462
1463 fn restore_if_unblocked(&mut self) {
1467 if self.blocked_by.is_empty() {
1468 self.status = match self.blocked_from {
1469 Some(TaskStatus::Running) => TaskStatus::Queued,
1470 Some(other) => other,
1471 None if self.hold_reason.is_some() || self.hold_source.is_some() => {
1472 TaskStatus::Held
1473 }
1474 None => TaskStatus::Queued,
1475 };
1476 self.block_reason = None;
1477 self.blocked_from = None;
1478 }
1479 }
1480
1481 pub fn record_answer(&mut self, question: String, answer: String) {
1486 self.answers.push(AnsweredQuestion { question, answer });
1487 }
1488
1489 pub fn request_review(&mut self, branch: String) {
1493 self.release();
1494 self.review_branch = Some(branch);
1495 self.review_instruction = Some(self.instruction.clone());
1496 }
1497
1498 pub fn requeue(&mut self) {
1501 self.release();
1502 self.review_branch = None;
1504 self.review_instruction = None;
1505 self.fresh_start = true;
1506 }
1507
1508 pub fn hold_for_handover(&mut self, branch: Option<String>, reason: String) {
1517 if branch.is_some() {
1518 self.review_branch = branch;
1519 self.review_instruction = Some(self.instruction.clone());
1520 }
1521 self.hold_machine(Some(reason));
1522 }
1523
1524 pub fn set_urgent(&mut self, urgent: bool) -> Result<()> {
1526 if !matches!(self.status, TaskStatus::Queued | TaskStatus::Held) {
1527 bail!(
1528 "task {} is {}; urgent can only change while queued or held; \
1529 wait for the active attempt to finish and requeue or hold the task first",
1530 self.short(),
1531 self.status.as_str()
1532 );
1533 }
1534 self.urgent = urgent;
1535 Ok(())
1536 }
1537
1538 pub fn set_priority(&mut self, priority: i32) -> Result<()> {
1547 if self.status == TaskStatus::Running {
1548 bail!(
1549 "task {} is running; its priority cannot be changed until \
1550 this attempt finishes",
1551 self.short()
1552 );
1553 }
1554 self.priority = priority;
1555 Ok(())
1556 }
1557
1558 pub fn set_interrupt(&mut self, interrupt: bool) -> Result<()> {
1573 if interrupt && !self.status.runnable() {
1574 bail!(
1575 "task {} is {}; only a queued or failed task can be marked \
1576 to interrupt",
1577 self.short(),
1578 self.status.as_str()
1579 );
1580 }
1581 self.interrupt = interrupt;
1582 Ok(())
1583 }
1584
1585 pub fn effective_attempt_limit(&self, daemon_limit: usize) -> usize {
1587 self.attempt_limit.map_or(daemon_limit, NonZeroUsize::get)
1588 }
1589
1590 pub fn change_attempt_limit(&mut self, limit: Option<NonZeroUsize>) -> Result<()> {
1593 if !matches!(self.status, TaskStatus::Queued | TaskStatus::Held) {
1594 bail!("only queued or held tasks can change the attempt limit");
1595 }
1596 self.stamp_schema();
1597 self.attempt_limit = limit;
1598 Ok(())
1599 }
1600
1601 pub fn implementers(&self) -> Option<usize> {
1603 if self.solo {
1604 Some(1)
1605 } else {
1606 self.overrides.as_ref().and_then(|o| o.candidates)
1607 }
1608 }
1609
1610 pub fn change_implementers(&mut self, count: u8) -> Result<()> {
1613 self.check_implementers_editable()?;
1614 let explicit = self.overrides.as_ref().and_then(|o| o.config.as_deref());
1615 let (mut config, _) = crate::config::Config::discover(&self.repo, explicit)?;
1616 if let Some(o) = &self.overrides {
1617 o.apply(&mut config);
1618 }
1619 self.set_implementers(count, &config)
1620 }
1621
1622 fn check_implementers_editable(&self) -> Result<()> {
1623 if !matches!(self.status, TaskStatus::Queued | TaskStatus::Held) {
1624 bail!(
1625 "task {} is {}; only queued or held tasks can change implementers",
1626 self.short(),
1627 self.status.as_str()
1628 );
1629 }
1630 Ok(())
1631 }
1632
1633 pub fn set_implementers(&mut self, count: u8, config: &crate::config::Config) -> Result<()> {
1635 self.check_implementers_editable()?;
1636 crate::talk::check_implementers(count, config).map_err(anyhow::Error::msg)?;
1637 self.stamp_schema();
1638 self.solo = count == 1;
1639 let mut overrides = self.overrides.clone().unwrap_or_else(|| RunOverrides {
1640 inherit_daemon_options: true,
1641 ..Default::default()
1642 });
1643 overrides.candidates = (count > 1).then_some(usize::from(count));
1644 let mut settings = overrides.clone();
1645 settings.inherit_daemon_options = false;
1646 self.overrides = if overrides.inherit_daemon_options && settings == RunOverrides::default()
1648 {
1649 None
1650 } else {
1651 Some(overrides)
1652 };
1653 Ok(())
1654 }
1655
1656 pub fn edit(&mut self, title: String, instruction: String) -> Result<()> {
1668 if !matches!(self.status, TaskStatus::Queued | TaskStatus::Held) {
1669 bail!(
1670 "task {} is {}; only a queued or held task's instruction can \
1671 be edited",
1672 self.short(),
1673 self.status.as_str()
1674 );
1675 }
1676 if self.review_branch.is_some() && self.review_instruction.is_none() {
1679 self.review_instruction = Some(self.instruction.clone());
1680 }
1681 if self.instruction != instruction {
1682 self.resume_override = None;
1685 }
1686 self.title = title;
1687 self.instruction = instruction;
1688 Ok(())
1689 }
1690
1691 pub fn handed_off(&mut self, why: impl Into<String>) {
1711 let why = why.into();
1712 self.diagnostic = None;
1713 self.note_held();
1714 self.status = TaskStatus::Held;
1715 self.hold_source = Some(HoldSource::Machine);
1716 self.hold_reason = Some(why.clone());
1717 self.last_error = Some(why);
1718 }
1719
1720 pub fn release_operator(&mut self, source: &str, question_id: Option<&str>) {
1722 if self
1723 .resumption
1724 .as_ref()
1725 .is_some_and(|r| r.instruction != self.instruction)
1726 {
1727 self.resume_override = None;
1730 }
1731 let approval = Resumption {
1732 generation: self.resumption.as_ref().map_or(1, |r| r.generation + 1),
1733 source: source.to_owned(),
1734 question_id: question_id.map(str::to_owned),
1735 at: Timestamp::now(),
1736 instruction: self.instruction.clone(),
1737 answers_through: self.answers.len(),
1738 runs_through: self.runs.len(),
1739 blocking_evidence: Vec::new(),
1740 cleared_reason: self
1741 .hold_reason
1742 .clone()
1743 .or_else(|| self.block_reason.clone()),
1744 };
1745 self.release();
1746 self.operator_decided_at = Some(approval.at);
1747 if let Some(previous) = self.resumption.replace(approval) {
1748 self.resumption_history.push(previous);
1749 }
1750 }
1751
1752 pub fn release(&mut self) {
1756 self.import_chat_notice();
1759 if self.hold_notice_cause.is_none()
1760 && matches!(self.status, TaskStatus::Held | TaskStatus::Blocked)
1761 {
1762 self.hold_notice_cause = Some(self.unresolved_hold_cause());
1763 }
1764 let refused_handover = self.status == TaskStatus::Held
1765 && self.hold_source == Some(HoldSource::Machine)
1766 && self.review_branch.is_some()
1767 && self
1768 .review_instruction
1769 .as_ref()
1770 .is_none_or(|i| *i == self.instruction);
1771 self.status = TaskStatus::Queued;
1772 self.held_at = None;
1773 self.attempts = 0;
1774 self.last_error = None;
1775 self.park_reason = None;
1776 self.hold_reason = None;
1779 self.hold_source = None;
1780 self.diagnostic = None;
1781 self.blocked_by.clear();
1786 self.block_reason = None;
1787 self.blocked_from = None;
1788 if !refused_handover {
1792 self.review_branch = None;
1793 self.review_instruction = None;
1794 }
1795 self.fresh_start = false;
1796 }
1797}
1798
1799fn copy_new(dir: &Path, src: &Path, name: &str) -> Result<(String, PathBuf)> {
1803 use std::io::ErrorKind;
1804 let (stem, ext) = match name.rfind('.') {
1805 Some(i) if i > 0 => (&name[..i], &name[i..]),
1806 _ => (name, ""),
1807 };
1808 for n in 1u32.. {
1809 let candidate = if n == 1 {
1810 name.to_owned()
1811 } else {
1812 let suffix = format!("-{n}");
1813 let room = 64usize.saturating_sub(suffix.len() + ext.len());
1814 let stem: String = stem.chars().take(room).collect();
1815 format!("{stem}{suffix}{ext}")
1816 };
1817 if !crate::ask::valid_asset_name(&candidate) {
1818 bail!("no valid attachment name is left for `{name}`");
1819 }
1820 let path = dir.join(&candidate);
1821 match std::fs::OpenOptions::new()
1822 .write(true)
1823 .create_new(true)
1824 .open(&path)
1825 {
1826 Ok(mut out) => {
1827 let copied = std::fs::File::open(src)
1828 .and_then(|mut input| std::io::copy(&mut input, &mut out));
1829 if let Err(e) = copied {
1830 drop(out);
1831 let _ = std::fs::remove_file(&path);
1832 return Err(e).with_context(|| format!("copy {}", src.display()));
1833 }
1834 return Ok((candidate, path));
1835 }
1836 Err(e) if e.kind() == ErrorKind::AlreadyExists => continue,
1837 Err(e) => return Err(e).with_context(|| format!("create {}", path.display())),
1838 }
1839 }
1840 unreachable!("the counter never runs out")
1841}
1842
1843const TASK_LOCK_STALE: std::time::Duration = std::time::Duration::from_secs(10);
1846
1847struct TaskLock {
1850 path: PathBuf,
1851 token: String,
1852}
1853
1854fn owner_token() -> String {
1857 static COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
1858 let n = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1859 let mut r = crate::rng::SplitMix64::new(crate::rng::entropy() ^ n.rotate_left(32));
1860 format!("{}-{:016x}", std::process::id(), r.next_u64())
1861}
1862
1863fn break_marker(path: &Path, token: &str) -> PathBuf {
1866 let mut h: u64 = 0xcbf29ce484222325;
1867 for b in token.bytes() {
1868 h ^= u64::from(b);
1869 h = h.wrapping_mul(0x100000001b3);
1870 }
1871 let mut name = path.as_os_str().to_owned();
1872 name.push(format!(".break-{h:016x}"));
1873 PathBuf::from(name)
1874}
1875
1876fn older_than_stale(path: &Path) -> bool {
1877 std::fs::metadata(path)
1878 .and_then(|m| m.modified())
1879 .ok()
1880 .and_then(|t| t.elapsed().ok())
1881 .is_some_and(|age| age > TASK_LOCK_STALE)
1882}
1883
1884const MAX_MARKER_DEPTH: u8 = 3;
1888
1889fn take_marker(path: &Path, token: &str, depth: u8) -> Option<(PathBuf, String)> {
1895 use std::io::Write;
1896 let marker = break_marker(path, token);
1897 for _ in 0..2 {
1898 match std::fs::OpenOptions::new()
1899 .write(true)
1900 .create_new(true)
1901 .open(&marker)
1902 {
1903 Ok(mut f) => {
1904 let mine = owner_token();
1905 if f.write_all(mine.as_bytes()).is_err() {
1906 drop(f);
1907 let _ = std::fs::remove_file(&marker);
1908 return None;
1909 }
1910 return Some((marker, mine));
1911 }
1912 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
1913 let judged = std::fs::read_to_string(&marker).ok();
1914 match judged.filter(|_| older_than_stale(&marker)) {
1915 Some(judged) if depth < MAX_MARKER_DEPTH => {
1916 remove_lock_if(&marker, &judged, true, depth + 1)?;
1917 }
1918 _ => return None,
1919 }
1920 }
1921 Err(_) => return None,
1922 }
1923 }
1924 None
1925}
1926
1927fn remove_lock_if(path: &Path, token: &str, require_stale: bool, depth: u8) -> Option<bool> {
1932 let (marker, mine) = take_marker(path, token, depth)?;
1933 let still = std::fs::read_to_string(path).is_ok_and(|c| c == token)
1934 && (!require_stale || older_than_stale(path));
1935 if still {
1936 let _ = std::fs::remove_file(path);
1937 }
1938 if depth >= MAX_MARKER_DEPTH {
1940 if std::fs::read_to_string(&marker).is_ok_and(|c| c == mine) {
1941 let _ = std::fs::remove_file(&marker);
1942 }
1943 } else {
1944 let _ = remove_lock_if(&marker, &mine, false, depth + 1);
1945 }
1946 Some(still)
1947}
1948
1949fn break_stale(path: &Path, judged: &str) -> bool {
1952 remove_lock_if(path, judged, true, 0).unwrap_or(false)
1953}
1954
1955impl Drop for TaskLock {
1956 fn drop(&mut self) {
1957 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
1958 loop {
1959 match remove_lock_if(&self.path, &self.token, false, 0) {
1960 Some(_) => return,
1961 None if std::time::Instant::now() > deadline => return,
1962 None => std::thread::sleep(std::time::Duration::from_millis(5)),
1963 }
1964 }
1965 }
1966}
1967
1968#[derive(Debug, Clone)]
1970pub struct Queue {
1971 root: PathBuf,
1972}
1973
1974impl Queue {
1975 pub fn open() -> Self {
1977 Self::at(crate::run::home().join("queue"))
1978 }
1979
1980 pub fn at(root: PathBuf) -> Self {
1983 Self { root }
1984 }
1985
1986 pub fn root(&self) -> &Path {
1988 &self.root
1989 }
1990
1991 pub fn path_of(&self, id: &str) -> PathBuf {
1993 self.root.join(format!("{id}.json"))
1994 }
1995
1996 pub fn attachments_dir(&self, id: &str) -> PathBuf {
1999 self.root.join(format!("{id}.attachments"))
2000 }
2001
2002 pub fn attach(&self, task: &mut Task, sources: &[PathBuf]) -> Result<Vec<String>> {
2013 let mut wanted = Vec::new();
2014 for src in sources {
2015 let name = src
2016 .file_name()
2017 .and_then(|n| n.to_str())
2018 .with_context(|| format!("`{}` has no usable file name", src.display()))?;
2019 if !crate::ask::valid_asset_name(name) {
2020 bail!(
2021 "attachment name `{name}` must match ^[A-Za-z0-9][A-Za-z0-9._-]{{0,63}}$ \
2022 with no `..`; rename the file and try again"
2023 );
2024 }
2025 if !src.is_file() {
2026 bail!("attachment `{}` is not a file", src.display());
2027 }
2028 wanted.push((src, name));
2029 }
2030 let dir = self.attachments_dir(&task.id);
2031 let existed = dir.is_dir();
2032 std::fs::create_dir_all(&dir).with_context(|| format!("create {}", dir.display()))?;
2033 let mut created: Vec<PathBuf> = Vec::new();
2034 let mut names = Vec::new();
2035 let mut copy_all = || -> Result<()> {
2036 for (src, name) in &wanted {
2037 let (stored, path) = copy_new(&dir, src, name)?;
2038 created.push(path);
2039 names.push(stored);
2040 }
2041 Ok(())
2042 };
2043 if let Err(e) = copy_all() {
2044 for path in &created {
2045 let _ = std::fs::remove_file(path);
2046 }
2047 if !existed {
2048 let _ = std::fs::remove_dir(&dir);
2049 }
2050 return Err(e);
2051 }
2052 task.attachments.extend(names.iter().cloned());
2053 Ok(names)
2054 }
2055
2056 pub fn attach_and_put(&self, task: &mut Task, sources: &[PathBuf]) -> Result<Vec<String>> {
2067 let dir = self.attachments_dir(&task.id);
2068 let existed = dir.is_dir();
2069 let before = task.attachments.len();
2070 let names = self.attach(task, sources)?;
2071 if let Err(e) = self.put(task) {
2072 for name in &names {
2073 let _ = std::fs::remove_file(dir.join(name));
2074 }
2075 if !existed {
2076 let _ = std::fs::remove_dir(&dir);
2077 }
2078 task.attachments.truncate(before);
2079 return Err(e);
2080 }
2081 Ok(names)
2082 }
2083
2084 pub fn attachment_paths(&self, task: &Task) -> Vec<PathBuf> {
2088 let dir = self.attachments_dir(&task.id);
2089 task.attachments
2090 .iter()
2091 .map(|n| {
2092 let p = dir.join(n);
2093 std::path::absolute(&p).unwrap_or(p)
2094 })
2095 .collect()
2096 }
2097
2098 pub fn put(&self, task: &mut Task) -> Result<()> {
2107 let _lock = self.lock_task(&task.id)?;
2108 self.put_unlocked(task)
2109 }
2110
2111 pub fn create_new(&self, task: &mut Task) -> Result<bool> {
2116 let _lock = self.lock_task(&task.id)?;
2117 if self.path_of(&task.id).exists() {
2118 return Ok(false);
2119 }
2120 self.put_unlocked(task)?;
2121 Ok(true)
2122 }
2123
2124 fn put_unlocked(&self, task: &mut Task) -> Result<()> {
2126 if let Ok(mut stored) = read_path(&self.path_of(&task.id)) {
2127 stored.import_chat_notice();
2128 for key in stored.chat_notices {
2129 if !task.chat_notices.contains(&key) {
2130 task.chat_notices.push(key);
2131 }
2132 }
2133 for run in stored.runs {
2134 if !task.runs.contains(&run) {
2135 task.runs.push(run);
2136 }
2137 }
2138 for p in stored.passes {
2139 if !task
2140 .passes
2141 .iter()
2142 .any(|q| q.run == p.run && q.pass_index == p.pass_index)
2143 {
2144 task.passes.push(p);
2145 }
2146 }
2147 if let Some(n) = stored.chat_notice
2150 && task.chat_notice.as_ref().is_none_or(|mine| mine.at < n.at)
2151 {
2152 task.chat_notice = Some(n);
2153 }
2154 }
2155 task.import_chat_notice();
2156 task.updated_at = Timestamp::now();
2157 std::fs::create_dir_all(&self.root)
2158 .with_context(|| format!("create {}", self.root.display()))?;
2159 let body = serde_json::to_string_pretty(task).context("serialize task")?;
2160 let path = self.path_of(&task.id);
2161 let tmp = path.with_extension("json.tmp");
2162 std::fs::write(&tmp, &body).with_context(|| format!("write {}", tmp.display()))?;
2163 std::fs::rename(&tmp, &path).with_context(|| format!("replace {}", path.display()))?;
2164 if let (Some(notice), Some(home)) = (
2169 crate::notices::task_held(task),
2170 self.root.parent().filter(|p| !p.as_os_str().is_empty()),
2171 ) {
2172 crate::notices::raise_in(home, notice);
2173 }
2174 Ok(())
2175 }
2176
2177 pub fn modify(&self, id: &str, f: impl FnOnce(&mut Task) -> bool) -> Result<bool> {
2182 let id = self.resolve_id(id)?;
2183 let _lock = self.lock_task(&id)?;
2184 let mut task = self.get(&id)?;
2185 if !f(&mut task) {
2186 return Ok(false);
2187 }
2188 self.put_unlocked(&mut task)?;
2189 Ok(true)
2190 }
2191
2192 pub fn link_run(&self, id: &str, run: &str) -> Result<Task> {
2197 let id = self.resolve_id(id)?;
2198 let _lock = self.lock_task(&id)?;
2201 let mut task = self.get(&id)?;
2202 if task.link_run(run) {
2203 self.put_unlocked(&mut task)?;
2204 }
2205 Ok(task)
2206 }
2207
2208 fn lock_task(&self, id: &str) -> Result<TaskLock> {
2216 std::fs::create_dir_all(&self.root)
2217 .with_context(|| format!("create {}", self.root.display()))?;
2218 let path = self.root.join(format!("{id}.write-lock"));
2219 let started = std::time::Instant::now();
2220 loop {
2221 match std::fs::OpenOptions::new()
2222 .write(true)
2223 .create_new(true)
2224 .open(&path)
2225 {
2226 Ok(mut file) => {
2227 use std::io::Write;
2228 let token = owner_token();
2229 if let Err(e) = file.write_all(token.as_bytes()) {
2230 drop(file);
2231 let _ = std::fs::remove_file(&path);
2232 return Err(e).with_context(|| format!("lock {}", path.display()));
2233 }
2234 return Ok(TaskLock { path, token });
2235 }
2236 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
2237 let judged = std::fs::read_to_string(&path).ok();
2240 let broken = judged
2241 .filter(|_| older_than_stale(&path))
2242 .is_some_and(|judged| break_stale(&path, &judged));
2243 if !broken {
2247 if started.elapsed() > TASK_LOCK_STALE {
2248 bail!("could not lock task {id}");
2249 }
2250 std::thread::sleep(std::time::Duration::from_millis(15));
2251 }
2252 }
2253 Err(e)
2257 if e.kind() == std::io::ErrorKind::PermissionDenied
2258 && started.elapsed() <= TASK_LOCK_STALE =>
2259 {
2260 std::thread::sleep(std::time::Duration::from_millis(15));
2261 }
2262 Err(e) => return Err(e).with_context(|| format!("lock {}", path.display())),
2263 }
2264 }
2265 }
2266
2267 pub fn get(&self, id: &str) -> Result<Task> {
2269 let resolved = self.resolve_id(id)?;
2270 read_path(&self.path_of(&resolved))
2271 }
2272
2273 pub fn remove(&self, id: &str, in_flight: bool, questions: &Questions) -> Result<Removal> {
2305 let resolved = self.resolve_id(id)?;
2306 if in_flight {
2307 bail!("task {resolved} is being run by a live daemon right now");
2308 }
2309 self.write_tombstone(&resolved)?;
2310 if let Err(e) = self.remove_record_with_attachments(&resolved, |p| std::fs::remove_file(p))
2311 {
2312 if self.path_of(&resolved).exists() {
2316 let _ = std::fs::remove_file(self.tombstone_path(&resolved));
2317 }
2318 return Err(e);
2319 }
2320 let (released, still_blocked) = self.release_dependents_of(&resolved, questions);
2321 Ok(Removal {
2322 id: resolved,
2323 released,
2324 still_blocked,
2325 })
2326 }
2327
2328 fn tombstone_path(&self, id: &str) -> PathBuf {
2331 self.root.join(format!("{id}.removed"))
2332 }
2333
2334 fn write_tombstone(&self, id: &str) -> Result<()> {
2335 let path = self.tombstone_path(id);
2336 let tmp = self.root.join(format!("{id}.removed.tmp"));
2337 std::fs::write(&tmp, b"")
2338 .and_then(|()| std::fs::rename(&tmp, &path))
2339 .with_context(|| format!("write {}", path.display()))
2340 }
2341
2342 fn was_deleted(&self, id: &str) -> bool {
2346 self.tombstone_path(id).is_file() && !self.path_of(id).exists()
2347 }
2348
2349 pub fn apply_deleted_blockers(&self, task: &mut Task, questions: &Questions) -> Vec<String> {
2353 let deleted = deleted_blockers(self, questions, &task.blocked_by);
2354 deleted
2355 .into_iter()
2356 .filter(|id| task.dependency_deleted(id))
2357 .collect()
2358 }
2359
2360 pub fn note_dependency_deleted(&self, dependent: &Task, deleted: &str) {
2364 let Some(home) = self.root.parent().filter(|p| !p.as_os_str().is_empty()) else {
2365 return;
2366 };
2367 let ja = crate::lang::is_japanese(&crate::lang::of_repo(&dependent.repo));
2368 let waiting = dependent.status == TaskStatus::Blocked;
2369 let message = match (ja, waiting) {
2370 (true, false) => format!(
2371 "タスク {} は、待っていた {} が削除されたため待機を解除し、元の状態に戻しました",
2372 dependent.short(),
2373 short(deleted)
2374 ),
2375 (true, true) => format!(
2376 "タスク {} は、待っていた {} が削除されたため、残りの依存を待っています",
2377 dependent.short(),
2378 short(deleted)
2379 ),
2380 (false, false) => format!(
2381 "Task {} stopped waiting on {} because it was deleted, and returned to its previous state",
2382 dependent.short(),
2383 short(deleted)
2384 ),
2385 (false, true) => format!(
2386 "Task {} stopped waiting on {} because it was deleted, and is still waiting on its other dependencies",
2387 dependent.short(),
2388 short(deleted)
2389 ),
2390 };
2391 crate::notices::raise_in(
2392 home,
2393 crate::notices::Notice::info(
2394 &format!("unblocked:{}:{}", dependent.id, deleted),
2395 message,
2396 )
2397 .link(crate::notices::Link::Task {
2398 id: dependent.id.clone(),
2399 }),
2400 );
2401 }
2402
2403 fn release_dependents_of(
2408 &self,
2409 dependency: &str,
2410 questions: &Questions,
2411 ) -> (Vec<String>, Vec<String>) {
2412 let (mut released, mut still_blocked) = (Vec::new(), Vec::new());
2413 for listed in self.list() {
2414 if listed.status != TaskStatus::Blocked || !listed.blocked_by.iter().any(|b| {
2415 matches!(crate::queue::dependency(self, questions, b), Dependency::Deleted(id) if id == dependency)
2416 }) {
2417 continue;
2418 }
2419 let Ok(_claim) = self.claim(&listed.id) else {
2420 continue;
2421 };
2422 let Ok(mut task) = self.get(&listed.id) else {
2423 continue;
2424 };
2425 let references: Vec<String> = task.blocked_by.iter().filter(|id| {
2426 matches!(crate::queue::dependency(self, questions, id), Dependency::Deleted(full) if full == dependency)
2427 }).cloned().collect();
2428 if references.is_empty() {
2429 continue;
2430 }
2431 for reference in references {
2432 task.dependency_deleted(&reference);
2433 }
2434 if self.put(&mut task).is_err() {
2435 continue;
2436 }
2437 self.note_dependency_deleted(&task, dependency);
2438 if task.status == TaskStatus::Blocked {
2439 still_blocked.push(task.id.clone());
2440 } else {
2441 released.push(task.id.clone());
2442 }
2443 }
2444 (released, still_blocked)
2445 }
2446
2447 fn remove_record_with_attachments(
2451 &self,
2452 resolved: &str,
2453 remove_record: impl FnOnce(&Path) -> std::io::Result<()>,
2454 ) -> Result<()> {
2455 self.sweep_removed_attachments();
2462 let attachments = self.attachments_dir(resolved);
2463 let aside = self.root.join(format!("{resolved}.attachments.removing"));
2464 let moved = match std::fs::rename(&attachments, &aside) {
2465 Ok(()) => true,
2466 Err(e) if e.kind() == std::io::ErrorKind::NotFound => false,
2467 Err(e) => {
2468 return Err(e).with_context(|| format!("remove {}", attachments.display()));
2469 }
2470 };
2471 let path = self.path_of(resolved);
2472 if let Err(e) = remove_record(&path) {
2473 if moved {
2474 let _ = std::fs::rename(&aside, &attachments);
2475 }
2476 return Err(e).with_context(|| format!("remove {}", path.display()));
2477 }
2478 if moved {
2479 if let Err(e) = std::fs::remove_dir_all(&aside) {
2480 tracing::warn!("leftover attachments {}: {e}", aside.display());
2481 }
2482 }
2483 let lock = self.lock_path(resolved);
2484 if let Err(e) = std::fs::remove_file(&lock) {
2485 if e.kind() != std::io::ErrorKind::NotFound {
2486 return Err(e).with_context(|| format!("remove {}", lock.display()));
2487 }
2488 }
2489 Ok(())
2490 }
2491
2492 fn sweep_removed_attachments(&self) {
2498 let Ok(entries) = std::fs::read_dir(&self.root) else {
2499 return;
2500 };
2501 for entry in entries.flatten() {
2502 let name = entry.file_name();
2503 let name = name.to_string_lossy();
2504 let Some(id) = name.strip_suffix(".attachments.removing") else {
2505 continue;
2506 };
2507 if !self.path_of(id).exists() {
2510 if let Err(e) = std::fs::remove_dir_all(entry.path()) {
2511 tracing::warn!("leftover attachments {}: {e}", entry.path().display());
2512 }
2513 }
2514 }
2515 self.sweep_tombstones();
2516 }
2517
2518 fn sweep_tombstones(&self) {
2527 let Ok(entries) = std::fs::read_dir(&self.root) else {
2528 return;
2529 };
2530 let tasks = self.list();
2531 for entry in entries.flatten() {
2532 let name = entry.file_name();
2533 let name = name.to_string_lossy();
2534 let Some(id) = name.strip_suffix(".removed") else {
2535 continue;
2536 };
2537 if self.path_of(id).exists()
2538 || tasks.iter().any(|t| {
2539 t.blocked_by
2540 .iter()
2541 .any(|b| id.starts_with(b) || id.ends_with(b))
2542 })
2543 {
2544 continue;
2545 }
2546 let old = entry
2547 .metadata()
2548 .and_then(|m| m.modified())
2549 .ok()
2550 .and_then(|m| m.elapsed().ok())
2551 .is_some_and(|age| age >= TOMBSTONE_GRACE);
2552 if old {
2553 let _ = std::fs::remove_file(entry.path());
2554 }
2555 }
2556 }
2557
2558 fn lock_path(&self, id: &str) -> PathBuf {
2561 self.root.join(format!("{id}.lock"))
2562 }
2563
2564 pub fn list(&self) -> Vec<Task> {
2577 let mut tasks: Vec<Task> = std::fs::read_dir(&self.root)
2578 .into_iter()
2579 .flatten()
2580 .flatten()
2581 .map(|e| e.path())
2582 .filter(|p| p.extension().is_some_and(|x| x == "json"))
2583 .filter_map(|p| read_path(&p).ok())
2584 .collect();
2585 tasks.sort_unstable_by(|a, b| b.priority.cmp(&a.priority).then_with(|| b.id.cmp(&a.id)));
2586 tasks
2587 }
2588
2589 pub fn superseded(&self) -> HashMap<String, String> {
2602 let mut by = HashMap::new();
2603 for task in self.list() {
2604 for earlier in &task.runs {
2605 if let Some(later) = task.successor_of(earlier) {
2606 by.insert(earlier.clone(), later.clone());
2607 }
2608 }
2609 }
2610 by
2611 }
2612
2613 pub fn superseded_by(&self, run: &str) -> Option<String> {
2621 for task in self.list() {
2622 if task.runs.iter().any(|r| r == run) {
2623 return task.successor_of(run).cloned();
2624 }
2625 }
2626 None
2627 }
2628
2629 pub fn latest_attempt(&self, run: &str) -> Option<String> {
2640 for task in self.list() {
2641 if task.runs.iter().any(|r| r == run) {
2642 return task.runs.last().filter(|last| **last != run).cloned();
2643 }
2644 }
2645 None
2646 }
2647
2648 pub fn next_runnable(&self) -> Option<Task> {
2653 let mut runnable: Vec<Task> = self
2654 .list()
2655 .into_iter()
2656 .filter(|t| t.status.runnable())
2657 .collect();
2658 runnable.sort_unstable_by(|a, b| b.priority.cmp(&a.priority).then(a.id.cmp(&b.id)));
2659 runnable.into_iter().next()
2660 }
2661
2662 pub fn claim(&self, id: &str) -> Result<Claim> {
2669 std::fs::create_dir_all(&self.root)
2670 .with_context(|| format!("create {}", self.root.display()))?;
2671 let path = self.lock_path(id);
2672 match std::fs::OpenOptions::new()
2673 .write(true)
2674 .create_new(true)
2675 .open(&path)
2676 {
2677 Ok(mut f) => {
2678 use std::io::Write as _;
2679 let _ = writeln!(f, "{}", std::process::id());
2681 Ok(Claim { path })
2682 }
2683 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
2684 bail!("task {id} is already claimed ({} exists)", path.display())
2685 }
2686 Err(e) => Err(e).with_context(|| format!("lock {}", path.display())),
2687 }
2688 }
2689
2690 pub fn resolve_id(&self, prefix: &str) -> Result<String> {
2692 if !prefix.is_empty()
2693 && prefix != "."
2694 && prefix != ".."
2695 && !prefix.contains(['/', '\\'])
2696 && self.path_of(prefix).is_file()
2697 {
2698 return Ok(prefix.to_owned());
2699 }
2700 let ids = record_ids(&self.root, "json")?;
2701 match resolve_reference(prefix, ids.iter().map(String::as_str)) {
2702 Reference::Found(id) => Ok(id),
2703 Reference::Missing => bail!("no task matches `{prefix}`"),
2704 Reference::Ambiguous => bail!("`{prefix}` matches multiple tasks"),
2705 }
2706 }
2707
2708 pub fn revision(&self) -> u64 {
2715 self.revision_excluding(&std::collections::BTreeSet::new())
2716 }
2717
2718 pub fn revision_excluding(&self, skip: &std::collections::BTreeSet<String>) -> u64 {
2721 use std::hash::{Hash as _, Hasher as _};
2722
2723 let mut entries: Vec<(String, u64)> = std::fs::read_dir(&self.root)
2724 .into_iter()
2725 .flatten()
2726 .flatten()
2727 .filter(|e| e.path().extension().is_some_and(|ext| ext == "json"))
2728 .filter(|e| {
2729 let path = e.path();
2730 !path
2731 .file_stem()
2732 .is_some_and(|stem| skip.contains(stem.to_string_lossy().as_ref()))
2733 })
2734 .filter_map(|e| {
2735 let name = e.file_name().to_string_lossy().into_owned();
2736 let mtime = e
2737 .metadata()
2738 .ok()?
2739 .modified()
2740 .ok()?
2741 .duration_since(std::time::UNIX_EPOCH)
2742 .ok()?
2743 .as_millis() as u64;
2744 Some((name, mtime))
2745 })
2746 .collect();
2747
2748 if entries.is_empty() {
2749 return 0;
2750 }
2751
2752 entries.sort_unstable();
2753 let mut hasher = std::hash::DefaultHasher::new();
2754 for (name, mtime) in &entries {
2755 name.hash(&mut hasher);
2756 mtime.hash(&mut hasher);
2757 }
2758 let h = hasher.finish();
2759 if h == 0 { 1 } else { h }
2760 }
2761}
2762
2763const TOMBSTONE_GRACE: std::time::Duration = std::time::Duration::from_secs(3600);
2766
2767#[derive(Debug, Clone)]
2769pub struct Removal {
2770 pub id: String,
2772 pub released: Vec<String>,
2776 pub still_blocked: Vec<String>,
2779}
2780
2781#[derive(Debug)]
2783pub struct Claim {
2784 path: PathBuf,
2785}
2786
2787impl Drop for Claim {
2788 fn drop(&mut self) {
2789 let _ = std::fs::remove_file(&self.path);
2790 }
2791}
2792
2793pub fn title_from(instruction: &str, max: usize) -> String {
2796 let Some(line) = first_line(instruction) else {
2797 return "(empty task)".to_owned();
2798 };
2799 if line.chars().count() <= max {
2800 return line.to_owned();
2801 }
2802 let head: String = line.chars().take(max.saturating_sub(1)).collect();
2803 format!("{head}…")
2804}
2805
2806pub(crate) fn first_line(instruction: &str) -> Option<&str> {
2812 let line = instruction
2813 .lines()
2814 .map(str::trim)
2815 .find(|l| !l.is_empty())?
2816 .trim_start_matches(['#', '-', '*', '>', ' '])
2817 .trim();
2818 (!line.is_empty()).then_some(line)
2819}
2820
2821fn read_path(path: &Path) -> Result<Task> {
2822 let body = std::fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
2823 let task: Task =
2824 serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))?;
2825 if task.schema > SCHEMA {
2831 bail!(
2832 "task {} was written by a different magi (schema {}, this build \
2833 speaks {SCHEMA})",
2834 task.id,
2835 task.schema
2836 );
2837 }
2838 Ok(task)
2839}
2840
2841#[derive(Debug, PartialEq, Eq)]
2844pub(crate) enum Reference {
2845 Found(String),
2846 Missing,
2847 Ambiguous,
2848}
2849
2850pub(crate) fn resolve_reference<'a>(id: &str, ids: impl Iterator<Item = &'a str>) -> Reference {
2851 let mut hits = Vec::new();
2852 for candidate in ids {
2853 if candidate == id {
2854 return Reference::Found(candidate.to_owned());
2855 }
2856 if candidate.starts_with(id) || candidate.ends_with(id) {
2857 hits.push(candidate);
2858 }
2859 }
2860 match hits.as_slice() {
2861 [] => Reference::Missing,
2862 [one] => Reference::Found((*one).to_owned()),
2863 _ => Reference::Ambiguous,
2864 }
2865}
2866
2867pub(crate) fn record_ids(root: &Path, extension: &str) -> Result<Vec<String>> {
2870 let entries = match std::fs::read_dir(root) {
2871 Ok(entries) => entries,
2872 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
2873 Err(e) => return Err(e.into()),
2874 };
2875 let mut ids = Vec::new();
2876 for entry in entries {
2877 let path = entry?.path();
2878 if path.extension().is_some_and(|e| e == extension)
2879 && let Some(id) = path.file_stem().and_then(|s| s.to_str())
2880 {
2881 ids.push(id.to_owned());
2882 }
2883 }
2884 Ok(ids)
2885}
2886
2887#[derive(Debug, PartialEq, Eq)]
2890pub enum Dependency {
2891 Task(String),
2893 Question(String),
2895 Deleted(String),
2897 Missing,
2899 Ambiguous,
2901 Unknown,
2903}
2904
2905pub fn dependency(queue: &Queue, questions: &Questions, id: &str) -> Dependency {
2907 let (Ok(tasks), Ok(qs), Ok(removed)) = (
2908 record_ids(queue.root(), "json"),
2909 record_ids(questions.root(), "json"),
2910 record_ids(queue.root(), "removed"),
2911 ) else {
2912 return Dependency::Unknown;
2913 };
2914 let mut ids = tasks
2917 .iter()
2918 .chain(qs.iter())
2919 .chain(removed.iter())
2920 .cloned()
2921 .collect::<Vec<_>>();
2922 ids.sort();
2923 ids.dedup();
2924 match resolve_reference(id, ids.iter().map(String::as_str)) {
2925 Reference::Found(id) if tasks.contains(&id) => Dependency::Task(id),
2926 Reference::Found(id) if qs.contains(&id) => Dependency::Question(id),
2927 Reference::Found(id) if queue.was_deleted(&id) => Dependency::Deleted(id),
2928 Reference::Found(_) => Dependency::Unknown,
2929 Reference::Missing => Dependency::Missing,
2930 Reference::Ambiguous => Dependency::Ambiguous,
2931 }
2932}
2933
2934pub fn normalize_blockers(queue: &Queue, questions: &Questions, ids: &mut [String]) {
2936 for id in ids {
2937 if let Dependency::Task(full) | Dependency::Question(full) | Dependency::Deleted(full) =
2938 dependency(queue, questions, id)
2939 {
2940 *id = full;
2941 }
2942 }
2943}
2944
2945pub fn dependency_facts(queue: &Queue, questions: &Questions) -> String {
2948 let inventory = crate::blockers::Inventory::load(queue, questions);
2949 let mut text = String::from(
2950 "Current authoritative queue (historical missing-task claims may be stale; unreadable or ambiguous references must keep waiting):\n",
2951 );
2952 match record_ids(queue.root(), "json") {
2953 Ok(ids) => {
2954 for id in ids {
2955 match queue.get(&id) {
2956 Ok(task) => text.push_str(&format!(
2957 "{}: {}; blocked_by={:?}; chain={:?}\n",
2958 task.id,
2959 task.status.as_str(),
2960 task.blocked_by,
2961 inventory.waits_on(&task)
2962 )),
2963 Err(_) => text.push_str(&format!("{id}: exists, state unreadable\n")),
2964 }
2965 }
2966 }
2967 Err(_) => text.push_str("Queue inventory unreadable: existence is unknown.\n"),
2968 }
2969 text
2970}
2971
2972pub fn missing_blockers(
2974 queue: &Queue,
2975 questions: &Questions,
2976 blocked_by: &[String],
2977) -> Vec<String> {
2978 blocked_by
2979 .iter()
2980 .filter(|id| dependency(queue, questions, id) == Dependency::Missing)
2981 .cloned()
2982 .collect()
2983}
2984
2985pub fn deleted_blockers(
2988 queue: &Queue,
2989 questions: &Questions,
2990 blocked_by: &[String],
2991) -> Vec<String> {
2992 blocked_by
2993 .iter()
2994 .filter(|id| matches!(dependency(queue, questions, id), Dependency::Deleted(_)))
2995 .cloned()
2996 .collect()
2997}
2998
2999pub fn missing_blocker_hold_reason(blocked_by: &[String], missing: &[String]) -> String {
3011 missing_blocker_hold_reason_in(blocked_by, missing, "en")
3012}
3013
3014pub fn missing_blocker_hold_reason_in(
3016 blocked_by: &[String],
3017 missing: &[String],
3018 language: &str,
3019) -> String {
3020 if crate::lang::is_japanese(language) {
3021 format!(
3022 "{} を待っていましたが、{} はディスク上に存在しません - `magi task triage` を参照",
3023 blocked_by.join(", "),
3024 missing.join(", "),
3025 )
3026 } else {
3027 format!(
3028 "blocked on {} but {} no longer exist(s) on disk - see `magi task triage`",
3029 blocked_by.join(", "),
3030 missing.join(", "),
3031 )
3032 }
3033}
3034
3035pub fn short(id: &str) -> &str {
3037 id.split('-').next_back().unwrap_or(id)
3038}
3039
3040fn new_id() -> String {
3041 let stamp = jiff::Zoned::now().strftime("%Y%m%d-%H%M%S");
3042 let seed = crate::rng::entropy();
3043 format!("{stamp}-{:04x}", (seed ^ (seed >> 32)) & 0xffff)
3044}
3045
3046#[cfg(test)]
3047mod tests {
3048 #[test]
3049 fn explicit_resumption_is_not_invented_for_legacy_or_automatic_release() {
3050 let t = Task::new(
3051 "retry".into(),
3052 "do it".into(),
3053 PathBuf::from("."),
3054 Source::Human,
3055 );
3056 let mut value = serde_json::to_value(&t).unwrap();
3057 for field in ["resumption", "resumption_history", "operator_decided_at"] {
3058 value.as_object_mut().unwrap().remove(field);
3059 }
3060 value["schema"] = serde_json::json!(15);
3061 let mut legacy: Task = serde_json::from_value(value).unwrap();
3062 legacy.hold_machine(Some("automatic retry".into()));
3063 legacy.release();
3064 assert!(legacy.resumption.is_none());
3065 assert!(legacy.resumption_history.is_empty());
3066 assert!(legacy.operator_decided_at.is_none());
3067 legacy.release_operator("cli", None);
3068 legacy.start("run".into());
3069 let reloaded: Task =
3070 serde_json::from_str(&serde_json::to_string(&legacy).unwrap()).unwrap();
3071 assert!(reloaded.resume_override.is_none());
3072 assert_eq!(reloaded.resumption.as_ref().unwrap().generation, 1);
3073 }
3074
3075 #[test]
3076 fn merge_over_lets_the_later_choice_win_and_keeps_the_rest() {
3077 let mut base = RunOverrides {
3078 merge: Some("pr".to_owned()),
3079 candidates: Some(3),
3080 ..RunOverrides::default()
3081 };
3082 base.merge_over(&RunOverrides {
3083 merge: Some("none".to_owned()),
3084 seed: Some(7),
3085 ..RunOverrides::default()
3086 });
3087 assert_eq!(base.merge.as_deref(), Some("none"));
3088 assert_eq!(base.candidates, Some(3));
3089 assert_eq!(base.seed, Some(7));
3090 }
3091
3092 #[test]
3093 fn an_already_landed_task_is_done_with_its_attempt_refunded() {
3094 let mut t = task("relanded");
3095 t.attempts = 1;
3096 t.status = TaskStatus::Running;
3097 t.already_landed("already in main as 0e368de");
3098 assert_eq!(t.status, TaskStatus::Done);
3099 assert_eq!(t.attempts, 0);
3100 assert!(t.hold_reason.is_none());
3101 assert_eq!(t.last_error.as_deref(), Some("already in main as 0e368de"));
3102 }
3103
3104 #[test]
3105 fn missing_blocker_reason_follows_the_language() {
3106 let b = vec!["a".to_owned()];
3107 let en = missing_blocker_hold_reason_in(&b, &b, "en");
3108 assert_eq!(en, missing_blocker_hold_reason(&b, &b));
3109 assert!(en.starts_with("blocked on a"));
3110 assert!(missing_blocker_hold_reason_in(&b, &b, "ja").contains("存在しません"));
3111 assert_eq!(missing_blocker_hold_reason_in(&b, &b, "de"), en);
3112 }
3113
3114 use super::*;
3115
3116 #[test]
3117 fn triage_applied_survives_release_and_old_records_read_as_empty() {
3118 let mut t = Task::new(
3119 "t".to_owned(),
3120 "i".to_owned(),
3121 PathBuf::from("r"),
3122 Source::Human,
3123 );
3124 t.mark_triage_applied("q1");
3125 t.mark_triage_applied("q1");
3126 t.hold_machine(Some("x".to_owned()));
3127 t.release();
3128 assert_eq!(t.triage_applied, ["q1"]);
3129 assert!(t.triage_applied("q1") && !t.triage_applied("q2"));
3130
3131 let mut v = serde_json::to_value(&t).unwrap();
3132 v.as_object_mut().unwrap().remove("triage_applied");
3133 let old: Task = serde_json::from_value(v).unwrap();
3134 assert!(old.triage_applied.is_empty());
3135 }
3136
3137 #[test]
3138 fn task_counts_of_empty_is_all_zero() {
3139 assert_eq!(TaskCounts::of(&[]), TaskCounts::default());
3140 }
3141
3142 #[test]
3143 fn task_counts_of_tallies_every_status() {
3144 let mut queued = Task::new(
3145 "q".to_owned(),
3146 "i".to_owned(),
3147 PathBuf::from("."),
3148 Source::Human,
3149 );
3150 queued.status = TaskStatus::Queued;
3151 let mut running = queued.clone();
3152 running.status = TaskStatus::Running;
3153 let mut done = queued.clone();
3154 done.status = TaskStatus::Done;
3155 let mut failed = queued.clone();
3156 failed.status = TaskStatus::Failed;
3157 let mut held = queued.clone();
3158 held.status = TaskStatus::Held;
3159 let mut blocked = queued.clone();
3160 blocked.status = TaskStatus::Blocked;
3161
3162 let mut parked = queued.clone();
3163 parked.status = TaskStatus::Parked;
3164
3165 let counts = TaskCounts::of(&[
3166 queued,
3167 running,
3168 done.clone(),
3169 done,
3170 failed,
3171 held,
3172 blocked,
3173 parked,
3174 ]);
3175 assert_eq!(
3176 counts,
3177 TaskCounts {
3178 queued: 1,
3179 running: 1,
3180 done: 2,
3181 failed: 1,
3182 held: 1,
3183 blocked: 1,
3184 parked: 1,
3185 }
3186 );
3187 }
3188
3189 #[test]
3190 fn task_implementers_persist_normalized_without_losing_history() {
3191 let (_dir, q) = queue();
3192 let mut t = task("keep me");
3193 t.hold_manual(Some("waiting".into()));
3194 t.runs.push("earlier-run".into());
3195 t.attachments.push("reference.txt".into());
3196 let cfg: crate::config::Config =
3197 toml::from_str("[[agents]]\nid = 'mock'\nkind = 'command'\ncommand = ['true']\n")
3198 .unwrap();
3199 q.put(&mut t).unwrap();
3200 let original = serde_json::to_value(&t).unwrap();
3201 for n in [2, 1, 2] {
3202 t.set_implementers(n, &cfg).unwrap();
3203 q.put(&mut t).unwrap();
3204 t = q.get(&t.id).unwrap();
3205 assert_eq!(t.implementers(), Some(usize::from(n)));
3206 assert_eq!(t.solo, n == 1);
3207 if n == 1 {
3208 assert!(t.overrides.is_none());
3209 }
3210 let mut actual = serde_json::to_value(&t).unwrap();
3211 for field in ["solo", "overrides", "updated_at"] {
3212 actual[field] = original[field].clone();
3213 }
3214 assert_eq!(actual, original);
3215 }
3216 t.overrides.as_mut().unwrap().judges = Some(2);
3217 t.set_implementers(1, &cfg).unwrap();
3218 assert_eq!(t.overrides.as_ref().unwrap().judges, Some(2));
3219 assert_eq!(t.overrides.as_ref().unwrap().candidates, None);
3220 }
3221
3222 #[test]
3223 fn task_implementers_old_records_keep_inheritance_until_changed() {
3224 let mut value = serde_json::to_value(task("old task")).unwrap();
3225 value.as_object_mut().unwrap().remove("solo");
3226 value.as_object_mut().unwrap().remove("overrides");
3227 let mut old: Task = serde_json::from_value(value).unwrap();
3228 assert_eq!(old.implementers(), None);
3229 old.set_implementers(1, &crate::config::Config::default())
3230 .unwrap();
3231 assert!(old.solo);
3232 assert!(old.overrides.is_none());
3233 }
3234
3235 #[test]
3236 fn task_implementers_reject_invalid_counts_and_states() {
3237 let cfg = crate::config::Config::default();
3238 let mut t = task("unchanged");
3239 for n in [0, 4, 255] {
3240 assert!(t.set_implementers(n, &cfg).is_err());
3241 }
3242 for status in [
3243 TaskStatus::Running,
3244 TaskStatus::Done,
3245 TaskStatus::Failed,
3246 TaskStatus::Blocked,
3247 TaskStatus::Parked,
3248 ] {
3249 t.status = status;
3250 let before = serde_json::to_value(&t).unwrap();
3251 assert!(t.set_implementers(1, &cfg).is_err());
3252 assert_eq!(serde_json::to_value(&t).unwrap(), before);
3253 }
3254 }
3255
3256 fn queue() -> (tempfile::TempDir, Queue) {
3259 let dir = tempfile::tempdir().unwrap();
3260 let q = Queue::at(dir.path().join("queue"));
3261 (dir, q)
3262 }
3263
3264 #[test]
3265 fn putting_a_machine_held_task_files_a_notification_beside_the_queue() {
3266 let dir = tempfile::tempdir().unwrap();
3267 let q = Queue::at(dir.path().join("queue"));
3268 let mut t = task("held");
3269 q.put(&mut t).unwrap();
3270 assert_eq!(
3271 crate::notices::Notices::at(dir.path().join("notifications"))
3272 .list()
3273 .len(),
3274 0
3275 );
3276 t.hold_machine(Some("out of attempts".to_owned()));
3277 q.put(&mut t).unwrap();
3278 let listed = crate::notices::Notices::at(dir.path().join("notifications")).list();
3279 assert_eq!(listed.len(), 1);
3280 assert!(listed[0].message.contains("out of attempts"));
3281 }
3282
3283 fn task(title: &str) -> Task {
3284 Task::new(
3285 title.to_owned(),
3286 format!("do {title}"),
3287 PathBuf::from("."),
3288 Source::Human,
3289 )
3290 }
3291
3292 #[test]
3293 fn attempt_limit_inherits_old_records_and_controls_failure() {
3294 let mut t = task("budget");
3295 let mut old = serde_json::to_value(&t).unwrap();
3296 old.as_object_mut().unwrap().remove("attempt_limit");
3297 old["schema"] = serde_json::json!(15);
3298 let legacy: Task = serde_json::from_value(old).unwrap();
3299 assert_eq!(legacy.attempt_limit, None);
3300 assert_eq!(legacy.effective_attempt_limit(2), 2);
3301 t.attempts = 2;
3302 t.change_attempt_limit(NonZeroUsize::new(3)).unwrap();
3303 t.fail("retry", 1);
3304 assert_eq!(t.status, TaskStatus::Failed);
3305 t.attempts = 3;
3306 t.fail("stop", 10);
3307 assert_eq!(t.status, TaskStatus::Held);
3308 t.change_attempt_limit(None).unwrap();
3309 assert_eq!(t.status, TaskStatus::Held);
3310 assert_eq!(t.attempts, 3);
3311 t.fail("inherit", 4);
3312 assert_eq!(t.status, TaskStatus::Failed);
3313 for status in [
3314 TaskStatus::Running,
3315 TaskStatus::Done,
3316 TaskStatus::Failed,
3317 TaskStatus::Blocked,
3318 TaskStatus::Parked,
3319 ] {
3320 t.status = status;
3321 assert!(t.change_attempt_limit(NonZeroUsize::new(1)).is_err());
3322 assert_eq!(t.attempt_limit, None);
3323 }
3324 }
3325
3326 #[test]
3327 fn earlier_attempts_is_every_recorded_run_and_agrees_with_the_display() {
3328 let mut t = task("retried");
3329 assert!(
3330 t.earlier_attempts().is_empty(),
3331 "a first attempt takes nothing over"
3332 );
3333 t.runs = vec!["aaaa".to_owned(), "bbbb".to_owned()];
3334 assert_eq!(t.earlier_attempts(), ["aaaa", "bbbb"]);
3335 assert_eq!(t.successor_of("aaaa"), Some(&"bbbb".to_owned()));
3337 assert_eq!(t.successor_of("bbbb"), None);
3338 assert_eq!(t.successor_of("zzzz"), None);
3339 }
3340
3341 #[test]
3342 fn resumed_runs_are_superseded_only_after_their_last_occurrence() {
3343 let (_dir, q) = queue();
3344 let mut t = task("resumed");
3345 for (runs, successor) in [
3346 (vec!["aaaa", "aaaa"], None),
3347 (vec!["aaaa", "bbbb", "aaaa"], None),
3348 (vec!["aaaa", "aaaa", "bbbb"], Some("bbbb")),
3349 (vec!["aaaa", "bbbb", "aaaa", "cccc"], Some("cccc")),
3350 ] {
3351 t.runs = runs.into_iter().map(str::to_owned).collect();
3352 q.put(&mut t).unwrap();
3353
3354 assert_eq!(t.successor_of("aaaa").map(String::as_str), successor);
3355 assert_eq!(q.superseded_by("aaaa").as_deref(), successor);
3356 assert_eq!(q.superseded().get("aaaa").map(String::as_str), successor);
3357 }
3358 }
3359
3360 #[test]
3361 fn superseded_by_names_the_next_attempt_and_none_for_the_last() {
3362 let (_dir, q) = queue();
3363 let mut t = task("retried");
3364 t.runs = vec!["aaaa".to_owned(), "bbbb".to_owned(), "cccc".to_owned()];
3365 q.put(&mut t).unwrap();
3366
3367 assert_eq!(q.superseded_by("aaaa"), Some("bbbb".to_owned()));
3368 assert_eq!(q.superseded_by("bbbb"), Some("cccc".to_owned()));
3369 assert_eq!(
3370 q.superseded_by("cccc"),
3371 None,
3372 "the latest attempt replaces nothing"
3373 );
3374 assert_eq!(
3375 q.superseded_by("never-heard-of-it"),
3376 None,
3377 "a run belonging to no task on this queue is not superseded"
3378 );
3379
3380 let mut by = HashMap::new();
3381 by.insert("aaaa".to_owned(), "bbbb".to_owned());
3382 by.insert("bbbb".to_owned(), "cccc".to_owned());
3383 assert_eq!(
3384 q.superseded(),
3385 by,
3386 "the whole-map and single-run forms must agree"
3387 );
3388 }
3389
3390 #[test]
3391 fn superseded_attempts_is_empty_until_the_task_is_done() {
3392 let mut t = task("retried");
3393 t.runs = vec!["aaaa".to_owned(), "bbbb".to_owned()];
3394 t.status = TaskStatus::Failed;
3395 assert_eq!(
3396 t.superseded_attempts(true),
3397 &[] as &[String],
3398 "a task still retrying has no attempt yet that a later one made moot"
3399 );
3400
3401 t.status = TaskStatus::Running;
3402 assert_eq!(t.superseded_attempts(true), &[] as &[String]);
3403 }
3404
3405 #[test]
3406 fn superseded_attempts_names_every_run_before_the_one_that_succeeded() {
3407 let mut t = task("retried");
3408 t.runs = vec!["aaaa".to_owned(), "bbbb".to_owned(), "cccc".to_owned()];
3409 t.status = TaskStatus::Done;
3410 assert_eq!(
3411 t.superseded_attempts(true),
3412 &["aaaa".to_owned(), "bbbb".to_owned()],
3413 "cccc is the attempt whose success made the task done, and stays out"
3414 );
3415 }
3416
3417 #[test]
3418 fn superseded_attempts_is_empty_for_a_done_task_with_only_one_attempt() {
3419 let mut t = task("first try landed");
3420 t.runs = vec!["aaaa".to_owned()];
3421 t.status = TaskStatus::Done;
3422 assert_eq!(t.superseded_attempts(true), &[] as &[String]);
3423 }
3424
3425 #[test]
3426 fn superseded_attempts_is_empty_when_the_last_run_never_actually_succeeded() {
3427 let mut t = task("closed by hand after a manual merge");
3434 t.runs = vec!["aaaa".to_owned(), "bbbb".to_owned()];
3435 t.status = TaskStatus::Done;
3436 assert_eq!(
3437 t.superseded_attempts(false),
3438 &[] as &[String],
3439 "nothing here is provably why the task is done, so nothing is superseded"
3440 );
3441 }
3442
3443 #[test]
3444 fn latest_attempt_names_the_chain_s_current_head_not_just_the_next_one() {
3445 let (_dir, q) = queue();
3446 let mut t = task("retried twice");
3447 t.runs = vec!["aaaa".to_owned(), "bbbb".to_owned(), "cccc".to_owned()];
3448 q.put(&mut t).unwrap();
3449
3450 assert_eq!(
3451 q.latest_attempt("aaaa"),
3452 Some("cccc".to_owned()),
3453 "an old attempt points straight at the chain's current head, not the \
3454 next attempt in the middle of it"
3455 );
3456 assert_eq!(q.latest_attempt("bbbb"), Some("cccc".to_owned()));
3457 assert_eq!(
3458 q.latest_attempt("cccc"),
3459 None,
3460 "the latest attempt is not superseded by anything"
3461 );
3462 assert_eq!(
3463 q.latest_attempt("never-heard-of-it"),
3464 None,
3465 "a run belonging to no task on this queue is not superseded"
3466 );
3467 }
3468
3469 #[test]
3470 fn a_markdown_heading_is_the_title_not_decoration() {
3471 assert_eq!(
3476 title_from("# Rework the config loader\n\nIt re-reads it.\n", 40),
3477 "Rework the config loader"
3478 );
3479 assert_eq!(title_from("- fix the thing", 40), "fix the thing");
3480 assert_eq!(title_from("> quoted task", 40), "quoted task");
3481 assert_eq!(title_from(" \n\n", 40), "(empty task)");
3483 assert_eq!(title_from("###\n", 40), "(empty task)");
3484 }
3485
3486 #[test]
3487 fn a_long_title_is_elided_by_characters_not_bytes() {
3488 let long = "課題".repeat(30);
3490 let title = title_from(&long, 10);
3491 assert_eq!(title.chars().count(), 10);
3492 assert!(title.ends_with('…'));
3493 }
3494
3495 #[test]
3496 fn priority_wins_and_ties_break_oldest_first() {
3497 let (_dir, q) = queue();
3498 let mut a = task("first");
3499 let mut b = task("second");
3500 let mut c = task("urgent");
3501 a.id = "20260101-000001-aaaa".to_owned();
3503 b.id = "20260101-000002-bbbb".to_owned();
3504 c.id = "20260101-000003-cccc".to_owned();
3505 c.priority = 5;
3506 for t in [&mut a, &mut b, &mut c] {
3507 q.put(t).unwrap();
3508 }
3509
3510 assert_eq!(q.next_runnable().unwrap().id, c.id);
3512 c.hold_machine(None);
3513 q.put(&mut c).unwrap();
3514 assert_eq!(q.next_runnable().unwrap().id, a.id);
3516 assert_eq!(q.list().len(), 3, "b is still waiting its turn");
3517 }
3518
3519 #[test]
3520 fn a_blocked_task_never_starves_another_runnable_one() {
3521 let (_dir, q) = queue();
3522 let mut blocked = task("blocked");
3523 blocked.block(vec!["something".to_owned()], None);
3524 q.put(&mut blocked).unwrap();
3525
3526 let mut runnable = task("free to go");
3527 q.put(&mut runnable).unwrap();
3528
3529 let next = q.next_runnable().expect("a runnable task is still offered");
3530 assert_eq!(next.id, runnable.id);
3531 }
3532
3533 #[test]
3534 fn a_held_task_is_never_offered_to_the_loop() {
3535 let (_dir, q) = queue();
3536 let mut t = task("held");
3537 q.put(&mut t).unwrap();
3538 assert!(q.next_runnable().is_some());
3539
3540 t.hold_machine(None);
3541 q.put(&mut t).unwrap();
3542 assert!(
3543 q.next_runnable().is_none(),
3544 "a held task must wait for a human"
3545 );
3546
3547 t.status = TaskStatus::Failed;
3549 q.put(&mut t).unwrap();
3550 assert!(q.next_runnable().is_some());
3551 }
3552
3553 #[test]
3554 fn attempts_are_capped_and_then_the_task_is_held() {
3555 let mut t = task("doomed");
3556
3557 t.start("run-1".to_owned());
3558 t.fail("gate red", 2);
3559 assert_eq!(t.status, TaskStatus::Failed, "one attempt of two: retry");
3560
3561 t.start("run-2".to_owned());
3562 t.fail("gate red", 2);
3563 assert_eq!(
3564 t.status,
3565 TaskStatus::Held,
3566 "out of attempts: stop spending money on it"
3567 );
3568 assert_eq!(t.runs, ["run-1", "run-2"]);
3569 assert_eq!(t.last_error.as_deref(), Some("gate red"));
3570 assert_eq!(
3571 t.hold_reason.as_deref(),
3572 Some("gate red"),
3573 "the hold must say why, not leave hold_reason null next to a \
3574 populated last_error"
3575 );
3576 }
3577
3578 #[test]
3579 fn handing_off_a_task_records_a_hold_reason_too() {
3580 let mut t = task("left a pull request");
3581 t.start("run-1".to_owned());
3582 t.handed_off("run ended with a pull request open [run run-1]");
3583 assert_eq!(t.status, TaskStatus::Held);
3584 assert_eq!(t.hold_source, Some(HoldSource::Machine));
3585 assert_eq!(
3586 t.hold_reason.as_deref(),
3587 Some("run ended with a pull request open [run run-1]")
3588 );
3589 assert_eq!(t.hold_reason, t.last_error);
3590 }
3591
3592 #[test]
3593 fn a_quota_stall_is_refunded_so_the_backlog_survives_the_night() {
3594 let mut t = task("stalled by quota");
3595
3596 t.start("run-1".to_owned());
3597 assert_eq!(t.attempts, 1);
3598 t.stall("judge-1, judge-2 out of quota");
3599 assert_eq!(
3600 t.attempts, 0,
3601 "a closed quota window must not spend the task's retry budget"
3602 );
3603 assert_eq!(t.status, TaskStatus::Failed, "the loop should retry it");
3604 assert_eq!(
3605 t.last_error.as_deref(),
3606 Some("judge-1, judge-2 out of quota")
3607 );
3608
3609 for _ in 0..20 {
3612 t.start("run-n".to_owned());
3613 t.stall("still out of quota");
3614 }
3615 t.start("run-real".to_owned());
3616 t.fail("gate red", 2);
3617 assert_eq!(
3618 t.status,
3619 TaskStatus::Failed,
3620 "the first attempt that was really judged is attempt one"
3621 );
3622 }
3623
3624 #[test]
3625 fn releasing_a_held_task_gives_it_a_real_second_chance() {
3626 let mut t = task("retry me");
3627 t.start("run-1".to_owned());
3628 t.fail("gate red", 1);
3629 assert_eq!(t.status, TaskStatus::Held);
3630
3631 t.release();
3632 assert_eq!(t.status, TaskStatus::Queued);
3633 assert_eq!(t.attempts, 0);
3636 assert!(t.last_error.is_none());
3637 assert_eq!(
3638 t.runs.len(),
3639 1,
3640 "history is kept: attempts reset, evidence does not"
3641 );
3642 }
3643
3644 #[test]
3645 fn a_hold_reason_survives_and_a_release_clears_it() {
3646 let mut t = task("waiting on something else");
3647 t.hold_manual(Some(
3648 "waiting for 20260101-000000-aaaa to land first".to_owned(),
3649 ));
3650 assert_eq!(t.status, TaskStatus::Held);
3651 assert_eq!(
3652 t.hold_reason.as_deref(),
3653 Some("waiting for 20260101-000000-aaaa to land first")
3654 );
3655
3656 t.hold_manual(None);
3658 assert_eq!(
3659 t.hold_reason.as_deref(),
3660 Some("waiting for 20260101-000000-aaaa to land first"),
3661 "a bare re-hold keeps whatever a human already wrote down"
3662 );
3663
3664 let mut plain = task("no reason given");
3666 plain.hold_manual(None);
3667 assert_eq!(plain.status, TaskStatus::Held);
3668 assert!(plain.hold_reason.is_none());
3669
3670 t.release();
3671 assert_eq!(t.status, TaskStatus::Queued);
3672 assert!(
3673 t.hold_reason.is_none(),
3674 "a stale reason must not greet the next person who holds this task"
3675 );
3676 }
3677
3678 #[test]
3679 fn closing_a_held_task_as_done_clears_its_hold_reason_too() {
3680 let mut t = task("landed by hand while held");
3685 t.hold_manual(Some("waiting on 3ed9".to_owned()));
3686 assert_eq!(t.hold_reason.as_deref(), Some("waiting on 3ed9"));
3687
3688 t.succeed();
3689 assert_eq!(t.status, TaskStatus::Done);
3690 assert!(
3691 t.hold_reason.is_none(),
3692 "a done task cannot still be waiting on something"
3693 );
3694 }
3695
3696 #[test]
3697 fn holding_or_closing_a_blocked_task_clears_its_dependency_too() {
3698 let mut held = task("held straight out of blocked");
3705 held.block(
3706 vec!["20260101-000000-dead".to_owned()],
3707 Some("waiting on the migration script".to_owned()),
3708 );
3709 assert_eq!(held.status, TaskStatus::Blocked);
3710
3711 held.hold_manual(None);
3712 assert_eq!(held.status, TaskStatus::Held);
3713 assert!(
3714 held.blocked_by.is_empty(),
3715 "hold overrides the wait, same as release"
3716 );
3717 assert!(held.block_reason.is_none());
3718
3719 let mut done = task("closed straight out of blocked");
3720 done.block(
3721 vec!["20260101-000000-dead".to_owned()],
3722 Some("waiting on the migration script".to_owned()),
3723 );
3724 done.succeed();
3725 assert_eq!(done.status, TaskStatus::Done);
3726 assert!(
3727 done.blocked_by.is_empty(),
3728 "a done task cannot still be waiting on a dependency"
3729 );
3730 assert!(done.block_reason.is_none());
3731 }
3732
3733 #[test]
3734 fn a_blocked_task_is_never_offered_to_the_loop() {
3735 let mut t = task("blocked");
3736 assert!(t.status.runnable());
3737 t.block(
3738 vec!["dep-id".to_owned()],
3739 Some("waits on dep-id".to_owned()),
3740 );
3741 assert_eq!(t.status, TaskStatus::Blocked);
3742 assert!(!t.status.runnable());
3743 assert_eq!(TaskStatus::Blocked.as_str(), "blocked");
3744 }
3745
3746 #[test]
3747 fn unblocking_the_last_dependency_returns_the_task_to_queued() {
3748 let mut t = task("blocked on two");
3749 t.block(
3750 vec!["a".to_owned(), "b".to_owned()],
3751 Some("waits on a and b".to_owned()),
3752 );
3753
3754 t.unblock("a");
3755 assert_eq!(t.status, TaskStatus::Blocked, "b is still outstanding");
3756 assert_eq!(t.blocked_by, ["b"]);
3757
3758 t.unblock("b");
3759 assert_eq!(t.status, TaskStatus::Queued);
3760 assert!(t.blocked_by.is_empty());
3761 assert!(t.block_reason.is_none());
3762 }
3763
3764 #[test]
3765 fn unblocking_an_id_on_a_task_that_is_not_blocked_is_a_no_op() {
3766 let mut t = task("never blocked");
3767 t.unblock("whatever");
3768 assert_eq!(t.status, TaskStatus::Queued);
3769 }
3770
3771 #[test]
3772 fn a_held_task_blocked_on_a_question_returns_to_held_not_queued() {
3773 let mut t = task("held, then asked about");
3778 t.hold_machine(Some("out of attempts".to_owned()));
3779 assert_eq!(t.status, TaskStatus::Held);
3780
3781 t.block(vec!["q1".to_owned()], Some("what now?".to_owned()));
3782 assert_eq!(t.status, TaskStatus::Blocked);
3783
3784 t.record_answer("what now?".to_owned(), "leave it held".to_owned());
3785 t.unblock("q1");
3786 assert_eq!(t.status, TaskStatus::Held, "must restore, not requeue");
3787 assert_eq!(t.hold_reason.as_deref(), Some("out of attempts"));
3788 assert_eq!(t.hold_source, Some(HoldSource::Machine));
3789 assert!(t.blocked_from.is_none(), "consumed once restored");
3790 }
3791
3792 #[test]
3793 fn a_manually_held_task_blocked_on_a_question_returns_to_held() {
3794 let mut t = task("manually held, then asked about");
3795 t.hold_manual(Some("waiting on a dependency".to_owned()));
3796
3797 t.block(vec!["q1".to_owned()], None);
3798 t.unblock("q1");
3799
3800 assert_eq!(t.status, TaskStatus::Held);
3801 assert_eq!(t.hold_source, Some(HoldSource::Manual));
3802 }
3803
3804 #[test]
3805 fn re_blocking_an_already_blocked_task_keeps_the_original_blocked_from() {
3806 let mut t = task("held, blocked twice");
3810 t.hold_machine(None);
3811 t.block(vec!["q1".to_owned()], Some("first".to_owned()));
3812 t.block(
3813 vec!["q1".to_owned(), "q2".to_owned()],
3814 Some("second".to_owned()),
3815 );
3816
3817 t.unblock("q1");
3818 assert_eq!(t.status, TaskStatus::Blocked, "q2 still outstanding");
3819 t.unblock("q2");
3820 assert_eq!(t.status, TaskStatus::Held);
3821 }
3822
3823 #[test]
3824 fn unblocking_a_task_blocked_while_running_lands_on_queued_not_running() {
3825 let mut t = task("blocked mid-run");
3829 t.start("run-1".to_owned());
3830 assert_eq!(t.status, TaskStatus::Running);
3831
3832 t.block(vec!["q1".to_owned()], None);
3833 t.unblock("q1");
3834 assert_eq!(t.status, TaskStatus::Queued);
3835 }
3836
3837 #[test]
3838 fn a_pre_schema_4_blocked_record_with_hold_evidence_restores_to_held() {
3839 let mut t = task("legacy record, held before it was blocked");
3845 t.hold_source = Some(HoldSource::Machine);
3846 t.hold_reason = Some("legacy hold reason".to_owned());
3847 t.status = TaskStatus::Blocked;
3848 t.blocked_by = vec!["q1".to_owned()];
3849 t.blocked_from = None;
3850
3851 t.unblock("q1");
3852 assert_eq!(t.status, TaskStatus::Held);
3853 }
3854
3855 #[test]
3856 fn a_pre_schema_4_blocked_record_with_no_hold_evidence_restores_to_queued() {
3857 let mut t = task("legacy record, ordinary dependency block");
3858 t.status = TaskStatus::Blocked;
3859 t.blocked_by = vec!["dep".to_owned()];
3860 t.blocked_from = None;
3861
3862 t.unblock("dep");
3863 assert_eq!(t.status, TaskStatus::Queued);
3864 }
3865
3866 #[test]
3867 fn answering_a_question_is_recorded_and_survives_a_release() {
3868 let mut t = task("asked something");
3869 t.block(vec!["q1".to_owned()], Some("which backend?".to_owned()));
3870 t.record_answer("Which backend?".to_owned(), "SQLite".to_owned());
3871 t.unblock("q1");
3872 assert_eq!(t.status, TaskStatus::Queued);
3873 assert_eq!(t.answers.len(), 1);
3874 assert_eq!(t.answers[0].answer, "SQLite");
3875
3876 t.release();
3880 assert_eq!(t.answers.len(), 1, "the answer is not lost on release");
3881 }
3882
3883 #[test]
3884 fn resumption_review_branch_requires_unchanged_authored_criteria() {
3885 for source in ["cli", "web"] {
3886 let mut t = task("review old candidate");
3887 t.request_review("candidate-A".into());
3888 t.hold_for_handover(Some("candidate-A".into()), "handover refused".into());
3889 let mut unchanged = t.clone();
3890 unchanged.release_operator(source, None);
3891 assert_eq!(unchanged.review_branch.as_deref(), Some("candidate-A"));
3892 t.edit(
3893 "review approved head".into(),
3894 "Reuse measured PR head B; fix Clippy; review".into(),
3895 )
3896 .unwrap();
3897 t.release_operator(source, None);
3898 assert!(t.review_branch.is_none());
3899 assert!(t.review_instruction.is_none());
3900 let mut legacy = unchanged;
3902 legacy.review_instruction = None;
3903 legacy.hold_for_handover(None, "still checked out".into());
3904 legacy
3905 .edit("new plan".into(), "Reuse measured PR head B".into())
3906 .unwrap();
3907 legacy.release_operator(source, None);
3908 assert!(legacy.review_branch.is_none());
3909 }
3910 }
3911
3912 #[test]
3913 fn a_refused_handover_keeps_the_review_branch_across_release() {
3914 let mut t = task("refused takeover");
3915 t.start("run-1".to_owned());
3916 t.hold_for_handover(Some("magi/eba2/A".to_owned()), "checked out".to_owned());
3917 assert_eq!(t.status, TaskStatus::Held);
3918 assert_eq!(t.attempts, 1);
3919 t.release();
3920 assert_eq!(t.status, TaskStatus::Queued);
3921 assert_eq!(t.attempts, 0);
3922 assert_eq!(t.review_branch.as_deref(), Some("magi/eba2/A"));
3923
3924 t.hold_for_handover(None, "again".to_owned());
3926 t.requeue();
3927 assert!(t.review_branch.is_none());
3928
3929 let mut m = task("manual");
3931 m.review_branch = Some("magi/x/A".to_owned());
3932 m.hold_manual(None);
3933 m.release();
3934 assert!(m.review_branch.is_none());
3935 }
3936
3937 #[test]
3938 fn requesting_review_requeues_the_task_and_remembers_the_branch() {
3939 let mut t = task("blocked run with a surviving branch");
3940 t.start("run-1".to_owned());
3941 t.fail("blocked with major findings", 5);
3942 assert_eq!(t.status, TaskStatus::Failed);
3943
3944 t.request_review("magi/eba2/A".to_owned());
3945 assert_eq!(t.status, TaskStatus::Queued);
3946 assert_eq!(t.attempts, 0);
3947 assert_eq!(t.review_branch.as_deref(), Some("magi/eba2/A"));
3948
3949 t.release();
3951 assert!(t.review_branch.is_none());
3952 }
3953
3954 #[test]
3955 fn conductor_requeue_but_not_an_ordinary_release_forces_a_fresh_start() {
3956 let mut t = task("retry");
3957 t.start("run-1".to_owned());
3958 t.requeue();
3959 assert!(t.fresh_start);
3960
3961 t.release();
3962 assert!(!t.fresh_start);
3963 }
3964
3965 #[test]
3966 fn urgent_only_changes_queued_or_held_and_preserves_settings() {
3967 for status in [
3968 TaskStatus::Queued,
3969 TaskStatus::Held,
3970 TaskStatus::Running,
3971 TaskStatus::Done,
3972 TaskStatus::Failed,
3973 TaskStatus::Blocked,
3974 TaskStatus::Parked,
3975 ] {
3976 let mut t = task("urgent settings");
3977 t.status = status;
3978 t.attempts = 2;
3979 t.hold_reason = Some("waiting".into());
3980 t.priority = 7;
3981 t.interrupt = true;
3982 let before = serde_json::to_value(&t).unwrap();
3983 for urgent in [true, true, false, false] {
3984 let result = t.set_urgent(urgent);
3985 let mut expected = before.clone();
3986 if matches!(status, TaskStatus::Queued | TaskStatus::Held) {
3987 result.unwrap();
3988 expected["urgent"] = urgent.into();
3989 } else {
3990 let error = result.unwrap_err().to_string();
3991 assert!(error.contains(status.as_str()), "{error}");
3992 assert!(error.contains("queued or held"), "{error}");
3993 }
3994 assert_eq!(serde_json::to_value(&t).unwrap(), expected);
3995 }
3996 }
3997 }
3998
3999 #[test]
4000 fn priority_can_be_changed_while_queued_but_not_while_running() {
4001 let mut t = task("reprioritise me");
4002 t.set_priority(5).unwrap();
4003 assert_eq!(t.priority, 5);
4004
4005 t.start("run-1".to_owned());
4006 let err = t.set_priority(9).unwrap_err().to_string();
4007 assert!(err.contains("running"), "{err}");
4008 assert_eq!(t.priority, 5, "the rejected write must not partially apply");
4009 }
4010
4011 #[test]
4012 fn interrupt_can_be_marked_while_queued_but_not_while_running() {
4013 let mut t = task("interrupt me");
4014 assert!(!t.interrupt, "off unless asked, same as any other task");
4015
4016 t.set_interrupt(true).unwrap();
4017 assert!(t.interrupt);
4018
4019 t.start("run-1".to_owned());
4020 assert!(
4021 !t.interrupt,
4022 "the mark is one-shot: dispatching the task fulfils it, \
4023 whatever the run that follows ends up doing"
4024 );
4025 let err = t.set_interrupt(true).unwrap_err().to_string();
4026 assert!(err.contains("running"), "{err}");
4027 t.set_interrupt(false).unwrap();
4030 assert!(!t.interrupt);
4031 }
4032
4033 #[test]
4037 fn a_failed_run_does_not_leave_the_task_still_marked_to_interrupt() {
4038 let mut t = task("interrupt me");
4039 t.set_interrupt(true).unwrap();
4040 t.start("run-1".to_owned());
4041 t.fail("mock failure", 5);
4042 assert_eq!(t.status, TaskStatus::Failed);
4043 assert!(
4044 !t.interrupt,
4045 "one attempt already spent the mark; a retry is an ordinary \
4046 requeue, not a fresh interrupt request"
4047 );
4048 }
4049
4050 #[test]
4051 fn changing_priority_moves_a_task_ahead_in_the_real_queue_order() {
4052 let (_dir, q) = queue();
4053 let mut a = task("first filed");
4054 let mut b = task("second filed");
4055 a.id = "20260101-000001-aaaa".to_owned();
4056 b.id = "20260101-000002-bbbb".to_owned();
4057 q.put(&mut a).unwrap();
4058 q.put(&mut b).unwrap();
4059
4060 assert_eq!(
4061 q.next_runnable().unwrap().id,
4062 a.id,
4063 "with equal priority the older task goes first, so a burst of \
4064 new work cannot starve it"
4065 );
4066 assert_eq!(
4067 q.list()[0].id,
4068 b.id,
4069 "but the list an operator reads is newest first, the same as \
4070 before priority existed - a's turn to run does not make it the \
4071 newest task"
4072 );
4073
4074 let mut a = q.get(&a.id).unwrap();
4075 a.set_priority(10).unwrap();
4076 q.put(&mut a).unwrap();
4077
4078 assert_eq!(
4079 q.next_runnable().unwrap().id,
4080 a.id,
4081 "a raised priority must be reflected the moment it is saved"
4082 );
4083 assert_eq!(
4087 q.list()[0].id,
4088 a.id,
4089 "the raised task must sort first in the list an operator reads, \
4090 not only in next_runnable's own ordering"
4091 );
4092 }
4093
4094 #[test]
4095 fn editing_replaces_title_and_instruction_but_keeps_identity_and_history() {
4096 let mut t = Task::new(
4097 "old title".to_owned(),
4098 "old instruction".to_owned(),
4099 PathBuf::from("/repo"),
4100 Source::Agent {
4101 run: "20260101-000000-beef".to_owned(),
4102 node: "implement".to_owned(),
4103 },
4104 );
4105 let id = t.id.clone();
4106 let created_at = t.created_at;
4107 t.runs.push("20260101-000000-beef".to_owned());
4108
4109 t.edit("new title".to_owned(), "new instruction".to_owned())
4110 .unwrap();
4111
4112 assert_eq!(t.title, "new title");
4113 assert_eq!(t.instruction, "new instruction");
4114 assert_eq!(t.id, id, "editing must not mint a new id");
4115 assert_eq!(t.created_at, created_at);
4116 assert_eq!(
4117 t.source,
4118 Source::Agent {
4119 run: "20260101-000000-beef".to_owned(),
4120 node: "implement".to_owned(),
4121 },
4122 "editing must not turn agent attribution into human"
4123 );
4124 assert_eq!(t.runs, ["20260101-000000-beef"]);
4125 }
4126
4127 #[test]
4128 fn editing_is_refused_once_a_task_is_running_or_finished() {
4129 let mut running = task("in flight");
4130 running.start("run-1".to_owned());
4131 let err = running
4132 .edit("x".to_owned(), "y".to_owned())
4133 .unwrap_err()
4134 .to_string();
4135 assert!(err.contains("running"), "{err}");
4136
4137 let mut done = task("finished");
4138 done.succeed();
4139 let err = done
4140 .edit("x".to_owned(), "y".to_owned())
4141 .unwrap_err()
4142 .to_string();
4143 assert!(err.contains("done"), "{err}");
4144
4145 let mut queued = task("waiting");
4147 queued.edit("x".to_owned(), "y".to_owned()).unwrap();
4148 let mut held = task("parked");
4149 held.hold_machine(None);
4150 held.edit("x".to_owned(), "y".to_owned()).unwrap();
4151 }
4152
4153 #[test]
4154 fn a_task_recorded_without_a_hold_reason_still_reads_as_none() {
4155 let (_dir, q) = queue();
4156 let path = q.path_of("20260101-000000-aaaa");
4157 std::fs::create_dir_all(q.root()).unwrap();
4158 std::fs::write(
4159 &path,
4160 serde_json::json!({
4161 "schema": SCHEMA,
4162 "id": "20260101-000000-aaaa",
4163 "title": "from before hold reasons existed",
4164 "instruction": "from before hold reasons existed",
4165 "repo": ".",
4166 "source": { "kind": "human" },
4167 "status": "held",
4168 "created_at": Timestamp::now().to_string(),
4169 "updated_at": Timestamp::now().to_string(),
4170 })
4171 .to_string(),
4172 )
4173 .unwrap();
4174
4175 let task = q.get("20260101-000000-aaaa").expect("must still read");
4176 assert!(task.hold_reason.is_none());
4177 assert!(task.operator_held());
4178 }
4179
4180 #[test]
4181 fn a_legacy_reasoned_hold_defaults_to_operator_protection() {
4182 let (_dir, q) = queue();
4183 let path = q.path_of("20260101-000000-bbbb");
4184 std::fs::create_dir_all(q.root()).unwrap();
4185 std::fs::write(
4186 &path,
4187 serde_json::json!({
4188 "schema": 2,
4189 "id": "20260101-000000-bbbb",
4190 "title": "old manual recovery",
4191 "instruction": "old manual recovery",
4192 "repo": ".",
4193 "source": { "kind": "human" },
4194 "status": "held",
4195 "hold_reason": "active manual recovery run20260912-224242-daf5",
4196 "created_at": Timestamp::now().to_string(),
4197 "updated_at": Timestamp::now().to_string(),
4198 })
4199 .to_string(),
4200 )
4201 .unwrap();
4202
4203 let task = q.get("20260101-000000-bbbb").expect("must still read");
4204 assert_eq!(task.hold_source, None);
4205 assert!(task.operator_held());
4206 }
4207
4208 #[test]
4209 fn a_task_recorded_without_a_diagnostic_still_reads_as_none() {
4210 let (_dir, q) = queue();
4211 let path = q.path_of("20260101-000000-aaaa");
4212 std::fs::create_dir_all(q.root()).unwrap();
4213 std::fs::write(
4214 &path,
4215 serde_json::json!({
4216 "schema": SCHEMA,
4217 "id": "20260101-000000-aaaa",
4218 "title": "from before diagnostics existed",
4219 "instruction": "from before diagnostics existed",
4220 "repo": ".",
4221 "source": { "kind": "human" },
4222 "status": "held",
4223 "created_at": Timestamp::now().to_string(),
4224 "updated_at": Timestamp::now().to_string(),
4225 })
4226 .to_string(),
4227 )
4228 .unwrap();
4229
4230 let task = q.get("20260101-000000-aaaa").expect("must still read");
4231 assert!(task.diagnostic.is_none());
4232 }
4233
4234 #[test]
4235 fn a_schema_1_task_with_no_blocking_fields_still_reads() {
4236 let (_dir, q) = queue();
4240 let path = q.path_of("20260101-000000-aaaa");
4241 std::fs::create_dir_all(q.root()).unwrap();
4242 std::fs::write(
4243 &path,
4244 serde_json::json!({
4245 "schema": 1,
4246 "id": "20260101-000000-aaaa",
4247 "title": "from before blocking existed",
4248 "instruction": "from before blocking existed",
4249 "repo": ".",
4250 "source": { "kind": "human" },
4251 "status": "queued",
4252 "created_at": Timestamp::now().to_string(),
4253 "updated_at": Timestamp::now().to_string(),
4254 })
4255 .to_string(),
4256 )
4257 .unwrap();
4258
4259 let task = q.get("20260101-000000-aaaa").expect("must still read");
4260 assert!(task.blocked_by.is_empty());
4261 assert!(task.block_reason.is_none());
4262 assert!(task.answers.is_empty());
4263 assert!(task.review_branch.is_none());
4264 }
4265
4266 #[test]
4267 fn releasing_or_finishing_a_task_clears_its_stale_diagnostic() {
4268 let mut held = task("diagnosed");
4273 held.start("run-1".to_owned());
4274 held.fail("gate red", 1);
4275 held.diagnostic = Some("cargo test failed: ...".to_owned());
4276 assert_eq!(held.status, TaskStatus::Held);
4277
4278 held.release();
4279 assert!(held.diagnostic.is_none());
4280
4281 held.diagnostic = Some("cargo test failed: ...".to_owned());
4282 held.succeed();
4283 assert!(held.diagnostic.is_none());
4284 }
4285
4286 #[test]
4287 fn failing_a_task_always_clears_whatever_diagnostic_it_carried() {
4288 let mut t = task("retried");
4289 t.start("run-1".to_owned());
4290 t.diagnostic = Some("stale evidence from a previous hold".to_owned());
4291 t.fail("unrelated config error", 5);
4292 assert_eq!(t.status, TaskStatus::Failed);
4293 assert!(
4294 t.diagnostic.is_none(),
4295 "fail() must not let an old diagnostic outlive the run that produced it"
4296 );
4297 }
4298
4299 #[test]
4300 fn a_claim_is_exclusive_and_releases_on_drop() {
4301 let (_dir, q) = queue();
4302 let mut t = task("contended");
4303 q.put(&mut t).unwrap();
4304
4305 let held = q.claim(&t.id).unwrap();
4306 assert!(
4307 q.claim(&t.id).is_err(),
4308 "two daemons must not drive one task into two runs"
4309 );
4310 drop(held);
4311 assert!(q.claim(&t.id).is_ok(), "a released claim is reclaimable");
4312 }
4313
4314 #[test]
4315 fn a_round_trip_survives_disk() {
4316 let (_dir, q) = queue();
4317 let mut t = Task::new(
4318 "titled".to_owned(),
4319 "body".to_owned(),
4320 PathBuf::from("/repo"),
4321 Source::Agent {
4322 run: "20260101-000000-beef".to_owned(),
4323 node: "implement".to_owned(),
4324 },
4325 );
4326 t.priority = 3;
4327 q.put(&mut t).unwrap();
4328
4329 let back = q.get(&t.id).unwrap();
4330 assert_eq!(back.id, t.id);
4331 assert_eq!(back.priority, 3);
4332 assert_eq!(back.source.label(), "implement@beef");
4333 assert_eq!(q.get(t.short()).unwrap().id, t.id);
4335 }
4336
4337 #[test]
4338 fn an_unreadable_task_does_not_take_the_queue_down() {
4339 let (_dir, q) = queue();
4340 let mut t = task("fine");
4341 q.put(&mut t).unwrap();
4342 std::fs::write(q.root().join("broken.json"), "{ not json").unwrap();
4343
4344 let listed = q.list();
4345 assert_eq!(listed.len(), 1, "the readable task still lists");
4346 assert_eq!(listed[0].id, t.id);
4347 }
4348
4349 #[test]
4350 fn a_task_recorded_without_a_solo_field_still_reads_as_not_solo() {
4351 let (_dir, q) = queue();
4352 let path = q.path_of("20260101-000000-aaaa");
4353 std::fs::create_dir_all(q.root()).unwrap();
4354 std::fs::write(
4355 &path,
4356 serde_json::json!({
4357 "schema": SCHEMA,
4358 "id": "20260101-000000-aaaa",
4359 "title": "from before solo existed",
4360 "instruction": "from before solo existed",
4361 "repo": ".",
4362 "source": { "kind": "human" },
4363 "status": "queued",
4364 "created_at": Timestamp::now().to_string(),
4365 "updated_at": Timestamp::now().to_string(),
4366 })
4367 .to_string(),
4368 )
4369 .unwrap();
4370
4371 let task = q.get("20260101-000000-aaaa").expect("must still read");
4372 assert!(!task.solo, "a queue file with no `solo` field means false");
4373 }
4374
4375 #[test]
4376 fn a_task_recorded_without_an_urgent_field_still_reads_as_not_urgent() {
4377 let (_dir, q) = queue();
4378 let path = q.path_of("20260101-000000-bbbb");
4379 std::fs::create_dir_all(q.root()).unwrap();
4380 std::fs::write(
4381 &path,
4382 serde_json::json!({
4383 "schema": SCHEMA,
4384 "id": "20260101-000000-bbbb",
4385 "title": "from before urgent existed",
4386 "instruction": "from before urgent existed",
4387 "repo": ".",
4388 "source": { "kind": "human" },
4389 "status": "queued",
4390 "created_at": Timestamp::now().to_string(),
4391 "updated_at": Timestamp::now().to_string(),
4392 })
4393 .to_string(),
4394 )
4395 .unwrap();
4396
4397 let task = q.get("20260101-000000-bbbb").expect("must still read");
4398 assert!(
4399 !task.urgent,
4400 "a queue file with no `urgent` field means false, same as `solo`"
4401 );
4402 }
4403
4404 #[test]
4405 fn a_task_from_a_future_schema_is_refused_rather_than_guessed_at() {
4406 let (_dir, q) = queue();
4407 let mut t = task("from the future");
4408 q.put(&mut t).unwrap();
4409 let path = q.path_of(&t.id);
4410 let body = std::fs::read_to_string(&path)
4411 .unwrap()
4412 .replace(&format!("\"schema\": {SCHEMA}"), "\"schema\": 99");
4413 std::fs::write(&path, body).unwrap();
4414
4415 let err = q.get(&t.id).unwrap_err().to_string();
4416 assert!(err.contains("schema 99"), "{err}");
4417 }
4418
4419 #[test]
4420 fn revision_moves_when_the_queue_changes() {
4421 let (_dir, q) = queue();
4422 assert_eq!(q.revision(), 0, "an empty queue has no revision");
4423 let mut t = task("first");
4424 q.put(&mut t).unwrap();
4425 assert!(q.revision() > 0, "a written task moves the revision");
4426 }
4427
4428 #[test]
4429 fn revision_moves_when_deleting_an_older_task() {
4430 let (dir, q) = queue();
4431 let questions = Questions::at(dir.path().join("questions"));
4432 let mut t1 = task("older");
4433 q.put(&mut t1).unwrap();
4434 std::thread::sleep(std::time::Duration::from_millis(10));
4436 let mut t2 = task("newer");
4437 q.put(&mut t2).unwrap();
4438
4439 let rev_before = q.revision();
4440 q.remove(&t1.id, false, &questions).unwrap();
4441 let rev_after = q.revision();
4442
4443 assert_ne!(
4444 rev_before, rev_after,
4445 "deleting an older task must change the revision so other clients see the deletion"
4446 );
4447 }
4448
4449 #[test]
4450 fn removing_a_task_takes_it_out_of_the_listing() {
4451 let (dir, q) = queue();
4452 let questions = Questions::at(dir.path().join("questions"));
4453 let mut t = task("delete me");
4454 q.put(&mut t).unwrap();
4455 let removed = q.remove(t.short(), false, &questions).unwrap();
4456 assert_eq!(removed.id, t.id, "a prefix resolves before deleting");
4457 assert!(removed.released.is_empty() && removed.still_blocked.is_empty());
4458 assert!(q.list().is_empty());
4459 assert!(
4460 q.remove(&t.id, false, &questions).is_err(),
4461 "removing twice is an error"
4462 );
4463 }
4464
4465 #[test]
4466 fn removing_a_task_takes_its_stale_lock_with_it() {
4467 let (dir, q) = queue();
4468 let questions = Questions::at(dir.path().join("questions"));
4469 let mut t = task("interrupted");
4470 q.put(&mut t).unwrap();
4471
4472 let claim = q.claim(&t.id).unwrap();
4475 std::mem::forget(claim);
4476 assert!(
4477 q.claim(&t.id).is_err(),
4478 "the orphaned lock is what makes the task look claimed"
4479 );
4480
4481 let err = q.remove(&t.id, true, &questions).unwrap_err().to_string();
4483 assert!(err.contains("live daemon"), "{err}");
4484 assert!(q.get(&t.id).is_ok(), "a refused delete keeps the task");
4485
4486 q.remove(&t.id, false, &questions).unwrap();
4488 assert!(q.list().is_empty());
4489 let mut again = task("interrupted");
4490 again.id = t.id.clone();
4491 q.put(&mut again).unwrap();
4492 assert!(
4493 q.claim(&t.id).is_ok(),
4494 "a task that comes back must be claimable, which a left-behind lock would prevent"
4495 );
4496 }
4497
4498 fn notices_of(dir: &Path) -> Vec<crate::notices::Notice> {
4499 crate::notices::Notices::at(dir.join("notifications")).list()
4500 }
4501
4502 #[test]
4503 fn removing_a_sole_dependency_releases_the_dependent_without_a_hold() {
4504 let (dir, q) = queue();
4505 let questions = Questions::at(dir.path().join("questions"));
4506 let mut dep = task("dependency");
4507 q.put(&mut dep).unwrap();
4508 let mut blocked = task("waiting");
4509 blocked.block(vec![dep.id.clone()], Some("waits".to_owned()));
4510 q.put(&mut blocked).unwrap();
4511
4512 let removed = q.remove(&dep.id, false, &questions).unwrap();
4513 assert_eq!(removed.released, [blocked.id.clone()]);
4514 assert!(removed.still_blocked.is_empty());
4515
4516 let after = q.get(&blocked.id).unwrap();
4517 assert_eq!(after.status, TaskStatus::Queued);
4518 assert!(after.blocked_by.is_empty());
4519 assert!(after.block_reason.is_none());
4520 let notes = notices_of(dir.path());
4521 assert_eq!(notes.len(), 1, "{notes:?}");
4522 assert_eq!(notes[0].severity, crate::notices::Severity::Info);
4523 assert_eq!(
4524 notes[0].link,
4525 Some(crate::notices::Link::Task {
4526 id: blocked.id.clone(),
4527 })
4528 );
4529 }
4530
4531 #[test]
4532 fn removing_one_of_two_dependencies_keeps_the_other() {
4533 let (dir, q) = queue();
4534 let questions = Questions::at(dir.path().join("questions"));
4535 let mut dep = task("dependency");
4536 q.put(&mut dep).unwrap();
4537 let mut other = task("other");
4538 q.put(&mut other).unwrap();
4539 let mut blocked = task("waiting");
4540 blocked.block(
4541 vec![dep.id.clone(), other.id.clone()],
4542 Some("waits on both".to_owned()),
4543 );
4544 q.put(&mut blocked).unwrap();
4545
4546 let removed = q.remove(&dep.id, false, &questions).unwrap();
4547 assert!(removed.released.is_empty());
4548 assert_eq!(removed.still_blocked, [blocked.id.clone()]);
4549
4550 let after = q.get(&blocked.id).unwrap();
4551 assert_eq!(after.status, TaskStatus::Blocked);
4552 assert_eq!(after.blocked_by, [other.id.clone()]);
4553 assert!(missing_blockers(&q, &questions, &after.blocked_by).is_empty());
4556 }
4557
4558 #[test]
4559 fn a_dependency_deleted_before_its_dependents_were_rewritten_is_released_later() {
4560 let (dir, q) = queue();
4561 let questions = Questions::at(dir.path().join("questions"));
4562 let mut dep = task("dependency");
4563 q.put(&mut dep).unwrap();
4564 let mut blocked = task("waiting");
4565 blocked.block(vec![dep.id.clone()], None);
4566 q.put(&mut blocked).unwrap();
4567
4568 let claim = q.claim(&blocked.id).unwrap();
4570 let removed = q.remove(&dep.id, false, &questions).unwrap();
4571 assert!(removed.released.is_empty());
4572 drop(claim);
4573
4574 let mut task = q.get(&blocked.id).unwrap();
4575 assert_eq!(task.status, TaskStatus::Blocked);
4576 assert!(missing_blockers(&q, &questions, &task.blocked_by).is_empty());
4577 assert_eq!(
4578 q.apply_deleted_blockers(&mut task, &questions),
4579 [dep.id.clone()]
4580 );
4581 assert_eq!(task.status, TaskStatus::Queued);
4582 }
4583
4584 #[test]
4585 fn dependency_resolution_short_deleted_reference_releases_only_its_waiters() {
4586 let (dir, q) = queue();
4587 let questions = Questions::at(dir.path().join("questions"));
4588 let mut dep = task("dependency");
4589 dep.id = "20261013-193611-ca5b".into();
4590 q.put(&mut dep).unwrap();
4591 let mut waiting = task("waiting");
4592 waiting.block(vec!["ca5b".into()], None);
4593 q.put(&mut waiting).unwrap();
4594 q.remove(&dep.id, false, &questions).unwrap();
4595 assert_eq!(q.get(&waiting.id).unwrap().status, TaskStatus::Queued);
4596 assert_eq!(
4597 dependency(&q, &questions, "ca5b"),
4598 Dependency::Deleted(dep.id)
4599 );
4600 }
4601
4602 #[test]
4603 fn dependency_resolution_preserves_cross_store_ambiguity_and_exact_ids() {
4604 let (dir, queue) = queue();
4605 let questions = Questions::at(dir.path().join("questions"));
4606 let mut dep = task("dependency");
4607 dep.id = "20261013-193611-ca5b".into();
4608 queue.put(&mut dep).unwrap();
4609 std::fs::create_dir_all(questions.root()).unwrap();
4610 let question_id = "20261014-193611-ca5b";
4611 std::fs::write(questions.path_of(question_id), "unreadable question").unwrap();
4612 assert_eq!(questions.resolve_id("ca5b").unwrap(), question_id);
4613 assert_eq!(
4614 dependency(&queue, &questions, "ca5b"),
4615 Dependency::Ambiguous
4616 );
4617 assert_eq!(
4618 dependency(&queue, &questions, &dep.id),
4619 Dependency::Task(dep.id)
4620 );
4621 assert_eq!(
4622 dependency(&queue, &questions, question_id),
4623 Dependency::Question(question_id.into())
4624 );
4625 assert_eq!(
4626 dependency(&queue, &questions, "missing"),
4627 Dependency::Missing
4628 );
4629 }
4630
4631 #[test]
4632 fn dependency_resolution_directory_errors_are_unknown_not_missing() {
4633 let dir = tempfile::tempdir().unwrap();
4634 let q = Queue::at(dir.path().join("not-a-directory"));
4635 std::fs::write(q.root(), "file").unwrap();
4636 let questions = Questions::at(dir.path().join("questions"));
4637 assert_eq!(dependency(&q, &questions, "ca5b"), Dependency::Unknown);
4638 assert!(missing_blockers(&q, &questions, &["ca5b".into()]).is_empty());
4639 }
4640
4641 #[test]
4642 fn a_dependency_without_a_tombstone_is_still_missing() {
4643 let (dir, q) = queue();
4644 let questions = Questions::at(dir.path().join("questions"));
4645 let mut dep = task("dependency");
4646 q.put(&mut dep).unwrap();
4647 std::fs::remove_file(q.path_of(&dep.id)).unwrap();
4648 assert_eq!(
4649 missing_blockers(&q, &questions, std::slice::from_ref(&dep.id)),
4650 [dep.id.clone()]
4651 );
4652 assert!(deleted_blockers(&q, &questions, std::slice::from_ref(&dep.id)).is_empty());
4653 }
4654
4655 #[test]
4656 fn a_held_dependent_is_left_alone_by_a_dependency_deletion() {
4657 let mut t = task("held");
4658 t.hold_machine(Some("because".to_owned()));
4659 t.blocked_by = vec!["gone".to_owned()];
4660 assert!(!t.dependency_deleted("gone"));
4661 assert_eq!(t.status, TaskStatus::Held);
4662 }
4663
4664 #[test]
4665 fn a_failed_record_removal_takes_the_tombstone_back() {
4666 let (_dir, q) = queue();
4667 let mut t = task("stays");
4668 q.put(&mut t).unwrap();
4669 q.write_tombstone(&t.id).unwrap();
4670 let err = q.remove_record_with_attachments(&t.id, |_| Err(std::io::Error::other("nope")));
4671 assert!(err.is_err());
4672 assert!(!q.was_deleted(&t.id));
4675 }
4676
4677 fn source_file(dir: &Path, name: &str, body: &str) -> PathBuf {
4678 let p = dir.join(name);
4679 std::fs::write(&p, body).unwrap();
4680 p
4681 }
4682
4683 #[test]
4684 fn an_attachment_copy_survives_deleting_its_source() {
4685 let (dir, q) = queue();
4686 let src = source_file(dir.path(), "shot.png", "pixels");
4687 let mut t = task("with a picture");
4688 let names = q.attach(&mut t, std::slice::from_ref(&src)).unwrap();
4689 q.put(&mut t).unwrap();
4690 std::fs::remove_file(&src).unwrap();
4691 assert_eq!(names, ["shot.png"]);
4692 let loaded = q.get(&t.id).unwrap();
4693 let paths = q.attachment_paths(&loaded);
4694 assert_eq!(paths.len(), 1);
4695 assert_eq!(std::fs::read_to_string(&paths[0]).unwrap(), "pixels");
4696 }
4697
4698 #[test]
4699 fn attachment_names_that_could_traverse_or_are_odd_are_refused() {
4700 let (dir, q) = queue();
4701 let mut t = task("bad names");
4702 for name in ["a..b.png", ".hidden", "with space.png", "-x.png"] {
4703 let src = source_file(dir.path(), name, "x");
4704 assert!(
4705 q.attach(&mut t, &[src]).is_err(),
4706 "`{name}` must be refused"
4707 );
4708 }
4709 assert!(!crate::ask::valid_asset_name("C:foo.png"));
4713 #[cfg(not(windows))]
4714 {
4715 let src = source_file(dir.path(), "C:foo.png", "x");
4716 assert!(q.attach(&mut t, &[src]).is_err());
4717 }
4718 let long = format!("{}.png", "a".repeat(70));
4719 let src = source_file(dir.path(), &long, "x");
4720 assert!(q.attach(&mut t, &[src]).is_err());
4721 assert!(t.attachments.is_empty());
4722 assert!(!q.attachments_dir(&t.id).exists());
4723 }
4724
4725 #[test]
4726 fn a_taken_attachment_name_is_numbered_not_overwritten() {
4727 let (dir, q) = queue();
4728 let a = source_file(dir.path(), "shot.png", "one");
4729 let sub = dir.path().join("other");
4730 std::fs::create_dir_all(&sub).unwrap();
4731 let b = source_file(&sub, "shot.png", "two");
4732 let mut t = task("collision");
4733 q.attach(&mut t, &[a]).unwrap();
4734 q.attach(&mut t, &[b]).unwrap();
4735 assert_eq!(t.attachments, ["shot.png", "shot-2.png"]);
4736 let paths = q.attachment_paths(&t);
4737 assert_eq!(std::fs::read_to_string(&paths[0]).unwrap(), "one");
4738 assert_eq!(std::fs::read_to_string(&paths[1]).unwrap(), "two");
4739 }
4740
4741 #[test]
4742 fn a_renumbered_name_stays_inside_the_length_bound() {
4743 let (dir, q) = queue();
4744 let name = format!("{}.png", "a".repeat(60));
4745 assert_eq!(name.len(), 64);
4746 let a = source_file(dir.path(), &name, "one");
4747 let sub = dir.path().join("other");
4748 std::fs::create_dir_all(&sub).unwrap();
4749 let b = source_file(&sub, &name, "two");
4750 let mut t = task("long");
4751 q.attach(&mut t, &[a, b]).unwrap();
4752 assert_eq!(t.attachments.len(), 2);
4753 assert!(
4754 t.attachments
4755 .iter()
4756 .all(|n| crate::ask::valid_asset_name(n))
4757 );
4758 assert!(t.attachments[1].ends_with("-2.png"));
4759 }
4760
4761 #[test]
4762 fn a_failed_attach_keeps_existing_attachments_and_leaves_no_partial_copy() {
4763 let (dir, q) = queue();
4764 let good = source_file(dir.path(), "good.png", "ok");
4765 let mut t = task("partial");
4766 q.attach(&mut t, &[good]).unwrap();
4767 let more = source_file(dir.path(), "more.png", "ok");
4768 let missing = dir.path().join("missing.png");
4769 assert!(q.attach(&mut t, &[more, missing]).is_err());
4770 assert_eq!(t.attachments, ["good.png"]);
4771 let on_disk: Vec<_> = std::fs::read_dir(q.attachments_dir(&t.id))
4772 .unwrap()
4773 .flatten()
4774 .collect();
4775 assert_eq!(on_disk.len(), 1);
4776 }
4777
4778 fn block_put(q: &Queue, t: &Task) -> PathBuf {
4780 let tmp = q.path_of(&t.id).with_extension("json.tmp");
4781 std::fs::create_dir_all(&tmp).unwrap();
4782 tmp
4783 }
4784
4785 #[test]
4786 fn a_failed_put_leaves_no_new_attachment_directory() {
4787 let (dir, q) = queue();
4788 let mut t = task("fresh");
4789 let tmp = block_put(&q, &t);
4790 let src = source_file(dir.path(), "shot.png", "x");
4791 assert!(q.attach_and_put(&mut t, &[src]).is_err());
4792 assert!(t.attachments.is_empty());
4793 assert!(!q.attachments_dir(&t.id).exists());
4794 assert!(!q.path_of(&t.id).exists());
4795 std::fs::remove_dir(tmp).unwrap();
4796 }
4797
4798 #[test]
4799 fn a_failed_put_removes_only_the_copy_it_just_made() {
4800 let (dir, q) = queue();
4801 let mut t = task("edited");
4802 let first = source_file(dir.path(), "first.png", "1");
4803 q.attach_and_put(&mut t, &[first]).unwrap();
4804 block_put(&q, &t);
4805 let second = source_file(dir.path(), "second.png", "2");
4806 assert!(q.attach_and_put(&mut t, &[second]).is_err());
4807 assert_eq!(t.attachments, ["first.png"]);
4808 let on_disk: Vec<_> = std::fs::read_dir(q.attachments_dir(&t.id))
4809 .unwrap()
4810 .flatten()
4811 .map(|e| e.file_name().to_string_lossy().into_owned())
4812 .collect();
4813 assert_eq!(on_disk, ["first.png"]);
4814 assert_eq!(q.get(&t.id).unwrap().attachments, ["first.png"]);
4815 }
4816
4817 #[test]
4818 fn a_leftover_removing_directory_is_swept_by_the_next_removal() {
4819 let (dir, q) = queue();
4820 let questions = Questions::at(dir.path().join("questions"));
4821 let gone = task("gone");
4822 let mut other = task("other");
4823 let mut live = task("live");
4824 q.put(&mut other).unwrap();
4825 q.put(&mut live).unwrap();
4826 let orphan = q.root.join(format!("{}.attachments.removing", gone.id));
4829 std::fs::create_dir_all(&orphan).unwrap();
4830 std::fs::write(orphan.join("shot.png"), "x").unwrap();
4831 let busy = q.root.join(format!("{}.attachments.removing", live.id));
4834 std::fs::create_dir_all(&busy).unwrap();
4835
4836 q.remove(&other.id, false, &questions).unwrap();
4837 assert!(!orphan.exists(), "an orphan is swept");
4838 assert!(busy.exists(), "a removal in progress is left alone");
4839 }
4840
4841 #[test]
4842 fn a_blocked_aside_rename_fails_the_removal_and_loses_nothing() {
4843 let (dir, q) = queue();
4844 let questions = Questions::at(dir.path().join("questions"));
4845 let mut t = task("stuck");
4846 let src = source_file(dir.path(), "shot.png", "x");
4847 q.attach_and_put(&mut t, &[src]).unwrap();
4848 let aside = q.root.join(format!("{}.attachments.removing", t.id));
4851 std::fs::create_dir_all(&aside).unwrap();
4852 std::fs::write(aside.join("old.png"), "o").unwrap();
4853 assert!(q.remove(&t.id, false, &questions).is_err());
4854 assert!(q.path_of(&t.id).exists());
4855 assert!(q.attachments_dir(&t.id).join("shot.png").is_file());
4856 }
4857
4858 #[test]
4859 fn a_failed_record_removal_puts_the_attachments_back() {
4860 let (dir, q) = queue();
4861 let mut t = task("rollback");
4862 let src = source_file(dir.path(), "shot.png", "x");
4863 q.attach_and_put(&mut t, &[src]).unwrap();
4864 let err = q
4865 .remove_record_with_attachments(&t.id, |_| {
4866 Err(std::io::Error::other("injected failure"))
4867 })
4868 .unwrap_err();
4869 assert!(format!("{err:#}").contains("injected failure"));
4870 assert!(q.path_of(&t.id).exists());
4871 assert!(q.attachments_dir(&t.id).join("shot.png").is_file());
4872 assert!(
4873 !q.root
4874 .join(format!("{}.attachments.removing", t.id))
4875 .exists()
4876 );
4877 }
4878
4879 #[test]
4880 fn editing_a_task_keeps_its_attachments() {
4881 let (dir, q) = queue();
4882 let src = source_file(dir.path(), "shot.png", "x");
4883 let mut t = task("editable");
4884 q.attach(&mut t, &[src]).unwrap();
4885 t.edit("new".to_owned(), "new text".to_owned()).unwrap();
4886 q.put(&mut t).unwrap();
4887 assert_eq!(q.get(&t.id).unwrap().attachments, ["shot.png"]);
4888 }
4889
4890 #[test]
4891 fn removing_a_task_deletes_its_attachments() {
4892 let (dir, q) = queue();
4893 let questions = Questions::at(dir.path().join("questions"));
4894 let src = source_file(dir.path(), "shot.png", "x");
4895 let mut t = task("doomed");
4896 q.attach(&mut t, &[src]).unwrap();
4897 q.put(&mut t).unwrap();
4898 assert!(q.attachments_dir(&t.id).is_dir());
4899 q.remove(&t.id, false, &questions).unwrap();
4900 assert!(!q.attachments_dir(&t.id).exists());
4901 assert!(q.list().is_empty());
4902 }
4903
4904 #[test]
4905 fn a_task_written_before_attachments_still_reads() {
4906 let (_dir, q) = queue();
4907 let mut t = task("old");
4908 q.put(&mut t).unwrap();
4909 let path = q.path_of(&t.id);
4910 let mut v: serde_json::Value =
4911 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
4912 v.as_object_mut().unwrap().remove("attachments");
4913 std::fs::write(&path, v.to_string()).unwrap();
4914 assert!(q.get(&t.id).unwrap().attachments.is_empty());
4915 }
4916
4917 #[test]
4918 fn attachment_paths_are_absolute_even_when_the_root_is_relative() {
4919 let q = Queue::at(PathBuf::from("relative-queue"));
4920 let mut t = task("rel");
4921 t.attachments.push("shot.png".to_owned());
4922 let paths = q.attachment_paths(&t);
4923 assert!(paths[0].is_absolute(), "{}", paths[0].display());
4924 assert!(paths[0].ends_with(format!("{}.attachments/shot.png", t.id)));
4925 }
4926
4927 #[test]
4928 fn explicitly_resumed_older_run_becomes_current_without_duplicate_history() {
4929 let dir = tempfile::tempdir().unwrap();
4930 let q = Queue::at(dir.path().join("queue"));
4931 let mut t = task("resume");
4932 t.start("A".into());
4933 t.record_pass("A", 0, 1, None);
4934 t.start("B".into());
4935 q.put(&mut t).unwrap();
4936 assert_eq!(t.runs, vec!["A", "B"]);
4937 t.start("A".into());
4938 t.record_pass("A", 1, 3, None);
4939 q.put(&mut t).unwrap();
4940 let saved = q.get(&t.id).unwrap();
4941 assert_eq!(saved.runs, vec!["B", "A"]);
4942 assert_eq!(saved.runs.last().map(String::as_str), Some("A"));
4943 assert_eq!(saved.attempts, 3);
4944 assert_eq!(saved.passes.len(), 2);
4945 assert_eq!(saved.passes[0].run, "A");
4946 assert_eq!(saved.passes[1].run, "A");
4947 }
4948
4949 #[test]
4950 fn repeated_start_records_one_run_and_separate_passes() {
4951 let mut t = task("resume");
4952 t.start("same-run".into());
4953 t.record_pass("same-run", 0, 1, None);
4954 t.start("same-run".into());
4955 t.record_pass("same-run", 1, 2, None);
4956 assert_eq!(t.runs, vec!["same-run"]);
4957 assert_eq!(t.attempts, 2);
4958 assert_eq!(t.passes.len(), 2);
4959 }
4960
4961 #[test]
4962 fn link_run_adds_a_run_once_and_touches_nothing_else() {
4963 let dir = tempfile::tempdir().unwrap();
4964 let queue = Queue::at(dir.path().join("queue"));
4965 let mut t = Task::new(
4966 "t".to_owned(),
4967 "do it".to_owned(),
4968 PathBuf::from("."),
4969 Source::Human,
4970 );
4971 queue.put(&mut t).unwrap();
4972 let before = queue.get(&t.id).unwrap();
4973
4974 let linked = queue.link_run(&t.id[..4], "20260930-092817-ec34").unwrap();
4975 assert_eq!(linked.runs, vec!["20260930-092817-ec34".to_owned()]);
4976 assert_eq!(linked.status, before.status);
4977 assert_eq!(linked.attempts, before.attempts);
4978 assert_eq!(linked.interrupt, before.interrupt);
4979
4980 let again = queue.link_run(&t.id, "20260930-092817-ec34").unwrap();
4981 assert_eq!(again.runs.len(), 1, "linking twice must not duplicate");
4982 assert_eq!(queue.get(&t.id).unwrap().runs.len(), 1);
4983 assert!(queue.link_run("no-such-task", "r").is_err());
4984 }
4985
4986 #[test]
4987 fn put_keeps_a_run_linked_after_the_writer_took_its_snapshot() {
4988 let dir = tempfile::tempdir().unwrap();
4989 let queue = Queue::at(dir.path().join("queue"));
4990 let mut t = Task::new(
4991 "t".to_owned(),
4992 "do it".to_owned(),
4993 PathBuf::from("."),
4994 Source::Human,
4995 );
4996 queue.put(&mut t).unwrap();
4997 let mut snapshot = queue.get(&t.id).unwrap();
4999 queue.link_run(&t.id, "20260930-092817-ec34").unwrap();
5000
5001 snapshot.start("20260930-000000-aaaa".to_owned());
5002 queue.put(&mut snapshot).unwrap();
5003
5004 let stored = queue.get(&t.id).unwrap();
5005 assert!(stored.runs.contains(&"20260930-092817-ec34".to_owned()));
5006 assert!(stored.runs.contains(&"20260930-000000-aaaa".to_owned()));
5007 assert_eq!(stored.attempts, 1);
5008 }
5009
5010 #[test]
5011 fn concurrent_links_and_daemon_saves_lose_nothing() {
5012 let dir = tempfile::tempdir().unwrap();
5013 let queue = Queue::at(dir.path().join("queue"));
5014 let mut t = Task::new(
5015 "t".to_owned(),
5016 "do it".to_owned(),
5017 PathBuf::from("."),
5018 Source::Human,
5019 );
5020 queue.put(&mut t).unwrap();
5021 let id = t.id.clone();
5022
5023 let linkers: Vec<_> = (0..4)
5024 .map(|n| {
5025 let (queue, id) = (queue.clone(), id.clone());
5026 std::thread::spawn(move || {
5027 for k in 0..10 {
5028 queue
5029 .link_run(&id, &format!("20260930-00000{n}-l{k:03}"))
5030 .unwrap();
5031 }
5032 })
5033 })
5034 .collect();
5035 let mut mine = queue.get(&id).unwrap();
5038 for k in 0..10 {
5039 mine.start(format!("20260930-000009-d{k:03}"));
5040 queue.put(&mut mine).unwrap();
5041 }
5042 for l in linkers {
5043 l.join().unwrap();
5044 }
5045
5046 let stored = queue.get(&id).unwrap();
5047 assert_eq!(stored.runs.len(), 50, "{:?}", stored.runs);
5048 assert_eq!(
5049 stored.attempts, 10,
5050 "linking never rewinds the daemon's work"
5051 );
5052 }
5053
5054 fn age_lock(path: &Path) {
5055 let f = std::fs::OpenOptions::new().write(true).open(path).unwrap();
5056 f.set_modified(std::time::SystemTime::now() - std::time::Duration::from_secs(60))
5057 .unwrap();
5058 }
5059
5060 #[test]
5061 fn concurrent_stale_takeover_yields_one_holder() {
5062 use std::sync::atomic::{AtomicUsize, Ordering};
5063 use std::sync::{Arc, Barrier};
5064 for _ in 0..5 {
5065 let dir = tempfile::tempdir().unwrap();
5066 let q = Queue::at(dir.path().to_path_buf());
5067 let lock = dir.path().join("t.write-lock");
5068 std::fs::write(&lock, "dead-0000").unwrap();
5069 age_lock(&lock);
5070 let n = 6;
5071 let barrier = Arc::new(Barrier::new(n));
5072 let (now, max) = (Arc::new(AtomicUsize::new(0)), Arc::new(AtomicUsize::new(0)));
5073 let handles: Vec<_> = (0..n)
5074 .map(|_| {
5075 let (q, b, now, max) = (q.clone(), barrier.clone(), now.clone(), max.clone());
5076 std::thread::spawn(move || {
5077 b.wait();
5078 let g = q.lock_task("t").unwrap();
5079 let held = now.fetch_add(1, Ordering::SeqCst) + 1;
5080 max.fetch_max(held, Ordering::SeqCst);
5081 std::thread::sleep(std::time::Duration::from_millis(20));
5082 now.fetch_sub(1, Ordering::SeqCst);
5083 drop(g);
5084 })
5085 })
5086 .collect();
5087 for h in handles {
5088 h.join().unwrap();
5089 }
5090 assert_eq!(max.load(Ordering::SeqCst), 1);
5091 assert!(!lock.exists());
5092 }
5093 }
5094
5095 #[test]
5096 fn dropping_a_stolen_lock_leaves_the_new_holders_lock() {
5097 let dir = tempfile::tempdir().unwrap();
5098 let q = Queue::at(dir.path().to_path_buf());
5099 let lock = dir.path().join("t.write-lock");
5100 let a = q.lock_task("t").unwrap();
5101 age_lock(&lock);
5102 let b = q.lock_task("t").unwrap();
5103 assert_ne!(a.token, b.token);
5104 drop(a);
5105 assert_eq!(std::fs::read_to_string(&lock).unwrap(), b.token);
5106 drop(b);
5107 assert!(!lock.exists());
5108 }
5109
5110 #[test]
5111 fn break_stale_leaves_a_lock_that_replaced_the_one_judged() {
5112 let dir = tempfile::tempdir().unwrap();
5113 let lock = dir.path().join("t.write-lock");
5114 std::fs::write(&lock, "new-token").unwrap();
5115 assert!(!break_stale(&lock, "old-token"));
5116 assert_eq!(std::fs::read_to_string(&lock).unwrap(), "new-token");
5117 assert!(!break_stale(&lock, "new-token"));
5119 assert!(lock.exists());
5120 age_lock(&lock);
5121 assert!(break_stale(&lock, "new-token"));
5122 assert!(!lock.exists());
5123 }
5124
5125 #[test]
5126 fn a_stale_break_marker_is_recovered_and_a_fresh_one_is_respected() {
5127 let dir = tempfile::tempdir().unwrap();
5128 let lock = dir.path().join("t.write-lock");
5129 std::fs::write(&lock, "dead-1").unwrap();
5130 age_lock(&lock);
5131 let marker = break_marker(&lock, "dead-1");
5132 std::fs::write(&marker, "crashed-remover").unwrap();
5133 assert!(!break_stale(&lock, "dead-1"));
5135 assert!(lock.exists());
5136 age_lock(&marker);
5138 assert!(break_stale(&lock, "dead-1"));
5139 assert!(!lock.exists());
5140 assert!(!marker.exists());
5141 }
5142
5143 fn chat_task() -> Task {
5144 Task::new(
5145 "t".to_owned(),
5146 "do t".to_owned(),
5147 PathBuf::from("."),
5148 Source::Agent {
5149 run: "20260902-000000-beef".to_owned(),
5150 node: CHAT_NODE.to_owned(),
5151 },
5152 )
5153 }
5154
5155 #[test]
5156 fn a_blocked_hold_keeps_its_anchor_but_a_new_run_starts_a_new_hold() {
5157 let mut t = chat_task();
5158 t.hold_machine(Some("waiting".into()));
5159 let old: Timestamp = "2020-01-01T00:00:00Z".parse().unwrap();
5160 t.held_at = Some(old);
5161 t.block(vec!["decision".into()], None);
5162 t.hold_machine(Some("same waiting decision".into()));
5163 assert_eq!(t.held_at, Some(old));
5164 t.start("real-new-run".into());
5165 t.fail("new failure", 1);
5166 assert_ne!(
5167 t.held_at,
5168 Some(old),
5169 "old answers cannot approve a new hold"
5170 );
5171 }
5172
5173 #[test]
5174 fn legacy_delivery_survives_upgrade_and_a_no_progress_run_link() {
5175 let (_dir, queue) = queue();
5176 let mut t = chat_task();
5177 t.start("old-run".into());
5178 t.hold_machine(Some("operator is considering recovery".into()));
5179 t.chat_notice = Some(ChatNotice {
5180 key: format!("held#{}", t.held_at.unwrap()),
5181 at: Timestamp::now(),
5182 outcome: ChatNoticeOutcome::Posted,
5183 });
5184 let mut raw = serde_json::to_value(&t).unwrap();
5185 raw.as_object_mut().unwrap().remove("chat_notices");
5186 raw.as_object_mut().unwrap().remove("hold_notice_cause");
5187 std::fs::create_dir_all(queue.path_of(&t.id).parent().unwrap()).unwrap();
5188 std::fs::write(queue.path_of(&t.id), serde_json::to_string(&raw).unwrap()).unwrap();
5189 assert_eq!(queue.get(&t.id).unwrap().chat_notice_due(), None);
5190 queue
5191 .modify(&t.id, |task| {
5192 task.requeue();
5193 task.link_run("unprepared-run");
5194 task.startup_notice_cause("startup-source");
5195 task.fail("same unresolved preflight", 0);
5196 true
5197 })
5198 .unwrap();
5199 assert_eq!(queue.get(&t.id).unwrap().chat_notice_due(), None);
5200 queue
5201 .modify(&t.id, |task| {
5202 task.succeed();
5203 true
5204 })
5205 .unwrap();
5206 assert_eq!(
5207 queue.get(&t.id).unwrap().chat_notice_due().as_deref(),
5208 Some("done")
5209 );
5210 }
5211
5212 #[test]
5213 fn a_dependency_wait_does_not_change_when_released_and_blocked_again() {
5214 let mut t = chat_task();
5215 t.block(vec!["a".into(), "b".into()], Some("waiting".into()));
5216 let key = t.chat_notice_key().unwrap();
5217 t.chat_notices.push(key);
5218 t.release();
5219 t.block(
5220 vec!["b".into(), "a".into(), "a".into()],
5221 Some("still waiting on prerequisites".into()),
5222 );
5223 assert_eq!(t.chat_notice_due(), None);
5224 t.block(
5225 vec!["new-question".into()],
5226 Some("different decision".into()),
5227 );
5228 assert!(t.chat_notice_due().is_some());
5229 }
5230
5231 #[test]
5232 fn running_startup_failures_preserve_stage_and_report_distinct_blockers() {
5233 let mut t = chat_task();
5234 t.start("existing-run".into());
5235 t.fail("implementation failed", 1);
5236 let failed = t.chat_notice_key().unwrap();
5237 t.chat_notices.push(failed.clone());
5238 t.release_operator("test", None);
5239 t.start("existing-run".into());
5240 t.fail_startup("startup-source", "same unresolved recovery source", 1);
5241 assert_eq!(
5242 t.chat_notice_due(),
5243 None,
5244 "no-progress recovery stays quiet"
5245 );
5246 t.release_operator("test", None);
5247 t.start("existing-run".into());
5248 t.fail_startup("startup-attachments", "attachment missing", 1);
5249 let attachments = t.chat_notice_due().expect("new attachment blocker");
5250 assert_ne!(attachments, failed);
5251 assert_eq!(
5252 t.hold_notice_cause.as_ref().unwrap().stage,
5253 "startup-attachments"
5254 );
5255 t.chat_notices.push(attachments);
5256 t.release_operator("test", None);
5257 t.start("existing-run".into());
5258 t.fail_startup(
5259 "startup-attachments",
5260 "same missing attachment, reworded",
5261 1,
5262 );
5263 assert_eq!(
5264 t.chat_notice_due(),
5265 None,
5266 "same stage after attempt counter reset"
5267 );
5268 t.release_operator("test", None);
5269 t.start("existing-run".into());
5270 t.fail_startup("startup-config", "different preflight stage", 1);
5271 assert!(t.chat_notice_due().is_some());
5272 assert_eq!(
5273 t.hold_notice_cause.as_ref().unwrap().stage,
5274 "startup-config"
5275 );
5276 }
5277
5278 #[test]
5279 fn structured_causes_preserve_no_progress_retries_and_report_new_passes() {
5280 let mut t = chat_task();
5281 t.hold_machine(Some("startup source missing".into()));
5282 t.startup_notice_cause("startup-source");
5283 let first = t.chat_notice_key().unwrap();
5284 t.chat_notices.push(first.clone());
5285 t.release_operator("test", None);
5286 t.startup_notice_cause("startup-source");
5287 t.hold_machine(Some("same source cannot be read".into()));
5288 assert_eq!(t.chat_notice_due(), None);
5289 t.startup_notice_cause("startup-config");
5290 assert!(t.chat_notice_due().is_some(), "different startup stage");
5291 t.notice_cause("failed", Some("existing-run"), Some(0));
5292 let run_failure = t.chat_notice_key().unwrap();
5293 t.chat_notices.push(run_failure.clone());
5294 t.release_operator("test", None);
5295 t.startup_notice_cause("startup-source");
5296 t.hold_machine(Some("no new work happened".into()));
5297 assert_eq!(t.chat_notice_due(), None);
5298 t.notice_cause("failed", Some("existing-run"), Some(1));
5299 assert!(t.chat_notice_due().is_some(), "a real resumed pass failed");
5300 assert_ne!(t.chat_notice_key().unwrap(), run_failure);
5301 }
5302
5303 #[test]
5304 fn legacy_hold_baseline_does_not_replay_or_hide_new_failures_and_completion() {
5305 let mut t = chat_task();
5306 t.hold_machine(Some("still waiting".into()));
5307 t.chat_notice = Some(ChatNotice {
5308 key: "held#2026-10-14T00:00:00Z".into(),
5309 at: Timestamp::now(),
5310 outcome: ChatNoticeOutcome::Posted,
5311 });
5312 assert_eq!(t.chat_notice_due(), None, "old hold baseline");
5313 t.import_chat_notice();
5314 t.notice_cause("failed", Some("new-run"), Some(0));
5315 assert!(t.chat_notice_due().is_some(), "new producer evidence");
5316 t.succeed();
5317 assert_eq!(t.chat_notice_due().as_deref(), Some("done"));
5318 }
5319
5320 #[test]
5321 fn a_chat_notice_is_due_only_for_a_final_state_and_only_once() {
5322 let mut t = chat_task();
5323 assert_eq!(t.chat_notice_due(), None, "queued");
5324 t.start("r1".to_owned());
5325 assert_eq!(t.chat_notice_due(), None, "running");
5326 t.fail("boom", 3);
5327 assert_eq!(t.status, TaskStatus::Failed);
5328 assert_eq!(t.chat_notice_due(), None, "a retried failure is not final");
5329 t.start("r2".to_owned());
5330 t.park("upgrade");
5331 assert_eq!(t.chat_notice_due(), None, "parked");
5332 t.fail("boom", 1);
5333 assert_eq!(t.status, TaskStatus::Held);
5334 let key = t
5335 .chat_notice_due()
5336 .expect("held on the last attempt is final");
5337 t.chat_notice = Some(ChatNotice {
5338 key,
5339 at: Timestamp::now(),
5340 outcome: ChatNoticeOutcome::Posted,
5341 });
5342 assert_eq!(t.chat_notice_due(), None, "same hold, no second notice");
5343 t.release();
5344 assert_eq!(t.chat_notice_due(), None, "requeued after a release");
5345 t.start("r3".to_owned());
5346 t.succeed();
5347 assert!(t.chat_notice_due().is_some(), "done is a new final state");
5348 }
5349
5350 #[test]
5351 fn completed_chat_notices_keep_current_and_legacy_keys_after_requeue() {
5352 for key in ["done", "done#0", "done#1", "done#12"] {
5353 let mut t = chat_task();
5354 t.start("r1".to_owned());
5355 t.succeed();
5356 t.chat_notice = Some(ChatNotice {
5357 key: key.to_owned(),
5358 at: Timestamp::now(),
5359 outcome: ChatNoticeOutcome::Posted,
5360 });
5361 t.requeue();
5362 t.start("r2".to_owned());
5363 t.succeed();
5364 assert_eq!(t.chat_notice_key().as_deref(), Some("done"));
5365 assert_eq!(t.chat_notice_due(), None, "recorded key: {key}");
5366 }
5367 }
5368
5369 #[test]
5370 fn a_release_without_progress_keeps_the_unresolved_notice_cause() {
5371 let mut t = chat_task();
5372 t.hold_manual(Some("wait".to_owned()));
5373 let first = t.chat_notice_due().expect("held");
5374 t.chat_notice = Some(ChatNotice {
5375 key: first.clone(),
5376 at: Timestamp::now(),
5377 outcome: ChatNoticeOutcome::Posted,
5378 });
5379 t.hold_manual(Some("still waiting".to_owned()));
5380 assert_eq!(t.chat_notice_due(), None, "a re-hold keeps its start");
5381 t.release();
5382 std::thread::sleep(std::time::Duration::from_millis(5));
5383 t.hold_manual(None);
5384 assert_eq!(t.chat_notice_due(), None, "no progress, same cause");
5385 }
5386
5387 #[test]
5388 fn a_chat_notice_needs_a_chat_source_and_a_current_schema() {
5389 let mut human = task("h");
5390 human.succeed();
5391 assert_eq!(human.chat_notice_due(), None);
5392 let mut inherited = task("i");
5394 inherited.origin_chat = Some("20260902-000000-beef".to_owned());
5395 inherited.succeed();
5396 assert_eq!(inherited.chat_notice_due(), None);
5397 let mut old = chat_task();
5398 old.schema = 12;
5399 old.status = TaskStatus::Done;
5400 assert_eq!(old.chat_notice_due(), None, "history is not replayed");
5401 let mut live = chat_task();
5402 live.schema = 12;
5403 live.start("r1".to_owned());
5404 live.succeed();
5405 assert!(live.chat_notice_due().is_some(), "started by this build");
5406 let mut held = chat_task();
5407 held.schema = 12;
5408 held.hold_machine(Some("config".to_owned()));
5409 assert!(held.chat_notice_due().is_some(), "held before any start");
5410 }
5411
5412 #[test]
5413 fn a_stale_snapshot_does_not_rewind_a_recorded_chat_notice() {
5414 let tmp = tempfile::tempdir().expect("tempdir");
5415 let q = Queue::at(tmp.path().join("queue"));
5416 let mut t = chat_task();
5417 t.succeed();
5418 q.put(&mut t).expect("put");
5419 let mut stale = q.get(&t.id).expect("get");
5420 let key = t.chat_notice_due().expect("due");
5421 q.modify(&t.id, |x| {
5422 x.chat_notice = Some(ChatNotice {
5423 key: key.clone(),
5424 at: Timestamp::now(),
5425 outcome: ChatNoticeOutcome::Posted,
5426 });
5427 true
5428 })
5429 .expect("modify");
5430 q.put(&mut stale).expect("stale put");
5431 assert_eq!(q.get(&t.id).expect("get").chat_notice_due(), None);
5432 }
5433}