1use std::path::Path;
38
39use anyhow::{Context, Result, bail, ensure};
40
41use crate::queue::{Queue, Task, TaskStatus};
42use crate::talk::{Talk, Talks};
43
44#[cfg(any(unix, windows))]
45pub(crate) mod bridge;
46
47#[derive(Debug, serde::Serialize, serde::Deserialize)]
50#[serde(tag = "operation", deny_unknown_fields)]
51pub enum Operation {
52 GrantRepair {
54 request: crate::repair_budget::Request,
56 },
57 Add {
59 filing: Box<Filing>,
61 },
62 Done {
64 task: String,
66 },
67 Priority {
69 task: String,
71 priority: i32,
73 },
74 MergePr {
76 repo: String,
78 number: u64,
80 head: String,
82 request: String,
84 },
85 Resume {
87 run: String,
89 },
90 Hold {
92 task: String,
94 reason: String,
96 },
97 Release {
99 task: String,
101 },
102 Retry {
104 task: String,
106 title: Option<String>,
108 instruction: String,
110 },
111 RerunChecks {
113 run: String,
115 request: String,
117 head: String,
119 workflows: Vec<String>,
121 },
122 Decide {
124 question: String,
126 choice: String,
128 #[serde(default)]
130 title: Option<String>,
131 },
132 Answer {
135 question: String,
137 reply: String,
139 #[serde(default)]
141 title: Option<String>,
142 },
143 ReplyOther {
147 question: String,
149 text: String,
151 },
152 ReconcileRetention {
154 task: String,
156 authority: String,
158 apply: Option<String>,
160 },
161 Abandon {
163 run: String,
165 },
166}
167
168#[derive(Debug, serde::Serialize, serde::Deserialize)]
170#[serde(deny_unknown_fields)]
171pub struct Filing {
172 pub title: String,
174 pub instruction: String,
176 pub repo: std::path::PathBuf,
178 pub source: crate::queue::Source,
180 pub sources: crate::refs::Sources,
182 pub hold: Option<String>,
184 pub priority: i32,
186 pub solo: bool,
188 pub urgent: bool,
190 pub implementers: Option<usize>,
192 pub attach: Vec<std::path::PathBuf>,
194}
195
196pub fn has_channel() -> bool {
199 #[cfg(any(unix, windows))]
200 {
201 std::env::var_os(bridge::CHANNEL_ENV).is_some()
202 }
203 #[cfg(not(any(unix, windows)))]
204 {
205 false
206 }
207}
208
209pub async fn dispatch(
212 queue: &Queue,
213 home: &Path,
214 talks: &Talks,
215 talk: &str,
216 operation: Operation,
217) -> Result<String> {
218 #[cfg(unix)]
219 if std::env::var_os(bridge::CHANNEL_ENV).is_some() {
220 match bridge::request(&operation) {
223 Err(error)
224 if error.is::<bridge::Unavailable>()
225 && std::env::var("MAGI_ALLOW_WRITE").as_deref() == Ok("1") => {}
226 result => return result,
227 }
228 }
229 #[cfg(windows)]
230 if std::env::var_os(bridge::CHANNEL_ENV).is_some() {
231 return bridge::request(&operation).await;
232 }
233 execute(queue, home, talks, talk, operation).await
234}
235
236async fn execute(
237 queue: &Queue,
238 home: &Path,
239 talks: &Talks,
240 talk: &str,
241 operation: Operation,
242) -> Result<String> {
243 authorize(talks, talk, crate::queue::CHAT_NODE)?;
244 Ok(match operation {
245 Operation::Add { filing } => {
246 let mut task = Task::new(filing.title, filing.instruction, filing.repo, filing.source);
247 task.sources = filing.sources;
248 if let Some(reason) = filing.hold {
249 task.hold_manual(Some(reason));
250 }
251 task.priority = filing.priority;
252 task.solo = filing.solo;
253 task.urgent = filing.urgent;
254 if filing.implementers.is_some() {
255 task.overrides = Some(crate::queue::RunOverrides {
256 candidates: filing.implementers,
257 ..Default::default()
258 });
259 }
260 if filing.attach.is_empty() {
261 queue.put(&mut task)?;
262 } else {
263 queue.attach_and_put(&mut task, &filing.attach)?;
264 }
265 serde_json::to_string(&task)?
266 }
267 Operation::Done { task } => {
268 let t = done(queue, home, &task)?;
269 format!("done {} {}", t.short(), t.title)
270 }
271 Operation::Priority { task, priority } => {
272 let t = set_priority(queue, &task, priority)?;
273 format!("{} priority now {} - {}", t.short(), t.priority, t.title)
274 }
275 Operation::MergePr {
276 repo,
277 number,
278 head,
279 request,
280 } => {
281 crate::chat_merge::merge(
282 home,
283 talks,
284 talk,
285 crate::chat_merge::Request {
286 repo,
287 number,
288 head,
289 request,
290 },
291 )
292 .await?
293 }
294 Operation::GrantRepair { request } => {
295 serde_json::to_string(
298 &Box::pin(crate::repair_budget::grant(queue, home, request)).await?,
299 )?
300 }
301 Operation::Resume { run } => {
302 let t = resume(queue, home, &run)?;
303 format!(
304 "{}; the loop (`magi serve`) resumes the run under its execution lease",
305 describe(&t, "resume queued for")
306 )
307 }
308 Operation::Hold { task, reason } => describe(&hold(queue, &task, &reason)?, "held"),
309 Operation::Release { task } => describe(&release(queue, &task)?, "released"),
310 Operation::Retry {
311 task,
312 title,
313 instruction,
314 } => describe(
315 &retry(queue, &task, title, &instruction)?,
316 "retry queued for",
317 ),
318 Operation::RerunChecks {
319 run,
320 request,
321 head,
322 workflows,
323 } => {
324 let ids = rerun_checks(queue, home, talk, &run, &request, &head, &workflows).await?;
325 format!("requested a rerun of workflow run(s) {}", ids.join(", "))
326 }
327 Operation::Decide {
328 question,
329 choice,
330 title,
331 } => {
332 let q = record_decision(home, &question, &choice, title.as_deref())?;
333 format!("recorded `{choice}` for decision {}", q.short())
334 }
335 Operation::Answer {
336 question,
337 reply,
338 title,
339 } => crate::ask::record_answer(
340 &crate::ask::Questions::at(home.join("questions")),
341 &question,
342 &reply,
343 title.as_deref(),
344 )?,
345 Operation::ReplyOther { question, text } => crate::consult::reply_other(
346 &crate::ask::Questions::at(home.join("questions")),
347 &question,
348 &text,
349 )?,
350 Operation::ReconcileRetention {
351 task,
352 authority,
353 apply,
354 } => {
355 let path = Box::pin(crate::retention::reconcile::run_chat(
356 home,
357 &task,
358 &authority,
359 apply.as_deref(),
360 crate::retention::reconcile::ChatRequest {
361 talks,
362 conversation: talk,
363 },
364 ))
365 .await?;
366 if apply.is_none() {
367 crate::retention::reconcile::chat_summary(&path)?
369 } else {
370 format!(
371 "Applied saved plan {}",
372 apply.as_deref().unwrap_or_default()
373 )
374 }
375 }
376 Operation::Abandon { run } => {
377 abandon(queue, home, &run)?;
378 format!("abandoned run {run}")
379 }
380 })
381}
382
383pub fn decide(
387 talks: &Talks,
388 home: &Path,
389 talk: &str,
390 question: &str,
391 choice: &str,
392) -> Result<crate::ask::Question> {
393 authorize(talks, talk, crate::queue::CHAT_NODE)?;
394 record_decision(home, question, choice, None)
395}
396
397fn record_decision(
403 home: &Path,
404 question: &str,
405 choice: &str,
406 title: Option<&str>,
407) -> Result<crate::ask::Question> {
408 use crate::ask::{Answer, Questions};
409 let questions = Questions::at(home.join("questions"));
410 let id = questions.resolve_id(question)?;
411 let q = questions.get(&id)?;
412 ensure!(
413 crate::task_chat::awaits_decision(&q),
414 "question {} is not a decision the machinery waits on; answer it with `magi answer`",
415 q.short()
416 );
417 let (q, ()) = questions.update(&q.id, |r| {
418 ensure!(
419 r.status.open(),
420 "question {} is already {}",
421 r.short(),
422 r.status.as_str()
423 );
424 let reply = r.resolve_reply(choice)?.to_owned();
425 let replacement =
426 r.node == crate::github_text::ASK_NODE && reply == crate::github_text::USE_MY_TEXT;
427 ensure!(
428 replacement || title.is_none(),
429 "--title is only valid for a publication decision's `use my text` choice"
430 );
431 let title = if replacement {
432 let title =
433 title.context("use my text needs --title with the literal replacement title")?;
434 ensure!(
435 !title.trim().is_empty(),
436 "replacement title must not be empty"
437 );
438 Some(title.to_owned())
439 } else {
440 None
441 };
442 let answer = if r.free_text() {
443 Answer::Text(reply)
444 } else {
445 Answer::Choice(reply)
446 };
447 r.publication_title = title;
448 r.answer(answer)
449 })?;
450 Ok(q)
451}
452
453pub fn authorize(talks: &Talks, run: &str, node: &str) -> Result<Talk> {
455 if node != crate::queue::CHAT_NODE || run.trim().is_empty() {
456 bail!("task control requires MAGI_NODE=chat and MAGI_RUN from a standing chat");
457 }
458 let id = crate::chat_watch::authorize(talks, run, node)?;
459 talks.get(&id)
460}
461
462fn run_task(queue: &Queue, run: &str) -> Result<Task> {
464 ensure!(
465 crate::run::is_run_id(run),
466 "an execution command needs the full run id"
467 );
468 queue
469 .list()
470 .into_iter()
471 .find(|t| t.runs.iter().any(|r| r == run))
472 .with_context(|| format!("run {run} belongs to no task"))
473}
474
475fn claimed(queue: &Queue, reference: &str) -> Result<(crate::queue::Claim, Task)> {
478 let id = queue.resolve_id(reference)?;
479 let claim = queue
480 .claim(&id)
481 .with_context(|| format!("task {id} is claimed by a running daemon"))?;
482 let task = queue.get(&id)?;
483 Ok((claim, task))
484}
485
486fn check_hold_release(task: &Task, operation: &str) -> Result<()> {
487 match task.status {
488 TaskStatus::Queued
489 | TaskStatus::Held
490 | TaskStatus::Failed
491 | TaskStatus::Blocked
492 | TaskStatus::Parked => Ok(()),
493 TaskStatus::Done => bail!(
494 "cannot {operation} task {} (done): completed work must not be restarted; create a new task instead",
495 task.id
496 ),
497 TaskStatus::Running => bail!(
498 "cannot {operation} task {} (running): its run may still be active; use resume or abandon for the existing run instead",
499 task.id
500 ),
501 }
502}
503
504pub fn done(queue: &Queue, home: &Path, reference: &str) -> Result<Task> {
506 let (_claim, mut task) = claimed(queue, reference)?;
507 task.succeed();
508 crate::daemon::supersede_prior_runs(&task, home);
510 queue.put(&mut task)?;
511 Ok(task)
512}
513
514pub fn set_priority(queue: &Queue, reference: &str, priority: i32) -> Result<Task> {
516 let (_claim, mut task) = claimed(queue, reference)?;
517 task.set_priority(priority)?;
518 queue.put(&mut task)?;
519 Ok(task)
520}
521
522pub fn hold(queue: &Queue, reference: &str, reason: &str) -> Result<Task> {
524 let (_claim, mut task) = claimed(queue, reference)?;
525 check_hold_release(&task, "hold")?;
526 let reason = reason.trim();
527 task.hold_manual((!reason.is_empty()).then(|| reason.to_owned()));
528 queue.put(&mut task)?;
529 Ok(task)
530}
531
532pub fn release(queue: &Queue, reference: &str) -> Result<Task> {
536 let (_claim, mut task) = claimed(queue, reference)?;
537 check_hold_release(&task, "release")?;
538 task.release_operator("chat", None);
539 queue.put(&mut task)?;
540 Ok(task)
541}
542
543pub fn retry(
548 queue: &Queue,
549 reference: &str,
550 title: Option<String>,
551 instruction: &str,
552) -> Result<Task> {
553 ensure!(
554 !instruction.trim().is_empty(),
555 "the new instruction is empty"
556 );
557 let (_claim, mut task) = claimed(queue, reference)?;
558 let title = title.unwrap_or_else(|| crate::queue::title_from(instruction, 72));
559 task.edit(title, instruction.to_owned())?;
560 task.release_operator("chat", None);
561 queue.put(&mut task)?;
562 Ok(task)
563}
564
565pub fn resume(queue: &Queue, home: &Path, run: &str) -> Result<Task> {
568 let task = run_task(queue, run)?;
569 ensure!(
570 task.runs.last().map(String::as_str) == Some(run),
571 "only the task's latest run can be resumed"
572 );
573 crate::resume::validate(home, run)?;
574 ensure!(
575 crate::resume::pin_queued(queue, run, None, None)?,
576 "no queued task owns run {run}"
577 );
578 queue.get(&task.id)
579}
580
581pub async fn rerun_checks(
583 queue: &Queue,
584 home: &Path,
585 talk: &str,
586 run: &str,
587 request: &str,
588 head: &str,
589 workflows: &[String],
590) -> Result<Vec<String>> {
591 run_task(queue, run)?;
592 let state = crate::run::RunState::load_under(run, home)?;
593 let pr = &state
594 .pr
595 .as_ref()
596 .context("the run recorded no pull request")?
597 .url;
598 ensure!(
599 crate::ask::valid_asset_name(request),
600 "request must be a safe filename of at most 64 bytes"
601 );
602 let dir = home.join("chat-controls").join(talk);
603 std::fs::create_dir_all(&dir)?;
604 let lock = std::fs::OpenOptions::new()
605 .create(true)
606 .truncate(false)
607 .write(true)
608 .open(dir.join(format!("{request}.lock")))?;
609 crate::pr_head::try_lock_file(&lock)?;
610 let path = dir.join(format!("{request}.json"));
611 let action = serde_json::json!({
612 "run": run, "repo": state.repo, "pr": pr, "head": head, "runs": workflows
613 });
614 if path.exists() {
615 let prior: serde_json::Value = serde_json::from_slice(&std::fs::read(&path)?)?;
616 ensure!(
617 prior["action"] == action,
618 "request ID already binds a different operation"
619 );
620 ensure!(
621 prior["status"] == "applied",
622 "previous request has an uncertain result; inspect its receipt before a new request"
623 );
624 return Ok(workflows.to_vec());
625 }
626 let record = |status: &str| -> Result<()> {
627 let tmp = path.with_extension("tmp");
628 std::fs::write(
629 &tmp,
630 serde_json::to_vec(&serde_json::json!({
631 "action": action, "status": status
632 }))?,
633 )?;
634 std::fs::rename(tmp, &path)?;
635 Ok(())
636 };
637 let operation = format!("chat:{talk}:{request}");
638 crate::daemon::rerun_checks_once(home, &operation, &state.repo, pr, head, workflows, || {
639 record("started")
640 })
641 .await?;
642 record("applied")?;
643 Ok(workflows.to_vec())
644}
645
646pub fn abandon(queue: &Queue, home: &Path, run: &str) -> Result<()> {
650 run_task(queue, run)?;
651 crate::driver::abandon_proven_dead(home, run)
652}
653
654pub fn describe(task: &Task, what: &str) -> String {
656 let status = match task.status {
657 TaskStatus::Queued => "queued",
658 _ => task.status.as_str(),
659 };
660 format!("{what} {} {} ({status})", task.short(), task.title)
661}
662
663#[cfg(test)]
664mod tests {
665 use super::*;
666 use crate::queue::Source;
667 use jiff::Timestamp;
668
669 fn talk(talks: &Talks, id: &str) -> Talk {
670 let mut t: Talk = serde_json::from_value(serde_json::json!({
671 "schema":1,"id":id,"repo":".","agent":"worker","status":"open","turns":[],
672 "created_at":Timestamp::now(),"updated_at":Timestamp::now(),
673 "seat":crate::agent::SeatState::new("talk","worker",1)
674 }))
675 .unwrap();
676 talks.put(&mut t).unwrap();
677 t
678 }
679
680 fn filed(queue: &Queue, talk: &str, text: &str) -> Task {
681 let mut t = Task::new(
682 text.into(),
683 text.into(),
684 ".".into(),
685 Source::Agent {
686 run: talk.into(),
687 node: crate::queue::CHAT_NODE.into(),
688 },
689 );
690 queue.put(&mut t).unwrap();
691 t
692 }
693
694 #[test]
695 fn only_the_chat_seat_of_an_open_talk_is_authorized() {
696 let tmp = tempfile::tempdir().unwrap();
697 let talks = Talks::at(tmp.path().join("talks"));
698 let t = talk(&talks, "talk-a");
699 assert!(authorize(&talks, &t.id, "chat").is_ok());
700 assert!(authorize(&talks, &t.id, "deputy").is_err());
701 assert!(authorize(&talks, &t.id, "").is_err());
702 assert!(authorize(&talks, "", "chat").is_err());
703 assert!(
704 authorize(&talks, "talk-", "chat").is_err(),
705 "prefix is not an id"
706 );
707 }
708
709 #[test]
710 fn tasks_of_any_origin_are_controlled_on_request() {
711 let tmp = tempfile::tempdir().unwrap();
712 let queue = Queue::at(tmp.path().join("queue"));
713 let theirs = filed(&queue, "talk-b", "theirs");
714 let mut hand = Task::new("h".into(), "h".into(), ".".into(), Source::Human);
715 hand.hold_manual(Some("operator".into()));
716 queue.put(&mut hand).unwrap();
717 assert_eq!(
720 hold(&queue, &theirs.id, "wait").unwrap().status,
721 TaskStatus::Held
722 );
723 assert_eq!(
724 release(&queue, &hand.id).unwrap().status,
725 TaskStatus::Queued
726 );
727 let again = retry(&queue, &theirs.id, None, "new").unwrap();
728 assert_eq!(again.instruction, "new");
729 assert_eq!(again.status, TaskStatus::Queued);
730 }
731
732 #[test]
733 fn hold_then_release_keeps_history_and_restarts_attempts() {
734 let tmp = tempfile::tempdir().unwrap();
735 let queue = Queue::at(tmp.path().join("queue"));
736 let mut t = filed(&queue, "talk-a", "work");
737 t.attempts = 3;
738 t.runs.push("20260101-000000-aaaa".into());
739 queue.put(&mut t).unwrap();
740 let held = hold(&queue, &t.id, "owner asked").unwrap();
741 assert_eq!(held.status, TaskStatus::Held);
742 assert_eq!(held.hold_reason.as_deref(), Some("owner asked"));
743 let back = release(&queue, &t.id).unwrap();
744 assert_eq!(back.status, TaskStatus::Queued);
745 assert_eq!(back.attempts, 0);
746 assert_eq!(back.runs, t.runs);
747 assert!(back.resumption.is_some());
748 }
749
750 #[test]
751 fn hold_and_release_refuse_done_and_running_without_saving() {
752 let tmp = tempfile::tempdir().unwrap();
753 let queue = Queue::at(tmp.path().join("queue"));
754 for status in [TaskStatus::Done, TaskStatus::Running] {
755 let mut t = filed(&queue, "talk-a", "work");
756 t.start("20260101-000000-aaaa".into());
757 if status == TaskStatus::Done {
758 t.succeed();
759 }
760 queue.put(&mut t).unwrap();
761 let before = std::fs::read(queue.path_of(&t.id)).unwrap();
762 for operation in ["hold", "release"] {
763 let error = if operation == "hold" {
764 hold(&queue, &t.id, "owner asked")
765 } else {
766 release(&queue, &t.id)
767 }
768 .unwrap_err()
769 .to_string();
770 assert!(error.contains(operation), "{error}");
771 assert!(error.contains(&t.id), "{error}");
772 assert!(error.contains(status.as_str()), "{error}");
773 let guidance = if status == TaskStatus::Done {
774 "create a new task"
775 } else {
776 "use resume or abandon"
777 };
778 assert!(error.contains(guidance), "{error}");
779 assert_eq!(std::fs::read(queue.path_of(&t.id)).unwrap(), before);
780 }
781 }
782 }
783
784 #[test]
785 fn hold_and_release_allow_unfinished_inactive_tasks() {
786 let tmp = tempfile::tempdir().unwrap();
787 let queue = Queue::at(tmp.path().join("queue"));
788 for status in [
789 TaskStatus::Queued,
790 TaskStatus::Held,
791 TaskStatus::Failed,
792 TaskStatus::Blocked,
793 TaskStatus::Parked,
794 ] {
795 for operation in ["hold", "release"] {
796 let mut t = filed(&queue, "talk-a", "work");
797 t.status = status;
798 t.hold_reason = Some("old reason".into());
799 t.attempts = 3;
800 t.runs.push("20260101-000000-aaaa".into());
801 queue.put(&mut t).unwrap();
802 let changed = if operation == "hold" {
803 let held = hold(&queue, &t.id, " new reason ").unwrap();
804 assert_eq!(held.status, TaskStatus::Held);
805 assert_eq!(held.hold_reason.as_deref(), Some("new reason"));
806 assert_eq!(held.attempts, t.attempts);
807 held
808 } else {
809 let released = release(&queue, &t.id).unwrap();
810 assert_eq!(released.status, TaskStatus::Queued);
811 assert_eq!(released.attempts, 0);
812 assert_eq!(released.resumption.as_ref().unwrap().source, "chat");
813 released
814 };
815 assert_eq!(changed.runs, t.runs);
816 assert_eq!(queue.get(&t.id).unwrap().status, changed.status);
817 }
818 }
819 }
820
821 #[test]
822 fn retry_edits_and_requeues_in_one_save() {
823 let tmp = tempfile::tempdir().unwrap();
824 let queue = Queue::at(tmp.path().join("queue"));
825 let mut t = filed(&queue, "talk-a", "old text");
826 t.runs.push("20260101-000000-aaaa".into());
827 t.attempts = 2;
828 t.hold_manual(Some("stopped".into()));
829 queue.put(&mut t).unwrap();
830 let again = retry(&queue, &t.id, None, "new text").unwrap();
831 assert_eq!(again.instruction, "new text");
832 assert_eq!(again.status, TaskStatus::Queued);
833 assert_eq!(again.attempts, 0);
834 assert_eq!(again.runs, t.runs, "run history is kept");
835 assert_eq!(again.resumption.as_ref().unwrap().source, "chat");
836 assert_eq!(again.resumption.as_ref().unwrap().instruction, "new text");
837 let mut running = filed(&queue, "talk-a", "busy");
839 running.start("20260101-000000-bbbb".into());
840 queue.put(&mut running).unwrap();
841 assert!(retry(&queue, &running.id, None, "x").is_err());
842 assert_eq!(queue.get(&running.id).unwrap().instruction, "busy");
843 assert!(retry(&queue, &t.id, None, " ").is_err());
844 }
845
846 #[test]
847 fn execution_commands_need_the_full_id_of_a_recorded_run() {
848 let tmp = tempfile::tempdir().unwrap();
849 let queue = Queue::at(tmp.path().join("queue"));
850 let mut t = filed(&queue, "talk-b", "work");
851 t.runs.push("20260101-000000-aaaa".into());
852 queue.put(&mut t).unwrap();
853 assert_eq!(
854 run_task(&queue, "20260101-000000-aaaa").unwrap().id,
855 t.id,
856 "any chat may address another chat's run"
857 );
858 assert!(run_task(&queue, "aaaa").is_err());
859 assert!(run_task(&queue, "20260101-000000-bbbb").is_err());
860 assert!(resume(&queue, tmp.path(), "aaaa").is_err());
861 }
862
863 #[test]
864 fn abandon_needs_no_phrase_but_refuses_unrecorded_and_unknown_runs() {
865 let tmp = tempfile::tempdir().unwrap();
866 let queue = Queue::at(tmp.path().join("queue"));
867 let mut t = filed(&queue, "talk-b", "work");
868 t.runs.push("20260101-000000-aaaa".into());
869 queue.put(&mut t).unwrap();
870 assert!(abandon(&queue, tmp.path(), "20260101-000000-bbbb").is_err());
872 assert!(abandon(&queue, tmp.path(), "20260101-000000-aaaa").is_err());
875 }
876}