1use std::collections::{BTreeMap, BTreeSet};
44use std::path::{Path, PathBuf};
45use std::time::Duration;
46
47use anyhow::{Context as _, Result};
48use serde::{Deserialize, Serialize};
49
50use crate::agent::{self, Invocation, SeatState};
51use crate::ask::{Deputy, Question, Questions};
52use crate::config::Config;
53use crate::prompt;
54use crate::queue::{Queue, Task, TaskStatus};
55use crate::run::RunState;
56use crate::verdict;
57
58const SEAT: &str = "conduct";
61
62pub const NODE: &str = "conduct";
66
67const TURN_TIMEOUT: Duration = Duration::from_secs(300);
72
73#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
76#[serde(rename_all = "lowercase")]
77pub enum Recovery {
78 Requeue,
81 Hold,
84 Review,
89 Done,
97}
98
99#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
102#[serde(rename_all = "snake_case")]
103pub enum QuestionOperation {
104 Recover,
106 Resume,
108 Repair,
110 Complete,
112 Inspect,
114 Configure,
116}
117
118#[derive(Debug, Clone, Deserialize)]
121#[serde(tag = "kind", rename_all = "snake_case")]
122pub enum QuestionTarget {
123 Task,
125 Run {
127 id: String,
129 },
130 Setting {
133 path: String,
135 keys: Vec<String>,
137 },
138 Source {
140 reference: String,
142 },
143}
144
145#[derive(Debug, Clone, Deserialize)]
147pub struct QuestionDecision {
148 pub operation: QuestionOperation,
150 pub target: QuestionTarget,
152}
153
154impl QuestionDecision {
155 fn identity(&self, task: &Task) -> Result<(QuestionOperation, String)> {
156 let target = match &self.target {
157 QuestionTarget::Task => format!("task:{}", task.id),
158 QuestionTarget::Run { id } => {
159 anyhow::ensure!(
160 task.runs.contains(id),
161 "question target is not this task's run"
162 );
163 format!("run:{id}")
164 }
165 QuestionTarget::Setting { path, keys } => {
166 use std::path::Component;
167 anyhow::ensure!(
168 !path.is_empty()
169 && Path::new(path)
170 .components()
171 .all(|c| matches!(c, Component::Normal(_)))
172 && !keys.is_empty()
173 && keys.iter().all(|key| !key.is_empty()),
174 "question setting must name a repository file and concrete keys"
175 );
176 let root = task.repo.canonicalize()?;
177 let file = root.join(path).canonicalize()?;
178 let relative = file
179 .strip_prefix(&root)
180 .context("question setting is outside the repository")?;
181 let content = std::fs::read_to_string(&file)?;
182 let value: toml::Value = toml::from_str(&content)?;
183 let mut setting = &value;
184 for key in keys {
185 setting = setting
186 .get(key)
187 .context("question setting key does not exist")?;
188 }
189 format!("setting:{}", serde_json::to_string(&(relative, keys))?)
190 }
191 QuestionTarget::Source { reference } => {
192 use crate::proc::Quiet as _;
193 let output = std::process::Command::new("git")
194 .args([
195 "rev-parse",
196 "--verify",
197 "--end-of-options",
198 &format!("{reference}^{{commit}}"),
199 ])
200 .current_dir(&task.repo)
201 .quiet()
202 .output()
203 .context("resolve question source")?;
204 anyhow::ensure!(
205 output.status.success(),
206 "question source cannot be resolved"
207 );
208 let commit = String::from_utf8(output.stdout)?.trim().to_owned();
209 anyhow::ensure!(
210 !commit.is_empty() && commit.bytes().all(|c| c.is_ascii_hexdigit()),
211 "invalid question source commit"
212 );
213 format!("source:{commit}")
214 }
215 };
216 Ok((self.operation, target))
217 }
218}
219
220#[derive(Debug, Clone, Default, Deserialize)]
223pub struct Decision {
224 pub id: String,
226 #[serde(default)]
230 pub blocked_by: Vec<String>,
231 #[serde(default)]
236 pub missing_dependencies: Vec<String>,
237 #[serde(default)]
239 pub reason: Option<String>,
240 #[serde(default)]
247 pub recovery: Option<Recovery>,
248 #[serde(default)]
254 pub question: Option<String>,
255 #[serde(default)]
257 pub choices: Vec<String>,
258 #[serde(default)]
261 pub question_decision: Option<QuestionDecision>,
262 #[serde(default)]
264 pub evidence: Vec<String>,
265}
266
267#[derive(Debug, Clone, Default, Deserialize)]
280pub struct Verdict {
281 pub decisions: Vec<Decision>,
284}
285
286fn view(t: &Task, max_attempts: usize) -> prompt::ConductTask {
289 prompt::ConductTask {
290 id: t.id.clone(),
291 title: t.title.clone(),
292 instruction: t.instruction.clone(),
293 repo: t.repo.display().to_string(),
294 priority: t.priority,
295 status: t.status.as_str().to_owned(),
296 attempts: t.attempts,
297 max_attempts: t.effective_attempt_limit(max_attempts),
298 last_error: t.last_error.clone(),
299 hold_reason: t.hold_reason.clone(),
300 hold_source: t.hold_source.map(|source| source.label().to_owned()),
301 blocked_by: t.blocked_by.clone(),
302 answers: t
303 .answers
304 .iter()
305 .map(|a| prompt::ConductAnswer {
306 question: a.question.clone(),
307 answer: a.answer.clone(),
308 })
309 .collect(),
310 operator_resume: t.resumption.as_ref().map(|r| format!(
311 "Explicit operator resumption generation {} via {} at {}. Approved instruction: {}. \
312 The first {} historical answers and prior hold ({:?}) are superseded as waiting premises. \
313 To block again cite evidence ids from a later owned question, changed dependency, or new failed run; \
314 wording alone is insufficient.",
315 r.generation, r.source, r.at, r.instruction, r.answers_through, r.cleared_reason
316 )).or_else(|| t.resume_override.as_ref().map(|o| {
317 format!(
318 "the operator explicitly answered \"resume\" at {}; do not hold this \
319 task again for the same reason unless there is new information",
320 o.at
321 )
322 })),
323 }
324}
325
326fn pinned_resume(task: &Task) -> bool {
330 task.resume_override
331 .as_ref()
332 .is_some_and(|o| o.pinned_run.is_some())
333}
334
335fn new_blocking_evidence(
338 queue: &Queue,
339 questions: &Questions,
340 task: &Task,
341 d: &Decision,
342) -> Vec<String> {
343 let Some(r) = &task.resumption else {
344 return Vec::new();
345 };
346 d.evidence
347 .iter()
348 .filter_map(|id| {
349 let version = if let Ok(q) = questions.get(id) {
350 (q.asked_at > r.at
351 && (q.run == task.id || task.runs.contains(&q.run))
352 && q.node != NODE
353 && q.status != crate::ask::QuestionStatus::Abandoned)
354 .then(|| {
355 format!(
356 "question:{id}:{}:{:?}:{}",
357 q.asked_at,
358 q.status,
359 q.resolution().unwrap_or_default()
360 )
361 })
362 } else if d.blocked_by.contains(id) {
363 queue
364 .get(id)
365 .ok()
366 .filter(|dep| {
367 dep.id != task.id
368 && dep.status != TaskStatus::Done
369 && (dep.created_at > r.at
370 || dep.held_at.is_some_and(|at| at > r.at)
371 || dep.operator_decided_at.is_some_and(|at| at > r.at)
372 || dep.passes.iter().any(|p| p.settled_at > r.at)
373 || (dep.status == TaskStatus::Blocked
374 && dep.blocked_at.is_some_and(|at| at > r.at)))
375 })
376 .map(|dep| {
377 format!(
378 "dependency:{id}:{:?}:{:?}:{:?}",
379 dep.status,
380 dep.chat_notice_key(),
381 dep.hold_notice_cause
382 )
383 })
384 } else if task.runs.iter().any(|run| run == id) {
385 RunState::load(id)
386 .ok()
387 .filter(|run| {
388 run.updated_at > r.at
391 && run
392 .passes
393 .last()
394 .map_or(run.created_at > r.at, |p| p.started_at > r.at)
395 && matches!(
396 run.status,
397 crate::run::RunStatus::Failed
398 | crate::run::RunStatus::Blocked
399 | crate::run::RunStatus::Stalled
400 )
401 })
402 .map(|run| {
403 format!(
404 "run:{id}:{:?}:{:?}",
405 run.status,
406 run.passes.last().map(|p| p.index)
407 )
408 })
409 } else {
410 None
411 };
412 version.filter(|v| !r.blocking_evidence.contains(v))
413 })
414 .collect()
415}
416
417fn may_hold(task: &mut Task, reason: &str) -> bool {
421 if pinned_resume(task) {
422 return false;
423 }
424 if task.resumption.is_some() {
425 return true; }
427 let Some(o) = task.resume_override.as_mut() else {
428 return true;
429 };
430 if o.forced || o.pinned_run.is_some() {
431 tracing::warn!(
432 "conductor tried to hold task {} after the operator forced a resume: {reason}",
433 task.id
434 );
435 return false;
436 }
437 if o.conductor_rehold.is_some() {
438 return false;
439 }
440 o.conductor_rehold = Some(reason.to_owned());
441 true
442}
443
444fn severity_str(s: crate::verdict::Severity) -> &'static str {
447 match s {
448 crate::verdict::Severity::Nit => "nit",
449 crate::verdict::Severity::Minor => "minor",
450 crate::verdict::Severity::Major => "major",
451 crate::verdict::Severity::Blocker => "blocker",
452 }
453}
454
455fn surviving_branch(task: &Task) -> Option<String> {
460 let last = task.runs.last()?;
461 let state = RunState::load(last).ok()?;
462 if !crate::daemon::compatible_run_instruction(&state, task) {
463 return None;
464 }
465 state.winner().map(|c| c.branch.clone())
466}
467
468fn reaffirmed_hold_reason(task: &Task, d: &Decision) -> Option<String> {
495 let note = match &d.reason {
496 Some(reason) => reason.clone(),
497 None => match task.answers.last() {
498 Some(a) => format!(
499 "conduct held this again with no new reason given; last operator \
500 answer on record: {}",
501 a.answer
502 ),
503 None => "conduct held this again with no reason given".to_owned(),
504 },
505 };
506 match task.hold_reason.as_deref() {
507 Some(prior) if !prior.is_empty() => {
508 let outer = outermost_hold_note(prior);
509 if note.trim() == prior.trim() || note.trim() == outer {
510 None
511 } else {
512 Some(format!("{note}\n\n(previously: {outer})"))
513 }
514 }
515 _ => Some(note),
516 }
517}
518
519const PREVIOUSLY: &str = "\n\n(previously: ";
522
523fn outermost_hold_note(reason: &str) -> &str {
526 reason.split(PREVIOUSLY).next().unwrap_or(reason).trim()
527}
528
529fn hold_note(d: &Decision) -> String {
531 d.reason
532 .clone()
533 .unwrap_or_else(|| "(no reason given)".to_owned())
534}
535
536async fn outcome_for(task: &Task, repo: &Path) -> prompt::ConductOutcome {
538 let Some(run_id) = task.runs.last().cloned() else {
539 return prompt::ConductOutcome {
540 run_id: "(none)".to_owned(),
541 unreadable: Some("this task has not produced a run yet".to_owned()),
542 run_status: None,
543 open_findings: Vec::new(),
544 rounds_used: 0,
545 rounds_max: 0,
546 rounds: Vec::new(),
547 branch: None,
548 branch_head: None,
549 references: None,
550 empty_candidate: false,
551 };
552 };
553 let state = match RunState::load(&run_id) {
554 Ok(s) => s,
555 Err(e) => {
556 tracing::warn!(
561 "conductor: could not read run {run_id} for task {}: {e:#}",
562 task.short()
563 );
564 return prompt::ConductOutcome {
565 run_id,
566 unreadable: Some(format!("{e:#}")),
567 run_status: None,
568 open_findings: Vec::new(),
569 rounds_used: 0,
570 rounds_max: 0,
571 rounds: Vec::new(),
572 branch: None,
573 branch_head: None,
574 references: None,
575 empty_candidate: false,
576 };
577 }
578 };
579
580 let finding_view = |f: &crate::verdict::Finding| prompt::ConductFinding {
581 id: f.id.clone(),
582 title: f.title.clone(),
583 severity: severity_str(f.severity).to_owned(),
584 };
585 let open_findings = state
586 .open_findings()
587 .into_iter()
588 .map(finding_view)
589 .collect();
590 let rounds = state
591 .reviews
592 .iter()
593 .map(|r| prompt::ConductRound {
594 round: r.round,
595 findings: r
596 .reviews
597 .iter()
598 .flat_map(|rec| rec.findings.iter())
599 .map(finding_view)
600 .collect(),
601 addressed: r
602 .fix
603 .as_ref()
604 .map(|fx| fx.addressed.clone())
605 .unwrap_or_default(),
606 rejected: r
607 .fix
608 .as_ref()
609 .map(|fx| {
610 fx.rejected
611 .iter()
612 .map(|rej| prompt::ConductRejection {
613 id: rej.id.clone(),
614 why: rej.why.clone(),
615 })
616 .collect()
617 })
618 .unwrap_or_default(),
619 })
620 .collect();
621 let branch = state.winner().map(|c| c.branch.clone());
622 let branch_head = match &branch {
623 Some(b) => crate::git::rev_parse(repo, b)
624 .await
625 .ok()
626 .map(|h| h.chars().take(8).collect()),
627 None => None,
628 };
629
630 prompt::ConductOutcome {
631 run_id,
632 unreadable: None,
633 run_status: Some(state.status.as_str().to_owned()),
634 open_findings,
635 rounds_used: state.reviews.len(),
636 rounds_max: state.config.graph.review_rounds,
637 rounds,
638 branch,
639 branch_head,
640 references: crate::refs::describe(&state.seeds),
641 empty_candidate: state.merge.as_ref().is_some_and(|m| m.empty),
642 }
643}
644
645async fn open_pr_fact(task: &Task, repo: &Path) -> Option<String> {
649 let id = task.runs.last()?;
650 let state = match RunState::load(id) {
651 Ok(s) => s,
652 Err(e) => return Some(format!("could not check open pull requests: {e:#}")),
653 };
654 let branch = state.winner()?.branch.clone();
655 let base = state.base_branch.clone();
656 match crate::land::find_open_pr(repo, &branch, &base).await {
657 Ok(crate::land::OpenPr::None) => None,
658 Ok(crate::land::OpenPr::One { url, .. }) => Some(format!(
659 "pull request {url} is already open for {branch} into {base}"
660 )),
661 Ok(crate::land::OpenPr::Many(urls)) => Some(format!(
662 "several pull requests are already open for {branch} into {base}: {}",
663 urls.join(" ")
664 )),
665 Err(e) => Some(format!("could not check open pull requests: {e:#}")),
666 }
667}
668
669async fn attach_facts(cfg: &Config, repo: &Path, queue: &Queue, verdict: &mut Verdict) {
676 for d in verdict
677 .decisions
678 .iter_mut()
679 .filter(|d| d.question.is_some())
680 {
681 let Ok(task) = queue.get(&d.id) else {
682 continue;
683 };
684 let text = format!("{}\n{}", task.title, task.instruction);
685 let pr_fact = open_pr_fact(&task, repo_for(&task, repo).as_path()).await;
686 if crate::refs::scan(&text).is_empty() {
687 if let (Some(fact), Some(q)) = (pr_fact, d.question.as_mut()) {
688 q.push_str(&format!(
689 "\n\nChecked against the repository (magi did this, not the model):\n{fact}"
690 ));
691 }
692 continue;
693 }
694 let repo = repo_for(&task, repo);
695 let remote = &cfg.merge.remote;
696 let base = crate::git::merge_base_branch(&repo, remote, cfg.merge.base.as_deref())
697 .await
698 .ok();
699 let facts = match base {
700 Some(base) => {
701 let base_name = base.clone();
702 let tracking = format!("{remote}/{base}");
703 let refreshed = crate::git::fetch(&repo, remote, &base)
706 .await
707 .is_ok_and(|o| o.ok());
708 let against = tracking;
711 match crate::git::rev_parse(&repo, &against).await {
712 Ok(tip) => crate::refs::describe(
713 &crate::refs::resolve(&repo, &tip, remote, &text).await,
714 )
715 .map(|facts| {
716 if refreshed {
717 facts
718 } else {
719 format!(
720 "{facts}\n(could not fetch {remote}/{base_name}: this is \
721 against the last fetched `{against}`, which may be behind \
722 the remote)"
723 )
724 }
725 }),
726 Err(_) => Some(format!(
727 "could NOT be checked against the repository: {against} is not \
728 available here, so whether the work is in the base is unknown"
729 )),
730 }
731 }
732 None => Some("could not check the repository: no base branch known".to_owned()),
733 };
734 let facts = match (facts, pr_fact) {
735 (Some(f), Some(p)) => Some(format!("{f}\n{p}")),
736 (f, p) => f.or(p),
737 };
738 if let (Some(facts), Some(q)) = (facts, d.question.as_mut()) {
739 q.push_str(&format!(
740 "\n\nChecked against the repository (magi did this, not the model):\n{facts}"
741 ));
742 }
743 }
744}
745
746async fn finished_view(t: &Task, repo: &Path, max_attempts: usize) -> prompt::ConductFinished {
748 prompt::ConductFinished {
749 task: view(t, max_attempts),
750 outcome: outcome_for(t, &repo_for(t, repo)).await,
751 }
752}
753
754fn repo_for(task: &Task, fallback: &Path) -> PathBuf {
757 if task.repo.as_os_str().is_empty() || task.repo == Path::new(".") {
758 fallback.to_path_buf()
759 } else {
760 task.repo.clone()
761 }
762}
763
764fn apply_one(
781 queue: &Queue,
782 questions: &Questions,
783 d: &Decision,
784 snapshot: Option<&Task>,
785) -> Result<()> {
786 let _claim = queue
787 .claim(&d.id)
788 .with_context(|| format!("task {} is claimed elsewhere right now", d.id))?;
789 let mut task = queue.get(&d.id).context("no such task")?;
790
791 if snapshot.is_some_and(|old| decision_input(old) != decision_input(&task)) {
794 return Ok(());
795 }
796 let blocking =
797 d.question.is_some() || !d.blocked_by.is_empty() || d.recovery == Some(Recovery::Hold);
798 let evidence = new_blocking_evidence(queue, questions, &task, d);
799 if blocking && task.resumption.is_some() && evidence.is_empty() {
800 crate::notices::raise(crate::notices::conductor_resumption(
801 &task.id,
802 task.short(),
803 d.reason.as_deref(),
804 &d.blocked_by,
805 &d.evidence,
806 d.question.is_some(),
807 ));
808 return Ok(());
809 }
810
811 if blocking && !evidence.is_empty() {
812 let mut cause = evidence.clone();
813 cause.sort();
814 cause.dedup();
815 task.notice_cause(
816 &format!("conductor-evidence:{}", cause.join(",")),
817 None,
818 None,
819 );
820 }
821 if blocking {
822 if let Some(r) = task.resumption.as_mut() {
823 r.blocking_evidence.extend(evidence);
824 }
825 }
826
827 if task.operator_held() {
831 return Ok(());
832 }
833
834 if task.status == TaskStatus::Held && crate::triage::pending_for(questions, &task) {
838 return Ok(());
839 }
840
841 let pinned = pinned_resume(&task);
844 if pinned && (d.question.is_some() || !d.blocked_by.is_empty()) {
845 return Ok(());
846 }
847
848 if matches!(task.status, TaskStatus::Queued | TaskStatus::Held)
852 && !d.missing_dependencies.is_empty()
853 && (d.question.is_some() || d.recovery == Some(Recovery::Hold))
854 && d.missing_dependencies
855 .iter()
856 .any(|id| !d.blocked_by.contains(id))
857 && d.missing_dependencies
858 .iter()
859 .chain(&d.blocked_by)
860 .any(|id| {
861 matches!(
862 crate::queue::dependency(queue, questions, id),
863 crate::queue::Dependency::Ambiguous | crate::queue::Dependency::Unknown
864 )
865 })
866 {
867 return Ok(());
868 }
869
870 if matches!(task.status, TaskStatus::Queued | TaskStatus::Held)
876 && !d.missing_dependencies.is_empty()
877 && d.missing_dependencies.iter().all(|id| {
878 let claim = crate::queue::dependency(queue, questions, id);
879 claim != crate::queue::Dependency::Missing
880 && d.blocked_by.iter().any(|blocker| {
881 blocker == id
882 || (matches!(
883 &claim,
884 crate::queue::Dependency::Task(_)
885 | crate::queue::Dependency::Question(_)
886 | crate::queue::Dependency::Deleted(_)
887 ) && crate::queue::dependency(queue, questions, blocker) == claim)
888 })
889 })
890 && (d.question.is_some() || d.recovery == Some(Recovery::Hold))
891 && crate::queue::missing_blockers(queue, questions, &d.blocked_by).is_empty()
892 {
893 let mut ids = d.blocked_by.clone();
894 crate::queue::normalize_blockers(queue, questions, &mut ids);
895 let reason = format!("Waiting on dependencies: {}", ids.join(", "));
896 task.block(ids, Some(reason));
897 return queue.put(&mut task);
898 }
899
900 if let Some(text) = &d.question {
901 if task.status == TaskStatus::Done {
902 return Ok(());
903 }
904 let mut held = task.clone();
905 held.status = TaskStatus::Held;
906 let targets: BTreeSet<_> = d
907 .blocked_by
908 .iter()
909 .filter_map(|id| {
910 if let Ok(dep) = queue.get(id) {
911 Some(format!("task:{id}:{:?}", dep.chat_notice_key()))
912 } else if questions.get(id).is_ok() {
913 Some(format!("question:{id}"))
914 } else {
915 None
916 }
917 })
918 .collect();
919 let decision = d
920 .question_decision
921 .as_ref()
922 .and_then(|q| match q.identity(&task) {
923 Ok(identity) => Some(identity),
924 Err(error) => {
925 tracing::warn!(task = %task.id, %error, "ignoring invalid question decision target");
928 None
929 }
930 });
931 let context_value =
932 serde_json::json!([&task.instruction, held.chat_notice_key(), targets, decision]);
933 let context = serde_json::to_string(&context_value)?;
934 if questions.list().iter().any(|q| {
938 q.node == NODE
939 && q.run == task.id
940 && q.status == crate::ask::QuestionStatus::Answered
941 && q.decision_context.as_ref().is_some_and(|stored| {
942 if stored == &context {
943 return true;
944 }
945 decision.is_none()
948 && serde_json::from_str::<serde_json::Value>(stored)
949 .ok()
950 .and_then(|v| {
951 v.as_array().map(|items| {
952 items.len() == 4
953 && items[..3]
954 == context_value.as_array().expect("context")[..3]
955 })
956 })
957 == Some(true)
958 })
959 }) {
960 return Ok(());
961 }
962 let question_id = match questions
966 .list()
967 .into_iter()
968 .find(|q| q.status.open() && q.node == NODE && q.run == task.id)
969 {
970 Some(existing) => existing.id,
971 None => {
972 let mut q = Question::new(
973 task.id.clone(),
974 NODE.to_owned(),
975 SEAT.to_owned(),
976 text.clone(),
977 d.reason.clone().unwrap_or_default(),
978 d.choices.clone(),
979 );
980 q.decision_context = Some(context);
981 q.deputy = Some(Deputy::new(crate::deputy::brief(
986 &task.id,
987 d.reason.as_deref().unwrap_or_default(),
988 &q.choices,
989 &q.actions,
990 )));
991 if task.repo.is_dir() && task.repo != Path::new(".") {
992 q.cwd = Some(task.repo.to_string_lossy().into_owned());
993 }
994 questions.put(&mut q)?;
995 q.id
996 }
997 };
998 task.block(vec![question_id], d.reason.clone());
999 return queue.put(&mut task);
1000 }
1001
1002 match task.status {
1003 TaskStatus::Queued if d.recovery == Some(Recovery::Hold) => {
1007 if may_hold(&mut task, &hold_note(d)) {
1008 task.hold_machine(d.reason.clone());
1009 queue.put(&mut task)?;
1010 }
1011 }
1012 TaskStatus::Queued if !d.blocked_by.is_empty() => {
1013 let mut ids = d.blocked_by.clone();
1014 crate::queue::normalize_blockers(queue, questions, &mut ids);
1015 task.block(ids, d.reason.clone());
1016 queue.put(&mut task)?;
1017 }
1018 TaskStatus::Running => match d.recovery {
1019 Some(Recovery::Requeue) if pinned_resume(&task) => {}
1020 Some(Recovery::Requeue) => {
1021 task.requeue();
1022 queue.put(&mut task)?;
1023 }
1024 Some(Recovery::Hold) if may_hold(&mut task, &hold_note(d)) => {
1025 task.hold_machine(d.reason.clone());
1026 queue.put(&mut task)?;
1027 }
1028 _ => {}
1032 },
1033 TaskStatus::Failed | TaskStatus::Held => match d.recovery {
1034 Some(Recovery::Requeue) if pinned_resume(&task) => {}
1037 Some(Recovery::Requeue) => {
1038 task.requeue();
1039 queue.put(&mut task)?;
1040 }
1041 Some(Recovery::Hold) => {
1042 if let Some(reason) = reaffirmed_hold_reason(&task, d) {
1046 if may_hold(&mut task, &hold_note(d)) {
1047 task.hold_machine(Some(reason));
1048 queue.put(&mut task)?;
1049 }
1050 }
1051 }
1052 Some(Recovery::Review) => {
1053 if let Some(branch) = surviving_branch(&task) {
1054 task.request_review(branch);
1055 queue.put(&mut task)?;
1056 }
1057 }
1063 Some(Recovery::Done) => {
1064 task.succeed();
1065 crate::daemon::supersede_prior_runs(&task, &crate::run::home());
1069 queue.put(&mut task)?;
1070 }
1071 None => {}
1072 },
1073 _ => {}
1076 }
1077 Ok(())
1078}
1079
1080pub fn apply(queue: &Queue, questions: &Questions, verdict: &Verdict) -> Result<()> {
1084 for d in &verdict.decisions {
1085 if let Err(e) = apply_one(queue, questions, d, None) {
1086 tracing::warn!("conductor decision for task {}: {e:#}", d.id);
1087 }
1088 }
1089 Ok(())
1090}
1091
1092pub fn seat_path(home: &Path) -> PathBuf {
1098 home.join("conduct").join("seat.json")
1099}
1100
1101pub fn load_seat(home: &Path) -> Option<SeatState> {
1103 serde_json::from_str(&std::fs::read_to_string(seat_path(home)).ok()?).ok()
1104}
1105
1106fn busy_path(home: &Path) -> PathBuf {
1107 home.join("conduct").join("busy")
1108}
1109
1110pub fn busy(home: &Path) -> bool {
1116 std::fs::metadata(busy_path(home))
1117 .and_then(|m| m.modified())
1118 .ok()
1119 .and_then(|t| t.elapsed().ok())
1120 .is_some_and(|age| age < TURN_TIMEOUT + Duration::from_secs(30))
1121}
1122
1123struct Busy(PathBuf);
1125
1126impl Busy {
1127 fn mark(home: &Path) -> Self {
1128 let path = busy_path(home);
1129 if let Some(dir) = path.parent() {
1130 let _ = std::fs::create_dir_all(dir);
1131 }
1132 let _ = std::fs::write(&path, std::process::id().to_string());
1133 Self(path)
1134 }
1135}
1136
1137impl Drop for Busy {
1138 fn drop(&mut self) {
1139 let _ = std::fs::remove_file(&self.0);
1140 }
1141}
1142
1143#[derive(Debug, Default)]
1146pub struct Conductor {
1147 seat: Option<SeatState>,
1148 last_seen: Option<(u64, BTreeSet<String>)>,
1149 considered: BTreeMap<String, String>,
1153}
1154
1155fn decision_input(task: &Task) -> serde_json::Value {
1160 let mut v = serde_json::to_value(task).unwrap_or_default();
1161 if let Some(o) = v.as_object_mut() {
1162 o.remove("chat_notice");
1163 o.remove("chat_notices");
1164 o.remove("updated_at");
1166 }
1167 v
1168}
1169
1170fn input_fingerprint(task: &Task) -> String {
1171 let mut v = decision_input(task);
1172 if let Some(o) = v.as_object_mut() {
1173 o.remove("hold_reason");
1174 if let Some(ro) = o.get_mut("resume_override").and_then(|r| r.as_object_mut()) {
1175 ro.remove("conductor_rehold");
1176 }
1177 }
1178 v.to_string()
1179}
1180
1181impl Conductor {
1182 #[must_use]
1184 pub fn new() -> Self {
1185 Self::default()
1186 }
1187
1188 fn settled(
1193 considered: &BTreeMap<String, String>,
1194 stalled: &[Task],
1195 finished: &[Task],
1196 ) -> BTreeSet<String> {
1197 stalled
1198 .iter()
1199 .chain(finished)
1200 .filter(|t| t.status == TaskStatus::Held)
1201 .filter(|t| considered.get(&t.id) == Some(&input_fingerprint(t)))
1202 .map(|t| t.id.clone())
1203 .collect()
1204 }
1205
1206 fn snapshot_with(
1207 considered: &BTreeMap<String, String>,
1208 queue: &Queue,
1209 stalled: &[Task],
1210 finished: &[Task],
1211 ) -> (u64, BTreeSet<String>) {
1212 let ids = stalled
1213 .iter()
1214 .chain(finished)
1215 .map(|t| t.id.clone())
1216 .collect();
1217 let skip = Self::settled(considered, stalled, finished);
1218 (queue.revision_excluding(&skip), ids)
1219 }
1220
1221 fn snapshot(
1222 &self,
1223 queue: &Queue,
1224 stalled: &[Task],
1225 finished: &[Task],
1226 ) -> (u64, BTreeSet<String>) {
1227 Self::snapshot_with(&self.considered, queue, stalled, finished)
1228 }
1229
1230 #[must_use]
1247 pub fn worth_a_look(&self, queue: &Queue, stalled: &[Task], finished: &[Task]) -> bool {
1248 self.last_seen.as_ref() != Some(&self.snapshot(queue, stalled, finished))
1249 }
1250
1251 #[allow(clippy::too_many_arguments)]
1255 pub async fn maybe_run(
1256 &mut self,
1257 cfg: &Config,
1258 repo: &Path,
1259 queue: &Queue,
1260 questions: &Questions,
1261 home: &Path,
1262 queued: &[Task],
1263 stalled: &[Task],
1264 finished: &[Task],
1265 max_attempts: usize,
1266 ) {
1267 if self.last_seen.as_ref() == Some(&self.snapshot(queue, stalled, finished)) {
1268 return;
1269 }
1270 self.considered = stalled
1273 .iter()
1274 .chain(finished)
1275 .filter(|t| t.status == TaskStatus::Held)
1276 .map(|t| (t.id.clone(), input_fingerprint(t)))
1277 .collect();
1278 self.last_seen = Some(self.snapshot(queue, stalled, finished));
1279 if let Err(e) = self
1280 .run_once(
1281 cfg,
1282 repo,
1283 queue,
1284 questions,
1285 home,
1286 queued,
1287 stalled,
1288 finished,
1289 max_attempts,
1290 )
1291 .await
1292 {
1293 tracing::warn!("conductor: {e:#}");
1299 }
1300 }
1301
1302 #[allow(clippy::too_many_arguments)]
1303 async fn run_once(
1304 &mut self,
1305 cfg: &Config,
1306 repo: &Path,
1307 queue: &Queue,
1308 questions: &Questions,
1309 home: &Path,
1310 queued: &[Task],
1311 stalled: &[Task],
1312 finished: &[Task],
1313 max_attempts: usize,
1314 ) -> Result<()> {
1315 if queued.is_empty() && stalled.is_empty() && finished.is_empty() {
1316 return Ok(());
1317 }
1318
1319 let primary = cfg
1320 .resolve_roles()
1321 .context("resolving the conductor seat")?
1322 .conductor;
1323 let chain = match cfg.roles.conductor.as_ref() {
1327 Some(c) if c.ids().len() > 1 => {
1328 agent::pick_chain(&cfg.agents, Some(c), &agent::installed, "conductor")?
1329 }
1330 _ => vec![primary],
1331 };
1332
1333 let runnable_views: Vec<prompt::ConductTask> =
1334 queued.iter().map(|t| view(t, max_attempts)).collect();
1335 let stalled_views: Vec<prompt::ConductTask> =
1336 stalled.iter().map(|t| view(t, max_attempts)).collect();
1337 let mut finished_views = Vec::with_capacity(finished.len());
1338 for t in finished {
1339 finished_views.push(finished_view(t, repo, max_attempts).await);
1340 }
1341
1342 let body = prompt::with_overlay(
1343 prompt::conduct(
1344 &runnable_views,
1345 &stalled_views,
1346 &finished_views,
1347 &cfg.graph.language,
1348 ),
1349 cfg.prompts.overlay(NODE),
1350 );
1351
1352 let body = format!(
1353 "{body}\n\n{}",
1354 crate::queue::dependency_facts(queue, questions)
1355 );
1356 let artifacts = home.join("conduct").join("artifacts");
1357 let cache_dir = cfg.cache_dir();
1360
1361 let busy = Busy::mark(home);
1363 let mut result: Result<Verdict> = Err(anyhow::anyhow!("no conductor agent ran"));
1364 for spec in &chain {
1365 let needs_new_seat = !matches!(&self.seat, Some(s) if s.agent == spec.id);
1368 if needs_new_seat {
1369 self.seat = Some(SeatState::new(SEAT, &spec.id, crate::rng::entropy()));
1370 }
1371 let seat = self.seat.as_mut().expect("just ensured a seat exists");
1372 let stem = if std::ptr::eq(spec, &chain[0]) {
1375 format!("turn-{}", seat.turns + 1)
1376 } else {
1377 format!("turn-{}-{}", seat.turns + 1, spec.id)
1378 };
1379 let inv = Invocation {
1380 coordination: cfg.coordination.as_ref(),
1381 cwd: repo,
1382 prompt: &body,
1383 timeout: TURN_TIMEOUT,
1384 allow_write: false,
1387 unsandboxed: false,
1388 sessions: cfg.graph.sessions,
1389 artifacts: &artifacts,
1390 stem: &stem,
1391 run: NODE,
1392 node: NODE,
1393 cache_dir: cache_dir.as_deref(),
1394 attachments: &[],
1395 writable: &[],
1396 };
1397 let out = agent::invoke(spec, seat, &inv).await;
1398 if let Ok(body) = serde_json::to_string(&*seat) {
1401 let path = seat_path(home);
1402 if let Some(dir) = path.parent() {
1403 let _ = std::fs::create_dir_all(dir);
1404 }
1405 let _ = std::fs::write(path, body);
1406 }
1407 let advance = agent::chain_advances(&out);
1408 result = match out {
1409 Err(e) => Err(e.context("invoking the conductor")),
1410 Ok(out) if advance => Err(anyhow::anyhow!(
1411 "no usable reply (exit {:?}, timed out {})",
1412 out.exit_code,
1413 out.timed_out
1414 )),
1415 Ok(out) => verdict::extract_json(&out.text)
1416 .context("the conductor's reply could not be parsed"),
1417 };
1418 if result.is_ok() {
1419 break;
1420 }
1421 if chain.len() > 1 {
1422 tracing::warn!("conductor: `{}` failed, trying the next agent", spec.id);
1423 }
1424 }
1425 drop(busy);
1426 let mut verdict = result?;
1427 attach_facts(cfg, repo, queue, &mut verdict).await;
1428 for d in &verdict.decisions {
1429 let snapshot = queued
1430 .iter()
1431 .chain(stalled)
1432 .chain(finished)
1433 .find(|t| t.id == d.id);
1434 if let Some(snapshot) = snapshot {
1435 if let Err(e) = apply_one(queue, questions, d, Some(snapshot)) {
1436 tracing::warn!("conductor decision for task {}: {e:#}", d.id);
1437 }
1438 }
1439 }
1440 Ok(())
1441 }
1442}
1443
1444#[cfg(test)]
1445mod tests {
1446 use std::collections::BTreeMap;
1447
1448 use tempfile::tempdir;
1449
1450 use super::*;
1451 use crate::ask::{Answer, QuestionStatus};
1452 use crate::config::{AgentKind, AgentSpec, Graph};
1453 use crate::queue::Source;
1454
1455 fn mock_agent(dir: &Path, script: &str, env: BTreeMap<String, String>) -> AgentSpec {
1456 let path = dir.join("mock-conduct-agent.sh");
1457 std::fs::write(&path, script).expect("write mock");
1458 AgentSpec {
1459 id: "mock".to_owned(),
1460 kind: AgentKind::Command,
1461 model: None,
1462 command: vec!["sh".to_owned(), path.to_string_lossy().into_owned()],
1463 extra_args: Vec::new(),
1464 env,
1465 prompt_delivery: None,
1466 }
1467 }
1468
1469 fn config(spec: AgentSpec) -> Config {
1470 Config {
1471 agents: vec![spec],
1472 graph: Graph {
1473 language: "en".to_owned(),
1474 ..Graph::default()
1475 },
1476 ..Config::default()
1477 }
1478 }
1479
1480 fn task(title: &str) -> Task {
1481 Task::new(
1482 title.to_owned(),
1483 format!("do {title}"),
1484 std::path::PathBuf::from("."),
1485 Source::Human,
1486 )
1487 }
1488
1489 #[test]
1490 fn delivery_bookkeeping_does_not_restart_conductor_deliberation() {
1491 let mut t = task("waiting");
1492 t.hold_machine(Some("waiting for recovery".into()));
1493 let before = input_fingerprint(&t);
1494 let snapshot = decision_input(&t);
1495 t.chat_notice = Some(crate::queue::ChatNotice {
1496 key: t.chat_notice_key().unwrap(),
1497 at: jiff::Timestamp::now(),
1498 outcome: crate::queue::ChatNoticeOutcome::Posted,
1499 });
1500 t.chat_notices.push(t.chat_notice_key().unwrap());
1501 t.updated_at = jiff::Timestamp::now();
1502 assert_eq!(before, input_fingerprint(&t));
1503 assert_eq!(snapshot, decision_input(&t));
1504 t.hold_reason = Some("recovery remains under consideration".into());
1505 assert_eq!(before, input_fingerprint(&t));
1506 t.record_answer("a different question".into(), "a new decision".into());
1507 assert_ne!(before, input_fingerprint(&t));
1508 }
1509
1510 const BROKEN: &str = "#!/bin/sh\ncat >/dev/null\nexit 3\n";
1511 const GARBAGE: &str = "#!/bin/sh\ncat >/dev/null\nprintf 'not json at all\\n'\n";
1512
1513 fn env(reply: &str) -> BTreeMap<String, String> {
1514 BTreeMap::from([("MOCK_REPLY".to_owned(), reply.to_owned())])
1515 }
1516
1517 const REPLY: &str = "#!/bin/sh\ncat >/dev/null\nprintf '%s\\n' \"$MOCK_REPLY\"\n";
1518
1519 fn init_repo_with_branch(dir: &Path, branch: &str) {
1523 use crate::proc::Quiet as _;
1524 let run = |args: &[&str]| {
1525 let out = std::process::Command::new("git")
1526 .args(args)
1527 .current_dir(dir)
1528 .quiet()
1529 .output()
1530 .expect("spawn git");
1531 assert!(
1532 out.status.success(),
1533 "git {args:?} failed: {}",
1534 String::from_utf8_lossy(&out.stderr)
1535 );
1536 };
1537 run(&["init", "-b", "main"]);
1538 run(&["config", "user.name", "magi test"]);
1539 run(&["config", "user.email", "magi@example.com"]);
1540 std::fs::write(dir.join("README.md"), "# fixture\n").unwrap();
1541 run(&["add", "-A"]);
1542 run(&["commit", "-m", "init"]);
1543 run(&["checkout", "-b", branch]);
1544 std::fs::write(dir.join("change.txt"), "x\n").unwrap();
1545 run(&["add", "-A"]);
1546 run(&["commit", "-m", "candidate work"]);
1547 }
1548
1549 fn review_round_with_finding(
1550 round: usize,
1551 finding_id: &str,
1552 title: &str,
1553 addressed: &[&str],
1554 rejected: &[(&str, &str)],
1555 ) -> crate::run::ReviewRound {
1556 crate::run::ReviewRound {
1557 round,
1558 head: "deadbeef".to_owned(),
1559 verified_head: None,
1560 verified_at: None,
1561 reviews: vec![crate::run::ReviewRecord {
1562 attempts: 0,
1563 reviewer: 1,
1564 agent: "mock".to_owned(),
1565 summary: String::new(),
1566 findings: vec![crate::verdict::Finding {
1567 id: finding_id.to_owned(),
1568 severity: crate::verdict::Severity::Major,
1569 file: None,
1570 line: None,
1571 title: title.to_owned(),
1572 detail: String::new(),
1573 }],
1574 vote: None,
1575 failed: None,
1576 duration_ms: 0,
1577 }],
1578 e2e: Vec::new(),
1579 verify_retried: false,
1580 e2e_deferred: false,
1581 e2e_defer_reason: None,
1582 fix: Some(crate::run::FixRecord {
1583 agent: "mock".to_owned(),
1584 addressed: addressed.iter().map(|s| (*s).to_owned()).collect(),
1585 rejected: rejected
1586 .iter()
1587 .map(|(id, why)| crate::verdict::Rejection {
1588 id: (*id).to_owned(),
1589 why: (*why).to_owned(),
1590 })
1591 .collect(),
1592 notes: String::new(),
1593 committed: false,
1594 failed: None,
1595 duration_ms: 0,
1596 continuation: None,
1597 }),
1598 blocking: 1,
1599 answered: 1,
1600 expected: 1,
1601 clean: false,
1602 progressed: true,
1603 vote_split: false,
1604 reconsideration: Vec::new(),
1605 verdict: None,
1606 }
1607 }
1608
1609 #[test]
1610 fn explicit_resumption_supersedes_history_and_rejects_stale_decisions() {
1611 crate::run::pin_test_home();
1612 let dir = tempdir().unwrap();
1613 let queue = Queue::at(dir.path().join("queue"));
1614 let questions = Questions::at(dir.path().join("questions"));
1615 let mut t = task("recover");
1616 t.answers.push(crate::queue::AnsweredQuestion {
1617 question: "Inspect remote first?".into(),
1618 answer: "Inspect, then await resumption instructions".into(),
1619 });
1620 t.hold_machine(Some("waiting for remote inspection".into()));
1621 queue.put(&mut t).unwrap();
1622 let snapshot = queue.get(&t.id).unwrap();
1623 t.edit(
1624 "recover".into(),
1625 "Reuse the measured PR head; fix Clippy; review".into(),
1626 )
1627 .unwrap();
1628 t.release_operator("cli", None);
1629 queue.put(&mut t).unwrap();
1630 let approval = t.resumption.clone();
1631 let d = Decision {
1632 id: t.id.clone(),
1633 recovery: Some(Recovery::Hold),
1634 reason: Some("remote inspection still required".into()),
1635 ..Decision::default()
1636 };
1637 apply_one(&queue, &questions, &d, Some(&snapshot)).unwrap();
1638 let stale_question = Decision {
1639 question: Some("Inspect remote?".into()),
1640 ..d.clone()
1641 };
1642 apply_one(&queue, &questions, &stale_question, Some(&snapshot)).unwrap();
1643 assert!(questions.list().is_empty());
1644 for reason in [
1645 "waiting for remote inspection",
1646 "inspection has not been confirmed",
1647 ] {
1648 let repeated = Decision {
1649 reason: Some(reason.into()),
1650 ..d.clone()
1651 };
1652 apply(
1653 &queue,
1654 &questions,
1655 &Verdict {
1656 decisions: vec![repeated],
1657 },
1658 )
1659 .unwrap();
1660 assert_eq!(queue.get(&t.id).unwrap().status, TaskStatus::Queued);
1661 }
1662 let reloaded = Queue::at(dir.path().join("queue")).get(&t.id).unwrap();
1663 assert_eq!(reloaded.resumption, approval);
1664 assert_eq!(reloaded.answers, t.answers);
1665 let v = view(&reloaded, 3);
1666 assert!(v.operator_resume.unwrap().contains("superseded"));
1667 let mut dep = task("new prerequisite");
1668 dep.hold_machine(Some("new authentication failure".into()));
1669 queue.put(&mut dep).unwrap();
1670 let new_blocker = Decision {
1671 blocked_by: vec![dep.id.clone()],
1672 evidence: vec![dep.id.clone()],
1673 recovery: None,
1675 reason: Some("new prerequisite failed".into()),
1676 ..d.clone()
1677 };
1678 apply(
1679 &queue,
1680 &questions,
1681 &Verdict {
1682 decisions: vec![new_blocker.clone()],
1683 },
1684 )
1685 .unwrap();
1686 let mut after = queue.get(&t.id).unwrap();
1687 assert_eq!(after.status, TaskStatus::Blocked);
1688 after.release();
1691 queue.put(&mut after).unwrap();
1692 apply(
1693 &queue,
1694 &questions,
1695 &Verdict {
1696 decisions: vec![new_blocker],
1697 },
1698 )
1699 .unwrap();
1700 assert_eq!(queue.get(&t.id).unwrap().status, TaskStatus::Queued);
1701 after.start("new-run".into());
1702 assert_eq!(after.resumption.as_ref().unwrap().generation, 1);
1703 after.hold_manual(Some("operator says wait".into()));
1704 queue.put(&mut after).unwrap();
1705 apply(&queue, &questions, &Verdict { decisions: vec![d] }).unwrap();
1706 assert!(queue.get(&t.id).unwrap().operator_held());
1707 }
1708
1709 #[test]
1710 fn newly_blocked_dependency_is_material_evidence_after_approval() {
1711 let dir = tempdir().unwrap();
1712 let queue = Queue::at(dir.path().join("queue"));
1713 let questions = Questions::at(dir.path().join("questions"));
1714 let mut dep = task("old queued dependency");
1715 queue.put(&mut dep).unwrap();
1716 let mut t = task("approved task");
1717 t.release_operator("test", None);
1718 queue.put(&mut t).unwrap();
1719 let mut q = Question::new(
1720 dep.id.clone(),
1721 "check".into(),
1722 "check".into(),
1723 "New dependency approval".into(),
1724 String::new(),
1725 Vec::new(),
1726 );
1727 questions.put(&mut q).unwrap();
1728 dep.block(vec![q.id.clone()], Some("new decision needed".into()));
1729 let changed = dep.blocked_at;
1730 queue.put(&mut dep).unwrap();
1731 dep.block(vec![q.id], Some("same decision paraphrased".into()));
1732 assert_eq!(
1733 dep.blocked_at, changed,
1734 "prose is not new blocking evidence"
1735 );
1736 queue.put(&mut dep).unwrap();
1737 let d = Decision {
1738 id: t.id.clone(),
1739 blocked_by: vec![dep.id.clone()],
1740 evidence: vec![dep.id],
1741 ..Decision::default()
1742 };
1743 assert_eq!(new_blocking_evidence(&queue, &questions, &t, &d).len(), 1);
1744 apply_one(&queue, &questions, &d, None).unwrap();
1745 assert_eq!(queue.get(&t.id).unwrap().status, TaskStatus::Blocked);
1746 }
1747
1748 #[test]
1749 fn resumed_run_evidence_uses_approval_time_not_run_list_offsets() {
1750 crate::run::pin_test_home();
1751 let dir = tempdir().unwrap();
1752 let queue = Queue::at(dir.path().join("queue"));
1753 let questions = Questions::at(dir.path().join("questions"));
1754 let mut t = task("resume evidence");
1755 let mut run = RunState::new(
1756 t.repo.clone(),
1757 "main".into(),
1758 "base".into(),
1759 "work".into(),
1760 crate::config::Config::default(),
1761 );
1762 run.begin_pass();
1763 run.status = crate::run::RunStatus::Failed;
1764 run.save().unwrap();
1765 t.start(run.id.clone());
1766 t.runs.push(run.id.clone());
1768 t.release_operator("cli", None);
1769 let approval = t.resumption.as_ref().unwrap().at;
1770 let decision = Decision {
1771 evidence: vec![run.id.clone()],
1772 ..Decision::default()
1773 };
1774 assert!(new_blocking_evidence(&queue, &questions, &t, &decision).is_empty());
1775 t.start(run.id.clone());
1776 assert_eq!(t.runs.len(), 1);
1777 run.begin_pass();
1778 run.save().unwrap();
1779 assert!(run.passes.last().unwrap().started_at > approval);
1780 let evidence = new_blocking_evidence(&queue, &questions, &t, &decision);
1781 assert_eq!(evidence.len(), 1);
1782 t.resumption.as_mut().unwrap().blocking_evidence = evidence;
1783 assert!(new_blocking_evidence(&queue, &questions, &t, &decision).is_empty());
1784 run.begin_pass();
1785 run.save().unwrap();
1786 assert_eq!(
1787 new_blocking_evidence(&queue, &questions, &t, &decision).len(),
1788 1
1789 );
1790
1791 let mut q = Question::new(
1792 run.id.clone(),
1793 "review".into(),
1794 "check".into(),
1795 "New failure".into(),
1796 String::new(),
1797 Vec::new(),
1798 );
1799 questions.put(&mut q).unwrap();
1800 let decision = Decision {
1801 evidence: vec![q.id.clone()],
1802 ..Decision::default()
1803 };
1804 assert_eq!(
1805 new_blocking_evidence(&queue, &questions, &t, &decision).len(),
1806 1
1807 );
1808 q.asked_at = approval;
1809 questions.put(&mut q).unwrap();
1810 assert!(new_blocking_evidence(&queue, &questions, &t, &decision).is_empty());
1811 q.asked_at = jiff::Timestamp::now();
1812 q.run = "unrelated".into();
1813 questions.put(&mut q).unwrap();
1814 assert!(new_blocking_evidence(&queue, &questions, &t, &decision).is_empty());
1815
1816 let mut fresh = RunState::new(
1817 t.repo.clone(),
1818 "main".into(),
1819 "base".into(),
1820 "fresh work".into(),
1821 crate::config::Config::default(),
1822 );
1823 fresh.status = crate::run::RunStatus::Failed;
1824 fresh.save().unwrap();
1825 t.start(fresh.id.clone());
1826 let decision = Decision {
1827 evidence: vec![fresh.id.clone()],
1828 ..Decision::default()
1829 };
1830 assert_eq!(
1831 new_blocking_evidence(&queue, &questions, &t, &decision).len(),
1832 1
1833 );
1834 }
1835
1836 #[test]
1837 fn outcome_for_carries_every_rounds_findings_and_the_branch_head() {
1838 crate::run::pin_test_home();
1839 let dir = tempdir().unwrap();
1840 let default_repo = dir.path().join("default");
1841 let task_repo = dir.path().join("task");
1842 std::fs::create_dir_all(&default_repo).unwrap();
1843 std::fs::create_dir_all(&task_repo).unwrap();
1844 init_repo_with_branch(&default_repo, "other-branch");
1845 init_repo_with_branch(&task_repo, "magi/f00d/A");
1846
1847 let mut config = Config::default();
1848 config.graph.review_rounds = 6;
1849 let mut state = crate::run::RunState::new(
1850 task_repo.clone(),
1851 "main".to_owned(),
1852 "deadbeef".to_owned(),
1853 "task".to_owned(),
1854 config,
1855 );
1856 state.status = crate::run::RunStatus::Blocked;
1857 state.candidates.push(crate::run::Candidate {
1858 index: 0,
1859 label: 'A',
1860 agent: "mock".to_owned(),
1861 branch: "magi/f00d/A".to_owned(),
1862 worktree: task_repo.clone(),
1863 summary: String::new(),
1864 stat: String::new(),
1865 files: 1,
1866 commits: 1,
1867 empty: false,
1868 failed: None,
1869 verified_noop: None,
1870 duration_ms: 0,
1871 folded: false,
1872 });
1873 state.tally = Some(crate::run::Tally {
1874 first_choice: std::collections::BTreeMap::new(),
1875 borda: std::collections::BTreeMap::new(),
1876 winner: 'A',
1877 rankings: 0,
1878 unanimous_initial: false,
1879 deliberated: false,
1880 changed_votes: 0,
1881 unanimous_final: false,
1882 tie_break: None,
1883 judges: 0,
1884 present: 0,
1885 quorum: 0,
1886 met_quorum: true,
1887 uncontested: Some("solo".to_owned()),
1888 });
1889 state.reviews = vec![
1890 review_round_with_finding(
1891 1,
1892 "R1-1-2",
1893 "answer content is dropped",
1894 &[],
1895 &[("R1-1-2", "the id leaving blocked_by is enough")],
1896 ),
1897 review_round_with_finding(2, "R2-1-3", "answer content is still dropped", &[], &[]),
1898 ];
1899 state.save().unwrap();
1900
1901 let mut t = task("outcome test");
1902 t.repo = task_repo;
1903 t.runs.push(state.id.clone());
1904
1905 let finished = tokio_test_block_on(finished_view(&t, &default_repo, 2));
1906 let outcome = finished.outcome;
1907
1908 assert!(outcome.unreadable.is_none());
1909 assert_eq!(outcome.run_status.as_deref(), Some("blocked"));
1910 assert_eq!(outcome.rounds_used, 2);
1911 assert_eq!(outcome.rounds_max, 6);
1912 assert_eq!(outcome.rounds.len(), 2);
1913 assert_eq!(outcome.rounds[0].findings[0].id, "R1-1-2");
1914 assert_eq!(outcome.rounds[0].rejected[0].id, "R1-1-2");
1915 assert!(outcome.rounds[1].addressed.is_empty());
1916 assert!(outcome.rounds[1].rejected.is_empty());
1917 assert_eq!(outcome.branch.as_deref(), Some("magi/f00d/A"));
1918 assert!(
1919 outcome.branch_head.is_some(),
1920 "a real branch must resolve a head commit: {outcome:?}"
1921 );
1922 }
1923
1924 fn tokio_test_block_on<F: std::future::Future>(f: F) -> F::Output {
1928 tokio::runtime::Builder::new_current_thread()
1929 .enable_all()
1930 .build()
1931 .unwrap()
1932 .block_on(f)
1933 }
1934
1935 #[test]
1936 fn view_resolves_task_attempt_limit_before_daemon_default() {
1937 let mut t = task("budget");
1938 assert_eq!(view(&t, 7).max_attempts, 7);
1939 t.change_attempt_limit(std::num::NonZeroUsize::new(3))
1940 .unwrap();
1941 assert_eq!(view(&t, 7).max_attempts, 3);
1942 t.change_attempt_limit(None).unwrap();
1943 assert_eq!(view(&t, 7).max_attempts, 7);
1944 }
1945
1946 #[test]
1947 fn view_carries_a_tasks_recorded_answers_into_the_conductor_prompt_input() {
1948 let mut t = task("answered");
1949 t.record_answer("Which backend?".to_owned(), "SQLite".to_owned());
1950 let v = view(&t, 2);
1951 assert_eq!(v.answers.len(), 1);
1952 assert_eq!(v.answers[0].question, "Which backend?");
1953 assert_eq!(v.answers[0].answer, "SQLite");
1954 }
1955
1956 #[tokio::test]
1957 async fn dependency_resolution_rechecks_questions_in_the_production_conductor_path() {
1958 for held in [false, true] {
1959 for reference in ["ca5b", "20261013-193611-ca5b"] {
1960 for question in [false, true] {
1961 let dir = tempdir().unwrap();
1962 let queue = Queue::at(dir.path().join("queue"));
1963 let questions = Questions::at(dir.path().join("questions"));
1964 let mut dep = task("prerequisite");
1965 dep.id = "20261013-193611-ca5b".into();
1966 dep.start("existing-run".into());
1967 queue.put(&mut dep).unwrap();
1968 let mut waiting = task("waiting");
1969 if held {
1970 waiting.hold_machine(Some("dependency could not be located".into()));
1971 }
1972 queue.put(&mut waiting).unwrap();
1973 let missing_reference = if reference == "ca5b" {
1974 dep.id.as_str()
1975 } else {
1976 "ca5b"
1977 };
1978 let reply = serde_json::json!({"decisions": [{
1979 "id": waiting.id, "blocked_by": [reference],
1980 "missing_dependencies": [missing_reference],
1981 "question": question.then_some("ca5b is missing; where is its replacement?"),
1982 "recovery": (!question).then_some("hold"),
1983 "reason": "ca5b is missing"
1984 }]}).to_string();
1985 let cfg = config(mock_agent(dir.path(), REPLY, env(&reply)));
1986 let (queued, finished) = if held {
1987 (vec![], vec![waiting.clone()])
1988 } else {
1989 (vec![waiting.clone()], vec![])
1990 };
1991 Conductor::new()
1992 .run_once(
1993 &cfg,
1994 dir.path(),
1995 &queue,
1996 &questions,
1997 dir.path(),
1998 &queued,
1999 &[],
2000 &finished,
2001 2,
2002 )
2003 .await
2004 .unwrap();
2005 let after = queue.get(&waiting.id).unwrap();
2006 assert_eq!(after.status, TaskStatus::Blocked);
2007 assert_eq!(after.blocked_by, [dep.id]);
2008 assert_eq!(after.blocked_from, Some(waiting.status));
2009 assert!(!after.block_reason.unwrap().contains("missing"));
2010 assert!(questions.list().is_empty());
2011 assert_eq!(after.instruction, waiting.instruction);
2012 }
2013 }
2014 }
2015 }
2016
2017 #[tokio::test]
2018 async fn dependency_resolution_inconclusive_missing_only_decisions_keep_waiting() {
2019 for held in [false, true] {
2020 for unknown in [false, true] {
2021 for question in [false, true] {
2022 let dir = tempdir().unwrap();
2023 let queue = Queue::at(dir.path().join("queue"));
2024 let questions = Questions::at(dir.path().join("questions"));
2025 let mut dep = task("running prerequisite");
2026 dep.id = "20261013-193611-ca5b".into();
2027 dep.start("existing-run".into());
2028 queue.put(&mut dep).unwrap();
2029 let reference = if unknown {
2030 std::fs::write(questions.root(), "not a directory").unwrap();
2033 dep.id.clone()
2034 } else {
2035 let mut other = task("another prerequisite");
2036 other.id = "20261014-193611-ca5b".into();
2037 queue.put(&mut other).unwrap();
2038 "ca5b".to_owned()
2039 };
2040 assert_eq!(
2041 crate::queue::dependency(&queue, &questions, &reference),
2042 if unknown {
2043 crate::queue::Dependency::Unknown
2044 } else {
2045 crate::queue::Dependency::Ambiguous
2046 }
2047 );
2048 let mut waiting = task("waiting");
2049 if held {
2050 waiting.hold_machine(Some("dependency could not be located".into()));
2051 }
2052 queue.put(&mut waiting).unwrap();
2053 let reply = serde_json::json!({"decisions": [{
2054 "id": waiting.id, "blocked_by": [reference],
2055 "missing_dependencies": [reference],
2056 "question": question.then_some("ca5b is missing"),
2057 "recovery": (!question).then_some("hold"),
2058 "reason": "ca5b is missing"
2059 }]})
2060 .to_string();
2061 let cfg = config(mock_agent(dir.path(), REPLY, env(&reply)));
2062 let (queued, finished) = if held {
2063 (vec![], vec![waiting.clone()])
2064 } else {
2065 (vec![waiting.clone()], vec![])
2066 };
2067 Conductor::new()
2068 .run_once(
2069 &cfg,
2070 dir.path(),
2071 &queue,
2072 &questions,
2073 dir.path(),
2074 &queued,
2075 &[],
2076 &finished,
2077 2,
2078 )
2079 .await
2080 .unwrap();
2081 let after = queue.get(&waiting.id).unwrap();
2082 assert_eq!(after.status, TaskStatus::Blocked);
2083 assert_eq!(after.blocked_by, [reference]);
2084 assert_eq!(after.blocked_from, Some(waiting.status));
2085 assert!(!after.block_reason.unwrap().contains("missing"));
2086 assert_eq!(after.instruction, waiting.instruction);
2087 if unknown {
2088 assert!(questions.root().is_file());
2089 } else {
2090 assert!(questions.list().is_empty());
2091 }
2092 }
2093 }
2094 }
2095 }
2096
2097 #[tokio::test]
2098 async fn dependency_resolution_unverifiable_alias_decisions_are_discarded() {
2099 for held in [false, true] {
2100 for unknown in [false, true] {
2101 for question in [false, true] {
2102 for short_blocker in [false, true] {
2103 let dir = tempdir().unwrap();
2104 let queue = Queue::at(dir.path().join("queue"));
2105 let questions = Questions::at(dir.path().join("questions"));
2106 let mut dep = task("running prerequisite");
2107 dep.id = "20261013-193611-ca5b".into();
2108 dep.start("existing-run".into());
2109 queue.put(&mut dep).unwrap();
2110 if unknown {
2111 std::fs::write(questions.root(), "not a directory").unwrap();
2112 } else {
2113 let mut other = task("another prerequisite");
2114 other.id = "20261014-193611-ca5b".into();
2115 queue.put(&mut other).unwrap();
2116 }
2117 let (blocker, missing) = if short_blocker {
2118 ("ca5b", dep.id.as_str())
2119 } else {
2120 (dep.id.as_str(), "ca5b")
2121 };
2122 let mut waiting = task("waiting");
2123 if held {
2124 waiting.hold_machine(Some("dependency could not be located".into()));
2125 }
2126 queue.put(&mut waiting).unwrap();
2127 let reply = serde_json::json!({"decisions": [{
2128 "id": waiting.id, "blocked_by": [blocker],
2129 "missing_dependencies": [missing],
2130 "question": question.then_some("ca5b is missing"),
2131 "recovery": (!question).then_some("hold"),
2132 "reason": "ca5b is missing"
2133 }]})
2134 .to_string();
2135 let cfg = config(mock_agent(dir.path(), REPLY, env(&reply)));
2136 let (queued, finished) = if held {
2137 (vec![], vec![waiting.clone()])
2138 } else {
2139 (vec![waiting.clone()], vec![])
2140 };
2141 Conductor::new()
2142 .run_once(
2143 &cfg,
2144 dir.path(),
2145 &queue,
2146 &questions,
2147 dir.path(),
2148 &queued,
2149 &[],
2150 &finished,
2151 2,
2152 )
2153 .await
2154 .unwrap();
2155 assert_eq!(
2156 serde_json::to_value(queue.get(&waiting.id).unwrap()).unwrap(),
2157 serde_json::to_value(&waiting).unwrap(),
2158 "unverifiable aliases must leave the entire task unchanged"
2159 );
2160 if unknown {
2161 assert!(questions.root().is_file());
2162 } else {
2163 assert!(questions.list().is_empty());
2164 }
2165 }
2166 }
2167 }
2168 }
2169 }
2170
2171 #[tokio::test]
2172 async fn dependency_resolution_genuine_missing_reference_still_asks() {
2173 let dir = tempdir().unwrap();
2174 let queue = Queue::at(dir.path().join("queue"));
2175 let questions = Questions::at(dir.path().join("questions"));
2176 let mut waiting = task("missing prerequisite");
2177 queue.put(&mut waiting).unwrap();
2178 let reply = serde_json::json!({"decisions": [{
2179 "id": waiting.id, "blocked_by": ["not-present"],
2180 "missing_dependencies": ["not-present"],
2181 "question": "Which prerequisite replaces the missing task?"
2182 }]})
2183 .to_string();
2184 let cfg = config(mock_agent(dir.path(), REPLY, env(&reply)));
2185 Conductor::new()
2186 .run_once(
2187 &cfg,
2188 dir.path(),
2189 &queue,
2190 &questions,
2191 dir.path(),
2192 &[waiting.clone()],
2193 &[],
2194 &[],
2195 2,
2196 )
2197 .await
2198 .unwrap();
2199 let after = queue.get(&waiting.id).unwrap();
2200 assert_eq!(after.status, TaskStatus::Blocked);
2201 assert_eq!(
2202 questions.get(&after.blocked_by[0]).unwrap().summary,
2203 "Which prerequisite replaces the missing task?"
2204 );
2205 }
2206
2207 #[tokio::test]
2208 async fn dependency_resolution_keeps_decisions_during_unrelated_task_progress() {
2209 let dir = tempdir().unwrap();
2210 let queue = Queue::at(dir.path().join("queue"));
2211 let questions = Questions::at(dir.path().join("questions"));
2212 let mut dep = task("prerequisite");
2213 dep.id = "20261013-193611-ca5b".into();
2214 dep.start("existing-run".into());
2215 queue.put(&mut dep).unwrap();
2216 let mut unrelated = task("unrelated work");
2217 unrelated.start("other-run".into());
2218 queue.put(&mut unrelated).unwrap();
2219 std::fs::File::options()
2222 .write(true)
2223 .open(queue.path_of(&unrelated.id))
2224 .unwrap()
2225 .set_times(std::fs::FileTimes::new().set_modified(std::time::UNIX_EPOCH))
2226 .unwrap();
2227 unrelated.succeed();
2228 let mut waiting = task("waiting");
2229 let mut asking = task("needs a product decision");
2230 let mut holding = task("must stay held");
2231 queue.put(&mut waiting).unwrap();
2232 queue.put(&mut asking).unwrap();
2233 queue.put(&mut holding).unwrap();
2234 let reply = serde_json::json!({"decisions": [
2235 {"id": waiting.id, "blocked_by": ["ca5b"], "reason": "wait for implementation"},
2236 {"id": asking.id, "question": "Which behavior do you want?"},
2237 {"id": holding.id, "recovery": "hold", "reason": "already implemented elsewhere"}
2238 ]})
2239 .to_string();
2240 let mut vars = env(&reply);
2241 vars.insert(
2242 "UNRELATED_PATH".into(),
2243 queue.path_of(&unrelated.id).to_string_lossy().into_owned(),
2244 );
2245 vars.insert(
2246 "UNRELATED_RECORD".into(),
2247 serde_json::to_string(&unrelated).unwrap(),
2248 );
2249 let script = r#"#!/bin/sh
2250cat >/dev/null
2251printf '%s' "$UNRELATED_RECORD" > "$UNRELATED_PATH"
2252printf '%s\n' "$MOCK_REPLY"
2253"#;
2254 let cfg = config(mock_agent(dir.path(), script, vars));
2255 Conductor::new()
2256 .run_once(
2257 &cfg,
2258 dir.path(),
2259 &queue,
2260 &questions,
2261 dir.path(),
2262 &[waiting.clone(), asking.clone(), holding.clone()],
2263 &[],
2264 &[],
2265 2,
2266 )
2267 .await
2268 .unwrap();
2269 assert_eq!(queue.get(&unrelated.id).unwrap().status, TaskStatus::Done);
2270 let after = queue.get(&waiting.id).unwrap();
2271 assert_eq!(after.status, TaskStatus::Blocked);
2272 assert_eq!(after.blocked_by, [dep.id]);
2273 let asked = queue.get(&asking.id).unwrap();
2274 assert_eq!(asked.status, TaskStatus::Blocked);
2275 assert_eq!(
2276 questions.get(&asked.blocked_by[0]).unwrap().summary,
2277 "Which behavior do you want?"
2278 );
2279 assert_eq!(queue.get(&holding.id).unwrap().status, TaskStatus::Held);
2280 }
2281
2282 #[test]
2283 fn a_dependency_decision_blocks_the_task_and_leaves_priority_alone() {
2284 let dir = tempdir().unwrap();
2285 let queue = Queue::at(dir.path().join("queue"));
2286 let questions = Questions::at(dir.path().join("questions"));
2287 let mut a = task("a");
2288 a.priority = 9;
2289 queue.put(&mut a).unwrap();
2290
2291 let verdict = Verdict {
2292 decisions: vec![Decision {
2293 id: a.id.clone(),
2294 blocked_by: vec!["20260101-000000-dead".to_owned()],
2295 reason: Some("waits on the other task".to_owned()),
2296 recovery: None,
2297 question: None,
2298 choices: Vec::new(),
2299 question_decision: None,
2300 evidence: Vec::new(),
2301 missing_dependencies: Vec::new(),
2302 }],
2303 };
2304 apply(&queue, &questions, &verdict).unwrap();
2305
2306 let back = queue.get(&a.id).unwrap();
2307 assert_eq!(back.status, TaskStatus::Blocked);
2308 assert_eq!(back.blocked_by, ["20260101-000000-dead"]);
2309 assert_eq!(
2310 back.priority, 9,
2311 "the conductor's reply cannot carry priority"
2312 );
2313 }
2314
2315 #[test]
2316 fn a_question_decision_files_one_and_blocks_on_its_id() {
2317 let dir = tempdir().unwrap();
2318 let queue = Queue::at(dir.path().join("queue"));
2319 let questions = Questions::at(dir.path().join("questions"));
2320 let mut t = task("ambiguous");
2321 queue.put(&mut t).unwrap();
2322
2323 let verdict = Verdict {
2324 decisions: vec![Decision {
2325 id: t.id.clone(),
2326 blocked_by: Vec::new(),
2327 reason: Some("which backend?".to_owned()),
2328 recovery: None,
2329 question: Some("Which storage backend?".to_owned()),
2330 choices: vec!["SQLite".to_owned(), "Redis".to_owned()],
2331 question_decision: None,
2332 evidence: Vec::new(),
2333 missing_dependencies: Vec::new(),
2334 }],
2335 };
2336 apply(&queue, &questions, &verdict).unwrap();
2337
2338 let back = queue.get(&t.id).unwrap();
2339 assert_eq!(back.status, TaskStatus::Blocked);
2340 assert_eq!(back.blocked_by.len(), 1);
2341 let q = questions.get(&back.blocked_by[0]).unwrap();
2342 assert_eq!(q.summary, "Which storage backend?");
2343 assert_eq!(q.node, NODE);
2344 assert!(q.status.open());
2345 }
2346
2347 #[test]
2348 fn a_task_with_an_open_question_already_reuses_it_rather_than_filing_a_second_one() {
2349 let dir = tempdir().unwrap();
2350 let queue = Queue::at(dir.path().join("queue"));
2351 let questions = Questions::at(dir.path().join("questions"));
2352 let mut t = task("asked once");
2353 queue.put(&mut t).unwrap();
2354
2355 let decision = Decision {
2356 id: t.id.clone(),
2357 reason: Some("still deciding".to_owned()),
2358 question: Some("Which backend?".to_owned()),
2359 ..Decision::default()
2360 };
2361 apply(
2362 &queue,
2363 &questions,
2364 &Verdict {
2365 decisions: vec![decision.clone()],
2366 },
2367 )
2368 .unwrap();
2369 assert_eq!(questions.list().len(), 1);
2370 let first_question_id = queue.get(&t.id).unwrap().blocked_by[0].clone();
2371
2372 let mut released = queue.get(&t.id).unwrap();
2377 released.release();
2378 queue.put(&mut released).unwrap();
2379
2380 apply(
2381 &queue,
2382 &questions,
2383 &Verdict {
2384 decisions: vec![decision],
2385 },
2386 )
2387 .unwrap();
2388 assert_eq!(questions.list().len(), 1, "no duplicate question was filed");
2389 let after = queue.get(&t.id).unwrap();
2390 assert_eq!(
2391 after.blocked_by,
2392 [first_question_id],
2393 "the existing open question is reused, not replaced"
2394 );
2395 }
2396
2397 #[test]
2398 fn a_same_id_question_from_another_node_is_not_reused() {
2399 let dir = tempdir().unwrap();
2400 let queue = Queue::at(dir.path().join("queue"));
2401 let questions = Questions::at(dir.path().join("questions"));
2402 let mut t = task("must ask the conductor");
2403 queue.put(&mut t).unwrap();
2404
2405 let mut unrelated = Question::new(
2406 t.id.clone(),
2407 "review".to_owned(),
2408 "reviewer-1".to_owned(),
2409 "An unrelated review question".to_owned(),
2410 String::new(),
2411 Vec::new(),
2412 );
2413 questions.put(&mut unrelated).unwrap();
2414
2415 apply(
2416 &queue,
2417 &questions,
2418 &Verdict {
2419 decisions: vec![Decision {
2420 id: t.id.clone(),
2421 question: Some("Which backend?".to_owned()),
2422 ..Decision::default()
2423 }],
2424 },
2425 )
2426 .unwrap();
2427
2428 let blocked_by = &queue.get(&t.id).unwrap().blocked_by;
2429 assert_eq!(blocked_by.len(), 1);
2430 assert_ne!(blocked_by[0], unrelated.id);
2431 assert!(questions.get(&unrelated.id).unwrap().status.open());
2432 assert_eq!(questions.get(&blocked_by[0]).unwrap().node, NODE);
2433 }
2434
2435 #[test]
2436 fn answering_the_question_lets_the_resolver_clear_the_block_with_the_answer_kept() {
2437 let dir = tempdir().unwrap();
2438 let queue = Queue::at(dir.path().join("queue"));
2439 let questions = Questions::at(dir.path().join("questions"));
2440 let mut t = task("waits on an answer");
2441 queue.put(&mut t).unwrap();
2442
2443 apply(
2444 &queue,
2445 &questions,
2446 &Verdict {
2447 decisions: vec![Decision {
2448 id: t.id.clone(),
2449 blocked_by: Vec::new(),
2450 reason: None,
2451 recovery: None,
2452 question: Some("Which backend?".to_owned()),
2453 choices: Vec::new(),
2454 question_decision: None,
2455 evidence: Vec::new(),
2456 missing_dependencies: Vec::new(),
2457 }],
2458 },
2459 )
2460 .unwrap();
2461 let blocked = queue.get(&t.id).unwrap();
2462 let question_id = blocked.blocked_by[0].clone();
2463
2464 let mut q = questions.get(&question_id).unwrap();
2465 q.answer(Answer::Text("SQLite".to_owned())).unwrap();
2466 questions.put(&mut q).unwrap();
2467 assert_eq!(q.status, QuestionStatus::Answered);
2468
2469 let mut task_after = queue.get(&t.id).unwrap();
2473 task_after.record_answer(q.summary.clone(), "SQLite".to_owned());
2474 task_after.unblock(&question_id);
2475 assert_eq!(task_after.status, TaskStatus::Queued);
2476 assert_eq!(task_after.answers[0].answer, "SQLite");
2477 }
2478
2479 #[test]
2480 fn a_stalled_task_can_be_requeued_or_held() {
2481 let dir = tempdir().unwrap();
2482 let queue = Queue::at(dir.path().join("queue"));
2483 let questions = Questions::at(dir.path().join("questions"));
2484
2485 let mut requeue_me = task("stuck a");
2486 requeue_me.start("run-1".to_owned());
2487 queue.put(&mut requeue_me).unwrap();
2488
2489 let mut hold_me = task("stuck b");
2490 hold_me.start("run-2".to_owned());
2491 queue.put(&mut hold_me).unwrap();
2492
2493 apply(
2494 &queue,
2495 &questions,
2496 &Verdict {
2497 decisions: vec![
2498 Decision {
2499 id: requeue_me.id.clone(),
2500 recovery: Some(Recovery::Requeue),
2501 ..Decision::default()
2502 },
2503 Decision {
2504 id: hold_me.id.clone(),
2505 recovery: Some(Recovery::Hold),
2506 reason: Some("looks broken".to_owned()),
2507 ..Decision::default()
2508 },
2509 ],
2510 },
2511 )
2512 .unwrap();
2513
2514 let requeued = queue.get(&requeue_me.id).unwrap();
2515 assert_eq!(requeued.status, TaskStatus::Queued);
2516 assert_eq!(requeued.attempts, 0);
2517
2518 let held = queue.get(&hold_me.id).unwrap();
2519 assert_eq!(held.status, TaskStatus::Held);
2520 assert_eq!(held.hold_reason.as_deref(), Some("looks broken"));
2521 }
2522
2523 #[test]
2524 fn a_machine_held_task_asked_about_restores_to_held_once_answered() {
2525 let dir = tempdir().unwrap();
2530 let queue = Queue::at(dir.path().join("queue"));
2531 let questions = Questions::at(dir.path().join("questions"));
2532 let mut t = task("held out of attempts");
2533 t.hold_machine(Some("out of attempts".to_owned()));
2534 queue.put(&mut t).unwrap();
2535
2536 apply(
2537 &queue,
2538 &questions,
2539 &Verdict {
2540 decisions: vec![Decision {
2541 id: t.id.clone(),
2542 reason: Some("what should happen to this one?".to_owned()),
2543 question: Some("Hold it, or try again?".to_owned()),
2544 ..Decision::default()
2545 }],
2546 },
2547 )
2548 .unwrap();
2549 let blocked = queue.get(&t.id).unwrap();
2550 assert_eq!(blocked.status, TaskStatus::Blocked);
2551 let question_id = blocked.blocked_by[0].clone();
2552
2553 let mut q = questions.get(&question_id).unwrap();
2554 q.answer(Answer::Text("leave it held".to_owned())).unwrap();
2555 questions.put(&mut q).unwrap();
2556
2557 let mut after = queue.get(&t.id).unwrap();
2559 after.record_answer(q.summary.clone(), "leave it held".to_owned());
2560 after.unblock(&question_id);
2561 assert_eq!(
2562 after.status,
2563 TaskStatus::Held,
2564 "must not fall back to queued"
2565 );
2566 assert_eq!(after.hold_reason.as_deref(), Some("out of attempts"));
2567 }
2568
2569 #[test]
2570 fn a_reaffirmed_hold_with_no_new_reason_is_not_silently_auto_released_by_triage() {
2571 let dir = tempdir().unwrap();
2581 let queue = Queue::at(dir.path().join("queue"));
2582 let questions = Questions::at(dir.path().join("questions"));
2583 let mut t = task("disk pressure, then reconsidered");
2584 t.hold_machine(Some(
2585 "not enough free space to start a run: 10 bytes free, 100 required by \
2586 `[disk] min_free_bytes`"
2587 .to_owned(),
2588 ));
2589 t.record_answer(
2590 "How should this be handled?".to_owned(),
2591 "keep it held, a human will look at it later".to_owned(),
2592 );
2593 queue.put(&mut t).unwrap();
2594
2595 apply(
2596 &queue,
2597 &questions,
2598 &Verdict {
2599 decisions: vec![Decision {
2600 id: t.id.clone(),
2601 recovery: Some(Recovery::Hold),
2602 ..Decision::default()
2603 }],
2604 },
2605 )
2606 .unwrap();
2607
2608 let after = queue.get(&t.id).unwrap();
2609 assert_eq!(after.status, TaskStatus::Held);
2610 assert!(
2611 !after
2612 .hold_reason
2613 .as_deref()
2614 .unwrap_or_default()
2615 .starts_with("not enough free space"),
2616 "the stale disk-pressure text must not survive a reconfirmed hold: {:?}",
2617 after.hold_reason
2618 );
2619
2620 let cfg_dir = tempdir().unwrap();
2623 let config = cfg_dir.path().join("magi.toml");
2624 std::fs::write(&config, "[disk]\nmin_free_bytes = 0\n").unwrap();
2625 let report =
2626 crate::triage::run_once(&queue, &questions, Some(&config), jiff::Timestamp::now());
2627 assert!(report.resumed.is_empty(), "must not be auto-released");
2628 assert_eq!(queue.get(&t.id).unwrap().status, TaskStatus::Held);
2629 }
2630
2631 #[test]
2632 fn explicit_resumption_rehold_requires_new_evidence_and_another_answer() {
2633 crate::run::pin_test_home();
2634 let dir = tempdir().unwrap();
2635 let queue = Queue::at(dir.path().join("queue"));
2636 let questions = Questions::at(dir.path().join("questions"));
2637 let config = dir.path().join("magi.toml");
2638 std::fs::write(&config, "[disk]\nmin_free_bytes = 0\n").unwrap();
2639 let now = jiff::Timestamp::now();
2640 let triage = || crate::triage::run_once(&queue, &questions, Some(&config), now);
2641 let open = || {
2642 questions
2643 .list()
2644 .into_iter()
2645 .filter(|q| q.node == "triage" && q.status.open())
2646 .collect::<Vec<_>>()
2647 };
2648 let hold = Decision {
2649 recovery: Some(Recovery::Hold),
2650 reason: Some("waiting on manual worktree cleanup".to_owned()),
2651 ..Decision::default()
2652 };
2653
2654 let mut t = task("looping hold");
2655 t.hold_machine(Some("waiting on manual worktree cleanup".to_owned()));
2656 queue.put(&mut t).unwrap();
2657 let hold = Decision {
2658 id: t.id.clone(),
2659 ..hold
2660 };
2661
2662 assert_eq!(triage().asked.len(), 1);
2664 let first = open().remove(0);
2665 let mut q = questions.get(&first.id).unwrap();
2666 let resume = q.choices[0].clone();
2667 q.answer(Answer::Choice(resume)).unwrap();
2668 questions.put(&mut q).unwrap();
2669 assert_eq!(triage().answered.len(), 1);
2670 assert_eq!(queue.get(&t.id).unwrap().status, TaskStatus::Queued);
2671
2672 apply(
2674 &queue,
2675 &questions,
2676 &Verdict {
2677 decisions: vec![hold.clone()],
2678 },
2679 )
2680 .unwrap();
2681 assert_eq!(queue.get(&t.id).unwrap().status, TaskStatus::Queued);
2682 assert!(triage().asked.is_empty());
2683
2684 let mut evidence = Question::new(
2686 t.id.clone(),
2687 "preflight".into(),
2688 "check".into(),
2689 "New permission failure".into(),
2690 String::new(),
2691 Vec::new(),
2692 );
2693 questions.put(&mut evidence).unwrap();
2694 let hold = Decision {
2695 evidence: vec![evidence.id.clone()],
2696 reason: Some("new permission check failed".into()),
2697 ..hold
2698 };
2699 apply(
2700 &queue,
2701 &questions,
2702 &Verdict {
2703 decisions: vec![hold.clone()],
2704 },
2705 )
2706 .unwrap();
2707 assert_eq!(queue.get(&t.id).unwrap().status, TaskStatus::Held);
2708
2709 assert_eq!(triage().asked.len(), 1);
2711 let second = open().remove(0);
2712 assert!(second.detail.contains("new permission check failed"));
2713 assert!(triage().asked.is_empty(), "no duplicate question");
2714 assert_eq!(open().len(), 1);
2715
2716 let mut q = questions.get(&second.id).unwrap();
2718 let force = q.choices[0].clone();
2719 q.answer(Answer::Choice(force)).unwrap();
2720 questions.put(&mut q).unwrap();
2721 assert_eq!(triage().answered.len(), 1);
2722 apply(
2723 &queue,
2724 &questions,
2725 &Verdict {
2726 decisions: vec![hold],
2727 },
2728 )
2729 .unwrap();
2730 assert_eq!(queue.get(&t.id).unwrap().status, TaskStatus::Queued);
2731 }
2732
2733 #[test]
2734 fn conductor_resumption_notice_does_not_solicit_unregistered_questions() {
2735 crate::run::pin_test_home();
2736 let dir = tempdir().unwrap();
2737 let queue = Queue::at(dir.path().join("queue"));
2738 let questions = Questions::at(dir.path().join("questions"));
2739
2740 let mut t = task("task with explicit resumption");
2741 t.status = TaskStatus::Queued;
2742 t.resumption = Some(crate::queue::Resumption {
2743 generation: 1,
2744 source: "test".into(),
2745 question_id: Some("q1".into()),
2746 at: jiff::Timestamp::now(),
2747 instruction: "resume".into(),
2748 answers_through: 0,
2749 runs_through: 0,
2750 cleared_reason: None,
2751 blocking_evidence: Vec::new(),
2752 });
2753 queue.put(&mut t).unwrap();
2754
2755 let decision = Decision {
2757 id: t.id.clone(),
2758 question: Some("How should we proceed with this task?".into()),
2759 blocked_by: vec!["dep-x".into()],
2760 reason: Some("needs clarification".into()),
2761 recovery: Some(Recovery::Hold),
2762 ..Decision::default()
2763 };
2764
2765 apply(
2766 &queue,
2767 &questions,
2768 &Verdict {
2769 decisions: vec![decision],
2770 },
2771 )
2772 .unwrap();
2773
2774 assert_eq!(queue.get(&t.id).unwrap().status, TaskStatus::Queued);
2776 assert!(questions.list().is_empty());
2778
2779 let notices = crate::notices::Notices::open();
2781 let notice = notices
2782 .get(&crate::notices::id_of(&format!(
2783 "conductor-resumption:{}",
2784 t.id
2785 )))
2786 .expect("conductor-resumption notice filed");
2787
2788 assert_eq!(
2789 notice.link,
2790 Some(crate::notices::Link::Task { id: t.id.clone() })
2791 );
2792 assert!(!notice.message.contains("How should we proceed"));
2794 assert!(
2796 notice
2797 .message
2798 .contains("No reply to this notice is needed.")
2799 );
2800 assert!(!notice.message.contains("Some("));
2802 assert!(!notice.message.contains("None"));
2803 assert!(!notice.message.contains("[]"));
2804 assert!(!notice.message.contains("{:?}"));
2805 assert!(notice.message.contains("Proposed dependencies: dep-x."));
2807 }
2808
2809 #[test]
2810 fn a_runnable_task_can_be_held_directly_without_a_question() {
2811 let dir = tempdir().unwrap();
2812 let queue = Queue::at(dir.path().join("queue"));
2813 let questions = Questions::at(dir.path().join("questions"));
2814 let mut t = task("already answered, should stay put");
2815 queue.put(&mut t).unwrap();
2816
2817 apply(
2818 &queue,
2819 &questions,
2820 &Verdict {
2821 decisions: vec![Decision {
2822 id: t.id.clone(),
2823 recovery: Some(Recovery::Hold),
2824 reason: Some("operator already said keep this held".to_owned()),
2825 ..Decision::default()
2826 }],
2827 },
2828 )
2829 .unwrap();
2830
2831 let after = queue.get(&t.id).unwrap();
2832 assert_eq!(after.status, TaskStatus::Held);
2833 assert_eq!(after.hold_source, Some(crate::queue::HoldSource::Machine));
2834 }
2835
2836 #[test]
2837 fn done_recovery_closes_a_held_task_whose_goal_is_already_met() {
2838 crate::run::pin_test_home();
2839 let dir = tempdir().unwrap();
2845 let queue = Queue::at(dir.path().join("queue"));
2846 let questions = Questions::at(dir.path().join("questions"));
2847 let mut t = task("already merged by hand");
2848 t.hold_machine(Some("branch survived, awaiting a decision".to_owned()));
2849 t.record_answer(
2850 "Handle this one?".to_owned(),
2851 "already merged and cleaned up, close it".to_owned(),
2852 );
2853 queue.put(&mut t).unwrap();
2854
2855 apply(
2856 &queue,
2857 &questions,
2858 &Verdict {
2859 decisions: vec![Decision {
2860 id: t.id.clone(),
2861 recovery: Some(Recovery::Done),
2862 reason: Some("operator confirmed this already landed".to_owned()),
2863 ..Decision::default()
2864 }],
2865 },
2866 )
2867 .unwrap();
2868
2869 let after = queue.get(&t.id).unwrap();
2870 assert_eq!(after.status, TaskStatus::Done);
2871 assert!(after.hold_reason.is_none());
2872 assert_eq!(after.answers.len(), 1, "the record of why is kept");
2873 }
2874
2875 #[test]
2876 fn done_recovery_is_ignored_for_a_runnable_or_running_task() {
2877 let dir = tempdir().unwrap();
2878 let queue = Queue::at(dir.path().join("queue"));
2879 let questions = Questions::at(dir.path().join("questions"));
2880
2881 let mut queued = task("never ran yet");
2882 queue.put(&mut queued).unwrap();
2883
2884 let mut running = task("mid-run");
2885 running.start("run-1".to_owned());
2886 queue.put(&mut running).unwrap();
2887
2888 for id in [queued.id.clone(), running.id.clone()] {
2889 apply(
2890 &queue,
2891 &questions,
2892 &Verdict {
2893 decisions: vec![Decision {
2894 id,
2895 recovery: Some(Recovery::Done),
2896 ..Decision::default()
2897 }],
2898 },
2899 )
2900 .unwrap();
2901 }
2902
2903 assert_eq!(queue.get(&queued.id).unwrap().status, TaskStatus::Queued);
2904 assert_eq!(queue.get(&running.id).unwrap().status, TaskStatus::Running);
2905 }
2906
2907 #[test]
2908 fn a_question_after_two_settled_answers_is_still_filed_and_blocks() {
2909 let dir = tempdir().unwrap();
2912 let queue = Queue::at(dir.path().join("queue"));
2913 let questions = Questions::at(dir.path().join("questions"));
2914 let mut t = task("asked about repeatedly");
2915 t.hold_machine(Some("out of attempts".to_owned()));
2916 t.record_answer("Handle this one? (1)".to_owned(), "not yet".to_owned());
2917 t.record_answer(
2918 "Handle this one? (2)".to_owned(),
2919 "still not yet".to_owned(),
2920 );
2921 queue.put(&mut t).unwrap();
2922 assert_eq!(questions.list().len(), 0);
2923
2924 let verdict = Verdict {
2925 decisions: vec![Decision {
2926 id: t.id.clone(),
2927 question: Some("Branch conflicts with origin/main, how do we proceed?".to_owned()),
2928 ..Decision::default()
2929 }],
2930 };
2931 apply(&queue, &questions, &verdict).unwrap();
2932
2933 let filed = questions.list();
2934 assert_eq!(filed.len(), 1, "the question was filed");
2935 assert_eq!(
2936 filed[0].summary,
2937 "Branch conflicts with origin/main, how do we proceed?"
2938 );
2939 let deputy = filed[0]
2940 .deputy
2941 .as_ref()
2942 .expect("the wait is handed to a deputy");
2943 assert!(deputy.brief.contains(&t.id), "{}", deputy.brief);
2944 assert_eq!(
2945 deputy.starts, 0,
2946 "filing never starts anything: the loop does not wait"
2947 );
2948 assert!(filed[0].status.open());
2949 assert_eq!(filed[0].node, NODE);
2950 let after = queue.get(&t.id).unwrap();
2951 assert_eq!(after.status, TaskStatus::Blocked);
2952 assert_eq!(after.blocked_by, vec![filed[0].id.clone()]);
2953 assert_eq!(after.answers.len(), 2, "the prior answers are untouched");
2954 assert!(
2955 !after
2956 .hold_reason
2957 .clone()
2958 .unwrap_or_default()
2959 .contains("conduct tried to ask"),
2960 "no hold was applied"
2961 );
2962
2963 apply(&queue, &questions, &verdict).unwrap();
2964 let again = questions.list();
2965 assert_eq!(again.len(), 1, "the open question is reused");
2966 assert_eq!(again[0].id, filed[0].id);
2967 }
2968
2969 #[test]
2970 fn question_source_identity_resolves_aliases_and_rejects_invented_targets() {
2971 let dir = tempdir().unwrap();
2972 init_repo_with_branch(dir.path(), "source-a");
2973 let mut t = task("source decision");
2974 t.repo = dir.path().into();
2975 let decision = |reference: &str| QuestionDecision {
2976 operation: QuestionOperation::Recover,
2977 target: QuestionTarget::Source {
2978 reference: reference.into(),
2979 },
2980 };
2981 assert_eq!(
2982 decision("source-a").identity(&t).unwrap(),
2983 decision("refs/heads/source-a").identity(&t).unwrap()
2984 );
2985 assert_ne!(
2986 decision("source-a").identity(&t).unwrap(),
2987 decision("main").identity(&t).unwrap()
2988 );
2989 assert!(decision("invented-source-label").identity(&t).is_err());
2990 let unrelated = QuestionDecision {
2991 operation: QuestionOperation::Resume,
2992 target: QuestionTarget::Run {
2993 id: "unrelated-run".into(),
2994 },
2995 };
2996 assert!(unrelated.identity(&t).is_err());
2997 }
2998
2999 #[test]
3000 fn invalid_question_targets_still_file_and_block_queued_tasks() {
3001 let dir = tempdir().unwrap();
3002 init_repo_with_branch(dir.path(), "source-a");
3003 let queue = Queue::at(dir.path().join("queue"));
3004 let questions = Questions::at(dir.path().join("questions"));
3005 for target in [
3006 QuestionTarget::Source {
3007 reference: "missing-source".into(),
3008 },
3009 QuestionTarget::Run {
3010 id: "unrelated-run".into(),
3011 },
3012 ] {
3013 let mut t = task("decision with invalid metadata");
3014 t.repo = dir.path().into();
3015 queue.put(&mut t).unwrap();
3016 let d = Decision {
3017 id: t.id.clone(),
3018 question: Some("Which recovery should proceed?".into()),
3019 choices: vec!["inspect first".into(), "keep waiting".into()],
3020 question_decision: Some(QuestionDecision {
3021 operation: QuestionOperation::Recover,
3022 target,
3023 }),
3024 ..Decision::default()
3025 };
3026 apply_one(&queue, &questions, &d, None).unwrap();
3027 let q = questions
3028 .list()
3029 .into_iter()
3030 .find(|q| q.run == t.id)
3031 .unwrap();
3032 assert_eq!(q.summary, d.question.as_deref().unwrap());
3033 assert_eq!(q.choices, d.choices);
3034 assert!(q.status.open());
3035 let context: serde_json::Value =
3036 serde_json::from_str(q.decision_context.as_ref().unwrap()).unwrap();
3037 assert!(context[3].is_null(), "invalid metadata was discarded");
3038 let after = queue.get(&t.id).unwrap();
3039 assert_eq!(after.status, TaskStatus::Blocked);
3040 assert_eq!(after.blocked_by, vec![q.id.clone()]);
3041 apply_one(&queue, &questions, &d, None).unwrap();
3042 assert_eq!(questions.list().iter().filter(|q| q.run == t.id).count(), 1);
3043 }
3044 }
3045
3046 #[test]
3047 fn answered_conductor_question_needs_changed_facts_before_reasking() {
3048 let dir = tempdir().unwrap();
3049 let queue = Queue::at(dir.path().join("queue"));
3050 let questions = Questions::at(dir.path().join("questions"));
3051 let mut t = task("recovery decision");
3052 t.hold_machine(Some("out of attempts".into()));
3053 t.runs = vec!["source-run-a".into(), "source-run-b".into()];
3054 queue.put(&mut t).unwrap();
3055 let mut d = Decision {
3056 id: t.id.clone(),
3057 question: Some("Recover from source A?".into()),
3058 choices: vec!["approve source A".into(), "keep held".into()],
3059 question_decision: Some(QuestionDecision {
3060 operation: QuestionOperation::Recover,
3061 target: QuestionTarget::Run {
3062 id: "source-run-a".into(),
3063 },
3064 }),
3065 ..Decision::default()
3066 };
3067 apply_one(&queue, &questions, &d, None).unwrap();
3068 let mut q = questions.list().pop().unwrap();
3069 q.answer(crate::ask::Answer::Choice("keep held".into()))
3070 .unwrap();
3071 questions.put(&mut q).unwrap();
3072 queue
3073 .modify(&t.id, |task| {
3074 task.unblock(&q.id);
3075 task.hold_machine(Some("recovery remains under consideration".into()));
3076 true
3077 })
3078 .unwrap();
3079 d.question = Some("Should we keep waiting on recovery?".into());
3080 d.choices = vec!["recover using source A".into(), "remain on hold".into()];
3081 apply_one(&queue, &questions, &d, None).unwrap();
3082 assert_eq!(questions.list().len(), 1, "no repeated operator turn");
3083 d.question = Some("Recover from source B?".into());
3084 d.question_decision.as_mut().unwrap().target = QuestionTarget::Run {
3086 id: "source-run-b".into(),
3087 };
3088 apply_one(&queue, &questions, &d, None).unwrap();
3089 assert_eq!(
3090 questions.list().len(),
3091 2,
3092 "new decision with unchanged task facts"
3093 );
3094 let after = queue.get(&t.id).unwrap();
3095 assert_eq!(after.instruction, t.instruction);
3096 assert_eq!(after.runs, t.runs);
3097 assert_eq!(after.status, TaskStatus::Blocked);
3098 }
3099
3100 #[test]
3101 fn a_second_conductor_question_is_still_allowed_after_one_settled_answer() {
3102 let dir = tempdir().unwrap();
3103 let queue = Queue::at(dir.path().join("queue"));
3104 let questions = Questions::at(dir.path().join("questions"));
3105 let mut t = task("asked about once already");
3106 t.hold_machine(Some("out of attempts".to_owned()));
3107 t.record_answer("Handle this one?".to_owned(), "not yet".to_owned());
3108 queue.put(&mut t).unwrap();
3109
3110 apply(
3111 &queue,
3112 &questions,
3113 &Verdict {
3114 decisions: vec![Decision {
3115 id: t.id.clone(),
3116 question: Some("Still not sure - now what?".to_owned()),
3117 ..Decision::default()
3118 }],
3119 },
3120 )
3121 .unwrap();
3122
3123 assert_eq!(questions.list().len(), 1, "the second question was filed");
3124 assert_eq!(queue.get(&t.id).unwrap().status, TaskStatus::Blocked);
3125 }
3126
3127 #[test]
3128 fn a_held_task_with_an_open_triage_question_is_left_to_triage() {
3129 let dir = tempdir().unwrap();
3135 let queue = Queue::at(dir.path().join("queue"));
3136 let questions = Questions::at(dir.path().join("questions"));
3137 let mut t = task("held, triage already asking about it");
3138 t.hold_machine(Some("cause unclear".to_owned()));
3139 queue.put(&mut t).unwrap();
3140
3141 let mut triage_q = Question::new(
3142 t.id.clone(),
3143 crate::triage::NODE.to_owned(),
3144 "triage".to_owned(),
3145 "Still needed?".to_owned(),
3146 String::new(),
3147 vec![
3148 "resume".to_owned(),
3149 "not yet".to_owned(),
3150 "discard".to_owned(),
3151 ],
3152 );
3153 questions.put(&mut triage_q).unwrap();
3154
3155 for decision in [
3156 Decision {
3157 id: t.id.clone(),
3158 question: Some("what now?".to_owned()),
3159 ..Decision::default()
3160 },
3161 Decision {
3162 id: t.id.clone(),
3163 recovery: Some(Recovery::Requeue),
3164 ..Decision::default()
3165 },
3166 ] {
3167 apply(
3168 &queue,
3169 &questions,
3170 &Verdict {
3171 decisions: vec![decision],
3172 },
3173 )
3174 .unwrap();
3175 }
3176
3177 let after = queue.get(&t.id).unwrap();
3178 assert_eq!(
3179 after.status,
3180 TaskStatus::Held,
3181 "triage still owns this hold"
3182 );
3183 assert!(after.blocked_by.is_empty());
3184 assert_eq!(
3185 questions.list().len(),
3186 1,
3187 "no second, conductor-owned question was filed"
3188 );
3189 }
3190
3191 #[test]
3192 fn a_held_task_with_an_answered_but_unapplied_triage_question_is_still_left_alone() {
3193 let dir = tempdir().unwrap();
3201 let queue = Queue::at(dir.path().join("queue"));
3202 let questions = Questions::at(dir.path().join("questions"));
3203 let mut t = task("held, triage question answered but not yet applied");
3204 t.hold_machine(Some("cause unclear".to_owned()));
3205 queue.put(&mut t).unwrap();
3206
3207 let mut triage_q = Question::new(
3208 t.id.clone(),
3209 crate::triage::NODE.to_owned(),
3210 "triage".to_owned(),
3211 "Still needed?".to_owned(),
3212 String::new(),
3213 vec![
3214 "resume".to_owned(),
3215 "not yet".to_owned(),
3216 "discard".to_owned(),
3217 ],
3218 );
3219 questions.put(&mut triage_q).unwrap();
3220 triage_q
3221 .answer(Answer::Choice("not yet".to_owned()))
3222 .unwrap();
3223 questions.put(&mut triage_q).unwrap();
3224 assert!(!triage_q.status.open());
3225
3226 apply(
3227 &queue,
3228 &questions,
3229 &Verdict {
3230 decisions: vec![Decision {
3231 id: t.id.clone(),
3232 question: Some("what now?".to_owned()),
3233 ..Decision::default()
3234 }],
3235 },
3236 )
3237 .unwrap();
3238
3239 let after = queue.get(&t.id).unwrap();
3240 assert_eq!(
3241 after.status,
3242 TaskStatus::Held,
3243 "triage's own answer is not yet applied - conduct must wait"
3244 );
3245 assert_eq!(
3246 questions.list().len(),
3247 1,
3248 "no conductor question was filed over the pending triage answer"
3249 );
3250 }
3251
3252 #[test]
3253 fn manual_hold_rejects_hostile_or_stale_conductor_recovery() {
3254 let dir = tempdir().unwrap();
3255 let queue = Queue::at(dir.path().join("queue"));
3256 let questions = Questions::at(dir.path().join("questions"));
3257 let mut held = task("manual recovery");
3258 held.priority = 300;
3259 held.runs.push("run20260912-224242-daf5".to_owned());
3260 held.hold_manual(Some(
3261 "active manual recovery run20260912-224242-daf5".to_owned(),
3262 ));
3263 queue.put(&mut held).unwrap();
3264
3265 for decision in [
3269 Decision {
3270 id: held.id.clone(),
3271 recovery: Some(Recovery::Requeue),
3272 ..Decision::default()
3273 },
3274 Decision {
3275 id: held.id.clone(),
3276 recovery: Some(Recovery::Hold),
3277 reason: Some("stale replacement reason".to_owned()),
3278 ..Decision::default()
3279 },
3280 Decision {
3281 id: held.id.clone(),
3282 recovery: Some(Recovery::Review),
3283 ..Decision::default()
3284 },
3285 Decision {
3286 id: held.id.clone(),
3287 blocked_by: vec!["other-task".to_owned()],
3288 question: Some("retry now?".to_owned()),
3289 ..Decision::default()
3290 },
3291 ] {
3292 apply(
3293 &queue,
3294 &questions,
3295 &Verdict {
3296 decisions: vec![decision],
3297 },
3298 )
3299 .unwrap();
3300 }
3301
3302 let after = queue.get(&held.id).unwrap();
3303 assert_eq!(after.status, TaskStatus::Held);
3304 assert!(after.operator_held());
3305 assert_eq!(after.priority, 300);
3306 assert_eq!(after.runs, ["run20260912-224242-daf5"]);
3307 assert_eq!(
3308 after.hold_reason.as_deref(),
3309 Some("active manual recovery run20260912-224242-daf5")
3310 );
3311 assert!(after.blocked_by.is_empty());
3312 assert!(questions.list().is_empty());
3313 assert!(
3314 queue.next_runnable().is_none(),
3315 "must not dispatch a duplicate"
3316 );
3317 }
3318
3319 #[test]
3320 fn machine_holds_remain_recoverable_and_manual_release_is_authorization() {
3321 let dir = tempdir().unwrap();
3322 let queue = Queue::at(dir.path().join("queue"));
3323 let questions = Questions::at(dir.path().join("questions"));
3324
3325 let mut automatic = task("disk gate");
3326 automatic.hold_machine(Some("disk full".to_owned()));
3327 queue.put(&mut automatic).unwrap();
3328 let requeue = || Verdict {
3329 decisions: vec![Decision {
3330 id: automatic.id.clone(),
3331 recovery: Some(Recovery::Requeue),
3332 ..Decision::default()
3333 }],
3334 };
3335 apply(&queue, &questions, &requeue()).unwrap();
3336 assert_eq!(queue.get(&automatic.id).unwrap().status, TaskStatus::Queued);
3337
3338 let mut manual = task("operator gate");
3339 manual.hold_manual(Some("wait for operator".to_owned()));
3340 queue.put(&mut manual).unwrap();
3341 apply(
3342 &queue,
3343 &questions,
3344 &Verdict {
3345 decisions: vec![Decision {
3346 id: manual.id.clone(),
3347 recovery: Some(Recovery::Requeue),
3348 ..Decision::default()
3349 }],
3350 },
3351 )
3352 .unwrap();
3353 assert_eq!(queue.get(&manual.id).unwrap().status, TaskStatus::Held);
3354
3355 let mut released = queue.get(&manual.id).unwrap();
3358 released.release();
3359 queue.put(&mut released).unwrap();
3360 assert_eq!(queue.get(&manual.id).unwrap().status, TaskStatus::Queued);
3361 }
3362
3363 #[test]
3364 fn legacy_reasoned_hold_is_protected_without_losing_its_metadata() {
3365 let dir = tempdir().unwrap();
3366 let queue = Queue::at(dir.path().join("queue"));
3367 let questions = Questions::at(dir.path().join("questions"));
3368 let mut legacy = task("old explicit hold");
3369 legacy.status = TaskStatus::Held;
3370 legacy.hold_reason = Some("manual recovery already active".to_owned());
3371 legacy.hold_source = None;
3372 legacy.blocked_by = vec!["dependency".to_owned()];
3373 queue.put(&mut legacy).unwrap();
3374
3375 apply(
3376 &queue,
3377 &questions,
3378 &Verdict {
3379 decisions: vec![Decision {
3380 id: legacy.id.clone(),
3381 recovery: Some(Recovery::Requeue),
3382 ..Decision::default()
3383 }],
3384 },
3385 )
3386 .unwrap();
3387
3388 let after = queue.get(&legacy.id).unwrap();
3389 assert_eq!(after.status, TaskStatus::Held);
3390 assert_eq!(after.hold_source, None);
3391 assert_eq!(after.hold_reason, legacy.hold_reason);
3392 assert_eq!(after.blocked_by, legacy.blocked_by);
3393 }
3394
3395 #[test]
3396 fn review_recovery_is_a_no_op_without_a_survivable_branch() {
3397 crate::run::pin_test_home();
3403 let dir = tempdir().unwrap();
3404 let queue = Queue::at(dir.path().join("queue"));
3405 let questions = Questions::at(dir.path().join("questions"));
3406 let mut t = task("blocked with no readable run");
3407 t.start("20260101-000000-dead".to_owned()); t.fail("blocked", 5);
3409 queue.put(&mut t).unwrap();
3410
3411 apply(
3412 &queue,
3413 &questions,
3414 &Verdict {
3415 decisions: vec![Decision {
3416 id: t.id.clone(),
3417 recovery: Some(Recovery::Review),
3418 ..Decision::default()
3419 }],
3420 },
3421 )
3422 .unwrap();
3423
3424 let after = queue.get(&t.id).unwrap();
3425 assert_eq!(
3426 after.status,
3427 TaskStatus::Failed,
3428 "with nothing to reopen, the decision is dropped rather than guessed at"
3429 );
3430 assert!(after.review_branch.is_none());
3431 }
3432
3433 #[test]
3434 fn requeue_and_review_recovery_are_ignored_for_a_runnable_task() {
3435 let dir = tempdir().unwrap();
3439 let queue = Queue::at(dir.path().join("queue"));
3440 let questions = Questions::at(dir.path().join("questions"));
3441
3442 for recovery in [Recovery::Requeue, Recovery::Review] {
3443 let mut t = task("ordinary");
3444 queue.put(&mut t).unwrap();
3445
3446 apply(
3447 &queue,
3448 &questions,
3449 &Verdict {
3450 decisions: vec![Decision {
3451 id: t.id.clone(),
3452 recovery: Some(recovery),
3453 ..Decision::default()
3454 }],
3455 },
3456 )
3457 .unwrap();
3458
3459 assert_eq!(queue.get(&t.id).unwrap().status, TaskStatus::Queued);
3460 }
3461 }
3462
3463 #[tokio::test]
3464 async fn a_broken_agent_leaves_the_queue_untouched_and_does_not_error() {
3465 let dir = tempdir().unwrap();
3466 let cfg = config(mock_agent(dir.path(), BROKEN, BTreeMap::new()));
3467 let queue = Queue::at(dir.path().join("queue"));
3468 let questions = Questions::at(dir.path().join("questions"));
3469 let mut t = task("normal");
3470 queue.put(&mut t).unwrap();
3471
3472 let mut conductor = Conductor::new();
3473 conductor
3474 .maybe_run(
3475 &cfg,
3476 dir.path(),
3477 &queue,
3478 &questions,
3479 dir.path(),
3480 &[t.clone()],
3481 &[],
3482 &[],
3483 2,
3484 )
3485 .await;
3486
3487 assert_eq!(
3488 queue.get(&t.id).unwrap().status,
3489 TaskStatus::Queued,
3490 "a failed invocation must change nothing"
3491 );
3492 assert!(
3493 queue.next_runnable().is_some(),
3494 "the loop must still be able to take the next task"
3495 );
3496 }
3497
3498 #[tokio::test]
3499 async fn a_reply_with_no_json_leaves_the_queue_untouched() {
3500 let dir = tempdir().unwrap();
3501 let cfg = config(mock_agent(dir.path(), GARBAGE, BTreeMap::new()));
3502 let queue = Queue::at(dir.path().join("queue"));
3503 let questions = Questions::at(dir.path().join("questions"));
3504 let mut t = task("normal");
3505 queue.put(&mut t).unwrap();
3506
3507 let mut conductor = Conductor::new();
3508 conductor
3509 .maybe_run(
3510 &cfg,
3511 dir.path(),
3512 &queue,
3513 &questions,
3514 dir.path(),
3515 &[t.clone()],
3516 &[],
3517 &[],
3518 2,
3519 )
3520 .await;
3521
3522 assert_eq!(queue.get(&t.id).unwrap().status, TaskStatus::Queued);
3523 }
3524
3525 #[tokio::test]
3526 async fn a_conductor_chain_falls_to_the_next_agent_once_each() {
3527 let dir = tempdir().unwrap();
3528 let mut t = task("chained");
3529 let reply = format!(
3530 "{{\"decisions\":[{{\"id\":\"{}\",\"blocked_by\":[\"x\"],\"reason\":\"why\"}}]}}",
3531 t.id
3532 );
3533 let counted = |id: &str, tail: &str| {
3534 let calls = dir.path().join(format!("{id}.calls"));
3535 let path = dir.path().join(format!("{id}.sh"));
3536 std::fs::write(
3537 &path,
3538 format!(
3539 "#!/bin/sh\ncat >/dev/null\necho x >> '{}'\n{tail}\n",
3540 calls.display()
3541 ),
3542 )
3543 .unwrap();
3544 AgentSpec {
3545 id: id.to_owned(),
3546 kind: AgentKind::Command,
3547 model: None,
3548 command: vec!["sh".to_owned(), path.to_string_lossy().into_owned()],
3549 extra_args: Vec::new(),
3550 env: BTreeMap::new(),
3551 prompt_delivery: None,
3552 }
3553 };
3554 let n_calls = |id: &str| {
3555 std::fs::read_to_string(dir.path().join(format!("{id}.calls")))
3556 .map_or(0, |s| s.lines().count())
3557 };
3558 let mut cfg = config(counted("a", "exit 3"));
3559 cfg.agents = vec![
3560 counted("a", "exit 3"),
3561 counted("b", &format!("printf '%s' '{reply}'")),
3562 ];
3563 cfg.roles.conductor = Some(crate::config::AgentChoice::Chain(vec![
3564 "a".into(),
3565 "b".into(),
3566 "a".into(),
3567 ]));
3568 let queue = Queue::at(dir.path().join("queue"));
3569 let questions = Questions::at(dir.path().join("questions"));
3570 queue.put(&mut t).unwrap();
3571
3572 let mut conductor = Conductor::new();
3573 conductor
3574 .maybe_run(
3575 &cfg,
3576 dir.path(),
3577 &queue,
3578 &questions,
3579 dir.path(),
3580 &[t.clone()],
3581 &[],
3582 &[],
3583 2,
3584 )
3585 .await;
3586
3587 assert_eq!(queue.get(&t.id).unwrap().status, TaskStatus::Blocked);
3588 assert_eq!(n_calls("a"), 1, "each id is tried once");
3589 assert_eq!(n_calls("b"), 1);
3590 let saved = std::fs::read_to_string(seat_path(dir.path())).unwrap();
3591 assert!(
3592 saved.contains("\"b\""),
3593 "the seat on disk is the one that ran: {saved}"
3594 );
3595 }
3596
3597 #[tokio::test]
3598 async fn json_survives_code_fences_and_a_preamble() {
3599 let dir = tempdir().unwrap();
3600 let mut t = task("fenced");
3601 let reply = format!(
3602 "Sure, here is my decision.\n\n```json\n{{\"decisions\":[{{\"id\":\"{}\",\
3603 \"blocked_by\":[\"x\"],\"reason\":\"why\"}}]}}\n```\n",
3604 t.id
3605 );
3606 let cfg = config(mock_agent(dir.path(), REPLY, env(&reply)));
3607 let queue = Queue::at(dir.path().join("queue"));
3608 let questions = Questions::at(dir.path().join("questions"));
3609 queue.put(&mut t).unwrap();
3610
3611 let mut conductor = Conductor::new();
3612 conductor
3613 .maybe_run(
3614 &cfg,
3615 dir.path(),
3616 &queue,
3617 &questions,
3618 dir.path(),
3619 &[t.clone()],
3620 &[],
3621 &[],
3622 2,
3623 )
3624 .await;
3625
3626 let back = queue.get(&t.id).unwrap();
3627 assert_eq!(back.status, TaskStatus::Blocked);
3628 assert_eq!(back.blocked_by, ["x"]);
3629 }
3630
3631 #[tokio::test]
3632 async fn the_conductor_is_not_called_again_when_nothing_worth_looking_at_has_changed() {
3633 let dir = tempdir().unwrap();
3636 let cfg = config(mock_agent(dir.path(), REPLY, env("{\"decisions\":[]}")));
3637 let queue = Queue::at(dir.path().join("queue"));
3638 let questions = Questions::at(dir.path().join("questions"));
3639 let mut t = task("stable");
3640 queue.put(&mut t).unwrap();
3641 let artifacts = dir.path().join("conduct").join("artifacts");
3642 let turn = |n: usize| artifacts.join(format!("turn-{n}.out"));
3643
3644 let mut conductor = Conductor::new();
3645 conductor
3646 .maybe_run(
3647 &cfg,
3648 dir.path(),
3649 &queue,
3650 &questions,
3651 dir.path(),
3652 &[t.clone()],
3653 &[],
3654 &[],
3655 2,
3656 )
3657 .await;
3658 assert!(turn(1).is_file(), "the first cycle must call the conductor");
3659
3660 conductor
3661 .maybe_run(
3662 &cfg,
3663 dir.path(),
3664 &queue,
3665 &questions,
3666 dir.path(),
3667 &[t.clone()],
3668 &[],
3669 &[],
3670 2,
3671 )
3672 .await;
3673 assert!(
3674 !turn(2).is_file(),
3675 "an unchanged revision and an unchanged stalled/finished set must not call the \
3676 conductor twice"
3677 );
3678
3679 t.priority = 1;
3681 queue.put(&mut t).unwrap();
3682 conductor
3683 .maybe_run(
3684 &cfg,
3685 dir.path(),
3686 &queue,
3687 &questions,
3688 dir.path(),
3689 &[t.clone()],
3690 &[],
3691 &[],
3692 2,
3693 )
3694 .await;
3695 assert!(turn(2).is_file(), "a moved revision calls it again");
3696 }
3697
3698 #[tokio::test]
3699 async fn a_task_turning_stalled_calls_the_conductor_again_despite_an_unchanged_revision() {
3700 let dir = tempdir().unwrap();
3706 let cfg = config(mock_agent(dir.path(), REPLY, env("{\"decisions\":[]}")));
3707 let queue = Queue::at(dir.path().join("queue"));
3708 let questions = Questions::at(dir.path().join("questions"));
3709 let mut t = task("quiet");
3710 queue.put(&mut t).unwrap();
3711 let artifacts = dir.path().join("conduct").join("artifacts");
3712 let turn = |n: usize| artifacts.join(format!("turn-{n}.out"));
3713
3714 let mut conductor = Conductor::new();
3715 conductor
3716 .maybe_run(
3717 &cfg,
3718 dir.path(),
3719 &queue,
3720 &questions,
3721 dir.path(),
3722 &[t.clone()],
3723 &[],
3724 &[],
3725 2,
3726 )
3727 .await;
3728 assert!(turn(1).is_file());
3729
3730 conductor
3731 .maybe_run(
3732 &cfg,
3733 dir.path(),
3734 &queue,
3735 &questions,
3736 dir.path(),
3737 &[],
3738 &[t.clone()],
3739 &[],
3740 2,
3741 )
3742 .await;
3743 assert!(
3744 turn(2).is_file(),
3745 "a task turning stalled must call the conductor again"
3746 );
3747
3748 conductor
3751 .maybe_run(
3752 &cfg,
3753 dir.path(),
3754 &queue,
3755 &questions,
3756 dir.path(),
3757 &[],
3758 &[t.clone()],
3759 &[],
3760 2,
3761 )
3762 .await;
3763 assert!(
3764 !turn(3).is_file(),
3765 "the same stalled task lingering must not call the conductor every cycle"
3766 );
3767 }
3768
3769 fn rehold(queue: &Queue, questions: &Questions, id: &str, note: &str) {
3770 apply(
3771 queue,
3772 questions,
3773 &Verdict {
3774 decisions: vec![Decision {
3775 id: id.to_owned(),
3776 reason: Some(note.to_owned()),
3777 recovery: Some(Recovery::Hold),
3778 ..Decision::default()
3779 }],
3780 },
3781 )
3782 .unwrap();
3783 }
3784
3785 #[test]
3786 fn reholding_with_an_identical_note_changes_nothing() {
3787 let dir = tempdir().unwrap();
3788 let queue = Queue::at(dir.path().join("queue"));
3789 let questions = Questions::at(dir.path().join("questions"));
3790 let mut t = task("held");
3791 t.hold_machine(Some("waiting on a human".to_owned()));
3792 queue.put(&mut t).unwrap();
3793
3794 rehold(&queue, &questions, &t.id, "owner said keep it held");
3795 let first = queue.get(&t.id).unwrap();
3796 let rev = queue.revision();
3797 for _ in 0..5 {
3798 rehold(&queue, &questions, &t.id, "owner said keep it held");
3799 }
3800 let after = queue.get(&t.id).unwrap();
3801 assert_eq!(after.hold_reason, first.hold_reason);
3802 assert_eq!(after.updated_at, first.updated_at);
3803 assert_eq!(queue.revision(), rev, "nothing was written");
3804 }
3805
3806 #[test]
3807 fn reholding_with_different_notes_keeps_one_previously_level() {
3808 let dir = tempdir().unwrap();
3809 let queue = Queue::at(dir.path().join("queue"));
3810 let questions = Questions::at(dir.path().join("questions"));
3811 let mut t = task("held");
3812 t.hold_machine(Some("original".to_owned()));
3813 queue.put(&mut t).unwrap();
3814
3815 for i in 0..20 {
3816 rehold(&queue, &questions, &t.id, &format!("note {}", i % 2));
3817 }
3818 let reason = queue.get(&t.id).unwrap().hold_reason.unwrap();
3819 assert_eq!(reason.matches("(previously:").count(), 1, "{reason}");
3820 assert!(reason.starts_with("note "), "the new note leads: {reason}");
3821 }
3822
3823 #[test]
3824 fn an_already_nested_reason_is_matched_by_its_outermost_note() {
3825 let mut t = task("deep");
3826 let nested = "same\n\n(previously: same\n\n(previously: same))";
3827 t.hold_machine(Some(nested.to_owned()));
3828 let d = Decision {
3829 id: t.id.clone(),
3830 reason: Some("same".to_owned()),
3831 ..Decision::default()
3832 };
3833 assert_eq!(reaffirmed_hold_reason(&t, &d), None);
3834 let d2 = Decision {
3835 reason: Some("other".to_owned()),
3836 ..d
3837 };
3838 assert_eq!(
3839 reaffirmed_hold_reason(&t, &d2).as_deref(),
3840 Some("other\n\n(previously: same)")
3841 );
3842 }
3843
3844 #[tokio::test]
3845 async fn a_repeated_hold_decision_settles_instead_of_looping() {
3846 let dir = tempdir().unwrap();
3847 let queue = Queue::at(dir.path().join("queue"));
3848 let questions = Questions::at(dir.path().join("questions"));
3849 let reply = r#"{"decisions":[{"id":"ID","reason":"keep held","recovery":"hold"}]}"#;
3850 let mut t = task("held");
3851 t.hold_machine(Some("first".to_owned()));
3852 queue.put(&mut t).unwrap();
3853 let cfg = config(mock_agent(
3854 dir.path(),
3855 REPLY,
3856 env(&reply.replace("ID", &t.id)),
3857 ));
3858 let mut conductor = Conductor::new();
3859 for _ in 0..2 {
3861 let held = queue.get(&t.id).unwrap();
3862 conductor
3863 .maybe_run(
3864 &cfg,
3865 dir.path(),
3866 &queue,
3867 &questions,
3868 dir.path(),
3869 &[],
3870 &[],
3871 &[held],
3872 2,
3873 )
3874 .await;
3875 }
3876 let held = queue.get(&t.id).unwrap();
3877 assert!(
3878 held.hold_reason
3879 .as_deref()
3880 .unwrap()
3881 .starts_with("keep held")
3882 );
3883 assert!(
3884 !conductor.worth_a_look(&queue, &[], &[held]),
3885 "an unchanged held task must stop being reconsidered"
3886 );
3887 }
3888
3889 #[tokio::test]
3890 async fn alternating_wording_does_not_keep_a_held_task_in_the_loop() {
3891 let dir = tempdir().unwrap();
3892 let queue = Queue::at(dir.path().join("queue"));
3893 let questions = Questions::at(dir.path().join("questions"));
3894 let mut t = task("held");
3895 t.hold_machine(Some("first".to_owned()));
3896 queue.put(&mut t).unwrap();
3897 let reply = |note: &str| {
3898 format!(
3899 r#"{{"decisions":[{{"id":"{}","reason":"{note}","recovery":"hold"}}]}}"#,
3900 t.id
3901 )
3902 };
3903 let cfg_a = config(mock_agent(dir.path(), REPLY, env(&reply("keep held"))));
3904 let cfg_b = config(mock_agent(dir.path(), REPLY, env(&reply("leave held"))));
3905 let mut conductor = Conductor::new();
3906 let mut writes = 0;
3907 for i in 0..6 {
3908 let held = queue.get(&t.id).unwrap();
3909 let before = queue.revision();
3910 conductor
3911 .maybe_run(
3912 if i % 2 == 0 { &cfg_a } else { &cfg_b },
3913 dir.path(),
3914 &queue,
3915 &questions,
3916 dir.path(),
3917 &[],
3918 &[],
3919 &[held],
3920 2,
3921 )
3922 .await;
3923 if queue.revision() != before {
3924 writes += 1;
3925 }
3926 }
3927 assert!(writes <= 2, "the loop must settle, saw {writes} writes");
3928 let mut held = queue.get(&t.id).unwrap();
3929 held.instruction.push_str(" (edited)");
3930 queue.put(&mut held).unwrap();
3931 let held = queue.get(&t.id).unwrap();
3932 assert!(conductor.worth_a_look(&queue, &[], &[held]));
3933 }
3934
3935 #[test]
3936 fn worth_a_look_is_config_free_and_matches_maybe_runs_own_gate() {
3937 let dir = tempdir().unwrap();
3938 let queue = Queue::at(dir.path().join("queue"));
3939 let mut t = task("t");
3940 queue.put(&mut t).unwrap();
3941
3942 let mut conductor = Conductor::new();
3943 assert!(
3944 conductor.worth_a_look(&queue, &[], &[]),
3945 "a conductor that has never run has something to look at"
3946 );
3947
3948 conductor.last_seen = Some(conductor.snapshot(&queue, &[], &[]));
3949 assert!(
3950 !conductor.worth_a_look(&queue, &[], &[]),
3951 "nothing changed and nothing is stalled or finished"
3952 );
3953 assert!(
3954 conductor.worth_a_look(&queue, &[t.clone()], &[]),
3955 "a stalled task is worth a look even at the same revision"
3956 );
3957 assert!(
3958 conductor.worth_a_look(&queue, &[], &[t.clone()]),
3959 "a finished task is worth a look even at the same revision"
3960 );
3961 }
3962
3963 #[tokio::test]
3964 async fn the_conduct_path_never_calls_ask_and_wait() {
3965 let dir = tempdir().unwrap();
3971 let queue = Queue::at(dir.path().join("queue"));
3972 let questions = Questions::at(dir.path().join("questions"));
3973 let mut t = task("asks without blocking");
3974 queue.put(&mut t).unwrap();
3975
3976 apply(
3977 &queue,
3978 &questions,
3979 &Verdict {
3980 decisions: vec![Decision {
3981 id: t.id.clone(),
3982 question: Some("ok?".to_owned()),
3983 ..Decision::default()
3984 }],
3985 },
3986 )
3987 .unwrap();
3988 assert_eq!(queue.get(&t.id).unwrap().status, TaskStatus::Blocked);
3990 }
3991
3992 #[test]
3993 fn a_pinned_resume_is_not_held_or_requeued_by_the_conductor() {
3994 let mut t = Task::new("t".into(), "t".into(), PathBuf::new(), Source::Human);
3995 t.resume_override = Some(crate::queue::OperatorResume {
3996 question_id: "q".into(),
3997 at: jiff::Timestamp::now(),
3998 conductor_rehold: None,
3999 forced: false,
4000 pinned_run: Some("run-1".into()),
4001 });
4002 assert!(pinned_resume(&t));
4003 assert!(!may_hold(&mut t, "waiting for magi resume"));
4004 assert!(
4005 t.resume_override
4006 .as_ref()
4007 .unwrap()
4008 .conductor_rehold
4009 .is_none(),
4010 "a refused hold is not recorded as an override"
4011 );
4012 t.resume_override = None;
4013 assert!(!pinned_resume(&t));
4014 assert!(may_hold(&mut t, "no override, so a hold is allowed"));
4015 }
4016
4017 #[test]
4018 fn a_pinned_resume_is_not_blocked_by_a_conductor_question_or_dependency() {
4019 let dir = tempdir().unwrap();
4020 let queue = Queue::at(dir.path().join("queue"));
4021 let questions = Questions::at(dir.path().join("questions"));
4022 let mut t = task("resume me");
4023 t.resume_override = Some(crate::queue::OperatorResume {
4024 question_id: "q".into(),
4025 at: jiff::Timestamp::now(),
4026 conductor_rehold: None,
4027 forced: true,
4028 pinned_run: Some("run-1".into()),
4029 });
4030 queue.put(&mut t).unwrap();
4031
4032 apply(
4033 &queue,
4034 &questions,
4035 &Verdict {
4036 decisions: vec![
4037 Decision {
4038 id: t.id.clone(),
4039 question: Some("really?".to_owned()),
4040 ..Decision::default()
4041 },
4042 Decision {
4043 id: t.id.clone(),
4044 blocked_by: vec!["other".to_owned()],
4045 ..Decision::default()
4046 },
4047 ],
4048 },
4049 )
4050 .unwrap();
4051
4052 assert_eq!(queue.get(&t.id).unwrap().status, TaskStatus::Queued);
4053 assert!(questions.list().is_empty());
4054 }
4055}