1use std::path::{Path, PathBuf};
19use std::time::{Duration, Instant};
20
21use anyhow::{Context, Result, bail};
22use jiff::Timestamp;
23
24use crate::config::Config;
25use crate::queue::{Claim, Queue, RunOverrides, Source, Task, TaskStatus};
26
27#[derive(Debug, Clone)]
31pub struct Filing {
32 pub instruction: String,
34 pub title: String,
36 pub repo: PathBuf,
38 pub source: Source,
40 pub solo: bool,
42 pub overrides: RunOverrides,
44 pub review_of: Option<String>,
46 pub sources: crate::refs::Sources,
48}
49
50impl Filing {
51 fn into_task(self) -> Task {
52 let mut task = Task::new(self.title, self.instruction, self.repo, self.source);
53 task.sources = self.sources;
54 task.solo = self.solo;
55 task.review_of = self.review_of;
56 let mut overrides = self.overrides;
57 overrides.inherit_daemon_options = false;
58 task.overrides = Some(overrides);
59 task
60 }
61}
62
63#[derive(Debug)]
65pub enum Filed {
66 Daemon {
69 task: Task,
71 pid: Option<u32>,
73 },
74 Standalone {
77 task: Task,
79 claim: Claim,
81 },
82}
83
84pub fn file(
89 queue: &Queue,
90 home: &Path,
91 now: Timestamp,
92 own_pid: u32,
93 filing: Filing,
94 force_local: bool,
95) -> Result<Filed> {
96 let mut task = filing.into_task();
97 let owner = if force_local {
98 None
99 } else {
100 crate::daemon::foreign_loop(crate::daemon::read_status(home).as_ref(), now, own_pid)
101 };
102 match owner {
103 Some(pid) => {
104 task.urgent = true;
105 queue.put(&mut task).context("file the task")?;
106 Ok(Filed::Daemon { task, pid })
107 }
108 None => {
109 let claim = queue.file_claimed(&mut task).context("file the task")?;
112 Ok(Filed::Standalone { task, claim })
113 }
114 }
115}
116
117pub fn config_for(task: &Task, repo: &Path) -> Result<Config> {
120 let path = task.overrides.as_ref().and_then(|o| o.config.as_deref());
121 let (mut cfg, _) = Config::discover(repo, path)?;
122 if let Some(o) = &task.overrides {
123 o.apply(&mut cfg);
124 }
125 if task.solo {
126 cfg.graph.implementers = 1;
127 }
128 Ok(cfg)
129}
130
131#[derive(Debug)]
133pub struct Followed {
134 pub task: Task,
136 pub finished: bool,
139}
140
141impl Followed {
142 pub fn unfinished(&self) -> Option<anyhow::Error> {
146 if self.finished {
147 return None;
148 }
149 Some(anyhow::anyhow!(
150 "task {id} has not finished: it is still running under the loop. \
151 This is not a success; the outcome is not known yet. Read it with \
152 `magi task show {id}`. The loop keeps the task, so do not run it again.",
153 id = self.task.short()
154 ))
155 }
156}
157
158pub async fn follow(
169 queue: &Queue,
170 home: &Path,
171 id: &str,
172 poll: Duration,
173 max_wait: Option<Duration>,
174 mut seen: impl FnMut(&str),
175 mut progress: impl FnMut(&str),
176) -> Result<Followed> {
177 let began = Instant::now();
178 let mut announced = 0usize;
179 let mut last_status = String::new();
180 loop {
181 let task = queue.get(id)?;
182 for run in task.runs.iter().skip(announced) {
183 seen(run);
184 }
185 announced = task.runs.len();
186 if let Some(run) = task.runs.last()
187 && crate::run::try_home().is_some()
188 && let Ok(state) = crate::run::RunState::load(run)
189 {
190 let line = format!("run {}: {}", state.short(), state.status.as_str());
191 if line != last_status {
192 progress(&line);
193 last_status = line;
194 }
195 }
196 if matches!(
197 task.status,
198 TaskStatus::Done | TaskStatus::Held | TaskStatus::Blocked
199 ) {
200 return Ok(Followed {
201 task,
202 finished: true,
203 });
204 }
205 let reading = crate::daemon::read_status(home);
206 if crate::daemon::foreign_loop(reading.as_ref(), Timestamp::now(), std::process::id())
207 .is_none()
208 {
209 bail!(
210 "no magi loop is serving the queue any more, and task {} is still {}; it was \
211 not taken over here (the loop may only be slow to heartbeat). Start `magi \
212 serve` to carry on, or follow it with `magi task show {}`",
213 task.short(),
214 task.status.as_str(),
215 task.short()
216 );
217 }
218 if max_wait.is_some_and(|m| began.elapsed() >= m) {
219 return Ok(Followed {
220 task,
221 finished: false,
222 });
223 }
224 tokio::time::sleep(poll).await;
225 }
226}
227
228#[derive(Debug)]
231pub struct Adopted {
232 queue: Queue,
233 task: Option<Task>,
236 claim: Option<Claim>,
237 quota_before: Vec<crate::run::QuotaLoss>,
238}
239
240pub fn adopt(
249 state: &crate::run::RunState,
250 parent_task: Option<&str>,
251) -> anyhow::Result<Option<Adopted>> {
252 if crate::run::try_home().is_none() {
253 return Ok(None);
254 }
255 adopt_in(Queue::open(), state, parent_task)
256}
257
258fn adopt_in(
259 queue: Queue,
260 state: &crate::run::RunState,
261 parent_task: Option<&str>,
262) -> anyhow::Result<Option<Adopted>> {
263 use anyhow::Context as _;
264 if let Some(parent) = parent_task
265 && let Ok(id) = queue.resolve_id(parent)
266 {
267 return Ok(match queue.claim(&id) {
269 Ok(claim) => {
270 let started = queue.get(&id).and_then(|mut task| {
271 if !(task.status.runnable() || task.status == TaskStatus::Held) {
279 return Ok(None);
280 }
281 task.start(state.id.clone());
282 queue.put(&mut task)?;
283 Ok(Some(task))
284 });
285 match started {
286 Ok(None) => {
287 drop(claim);
288 let _ = queue.link_run(&id, &state.id);
289 None
290 }
291 Ok(Some(task)) => Some(Adopted {
292 queue,
293 task: Some(task),
294 claim: Some(claim),
295 quota_before: state.quota.clone(),
296 }),
297 Err(e) => {
298 tracing::warn!("could not start task {id} for run {}: {e:#}", state.id);
299 drop(claim);
300 let _ = queue.link_run(&id, &state.id);
301 None
302 }
303 }
304 }
305 Err(e) => {
306 tracing::warn!("task {id} is claimed elsewhere ({e:#}); linking run only");
308 let _ = queue.link_run(&id, &state.id);
309 None
310 }
311 });
312 }
313 let mut task = Task::new(
314 crate::queue::title_from(&state.instruction, 72),
315 state.instruction.clone(),
316 state.repo.clone(),
317 Source::Human,
318 );
319 task.start(state.id.clone());
320 let claim = queue
321 .file_claimed(&mut task)
322 .with_context(|| format!("could not file a new task for run {}", state.id))?;
323 Ok(Some(Adopted {
324 queue,
325 task: Some(task),
326 claim: Some(claim),
327 quota_before: state.quota.clone(),
328 }))
329}
330
331impl Adopted {
332 pub fn finish(mut self, state: &crate::run::RunState, result: Result<()>) {
334 if let Some(task) = &mut self.task {
335 crate::daemon::finish_attempt(
336 crate::daemon::Opts::default().max_attempts,
337 &self.queue,
338 task,
339 state,
340 &self.quota_before,
341 result,
342 );
343 crate::daemon::hold_if_runnable(&self.queue, task);
344 }
345 drop(self.claim.take());
346 }
347}
348
349#[cfg(test)]
350mod tests {
351 use super::*;
352 use crate::daemon::Status;
353
354 fn filing(repo: &Path) -> Filing {
355 Filing {
356 instruction: "review the branch".to_owned(),
357 title: "review x".to_owned(),
358 repo: repo.to_path_buf(),
359 source: Source::Human,
360 solo: false,
361 overrides: RunOverrides {
362 merge: Some("none".to_owned()),
363 ..RunOverrides::default()
364 },
365 sources: Default::default(),
366 review_of: Some("feat/x".to_owned()),
367 }
368 }
369
370 fn heartbeat(home: &Path, pid: u32, age_secs: i64) {
371 let mut status = Status::new();
372 status.pid = pid;
373 status.updated_at = Timestamp::now()
374 .checked_sub(jiff::SignedDuration::from_secs(age_secs))
375 .unwrap();
376 crate::daemon::write_status_to(&home.join("daemon.json"), &status).unwrap();
377 }
378
379 #[test]
380 fn explicit_sources_survive_filing_queue_storage_and_release() {
381 let dir = tempfile::tempdir().unwrap();
382 let queue = Queue::at(dir.path().join("queue"));
383 let mut direct = filing(dir.path());
384 direct.review_of = None;
385 direct.instruction = "Do not import the historical example abc1234".into();
386 direct.sources = crate::refs::Sources {
387 start_from: Some(crate::refs::Input {
388 token: "feature/selected".into(),
389 sha: "1".repeat(40),
390 provenance: crate::refs::Provenance::ExplicitCli,
391 }),
392 pick_commits: vec![crate::refs::Input {
393 token: "abc5678".into(),
394 sha: "2".repeat(40),
395 provenance: crate::refs::Provenance::ExplicitCli,
396 }],
397 };
398 let selected = direct.sources.clone();
399 let mut task = direct.into_task();
400 queue.put(&mut task).unwrap();
401 let mut restored = queue.get(&task.id).unwrap();
402 assert_eq!(restored.sources, selected);
403 restored.hold_manual(Some("retry preparation".into()));
404 restored.release_operator("test", None);
405 queue.put(&mut restored).unwrap();
406 assert_eq!(queue.get(&task.id).unwrap().sources, selected);
407 assert_eq!(restored.instruction, task.instruction);
408 }
409
410 #[test]
411 fn task_implementers_direct_filing_keeps_options_isolated() {
412 let dir = tempfile::tempdir().unwrap();
413 let mut direct = filing(dir.path());
414 direct.overrides.inherit_daemon_options = true;
415 let task = direct.into_task();
416 assert!(!task.overrides.unwrap().inherit_daemon_options);
417 }
418
419 #[test]
420 fn without_a_loop_the_task_is_claimed_before_anyone_else_can_take_it() {
421 let dir = tempfile::tempdir().unwrap();
422 let q = Queue::at(dir.path().join("queue"));
423 let filed = file(
424 &q,
425 dir.path(),
426 Timestamp::now(),
427 1,
428 filing(dir.path()),
429 false,
430 )
431 .unwrap();
432 let Filed::Standalone { task, claim } = filed else {
433 panic!("no loop is alive");
434 };
435 assert!(!task.urgent);
436 assert_eq!(task.review_of.as_deref(), Some("feat/x"));
437 assert_eq!(
438 task.overrides.as_ref().unwrap().merge.as_deref(),
439 Some("none")
440 );
441 assert!(
442 q.claim(&task.id).is_err(),
443 "a second process must not be able to claim a standalone run's task"
444 );
445 drop(claim);
446 assert!(q.claim(&task.id).is_ok(), "released with the claim");
447 }
448
449 #[test]
450 fn a_live_foreign_loop_gets_an_urgent_unclaimed_task() {
451 let dir = tempfile::tempdir().unwrap();
452 let q = Queue::at(dir.path().join("queue"));
453 heartbeat(dir.path(), 4242, 0);
454 let filed = file(
455 &q,
456 dir.path(),
457 Timestamp::now(),
458 1,
459 filing(dir.path()),
460 false,
461 )
462 .unwrap();
463 let Filed::Daemon { task, pid } = filed else {
464 panic!("a loop is alive");
465 };
466 assert_eq!(pid, Some(4242));
467 assert!(task.urgent);
468 let stored = q.get(&task.id).unwrap();
469 assert_eq!(stored.status, TaskStatus::Queued);
470 assert!(stored.runs.is_empty(), "nothing ran in this process");
471 assert!(q.claim(&task.id).is_ok(), "and this process holds no claim");
472 }
473
474 #[test]
475 fn a_stale_heartbeat_our_own_pid_and_a_dry_run_do_not_count_as_a_loop() {
476 let dir = tempfile::tempdir().unwrap();
477 let q = Queue::at(dir.path().join("queue"));
478 heartbeat(dir.path(), 4242, 3600);
479 assert!(matches!(
480 file(
481 &q,
482 dir.path(),
483 Timestamp::now(),
484 1,
485 filing(dir.path()),
486 false
487 )
488 .unwrap(),
489 Filed::Standalone { .. }
490 ));
491 heartbeat(dir.path(), 7, 0);
492 assert!(matches!(
493 file(
494 &q,
495 dir.path(),
496 Timestamp::now(),
497 7,
498 filing(dir.path()),
499 false
500 )
501 .unwrap(),
502 Filed::Standalone { .. }
503 ));
504 heartbeat(dir.path(), 4242, 0);
505 assert!(matches!(
506 file(
507 &q,
508 dir.path(),
509 Timestamp::now(),
510 1,
511 filing(dir.path()),
512 true
513 )
514 .unwrap(),
515 Filed::Standalone { .. }
516 ));
517 }
518
519 #[tokio::test]
520 async fn following_gives_up_on_a_task_nobody_is_serving() {
521 let dir = tempfile::tempdir().unwrap();
522 let q = Queue::at(dir.path().join("queue"));
523 let mut t = filing(dir.path()).into_task();
524 q.put(&mut t).unwrap();
525 let err = follow(
526 &q,
527 dir.path(),
528 &t.id,
529 Duration::from_millis(5),
530 None,
531 |_| {},
532 |_| {},
533 )
534 .await
535 .unwrap_err();
536 assert!(format!("{err}").contains("no magi loop"), "{err}");
537 assert!(q.claim(&t.id).is_ok(), "never taken over");
538 }
539
540 #[tokio::test]
541 async fn following_returns_when_the_loop_finishes_the_task_and_times_out_otherwise() {
542 let dir = tempfile::tempdir().unwrap();
543 let q = Queue::at(dir.path().join("queue"));
544 heartbeat(dir.path(), 4242, 0);
545 let mut t = filing(dir.path()).into_task();
546 q.put(&mut t).unwrap();
547 let waited = follow(
548 &q,
549 dir.path(),
550 &t.id,
551 Duration::from_millis(5),
552 Some(Duration::from_millis(30)),
553 |_| {},
554 |_| {},
555 )
556 .await
557 .unwrap();
558 assert!(!waited.finished, "the wait ran out, the loop still owns it");
559 let err = waited
560 .unfinished()
561 .expect("an unfinished wait is an error")
562 .to_string();
563 assert!(err.contains(t.short()), "{err}");
564 assert!(
565 err.contains("not finished") || err.contains("not a success"),
566 "{err}"
567 );
568 assert!(err.contains("magi task show"), "{err}");
569 assert_eq!(
570 q.get(&t.id).unwrap().status,
571 TaskStatus::Queued,
572 "the queue is untouched"
573 );
574 t.link_run("20260101-000000-abcd");
575 t.succeed();
576 q.put(&mut t).unwrap();
577 let mut runs = Vec::new();
578 let done = follow(
579 &q,
580 dir.path(),
581 &t.id,
582 Duration::from_millis(5),
583 Some(Duration::from_secs(5)),
584 |r| runs.push(r.to_owned()),
585 |_| {},
586 )
587 .await
588 .unwrap();
589 assert_eq!(done.task.status, TaskStatus::Done);
590 assert!(done.unfinished().is_none());
591 assert_eq!(runs, ["20260101-000000-abcd"]);
592 }
593
594 fn parent_in(q: &Queue, dir: &Path) -> Task {
595 let mut t = filing(dir).into_task();
596 q.put(&mut t).unwrap();
597 t
598 }
599
600 fn follow_up_state(dir: &Path) -> crate::run::RunState {
601 crate::run::RunState::new(
602 dir.to_path_buf(),
603 "main".to_owned(),
604 "abc1234".to_owned(),
605 "review the branch".to_owned(),
606 Config::default(),
607 )
608 }
609
610 #[test]
611 fn an_adopted_review_claims_starts_and_settles_a_runnable_task() {
612 let dir = tempfile::tempdir().unwrap();
613 let q = Queue::at(dir.path().join("queue"));
614 let parent = parent_in(&q, dir.path());
615 let state = follow_up_state(dir.path());
616 let adopted = adopt_in(q.clone(), &state, Some(&parent.id))
617 .unwrap()
618 .expect("adopted");
619 let running = q.get(&parent.id).unwrap();
620 assert_eq!(running.status, TaskStatus::Running);
621 assert_eq!(running.attempts, parent.attempts + 1);
622 assert_eq!(running.runs, std::slice::from_ref(&state.id));
623 assert!(q.claim(&parent.id).is_err(), "exclusive while it runs");
624 adopted.finish(&state, Err(anyhow::anyhow!("boom")));
625 let settled = q.get(&parent.id).unwrap();
626 assert_eq!(settled.status, TaskStatus::Held, "{settled:?}");
627 assert_eq!(
628 settled.runs,
629 std::slice::from_ref(&state.id),
630 "no duplicate run"
631 );
632 assert!(q.claim(&parent.id).is_ok(), "claim released");
633 assert_eq!(q.list().len(), 1, "no second owner was filed");
634 }
635
636 #[test]
637 fn an_ownerless_run_whose_task_cannot_be_filed_is_an_error() {
638 let dir = tempfile::tempdir().unwrap();
639 let blocker = dir.path().join("blocker");
641 std::fs::write(&blocker, "x").unwrap();
642 let q = Queue::at(blocker.join("q"));
643 let state = follow_up_state(dir.path());
644 let err = adopt_in(q, &state, None).expect_err("must not be swallowed");
645 assert!(format!("{err:#}").contains(&state.id), "{err:#}");
646 }
647
648 #[test]
649 fn a_task_claimed_elsewhere_only_gains_the_run() {
650 let dir = tempfile::tempdir().unwrap();
651 let q = Queue::at(dir.path().join("queue"));
652 let parent = parent_in(&q, dir.path());
653 let _theirs = q.claim(&parent.id).unwrap();
654 let state = follow_up_state(dir.path());
655 assert!(
656 adopt_in(q.clone(), &state, Some(&parent.id))
657 .unwrap()
658 .is_none()
659 );
660 let after = q.get(&parent.id).unwrap();
661 assert_eq!(after.status, parent.status);
662 assert_eq!(after.attempts, parent.attempts);
663 assert_eq!(after.runs, std::slice::from_ref(&state.id));
664 assert_eq!(q.list().len(), 1, "no second owner was filed");
665 }
666
667 #[test]
668 fn a_done_task_only_gains_the_run() {
669 let dir = tempfile::tempdir().unwrap();
670 let q = Queue::at(dir.path().join("queue"));
671 let mut parent = parent_in(&q, dir.path());
672 parent.succeed();
673 q.put(&mut parent).unwrap();
674 let state = follow_up_state(dir.path());
675 assert!(
676 adopt_in(q.clone(), &state, Some(&parent.id))
677 .unwrap()
678 .is_none()
679 );
680 let after = q.get(&parent.id).unwrap();
681 assert_eq!(after.status, TaskStatus::Done);
682 assert_eq!(after.attempts, parent.attempts);
683 assert_eq!(after.runs, std::slice::from_ref(&state.id));
684 assert!(q.claim(&parent.id).is_ok(), "claim released");
685 }
686
687 #[test]
688 fn a_held_task_is_claimed_and_settled_by_its_follow_up_review() {
689 let dir = tempfile::tempdir().unwrap();
690 let q = Queue::at(dir.path().join("queue"));
691 let mut parent = parent_in(&q, dir.path());
692 parent.hold_manual(Some("the run did not finish: stale".to_owned()));
693 q.put(&mut parent).unwrap();
694 let state = follow_up_state(dir.path());
695 let adopted = adopt_in(q.clone(), &state, Some(&parent.id))
696 .unwrap()
697 .expect("adopted");
698 assert_eq!(q.get(&parent.id).unwrap().status, TaskStatus::Running);
699 assert!(q.claim(&parent.id).is_err(), "claimed while it runs");
700 adopted.finish(&state, Err(anyhow::anyhow!("fresh failure")));
701 let after = q.get(&parent.id).unwrap();
702 assert_eq!(after.status, TaskStatus::Held);
703 assert!(
704 after
705 .hold_reason
706 .as_deref()
707 .unwrap()
708 .contains("fresh failure"),
709 "{after:?}"
710 );
711 assert_eq!(after.runs, std::slice::from_ref(&state.id));
712 assert!(q.claim(&parent.id).is_ok(), "claim released");
713 }
714}