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