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.claim(&task.id)?;
111 queue.put(&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 let claim = queue
320 .claim(&task.id)
321 .with_context(|| format!("could not claim a new task for run {}", state.id))?;
322 task.start(state.id.clone());
323 queue
324 .put(&mut task)
325 .with_context(|| format!("could not file a new task for run {}", state.id))?;
326 Ok(Some(Adopted {
327 queue,
328 task: Some(task),
329 claim: Some(claim),
330 quota_before: state.quota.clone(),
331 }))
332}
333
334impl Adopted {
335 pub fn finish(mut self, state: &crate::run::RunState, result: Result<()>) {
337 if let Some(task) = &mut self.task {
338 crate::daemon::finish_attempt(
339 crate::daemon::Opts::default().max_attempts,
340 &self.queue,
341 task,
342 state,
343 &self.quota_before,
344 result,
345 );
346 crate::daemon::hold_if_runnable(&self.queue, task);
347 }
348 drop(self.claim.take());
349 }
350}
351
352#[cfg(test)]
353mod tests {
354 use super::*;
355 use crate::daemon::Status;
356
357 fn filing(repo: &Path) -> Filing {
358 Filing {
359 instruction: "review the branch".to_owned(),
360 title: "review x".to_owned(),
361 repo: repo.to_path_buf(),
362 source: Source::Human,
363 solo: false,
364 overrides: RunOverrides {
365 merge: Some("none".to_owned()),
366 ..RunOverrides::default()
367 },
368 sources: Default::default(),
369 review_of: Some("feat/x".to_owned()),
370 }
371 }
372
373 fn heartbeat(home: &Path, pid: u32, age_secs: i64) {
374 let mut status = Status::new();
375 status.pid = pid;
376 status.updated_at = Timestamp::now()
377 .checked_sub(jiff::SignedDuration::from_secs(age_secs))
378 .unwrap();
379 crate::daemon::write_status_to(&home.join("daemon.json"), &status).unwrap();
380 }
381
382 #[test]
383 fn explicit_sources_survive_filing_queue_storage_and_release() {
384 let dir = tempfile::tempdir().unwrap();
385 let queue = Queue::at(dir.path().join("queue"));
386 let mut direct = filing(dir.path());
387 direct.review_of = None;
388 direct.instruction = "Do not import the historical example abc1234".into();
389 direct.sources = crate::refs::Sources {
390 start_from: Some(crate::refs::Input {
391 token: "feature/selected".into(),
392 sha: "1".repeat(40),
393 provenance: crate::refs::Provenance::ExplicitCli,
394 }),
395 pick_commits: vec![crate::refs::Input {
396 token: "abc5678".into(),
397 sha: "2".repeat(40),
398 provenance: crate::refs::Provenance::ExplicitCli,
399 }],
400 };
401 let selected = direct.sources.clone();
402 let mut task = direct.into_task();
403 queue.put(&mut task).unwrap();
404 let mut restored = queue.get(&task.id).unwrap();
405 assert_eq!(restored.sources, selected);
406 restored.hold_manual(Some("retry preparation".into()));
407 restored.release_operator("test", None);
408 queue.put(&mut restored).unwrap();
409 assert_eq!(queue.get(&task.id).unwrap().sources, selected);
410 assert_eq!(restored.instruction, task.instruction);
411 }
412
413 #[test]
414 fn task_implementers_direct_filing_keeps_options_isolated() {
415 let dir = tempfile::tempdir().unwrap();
416 let mut direct = filing(dir.path());
417 direct.overrides.inherit_daemon_options = true;
418 let task = direct.into_task();
419 assert!(!task.overrides.unwrap().inherit_daemon_options);
420 }
421
422 #[test]
423 fn without_a_loop_the_task_is_claimed_before_anyone_else_can_take_it() {
424 let dir = tempfile::tempdir().unwrap();
425 let q = Queue::at(dir.path().join("queue"));
426 let filed = file(
427 &q,
428 dir.path(),
429 Timestamp::now(),
430 1,
431 filing(dir.path()),
432 false,
433 )
434 .unwrap();
435 let Filed::Standalone { task, claim } = filed else {
436 panic!("no loop is alive");
437 };
438 assert!(!task.urgent);
439 assert_eq!(task.review_of.as_deref(), Some("feat/x"));
440 assert_eq!(
441 task.overrides.as_ref().unwrap().merge.as_deref(),
442 Some("none")
443 );
444 assert!(
445 q.claim(&task.id).is_err(),
446 "a second process must not be able to claim a standalone run's task"
447 );
448 drop(claim);
449 assert!(q.claim(&task.id).is_ok(), "released with the claim");
450 }
451
452 #[test]
453 fn a_live_foreign_loop_gets_an_urgent_unclaimed_task() {
454 let dir = tempfile::tempdir().unwrap();
455 let q = Queue::at(dir.path().join("queue"));
456 heartbeat(dir.path(), 4242, 0);
457 let filed = file(
458 &q,
459 dir.path(),
460 Timestamp::now(),
461 1,
462 filing(dir.path()),
463 false,
464 )
465 .unwrap();
466 let Filed::Daemon { task, pid } = filed else {
467 panic!("a loop is alive");
468 };
469 assert_eq!(pid, Some(4242));
470 assert!(task.urgent);
471 let stored = q.get(&task.id).unwrap();
472 assert_eq!(stored.status, TaskStatus::Queued);
473 assert!(stored.runs.is_empty(), "nothing ran in this process");
474 assert!(q.claim(&task.id).is_ok(), "and this process holds no claim");
475 }
476
477 #[test]
478 fn a_stale_heartbeat_our_own_pid_and_a_dry_run_do_not_count_as_a_loop() {
479 let dir = tempfile::tempdir().unwrap();
480 let q = Queue::at(dir.path().join("queue"));
481 heartbeat(dir.path(), 4242, 3600);
482 assert!(matches!(
483 file(
484 &q,
485 dir.path(),
486 Timestamp::now(),
487 1,
488 filing(dir.path()),
489 false
490 )
491 .unwrap(),
492 Filed::Standalone { .. }
493 ));
494 heartbeat(dir.path(), 7, 0);
495 assert!(matches!(
496 file(
497 &q,
498 dir.path(),
499 Timestamp::now(),
500 7,
501 filing(dir.path()),
502 false
503 )
504 .unwrap(),
505 Filed::Standalone { .. }
506 ));
507 heartbeat(dir.path(), 4242, 0);
508 assert!(matches!(
509 file(
510 &q,
511 dir.path(),
512 Timestamp::now(),
513 1,
514 filing(dir.path()),
515 true
516 )
517 .unwrap(),
518 Filed::Standalone { .. }
519 ));
520 }
521
522 #[tokio::test]
523 async fn following_gives_up_on_a_task_nobody_is_serving() {
524 let dir = tempfile::tempdir().unwrap();
525 let q = Queue::at(dir.path().join("queue"));
526 let mut t = filing(dir.path()).into_task();
527 q.put(&mut t).unwrap();
528 let err = follow(
529 &q,
530 dir.path(),
531 &t.id,
532 Duration::from_millis(5),
533 None,
534 |_| {},
535 |_| {},
536 )
537 .await
538 .unwrap_err();
539 assert!(format!("{err}").contains("no magi loop"), "{err}");
540 assert!(q.claim(&t.id).is_ok(), "never taken over");
541 }
542
543 #[tokio::test]
544 async fn following_returns_when_the_loop_finishes_the_task_and_times_out_otherwise() {
545 let dir = tempfile::tempdir().unwrap();
546 let q = Queue::at(dir.path().join("queue"));
547 heartbeat(dir.path(), 4242, 0);
548 let mut t = filing(dir.path()).into_task();
549 q.put(&mut t).unwrap();
550 let waited = follow(
551 &q,
552 dir.path(),
553 &t.id,
554 Duration::from_millis(5),
555 Some(Duration::from_millis(30)),
556 |_| {},
557 |_| {},
558 )
559 .await
560 .unwrap();
561 assert!(!waited.finished, "the wait ran out, the loop still owns it");
562 let err = waited
563 .unfinished()
564 .expect("an unfinished wait is an error")
565 .to_string();
566 assert!(err.contains(t.short()), "{err}");
567 assert!(
568 err.contains("not finished") || err.contains("not a success"),
569 "{err}"
570 );
571 assert!(err.contains("magi task show"), "{err}");
572 assert_eq!(
573 q.get(&t.id).unwrap().status,
574 TaskStatus::Queued,
575 "the queue is untouched"
576 );
577 t.link_run("20260101-000000-abcd");
578 t.succeed();
579 q.put(&mut t).unwrap();
580 let mut runs = Vec::new();
581 let done = follow(
582 &q,
583 dir.path(),
584 &t.id,
585 Duration::from_millis(5),
586 Some(Duration::from_secs(5)),
587 |r| runs.push(r.to_owned()),
588 |_| {},
589 )
590 .await
591 .unwrap();
592 assert_eq!(done.task.status, TaskStatus::Done);
593 assert!(done.unfinished().is_none());
594 assert_eq!(runs, ["20260101-000000-abcd"]);
595 }
596
597 fn parent_in(q: &Queue, dir: &Path) -> Task {
598 let mut t = filing(dir).into_task();
599 q.put(&mut t).unwrap();
600 t
601 }
602
603 fn follow_up_state(dir: &Path) -> crate::run::RunState {
604 crate::run::RunState::new(
605 dir.to_path_buf(),
606 "main".to_owned(),
607 "abc1234".to_owned(),
608 "review the branch".to_owned(),
609 Config::default(),
610 )
611 }
612
613 #[test]
614 fn an_adopted_review_claims_starts_and_settles_a_runnable_task() {
615 let dir = tempfile::tempdir().unwrap();
616 let q = Queue::at(dir.path().join("queue"));
617 let parent = parent_in(&q, dir.path());
618 let state = follow_up_state(dir.path());
619 let adopted = adopt_in(q.clone(), &state, Some(&parent.id))
620 .unwrap()
621 .expect("adopted");
622 let running = q.get(&parent.id).unwrap();
623 assert_eq!(running.status, TaskStatus::Running);
624 assert_eq!(running.attempts, parent.attempts + 1);
625 assert_eq!(running.runs, std::slice::from_ref(&state.id));
626 assert!(q.claim(&parent.id).is_err(), "exclusive while it runs");
627 adopted.finish(&state, Err(anyhow::anyhow!("boom")));
628 let settled = q.get(&parent.id).unwrap();
629 assert_eq!(settled.status, TaskStatus::Held, "{settled:?}");
630 assert_eq!(
631 settled.runs,
632 std::slice::from_ref(&state.id),
633 "no duplicate run"
634 );
635 assert!(q.claim(&parent.id).is_ok(), "claim released");
636 assert_eq!(q.list().len(), 1, "no second owner was filed");
637 }
638
639 #[test]
640 fn an_ownerless_run_whose_task_cannot_be_filed_is_an_error() {
641 let dir = tempfile::tempdir().unwrap();
642 let blocker = dir.path().join("blocker");
644 std::fs::write(&blocker, "x").unwrap();
645 let q = Queue::at(blocker.join("q"));
646 let state = follow_up_state(dir.path());
647 let err = adopt_in(q, &state, None).expect_err("must not be swallowed");
648 assert!(format!("{err:#}").contains(&state.id), "{err:#}");
649 }
650
651 #[test]
652 fn a_task_claimed_elsewhere_only_gains_the_run() {
653 let dir = tempfile::tempdir().unwrap();
654 let q = Queue::at(dir.path().join("queue"));
655 let parent = parent_in(&q, dir.path());
656 let _theirs = q.claim(&parent.id).unwrap();
657 let state = follow_up_state(dir.path());
658 assert!(
659 adopt_in(q.clone(), &state, Some(&parent.id))
660 .unwrap()
661 .is_none()
662 );
663 let after = q.get(&parent.id).unwrap();
664 assert_eq!(after.status, parent.status);
665 assert_eq!(after.attempts, parent.attempts);
666 assert_eq!(after.runs, std::slice::from_ref(&state.id));
667 assert_eq!(q.list().len(), 1, "no second owner was filed");
668 }
669
670 #[test]
671 fn a_done_task_only_gains_the_run() {
672 let dir = tempfile::tempdir().unwrap();
673 let q = Queue::at(dir.path().join("queue"));
674 let mut parent = parent_in(&q, dir.path());
675 parent.succeed();
676 q.put(&mut parent).unwrap();
677 let state = follow_up_state(dir.path());
678 assert!(
679 adopt_in(q.clone(), &state, Some(&parent.id))
680 .unwrap()
681 .is_none()
682 );
683 let after = q.get(&parent.id).unwrap();
684 assert_eq!(after.status, TaskStatus::Done);
685 assert_eq!(after.attempts, parent.attempts);
686 assert_eq!(after.runs, std::slice::from_ref(&state.id));
687 assert!(q.claim(&parent.id).is_ok(), "claim released");
688 }
689
690 #[test]
691 fn a_held_task_is_claimed_and_settled_by_its_follow_up_review() {
692 let dir = tempfile::tempdir().unwrap();
693 let q = Queue::at(dir.path().join("queue"));
694 let mut parent = parent_in(&q, dir.path());
695 parent.hold_manual(Some("the run did not finish: stale".to_owned()));
696 q.put(&mut parent).unwrap();
697 let state = follow_up_state(dir.path());
698 let adopted = adopt_in(q.clone(), &state, Some(&parent.id))
699 .unwrap()
700 .expect("adopted");
701 assert_eq!(q.get(&parent.id).unwrap().status, TaskStatus::Running);
702 assert!(q.claim(&parent.id).is_err(), "claimed while it runs");
703 adopted.finish(&state, Err(anyhow::anyhow!("fresh failure")));
704 let after = q.get(&parent.id).unwrap();
705 assert_eq!(after.status, TaskStatus::Held);
706 assert!(
707 after
708 .hold_reason
709 .as_deref()
710 .unwrap()
711 .contains("fresh failure"),
712 "{after:?}"
713 );
714 assert_eq!(after.runs, std::slice::from_ref(&state.id));
715 assert!(q.claim(&parent.id).is_ok(), "claim released");
716 }
717}