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