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 = 19;
133
134pub(crate) fn dispatch_order(a: &Task, b: &Task) -> std::cmp::Ordering {
140 match (a.upgrade_resume_key(), b.upgrade_resume_key()) {
141 (Some(a), Some(b)) => a.cmp(&b),
142 (Some(_), None) => std::cmp::Ordering::Less,
143 (None, Some(_)) => std::cmp::Ordering::Greater,
144 (None, None) => b.priority.cmp(&a.priority).then(a.id.cmp(&b.id)),
145 }
146}
147
148#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
150#[serde(rename_all = "lowercase")]
151pub enum HoldSource {
152 Manual,
154 Machine,
156}
157
158impl HoldSource {
159 pub fn label(self) -> &'static str {
161 match self {
162 Self::Manual => "manual",
163 Self::Machine => "machine",
164 }
165 }
166}
167
168#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
171#[serde(tag = "kind", rename_all = "lowercase")]
172pub enum Source {
173 Human,
175 Agent {
178 run: String,
180 node: String,
182 },
183 Issue {
185 number: u64,
187 repo: String,
189 },
190}
191
192pub const CHAT_NODE: &str = "chat";
195
196impl Source {
197 pub fn label(&self) -> String {
199 match self {
200 Self::Human => "human".to_owned(),
201 Self::Agent { run, node } => format!("{node}@{}", short(run)),
202 Self::Issue { number, .. } => format!("issue #{number}"),
203 }
204 }
205}
206
207#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
209#[serde(rename_all = "lowercase")]
210pub enum TaskStatus {
211 Queued,
213 Running,
215 Done,
217 Failed,
219 Held,
221 Blocked,
225 Parked,
230}
231
232impl TaskStatus {
233 pub fn runnable(self) -> bool {
235 matches!(self, Self::Queued | Self::Failed | Self::Parked)
236 }
237
238 pub fn as_str(self) -> &'static str {
240 match self {
241 Self::Queued => "queued",
242 Self::Running => "running",
243 Self::Done => "done",
244 Self::Failed => "failed",
245 Self::Held => "held",
246 Self::Blocked => "blocked",
247 Self::Parked => "parked",
248 }
249 }
250}
251
252#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
255pub struct TaskCounts {
256 pub queued: usize,
258 pub running: usize,
260 pub done: usize,
262 pub failed: usize,
264 pub held: usize,
266 pub blocked: usize,
268 pub parked: usize,
270}
271
272impl TaskCounts {
273 pub fn of(tasks: &[Task]) -> Self {
277 Self::of_statuses(tasks.iter().map(|task| task.status))
278 }
279
280 pub(crate) fn of_statuses(statuses: impl IntoIterator<Item = TaskStatus>) -> Self {
282 let mut counts = Self::default();
283 for status in statuses {
284 match status {
285 TaskStatus::Queued => counts.queued += 1,
286 TaskStatus::Running => counts.running += 1,
287 TaskStatus::Done => counts.done += 1,
288 TaskStatus::Failed => counts.failed += 1,
289 TaskStatus::Held => counts.held += 1,
290 TaskStatus::Blocked => counts.blocked += 1,
291 TaskStatus::Parked => counts.parked += 1,
292 }
293 }
294 counts
295 }
296}
297
298#[derive(Debug, Clone, Serialize, Deserialize)]
300#[serde(deny_unknown_fields)]
301pub struct Task {
302 pub schema: u32,
304 pub id: String,
306 pub title: String,
308 pub instruction: String,
310 #[serde(default, skip_serializing_if = "crate::refs::Sources::is_empty")]
312 pub sources: crate::refs::Sources,
313 pub repo: PathBuf,
315 pub source: Source,
317 #[serde(default)]
319 pub priority: i32,
320 #[serde(default)]
331 pub solo: bool,
332 pub status: TaskStatus,
334 #[serde(default)]
336 pub attempts: usize,
337 #[serde(default)]
339 pub attempt_limit: Option<NonZeroUsize>,
340 #[serde(default)]
343 pub runs: Vec<String>,
344 #[serde(default)]
346 pub last_error: Option<String>,
347 #[serde(default)]
360 pub hold_reason: Option<String>,
361 #[serde(default)]
364 pub hold_source: Option<HoldSource>,
365 #[serde(default)]
377 pub diagnostic: Option<String>,
378 #[serde(default)]
388 pub blocked_by: Vec<String>,
389 #[serde(default)]
392 pub block_reason: Option<String>,
393 #[serde(default)]
406 pub blocked_from: Option<TaskStatus>,
407 #[serde(default)]
409 pub blocked_at: Option<Timestamp>,
410 #[serde(default)]
421 pub answers: Vec<AnsweredQuestion>,
422 #[serde(default)]
428 pub triage_applied: Vec<String>,
429 #[serde(default)]
435 pub actions_applied: Vec<String>,
436 #[serde(default)]
441 pub resume_override: Option<OperatorResume>,
442 #[serde(default)]
445 pub resumption: Option<Resumption>,
446 #[serde(default)]
448 pub resumption_history: Vec<Resumption>,
449 #[serde(default)]
451 pub operator_decided_at: Option<Timestamp>,
452 #[serde(default)]
464 pub review_branch: Option<String>,
465 #[serde(default)]
467 pub review_instruction: Option<String>,
468 #[serde(default)]
470 pub pr_recovery: Option<crate::pr_recovery::Selection>,
471 #[serde(default)]
474 pub fresh_start: bool,
475 #[serde(default)]
477 pub replacement: Option<crate::merge_decision::Replacement>,
478 #[serde(default)]
489 pub interrupt: bool,
490 #[serde(default)]
513 pub urgent: bool,
514 #[serde(default)]
520 pub attachments: Vec<String>,
521 #[serde(default)]
525 pub followup: Option<FollowUp>,
526 #[serde(default)]
532 pub origin_chat: Option<String>,
533 #[serde(default)]
540 pub overrides: Option<RunOverrides>,
541 #[serde(default)]
548 pub review_of: Option<String>,
549 #[serde(default)]
554 pub held_at: Option<Timestamp>,
555 #[serde(default)]
559 pub park_reason: Option<String>,
560 #[serde(default)]
563 pub chat_notice: Option<ChatNotice>,
564 #[serde(default)]
566 pub chat_notices: Vec<String>,
567 #[serde(default)]
570 pub hold_notice_cause: Option<NoticeCause>,
571 #[serde(default)]
576 pub passes: Vec<TaskPass>,
577 #[serde(default)]
581 pub dispatched_at: Option<Timestamp>,
582 #[serde(default)]
584 pub recovery_budget: Option<RecoveryBudget>,
585 #[serde(default)]
588 pub recovery_admission: Option<RecoveryAdmission>,
589 #[serde(default)]
592 pub recovery_generations: Vec<RecoveryBudget>,
593 pub created_at: Timestamp,
595 pub updated_at: Timestamp,
597}
598
599#[derive(Debug, Clone, Serialize, Deserialize)]
601pub struct RecoveryAdmission {
602 pub base: String,
604 pub inputs: String,
606}
607
608#[derive(Debug, Clone, Serialize, Deserialize)]
611pub struct RecoveryBudget {
612 pub instruction: String,
614 pub base: String,
616 pub inputs: String,
618 pub stage: String,
620 pub failures: usize,
622 #[serde(default)]
625 pub progress: Option<String>,
626}
627
628fn recovery_progress(state: &crate::run::RunState) -> Option<String> {
629 use crate::proc::Quiet as _;
630 use std::hash::{Hash, Hasher};
631 let mut patches = Vec::new();
632 for candidate in state.candidates.iter().filter(|c| c.commits > 0) {
633 let output = std::process::Command::new("git")
634 .current_dir(&state.repo)
635 .args(["diff", "--binary", &state.base_commit, &candidate.branch])
636 .quiet()
637 .output()
638 .ok()?;
639 if !output.status.success() {
640 return None;
641 }
642 patches.push(output.stdout);
643 }
644 patches.sort();
645 patches.dedup();
646 let mut hash = std::collections::hash_map::DefaultHasher::new();
647 patches.hash(&mut hash);
648 Some(format!("{:x}", hash.finish()))
649}
650
651pub(crate) fn recovery_inputs(sources: &crate::refs::Sources) -> String {
655 serde_json::to_string(
656 &sources
657 .start_from
658 .iter()
659 .map(|i| ("start", &i.token, &i.sha))
660 .chain(
661 sources
662 .pick_commits
663 .iter()
664 .map(|i| ("pick", &i.token, &i.sha)),
665 )
666 .collect::<Vec<_>>(),
667 )
668 .unwrap_or_default()
669}
670
671#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
673pub struct NoticeCause {
674 pub stage: String,
676 #[serde(default, skip_serializing_if = "Option::is_none")]
679 pub target: Option<String>,
680 pub run: Option<String>,
682 pub pass: Option<usize>,
684}
685
686pub(crate) fn is_done_notice_key(key: &str) -> bool {
688 key == "done"
689 || key
690 .strip_prefix("done#")
691 .is_some_and(|count| !count.is_empty() && count.bytes().all(|b| b.is_ascii_digit()))
692}
693
694#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
696#[serde(rename_all = "snake_case")]
697pub enum PassCost {
698 Spent,
700 Refunded,
702}
703
704#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
706pub struct TaskPass {
707 pub run: String,
709 pub pass_index: usize,
711 pub cost: PassCost,
713 pub attempts_after: usize,
715 #[serde(default)]
717 pub started_at: Option<Timestamp>,
718 pub settled_at: Timestamp,
720 #[serde(default)]
722 pub parked: Option<crate::run::ParkCause>,
723}
724
725#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
727#[serde(rename_all = "lowercase")]
728pub enum ChatNoticeOutcome {
729 Posted,
731 Skipped,
733}
734
735#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
737pub struct ChatNotice {
738 pub key: String,
740 pub at: Timestamp,
742 pub outcome: ChatNoticeOutcome,
744}
745
746#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
748pub struct RunOverrides {
749 #[serde(default)]
752 pub inherit_daemon_options: bool,
753 #[serde(default)]
755 pub merge: Option<String>,
756 #[serde(default)]
758 pub candidates: Option<usize>,
759 #[serde(default)]
761 pub judges: Option<usize>,
762 #[serde(default)]
764 pub reviewers: Option<usize>,
765 #[serde(default)]
767 pub review_rounds: Option<usize>,
768 #[serde(default)]
770 pub seed: Option<u64>,
771 #[serde(default)]
773 pub config: Option<PathBuf>,
774}
775
776impl RunOverrides {
777 pub fn merge_over(&mut self, other: &RunOverrides) {
779 macro_rules! take {
780 ($($f:ident),*) => {$(
781 if other.$f.is_some() {
782 self.$f.clone_from(&other.$f);
783 }
784 )*};
785 }
786 take!(
787 merge,
788 candidates,
789 judges,
790 reviewers,
791 review_rounds,
792 seed,
793 config
794 );
795 }
796
797 pub fn apply(&self, config: &mut crate::config::Config) {
799 if let Some(n) = self.candidates {
800 config.graph.implementers = n;
801 }
802 if let Some(n) = self.judges {
803 config.graph.judges = n;
804 }
805 if let Some(n) = self.reviewers {
806 config.graph.reviewers = n;
807 }
808 if let Some(n) = self.review_rounds {
809 config.graph.review_rounds = n;
810 }
811 if let Some(m) = &self.merge
812 && let Ok(mode) = crate::daemon::merge_mode(m)
813 {
814 config.merge.mode = mode;
815 }
816 if let Some(s) = self.seed {
817 config.blind.seed = Some(s);
818 }
819 }
820}
821
822#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
824pub struct FollowUp {
825 pub run: String,
827 #[serde(default)]
829 pub origin_task: Option<String>,
830 pub pr: String,
832 pub findings: Vec<String>,
834 pub generation: u32,
837}
838
839#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
841pub struct Resumption {
842 pub generation: u64,
844 pub source: String,
846 pub question_id: Option<String>,
848 pub at: Timestamp,
850 pub instruction: String,
852 pub answers_through: usize,
854 pub runs_through: usize,
857 pub cleared_reason: Option<String>,
859 #[serde(default)]
861 pub blocking_evidence: Vec<String>,
862}
863
864#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
867pub struct OperatorResume {
868 pub question_id: String,
870 pub at: Timestamp,
872 #[serde(default)]
875 pub conductor_rehold: Option<String>,
876 #[serde(default)]
879 pub forced: bool,
880 #[serde(default)]
887 pub pinned_run: Option<String>,
888}
889
890#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
893pub struct AnsweredQuestion {
894 pub question: String,
896 pub answer: String,
898}
899
900impl Task {
901 pub fn new(title: String, instruction: String, repo: PathBuf, source: Source) -> Self {
903 let now = Timestamp::now();
904 let origin_chat = match &source {
905 Source::Agent { run, node } if node == CHAT_NODE => Some(run.clone()),
906 _ => None,
907 };
908 Self {
909 schema: SCHEMA,
910 id: new_id(),
911 title,
912 instruction,
913 sources: crate::refs::Sources::default(),
914 repo,
915 source,
916 priority: 0,
917 solo: false,
918 status: TaskStatus::Queued,
919 attempts: 0,
920 attempt_limit: None,
921 runs: Vec::new(),
922 last_error: None,
923 hold_reason: None,
924 hold_source: None,
925 diagnostic: None,
926 blocked_by: Vec::new(),
927 block_reason: None,
928 blocked_from: None,
929 blocked_at: None,
930 answers: Vec::new(),
931 triage_applied: Vec::new(),
932 actions_applied: Vec::new(),
933 resume_override: None,
934 resumption: None,
935 resumption_history: Vec::new(),
936 operator_decided_at: None,
937 review_branch: None,
938 review_instruction: None,
939 pr_recovery: None,
940 fresh_start: false,
941 replacement: None,
942 interrupt: false,
943 urgent: false,
944 attachments: Vec::new(),
945 followup: None,
946 origin_chat,
947 overrides: None,
948 review_of: None,
949 held_at: None,
950 park_reason: None,
951 chat_notice: None,
952 chat_notices: Vec::new(),
953 hold_notice_cause: None,
954 passes: Vec::new(),
955 dispatched_at: None,
956 recovery_budget: None,
957 recovery_admission: None,
958 recovery_generations: Vec::new(),
959 created_at: now,
960 updated_at: now,
961 }
962 }
963
964 pub fn chat_talk(&self) -> Option<&str> {
967 if let Some(id) = &self.origin_chat {
968 return Some(id);
969 }
970 match &self.source {
971 Source::Agent { run, node } if node == CHAT_NODE => Some(run),
972 _ => None,
973 }
974 }
975
976 pub fn filed_by_chat(&self) -> Option<&str> {
980 match &self.source {
981 Source::Agent { run, node } if node == CHAT_NODE => Some(run),
982 _ => None,
983 }
984 }
985
986 pub fn chat_notice_key(&self) -> Option<String> {
989 match self.status {
990 TaskStatus::Done => Some("done".to_owned()),
991 TaskStatus::Held => {
992 let cause = self
993 .hold_notice_cause
994 .clone()
995 .unwrap_or_else(|| self.unresolved_hold_cause());
996 Some(format!(
997 "cause:{}",
998 serde_json::to_string(&cause).expect("cause")
999 ))
1000 }
1001 TaskStatus::Blocked => {
1002 let ids: std::collections::BTreeSet<_> = self.blocked_by.iter().collect();
1003 let evidence = self
1006 .hold_notice_cause
1007 .as_ref()
1008 .filter(|c| c.stage.starts_with("conductor-evidence:"));
1009 Some(format!(
1010 "dependencies:{}",
1011 serde_json::to_string(&(ids, evidence)).expect("ids")
1012 ))
1013 }
1014 _ => None,
1015 }
1016 }
1017
1018 fn unresolved_hold_cause(&self) -> NoticeCause {
1019 NoticeCause {
1020 stage: "hold".into(),
1021 target: None,
1022 run: self.runs.last().cloned(),
1023 pass: None,
1024 }
1025 }
1026
1027 pub(crate) fn legacy_chat_notice_key(&self) -> Option<String> {
1029 let key = match self.status {
1030 TaskStatus::Held if self.hold_notice_cause.is_none() => Some(format!(
1031 "held#{}",
1032 self.held_at.map(|t| t.to_string()).unwrap_or_default()
1033 )),
1034 TaskStatus::Blocked => Some(format!(
1035 "blocked#{}#{}",
1036 self.blocked_by.join(","),
1037 self.block_reason.as_deref().unwrap_or_default()
1038 )),
1039 _ => None,
1040 };
1041 key.filter(|key| self.legacy_notice_matches(key))
1042 }
1043
1044 pub(crate) fn legacy_notice_matches(&self, key: &str) -> bool {
1045 match self.status {
1046 TaskStatus::Held if self.hold_notice_cause.is_none() => key
1047 .strip_prefix("held#")
1048 .is_some_and(|time| time.is_empty() || time.parse::<Timestamp>().is_ok()),
1049 TaskStatus::Blocked
1050 if self
1051 .hold_notice_cause
1052 .as_ref()
1053 .is_none_or(|c| !c.stage.starts_with("conductor-evidence:")) =>
1054 {
1055 key.strip_prefix("blocked#").is_some_and(|tail| {
1056 let Some((ids, _)) = tail.split_once('#') else {
1057 return false;
1058 };
1059 let old: std::collections::BTreeSet<_> =
1060 ids.split(',').filter(|id| !id.is_empty()).collect();
1061 let current: std::collections::BTreeSet<_> =
1062 self.blocked_by.iter().map(String::as_str).collect();
1063 old == current
1064 })
1065 }
1066 _ => false,
1067 }
1068 }
1069
1070 fn import_chat_notice(&mut self) {
1071 if let Some(n) = &self.chat_notice {
1072 let key = if is_done_notice_key(&n.key) {
1073 "done".to_owned()
1074 } else if self.legacy_notice_matches(&n.key) {
1075 self.chat_notice_key().unwrap_or_else(|| n.key.clone())
1076 } else {
1077 n.key.clone()
1078 };
1079 if !self.chat_notices.contains(&key) {
1080 self.chat_notices.push(key);
1081 }
1082 }
1083 }
1084
1085 pub(crate) fn notice_cause(&mut self, stage: &str, run: Option<&str>, pass: Option<usize>) {
1088 self.import_chat_notice();
1089 self.hold_notice_cause = Some(NoticeCause {
1090 stage: stage.to_owned(),
1091 target: None,
1092 run: run.map(str::to_owned),
1093 pass,
1094 });
1095 }
1096
1097 pub(crate) fn startup_notice_cause(&mut self, stage: &str) {
1100 let recovery_only = matches!(
1101 stage,
1102 "startup-source" | "startup-candidates" | "startup-public-head"
1103 );
1104 match self.hold_notice_cause.as_ref() {
1105 Some(cause) if recovery_only && !cause.stage.starts_with("startup-") => return,
1106 None if recovery_only
1107 && self
1108 .chat_notice
1109 .as_ref()
1110 .is_some_and(|n| !is_done_notice_key(&n.key)) =>
1111 {
1112 return;
1113 }
1114 _ => {}
1115 }
1116 self.notice_cause(stage, None, None);
1117 }
1118
1119 pub(crate) fn fail_startup(
1122 &mut self,
1123 stage: &str,
1124 why: impl Into<String>,
1125 max_attempts: usize,
1126 ) {
1127 let unresolved = self.hold_notice_cause.clone();
1128 self.fail(why, max_attempts);
1129 self.hold_notice_cause = unresolved;
1130 self.startup_notice_cause(stage);
1131 }
1132
1133 pub(crate) fn fail_startup_target(
1135 &mut self,
1136 stage: &str,
1137 target: String,
1138 why: impl Into<String>,
1139 max_attempts: usize,
1140 ) {
1141 self.fail_startup(stage, why, max_attempts);
1142 if let Some(cause) = self.hold_notice_cause.as_mut() {
1143 cause.target = Some(target);
1144 }
1145 }
1146
1147 pub fn chat_notice_due(&self) -> Option<String> {
1149 self.filed_by_chat()?;
1150 if self.schema < 13 {
1151 return None;
1152 }
1153 let key = self.chat_notice_key()?;
1154 if self.chat_notices.contains(&key) {
1155 return None;
1156 }
1157 match &self.chat_notice {
1158 Some(n)
1159 if n.key == key
1160 || (key == "done" && is_done_notice_key(&n.key))
1161 || self.legacy_notice_matches(&n.key) =>
1162 {
1163 None
1164 }
1165 _ => Some(key),
1166 }
1167 }
1168
1169 pub fn short(&self) -> &str {
1171 short(&self.id)
1172 }
1173
1174 pub fn mark_triage_applied(&mut self, question_id: &str) {
1176 if !self.triage_applied(question_id) {
1177 self.triage_applied.push(question_id.to_owned());
1178 }
1179 }
1180
1181 pub fn action_applied(&self, question_id: &str) -> bool {
1184 self.actions_applied.iter().any(|id| id == question_id)
1185 }
1186
1187 pub fn mark_action_applied(&mut self, question_id: &str) {
1189 if !self.action_applied(question_id) {
1190 self.actions_applied.push(question_id.to_owned());
1191 }
1192 }
1193
1194 pub fn triage_applied(&self, question_id: &str) -> bool {
1196 self.triage_applied.iter().any(|id| id == question_id)
1197 }
1198
1199 pub fn start(&mut self, run: String) {
1212 self.stamp_schema();
1213 self.status = TaskStatus::Running;
1214 self.attempts += 1;
1215 self.runs.retain(|id| id != &run);
1216 self.runs.push(run);
1217 self.last_error = None;
1218 self.park_reason = None;
1219 self.fresh_start = false;
1220 self.interrupt = false;
1221 self.resume_override = None;
1223 }
1224
1225 pub fn link_run(&mut self, run: &str) -> bool {
1230 if self.runs.iter().any(|r| r == run) {
1231 return false;
1232 }
1233 self.runs.push(run.to_owned());
1234 true
1235 }
1236
1237 pub fn succeed(&mut self) {
1247 self.stamp_schema();
1248 self.status = TaskStatus::Done;
1249 self.resume_override = None;
1250 self.last_error = None;
1251 self.park_reason = None;
1252 self.hold_reason = None;
1253 self.hold_source = None;
1254 self.diagnostic = None;
1255 self.blocked_by.clear();
1256 self.block_reason = None;
1257 self.blocked_from = None;
1258 }
1259
1260 pub fn already_landed(&mut self, note: impl Into<String>) {
1268 self.succeed();
1269 self.attempts = self.attempts.saturating_sub(1);
1270 self.last_error = Some(note.into());
1271 }
1272
1273 pub fn superseded_attempts(&self, last_run_succeeded: bool) -> &[String] {
1303 if self.status != TaskStatus::Done || !last_run_succeeded || self.runs.len() < 2 {
1304 return &[];
1305 }
1306 &self.runs[..self.runs.len() - 1]
1307 }
1308
1309 pub fn successor_of(&self, run: &str) -> Option<&String> {
1317 let pos = self.runs.iter().rposition(|r| r == run)?;
1318 self.runs.get(pos + 1)
1319 }
1320
1321 pub fn earlier_attempts(&self) -> &[String] {
1328 &self.runs
1329 }
1330
1331 fn stamp_schema(&mut self) {
1338 self.schema = self.schema.max(SCHEMA);
1339 }
1340
1341 fn note_held(&mut self) {
1342 self.stamp_schema();
1343 let continuing = self.status == TaskStatus::Held
1344 || (self.status == TaskStatus::Blocked
1345 && (self.blocked_from == Some(TaskStatus::Held)
1346 || (self.blocked_from.is_none()
1347 && (self.hold_reason.is_some() || self.hold_source.is_some()))));
1348 if !continuing || self.held_at.is_none() {
1349 self.held_at = Some(Timestamp::now());
1350 }
1351 }
1352
1353 pub fn fail(&mut self, why: impl Into<String>, max_attempts: usize) {
1369 let why = why.into();
1370 if self.status == TaskStatus::Running {
1371 let run = self.runs.last().cloned();
1372 self.notice_cause("run-failed", run.as_deref(), Some(self.attempts));
1373 }
1374 self.diagnostic = None;
1375 self.park_reason = None;
1376 self.status = if self.attempts >= self.effective_attempt_limit(max_attempts) {
1377 self.note_held();
1378 self.hold_source = Some(HoldSource::Machine);
1379 self.hold_reason = Some(why.clone());
1380 TaskStatus::Held
1381 } else {
1382 TaskStatus::Failed
1383 };
1384 self.last_error = Some(why);
1385 }
1386
1387 pub fn stall(&mut self, why: impl Into<String>) {
1397 self.last_error = Some(why.into());
1398 self.diagnostic = None;
1399 self.attempts = self.attempts.saturating_sub(1);
1400 self.status = TaskStatus::Failed;
1401 }
1402
1403 pub fn park(&mut self, why: impl Into<String>) {
1409 self.diagnostic = None;
1410 self.attempts = self.attempts.saturating_sub(1);
1411 self.status = TaskStatus::Parked;
1412 self.park_reason = Some(why.into());
1413 self.schema = self.schema.max(SCHEMA);
1416 }
1417
1418 pub fn record_pass(
1420 &mut self,
1421 run: &str,
1422 pass_index: usize,
1423 attempts_before: usize,
1424 parked: Option<crate::run::ParkCause>,
1425 ) {
1426 self.record_pass_at(run, pass_index, None, attempts_before, parked);
1427 }
1428
1429 pub fn record_pass_at(
1432 &mut self,
1433 run: &str,
1434 pass_index: usize,
1435 started_at: Option<Timestamp>,
1436 attempts_before: usize,
1437 parked: Option<crate::run::ParkCause>,
1438 ) {
1439 if self
1440 .passes
1441 .iter()
1442 .any(|p| p.run == run && p.pass_index == pass_index)
1443 {
1444 return;
1445 }
1446 let cost = if self.attempts < attempts_before {
1447 PassCost::Refunded
1448 } else {
1449 PassCost::Spent
1450 };
1451 self.passes.push(TaskPass {
1452 run: run.to_owned(),
1453 pass_index,
1454 started_at: self.dispatched_at.or(started_at),
1455 cost,
1456 attempts_after: self.attempts,
1457 settled_at: Timestamp::now(),
1458 parked,
1459 });
1460 self.schema = self.schema.max(SCHEMA);
1461 }
1462
1463 pub(crate) fn upgrade_resume_key(&self) -> Option<(Timestamp, &str)> {
1466 if self.status != TaskStatus::Parked || self.fresh_start {
1467 return None;
1468 }
1469 let pass = self.passes.last()?;
1470 if self.runs.last() != Some(&pass.run)
1471 || !matches!(pass.parked, Some(crate::run::ParkCause::Upgrade { .. }))
1472 {
1473 return None;
1474 }
1475 Some((pass.started_at.unwrap_or(pass.settled_at), &self.id))
1478 }
1479
1480 pub fn operator_held(&self) -> bool {
1486 self.status == TaskStatus::Held && !matches!(self.hold_source, Some(HoldSource::Machine))
1487 }
1488
1489 pub fn hold_manual(&mut self, reason: Option<String>) {
1499 self.operator_decided_at = Some(Timestamp::now());
1501 self.hold_unrequested_retry(reason);
1502 }
1503
1504 pub(crate) fn hold_unrequested_retry(&mut self, reason: Option<String>) {
1507 self.park_reason = None;
1508 self.note_held();
1509 self.status = TaskStatus::Held;
1510 if reason.is_some() {
1511 self.hold_reason = reason;
1512 }
1513 self.hold_source = Some(HoldSource::Manual);
1514 self.blocked_by.clear();
1515 self.block_reason = None;
1516 self.blocked_from = None;
1517 }
1518
1519 pub fn hold_machine(&mut self, reason: Option<String>) {
1524 self.park_reason = None;
1525 self.note_held();
1526 self.status = TaskStatus::Held;
1527 if reason.is_some() {
1528 self.hold_reason = reason;
1529 }
1530 self.hold_source = Some(HoldSource::Machine);
1531 self.blocked_by.clear();
1532 self.block_reason = None;
1533 self.blocked_from = None;
1534 }
1535
1536 pub fn block(&mut self, blocked_by: Vec<String>, reason: Option<String>) {
1546 self.stamp_schema();
1547 let before: std::collections::BTreeSet<_> = self.blocked_by.iter().collect();
1548 let after: std::collections::BTreeSet<_> = blocked_by.iter().collect();
1549 if self.status != TaskStatus::Blocked || before != after {
1550 self.blocked_at = Some(Timestamp::now());
1551 }
1552 if self.status != TaskStatus::Blocked {
1553 self.blocked_from = Some(self.status);
1554 }
1555 self.status = TaskStatus::Blocked;
1556 self.blocked_by = blocked_by;
1557 self.block_reason = reason;
1558 }
1559
1560 pub fn unblock(&mut self, resolved_id: &str) {
1583 if self.status != TaskStatus::Blocked {
1584 return;
1585 }
1586 self.blocked_by.retain(|id| id != resolved_id);
1587 self.restore_if_unblocked();
1588 }
1589
1590 pub fn dependency_deleted(&mut self, deleted_id: &str) -> bool {
1601 if self.status != TaskStatus::Blocked || !self.blocked_by.iter().any(|b| b == deleted_id) {
1602 return false;
1603 }
1604 self.blocked_by.retain(|id| id != deleted_id);
1605 self.restore_if_unblocked();
1606 true
1607 }
1608
1609 fn restore_if_unblocked(&mut self) {
1613 if self.blocked_by.is_empty() {
1614 self.status = match self.blocked_from {
1615 Some(TaskStatus::Running) => TaskStatus::Queued,
1616 Some(other) => other,
1617 None if self.hold_reason.is_some() || self.hold_source.is_some() => {
1618 TaskStatus::Held
1619 }
1620 None => TaskStatus::Queued,
1621 };
1622 self.block_reason = None;
1623 self.blocked_from = None;
1624 }
1625 }
1626
1627 pub fn record_answer(&mut self, question: String, answer: String) {
1632 self.answers.push(AnsweredQuestion { question, answer });
1633 }
1634
1635 pub fn request_review(&mut self, branch: String) {
1639 self.release();
1640 self.review_branch = Some(branch);
1641 self.review_instruction = Some(self.instruction.clone());
1642 }
1643
1644 pub fn requeue(&mut self) {
1647 self.release();
1648 self.review_branch = None;
1650 self.review_instruction = None;
1651 self.fresh_start = true;
1652 }
1653
1654 pub fn hold_for_handover(&mut self, branch: Option<String>, reason: String) {
1663 if branch.is_some() {
1664 self.review_branch = branch;
1665 self.review_instruction = Some(self.instruction.clone());
1666 }
1667 self.hold_machine(Some(reason));
1668 }
1669
1670 pub fn set_urgent(&mut self, urgent: bool) -> Result<()> {
1672 if !matches!(self.status, TaskStatus::Queued | TaskStatus::Held) {
1673 bail!(
1674 "task {} is {}; urgent can only change while queued or held; \
1675 wait for the active attempt to finish and requeue or hold the task first",
1676 self.short(),
1677 self.status.as_str()
1678 );
1679 }
1680 self.urgent = urgent;
1681 Ok(())
1682 }
1683
1684 pub fn set_priority(&mut self, priority: i32) -> Result<()> {
1693 if self.status == TaskStatus::Running {
1694 bail!(
1695 "task {} is running; its priority cannot be changed until \
1696 this attempt finishes",
1697 self.short()
1698 );
1699 }
1700 self.priority = priority;
1701 Ok(())
1702 }
1703
1704 pub fn set_interrupt(&mut self, interrupt: bool) -> Result<()> {
1719 if interrupt && !self.status.runnable() {
1720 bail!(
1721 "task {} is {}; only a queued or failed task can be marked \
1722 to interrupt",
1723 self.short(),
1724 self.status.as_str()
1725 );
1726 }
1727 self.interrupt = interrupt;
1728 Ok(())
1729 }
1730
1731 pub fn effective_attempt_limit(&self, daemon_limit: usize) -> usize {
1733 self.attempt_limit.map_or(daemon_limit, NonZeroUsize::get)
1734 }
1735
1736 pub fn change_attempt_limit(&mut self, limit: Option<NonZeroUsize>) -> Result<()> {
1739 if !matches!(self.status, TaskStatus::Queued | TaskStatus::Held) {
1740 bail!("only queued or held tasks can change the attempt limit");
1741 }
1742 self.stamp_schema();
1743 self.attempt_limit = limit;
1744 Ok(())
1745 }
1746
1747 pub fn implementers(&self) -> Option<usize> {
1749 if self.solo {
1750 Some(1)
1751 } else {
1752 self.overrides.as_ref().and_then(|o| o.candidates)
1753 }
1754 }
1755
1756 pub fn change_implementers(&mut self, count: u8) -> Result<()> {
1759 self.check_implementers_editable()?;
1760 let explicit = self.overrides.as_ref().and_then(|o| o.config.as_deref());
1761 let (mut config, _) = crate::config::Config::discover(&self.repo, explicit)?;
1762 if let Some(o) = &self.overrides {
1763 o.apply(&mut config);
1764 }
1765 self.set_implementers(count, &config)
1766 }
1767
1768 fn check_implementers_editable(&self) -> Result<()> {
1769 if !matches!(self.status, TaskStatus::Queued | TaskStatus::Held) {
1770 bail!(
1771 "task {} is {}; only queued or held tasks can change implementers",
1772 self.short(),
1773 self.status.as_str()
1774 );
1775 }
1776 Ok(())
1777 }
1778
1779 pub fn set_implementers(&mut self, count: u8, config: &crate::config::Config) -> Result<()> {
1781 self.check_implementers_editable()?;
1782 crate::talk::check_implementers(count, config).map_err(anyhow::Error::msg)?;
1783 self.stamp_schema();
1784 self.solo = count == 1;
1785 let mut overrides = self.overrides.clone().unwrap_or_else(|| RunOverrides {
1786 inherit_daemon_options: true,
1787 ..Default::default()
1788 });
1789 overrides.candidates = (count > 1).then_some(usize::from(count));
1790 let mut settings = overrides.clone();
1791 settings.inherit_daemon_options = false;
1792 self.overrides = if overrides.inherit_daemon_options && settings == RunOverrides::default()
1794 {
1795 None
1796 } else {
1797 Some(overrides)
1798 };
1799 Ok(())
1800 }
1801
1802 pub fn edit(&mut self, title: String, instruction: String) -> Result<()> {
1814 if !matches!(self.status, TaskStatus::Queued | TaskStatus::Held) {
1815 bail!(
1816 "task {} is {}; only a queued or held task's instruction can \
1817 be edited",
1818 self.short(),
1819 self.status.as_str()
1820 );
1821 }
1822 if self.review_branch.is_some() && self.review_instruction.is_none() {
1825 self.review_instruction = Some(self.instruction.clone());
1826 }
1827 if self.instruction != instruction {
1828 self.resume_override = None;
1831 }
1832 self.title = title;
1833 self.instruction = instruction;
1834 Ok(())
1835 }
1836
1837 pub fn handed_off(&mut self, why: impl Into<String>) {
1857 let why = why.into();
1858 self.diagnostic = None;
1859 self.note_held();
1860 self.status = TaskStatus::Held;
1861 self.hold_source = Some(HoldSource::Machine);
1862 self.hold_reason = Some(why.clone());
1863 self.last_error = Some(why);
1864 }
1865
1866 pub fn release_operator(&mut self, source: &str, question_id: Option<&str>) {
1868 self.recovery_budget = None;
1869 self.recovery_generations.clear();
1870 self.recovery_admission = None;
1871 if self
1872 .resumption
1873 .as_ref()
1874 .is_some_and(|r| r.instruction != self.instruction)
1875 {
1876 self.resume_override = None;
1879 }
1880 let approval = Resumption {
1881 generation: self.resumption.as_ref().map_or(1, |r| r.generation + 1),
1882 source: source.to_owned(),
1883 question_id: question_id.map(str::to_owned),
1884 at: Timestamp::now(),
1885 instruction: self.instruction.clone(),
1886 answers_through: self.answers.len(),
1887 runs_through: self.runs.len(),
1888 blocking_evidence: Vec::new(),
1889 cleared_reason: self
1890 .hold_reason
1891 .clone()
1892 .or_else(|| self.block_reason.clone()),
1893 };
1894 self.release();
1895 self.operator_decided_at = Some(approval.at);
1896 if let Some(previous) = self.resumption.replace(approval) {
1897 self.resumption_history.push(previous);
1898 }
1899 }
1900
1901 pub(crate) fn recovery_failed(&mut self, state: &crate::run::RunState) {
1903 let (base, inputs) = self.recovery_admission.as_ref().map_or_else(
1904 || (state.base_commit.clone(), recovery_inputs(&state.sources)),
1905 |a| (a.base.clone(), a.inputs.clone()),
1906 );
1907 let progress = recovery_progress(state);
1908 let stage = if state.prep_failure.is_some() {
1909 "preparation"
1910 } else {
1911 "execution"
1912 };
1913 let matches = |b: &RecoveryBudget| {
1914 b.instruction == self.instruction
1915 && b.base == base
1916 && b.inputs == inputs
1917 && (progress.is_none() || b.progress == progress)
1918 };
1919 if !self.recovery_budget.as_ref().is_some_and(matches) {
1920 let previous = self
1921 .recovery_generations
1922 .iter()
1923 .position(matches)
1924 .map(|index| self.recovery_generations.remove(index));
1925 if let Some(old) = self.recovery_budget.take() {
1926 self.recovery_generations.push(old);
1927 }
1928 self.recovery_budget = previous;
1929 }
1930 if let Some(budget) = &mut self.recovery_budget {
1931 budget.failures = budget.failures.saturating_add(1);
1932 budget.stage = stage.to_owned();
1933 } else {
1934 self.recovery_budget = Some(RecoveryBudget {
1935 instruction: self.instruction.clone(),
1936 base,
1937 inputs,
1938 stage: stage.to_owned(),
1939 failures: 1,
1940 progress,
1941 });
1942 }
1943 }
1944
1945 pub(crate) fn recovery_exhausted(
1946 &self,
1947 base: &str,
1948 inputs: &str,
1949 limit: usize,
1950 ) -> Option<String> {
1951 let same_input = |b: &&RecoveryBudget| {
1952 b.instruction == self.instruction && b.base == base && b.inputs == inputs
1953 };
1954 let budget = self
1955 .recovery_budget
1956 .as_ref()
1957 .filter(same_input)
1958 .or_else(|| self.recovery_generations.iter().rev().find(same_input))?;
1959 (budget.instruction == self.instruction && budget.base == base && budget.inputs == inputs
1960 && budget.failures >= limit.max(1)).then(|| format!(
1961 "[recovery-budget] {} failed passes against unchanged inputs ({}). Inspect the retained recovery sources and correct the cause, change the inputs, or explicitly release the task to approve another generation. Automatic requeue does not reset this budget.",
1962 budget.failures, budget.stage))
1963 }
1964
1965 pub fn release(&mut self) {
1969 self.import_chat_notice();
1972 if self.hold_notice_cause.is_none()
1973 && matches!(self.status, TaskStatus::Held | TaskStatus::Blocked)
1974 {
1975 self.hold_notice_cause = Some(self.unresolved_hold_cause());
1976 }
1977 let refused_handover = self.status == TaskStatus::Held
1978 && self.hold_source == Some(HoldSource::Machine)
1979 && self.review_branch.is_some()
1980 && self
1981 .review_instruction
1982 .as_ref()
1983 .is_none_or(|i| *i == self.instruction);
1984 self.status = TaskStatus::Queued;
1985 self.held_at = None;
1986 self.attempts = 0;
1987 self.last_error = None;
1988 self.park_reason = None;
1989 self.hold_reason = None;
1992 self.hold_source = None;
1993 self.diagnostic = None;
1994 self.blocked_by.clear();
1999 self.block_reason = None;
2000 self.blocked_from = None;
2001 if !refused_handover {
2005 self.review_branch = None;
2006 self.review_instruction = None;
2007 }
2008 self.fresh_start = false;
2009 }
2010}
2011
2012fn copy_new(dir: &Path, src: &Path, name: &str) -> Result<(String, PathBuf)> {
2016 use std::io::ErrorKind;
2017 let (stem, ext) = match name.rfind('.') {
2018 Some(i) if i > 0 => (&name[..i], &name[i..]),
2019 _ => (name, ""),
2020 };
2021 for n in 1u32.. {
2022 let candidate = if n == 1 {
2023 name.to_owned()
2024 } else {
2025 let suffix = format!("-{n}");
2026 let room = 64usize.saturating_sub(suffix.len() + ext.len());
2027 let stem: String = stem.chars().take(room).collect();
2028 format!("{stem}{suffix}{ext}")
2029 };
2030 if !crate::ask::valid_asset_name(&candidate) {
2031 bail!("no valid attachment name is left for `{name}`");
2032 }
2033 let path = dir.join(&candidate);
2034 match std::fs::OpenOptions::new()
2035 .write(true)
2036 .create_new(true)
2037 .open(&path)
2038 {
2039 Ok(mut out) => {
2040 let copied = std::fs::File::open(src)
2041 .and_then(|mut input| std::io::copy(&mut input, &mut out));
2042 if let Err(e) = copied {
2043 drop(out);
2044 let _ = std::fs::remove_file(&path);
2045 return Err(e).with_context(|| format!("copy {}", src.display()));
2046 }
2047 return Ok((candidate, path));
2048 }
2049 Err(e) if e.kind() == ErrorKind::AlreadyExists => continue,
2050 Err(e) => return Err(e).with_context(|| format!("create {}", path.display())),
2051 }
2052 }
2053 unreachable!("the counter never runs out")
2054}
2055
2056const TASK_LOCK_STALE: std::time::Duration = std::time::Duration::from_secs(10);
2059
2060pub(crate) struct TaskLock {
2063 path: PathBuf,
2064 token: String,
2065}
2066
2067fn owner_token() -> String {
2070 static COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
2071 let n = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
2072 let mut r = crate::rng::SplitMix64::new(crate::rng::entropy() ^ n.rotate_left(32));
2073 format!("{}-{:016x}", std::process::id(), r.next_u64())
2074}
2075
2076fn break_marker(path: &Path, token: &str) -> PathBuf {
2079 let mut h: u64 = 0xcbf29ce484222325;
2080 for b in token.bytes() {
2081 h ^= u64::from(b);
2082 h = h.wrapping_mul(0x100000001b3);
2083 }
2084 let mut name = path.as_os_str().to_owned();
2085 name.push(format!(".break-{h:016x}"));
2086 PathBuf::from(name)
2087}
2088
2089fn older_than_stale(path: &Path) -> bool {
2090 std::fs::metadata(path)
2091 .and_then(|m| m.modified())
2092 .ok()
2093 .and_then(|t| t.elapsed().ok())
2094 .is_some_and(|age| age > TASK_LOCK_STALE)
2095}
2096
2097fn writer_may_be_gone(token: &str) -> bool {
2098 let Some(pid) = token
2099 .split_once('-')
2100 .and_then(|(pid, _)| pid.parse::<u32>().ok())
2101 else {
2102 return true;
2105 };
2106 if pid == std::process::id() {
2107 return false;
2108 }
2109 crate::proc::pid_status(pid) == Some(false)
2111}
2112
2113const MAX_MARKER_DEPTH: u8 = 3;
2117
2118fn take_marker(
2124 path: &Path,
2125 token: &str,
2126 depth: u8,
2127 retry: crate::lock_file::Retry,
2128) -> Option<(PathBuf, String)> {
2129 use std::io::Write;
2130 let marker = break_marker(path, token);
2131 for _ in 0..2 {
2132 match retry.create(&marker) {
2133 Ok(mut f) => {
2134 let mine = owner_token();
2135 if f.write_all(mine.as_bytes()).is_err() {
2136 drop(f);
2137 let _ = std::fs::remove_file(&marker);
2138 return None;
2139 }
2140 return Some((marker, mine));
2141 }
2142 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
2143 let judged = std::fs::read_to_string(&marker).ok();
2144 match judged.filter(|_| older_than_stale(&marker)) {
2145 Some(judged) if depth < MAX_MARKER_DEPTH => {
2146 remove_lock_if(&marker, &judged, true, depth + 1, retry)?;
2147 }
2148 _ => return None,
2149 }
2150 }
2151 Err(_) => return None,
2152 }
2153 }
2154 None
2155}
2156
2157fn remove_lock_if(
2162 path: &Path,
2163 token: &str,
2164 require_stale: bool,
2165 depth: u8,
2166 retry: crate::lock_file::Retry,
2167) -> Option<bool> {
2168 let (marker, mine) = take_marker(path, token, depth, retry)?;
2169 let still = std::fs::read_to_string(path).is_ok_and(|c| c == token)
2170 && (!require_stale || older_than_stale(path) && writer_may_be_gone(token));
2171 if still {
2172 let _ = std::fs::remove_file(path);
2173 }
2174 if depth >= MAX_MARKER_DEPTH {
2176 if std::fs::read_to_string(&marker).is_ok_and(|c| c == mine) {
2177 let _ = std::fs::remove_file(&marker);
2178 }
2179 } else {
2180 let _ = remove_lock_if(&marker, &mine, false, depth + 1, retry);
2181 }
2182 Some(still)
2183}
2184
2185#[cfg(test)]
2188fn break_stale(path: &Path, judged: &str) -> bool {
2189 break_stale_with(path, judged, crate::lock_file::Retry::new(None))
2190}
2191
2192fn break_stale_with(path: &Path, judged: &str, retry: crate::lock_file::Retry) -> bool {
2193 remove_lock_if(path, judged, true, 0, retry).unwrap_or(false)
2194}
2195
2196impl Drop for TaskLock {
2197 fn drop(&mut self) {
2198 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
2199 let retry = crate::lock_file::Retry::new(Some(deadline));
2200 loop {
2201 match remove_lock_if(&self.path, &self.token, false, 0, retry) {
2202 Some(_) => return,
2203 None if std::time::Instant::now() > deadline => return,
2204 None => std::thread::sleep(std::time::Duration::from_millis(5)),
2205 }
2206 }
2207 }
2208}
2209
2210#[derive(Debug, Clone)]
2212pub struct Queue {
2213 root: PathBuf,
2214}
2215
2216impl Queue {
2217 pub fn open() -> Self {
2219 Self::at(crate::run::home().join("queue"))
2220 }
2221
2222 pub fn at(root: PathBuf) -> Self {
2225 Self { root }
2226 }
2227
2228 pub fn root(&self) -> &Path {
2230 &self.root
2231 }
2232
2233 pub fn path_of(&self, id: &str) -> PathBuf {
2235 self.root.join(format!("{id}.json"))
2236 }
2237
2238 pub fn attachments_dir(&self, id: &str) -> PathBuf {
2241 self.root.join(format!("{id}.attachments"))
2242 }
2243
2244 pub fn attach(&self, task: &mut Task, sources: &[PathBuf]) -> Result<Vec<String>> {
2255 let mut wanted = Vec::new();
2256 for src in sources {
2257 let name = src
2258 .file_name()
2259 .and_then(|n| n.to_str())
2260 .with_context(|| format!("`{}` has no usable file name", src.display()))?;
2261 if !crate::ask::valid_asset_name(name) {
2262 bail!(
2263 "attachment name `{name}` must match ^[A-Za-z0-9][A-Za-z0-9._-]{{0,63}}$ \
2264 with no `..`; rename the file and try again"
2265 );
2266 }
2267 if !src.is_file() {
2268 bail!("attachment `{}` is not a file", src.display());
2269 }
2270 wanted.push((src, name));
2271 }
2272 let dir = self.attachments_dir(&task.id);
2273 let existed = dir.is_dir();
2274 std::fs::create_dir_all(&dir).with_context(|| format!("create {}", dir.display()))?;
2275 let mut created: Vec<PathBuf> = Vec::new();
2276 let mut names = Vec::new();
2277 let mut copy_all = || -> Result<()> {
2278 for (src, name) in &wanted {
2279 let (stored, path) = copy_new(&dir, src, name)?;
2280 created.push(path);
2281 names.push(stored);
2282 }
2283 Ok(())
2284 };
2285 if let Err(e) = copy_all() {
2286 for path in &created {
2287 let _ = std::fs::remove_file(path);
2288 }
2289 if !existed {
2290 let _ = std::fs::remove_dir(&dir);
2291 }
2292 return Err(e);
2293 }
2294 task.attachments.extend(names.iter().cloned());
2295 Ok(names)
2296 }
2297
2298 pub fn attach_and_put(&self, task: &mut Task, sources: &[PathBuf]) -> Result<Vec<String>> {
2309 let dir = self.attachments_dir(&task.id);
2310 let existed = dir.is_dir();
2311 let before = task.attachments.len();
2312 let names = self.attach(task, sources)?;
2313 if let Err(e) = self.put(task) {
2314 for name in &names {
2315 let _ = std::fs::remove_file(dir.join(name));
2316 }
2317 if !existed {
2318 let _ = std::fs::remove_dir(&dir);
2319 }
2320 task.attachments.truncate(before);
2321 return Err(e);
2322 }
2323 Ok(names)
2324 }
2325
2326 pub fn attachment_paths(&self, task: &Task) -> Vec<PathBuf> {
2330 let dir = self.attachments_dir(&task.id);
2331 task.attachments
2332 .iter()
2333 .map(|n| {
2334 let p = dir.join(n);
2335 std::path::absolute(&p).unwrap_or(p)
2336 })
2337 .collect()
2338 }
2339
2340 pub fn put(&self, task: &mut Task) -> Result<()> {
2349 let _lock = self.lock_task(&task.id)?;
2350 self.put_unlocked(task)
2351 }
2352
2353 pub fn create_new(&self, task: &mut Task) -> Result<bool> {
2358 let _lock = self.lock_task(&task.id)?;
2359 if self.path_of(&task.id).exists() {
2360 return Ok(false);
2361 }
2362 self.put_unlocked(task)?;
2363 Ok(true)
2364 }
2365
2366 fn put_unlocked(&self, task: &mut Task) -> Result<()> {
2368 if let Ok(mut stored) = read_path(&self.path_of(&task.id)) {
2369 stored.import_chat_notice();
2370 for key in stored.chat_notices {
2371 if !task.chat_notices.contains(&key) {
2372 task.chat_notices.push(key);
2373 }
2374 }
2375 for run in stored.runs {
2376 if !task.runs.contains(&run) {
2377 task.runs.push(run);
2378 }
2379 }
2380 for p in stored.passes {
2381 if !task
2382 .passes
2383 .iter()
2384 .any(|q| q.run == p.run && q.pass_index == p.pass_index)
2385 {
2386 task.passes.push(p);
2387 }
2388 }
2389 if let Some(n) = stored.chat_notice
2392 && task.chat_notice.as_ref().is_none_or(|mine| mine.at < n.at)
2393 {
2394 task.chat_notice = Some(n);
2395 }
2396 }
2397 task.import_chat_notice();
2398 task.updated_at = Timestamp::now();
2399 std::fs::create_dir_all(&self.root)
2400 .with_context(|| format!("create {}", self.root.display()))?;
2401 let body = serde_json::to_string_pretty(task).context("serialize task")?;
2402 let path = self.path_of(&task.id);
2403 let tmp = path.with_extension("json.tmp");
2404 std::fs::write(&tmp, &body).with_context(|| format!("write {}", tmp.display()))?;
2405 std::fs::rename(&tmp, &path).with_context(|| format!("replace {}", path.display()))?;
2406 if let (Some(notice), Some(home)) = (
2411 crate::notices::task_held(task),
2412 self.root.parent().filter(|p| !p.as_os_str().is_empty()),
2413 ) {
2414 crate::notices::raise_in(home, notice);
2415 }
2416 Ok(())
2417 }
2418
2419 pub fn modify(&self, id: &str, f: impl FnOnce(&mut Task) -> bool) -> Result<bool> {
2424 let id = self.resolve_id(id)?;
2425 let _lock = self.lock_task(&id)?;
2426 let mut task = self.get(&id)?;
2427 if !f(&mut task) {
2428 return Ok(false);
2429 }
2430 self.put_unlocked(&mut task)?;
2431 Ok(true)
2432 }
2433
2434 pub fn link_run(&self, id: &str, run: &str) -> Result<Task> {
2439 let id = self.resolve_id(id)?;
2440 let _lock = self.lock_task(&id)?;
2443 let mut task = self.get(&id)?;
2444 if task.link_run(run) {
2445 self.put_unlocked(&mut task)?;
2446 }
2447 Ok(task)
2448 }
2449
2450 pub(crate) fn lock_task(&self, id: &str) -> Result<TaskLock> {
2458 std::fs::create_dir_all(&self.root)
2459 .with_context(|| format!("create {}", self.root.display()))?;
2460 let path = self.root.join(format!("{id}.write-lock"));
2461 let started = std::time::Instant::now();
2462 let retry = crate::lock_file::Retry::task_write(started + TASK_LOCK_STALE);
2463 let marker_retry = crate::lock_file::Retry::new(Some(started + TASK_LOCK_STALE));
2464 loop {
2465 match retry.create(&path) {
2466 Ok(mut file) => {
2467 use std::io::Write;
2468 let token = owner_token();
2469 if let Err(e) = file.write_all(token.as_bytes()) {
2470 drop(file);
2471 let _ = std::fs::remove_file(&path);
2472 return Err(e).with_context(|| format!("lock {}", path.display()));
2473 }
2474 return Ok(TaskLock { path, token });
2475 }
2476 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
2477 let judged = std::fs::read_to_string(&path).ok();
2480 let broken = judged
2481 .filter(|_| older_than_stale(&path))
2482 .is_some_and(|judged| break_stale_with(&path, &judged, marker_retry));
2483 if !broken {
2487 if started.elapsed() > TASK_LOCK_STALE {
2488 bail!("could not lock task {id}");
2489 }
2490 std::thread::sleep(std::time::Duration::from_millis(15));
2491 }
2492 }
2493 Err(e) => return Err(e).with_context(|| format!("lock {}", path.display())),
2494 }
2495 }
2496 }
2497
2498 pub fn get(&self, id: &str) -> Result<Task> {
2500 let resolved = self.resolve_id(id)?;
2501 read_path(&self.path_of(&resolved))
2502 }
2503
2504 pub fn remove(&self, id: &str, in_flight: bool, questions: &Questions) -> Result<Removal> {
2536 let resolved = self.resolve_id(id)?;
2537 if in_flight {
2538 bail!("task {resolved} is being run by a live daemon right now");
2539 }
2540 self.write_tombstone(&resolved)?;
2541 if let Err(e) = self.remove_record_with_attachments(&resolved, |p| std::fs::remove_file(p))
2542 {
2543 if self.path_of(&resolved).exists() {
2547 let _ = std::fs::remove_file(self.tombstone_path(&resolved));
2548 }
2549 return Err(e);
2550 }
2551 let (released, still_blocked) = self.release_dependents_of(&resolved, questions);
2552 Ok(Removal {
2553 id: resolved,
2554 released,
2555 still_blocked,
2556 })
2557 }
2558
2559 fn tombstone_path(&self, id: &str) -> PathBuf {
2562 self.root.join(format!("{id}.removed"))
2563 }
2564
2565 fn write_tombstone(&self, id: &str) -> Result<()> {
2566 let path = self.tombstone_path(id);
2567 let tmp = self.root.join(format!("{id}.removed.tmp"));
2568 std::fs::write(&tmp, b"")
2569 .and_then(|()| std::fs::rename(&tmp, &path))
2570 .with_context(|| format!("write {}", path.display()))
2571 }
2572
2573 fn was_deleted(&self, id: &str) -> bool {
2577 self.tombstone_path(id).is_file() && !self.path_of(id).exists()
2578 }
2579
2580 pub fn apply_deleted_blockers(&self, task: &mut Task, questions: &Questions) -> Vec<String> {
2584 let deleted = deleted_blockers(self, questions, &task.blocked_by);
2585 deleted
2586 .into_iter()
2587 .filter(|id| task.dependency_deleted(id))
2588 .collect()
2589 }
2590
2591 pub fn note_dependency_deleted(&self, dependent: &Task, deleted: &str) {
2595 let Some(home) = self.root.parent().filter(|p| !p.as_os_str().is_empty()) else {
2596 return;
2597 };
2598 let ja = crate::lang::is_japanese(&crate::lang::of_repo(&dependent.repo));
2599 let waiting = dependent.status == TaskStatus::Blocked;
2600 let message = match (ja, waiting) {
2601 (true, false) => format!(
2602 "タスク {} は、待っていた {} が削除されたため待機を解除し、元の状態に戻しました",
2603 dependent.short(),
2604 short(deleted)
2605 ),
2606 (true, true) => format!(
2607 "タスク {} は、待っていた {} が削除されたため、残りの依存を待っています",
2608 dependent.short(),
2609 short(deleted)
2610 ),
2611 (false, false) => format!(
2612 "Task {} stopped waiting on {} because it was deleted, and returned to its previous state",
2613 dependent.short(),
2614 short(deleted)
2615 ),
2616 (false, true) => format!(
2617 "Task {} stopped waiting on {} because it was deleted, and is still waiting on its other dependencies",
2618 dependent.short(),
2619 short(deleted)
2620 ),
2621 };
2622 crate::notices::raise_in(
2623 home,
2624 crate::notices::Notice::info(
2625 &format!("unblocked:{}:{}", dependent.id, deleted),
2626 message,
2627 )
2628 .link(crate::notices::Link::Task {
2629 id: dependent.id.clone(),
2630 }),
2631 );
2632 }
2633
2634 fn release_dependents_of(
2639 &self,
2640 dependency: &str,
2641 questions: &Questions,
2642 ) -> (Vec<String>, Vec<String>) {
2643 let (mut released, mut still_blocked) = (Vec::new(), Vec::new());
2644 for listed in self.list() {
2645 if listed.status != TaskStatus::Blocked || !listed.blocked_by.iter().any(|b| {
2646 matches!(crate::queue::dependency(self, questions, b), Dependency::Deleted(id) if id == dependency)
2647 }) {
2648 continue;
2649 }
2650 let Ok(_claim) = self.claim(&listed.id) else {
2651 continue;
2652 };
2653 let Ok(mut task) = self.get(&listed.id) else {
2654 continue;
2655 };
2656 let references: Vec<String> = task.blocked_by.iter().filter(|id| {
2657 matches!(crate::queue::dependency(self, questions, id), Dependency::Deleted(full) if full == dependency)
2658 }).cloned().collect();
2659 if references.is_empty() {
2660 continue;
2661 }
2662 for reference in references {
2663 task.dependency_deleted(&reference);
2664 }
2665 if self.put(&mut task).is_err() {
2666 continue;
2667 }
2668 self.note_dependency_deleted(&task, dependency);
2669 if task.status == TaskStatus::Blocked {
2670 still_blocked.push(task.id.clone());
2671 } else {
2672 released.push(task.id.clone());
2673 }
2674 }
2675 (released, still_blocked)
2676 }
2677
2678 fn remove_record_with_attachments(
2682 &self,
2683 resolved: &str,
2684 remove_record: impl FnOnce(&Path) -> std::io::Result<()>,
2685 ) -> Result<()> {
2686 self.sweep_removed_attachments();
2693 let attachments = self.attachments_dir(resolved);
2694 let aside = self.root.join(format!("{resolved}.attachments.removing"));
2695 let moved = match std::fs::rename(&attachments, &aside) {
2696 Ok(()) => true,
2697 Err(e) if e.kind() == std::io::ErrorKind::NotFound => false,
2698 Err(e) => {
2699 return Err(e).with_context(|| format!("remove {}", attachments.display()));
2700 }
2701 };
2702 let path = self.path_of(resolved);
2703 if let Err(e) = remove_record(&path) {
2704 if moved {
2705 let _ = std::fs::rename(&aside, &attachments);
2706 }
2707 return Err(e).with_context(|| format!("remove {}", path.display()));
2708 }
2709 if moved {
2710 if let Err(e) = std::fs::remove_dir_all(&aside) {
2711 tracing::warn!("leftover attachments {}: {e}", aside.display());
2712 }
2713 }
2714 let lock = self.lock_path(resolved);
2715 if let Err(e) = std::fs::remove_file(&lock) {
2716 if e.kind() != std::io::ErrorKind::NotFound {
2717 return Err(e).with_context(|| format!("remove {}", lock.display()));
2718 }
2719 }
2720 Ok(())
2721 }
2722
2723 fn sweep_removed_attachments(&self) {
2729 let Ok(entries) = std::fs::read_dir(&self.root) else {
2730 return;
2731 };
2732 for entry in entries.flatten() {
2733 let name = entry.file_name();
2734 let name = name.to_string_lossy();
2735 let Some(id) = name.strip_suffix(".attachments.removing") else {
2736 continue;
2737 };
2738 if !self.path_of(id).exists() {
2741 if let Err(e) = std::fs::remove_dir_all(entry.path()) {
2742 tracing::warn!("leftover attachments {}: {e}", entry.path().display());
2743 }
2744 }
2745 }
2746 self.sweep_tombstones();
2747 }
2748
2749 fn sweep_tombstones(&self) {
2758 let Ok(entries) = std::fs::read_dir(&self.root) else {
2759 return;
2760 };
2761 let tasks = self.list();
2762 for entry in entries.flatten() {
2763 let name = entry.file_name();
2764 let name = name.to_string_lossy();
2765 let Some(id) = name.strip_suffix(".removed") else {
2766 continue;
2767 };
2768 if self.path_of(id).exists()
2769 || tasks.iter().any(|t| {
2770 t.blocked_by
2771 .iter()
2772 .any(|b| id.starts_with(b) || id.ends_with(b))
2773 })
2774 {
2775 continue;
2776 }
2777 let old = entry
2778 .metadata()
2779 .and_then(|m| m.modified())
2780 .ok()
2781 .and_then(|m| m.elapsed().ok())
2782 .is_some_and(|age| age >= TOMBSTONE_GRACE);
2783 if old {
2784 let _ = std::fs::remove_file(entry.path());
2785 }
2786 }
2787 }
2788
2789 fn lock_path(&self, id: &str) -> PathBuf {
2792 self.root.join(format!("{id}.lock"))
2793 }
2794
2795 pub fn list(&self) -> Vec<Task> {
2808 let mut tasks: Vec<Task> = std::fs::read_dir(&self.root)
2809 .into_iter()
2810 .flatten()
2811 .flatten()
2812 .map(|e| e.path())
2813 .filter(|p| p.extension().is_some_and(|x| x == "json"))
2814 .filter_map(|p| read_path(&p).ok())
2815 .collect();
2816 tasks.sort_unstable_by(|a, b| b.priority.cmp(&a.priority).then_with(|| b.id.cmp(&a.id)));
2817 tasks
2818 }
2819
2820 pub fn unreadable(&self) -> Vec<PathBuf> {
2824 std::fs::read_dir(&self.root)
2825 .into_iter()
2826 .flatten()
2827 .flatten()
2828 .map(|e| e.path())
2829 .filter(|p| p.extension().is_some_and(|x| x == "json"))
2830 .filter(|p| read_path(p).is_err())
2831 .collect()
2832 }
2833
2834 pub fn superseded(&self) -> HashMap<String, String> {
2847 let mut by = HashMap::new();
2848 for task in self.list() {
2849 for earlier in &task.runs {
2850 if let Some(later) = task.successor_of(earlier) {
2851 by.insert(earlier.clone(), later.clone());
2852 }
2853 }
2854 }
2855 by
2856 }
2857
2858 pub fn superseded_by(&self, run: &str) -> Option<String> {
2866 for task in self.list() {
2867 if task.runs.iter().any(|r| r == run) {
2868 return task.successor_of(run).cloned();
2869 }
2870 }
2871 None
2872 }
2873
2874 pub fn latest_attempt(&self, run: &str) -> Option<String> {
2885 for task in self.list() {
2886 if task.runs.iter().any(|r| r == run) {
2887 return task.runs.last().filter(|last| **last != run).cloned();
2888 }
2889 }
2890 None
2891 }
2892
2893 pub fn next_runnable(&self) -> Option<Task> {
2898 let mut runnable: Vec<Task> = self
2899 .list()
2900 .into_iter()
2901 .filter(|t| t.status.runnable())
2902 .collect();
2903 runnable.sort_unstable_by(dispatch_order);
2904 runnable.into_iter().next()
2905 }
2906
2907 pub fn claim(&self, id: &str) -> Result<Claim> {
2914 std::fs::create_dir_all(&self.root)
2915 .with_context(|| format!("create {}", self.root.display()))?;
2916 let path = self.lock_path(id);
2917 let retry = crate::lock_file::Retry::new(None);
2918 match retry.create(&path) {
2919 Ok(mut f) => {
2920 use std::io::Write as _;
2921 let _ = writeln!(f, "{}", std::process::id());
2923 Ok(Claim { path })
2924 }
2925 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
2926 bail!("task {id} is already claimed ({} exists)", path.display())
2927 }
2928 Err(e) => Err(e).with_context(|| format!("lock {}", path.display())),
2929 }
2930 }
2931
2932 pub fn resolve_id(&self, prefix: &str) -> Result<String> {
2934 if !prefix.is_empty()
2935 && prefix != "."
2936 && prefix != ".."
2937 && !prefix.contains(['/', '\\'])
2938 && self.path_of(prefix).is_file()
2939 {
2940 return Ok(prefix.to_owned());
2941 }
2942 let ids = record_ids(&self.root, "json")?;
2943 match resolve_reference(prefix, ids.iter().map(String::as_str)) {
2944 Reference::Found(id) => Ok(id),
2945 Reference::Missing => bail!("no task matches `{prefix}`"),
2946 Reference::Ambiguous => bail!("`{prefix}` matches multiple tasks"),
2947 }
2948 }
2949
2950 pub fn revision(&self) -> u64 {
2957 self.revision_excluding(&std::collections::BTreeSet::new())
2958 }
2959
2960 pub fn revision_excluding(&self, skip: &std::collections::BTreeSet<String>) -> u64 {
2963 use std::hash::{Hash as _, Hasher as _};
2964
2965 let mut entries: Vec<(String, u64)> = std::fs::read_dir(&self.root)
2966 .into_iter()
2967 .flatten()
2968 .flatten()
2969 .filter(|e| e.path().extension().is_some_and(|ext| ext == "json"))
2970 .filter(|e| {
2971 let path = e.path();
2972 !path
2973 .file_stem()
2974 .is_some_and(|stem| skip.contains(stem.to_string_lossy().as_ref()))
2975 })
2976 .filter_map(|e| {
2977 let name = e.file_name().to_string_lossy().into_owned();
2978 let mtime = e
2979 .metadata()
2980 .ok()?
2981 .modified()
2982 .ok()?
2983 .duration_since(std::time::UNIX_EPOCH)
2984 .ok()?
2985 .as_millis() as u64;
2986 Some((name, mtime))
2987 })
2988 .collect();
2989
2990 if entries.is_empty() {
2991 return 0;
2992 }
2993
2994 entries.sort_unstable();
2995 let mut hasher = std::hash::DefaultHasher::new();
2996 for (name, mtime) in &entries {
2997 name.hash(&mut hasher);
2998 mtime.hash(&mut hasher);
2999 }
3000 let h = hasher.finish();
3001 if h == 0 { 1 } else { h }
3002 }
3003}
3004
3005const TOMBSTONE_GRACE: std::time::Duration = std::time::Duration::from_secs(3600);
3008
3009#[derive(Debug, Clone)]
3011pub struct Removal {
3012 pub id: String,
3014 pub released: Vec<String>,
3018 pub still_blocked: Vec<String>,
3021}
3022
3023#[derive(Debug)]
3025pub struct Claim {
3026 path: PathBuf,
3027}
3028
3029impl Drop for Claim {
3030 fn drop(&mut self) {
3031 let _ = std::fs::remove_file(&self.path);
3032 }
3033}
3034
3035pub fn title_from(instruction: &str, max: usize) -> String {
3038 let Some(line) = first_line(instruction) else {
3039 return "(empty task)".to_owned();
3040 };
3041 if line.chars().count() <= max {
3042 return line.to_owned();
3043 }
3044 let head: String = line.chars().take(max.saturating_sub(1)).collect();
3045 format!("{head}…")
3046}
3047
3048pub(crate) fn first_line(instruction: &str) -> Option<&str> {
3054 let line = instruction
3055 .lines()
3056 .map(str::trim)
3057 .find(|l| !l.is_empty())?
3058 .trim_start_matches(['#', '-', '*', '>', ' '])
3059 .trim();
3060 (!line.is_empty()).then_some(line)
3061}
3062
3063fn read_path(path: &Path) -> Result<Task> {
3064 let body = std::fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
3065 let task: Task =
3066 serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))?;
3067 if task.schema > SCHEMA {
3073 bail!(
3074 "task {} was written by a different magi (schema {}, this build \
3075 speaks {SCHEMA})",
3076 task.id,
3077 task.schema
3078 );
3079 }
3080 Ok(task)
3081}
3082
3083#[derive(Debug, PartialEq, Eq)]
3086pub(crate) enum Reference {
3087 Found(String),
3088 Missing,
3089 Ambiguous,
3090}
3091
3092pub(crate) fn resolve_reference<'a>(id: &str, ids: impl Iterator<Item = &'a str>) -> Reference {
3093 let mut hits = Vec::new();
3094 for candidate in ids {
3095 if candidate == id {
3096 return Reference::Found(candidate.to_owned());
3097 }
3098 if candidate.starts_with(id) || candidate.ends_with(id) {
3099 hits.push(candidate);
3100 }
3101 }
3102 match hits.as_slice() {
3103 [] => Reference::Missing,
3104 [one] => Reference::Found((*one).to_owned()),
3105 _ => Reference::Ambiguous,
3106 }
3107}
3108
3109pub(crate) fn record_ids(root: &Path, extension: &str) -> Result<Vec<String>> {
3112 let entries = match std::fs::read_dir(root) {
3113 Ok(entries) => entries,
3114 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
3115 Err(e) => return Err(e.into()),
3116 };
3117 let mut ids = Vec::new();
3118 for entry in entries {
3119 let path = entry?.path();
3120 if path.extension().is_some_and(|e| e == extension)
3121 && let Some(id) = path.file_stem().and_then(|s| s.to_str())
3122 {
3123 ids.push(id.to_owned());
3124 }
3125 }
3126 Ok(ids)
3127}
3128
3129#[derive(Debug, PartialEq, Eq)]
3132pub enum Dependency {
3133 Task(String),
3135 Question(String),
3137 Deleted(String),
3139 Missing,
3141 Ambiguous,
3143 Unknown,
3145}
3146
3147pub fn dependency(queue: &Queue, questions: &Questions, id: &str) -> Dependency {
3149 let (Ok(tasks), Ok(qs), Ok(removed)) = (
3150 record_ids(queue.root(), "json"),
3151 record_ids(questions.root(), "json"),
3152 record_ids(queue.root(), "removed"),
3153 ) else {
3154 return Dependency::Unknown;
3155 };
3156 let mut ids = tasks
3159 .iter()
3160 .chain(qs.iter())
3161 .chain(removed.iter())
3162 .cloned()
3163 .collect::<Vec<_>>();
3164 ids.sort();
3165 ids.dedup();
3166 match resolve_reference(id, ids.iter().map(String::as_str)) {
3167 Reference::Found(id) if tasks.contains(&id) => Dependency::Task(id),
3168 Reference::Found(id) if qs.contains(&id) => Dependency::Question(id),
3169 Reference::Found(id) if queue.was_deleted(&id) => Dependency::Deleted(id),
3170 Reference::Found(_) => Dependency::Unknown,
3171 Reference::Missing => Dependency::Missing,
3172 Reference::Ambiguous => Dependency::Ambiguous,
3173 }
3174}
3175
3176pub fn normalize_blockers(queue: &Queue, questions: &Questions, ids: &mut [String]) {
3178 for id in ids {
3179 if let Dependency::Task(full) | Dependency::Question(full) | Dependency::Deleted(full) =
3180 dependency(queue, questions, id)
3181 {
3182 *id = full;
3183 }
3184 }
3185}
3186
3187pub fn dependency_facts(queue: &Queue, questions: &Questions) -> String {
3190 let inventory = crate::blockers::Inventory::load(queue, questions);
3191 let mut text = String::from(
3192 "Current authoritative queue (historical missing-task claims may be stale; unreadable or ambiguous references must keep waiting):\n",
3193 );
3194 match record_ids(queue.root(), "json") {
3195 Ok(ids) => {
3196 for id in ids {
3197 match queue.get(&id) {
3198 Ok(task) => text.push_str(&format!(
3199 "{}: {}; blocked_by={:?}; chain={:?}\n",
3200 task.id,
3201 task.status.as_str(),
3202 task.blocked_by,
3203 inventory.waits_on(&task)
3204 )),
3205 Err(_) => text.push_str(&format!("{id}: exists, state unreadable\n")),
3206 }
3207 }
3208 }
3209 Err(_) => text.push_str("Queue inventory unreadable: existence is unknown.\n"),
3210 }
3211 text
3212}
3213
3214pub fn missing_blockers(
3216 queue: &Queue,
3217 questions: &Questions,
3218 blocked_by: &[String],
3219) -> Vec<String> {
3220 blocked_by
3221 .iter()
3222 .filter(|id| dependency(queue, questions, id) == Dependency::Missing)
3223 .cloned()
3224 .collect()
3225}
3226
3227pub fn deleted_blockers(
3230 queue: &Queue,
3231 questions: &Questions,
3232 blocked_by: &[String],
3233) -> Vec<String> {
3234 blocked_by
3235 .iter()
3236 .filter(|id| matches!(dependency(queue, questions, id), Dependency::Deleted(_)))
3237 .cloned()
3238 .collect()
3239}
3240
3241pub fn missing_blocker_hold_reason(blocked_by: &[String], missing: &[String]) -> String {
3253 missing_blocker_hold_reason_in(blocked_by, missing, "en")
3254}
3255
3256pub fn missing_blocker_hold_reason_in(
3258 blocked_by: &[String],
3259 missing: &[String],
3260 language: &str,
3261) -> String {
3262 if crate::lang::is_japanese(language) {
3263 format!(
3264 "{} を待っていましたが、{} はディスク上に存在しません - `magi task triage` を参照",
3265 blocked_by.join(", "),
3266 missing.join(", "),
3267 )
3268 } else {
3269 format!(
3270 "blocked on {} but {} no longer exist(s) on disk - see `magi task triage`",
3271 blocked_by.join(", "),
3272 missing.join(", "),
3273 )
3274 }
3275}
3276
3277pub fn short(id: &str) -> &str {
3279 id.split('-').next_back().unwrap_or(id)
3280}
3281
3282fn new_id() -> String {
3283 let stamp = jiff::Zoned::now().strftime("%Y%m%d-%H%M%S");
3284 let seed = crate::rng::entropy();
3285 format!("{stamp}-{:04x}", (seed ^ (seed >> 32)) & 0xffff)
3286}
3287
3288#[cfg(test)]
3289mod tests {
3290 #[test]
3291 fn explicit_resumption_is_not_invented_for_legacy_or_automatic_release() {
3292 let t = Task::new(
3293 "retry".into(),
3294 "do it".into(),
3295 PathBuf::from("."),
3296 Source::Human,
3297 );
3298 let mut value = serde_json::to_value(&t).unwrap();
3299 for field in ["resumption", "resumption_history", "operator_decided_at"] {
3300 value.as_object_mut().unwrap().remove(field);
3301 }
3302 value["schema"] = serde_json::json!(15);
3303 let mut legacy: Task = serde_json::from_value(value).unwrap();
3304 legacy.hold_machine(Some("automatic retry".into()));
3305 legacy.release();
3306 assert!(legacy.resumption.is_none());
3307 assert!(legacy.resumption_history.is_empty());
3308 assert!(legacy.operator_decided_at.is_none());
3309 legacy.release_operator("cli", None);
3310 legacy.start("run".into());
3311 let reloaded: Task =
3312 serde_json::from_str(&serde_json::to_string(&legacy).unwrap()).unwrap();
3313 assert!(reloaded.resume_override.is_none());
3314 assert_eq!(reloaded.resumption.as_ref().unwrap().generation, 1);
3315 }
3316
3317 #[test]
3318 fn merge_over_lets_the_later_choice_win_and_keeps_the_rest() {
3319 let mut base = RunOverrides {
3320 merge: Some("pr".to_owned()),
3321 candidates: Some(3),
3322 ..RunOverrides::default()
3323 };
3324 base.merge_over(&RunOverrides {
3325 merge: Some("none".to_owned()),
3326 seed: Some(7),
3327 ..RunOverrides::default()
3328 });
3329 assert_eq!(base.merge.as_deref(), Some("none"));
3330 assert_eq!(base.candidates, Some(3));
3331 assert_eq!(base.seed, Some(7));
3332 }
3333
3334 #[test]
3335 fn an_already_landed_task_is_done_with_its_attempt_refunded() {
3336 let mut t = task("relanded");
3337 t.attempts = 1;
3338 t.status = TaskStatus::Running;
3339 t.already_landed("already in main as 0e368de");
3340 assert_eq!(t.status, TaskStatus::Done);
3341 assert_eq!(t.attempts, 0);
3342 assert!(t.hold_reason.is_none());
3343 assert_eq!(t.last_error.as_deref(), Some("already in main as 0e368de"));
3344 }
3345
3346 #[test]
3347 fn missing_blocker_reason_follows_the_language() {
3348 let b = vec!["a".to_owned()];
3349 let en = missing_blocker_hold_reason_in(&b, &b, "en");
3350 assert_eq!(en, missing_blocker_hold_reason(&b, &b));
3351 assert!(en.starts_with("blocked on a"));
3352 assert!(missing_blocker_hold_reason_in(&b, &b, "ja").contains("存在しません"));
3353 assert_eq!(missing_blocker_hold_reason_in(&b, &b, "de"), en);
3354 }
3355
3356 use super::*;
3357
3358 #[test]
3359 fn triage_applied_survives_release_and_old_records_read_as_empty() {
3360 let mut t = Task::new(
3361 "t".to_owned(),
3362 "i".to_owned(),
3363 PathBuf::from("r"),
3364 Source::Human,
3365 );
3366 t.mark_triage_applied("q1");
3367 t.mark_triage_applied("q1");
3368 t.hold_machine(Some("x".to_owned()));
3369 t.release();
3370 assert_eq!(t.triage_applied, ["q1"]);
3371 assert!(t.triage_applied("q1") && !t.triage_applied("q2"));
3372
3373 let mut v = serde_json::to_value(&t).unwrap();
3374 v.as_object_mut().unwrap().remove("triage_applied");
3375 let old: Task = serde_json::from_value(v).unwrap();
3376 assert!(old.triage_applied.is_empty());
3377 }
3378
3379 #[test]
3380 fn task_counts_of_empty_is_all_zero() {
3381 assert_eq!(TaskCounts::of(&[]), TaskCounts::default());
3382 }
3383
3384 #[test]
3385 fn task_counts_of_tallies_every_status() {
3386 let mut queued = Task::new(
3387 "q".to_owned(),
3388 "i".to_owned(),
3389 PathBuf::from("."),
3390 Source::Human,
3391 );
3392 queued.status = TaskStatus::Queued;
3393 let mut running = queued.clone();
3394 running.status = TaskStatus::Running;
3395 let mut done = queued.clone();
3396 done.status = TaskStatus::Done;
3397 let mut failed = queued.clone();
3398 failed.status = TaskStatus::Failed;
3399 let mut held = queued.clone();
3400 held.status = TaskStatus::Held;
3401 let mut blocked = queued.clone();
3402 blocked.status = TaskStatus::Blocked;
3403
3404 let mut parked = queued.clone();
3405 parked.status = TaskStatus::Parked;
3406
3407 let counts = TaskCounts::of(&[
3408 queued,
3409 running,
3410 done.clone(),
3411 done,
3412 failed,
3413 held,
3414 blocked,
3415 parked,
3416 ]);
3417 assert_eq!(
3418 counts,
3419 TaskCounts {
3420 queued: 1,
3421 running: 1,
3422 done: 2,
3423 failed: 1,
3424 held: 1,
3425 blocked: 1,
3426 parked: 1,
3427 }
3428 );
3429 }
3430
3431 #[test]
3432 fn task_implementers_persist_normalized_without_losing_history() {
3433 let (_dir, q) = queue();
3434 let mut t = task("keep me");
3435 t.hold_manual(Some("waiting".into()));
3436 t.runs.push("earlier-run".into());
3437 t.attachments.push("reference.txt".into());
3438 let cfg: crate::config::Config =
3439 toml::from_str("[[agents]]\nid = 'mock'\nkind = 'command'\ncommand = ['true']\n")
3440 .unwrap();
3441 q.put(&mut t).unwrap();
3442 let original = serde_json::to_value(&t).unwrap();
3443 for n in [2, 1, 2] {
3444 t.set_implementers(n, &cfg).unwrap();
3445 q.put(&mut t).unwrap();
3446 t = q.get(&t.id).unwrap();
3447 assert_eq!(t.implementers(), Some(usize::from(n)));
3448 assert_eq!(t.solo, n == 1);
3449 if n == 1 {
3450 assert!(t.overrides.is_none());
3451 }
3452 let mut actual = serde_json::to_value(&t).unwrap();
3453 for field in ["solo", "overrides", "updated_at"] {
3454 actual[field] = original[field].clone();
3455 }
3456 assert_eq!(actual, original);
3457 }
3458 t.overrides.as_mut().unwrap().judges = Some(2);
3459 t.set_implementers(1, &cfg).unwrap();
3460 assert_eq!(t.overrides.as_ref().unwrap().judges, Some(2));
3461 assert_eq!(t.overrides.as_ref().unwrap().candidates, None);
3462 }
3463
3464 #[test]
3465 fn task_implementers_old_records_keep_inheritance_until_changed() {
3466 let mut value = serde_json::to_value(task("old task")).unwrap();
3467 value.as_object_mut().unwrap().remove("solo");
3468 value.as_object_mut().unwrap().remove("overrides");
3469 let mut old: Task = serde_json::from_value(value).unwrap();
3470 assert_eq!(old.implementers(), None);
3471 old.set_implementers(1, &crate::config::Config::default())
3472 .unwrap();
3473 assert!(old.solo);
3474 assert!(old.overrides.is_none());
3475 }
3476
3477 #[test]
3478 fn task_implementers_reject_invalid_counts_and_states() {
3479 let cfg = crate::config::Config::default();
3480 let mut t = task("unchanged");
3481 for n in [0, 4, 255] {
3482 assert!(t.set_implementers(n, &cfg).is_err());
3483 }
3484 for status in [
3485 TaskStatus::Running,
3486 TaskStatus::Done,
3487 TaskStatus::Failed,
3488 TaskStatus::Blocked,
3489 TaskStatus::Parked,
3490 ] {
3491 t.status = status;
3492 let before = serde_json::to_value(&t).unwrap();
3493 assert!(t.set_implementers(1, &cfg).is_err());
3494 assert_eq!(serde_json::to_value(&t).unwrap(), before);
3495 }
3496 }
3497
3498 fn queue() -> (tempfile::TempDir, Queue) {
3501 let dir = tempfile::tempdir().unwrap();
3502 let q = Queue::at(dir.path().join("queue"));
3503 (dir, q)
3504 }
3505
3506 #[test]
3507 fn putting_a_machine_held_task_files_a_notification_beside_the_queue() {
3508 let dir = tempfile::tempdir().unwrap();
3509 let q = Queue::at(dir.path().join("queue"));
3510 let mut t = task("held");
3511 q.put(&mut t).unwrap();
3512 assert_eq!(
3513 crate::notices::Notices::at(dir.path().join("notifications"))
3514 .list()
3515 .len(),
3516 0
3517 );
3518 t.hold_machine(Some("out of attempts".to_owned()));
3519 q.put(&mut t).unwrap();
3520 let listed = crate::notices::Notices::at(dir.path().join("notifications")).list();
3521 assert_eq!(listed.len(), 1);
3522 assert!(listed[0].message.contains("out of attempts"));
3523 }
3524
3525 fn task(title: &str) -> Task {
3526 Task::new(
3527 title.to_owned(),
3528 format!("do {title}"),
3529 PathBuf::from("."),
3530 Source::Human,
3531 )
3532 }
3533
3534 #[test]
3535 fn attempt_limit_inherits_old_records_and_controls_failure() {
3536 let mut t = task("budget");
3537 let mut old = serde_json::to_value(&t).unwrap();
3538 old.as_object_mut().unwrap().remove("attempt_limit");
3539 old["schema"] = serde_json::json!(15);
3540 let legacy: Task = serde_json::from_value(old).unwrap();
3541 assert_eq!(legacy.attempt_limit, None);
3542 assert_eq!(legacy.effective_attempt_limit(2), 2);
3543 t.attempts = 2;
3544 t.change_attempt_limit(NonZeroUsize::new(3)).unwrap();
3545 t.fail("retry", 1);
3546 assert_eq!(t.status, TaskStatus::Failed);
3547 t.attempts = 3;
3548 t.fail("stop", 10);
3549 assert_eq!(t.status, TaskStatus::Held);
3550 t.change_attempt_limit(None).unwrap();
3551 assert_eq!(t.status, TaskStatus::Held);
3552 assert_eq!(t.attempts, 3);
3553 t.fail("inherit", 4);
3554 assert_eq!(t.status, TaskStatus::Failed);
3555 for status in [
3556 TaskStatus::Running,
3557 TaskStatus::Done,
3558 TaskStatus::Failed,
3559 TaskStatus::Blocked,
3560 TaskStatus::Parked,
3561 ] {
3562 t.status = status;
3563 assert!(t.change_attempt_limit(NonZeroUsize::new(1)).is_err());
3564 assert_eq!(t.attempt_limit, None);
3565 }
3566 }
3567
3568 #[test]
3569 fn earlier_attempts_is_every_recorded_run_and_agrees_with_the_display() {
3570 let mut t = task("retried");
3571 assert!(
3572 t.earlier_attempts().is_empty(),
3573 "a first attempt takes nothing over"
3574 );
3575 t.runs = vec!["aaaa".to_owned(), "bbbb".to_owned()];
3576 assert_eq!(t.earlier_attempts(), ["aaaa", "bbbb"]);
3577 assert_eq!(t.successor_of("aaaa"), Some(&"bbbb".to_owned()));
3579 assert_eq!(t.successor_of("bbbb"), None);
3580 assert_eq!(t.successor_of("zzzz"), None);
3581 }
3582
3583 #[test]
3584 fn resumed_runs_are_superseded_only_after_their_last_occurrence() {
3585 let (_dir, q) = queue();
3586 let mut t = task("resumed");
3587 for (runs, successor) in [
3588 (vec!["aaaa", "aaaa"], None),
3589 (vec!["aaaa", "bbbb", "aaaa"], None),
3590 (vec!["aaaa", "aaaa", "bbbb"], Some("bbbb")),
3591 (vec!["aaaa", "bbbb", "aaaa", "cccc"], Some("cccc")),
3592 ] {
3593 t.runs = runs.into_iter().map(str::to_owned).collect();
3594 q.put(&mut t).unwrap();
3595
3596 assert_eq!(t.successor_of("aaaa").map(String::as_str), successor);
3597 assert_eq!(q.superseded_by("aaaa").as_deref(), successor);
3598 assert_eq!(q.superseded().get("aaaa").map(String::as_str), successor);
3599 }
3600 }
3601
3602 #[test]
3603 fn superseded_by_names_the_next_attempt_and_none_for_the_last() {
3604 let (_dir, q) = queue();
3605 let mut t = task("retried");
3606 t.runs = vec!["aaaa".to_owned(), "bbbb".to_owned(), "cccc".to_owned()];
3607 q.put(&mut t).unwrap();
3608
3609 assert_eq!(q.superseded_by("aaaa"), Some("bbbb".to_owned()));
3610 assert_eq!(q.superseded_by("bbbb"), Some("cccc".to_owned()));
3611 assert_eq!(
3612 q.superseded_by("cccc"),
3613 None,
3614 "the latest attempt replaces nothing"
3615 );
3616 assert_eq!(
3617 q.superseded_by("never-heard-of-it"),
3618 None,
3619 "a run belonging to no task on this queue is not superseded"
3620 );
3621
3622 let mut by = HashMap::new();
3623 by.insert("aaaa".to_owned(), "bbbb".to_owned());
3624 by.insert("bbbb".to_owned(), "cccc".to_owned());
3625 assert_eq!(
3626 q.superseded(),
3627 by,
3628 "the whole-map and single-run forms must agree"
3629 );
3630 }
3631
3632 #[test]
3633 fn superseded_attempts_is_empty_until_the_task_is_done() {
3634 let mut t = task("retried");
3635 t.runs = vec!["aaaa".to_owned(), "bbbb".to_owned()];
3636 t.status = TaskStatus::Failed;
3637 assert_eq!(
3638 t.superseded_attempts(true),
3639 &[] as &[String],
3640 "a task still retrying has no attempt yet that a later one made moot"
3641 );
3642
3643 t.status = TaskStatus::Running;
3644 assert_eq!(t.superseded_attempts(true), &[] as &[String]);
3645 }
3646
3647 #[test]
3648 fn superseded_attempts_names_every_run_before_the_one_that_succeeded() {
3649 let mut t = task("retried");
3650 t.runs = vec!["aaaa".to_owned(), "bbbb".to_owned(), "cccc".to_owned()];
3651 t.status = TaskStatus::Done;
3652 assert_eq!(
3653 t.superseded_attempts(true),
3654 &["aaaa".to_owned(), "bbbb".to_owned()],
3655 "cccc is the attempt whose success made the task done, and stays out"
3656 );
3657 }
3658
3659 #[test]
3660 fn superseded_attempts_is_empty_for_a_done_task_with_only_one_attempt() {
3661 let mut t = task("first try landed");
3662 t.runs = vec!["aaaa".to_owned()];
3663 t.status = TaskStatus::Done;
3664 assert_eq!(t.superseded_attempts(true), &[] as &[String]);
3665 }
3666
3667 #[test]
3668 fn superseded_attempts_is_empty_when_the_last_run_never_actually_succeeded() {
3669 let mut t = task("closed by hand after a manual merge");
3676 t.runs = vec!["aaaa".to_owned(), "bbbb".to_owned()];
3677 t.status = TaskStatus::Done;
3678 assert_eq!(
3679 t.superseded_attempts(false),
3680 &[] as &[String],
3681 "nothing here is provably why the task is done, so nothing is superseded"
3682 );
3683 }
3684
3685 #[test]
3686 fn latest_attempt_names_the_chain_s_current_head_not_just_the_next_one() {
3687 let (_dir, q) = queue();
3688 let mut t = task("retried twice");
3689 t.runs = vec!["aaaa".to_owned(), "bbbb".to_owned(), "cccc".to_owned()];
3690 q.put(&mut t).unwrap();
3691
3692 assert_eq!(
3693 q.latest_attempt("aaaa"),
3694 Some("cccc".to_owned()),
3695 "an old attempt points straight at the chain's current head, not the \
3696 next attempt in the middle of it"
3697 );
3698 assert_eq!(q.latest_attempt("bbbb"), Some("cccc".to_owned()));
3699 assert_eq!(
3700 q.latest_attempt("cccc"),
3701 None,
3702 "the latest attempt is not superseded by anything"
3703 );
3704 assert_eq!(
3705 q.latest_attempt("never-heard-of-it"),
3706 None,
3707 "a run belonging to no task on this queue is not superseded"
3708 );
3709 }
3710
3711 #[test]
3712 fn a_markdown_heading_is_the_title_not_decoration() {
3713 assert_eq!(
3718 title_from("# Rework the config loader\n\nIt re-reads it.\n", 40),
3719 "Rework the config loader"
3720 );
3721 assert_eq!(title_from("- fix the thing", 40), "fix the thing");
3722 assert_eq!(title_from("> quoted task", 40), "quoted task");
3723 assert_eq!(title_from(" \n\n", 40), "(empty task)");
3725 assert_eq!(title_from("###\n", 40), "(empty task)");
3726 }
3727
3728 #[test]
3729 fn a_long_title_is_elided_by_characters_not_bytes() {
3730 let long = "課題".repeat(30);
3732 let title = title_from(&long, 10);
3733 assert_eq!(title.chars().count(), 10);
3734 assert!(title.ends_with('…'));
3735 }
3736
3737 #[test]
3738 fn priority_wins_and_ties_break_oldest_first() {
3739 let (_dir, q) = queue();
3740 let mut a = task("first");
3741 let mut b = task("second");
3742 let mut c = task("urgent");
3743 a.id = "20260101-000001-aaaa".to_owned();
3745 b.id = "20260101-000002-bbbb".to_owned();
3746 c.id = "20260101-000003-cccc".to_owned();
3747 c.priority = 5;
3748 for t in [&mut a, &mut b, &mut c] {
3749 q.put(t).unwrap();
3750 }
3751
3752 assert_eq!(q.next_runnable().unwrap().id, c.id);
3754 c.hold_machine(None);
3755 q.put(&mut c).unwrap();
3756 assert_eq!(q.next_runnable().unwrap().id, a.id);
3758 assert_eq!(q.list().len(), 3, "b is still waiting its turn");
3759 }
3760
3761 #[test]
3762 fn a_blocked_task_never_starves_another_runnable_one() {
3763 let (_dir, q) = queue();
3764 let mut blocked = task("blocked");
3765 blocked.block(vec!["something".to_owned()], None);
3766 q.put(&mut blocked).unwrap();
3767
3768 let mut runnable = task("free to go");
3769 q.put(&mut runnable).unwrap();
3770
3771 let next = q.next_runnable().expect("a runnable task is still offered");
3772 assert_eq!(next.id, runnable.id);
3773 }
3774
3775 #[test]
3776 fn a_held_task_is_never_offered_to_the_loop() {
3777 let (_dir, q) = queue();
3778 let mut t = task("held");
3779 q.put(&mut t).unwrap();
3780 assert!(q.next_runnable().is_some());
3781
3782 t.hold_machine(None);
3783 q.put(&mut t).unwrap();
3784 assert!(
3785 q.next_runnable().is_none(),
3786 "a held task must wait for a human"
3787 );
3788
3789 t.status = TaskStatus::Failed;
3791 q.put(&mut t).unwrap();
3792 assert!(q.next_runnable().is_some());
3793 }
3794
3795 #[test]
3796 fn attempts_are_capped_and_then_the_task_is_held() {
3797 let mut t = task("doomed");
3798
3799 t.start("run-1".to_owned());
3800 t.fail("gate red", 2);
3801 assert_eq!(t.status, TaskStatus::Failed, "one attempt of two: retry");
3802
3803 t.start("run-2".to_owned());
3804 t.fail("gate red", 2);
3805 assert_eq!(
3806 t.status,
3807 TaskStatus::Held,
3808 "out of attempts: stop spending money on it"
3809 );
3810 assert_eq!(t.runs, ["run-1", "run-2"]);
3811 assert_eq!(t.last_error.as_deref(), Some("gate red"));
3812 assert_eq!(
3813 t.hold_reason.as_deref(),
3814 Some("gate red"),
3815 "the hold must say why, not leave hold_reason null next to a \
3816 populated last_error"
3817 );
3818 }
3819
3820 #[test]
3821 fn handing_off_a_task_records_a_hold_reason_too() {
3822 let mut t = task("left a pull request");
3823 t.start("run-1".to_owned());
3824 t.handed_off("run ended with a pull request open [run run-1]");
3825 assert_eq!(t.status, TaskStatus::Held);
3826 assert_eq!(t.hold_source, Some(HoldSource::Machine));
3827 assert_eq!(
3828 t.hold_reason.as_deref(),
3829 Some("run ended with a pull request open [run run-1]")
3830 );
3831 assert_eq!(t.hold_reason, t.last_error);
3832 }
3833
3834 #[test]
3835 fn a_quota_stall_is_refunded_so_the_backlog_survives_the_night() {
3836 let mut t = task("stalled by quota");
3837
3838 t.start("run-1".to_owned());
3839 assert_eq!(t.attempts, 1);
3840 t.stall("judge-1, judge-2 out of quota");
3841 assert_eq!(
3842 t.attempts, 0,
3843 "a closed quota window must not spend the task's retry budget"
3844 );
3845 assert_eq!(t.status, TaskStatus::Failed, "the loop should retry it");
3846 assert_eq!(
3847 t.last_error.as_deref(),
3848 Some("judge-1, judge-2 out of quota")
3849 );
3850
3851 for _ in 0..20 {
3854 t.start("run-n".to_owned());
3855 t.stall("still out of quota");
3856 }
3857 t.start("run-real".to_owned());
3858 t.fail("gate red", 2);
3859 assert_eq!(
3860 t.status,
3861 TaskStatus::Failed,
3862 "the first attempt that was really judged is attempt one"
3863 );
3864 }
3865
3866 #[test]
3867 fn releasing_a_held_task_gives_it_a_real_second_chance() {
3868 let mut t = task("retry me");
3869 t.start("run-1".to_owned());
3870 t.fail("gate red", 1);
3871 assert_eq!(t.status, TaskStatus::Held);
3872
3873 t.release();
3874 assert_eq!(t.status, TaskStatus::Queued);
3875 assert_eq!(t.attempts, 0);
3878 assert!(t.last_error.is_none());
3879 assert_eq!(
3880 t.runs.len(),
3881 1,
3882 "history is kept: attempts reset, evidence does not"
3883 );
3884 }
3885
3886 #[test]
3887 fn a_hold_reason_survives_and_a_release_clears_it() {
3888 let mut t = task("waiting on something else");
3889 t.hold_manual(Some(
3890 "waiting for 20260101-000000-aaaa to land first".to_owned(),
3891 ));
3892 assert_eq!(t.status, TaskStatus::Held);
3893 assert_eq!(
3894 t.hold_reason.as_deref(),
3895 Some("waiting for 20260101-000000-aaaa to land first")
3896 );
3897
3898 t.hold_manual(None);
3900 assert_eq!(
3901 t.hold_reason.as_deref(),
3902 Some("waiting for 20260101-000000-aaaa to land first"),
3903 "a bare re-hold keeps whatever a human already wrote down"
3904 );
3905
3906 let mut plain = task("no reason given");
3908 plain.hold_manual(None);
3909 assert_eq!(plain.status, TaskStatus::Held);
3910 assert!(plain.hold_reason.is_none());
3911
3912 t.release();
3913 assert_eq!(t.status, TaskStatus::Queued);
3914 assert!(
3915 t.hold_reason.is_none(),
3916 "a stale reason must not greet the next person who holds this task"
3917 );
3918 }
3919
3920 #[test]
3921 fn closing_a_held_task_as_done_clears_its_hold_reason_too() {
3922 let mut t = task("landed by hand while held");
3927 t.hold_manual(Some("waiting on 3ed9".to_owned()));
3928 assert_eq!(t.hold_reason.as_deref(), Some("waiting on 3ed9"));
3929
3930 t.succeed();
3931 assert_eq!(t.status, TaskStatus::Done);
3932 assert!(
3933 t.hold_reason.is_none(),
3934 "a done task cannot still be waiting on something"
3935 );
3936 }
3937
3938 #[test]
3939 fn holding_or_closing_a_blocked_task_clears_its_dependency_too() {
3940 let mut held = task("held straight out of blocked");
3947 held.block(
3948 vec!["20260101-000000-dead".to_owned()],
3949 Some("waiting on the migration script".to_owned()),
3950 );
3951 assert_eq!(held.status, TaskStatus::Blocked);
3952
3953 held.hold_manual(None);
3954 assert_eq!(held.status, TaskStatus::Held);
3955 assert!(
3956 held.blocked_by.is_empty(),
3957 "hold overrides the wait, same as release"
3958 );
3959 assert!(held.block_reason.is_none());
3960
3961 let mut done = task("closed straight out of blocked");
3962 done.block(
3963 vec!["20260101-000000-dead".to_owned()],
3964 Some("waiting on the migration script".to_owned()),
3965 );
3966 done.succeed();
3967 assert_eq!(done.status, TaskStatus::Done);
3968 assert!(
3969 done.blocked_by.is_empty(),
3970 "a done task cannot still be waiting on a dependency"
3971 );
3972 assert!(done.block_reason.is_none());
3973 }
3974
3975 #[test]
3976 fn a_blocked_task_is_never_offered_to_the_loop() {
3977 let mut t = task("blocked");
3978 assert!(t.status.runnable());
3979 t.block(
3980 vec!["dep-id".to_owned()],
3981 Some("waits on dep-id".to_owned()),
3982 );
3983 assert_eq!(t.status, TaskStatus::Blocked);
3984 assert!(!t.status.runnable());
3985 assert_eq!(TaskStatus::Blocked.as_str(), "blocked");
3986 }
3987
3988 #[test]
3989 fn unblocking_the_last_dependency_returns_the_task_to_queued() {
3990 let mut t = task("blocked on two");
3991 t.block(
3992 vec!["a".to_owned(), "b".to_owned()],
3993 Some("waits on a and b".to_owned()),
3994 );
3995
3996 t.unblock("a");
3997 assert_eq!(t.status, TaskStatus::Blocked, "b is still outstanding");
3998 assert_eq!(t.blocked_by, ["b"]);
3999
4000 t.unblock("b");
4001 assert_eq!(t.status, TaskStatus::Queued);
4002 assert!(t.blocked_by.is_empty());
4003 assert!(t.block_reason.is_none());
4004 }
4005
4006 #[test]
4007 fn unblocking_an_id_on_a_task_that_is_not_blocked_is_a_no_op() {
4008 let mut t = task("never blocked");
4009 t.unblock("whatever");
4010 assert_eq!(t.status, TaskStatus::Queued);
4011 }
4012
4013 #[test]
4014 fn a_held_task_blocked_on_a_question_returns_to_held_not_queued() {
4015 let mut t = task("held, then asked about");
4020 t.hold_machine(Some("out of attempts".to_owned()));
4021 assert_eq!(t.status, TaskStatus::Held);
4022
4023 t.block(vec!["q1".to_owned()], Some("what now?".to_owned()));
4024 assert_eq!(t.status, TaskStatus::Blocked);
4025
4026 t.record_answer("what now?".to_owned(), "leave it held".to_owned());
4027 t.unblock("q1");
4028 assert_eq!(t.status, TaskStatus::Held, "must restore, not requeue");
4029 assert_eq!(t.hold_reason.as_deref(), Some("out of attempts"));
4030 assert_eq!(t.hold_source, Some(HoldSource::Machine));
4031 assert!(t.blocked_from.is_none(), "consumed once restored");
4032 }
4033
4034 #[test]
4035 fn a_manually_held_task_blocked_on_a_question_returns_to_held() {
4036 let mut t = task("manually held, then asked about");
4037 t.hold_manual(Some("waiting on a dependency".to_owned()));
4038
4039 t.block(vec!["q1".to_owned()], None);
4040 t.unblock("q1");
4041
4042 assert_eq!(t.status, TaskStatus::Held);
4043 assert_eq!(t.hold_source, Some(HoldSource::Manual));
4044 }
4045
4046 #[test]
4047 fn re_blocking_an_already_blocked_task_keeps_the_original_blocked_from() {
4048 let mut t = task("held, blocked twice");
4052 t.hold_machine(None);
4053 t.block(vec!["q1".to_owned()], Some("first".to_owned()));
4054 t.block(
4055 vec!["q1".to_owned(), "q2".to_owned()],
4056 Some("second".to_owned()),
4057 );
4058
4059 t.unblock("q1");
4060 assert_eq!(t.status, TaskStatus::Blocked, "q2 still outstanding");
4061 t.unblock("q2");
4062 assert_eq!(t.status, TaskStatus::Held);
4063 }
4064
4065 #[test]
4066 fn unblocking_a_task_blocked_while_running_lands_on_queued_not_running() {
4067 let mut t = task("blocked mid-run");
4071 t.start("run-1".to_owned());
4072 assert_eq!(t.status, TaskStatus::Running);
4073
4074 t.block(vec!["q1".to_owned()], None);
4075 t.unblock("q1");
4076 assert_eq!(t.status, TaskStatus::Queued);
4077 }
4078
4079 #[test]
4080 fn a_pre_schema_4_blocked_record_with_hold_evidence_restores_to_held() {
4081 let mut t = task("legacy record, held before it was blocked");
4087 t.hold_source = Some(HoldSource::Machine);
4088 t.hold_reason = Some("legacy hold reason".to_owned());
4089 t.status = TaskStatus::Blocked;
4090 t.blocked_by = vec!["q1".to_owned()];
4091 t.blocked_from = None;
4092
4093 t.unblock("q1");
4094 assert_eq!(t.status, TaskStatus::Held);
4095 }
4096
4097 #[test]
4098 fn a_pre_schema_4_blocked_record_with_no_hold_evidence_restores_to_queued() {
4099 let mut t = task("legacy record, ordinary dependency block");
4100 t.status = TaskStatus::Blocked;
4101 t.blocked_by = vec!["dep".to_owned()];
4102 t.blocked_from = None;
4103
4104 t.unblock("dep");
4105 assert_eq!(t.status, TaskStatus::Queued);
4106 }
4107
4108 #[test]
4109 fn answering_a_question_is_recorded_and_survives_a_release() {
4110 let mut t = task("asked something");
4111 t.block(vec!["q1".to_owned()], Some("which backend?".to_owned()));
4112 t.record_answer("Which backend?".to_owned(), "SQLite".to_owned());
4113 t.unblock("q1");
4114 assert_eq!(t.status, TaskStatus::Queued);
4115 assert_eq!(t.answers.len(), 1);
4116 assert_eq!(t.answers[0].answer, "SQLite");
4117
4118 t.release();
4122 assert_eq!(t.answers.len(), 1, "the answer is not lost on release");
4123 }
4124
4125 #[test]
4126 fn resumption_review_branch_requires_unchanged_authored_criteria() {
4127 for source in ["cli", "web"] {
4128 let mut t = task("review old candidate");
4129 t.request_review("candidate-A".into());
4130 t.hold_for_handover(Some("candidate-A".into()), "handover refused".into());
4131 let mut unchanged = t.clone();
4132 unchanged.release_operator(source, None);
4133 assert_eq!(unchanged.review_branch.as_deref(), Some("candidate-A"));
4134 t.edit(
4135 "review approved head".into(),
4136 "Reuse measured PR head B; fix Clippy; review".into(),
4137 )
4138 .unwrap();
4139 t.release_operator(source, None);
4140 assert!(t.review_branch.is_none());
4141 assert!(t.review_instruction.is_none());
4142 let mut legacy = unchanged;
4144 legacy.review_instruction = None;
4145 legacy.hold_for_handover(None, "still checked out".into());
4146 legacy
4147 .edit("new plan".into(), "Reuse measured PR head B".into())
4148 .unwrap();
4149 legacy.release_operator(source, None);
4150 assert!(legacy.review_branch.is_none());
4151 }
4152 }
4153
4154 #[test]
4155 fn a_refused_handover_keeps_the_review_branch_across_release() {
4156 let mut t = task("refused takeover");
4157 t.start("run-1".to_owned());
4158 t.hold_for_handover(Some("magi/eba2/A".to_owned()), "checked out".to_owned());
4159 assert_eq!(t.status, TaskStatus::Held);
4160 assert_eq!(t.attempts, 1);
4161 t.release();
4162 assert_eq!(t.status, TaskStatus::Queued);
4163 assert_eq!(t.attempts, 0);
4164 assert_eq!(t.review_branch.as_deref(), Some("magi/eba2/A"));
4165
4166 t.hold_for_handover(None, "again".to_owned());
4168 t.requeue();
4169 assert!(t.review_branch.is_none());
4170
4171 let mut m = task("manual");
4173 m.review_branch = Some("magi/x/A".to_owned());
4174 m.hold_manual(None);
4175 m.release();
4176 assert!(m.review_branch.is_none());
4177 }
4178
4179 #[test]
4180 fn requesting_review_requeues_the_task_and_remembers_the_branch() {
4181 let mut t = task("blocked run with a surviving branch");
4182 t.start("run-1".to_owned());
4183 t.fail("blocked with major findings", 5);
4184 assert_eq!(t.status, TaskStatus::Failed);
4185
4186 t.request_review("magi/eba2/A".to_owned());
4187 assert_eq!(t.status, TaskStatus::Queued);
4188 assert_eq!(t.attempts, 0);
4189 assert_eq!(t.review_branch.as_deref(), Some("magi/eba2/A"));
4190
4191 t.release();
4193 assert!(t.review_branch.is_none());
4194 }
4195
4196 #[test]
4197 fn conductor_requeue_but_not_an_ordinary_release_forces_a_fresh_start() {
4198 let mut t = task("retry");
4199 t.start("run-1".to_owned());
4200 t.requeue();
4201 assert!(t.fresh_start);
4202
4203 t.release();
4204 assert!(!t.fresh_start);
4205 }
4206
4207 #[test]
4208 fn urgent_only_changes_queued_or_held_and_preserves_settings() {
4209 for status in [
4210 TaskStatus::Queued,
4211 TaskStatus::Held,
4212 TaskStatus::Running,
4213 TaskStatus::Done,
4214 TaskStatus::Failed,
4215 TaskStatus::Blocked,
4216 TaskStatus::Parked,
4217 ] {
4218 let mut t = task("urgent settings");
4219 t.status = status;
4220 t.attempts = 2;
4221 t.hold_reason = Some("waiting".into());
4222 t.priority = 7;
4223 t.interrupt = true;
4224 let before = serde_json::to_value(&t).unwrap();
4225 for urgent in [true, true, false, false] {
4226 let result = t.set_urgent(urgent);
4227 let mut expected = before.clone();
4228 if matches!(status, TaskStatus::Queued | TaskStatus::Held) {
4229 result.unwrap();
4230 expected["urgent"] = urgent.into();
4231 } else {
4232 let error = result.unwrap_err().to_string();
4233 assert!(error.contains(status.as_str()), "{error}");
4234 assert!(error.contains("queued or held"), "{error}");
4235 }
4236 assert_eq!(serde_json::to_value(&t).unwrap(), expected);
4237 }
4238 }
4239 }
4240
4241 #[test]
4242 fn priority_can_be_changed_while_queued_but_not_while_running() {
4243 let mut t = task("reprioritise me");
4244 t.set_priority(5).unwrap();
4245 assert_eq!(t.priority, 5);
4246
4247 t.start("run-1".to_owned());
4248 let err = t.set_priority(9).unwrap_err().to_string();
4249 assert!(err.contains("running"), "{err}");
4250 assert_eq!(t.priority, 5, "the rejected write must not partially apply");
4251 }
4252
4253 #[test]
4254 fn interrupt_can_be_marked_while_queued_but_not_while_running() {
4255 let mut t = task("interrupt me");
4256 assert!(!t.interrupt, "off unless asked, same as any other task");
4257
4258 t.set_interrupt(true).unwrap();
4259 assert!(t.interrupt);
4260
4261 t.start("run-1".to_owned());
4262 assert!(
4263 !t.interrupt,
4264 "the mark is one-shot: dispatching the task fulfils it, \
4265 whatever the run that follows ends up doing"
4266 );
4267 let err = t.set_interrupt(true).unwrap_err().to_string();
4268 assert!(err.contains("running"), "{err}");
4269 t.set_interrupt(false).unwrap();
4272 assert!(!t.interrupt);
4273 }
4274
4275 #[test]
4279 fn a_failed_run_does_not_leave_the_task_still_marked_to_interrupt() {
4280 let mut t = task("interrupt me");
4281 t.set_interrupt(true).unwrap();
4282 t.start("run-1".to_owned());
4283 t.fail("mock failure", 5);
4284 assert_eq!(t.status, TaskStatus::Failed);
4285 assert!(
4286 !t.interrupt,
4287 "one attempt already spent the mark; a retry is an ordinary \
4288 requeue, not a fresh interrupt request"
4289 );
4290 }
4291
4292 #[test]
4293 fn changing_priority_moves_a_task_ahead_in_the_real_queue_order() {
4294 let (_dir, q) = queue();
4295 let mut a = task("first filed");
4296 let mut b = task("second filed");
4297 a.id = "20260101-000001-aaaa".to_owned();
4298 b.id = "20260101-000002-bbbb".to_owned();
4299 q.put(&mut a).unwrap();
4300 q.put(&mut b).unwrap();
4301
4302 assert_eq!(
4303 q.next_runnable().unwrap().id,
4304 a.id,
4305 "with equal priority the older task goes first, so a burst of \
4306 new work cannot starve it"
4307 );
4308 assert_eq!(
4309 q.list()[0].id,
4310 b.id,
4311 "but the list an operator reads is newest first, the same as \
4312 before priority existed - a's turn to run does not make it the \
4313 newest task"
4314 );
4315
4316 let mut a = q.get(&a.id).unwrap();
4317 a.set_priority(10).unwrap();
4318 q.put(&mut a).unwrap();
4319
4320 assert_eq!(
4321 q.next_runnable().unwrap().id,
4322 a.id,
4323 "a raised priority must be reflected the moment it is saved"
4324 );
4325 assert_eq!(
4329 q.list()[0].id,
4330 a.id,
4331 "the raised task must sort first in the list an operator reads, \
4332 not only in next_runnable's own ordering"
4333 );
4334 }
4335
4336 #[test]
4337 fn editing_replaces_title_and_instruction_but_keeps_identity_and_history() {
4338 let mut t = Task::new(
4339 "old title".to_owned(),
4340 "old instruction".to_owned(),
4341 PathBuf::from("/repo"),
4342 Source::Agent {
4343 run: "20260101-000000-beef".to_owned(),
4344 node: "implement".to_owned(),
4345 },
4346 );
4347 let id = t.id.clone();
4348 let created_at = t.created_at;
4349 t.runs.push("20260101-000000-beef".to_owned());
4350
4351 t.edit("new title".to_owned(), "new instruction".to_owned())
4352 .unwrap();
4353
4354 assert_eq!(t.title, "new title");
4355 assert_eq!(t.instruction, "new instruction");
4356 assert_eq!(t.id, id, "editing must not mint a new id");
4357 assert_eq!(t.created_at, created_at);
4358 assert_eq!(
4359 t.source,
4360 Source::Agent {
4361 run: "20260101-000000-beef".to_owned(),
4362 node: "implement".to_owned(),
4363 },
4364 "editing must not turn agent attribution into human"
4365 );
4366 assert_eq!(t.runs, ["20260101-000000-beef"]);
4367 }
4368
4369 #[test]
4370 fn editing_is_refused_once_a_task_is_running_or_finished() {
4371 let mut running = task("in flight");
4372 running.start("run-1".to_owned());
4373 let err = running
4374 .edit("x".to_owned(), "y".to_owned())
4375 .unwrap_err()
4376 .to_string();
4377 assert!(err.contains("running"), "{err}");
4378
4379 let mut done = task("finished");
4380 done.succeed();
4381 let err = done
4382 .edit("x".to_owned(), "y".to_owned())
4383 .unwrap_err()
4384 .to_string();
4385 assert!(err.contains("done"), "{err}");
4386
4387 let mut queued = task("waiting");
4389 queued.edit("x".to_owned(), "y".to_owned()).unwrap();
4390 let mut held = task("parked");
4391 held.hold_machine(None);
4392 held.edit("x".to_owned(), "y".to_owned()).unwrap();
4393 }
4394
4395 #[test]
4396 fn a_task_recorded_without_a_hold_reason_still_reads_as_none() {
4397 let (_dir, q) = queue();
4398 let path = q.path_of("20260101-000000-aaaa");
4399 std::fs::create_dir_all(q.root()).unwrap();
4400 std::fs::write(
4401 &path,
4402 serde_json::json!({
4403 "schema": SCHEMA,
4404 "id": "20260101-000000-aaaa",
4405 "title": "from before hold reasons existed",
4406 "instruction": "from before hold reasons existed",
4407 "repo": ".",
4408 "source": { "kind": "human" },
4409 "status": "held",
4410 "created_at": Timestamp::now().to_string(),
4411 "updated_at": Timestamp::now().to_string(),
4412 })
4413 .to_string(),
4414 )
4415 .unwrap();
4416
4417 let task = q.get("20260101-000000-aaaa").expect("must still read");
4418 assert!(task.hold_reason.is_none());
4419 assert!(task.operator_held());
4420 }
4421
4422 #[test]
4423 fn a_legacy_reasoned_hold_defaults_to_operator_protection() {
4424 let (_dir, q) = queue();
4425 let path = q.path_of("20260101-000000-bbbb");
4426 std::fs::create_dir_all(q.root()).unwrap();
4427 std::fs::write(
4428 &path,
4429 serde_json::json!({
4430 "schema": 2,
4431 "id": "20260101-000000-bbbb",
4432 "title": "old manual recovery",
4433 "instruction": "old manual recovery",
4434 "repo": ".",
4435 "source": { "kind": "human" },
4436 "status": "held",
4437 "hold_reason": "active manual recovery run20260912-224242-daf5",
4438 "created_at": Timestamp::now().to_string(),
4439 "updated_at": Timestamp::now().to_string(),
4440 })
4441 .to_string(),
4442 )
4443 .unwrap();
4444
4445 let task = q.get("20260101-000000-bbbb").expect("must still read");
4446 assert_eq!(task.hold_source, None);
4447 assert!(task.operator_held());
4448 }
4449
4450 #[test]
4451 fn a_task_recorded_without_a_diagnostic_still_reads_as_none() {
4452 let (_dir, q) = queue();
4453 let path = q.path_of("20260101-000000-aaaa");
4454 std::fs::create_dir_all(q.root()).unwrap();
4455 std::fs::write(
4456 &path,
4457 serde_json::json!({
4458 "schema": SCHEMA,
4459 "id": "20260101-000000-aaaa",
4460 "title": "from before diagnostics existed",
4461 "instruction": "from before diagnostics existed",
4462 "repo": ".",
4463 "source": { "kind": "human" },
4464 "status": "held",
4465 "created_at": Timestamp::now().to_string(),
4466 "updated_at": Timestamp::now().to_string(),
4467 })
4468 .to_string(),
4469 )
4470 .unwrap();
4471
4472 let task = q.get("20260101-000000-aaaa").expect("must still read");
4473 assert!(task.diagnostic.is_none());
4474 }
4475
4476 #[test]
4477 fn a_schema_1_task_with_no_blocking_fields_still_reads() {
4478 let (_dir, q) = queue();
4482 let path = q.path_of("20260101-000000-aaaa");
4483 std::fs::create_dir_all(q.root()).unwrap();
4484 std::fs::write(
4485 &path,
4486 serde_json::json!({
4487 "schema": 1,
4488 "id": "20260101-000000-aaaa",
4489 "title": "from before blocking existed",
4490 "instruction": "from before blocking existed",
4491 "repo": ".",
4492 "source": { "kind": "human" },
4493 "status": "queued",
4494 "created_at": Timestamp::now().to_string(),
4495 "updated_at": Timestamp::now().to_string(),
4496 })
4497 .to_string(),
4498 )
4499 .unwrap();
4500
4501 let task = q.get("20260101-000000-aaaa").expect("must still read");
4502 assert!(task.blocked_by.is_empty());
4503 assert!(task.block_reason.is_none());
4504 assert!(task.answers.is_empty());
4505 assert!(task.review_branch.is_none());
4506 }
4507
4508 #[test]
4509 fn releasing_or_finishing_a_task_clears_its_stale_diagnostic() {
4510 let mut held = task("diagnosed");
4515 held.start("run-1".to_owned());
4516 held.fail("gate red", 1);
4517 held.diagnostic = Some("cargo test failed: ...".to_owned());
4518 assert_eq!(held.status, TaskStatus::Held);
4519
4520 held.release();
4521 assert!(held.diagnostic.is_none());
4522
4523 held.diagnostic = Some("cargo test failed: ...".to_owned());
4524 held.succeed();
4525 assert!(held.diagnostic.is_none());
4526 }
4527
4528 #[test]
4529 fn failing_a_task_always_clears_whatever_diagnostic_it_carried() {
4530 let mut t = task("retried");
4531 t.start("run-1".to_owned());
4532 t.diagnostic = Some("stale evidence from a previous hold".to_owned());
4533 t.fail("unrelated config error", 5);
4534 assert_eq!(t.status, TaskStatus::Failed);
4535 assert!(
4536 t.diagnostic.is_none(),
4537 "fail() must not let an old diagnostic outlive the run that produced it"
4538 );
4539 }
4540
4541 #[test]
4542 fn a_claim_is_exclusive_and_releases_on_drop() {
4543 let (_dir, q) = queue();
4544 let mut t = task("contended");
4545 q.put(&mut t).unwrap();
4546
4547 let held = q.claim(&t.id).unwrap();
4548 assert!(
4549 q.claim(&t.id).is_err(),
4550 "two daemons must not drive one task into two runs"
4551 );
4552 drop(held);
4553 assert!(q.claim(&t.id).is_ok(), "a released claim is reclaimable");
4554 }
4555
4556 #[test]
4557 fn a_round_trip_survives_disk() {
4558 let (_dir, q) = queue();
4559 let mut t = Task::new(
4560 "titled".to_owned(),
4561 "body".to_owned(),
4562 PathBuf::from("/repo"),
4563 Source::Agent {
4564 run: "20260101-000000-beef".to_owned(),
4565 node: "implement".to_owned(),
4566 },
4567 );
4568 t.priority = 3;
4569 q.put(&mut t).unwrap();
4570
4571 let back = q.get(&t.id).unwrap();
4572 assert_eq!(back.id, t.id);
4573 assert_eq!(back.priority, 3);
4574 assert_eq!(back.source.label(), "implement@beef");
4575 assert_eq!(q.get(t.short()).unwrap().id, t.id);
4577 }
4578
4579 #[test]
4580 fn an_unreadable_task_does_not_take_the_queue_down() {
4581 let (_dir, q) = queue();
4582 let mut t = task("fine");
4583 q.put(&mut t).unwrap();
4584 std::fs::write(q.root().join("broken.json"), "{ not json").unwrap();
4585
4586 let listed = q.list();
4587 assert_eq!(listed.len(), 1, "the readable task still lists");
4588 assert_eq!(listed[0].id, t.id);
4589 }
4590
4591 #[test]
4592 fn a_task_recorded_without_a_solo_field_still_reads_as_not_solo() {
4593 let (_dir, q) = queue();
4594 let path = q.path_of("20260101-000000-aaaa");
4595 std::fs::create_dir_all(q.root()).unwrap();
4596 std::fs::write(
4597 &path,
4598 serde_json::json!({
4599 "schema": SCHEMA,
4600 "id": "20260101-000000-aaaa",
4601 "title": "from before solo existed",
4602 "instruction": "from before solo existed",
4603 "repo": ".",
4604 "source": { "kind": "human" },
4605 "status": "queued",
4606 "created_at": Timestamp::now().to_string(),
4607 "updated_at": Timestamp::now().to_string(),
4608 })
4609 .to_string(),
4610 )
4611 .unwrap();
4612
4613 let task = q.get("20260101-000000-aaaa").expect("must still read");
4614 assert!(!task.solo, "a queue file with no `solo` field means false");
4615 }
4616
4617 #[test]
4618 fn a_task_recorded_without_an_urgent_field_still_reads_as_not_urgent() {
4619 let (_dir, q) = queue();
4620 let path = q.path_of("20260101-000000-bbbb");
4621 std::fs::create_dir_all(q.root()).unwrap();
4622 std::fs::write(
4623 &path,
4624 serde_json::json!({
4625 "schema": SCHEMA,
4626 "id": "20260101-000000-bbbb",
4627 "title": "from before urgent existed",
4628 "instruction": "from before urgent existed",
4629 "repo": ".",
4630 "source": { "kind": "human" },
4631 "status": "queued",
4632 "created_at": Timestamp::now().to_string(),
4633 "updated_at": Timestamp::now().to_string(),
4634 })
4635 .to_string(),
4636 )
4637 .unwrap();
4638
4639 let task = q.get("20260101-000000-bbbb").expect("must still read");
4640 assert!(
4641 !task.urgent,
4642 "a queue file with no `urgent` field means false, same as `solo`"
4643 );
4644 }
4645
4646 #[test]
4647 fn a_task_from_a_future_schema_is_refused_rather_than_guessed_at() {
4648 let (_dir, q) = queue();
4649 let mut t = task("from the future");
4650 q.put(&mut t).unwrap();
4651 let path = q.path_of(&t.id);
4652 let body = std::fs::read_to_string(&path)
4653 .unwrap()
4654 .replace(&format!("\"schema\": {SCHEMA}"), "\"schema\": 99");
4655 std::fs::write(&path, body).unwrap();
4656
4657 let err = q.get(&t.id).unwrap_err().to_string();
4658 assert!(err.contains("schema 99"), "{err}");
4659 }
4660
4661 #[test]
4662 fn revision_moves_when_the_queue_changes() {
4663 let (_dir, q) = queue();
4664 assert_eq!(q.revision(), 0, "an empty queue has no revision");
4665 let mut t = task("first");
4666 q.put(&mut t).unwrap();
4667 assert!(q.revision() > 0, "a written task moves the revision");
4668 }
4669
4670 #[test]
4671 fn revision_moves_when_deleting_an_older_task() {
4672 let (dir, q) = queue();
4673 let questions = Questions::at(dir.path().join("questions"));
4674 let mut t1 = task("older");
4675 q.put(&mut t1).unwrap();
4676 std::thread::sleep(std::time::Duration::from_millis(10));
4678 let mut t2 = task("newer");
4679 q.put(&mut t2).unwrap();
4680
4681 let rev_before = q.revision();
4682 q.remove(&t1.id, false, &questions).unwrap();
4683 let rev_after = q.revision();
4684
4685 assert_ne!(
4686 rev_before, rev_after,
4687 "deleting an older task must change the revision so other clients see the deletion"
4688 );
4689 }
4690
4691 #[test]
4692 fn removing_a_task_takes_it_out_of_the_listing() {
4693 let (dir, q) = queue();
4694 let questions = Questions::at(dir.path().join("questions"));
4695 let mut t = task("delete me");
4696 q.put(&mut t).unwrap();
4697 let removed = q.remove(t.short(), false, &questions).unwrap();
4698 assert_eq!(removed.id, t.id, "a prefix resolves before deleting");
4699 assert!(removed.released.is_empty() && removed.still_blocked.is_empty());
4700 assert!(q.list().is_empty());
4701 assert!(
4702 q.remove(&t.id, false, &questions).is_err(),
4703 "removing twice is an error"
4704 );
4705 }
4706
4707 #[test]
4708 fn removing_a_task_takes_its_stale_lock_with_it() {
4709 let (dir, q) = queue();
4710 let questions = Questions::at(dir.path().join("questions"));
4711 let mut t = task("interrupted");
4712 q.put(&mut t).unwrap();
4713
4714 let claim = q.claim(&t.id).unwrap();
4717 std::mem::forget(claim);
4718 assert!(
4719 q.claim(&t.id).is_err(),
4720 "the orphaned lock is what makes the task look claimed"
4721 );
4722
4723 let err = q.remove(&t.id, true, &questions).unwrap_err().to_string();
4725 assert!(err.contains("live daemon"), "{err}");
4726 assert!(q.get(&t.id).is_ok(), "a refused delete keeps the task");
4727
4728 q.remove(&t.id, false, &questions).unwrap();
4730 assert!(q.list().is_empty());
4731 let mut again = task("interrupted");
4732 again.id = t.id.clone();
4733 q.put(&mut again).unwrap();
4734 assert!(
4735 q.claim(&t.id).is_ok(),
4736 "a task that comes back must be claimable, which a left-behind lock would prevent"
4737 );
4738 }
4739
4740 fn notices_of(dir: &Path) -> Vec<crate::notices::Notice> {
4741 crate::notices::Notices::at(dir.join("notifications")).list()
4742 }
4743
4744 #[test]
4745 fn removing_a_sole_dependency_releases_the_dependent_without_a_hold() {
4746 let (dir, q) = queue();
4747 let questions = Questions::at(dir.path().join("questions"));
4748 let mut dep = task("dependency");
4749 q.put(&mut dep).unwrap();
4750 let mut blocked = task("waiting");
4751 blocked.block(vec![dep.id.clone()], Some("waits".to_owned()));
4752 q.put(&mut blocked).unwrap();
4753
4754 let removed = q.remove(&dep.id, false, &questions).unwrap();
4755 assert_eq!(removed.released, [blocked.id.clone()]);
4756 assert!(removed.still_blocked.is_empty());
4757
4758 let after = q.get(&blocked.id).unwrap();
4759 assert_eq!(after.status, TaskStatus::Queued);
4760 assert!(after.blocked_by.is_empty());
4761 assert!(after.block_reason.is_none());
4762 let notes = notices_of(dir.path());
4763 assert_eq!(notes.len(), 1, "{notes:?}");
4764 assert_eq!(notes[0].severity, crate::notices::Severity::Info);
4765 assert_eq!(
4766 notes[0].link,
4767 Some(crate::notices::Link::Task {
4768 id: blocked.id.clone(),
4769 })
4770 );
4771 }
4772
4773 #[test]
4774 fn removing_one_of_two_dependencies_keeps_the_other() {
4775 let (dir, q) = queue();
4776 let questions = Questions::at(dir.path().join("questions"));
4777 let mut dep = task("dependency");
4778 q.put(&mut dep).unwrap();
4779 let mut other = task("other");
4780 q.put(&mut other).unwrap();
4781 let mut blocked = task("waiting");
4782 blocked.block(
4783 vec![dep.id.clone(), other.id.clone()],
4784 Some("waits on both".to_owned()),
4785 );
4786 q.put(&mut blocked).unwrap();
4787
4788 let removed = q.remove(&dep.id, false, &questions).unwrap();
4789 assert!(removed.released.is_empty());
4790 assert_eq!(removed.still_blocked, [blocked.id.clone()]);
4791
4792 let after = q.get(&blocked.id).unwrap();
4793 assert_eq!(after.status, TaskStatus::Blocked);
4794 assert_eq!(after.blocked_by, [other.id.clone()]);
4795 assert!(missing_blockers(&q, &questions, &after.blocked_by).is_empty());
4798 }
4799
4800 #[test]
4801 fn a_dependency_deleted_before_its_dependents_were_rewritten_is_released_later() {
4802 let (dir, q) = queue();
4803 let questions = Questions::at(dir.path().join("questions"));
4804 let mut dep = task("dependency");
4805 q.put(&mut dep).unwrap();
4806 let mut blocked = task("waiting");
4807 blocked.block(vec![dep.id.clone()], None);
4808 q.put(&mut blocked).unwrap();
4809
4810 let claim = q.claim(&blocked.id).unwrap();
4812 let removed = q.remove(&dep.id, false, &questions).unwrap();
4813 assert!(removed.released.is_empty());
4814 drop(claim);
4815
4816 let mut task = q.get(&blocked.id).unwrap();
4817 assert_eq!(task.status, TaskStatus::Blocked);
4818 assert!(missing_blockers(&q, &questions, &task.blocked_by).is_empty());
4819 assert_eq!(
4820 q.apply_deleted_blockers(&mut task, &questions),
4821 [dep.id.clone()]
4822 );
4823 assert_eq!(task.status, TaskStatus::Queued);
4824 }
4825
4826 #[test]
4827 fn dependency_resolution_short_deleted_reference_releases_only_its_waiters() {
4828 let (dir, q) = queue();
4829 let questions = Questions::at(dir.path().join("questions"));
4830 let mut dep = task("dependency");
4831 dep.id = "20261013-193611-ca5b".into();
4832 q.put(&mut dep).unwrap();
4833 let mut waiting = task("waiting");
4834 waiting.block(vec!["ca5b".into()], None);
4835 q.put(&mut waiting).unwrap();
4836 q.remove(&dep.id, false, &questions).unwrap();
4837 assert_eq!(q.get(&waiting.id).unwrap().status, TaskStatus::Queued);
4838 assert_eq!(
4839 dependency(&q, &questions, "ca5b"),
4840 Dependency::Deleted(dep.id)
4841 );
4842 }
4843
4844 #[test]
4845 fn dependency_resolution_preserves_cross_store_ambiguity_and_exact_ids() {
4846 let (dir, queue) = queue();
4847 let questions = Questions::at(dir.path().join("questions"));
4848 let mut dep = task("dependency");
4849 dep.id = "20261013-193611-ca5b".into();
4850 queue.put(&mut dep).unwrap();
4851 std::fs::create_dir_all(questions.root()).unwrap();
4852 let question_id = "20261014-193611-ca5b";
4853 std::fs::write(questions.path_of(question_id), "unreadable question").unwrap();
4854 assert_eq!(questions.resolve_id("ca5b").unwrap(), question_id);
4855 assert_eq!(
4856 dependency(&queue, &questions, "ca5b"),
4857 Dependency::Ambiguous
4858 );
4859 assert_eq!(
4860 dependency(&queue, &questions, &dep.id),
4861 Dependency::Task(dep.id)
4862 );
4863 assert_eq!(
4864 dependency(&queue, &questions, question_id),
4865 Dependency::Question(question_id.into())
4866 );
4867 assert_eq!(
4868 dependency(&queue, &questions, "missing"),
4869 Dependency::Missing
4870 );
4871 }
4872
4873 #[test]
4874 fn dependency_resolution_directory_errors_are_unknown_not_missing() {
4875 let dir = tempfile::tempdir().unwrap();
4876 let q = Queue::at(dir.path().join("not-a-directory"));
4877 std::fs::write(q.root(), "file").unwrap();
4878 let questions = Questions::at(dir.path().join("questions"));
4879 assert_eq!(dependency(&q, &questions, "ca5b"), Dependency::Unknown);
4880 assert!(missing_blockers(&q, &questions, &["ca5b".into()]).is_empty());
4881 }
4882
4883 #[test]
4884 fn a_dependency_without_a_tombstone_is_still_missing() {
4885 let (dir, q) = queue();
4886 let questions = Questions::at(dir.path().join("questions"));
4887 let mut dep = task("dependency");
4888 q.put(&mut dep).unwrap();
4889 std::fs::remove_file(q.path_of(&dep.id)).unwrap();
4890 assert_eq!(
4891 missing_blockers(&q, &questions, std::slice::from_ref(&dep.id)),
4892 [dep.id.clone()]
4893 );
4894 assert!(deleted_blockers(&q, &questions, std::slice::from_ref(&dep.id)).is_empty());
4895 }
4896
4897 #[test]
4898 fn a_held_dependent_is_left_alone_by_a_dependency_deletion() {
4899 let mut t = task("held");
4900 t.hold_machine(Some("because".to_owned()));
4901 t.blocked_by = vec!["gone".to_owned()];
4902 assert!(!t.dependency_deleted("gone"));
4903 assert_eq!(t.status, TaskStatus::Held);
4904 }
4905
4906 #[test]
4907 fn a_failed_record_removal_takes_the_tombstone_back() {
4908 let (_dir, q) = queue();
4909 let mut t = task("stays");
4910 q.put(&mut t).unwrap();
4911 q.write_tombstone(&t.id).unwrap();
4912 let err = q.remove_record_with_attachments(&t.id, |_| Err(std::io::Error::other("nope")));
4913 assert!(err.is_err());
4914 assert!(!q.was_deleted(&t.id));
4917 }
4918
4919 fn source_file(dir: &Path, name: &str, body: &str) -> PathBuf {
4920 let p = dir.join(name);
4921 std::fs::write(&p, body).unwrap();
4922 p
4923 }
4924
4925 #[test]
4926 fn an_attachment_copy_survives_deleting_its_source() {
4927 let (dir, q) = queue();
4928 let src = source_file(dir.path(), "shot.png", "pixels");
4929 let mut t = task("with a picture");
4930 let names = q.attach(&mut t, std::slice::from_ref(&src)).unwrap();
4931 q.put(&mut t).unwrap();
4932 std::fs::remove_file(&src).unwrap();
4933 assert_eq!(names, ["shot.png"]);
4934 let loaded = q.get(&t.id).unwrap();
4935 let paths = q.attachment_paths(&loaded);
4936 assert_eq!(paths.len(), 1);
4937 assert_eq!(std::fs::read_to_string(&paths[0]).unwrap(), "pixels");
4938 }
4939
4940 #[test]
4941 fn attachment_names_that_could_traverse_or_are_odd_are_refused() {
4942 let (dir, q) = queue();
4943 let mut t = task("bad names");
4944 for name in ["a..b.png", ".hidden", "with space.png", "-x.png"] {
4945 let src = source_file(dir.path(), name, "x");
4946 assert!(
4947 q.attach(&mut t, &[src]).is_err(),
4948 "`{name}` must be refused"
4949 );
4950 }
4951 assert!(!crate::ask::valid_asset_name("C:foo.png"));
4955 #[cfg(not(windows))]
4956 {
4957 let src = source_file(dir.path(), "C:foo.png", "x");
4958 assert!(q.attach(&mut t, &[src]).is_err());
4959 }
4960 let long = format!("{}.png", "a".repeat(70));
4961 let src = source_file(dir.path(), &long, "x");
4962 assert!(q.attach(&mut t, &[src]).is_err());
4963 assert!(t.attachments.is_empty());
4964 assert!(!q.attachments_dir(&t.id).exists());
4965 }
4966
4967 #[test]
4968 fn a_taken_attachment_name_is_numbered_not_overwritten() {
4969 let (dir, q) = queue();
4970 let a = source_file(dir.path(), "shot.png", "one");
4971 let sub = dir.path().join("other");
4972 std::fs::create_dir_all(&sub).unwrap();
4973 let b = source_file(&sub, "shot.png", "two");
4974 let mut t = task("collision");
4975 q.attach(&mut t, &[a]).unwrap();
4976 q.attach(&mut t, &[b]).unwrap();
4977 assert_eq!(t.attachments, ["shot.png", "shot-2.png"]);
4978 let paths = q.attachment_paths(&t);
4979 assert_eq!(std::fs::read_to_string(&paths[0]).unwrap(), "one");
4980 assert_eq!(std::fs::read_to_string(&paths[1]).unwrap(), "two");
4981 }
4982
4983 #[test]
4984 fn a_renumbered_name_stays_inside_the_length_bound() {
4985 let (dir, q) = queue();
4986 let name = format!("{}.png", "a".repeat(60));
4987 assert_eq!(name.len(), 64);
4988 let a = source_file(dir.path(), &name, "one");
4989 let sub = dir.path().join("other");
4990 std::fs::create_dir_all(&sub).unwrap();
4991 let b = source_file(&sub, &name, "two");
4992 let mut t = task("long");
4993 q.attach(&mut t, &[a, b]).unwrap();
4994 assert_eq!(t.attachments.len(), 2);
4995 assert!(
4996 t.attachments
4997 .iter()
4998 .all(|n| crate::ask::valid_asset_name(n))
4999 );
5000 assert!(t.attachments[1].ends_with("-2.png"));
5001 }
5002
5003 #[test]
5004 fn a_failed_attach_keeps_existing_attachments_and_leaves_no_partial_copy() {
5005 let (dir, q) = queue();
5006 let good = source_file(dir.path(), "good.png", "ok");
5007 let mut t = task("partial");
5008 q.attach(&mut t, &[good]).unwrap();
5009 let more = source_file(dir.path(), "more.png", "ok");
5010 let missing = dir.path().join("missing.png");
5011 assert!(q.attach(&mut t, &[more, missing]).is_err());
5012 assert_eq!(t.attachments, ["good.png"]);
5013 let on_disk: Vec<_> = std::fs::read_dir(q.attachments_dir(&t.id))
5014 .unwrap()
5015 .flatten()
5016 .collect();
5017 assert_eq!(on_disk.len(), 1);
5018 }
5019
5020 fn block_put(q: &Queue, t: &Task) -> PathBuf {
5022 let tmp = q.path_of(&t.id).with_extension("json.tmp");
5023 std::fs::create_dir_all(&tmp).unwrap();
5024 tmp
5025 }
5026
5027 #[test]
5028 fn a_failed_put_leaves_no_new_attachment_directory() {
5029 let (dir, q) = queue();
5030 let mut t = task("fresh");
5031 let tmp = block_put(&q, &t);
5032 let src = source_file(dir.path(), "shot.png", "x");
5033 assert!(q.attach_and_put(&mut t, &[src]).is_err());
5034 assert!(t.attachments.is_empty());
5035 assert!(!q.attachments_dir(&t.id).exists());
5036 assert!(!q.path_of(&t.id).exists());
5037 std::fs::remove_dir(tmp).unwrap();
5038 }
5039
5040 #[test]
5041 fn a_failed_put_removes_only_the_copy_it_just_made() {
5042 let (dir, q) = queue();
5043 let mut t = task("edited");
5044 let first = source_file(dir.path(), "first.png", "1");
5045 q.attach_and_put(&mut t, &[first]).unwrap();
5046 block_put(&q, &t);
5047 let second = source_file(dir.path(), "second.png", "2");
5048 assert!(q.attach_and_put(&mut t, &[second]).is_err());
5049 assert_eq!(t.attachments, ["first.png"]);
5050 let on_disk: Vec<_> = std::fs::read_dir(q.attachments_dir(&t.id))
5051 .unwrap()
5052 .flatten()
5053 .map(|e| e.file_name().to_string_lossy().into_owned())
5054 .collect();
5055 assert_eq!(on_disk, ["first.png"]);
5056 assert_eq!(q.get(&t.id).unwrap().attachments, ["first.png"]);
5057 }
5058
5059 #[test]
5060 fn a_leftover_removing_directory_is_swept_by_the_next_removal() {
5061 let (dir, q) = queue();
5062 let questions = Questions::at(dir.path().join("questions"));
5063 let gone = task("gone");
5064 let mut other = task("other");
5065 let mut live = task("live");
5066 q.put(&mut other).unwrap();
5067 q.put(&mut live).unwrap();
5068 let orphan = q.root.join(format!("{}.attachments.removing", gone.id));
5071 std::fs::create_dir_all(&orphan).unwrap();
5072 std::fs::write(orphan.join("shot.png"), "x").unwrap();
5073 let busy = q.root.join(format!("{}.attachments.removing", live.id));
5076 std::fs::create_dir_all(&busy).unwrap();
5077
5078 q.remove(&other.id, false, &questions).unwrap();
5079 assert!(!orphan.exists(), "an orphan is swept");
5080 assert!(busy.exists(), "a removal in progress is left alone");
5081 }
5082
5083 #[test]
5084 fn a_blocked_aside_rename_fails_the_removal_and_loses_nothing() {
5085 let (dir, q) = queue();
5086 let questions = Questions::at(dir.path().join("questions"));
5087 let mut t = task("stuck");
5088 let src = source_file(dir.path(), "shot.png", "x");
5089 q.attach_and_put(&mut t, &[src]).unwrap();
5090 let aside = q.root.join(format!("{}.attachments.removing", t.id));
5093 std::fs::create_dir_all(&aside).unwrap();
5094 std::fs::write(aside.join("old.png"), "o").unwrap();
5095 assert!(q.remove(&t.id, false, &questions).is_err());
5096 assert!(q.path_of(&t.id).exists());
5097 assert!(q.attachments_dir(&t.id).join("shot.png").is_file());
5098 }
5099
5100 #[test]
5101 fn a_failed_record_removal_puts_the_attachments_back() {
5102 let (dir, q) = queue();
5103 let mut t = task("rollback");
5104 let src = source_file(dir.path(), "shot.png", "x");
5105 q.attach_and_put(&mut t, &[src]).unwrap();
5106 let err = q
5107 .remove_record_with_attachments(&t.id, |_| {
5108 Err(std::io::Error::other("injected failure"))
5109 })
5110 .unwrap_err();
5111 assert!(format!("{err:#}").contains("injected failure"));
5112 assert!(q.path_of(&t.id).exists());
5113 assert!(q.attachments_dir(&t.id).join("shot.png").is_file());
5114 assert!(
5115 !q.root
5116 .join(format!("{}.attachments.removing", t.id))
5117 .exists()
5118 );
5119 }
5120
5121 #[test]
5122 fn editing_a_task_keeps_its_attachments() {
5123 let (dir, q) = queue();
5124 let src = source_file(dir.path(), "shot.png", "x");
5125 let mut t = task("editable");
5126 q.attach(&mut t, &[src]).unwrap();
5127 t.edit("new".to_owned(), "new text".to_owned()).unwrap();
5128 q.put(&mut t).unwrap();
5129 assert_eq!(q.get(&t.id).unwrap().attachments, ["shot.png"]);
5130 }
5131
5132 #[test]
5133 fn removing_a_task_deletes_its_attachments() {
5134 let (dir, q) = queue();
5135 let questions = Questions::at(dir.path().join("questions"));
5136 let src = source_file(dir.path(), "shot.png", "x");
5137 let mut t = task("doomed");
5138 q.attach(&mut t, &[src]).unwrap();
5139 q.put(&mut t).unwrap();
5140 assert!(q.attachments_dir(&t.id).is_dir());
5141 q.remove(&t.id, false, &questions).unwrap();
5142 assert!(!q.attachments_dir(&t.id).exists());
5143 assert!(q.list().is_empty());
5144 }
5145
5146 #[test]
5147 fn a_task_written_before_attachments_still_reads() {
5148 let (_dir, q) = queue();
5149 let mut t = task("old");
5150 q.put(&mut t).unwrap();
5151 let path = q.path_of(&t.id);
5152 let mut v: serde_json::Value =
5153 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
5154 v.as_object_mut().unwrap().remove("attachments");
5155 std::fs::write(&path, v.to_string()).unwrap();
5156 assert!(q.get(&t.id).unwrap().attachments.is_empty());
5157 }
5158
5159 #[test]
5160 fn attachment_paths_are_absolute_even_when_the_root_is_relative() {
5161 let q = Queue::at(PathBuf::from("relative-queue"));
5162 let mut t = task("rel");
5163 t.attachments.push("shot.png".to_owned());
5164 let paths = q.attachment_paths(&t);
5165 assert!(paths[0].is_absolute(), "{}", paths[0].display());
5166 assert!(paths[0].ends_with(format!("{}.attachments/shot.png", t.id)));
5167 }
5168
5169 fn upgrade_parked(id: &str, started: &str) -> Task {
5170 let mut t = task(id);
5171 t.id = id.into();
5172 t.start(format!("run-{id}"));
5173 t.park("upgrade");
5174 t.record_pass_at(
5175 &format!("run-{id}"),
5176 0,
5177 Some(started.parse().unwrap()),
5178 1,
5179 Some(crate::run::ParkCause::Upgrade {
5180 from: "old".into(),
5181 to: None,
5182 }),
5183 );
5184 t
5185 }
5186
5187 #[test]
5188 fn upgrade_resume_order_follows_dispatch_claim_not_graph_start() {
5189 let stamp = |id: &str, dispatched: &str, started: &str| {
5191 let mut t = task(id);
5192 t.id = id.into();
5193 t.start(format!("run-{id}"));
5194 t.park("upgrade");
5195 t.dispatched_at = Some(dispatched.parse().unwrap());
5196 t.record_pass_at(
5197 &format!("run-{id}"),
5198 0,
5199 Some(started.parse().unwrap()),
5200 1,
5201 Some(crate::run::ParkCause::Upgrade {
5202 from: "old".into(),
5203 to: None,
5204 }),
5205 );
5206 t
5207 };
5208 let a = stamp("a", "2026-10-22T00:00:01Z", "2026-10-22T00:00:09Z");
5209 let b = stamp("b", "2026-10-22T00:00:02Z", "2026-10-22T00:00:03Z");
5210 assert!(dispatch_order(&a, &b).is_lt());
5211 }
5212
5213 #[test]
5214 fn upgrade_resume_order_survives_queue_reload_and_reversed_settlement() {
5215 let (_dir, q) = queue();
5216 let mut later = upgrade_parked("later", "2026-10-22T00:00:02Z");
5217 let mut earlier = upgrade_parked("earlier", "2026-10-22T00:00:01Z");
5218 earlier.priority = -10;
5219 let mut fresh = task("fresh");
5220 fresh.id = "newer".into();
5221 fresh.priority = 100;
5222 for t in [&mut fresh, &mut later, &mut earlier] {
5223 q.put(t).unwrap();
5224 }
5225 let reloaded = Queue::at(q.root().to_owned());
5226 let mut tasks = reloaded.list();
5227 tasks.sort_unstable_by(dispatch_order);
5228 assert_eq!(
5229 tasks.iter().map(|t| t.id.as_str()).collect::<Vec<_>>(),
5230 vec!["earlier", "later", "newer"]
5231 );
5232 assert_eq!(reloaded.next_runnable().unwrap().id, "earlier");
5233 assert_eq!(reloaded.get("earlier").unwrap().attempts, 0);
5234 }
5235
5236 #[test]
5237 fn upgrade_resume_legacy_order_uses_settlement_and_id() {
5238 let mut a = upgrade_parked("a", "2026-10-22T00:00:02Z");
5239 let mut b = upgrade_parked("b", "2026-10-22T00:00:01Z");
5240 for t in [&mut a, &mut b] {
5241 let mut value = serde_json::to_value(&*t).unwrap();
5242 value["passes"][0]
5243 .as_object_mut()
5244 .unwrap()
5245 .remove("started_at");
5246 *t = serde_json::from_value(value).unwrap();
5247 }
5248 assert!(dispatch_order(&a, &b).is_lt());
5249 b.passes[0].settled_at = a.passes[0].settled_at;
5250 assert!(dispatch_order(&a, &b).is_lt());
5251 }
5252
5253 #[test]
5254 fn upgrade_resume_key_excludes_other_parks_and_explicit_task_changes() {
5255 let original = upgrade_parked("a", "2026-10-22T00:00:01Z");
5256 assert!(original.upgrade_resume_key().is_some());
5257 for cause in [
5258 crate::run::ParkCause::Operator,
5259 crate::run::ParkCause::Question,
5260 crate::run::ParkCause::Interrupt {
5261 reason: "interrupt".into(),
5262 },
5263 crate::run::ParkCause::Unspecified,
5264 ] {
5265 let mut t = original.clone();
5266 t.passes[0].parked = Some(cause);
5267 assert!(t.upgrade_resume_key().is_none());
5268 let mut fresh = task("fresh");
5269 fresh.priority = 1;
5270 assert!(dispatch_order(&fresh, &t).is_lt());
5271 }
5272 let mut t = original.clone();
5273 t.release();
5274 assert!(t.upgrade_resume_key().is_none());
5275 t.edit("edited".into(), "new instruction".into()).unwrap();
5276 assert!(t.upgrade_resume_key().is_none());
5277 let mut t = original.clone();
5278 t.requeue();
5279 t.status = TaskStatus::Parked;
5280 assert!(t.upgrade_resume_key().is_none());
5281 let mut t = original.clone();
5282 t.start("different-run".into());
5283 t.park("no upgrade");
5284 assert!(t.upgrade_resume_key().is_none());
5285 }
5286
5287 #[test]
5288 fn explicitly_resumed_older_run_becomes_current_without_duplicate_history() {
5289 let dir = tempfile::tempdir().unwrap();
5290 let q = Queue::at(dir.path().join("queue"));
5291 let mut t = task("resume");
5292 t.start("A".into());
5293 t.record_pass("A", 0, 1, None);
5294 t.start("B".into());
5295 q.put(&mut t).unwrap();
5296 assert_eq!(t.runs, vec!["A", "B"]);
5297 t.start("A".into());
5298 t.record_pass("A", 1, 3, None);
5299 q.put(&mut t).unwrap();
5300 let saved = q.get(&t.id).unwrap();
5301 assert_eq!(saved.runs, vec!["B", "A"]);
5302 assert_eq!(saved.runs.last().map(String::as_str), Some("A"));
5303 assert_eq!(saved.attempts, 3);
5304 assert_eq!(saved.passes.len(), 2);
5305 assert_eq!(saved.passes[0].run, "A");
5306 assert_eq!(saved.passes[1].run, "A");
5307 }
5308
5309 #[test]
5310 fn repeated_start_records_one_run_and_separate_passes() {
5311 let mut t = task("resume");
5312 t.start("same-run".into());
5313 t.record_pass("same-run", 0, 1, None);
5314 t.start("same-run".into());
5315 t.record_pass("same-run", 1, 2, None);
5316 assert_eq!(t.runs, vec!["same-run"]);
5317 assert_eq!(t.attempts, 2);
5318 assert_eq!(t.passes.len(), 2);
5319 }
5320
5321 #[test]
5322 fn link_run_adds_a_run_once_and_touches_nothing_else() {
5323 let dir = tempfile::tempdir().unwrap();
5324 let queue = Queue::at(dir.path().join("queue"));
5325 let mut t = Task::new(
5326 "t".to_owned(),
5327 "do it".to_owned(),
5328 PathBuf::from("."),
5329 Source::Human,
5330 );
5331 queue.put(&mut t).unwrap();
5332 let before = queue.get(&t.id).unwrap();
5333
5334 let linked = queue.link_run(&t.id[..4], "20260930-092817-ec34").unwrap();
5335 assert_eq!(linked.runs, vec!["20260930-092817-ec34".to_owned()]);
5336 assert_eq!(linked.status, before.status);
5337 assert_eq!(linked.attempts, before.attempts);
5338 assert_eq!(linked.interrupt, before.interrupt);
5339
5340 let again = queue.link_run(&t.id, "20260930-092817-ec34").unwrap();
5341 assert_eq!(again.runs.len(), 1, "linking twice must not duplicate");
5342 assert_eq!(queue.get(&t.id).unwrap().runs.len(), 1);
5343 assert!(queue.link_run("no-such-task", "r").is_err());
5344 }
5345
5346 #[test]
5347 fn put_keeps_a_run_linked_after_the_writer_took_its_snapshot() {
5348 let dir = tempfile::tempdir().unwrap();
5349 let queue = Queue::at(dir.path().join("queue"));
5350 let mut t = Task::new(
5351 "t".to_owned(),
5352 "do it".to_owned(),
5353 PathBuf::from("."),
5354 Source::Human,
5355 );
5356 queue.put(&mut t).unwrap();
5357 let mut snapshot = queue.get(&t.id).unwrap();
5359 queue.link_run(&t.id, "20260930-092817-ec34").unwrap();
5360
5361 snapshot.start("20260930-000000-aaaa".to_owned());
5362 queue.put(&mut snapshot).unwrap();
5363
5364 let stored = queue.get(&t.id).unwrap();
5365 assert!(stored.runs.contains(&"20260930-092817-ec34".to_owned()));
5366 assert!(stored.runs.contains(&"20260930-000000-aaaa".to_owned()));
5367 assert_eq!(stored.attempts, 1);
5368 }
5369
5370 #[test]
5371 fn concurrent_links_and_daemon_saves_lose_nothing() {
5372 let dir = tempfile::tempdir().unwrap();
5373 let queue = Queue::at(dir.path().join("queue"));
5374 let mut t = Task::new(
5375 "t".to_owned(),
5376 "do it".to_owned(),
5377 PathBuf::from("."),
5378 Source::Human,
5379 );
5380 queue.put(&mut t).unwrap();
5381 let id = t.id.clone();
5382
5383 let linkers: Vec<_> = (0..4)
5384 .map(|n| {
5385 let (queue, id) = (queue.clone(), id.clone());
5386 std::thread::spawn(move || {
5387 for k in 0..10 {
5388 queue
5389 .link_run(&id, &format!("20260930-00000{n}-l{k:03}"))
5390 .unwrap();
5391 }
5392 })
5393 })
5394 .collect();
5395 let mut mine = queue.get(&id).unwrap();
5398 for k in 0..10 {
5399 mine.start(format!("20260930-000009-d{k:03}"));
5400 queue.put(&mut mine).unwrap();
5401 }
5402 for l in linkers {
5403 l.join().unwrap();
5404 }
5405
5406 let stored = queue.get(&id).unwrap();
5407 assert_eq!(stored.runs.len(), 50, "{:?}", stored.runs);
5408 assert_eq!(
5409 stored.attempts, 10,
5410 "linking never rewinds the daemon's work"
5411 );
5412 }
5413
5414 fn age_lock(path: &Path) {
5415 let f = std::fs::OpenOptions::new().write(true).open(path).unwrap();
5416 f.set_modified(std::time::SystemTime::now() - std::time::Duration::from_secs(60))
5417 .unwrap();
5418 }
5419
5420 #[test]
5421 fn concurrent_stale_takeover_yields_one_holder() {
5422 use std::sync::atomic::{AtomicUsize, Ordering};
5423 use std::sync::{Arc, Barrier};
5424 for _ in 0..5 {
5425 let dir = tempfile::tempdir().unwrap();
5426 let q = Queue::at(dir.path().to_path_buf());
5427 let lock = dir.path().join("t.write-lock");
5428 std::fs::write(&lock, "dead-0000").unwrap();
5429 age_lock(&lock);
5430 let n = 6;
5431 let barrier = Arc::new(Barrier::new(n));
5432 let (now, max) = (Arc::new(AtomicUsize::new(0)), Arc::new(AtomicUsize::new(0)));
5433 let handles: Vec<_> = (0..n)
5434 .map(|_| {
5435 let (q, b, now, max) = (q.clone(), barrier.clone(), now.clone(), max.clone());
5436 std::thread::spawn(move || {
5437 b.wait();
5438 let g = q.lock_task("t").unwrap();
5439 let held = now.fetch_add(1, Ordering::SeqCst) + 1;
5440 max.fetch_max(held, Ordering::SeqCst);
5441 std::thread::sleep(std::time::Duration::from_millis(20));
5442 now.fetch_sub(1, Ordering::SeqCst);
5443 drop(g);
5444 })
5445 })
5446 .collect();
5447 for h in handles {
5448 h.join().unwrap();
5449 }
5450 assert_eq!(max.load(Ordering::SeqCst), 1);
5451 assert!(!lock.exists());
5452 }
5453 }
5454
5455 #[test]
5456 fn dropping_a_replaced_lock_leaves_the_new_holders_token() {
5457 let dir = tempfile::tempdir().unwrap();
5458 let q = Queue::at(dir.path().to_path_buf());
5459 let lock = dir.path().join("t.write-lock");
5460 let a = q.lock_task("t").unwrap();
5461 let replacement = owner_token();
5464 std::fs::write(&lock, &replacement).unwrap();
5465 drop(a);
5466 assert_eq!(std::fs::read_to_string(&lock).unwrap(), replacement);
5467 }
5468
5469 #[test]
5470 fn aged_live_task_lock_excludes_updates_until_retirement_releases_it() {
5471 let dir = tempfile::tempdir().unwrap();
5472 let q = Queue::at(dir.path().to_path_buf());
5473 let held = q.lock_task("t").unwrap();
5474 let path = dir.path().join("t.write-lock");
5475 age_lock(&path);
5476 assert!(!break_stale(&path, &held.token));
5477 let (started_tx, started_rx) = std::sync::mpsc::channel();
5478 let (acquired_tx, acquired_rx) = std::sync::mpsc::channel();
5479 let updater = std::thread::spawn(move || {
5480 started_tx.send(()).unwrap();
5481 let _write = q.lock_task("t").unwrap();
5482 acquired_tx.send(()).unwrap();
5483 });
5484 started_rx.recv().unwrap();
5485 assert!(
5486 acquired_rx
5487 .recv_timeout(std::time::Duration::from_millis(100))
5488 .is_err()
5489 );
5490 assert_eq!(std::fs::read_to_string(&path).unwrap(), held.token);
5491 drop(held);
5492 acquired_rx
5493 .recv_timeout(std::time::Duration::from_secs(2))
5494 .unwrap();
5495 updater.join().unwrap();
5496 assert!(!path.exists());
5497 }
5498
5499 #[test]
5500 fn break_stale_leaves_a_lock_that_replaced_the_one_judged() {
5501 let dir = tempfile::tempdir().unwrap();
5502 let lock = dir.path().join("t.write-lock");
5503 std::fs::write(&lock, "new-token").unwrap();
5504 assert!(!break_stale(&lock, "old-token"));
5505 assert_eq!(std::fs::read_to_string(&lock).unwrap(), "new-token");
5506 assert!(!break_stale(&lock, "new-token"));
5508 assert!(lock.exists());
5509 age_lock(&lock);
5510 assert!(break_stale(&lock, "new-token"));
5511 assert!(!lock.exists());
5512 }
5513
5514 #[test]
5515 fn a_stale_break_marker_is_recovered_and_a_fresh_one_is_respected() {
5516 let dir = tempfile::tempdir().unwrap();
5517 let lock = dir.path().join("t.write-lock");
5518 std::fs::write(&lock, "dead-1").unwrap();
5519 age_lock(&lock);
5520 let marker = break_marker(&lock, "dead-1");
5521 std::fs::write(&marker, "crashed-remover").unwrap();
5522 assert!(!break_stale(&lock, "dead-1"));
5524 assert!(lock.exists());
5525 age_lock(&marker);
5527 assert!(break_stale(&lock, "dead-1"));
5528 assert!(!lock.exists());
5529 assert!(!marker.exists());
5530 }
5531
5532 fn chat_task() -> Task {
5533 Task::new(
5534 "t".to_owned(),
5535 "do t".to_owned(),
5536 PathBuf::from("."),
5537 Source::Agent {
5538 run: "20260902-000000-beef".to_owned(),
5539 node: CHAT_NODE.to_owned(),
5540 },
5541 )
5542 }
5543
5544 #[test]
5545 fn a_blocked_hold_keeps_its_anchor_but_a_new_run_starts_a_new_hold() {
5546 let mut t = chat_task();
5547 t.hold_machine(Some("waiting".into()));
5548 let old: Timestamp = "2020-01-01T00:00:00Z".parse().unwrap();
5549 t.held_at = Some(old);
5550 t.block(vec!["decision".into()], None);
5551 t.hold_machine(Some("same waiting decision".into()));
5552 assert_eq!(t.held_at, Some(old));
5553 t.start("real-new-run".into());
5554 t.fail("new failure", 1);
5555 assert_ne!(
5556 t.held_at,
5557 Some(old),
5558 "old answers cannot approve a new hold"
5559 );
5560 }
5561
5562 #[test]
5563 fn legacy_delivery_survives_upgrade_and_a_no_progress_run_link() {
5564 let (_dir, queue) = queue();
5565 let mut t = chat_task();
5566 t.start("old-run".into());
5567 t.hold_machine(Some("operator is considering recovery".into()));
5568 t.chat_notice = Some(ChatNotice {
5569 key: format!("held#{}", t.held_at.unwrap()),
5570 at: Timestamp::now(),
5571 outcome: ChatNoticeOutcome::Posted,
5572 });
5573 let mut raw = serde_json::to_value(&t).unwrap();
5574 raw.as_object_mut().unwrap().remove("chat_notices");
5575 raw.as_object_mut().unwrap().remove("hold_notice_cause");
5576 std::fs::create_dir_all(queue.path_of(&t.id).parent().unwrap()).unwrap();
5577 std::fs::write(queue.path_of(&t.id), serde_json::to_string(&raw).unwrap()).unwrap();
5578 assert_eq!(queue.get(&t.id).unwrap().chat_notice_due(), None);
5579 queue
5580 .modify(&t.id, |task| {
5581 task.requeue();
5582 task.link_run("unprepared-run");
5583 task.startup_notice_cause("startup-source");
5584 task.fail("same unresolved preflight", 0);
5585 true
5586 })
5587 .unwrap();
5588 assert_eq!(queue.get(&t.id).unwrap().chat_notice_due(), None);
5589 queue
5590 .modify(&t.id, |task| {
5591 task.succeed();
5592 true
5593 })
5594 .unwrap();
5595 assert_eq!(
5596 queue.get(&t.id).unwrap().chat_notice_due().as_deref(),
5597 Some("done")
5598 );
5599 }
5600
5601 #[test]
5602 fn a_dependency_wait_does_not_change_when_released_and_blocked_again() {
5603 let mut t = chat_task();
5604 t.block(vec!["a".into(), "b".into()], Some("waiting".into()));
5605 let key = t.chat_notice_key().unwrap();
5606 t.chat_notices.push(key);
5607 t.release();
5608 t.block(
5609 vec!["b".into(), "a".into(), "a".into()],
5610 Some("still waiting on prerequisites".into()),
5611 );
5612 assert_eq!(t.chat_notice_due(), None);
5613 t.block(
5614 vec!["new-question".into()],
5615 Some("different decision".into()),
5616 );
5617 assert!(t.chat_notice_due().is_some());
5618 }
5619
5620 #[test]
5621 fn running_startup_failures_preserve_stage_and_report_distinct_blockers() {
5622 let mut t = chat_task();
5623 t.start("existing-run".into());
5624 t.fail("implementation failed", 1);
5625 let failed = t.chat_notice_key().unwrap();
5626 t.chat_notices.push(failed.clone());
5627 t.release_operator("test", None);
5628 t.start("existing-run".into());
5629 t.fail_startup("startup-source", "same unresolved recovery source", 1);
5630 assert_eq!(
5631 t.chat_notice_due(),
5632 None,
5633 "no-progress recovery stays quiet"
5634 );
5635 t.release_operator("test", None);
5636 t.start("existing-run".into());
5637 t.fail_startup("startup-attachments", "attachment missing", 1);
5638 let attachments = t.chat_notice_due().expect("new attachment blocker");
5639 assert_ne!(attachments, failed);
5640 assert_eq!(
5641 t.hold_notice_cause.as_ref().unwrap().stage,
5642 "startup-attachments"
5643 );
5644 t.chat_notices.push(attachments);
5645 t.release_operator("test", None);
5646 t.start("existing-run".into());
5647 t.fail_startup(
5648 "startup-attachments",
5649 "same missing attachment, reworded",
5650 1,
5651 );
5652 assert_eq!(
5653 t.chat_notice_due(),
5654 None,
5655 "same stage after attempt counter reset"
5656 );
5657 t.release_operator("test", None);
5658 t.start("existing-run".into());
5659 t.fail_startup("startup-config", "different preflight stage", 1);
5660 assert!(t.chat_notice_due().is_some());
5661 assert_eq!(
5662 t.hold_notice_cause.as_ref().unwrap().stage,
5663 "startup-config"
5664 );
5665 }
5666
5667 #[test]
5668 fn structured_causes_preserve_no_progress_retries_and_report_new_passes() {
5669 let mut t = chat_task();
5670 t.hold_machine(Some("startup source missing".into()));
5671 t.startup_notice_cause("startup-source");
5672 let first = t.chat_notice_key().unwrap();
5673 t.chat_notices.push(first.clone());
5674 t.release_operator("test", None);
5675 t.startup_notice_cause("startup-source");
5676 t.hold_machine(Some("same source cannot be read".into()));
5677 assert_eq!(t.chat_notice_due(), None);
5678 t.startup_notice_cause("startup-config");
5679 assert!(t.chat_notice_due().is_some(), "different startup stage");
5680 t.notice_cause("failed", Some("existing-run"), Some(0));
5681 let run_failure = t.chat_notice_key().unwrap();
5682 t.chat_notices.push(run_failure.clone());
5683 t.release_operator("test", None);
5684 t.startup_notice_cause("startup-source");
5685 t.hold_machine(Some("no new work happened".into()));
5686 assert_eq!(t.chat_notice_due(), None);
5687 t.notice_cause("failed", Some("existing-run"), Some(1));
5688 assert!(t.chat_notice_due().is_some(), "a real resumed pass failed");
5689 assert_ne!(t.chat_notice_key().unwrap(), run_failure);
5690 }
5691
5692 #[test]
5693 fn legacy_hold_baseline_does_not_replay_or_hide_new_failures_and_completion() {
5694 let mut t = chat_task();
5695 t.hold_machine(Some("still waiting".into()));
5696 t.chat_notice = Some(ChatNotice {
5697 key: "held#2026-10-14T00:00:00Z".into(),
5698 at: Timestamp::now(),
5699 outcome: ChatNoticeOutcome::Posted,
5700 });
5701 assert_eq!(t.chat_notice_due(), None, "old hold baseline");
5702 t.import_chat_notice();
5703 t.notice_cause("failed", Some("new-run"), Some(0));
5704 assert!(t.chat_notice_due().is_some(), "new producer evidence");
5705 t.succeed();
5706 assert_eq!(t.chat_notice_due().as_deref(), Some("done"));
5707 }
5708
5709 #[test]
5710 fn a_chat_notice_is_due_only_for_a_final_state_and_only_once() {
5711 let mut t = chat_task();
5712 assert_eq!(t.chat_notice_due(), None, "queued");
5713 t.start("r1".to_owned());
5714 assert_eq!(t.chat_notice_due(), None, "running");
5715 t.fail("boom", 3);
5716 assert_eq!(t.status, TaskStatus::Failed);
5717 assert_eq!(t.chat_notice_due(), None, "a retried failure is not final");
5718 t.start("r2".to_owned());
5719 t.park("upgrade");
5720 assert_eq!(t.chat_notice_due(), None, "parked");
5721 t.fail("boom", 1);
5722 assert_eq!(t.status, TaskStatus::Held);
5723 let key = t
5724 .chat_notice_due()
5725 .expect("held on the last attempt is final");
5726 t.chat_notice = Some(ChatNotice {
5727 key,
5728 at: Timestamp::now(),
5729 outcome: ChatNoticeOutcome::Posted,
5730 });
5731 assert_eq!(t.chat_notice_due(), None, "same hold, no second notice");
5732 t.release();
5733 assert_eq!(t.chat_notice_due(), None, "requeued after a release");
5734 t.start("r3".to_owned());
5735 t.succeed();
5736 assert!(t.chat_notice_due().is_some(), "done is a new final state");
5737 }
5738
5739 #[test]
5740 fn completed_chat_notices_keep_current_and_legacy_keys_after_requeue() {
5741 for key in ["done", "done#0", "done#1", "done#12"] {
5742 let mut t = chat_task();
5743 t.start("r1".to_owned());
5744 t.succeed();
5745 t.chat_notice = Some(ChatNotice {
5746 key: key.to_owned(),
5747 at: Timestamp::now(),
5748 outcome: ChatNoticeOutcome::Posted,
5749 });
5750 t.requeue();
5751 t.start("r2".to_owned());
5752 t.succeed();
5753 assert_eq!(t.chat_notice_key().as_deref(), Some("done"));
5754 assert_eq!(t.chat_notice_due(), None, "recorded key: {key}");
5755 }
5756 }
5757
5758 #[test]
5759 fn a_release_without_progress_keeps_the_unresolved_notice_cause() {
5760 let mut t = chat_task();
5761 t.hold_manual(Some("wait".to_owned()));
5762 let first = t.chat_notice_due().expect("held");
5763 t.chat_notice = Some(ChatNotice {
5764 key: first.clone(),
5765 at: Timestamp::now(),
5766 outcome: ChatNoticeOutcome::Posted,
5767 });
5768 t.hold_manual(Some("still waiting".to_owned()));
5769 assert_eq!(t.chat_notice_due(), None, "a re-hold keeps its start");
5770 t.release();
5771 std::thread::sleep(std::time::Duration::from_millis(5));
5772 t.hold_manual(None);
5773 assert_eq!(t.chat_notice_due(), None, "no progress, same cause");
5774 }
5775
5776 #[test]
5777 fn a_chat_notice_needs_a_chat_source_and_a_current_schema() {
5778 let mut human = task("h");
5779 human.succeed();
5780 assert_eq!(human.chat_notice_due(), None);
5781 let mut inherited = task("i");
5783 inherited.origin_chat = Some("20260902-000000-beef".to_owned());
5784 inherited.succeed();
5785 assert_eq!(inherited.chat_notice_due(), None);
5786 let mut old = chat_task();
5787 old.schema = 12;
5788 old.status = TaskStatus::Done;
5789 assert_eq!(old.chat_notice_due(), None, "history is not replayed");
5790 let mut live = chat_task();
5791 live.schema = 12;
5792 live.start("r1".to_owned());
5793 live.succeed();
5794 assert!(live.chat_notice_due().is_some(), "started by this build");
5795 let mut held = chat_task();
5796 held.schema = 12;
5797 held.hold_machine(Some("config".to_owned()));
5798 assert!(held.chat_notice_due().is_some(), "held before any start");
5799 }
5800
5801 #[test]
5802 fn a_stale_snapshot_does_not_rewind_a_recorded_chat_notice() {
5803 let tmp = tempfile::tempdir().expect("tempdir");
5804 let q = Queue::at(tmp.path().join("queue"));
5805 let mut t = chat_task();
5806 t.succeed();
5807 q.put(&mut t).expect("put");
5808 let mut stale = q.get(&t.id).expect("get");
5809 let key = t.chat_notice_due().expect("due");
5810 q.modify(&t.id, |x| {
5811 x.chat_notice = Some(ChatNotice {
5812 key: key.clone(),
5813 at: Timestamp::now(),
5814 outcome: ChatNoticeOutcome::Posted,
5815 });
5816 true
5817 })
5818 .expect("modify");
5819 q.put(&mut stale).expect("stale put");
5820 assert_eq!(q.get(&t.id).expect("get").chat_notice_due(), None);
5821 }
5822
5823 #[test]
5824 fn empty_sources_are_omitted_so_older_builds_still_read_the_task() {
5825 let mut t = task("plain");
5828 let value = serde_json::to_value(&t).unwrap();
5829 assert!(value.get("sources").is_none(), "{value}");
5830 t.sources.pick_commits.push(crate::refs::Input {
5831 token: "abc1234".into(),
5832 sha: "a".repeat(40),
5833 provenance: crate::refs::Provenance::ExplicitCli,
5834 });
5835 let value = serde_json::to_value(&t).unwrap();
5836 assert_eq!(value["sources"]["pick_commits"][0]["sha"], "a".repeat(40));
5837 let restored: Task = serde_json::from_value(value).unwrap();
5838 assert_eq!(restored.sources, t.sources);
5839 }
5840}