1use std::path::Path;
27
28use anyhow::{Context, Result, bail, ensure};
29
30use crate::queue::{Queue, Task, TaskStatus};
31use crate::talk::{Talk, Talks};
32
33#[cfg(any(unix, windows))]
34pub(crate) mod bridge;
35
36#[derive(Debug, serde::Serialize, serde::Deserialize)]
39#[serde(tag = "operation", deny_unknown_fields)]
40pub enum Operation {
41 Resume {
43 run: String,
45 },
46 Hold {
48 task: String,
50 reason: String,
52 },
53 Release {
55 task: String,
57 },
58 Retry {
60 task: String,
62 title: Option<String>,
64 instruction: String,
66 },
67 RerunChecks {
69 run: String,
71 request: String,
73 head: String,
75 workflows: Vec<String>,
77 },
78 Decide {
80 question: String,
82 choice: String,
84 quote: Option<String>,
86 #[serde(default)]
88 title: Option<String>,
89 },
90 ReconcileRetention {
92 task: String,
94 authority: String,
96 apply: Option<String>,
98 quote: Option<String>,
100 },
101 Abandon {
103 run: String,
105 quote: Option<String>,
107 },
108}
109
110pub async fn dispatch(
113 queue: &Queue,
114 home: &Path,
115 talks: &Talks,
116 talk: &str,
117 operation: Operation,
118) -> Result<String> {
119 #[cfg(unix)]
120 if std::env::var_os(bridge::CHANNEL_ENV).is_some() {
121 match bridge::request(&operation) {
124 Err(error)
125 if error.is::<bridge::Unavailable>()
126 && std::env::var("MAGI_ALLOW_WRITE").as_deref() == Ok("1") => {}
127 result => return result,
128 }
129 }
130 #[cfg(windows)]
131 if std::env::var_os(bridge::CHANNEL_ENV).is_some() {
132 return bridge::request(&operation).await;
133 }
134 execute(queue, home, talks, talk, operation).await
135}
136
137async fn execute(
138 queue: &Queue,
139 home: &Path,
140 talks: &Talks,
141 talk: &str,
142 operation: Operation,
143) -> Result<String> {
144 authorize(talks, talk, crate::queue::CHAT_NODE)?;
145 ensure!(
146 !crate::consult::pending_consults(&crate::ask::Questions::at(home.join("questions")), talk),
147 "task control is excluded during consultation"
148 );
149 Ok(match operation {
150 Operation::Resume { run } => {
151 let t = resume(queue, home, talk, &run)?;
152 format!(
153 "{}; the loop (`magi serve`) resumes the run under its execution lease",
154 describe(&t, "resume queued for")
155 )
156 }
157 Operation::Hold { task, reason } => describe(&hold(queue, talk, &task, &reason)?, "held"),
158 Operation::Release { task } => describe(&release(queue, talk, &task)?, "released"),
159 Operation::Retry {
160 task,
161 title,
162 instruction,
163 } => describe(
164 &retry(queue, talk, &task, title, &instruction)?,
165 "retry queued for",
166 ),
167 Operation::RerunChecks {
168 run,
169 request,
170 head,
171 workflows,
172 } => {
173 let ids = rerun_checks(queue, home, talk, &run, &request, &head, &workflows).await?;
174 format!("requested a rerun of workflow run(s) {}", ids.join(", "))
175 }
176 Operation::Decide {
177 question,
178 choice,
179 quote,
180 title,
181 } => {
182 let q = record_decision(
183 queue,
184 talks,
185 home,
186 talk,
187 &question,
188 &choice,
189 DecisionInput {
190 quote: quote.as_deref(),
191 title: title.as_deref(),
192 },
193 )?;
194 format!("recorded `{choice}` for decision {}", q.short())
195 }
196 Operation::ReconcileRetention {
197 task,
198 authority,
199 apply,
200 quote,
201 } => {
202 let path = Box::pin(crate::retention::reconcile::run_chat(
203 home,
204 &task,
205 &authority,
206 apply.as_deref(),
207 crate::retention::reconcile::ChatRequest {
208 talks,
209 conversation: talk,
210 quote: quote.as_deref(),
211 },
212 ))
213 .await?;
214 if apply.is_none() {
215 crate::retention::reconcile::chat_summary(&path)?
217 } else {
218 format!(
219 "Applied saved plan {}",
220 apply.as_deref().unwrap_or_default()
221 )
222 }
223 }
224 Operation::Abandon { run, quote } => {
225 abandon(queue, talks, home, talk, &run, quote.as_deref())?;
226 format!("abandoned run {run}")
227 }
228 })
229}
230
231pub fn decide(
235 queue: &Queue,
236 talks: &Talks,
237 home: &Path,
238 talk: &str,
239 question: &str,
240 choice: &str,
241 quote: Option<&str>,
242) -> Result<crate::ask::Question> {
243 record_decision(
244 queue,
245 talks,
246 home,
247 talk,
248 question,
249 choice,
250 DecisionInput { quote, title: None },
251 )
252}
253
254struct DecisionInput<'a> {
255 quote: Option<&'a str>,
256 title: Option<&'a str>,
257}
258
259fn record_decision(
260 queue: &Queue,
261 talks: &Talks,
262 home: &Path,
263 talk: &str,
264 question: &str,
265 choice: &str,
266 input: DecisionInput<'_>,
267) -> Result<crate::ask::Question> {
268 use crate::ask::{Answer, Questions};
269 let questions = Questions::at(home.join("questions"));
270 let id = questions.resolve_id(question)?;
271 let q = questions.get(&id)?;
272 ensure!(
273 q.routed_chat.as_deref() == Some(talk) && crate::task_chat::awaits_decision(&q),
274 "question {} is not a decision routed to this conversation",
275 q.short()
276 );
277 let task_id = q
278 .routed_task
279 .clone()
280 .context("question is not routed to a task")?;
281 ensure!(
282 owned(&queue.get(&task_id)?, talk),
283 "the task behind this decision is not owned by this conversation"
284 );
285 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(20);
286 let (q, guard) = loop {
287 let mut busy = false;
288 let updated = questions.update(&q.id, |r| {
289 let guard = match talks.try_answer_guard()? {
290 Some(g) => g,
291 None => {
292 busy = true;
293 bail!("conversation evidence is locked by another process");
294 }
295 };
296 ensure!(
297 r.status.open(),
298 "question {} is already {}",
299 r.short(),
300 r.status.as_str()
301 );
302 let reply = r.resolve_reply(choice)?.to_owned();
303 let replacement = r.node == crate::github_text::ASK_NODE
304 && reply == crate::github_text::USE_MY_TEXT;
305 ensure!(replacement || input.title.is_none(), "--title is only valid for a publication decision's `use my text` choice");
306 let title = if replacement {
307 let title = input.title.context("use my text needs --title with the literal replacement title (no quote evidence required)")?;
308 ensure!(!title.trim().is_empty(), "replacement title must not be empty");
309 Some(title.to_owned())
310 } else { None };
311 let answer = if r.free_text() {
312 Answer::Text(reply.clone())
313 } else {
314 Answer::Choice(reply.clone())
315 };
316 crate::consult::refuse_operator_held(r, crate::queue::CHAT_NODE, &queue.list())?;
317 r.answer_authority = crate::consult::authorize_answer(
318 r,
319 talks,
320 &questions,
321 talk,
322 crate::queue::CHAT_NODE,
323 &reply,
324 input.quote,
325 )?;
326 r.publication_title = title;
327 r.answer(answer)?;
328 Ok(guard)
329 });
330 if busy && std::time::Instant::now() < deadline {
331 std::thread::sleep(std::time::Duration::from_millis(10));
332 continue;
333 }
334 break updated?;
335 };
336 drop(guard);
337 Ok(q)
338}
339
340pub fn abandon_instruction(run: &str) -> String {
342 format!("abandon-run: {run}")
343}
344
345pub fn authorize(talks: &Talks, run: &str, node: &str) -> Result<Talk> {
347 if node != crate::queue::CHAT_NODE || run.trim().is_empty() {
348 bail!("task control requires MAGI_NODE=chat and MAGI_RUN from a standing chat");
349 }
350 let id = crate::chat_watch::authorize(talks, run, node)?;
351 talks.get(&id)
352}
353
354fn owned(task: &Task, talk: &str) -> bool {
356 task.filed_by_chat() == Some(talk) || task.chat.as_deref() == Some(talk)
357}
358
359pub fn owned_task(queue: &Queue, talk: &str, reference: &str) -> Result<Task> {
361 let id = queue.resolve_id(reference)?;
362 let task = queue.get(&id)?;
363 ensure!(
364 owned(&task, talk),
365 "task {} was not filed by this conversation",
366 task.short()
367 );
368 Ok(task)
369}
370
371pub fn owned_run_task(queue: &Queue, talk: &str, run: &str) -> Result<Task> {
373 ensure!(
374 crate::run::is_run_id(run),
375 "an execution command needs the full run id"
376 );
377 queue
378 .list()
379 .into_iter()
380 .find(|t| owned(t, talk) && t.runs.iter().any(|r| r == run))
381 .with_context(|| format!("run {run} belongs to no task filed by this conversation"))
382}
383
384fn claimed(queue: &Queue, id: &str, talk: &str) -> Result<(crate::queue::Claim, Task)> {
385 let claim = queue
386 .claim(id)
387 .with_context(|| format!("task {id} is claimed by a running daemon"))?;
388 let task = queue.get(id)?;
389 ensure!(owned(&task, talk), "task is not filed by this conversation");
392 Ok((claim, task))
393}
394
395fn check_hold_release(task: &Task, operation: &str) -> Result<()> {
396 match task.status {
397 TaskStatus::Queued
398 | TaskStatus::Held
399 | TaskStatus::Failed
400 | TaskStatus::Blocked
401 | TaskStatus::Parked => Ok(()),
402 TaskStatus::Done => bail!(
403 "cannot {operation} task {} (done): completed work must not be restarted; create a new task instead",
404 task.id
405 ),
406 TaskStatus::Running => bail!(
407 "cannot {operation} task {} (running): its run may still be active; use resume or abandon for the existing run instead",
408 task.id
409 ),
410 }
411}
412
413pub fn hold(queue: &Queue, talk: &str, reference: &str, reason: &str) -> Result<Task> {
415 let id = owned_task(queue, talk, reference)?.id;
416 let (_claim, mut task) = claimed(queue, &id, talk)?;
417 check_hold_release(&task, "hold")?;
418 let reason = reason.trim();
419 task.hold_manual((!reason.is_empty()).then(|| reason.to_owned()));
420 queue.put(&mut task)?;
421 Ok(task)
422}
423
424pub fn release(queue: &Queue, talk: &str, reference: &str) -> Result<Task> {
427 let id = owned_task(queue, talk, reference)?.id;
428 let (_claim, mut task) = claimed(queue, &id, talk)?;
429 check_hold_release(&task, "release")?;
430 task.release_operator("chat", None);
431 queue.put(&mut task)?;
432 Ok(task)
433}
434
435pub fn retry(
440 queue: &Queue,
441 talk: &str,
442 reference: &str,
443 title: Option<String>,
444 instruction: &str,
445) -> Result<Task> {
446 ensure!(
447 !instruction.trim().is_empty(),
448 "the new instruction is empty"
449 );
450 let id = owned_task(queue, talk, reference)?.id;
451 let (_claim, mut task) = claimed(queue, &id, talk)?;
452 let title = title.unwrap_or_else(|| crate::queue::title_from(instruction, 72));
453 task.edit(title, instruction.to_owned())?;
454 task.release_operator("chat", None);
455 queue.put(&mut task)?;
456 Ok(task)
457}
458
459pub fn resume(queue: &Queue, home: &Path, talk: &str, run: &str) -> Result<Task> {
462 let task = owned_run_task(queue, talk, run)?;
463 ensure!(
464 task.runs.last().map(String::as_str) == Some(run),
465 "only the task's latest run can be resumed"
466 );
467 crate::resume::validate(home, run)?;
468 ensure!(
469 crate::resume::pin_queued(queue, run, None, None)?,
470 "no queued task owns run {run}"
471 );
472 queue.get(&task.id)
473}
474
475pub async fn rerun_checks(
477 queue: &Queue,
478 home: &Path,
479 talk: &str,
480 run: &str,
481 request: &str,
482 head: &str,
483 workflows: &[String],
484) -> Result<Vec<String>> {
485 owned_run_task(queue, talk, run)?;
486 let state = crate::run::RunState::load_under(run, home)?;
487 let pr = &state
488 .pr
489 .as_ref()
490 .context("the run recorded no pull request")?
491 .url;
492 ensure!(
493 crate::ask::valid_asset_name(request),
494 "request must be a safe filename of at most 64 bytes"
495 );
496 let dir = home.join("chat-controls").join(talk);
497 std::fs::create_dir_all(&dir)?;
498 let lock = std::fs::OpenOptions::new()
499 .create(true)
500 .truncate(false)
501 .write(true)
502 .open(dir.join(format!("{request}.lock")))?;
503 crate::pr_head::try_lock_file(&lock)?;
504 let path = dir.join(format!("{request}.json"));
505 let action = serde_json::json!({
506 "run": run, "repo": state.repo, "pr": pr, "head": head, "runs": workflows
507 });
508 if path.exists() {
509 let prior: serde_json::Value = serde_json::from_slice(&std::fs::read(&path)?)?;
510 ensure!(
511 prior["action"] == action,
512 "request ID already binds a different operation"
513 );
514 ensure!(
515 prior["status"] == "applied",
516 "previous request has an uncertain result; inspect its receipt before a new request"
517 );
518 return Ok(workflows.to_vec());
519 }
520 let record = |status: &str| -> Result<()> {
521 let tmp = path.with_extension("tmp");
522 std::fs::write(
523 &tmp,
524 serde_json::to_vec(&serde_json::json!({
525 "action": action, "status": status
526 }))?,
527 )?;
528 std::fs::rename(tmp, &path)?;
529 Ok(())
530 };
531 let operation = format!("chat:{talk}:{request}");
532 crate::daemon::rerun_checks_once(home, &operation, &state.repo, pr, head, workflows, || {
533 record("started")
534 })
535 .await?;
536 record("applied")?;
537 Ok(workflows.to_vec())
538}
539
540pub fn abandon(
545 queue: &Queue,
546 talks: &Talks,
547 home: &Path,
548 talk_id: &str,
549 run: &str,
550 quote: Option<&str>,
551) -> Result<()> {
552 owned_run_task(queue, talk_id, run)?;
553 let _evidence = talks.answer_guard()?;
554 let talk = talks.get(talk_id)?;
555 let expected = abandon_instruction(run);
556 let latest = crate::consult::latest_owner_instruction(&talk);
557 ensure!(
558 latest == Some(expected.as_str()) && quote == Some(expected.as_str()),
559 "abandoning a run needs individual owner confirmation: the owner's latest persisted \
560 message must be exactly `{expected}` and --quote must repeat it"
561 );
562 crate::driver::abandon_proven_dead(home, run)
563}
564
565pub fn describe(task: &Task, what: &str) -> String {
567 let status = match task.status {
568 TaskStatus::Queued => "queued",
569 _ => task.status.as_str(),
570 };
571 format!("{what} {} {} ({status})", task.short(), task.title)
572}
573
574#[cfg(test)]
575mod tests {
576 use super::*;
577 use crate::queue::Source;
578 use jiff::Timestamp;
579
580 fn talk(talks: &Talks, id: &str) -> Talk {
581 let mut t: Talk = serde_json::from_value(serde_json::json!({
582 "schema":1,"id":id,"repo":".","agent":"worker","status":"open","turns":[],
583 "created_at":Timestamp::now(),"updated_at":Timestamp::now(),
584 "seat":crate::agent::SeatState::new("talk","worker",1)
585 }))
586 .unwrap();
587 talks.put(&mut t).unwrap();
588 t
589 }
590
591 fn filed(queue: &Queue, talk: &str, text: &str) -> Task {
592 let mut t = Task::new(
593 text.into(),
594 text.into(),
595 ".".into(),
596 Source::Agent {
597 run: talk.into(),
598 node: crate::queue::CHAT_NODE.into(),
599 },
600 );
601 queue.put(&mut t).unwrap();
602 t
603 }
604
605 #[test]
606 fn only_the_chat_seat_of_an_open_talk_is_authorized() {
607 let tmp = tempfile::tempdir().unwrap();
608 let talks = Talks::at(tmp.path().join("talks"));
609 let t = talk(&talks, "talk-a");
610 assert!(authorize(&talks, &t.id, "chat").is_ok());
611 assert!(authorize(&talks, &t.id, "deputy").is_err());
612 assert!(authorize(&talks, &t.id, "").is_err());
613 assert!(authorize(&talks, "", "chat").is_err());
614 assert!(
615 authorize(&talks, "talk-", "chat").is_err(),
616 "prefix is not an id"
617 );
618 }
619
620 #[test]
621 fn tasks_of_other_origins_are_out_of_scope() {
622 let tmp = tempfile::tempdir().unwrap();
623 let queue = Queue::at(tmp.path().join("queue"));
624 let mine = filed(&queue, "talk-a", "mine");
625 let theirs = filed(&queue, "talk-b", "theirs");
626 let mut hand = Task::new("h".into(), "h".into(), ".".into(), Source::Human);
627 queue.put(&mut hand).unwrap();
628 assert!(hold(&queue, "talk-a", &mine.id, "wait").is_ok());
629 assert!(hold(&queue, "talk-a", &theirs.id, "x").is_err());
630 assert!(release(&queue, "talk-a", &hand.id).is_err());
631 assert!(retry(&queue, "talk-a", &theirs.id, None, "new").is_err());
632 assert_eq!(queue.get(&theirs.id).unwrap().status, TaskStatus::Queued);
633 }
634
635 #[test]
636 fn hold_then_release_keeps_history_and_restarts_attempts() {
637 let tmp = tempfile::tempdir().unwrap();
638 let queue = Queue::at(tmp.path().join("queue"));
639 let mut t = filed(&queue, "talk-a", "work");
640 t.attempts = 3;
641 t.runs.push("20260101-000000-aaaa".into());
642 queue.put(&mut t).unwrap();
643 let held = hold(&queue, "talk-a", &t.id, "owner asked").unwrap();
644 assert_eq!(held.status, TaskStatus::Held);
645 assert_eq!(held.hold_reason.as_deref(), Some("owner asked"));
646 let back = release(&queue, "talk-a", &t.id).unwrap();
647 assert_eq!(back.status, TaskStatus::Queued);
648 assert_eq!(back.attempts, 0);
649 assert_eq!(back.runs, t.runs);
650 assert!(back.resumption.is_some());
651 }
652
653 #[test]
654 fn hold_and_release_refuse_done_and_running_without_saving() {
655 let tmp = tempfile::tempdir().unwrap();
656 let queue = Queue::at(tmp.path().join("queue"));
657 for status in [TaskStatus::Done, TaskStatus::Running] {
658 let mut t = filed(&queue, "talk-a", "work");
659 t.start("20260101-000000-aaaa".into());
660 if status == TaskStatus::Done {
661 t.succeed();
662 }
663 queue.put(&mut t).unwrap();
664 let before = std::fs::read(queue.path_of(&t.id)).unwrap();
665 for operation in ["hold", "release"] {
666 let error = if operation == "hold" {
667 hold(&queue, "talk-a", &t.id, "owner asked")
668 } else {
669 release(&queue, "talk-a", &t.id)
670 }
671 .unwrap_err()
672 .to_string();
673 assert!(error.contains(operation), "{error}");
674 assert!(error.contains(&t.id), "{error}");
675 assert!(error.contains(status.as_str()), "{error}");
676 let guidance = if status == TaskStatus::Done {
677 "create a new task"
678 } else {
679 "use resume or abandon"
680 };
681 assert!(error.contains(guidance), "{error}");
682 assert_eq!(std::fs::read(queue.path_of(&t.id)).unwrap(), before);
683 }
684 }
685 }
686
687 #[test]
688 fn hold_and_release_allow_unfinished_inactive_tasks() {
689 let tmp = tempfile::tempdir().unwrap();
690 let queue = Queue::at(tmp.path().join("queue"));
691 for status in [
692 TaskStatus::Queued,
693 TaskStatus::Held,
694 TaskStatus::Failed,
695 TaskStatus::Blocked,
696 TaskStatus::Parked,
697 ] {
698 for operation in ["hold", "release"] {
699 let mut t = filed(&queue, "talk-a", "work");
700 t.status = status;
701 t.hold_reason = Some("old reason".into());
702 t.attempts = 3;
703 t.runs.push("20260101-000000-aaaa".into());
704 queue.put(&mut t).unwrap();
705 let changed = if operation == "hold" {
706 let held = hold(&queue, "talk-a", &t.id, " new reason ").unwrap();
707 assert_eq!(held.status, TaskStatus::Held);
708 assert_eq!(held.hold_reason.as_deref(), Some("new reason"));
709 assert_eq!(held.attempts, t.attempts);
710 held
711 } else {
712 let released = release(&queue, "talk-a", &t.id).unwrap();
713 assert_eq!(released.status, TaskStatus::Queued);
714 assert_eq!(released.attempts, 0);
715 assert_eq!(released.resumption.as_ref().unwrap().source, "chat");
716 released
717 };
718 assert_eq!(changed.runs, t.runs);
719 assert_eq!(queue.get(&t.id).unwrap().status, changed.status);
720 }
721 }
722 }
723
724 #[test]
725 fn retry_edits_and_requeues_in_one_save() {
726 let tmp = tempfile::tempdir().unwrap();
727 let queue = Queue::at(tmp.path().join("queue"));
728 let mut t = filed(&queue, "talk-a", "old text");
729 t.runs.push("20260101-000000-aaaa".into());
730 t.attempts = 2;
731 t.hold_manual(Some("stopped".into()));
732 queue.put(&mut t).unwrap();
733 let again = retry(&queue, "talk-a", &t.id, None, "new text").unwrap();
734 assert_eq!(again.instruction, "new text");
735 assert_eq!(again.status, TaskStatus::Queued);
736 assert_eq!(again.attempts, 0);
737 assert_eq!(again.runs, t.runs, "run history is kept");
738 assert_eq!(again.resumption.as_ref().unwrap().source, "chat");
739 assert_eq!(again.resumption.as_ref().unwrap().instruction, "new text");
740 let mut running = filed(&queue, "talk-a", "busy");
742 running.start("20260101-000000-bbbb".into());
743 queue.put(&mut running).unwrap();
744 assert!(retry(&queue, "talk-a", &running.id, None, "x").is_err());
745 assert_eq!(queue.get(&running.id).unwrap().instruction, "busy");
746 assert!(retry(&queue, "talk-a", &t.id, None, " ").is_err());
747 }
748
749 #[test]
750 fn execution_commands_need_a_full_run_id_of_an_owned_task() {
751 let tmp = tempfile::tempdir().unwrap();
752 let queue = Queue::at(tmp.path().join("queue"));
753 let mut t = filed(&queue, "talk-a", "work");
754 t.runs.push("20260101-000000-aaaa".into());
755 queue.put(&mut t).unwrap();
756 assert!(owned_run_task(&queue, "talk-a", "20260101-000000-aaaa").is_ok());
757 assert!(owned_run_task(&queue, "talk-b", "20260101-000000-aaaa").is_err());
758 assert!(owned_run_task(&queue, "talk-a", "aaaa").is_err());
759 assert!(resume(&queue, tmp.path(), "talk-b", "20260101-000000-aaaa").is_err());
760 }
761
762 #[test]
763 fn abandon_needs_the_exact_owner_message_and_a_matching_quote() {
764 let tmp = tempfile::tempdir().unwrap();
765 let queue = Queue::at(tmp.path().join("queue"));
766 let talks = Talks::at(tmp.path().join("talks"));
767 talk(&talks, "talk-a");
768 let mut t = filed(&queue, "talk-a", "work");
769 t.runs.push("20260101-000000-aaaa".into());
770 queue.put(&mut t).unwrap();
771 let run = "20260101-000000-aaaa";
772 assert!(
774 abandon(
775 &queue,
776 &talks,
777 tmp.path(),
778 "talk-a",
779 run,
780 Some("abandon-run: x")
781 )
782 .is_err()
783 );
784 assert!(abandon(&queue, &talks, tmp.path(), "talk-a", run, None).is_err());
785 assert!(
787 abandon(
788 &queue,
789 &talks,
790 tmp.path(),
791 "talk-a",
792 "20260101-000000-bbbb",
793 None
794 )
795 .is_err()
796 );
797 }
798}