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