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