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