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 Abandon {
80 run: String,
82 quote: Option<String>,
84 },
85}
86
87pub async fn dispatch(
90 queue: &Queue,
91 home: &Path,
92 talks: &Talks,
93 talk: &str,
94 operation: Operation,
95) -> Result<String> {
96 #[cfg(unix)]
97 if std::env::var_os(bridge::CHANNEL_ENV).is_some() {
98 match bridge::request(&operation) {
101 Err(error)
102 if error.is::<bridge::Unavailable>()
103 && std::env::var("MAGI_ALLOW_WRITE").as_deref() == Ok("1") => {}
104 result => return result,
105 }
106 }
107 #[cfg(windows)]
108 if std::env::var_os(bridge::CHANNEL_ENV).is_some() {
109 return bridge::request(&operation).await;
110 }
111 execute(queue, home, talks, talk, operation).await
112}
113
114async fn execute(
115 queue: &Queue,
116 home: &Path,
117 talks: &Talks,
118 talk: &str,
119 operation: Operation,
120) -> Result<String> {
121 authorize(talks, talk, crate::queue::CHAT_NODE)?;
122 ensure!(
123 !crate::consult::pending_consults(&crate::ask::Questions::at(home.join("questions")), talk),
124 "task control is excluded during consultation"
125 );
126 Ok(match operation {
127 Operation::Resume { run } => {
128 let t = resume(queue, home, talk, &run)?;
129 format!(
130 "{}; the loop (`magi serve`) resumes the run under its execution lease",
131 describe(&t, "resume queued for")
132 )
133 }
134 Operation::Hold { task, reason } => describe(&hold(queue, talk, &task, &reason)?, "held"),
135 Operation::Release { task } => describe(&release(queue, talk, &task)?, "released"),
136 Operation::Retry {
137 task,
138 title,
139 instruction,
140 } => describe(
141 &retry(queue, talk, &task, title, &instruction)?,
142 "retry queued for",
143 ),
144 Operation::RerunChecks {
145 run,
146 request,
147 head,
148 workflows,
149 } => {
150 let ids = rerun_checks(queue, home, talk, &run, &request, &head, &workflows).await?;
151 format!("requested a rerun of workflow run(s) {}", ids.join(", "))
152 }
153 Operation::Abandon { run, quote } => {
154 abandon(queue, talks, home, talk, &run, quote.as_deref())?;
155 format!("abandoned run {run}")
156 }
157 })
158}
159
160pub fn abandon_instruction(run: &str) -> String {
162 format!("abandon-run: {run}")
163}
164
165pub fn authorize(talks: &Talks, run: &str, node: &str) -> Result<Talk> {
167 if node != crate::queue::CHAT_NODE || run.trim().is_empty() {
168 bail!("task control requires MAGI_NODE=chat and MAGI_RUN from a standing chat");
169 }
170 let id = crate::chat_watch::authorize(talks, run, node)?;
171 talks.get(&id)
172}
173
174fn owned(task: &Task, talk: &str) -> bool {
176 task.filed_by_chat() == Some(talk)
177}
178
179pub fn owned_task(queue: &Queue, talk: &str, reference: &str) -> Result<Task> {
181 let id = queue.resolve_id(reference)?;
182 let task = queue.get(&id)?;
183 ensure!(
184 owned(&task, talk),
185 "task {} was not filed by this conversation",
186 task.short()
187 );
188 Ok(task)
189}
190
191pub fn owned_run_task(queue: &Queue, talk: &str, run: &str) -> Result<Task> {
193 ensure!(
194 crate::run::is_run_id(run),
195 "an execution command needs the full run id"
196 );
197 queue
198 .list()
199 .into_iter()
200 .find(|t| owned(t, talk) && t.runs.iter().any(|r| r == run))
201 .with_context(|| format!("run {run} belongs to no task filed by this conversation"))
202}
203
204fn claimed(queue: &Queue, id: &str, talk: &str) -> Result<(crate::queue::Claim, Task)> {
205 let claim = queue
206 .claim(id)
207 .with_context(|| format!("task {id} is claimed by a running daemon"))?;
208 let task = queue.get(id)?;
209 ensure!(owned(&task, talk), "task is not filed by this conversation");
212 Ok((claim, task))
213}
214
215fn check_hold_release(task: &Task, operation: &str) -> Result<()> {
216 match task.status {
217 TaskStatus::Queued
218 | TaskStatus::Held
219 | TaskStatus::Failed
220 | TaskStatus::Blocked
221 | TaskStatus::Parked => Ok(()),
222 TaskStatus::Done => bail!(
223 "cannot {operation} task {} (done): completed work must not be restarted; create a new task instead",
224 task.id
225 ),
226 TaskStatus::Running => bail!(
227 "cannot {operation} task {} (running): its run may still be active; use resume or abandon for the existing run instead",
228 task.id
229 ),
230 }
231}
232
233pub fn hold(queue: &Queue, talk: &str, reference: &str, reason: &str) -> Result<Task> {
235 let id = owned_task(queue, talk, reference)?.id;
236 let (_claim, mut task) = claimed(queue, &id, talk)?;
237 check_hold_release(&task, "hold")?;
238 let reason = reason.trim();
239 task.hold_manual((!reason.is_empty()).then(|| reason.to_owned()));
240 queue.put(&mut task)?;
241 Ok(task)
242}
243
244pub fn release(queue: &Queue, talk: &str, reference: &str) -> Result<Task> {
247 let id = owned_task(queue, talk, reference)?.id;
248 let (_claim, mut task) = claimed(queue, &id, talk)?;
249 check_hold_release(&task, "release")?;
250 task.release_operator("chat", None);
251 queue.put(&mut task)?;
252 Ok(task)
253}
254
255pub fn retry(
260 queue: &Queue,
261 talk: &str,
262 reference: &str,
263 title: Option<String>,
264 instruction: &str,
265) -> Result<Task> {
266 ensure!(
267 !instruction.trim().is_empty(),
268 "the new instruction is empty"
269 );
270 let id = owned_task(queue, talk, reference)?.id;
271 let (_claim, mut task) = claimed(queue, &id, talk)?;
272 let title = title.unwrap_or_else(|| crate::queue::title_from(instruction, 72));
273 task.edit(title, instruction.to_owned())?;
274 task.release_operator("chat", None);
275 queue.put(&mut task)?;
276 Ok(task)
277}
278
279pub fn resume(queue: &Queue, home: &Path, talk: &str, run: &str) -> Result<Task> {
282 let task = owned_run_task(queue, talk, run)?;
283 ensure!(
284 task.runs.last().map(String::as_str) == Some(run),
285 "only the task's latest run can be resumed"
286 );
287 crate::resume::validate(home, run)?;
288 ensure!(
289 crate::resume::pin_queued(queue, run, None, None)?,
290 "no queued task owns run {run}"
291 );
292 queue.get(&task.id)
293}
294
295pub async fn rerun_checks(
297 queue: &Queue,
298 home: &Path,
299 talk: &str,
300 run: &str,
301 request: &str,
302 head: &str,
303 workflows: &[String],
304) -> Result<Vec<String>> {
305 owned_run_task(queue, talk, run)?;
306 let state = crate::run::RunState::load_under(run, home)?;
307 let pr = &state
308 .pr
309 .as_ref()
310 .context("the run recorded no pull request")?
311 .url;
312 ensure!(
313 crate::ask::valid_asset_name(request),
314 "request must be a safe filename of at most 64 bytes"
315 );
316 let dir = home.join("chat-controls").join(talk);
317 std::fs::create_dir_all(&dir)?;
318 let lock = std::fs::OpenOptions::new()
319 .create(true)
320 .truncate(false)
321 .write(true)
322 .open(dir.join(format!("{request}.lock")))?;
323 crate::pr_head::try_lock_file(&lock)?;
324 let path = dir.join(format!("{request}.json"));
325 let action = serde_json::json!({
326 "run": run, "repo": state.repo, "pr": pr, "head": head, "runs": workflows
327 });
328 if path.exists() {
329 let prior: serde_json::Value = serde_json::from_slice(&std::fs::read(&path)?)?;
330 ensure!(
331 prior["action"] == action,
332 "request ID already binds a different operation"
333 );
334 ensure!(
335 prior["status"] == "applied",
336 "previous request has an uncertain result; inspect its receipt before a new request"
337 );
338 return Ok(workflows.to_vec());
339 }
340 let record = |status: &str| -> Result<()> {
341 let tmp = path.with_extension("tmp");
342 std::fs::write(
343 &tmp,
344 serde_json::to_vec(&serde_json::json!({
345 "action": action, "status": status
346 }))?,
347 )?;
348 std::fs::rename(tmp, &path)?;
349 Ok(())
350 };
351 let operation = format!("chat:{talk}:{request}");
352 crate::daemon::rerun_checks_once(home, &operation, &state.repo, pr, head, workflows, || {
353 record("started")
354 })
355 .await?;
356 record("applied")?;
357 Ok(workflows.to_vec())
358}
359
360pub fn abandon(
365 queue: &Queue,
366 talks: &Talks,
367 home: &Path,
368 talk_id: &str,
369 run: &str,
370 quote: Option<&str>,
371) -> Result<()> {
372 owned_run_task(queue, talk_id, run)?;
373 let _evidence = talks.answer_guard()?;
374 let talk = talks.get(talk_id)?;
375 let expected = abandon_instruction(run);
376 let latest = crate::consult::latest_owner_instruction(&talk);
377 ensure!(
378 latest == Some(expected.as_str()) && quote == Some(expected.as_str()),
379 "abandoning a run needs individual owner confirmation: the owner's latest persisted \
380 message must be exactly `{expected}` and --quote must repeat it"
381 );
382 crate::driver::abandon_proven_dead(home, run)
383}
384
385pub fn describe(task: &Task, what: &str) -> String {
387 let status = match task.status {
388 TaskStatus::Queued => "queued",
389 _ => task.status.as_str(),
390 };
391 format!("{what} {} {} ({status})", task.short(), task.title)
392}
393
394#[cfg(test)]
395mod tests {
396 use super::*;
397 use crate::queue::Source;
398 use jiff::Timestamp;
399
400 fn talk(talks: &Talks, id: &str) -> Talk {
401 let mut t: Talk = serde_json::from_value(serde_json::json!({
402 "schema":1,"id":id,"repo":".","agent":"worker","status":"open","turns":[],
403 "created_at":Timestamp::now(),"updated_at":Timestamp::now(),
404 "seat":crate::agent::SeatState::new("talk","worker",1)
405 }))
406 .unwrap();
407 talks.put(&mut t).unwrap();
408 t
409 }
410
411 fn filed(queue: &Queue, talk: &str, text: &str) -> Task {
412 let mut t = Task::new(
413 text.into(),
414 text.into(),
415 ".".into(),
416 Source::Agent {
417 run: talk.into(),
418 node: crate::queue::CHAT_NODE.into(),
419 },
420 );
421 queue.put(&mut t).unwrap();
422 t
423 }
424
425 #[test]
426 fn only_the_chat_seat_of_an_open_talk_is_authorized() {
427 let tmp = tempfile::tempdir().unwrap();
428 let talks = Talks::at(tmp.path().join("talks"));
429 let t = talk(&talks, "talk-a");
430 assert!(authorize(&talks, &t.id, "chat").is_ok());
431 assert!(authorize(&talks, &t.id, "deputy").is_err());
432 assert!(authorize(&talks, &t.id, "").is_err());
433 assert!(authorize(&talks, "", "chat").is_err());
434 assert!(
435 authorize(&talks, "talk-", "chat").is_err(),
436 "prefix is not an id"
437 );
438 }
439
440 #[test]
441 fn tasks_of_other_origins_are_out_of_scope() {
442 let tmp = tempfile::tempdir().unwrap();
443 let queue = Queue::at(tmp.path().join("queue"));
444 let mine = filed(&queue, "talk-a", "mine");
445 let theirs = filed(&queue, "talk-b", "theirs");
446 let mut hand = Task::new("h".into(), "h".into(), ".".into(), Source::Human);
447 queue.put(&mut hand).unwrap();
448 assert!(hold(&queue, "talk-a", &mine.id, "wait").is_ok());
449 assert!(hold(&queue, "talk-a", &theirs.id, "x").is_err());
450 assert!(release(&queue, "talk-a", &hand.id).is_err());
451 assert!(retry(&queue, "talk-a", &theirs.id, None, "new").is_err());
452 assert_eq!(queue.get(&theirs.id).unwrap().status, TaskStatus::Queued);
453 }
454
455 #[test]
456 fn hold_then_release_keeps_history_and_restarts_attempts() {
457 let tmp = tempfile::tempdir().unwrap();
458 let queue = Queue::at(tmp.path().join("queue"));
459 let mut t = filed(&queue, "talk-a", "work");
460 t.attempts = 3;
461 t.runs.push("20260101-000000-aaaa".into());
462 queue.put(&mut t).unwrap();
463 let held = hold(&queue, "talk-a", &t.id, "owner asked").unwrap();
464 assert_eq!(held.status, TaskStatus::Held);
465 assert_eq!(held.hold_reason.as_deref(), Some("owner asked"));
466 let back = release(&queue, "talk-a", &t.id).unwrap();
467 assert_eq!(back.status, TaskStatus::Queued);
468 assert_eq!(back.attempts, 0);
469 assert_eq!(back.runs, t.runs);
470 assert!(back.resumption.is_some());
471 }
472
473 #[test]
474 fn hold_and_release_refuse_done_and_running_without_saving() {
475 let tmp = tempfile::tempdir().unwrap();
476 let queue = Queue::at(tmp.path().join("queue"));
477 for status in [TaskStatus::Done, TaskStatus::Running] {
478 let mut t = filed(&queue, "talk-a", "work");
479 t.start("20260101-000000-aaaa".into());
480 if status == TaskStatus::Done {
481 t.succeed();
482 }
483 queue.put(&mut t).unwrap();
484 let before = std::fs::read(queue.path_of(&t.id)).unwrap();
485 for operation in ["hold", "release"] {
486 let error = if operation == "hold" {
487 hold(&queue, "talk-a", &t.id, "owner asked")
488 } else {
489 release(&queue, "talk-a", &t.id)
490 }
491 .unwrap_err()
492 .to_string();
493 assert!(error.contains(operation), "{error}");
494 assert!(error.contains(&t.id), "{error}");
495 assert!(error.contains(status.as_str()), "{error}");
496 let guidance = if status == TaskStatus::Done {
497 "create a new task"
498 } else {
499 "use resume or abandon"
500 };
501 assert!(error.contains(guidance), "{error}");
502 assert_eq!(std::fs::read(queue.path_of(&t.id)).unwrap(), before);
503 }
504 }
505 }
506
507 #[test]
508 fn hold_and_release_allow_unfinished_inactive_tasks() {
509 let tmp = tempfile::tempdir().unwrap();
510 let queue = Queue::at(tmp.path().join("queue"));
511 for status in [
512 TaskStatus::Queued,
513 TaskStatus::Held,
514 TaskStatus::Failed,
515 TaskStatus::Blocked,
516 TaskStatus::Parked,
517 ] {
518 for operation in ["hold", "release"] {
519 let mut t = filed(&queue, "talk-a", "work");
520 t.status = status;
521 t.hold_reason = Some("old reason".into());
522 t.attempts = 3;
523 t.runs.push("20260101-000000-aaaa".into());
524 queue.put(&mut t).unwrap();
525 let changed = if operation == "hold" {
526 let held = hold(&queue, "talk-a", &t.id, " new reason ").unwrap();
527 assert_eq!(held.status, TaskStatus::Held);
528 assert_eq!(held.hold_reason.as_deref(), Some("new reason"));
529 assert_eq!(held.attempts, t.attempts);
530 held
531 } else {
532 let released = release(&queue, "talk-a", &t.id).unwrap();
533 assert_eq!(released.status, TaskStatus::Queued);
534 assert_eq!(released.attempts, 0);
535 assert_eq!(released.resumption.as_ref().unwrap().source, "chat");
536 released
537 };
538 assert_eq!(changed.runs, t.runs);
539 assert_eq!(queue.get(&t.id).unwrap().status, changed.status);
540 }
541 }
542 }
543
544 #[test]
545 fn retry_edits_and_requeues_in_one_save() {
546 let tmp = tempfile::tempdir().unwrap();
547 let queue = Queue::at(tmp.path().join("queue"));
548 let mut t = filed(&queue, "talk-a", "old text");
549 t.runs.push("20260101-000000-aaaa".into());
550 t.attempts = 2;
551 t.hold_manual(Some("stopped".into()));
552 queue.put(&mut t).unwrap();
553 let again = retry(&queue, "talk-a", &t.id, None, "new text").unwrap();
554 assert_eq!(again.instruction, "new text");
555 assert_eq!(again.status, TaskStatus::Queued);
556 assert_eq!(again.attempts, 0);
557 assert_eq!(again.runs, t.runs, "run history is kept");
558 assert_eq!(again.resumption.as_ref().unwrap().source, "chat");
559 assert_eq!(again.resumption.as_ref().unwrap().instruction, "new text");
560 let mut running = filed(&queue, "talk-a", "busy");
562 running.start("20260101-000000-bbbb".into());
563 queue.put(&mut running).unwrap();
564 assert!(retry(&queue, "talk-a", &running.id, None, "x").is_err());
565 assert_eq!(queue.get(&running.id).unwrap().instruction, "busy");
566 assert!(retry(&queue, "talk-a", &t.id, None, " ").is_err());
567 }
568
569 #[test]
570 fn execution_commands_need_a_full_run_id_of_an_owned_task() {
571 let tmp = tempfile::tempdir().unwrap();
572 let queue = Queue::at(tmp.path().join("queue"));
573 let mut t = filed(&queue, "talk-a", "work");
574 t.runs.push("20260101-000000-aaaa".into());
575 queue.put(&mut t).unwrap();
576 assert!(owned_run_task(&queue, "talk-a", "20260101-000000-aaaa").is_ok());
577 assert!(owned_run_task(&queue, "talk-b", "20260101-000000-aaaa").is_err());
578 assert!(owned_run_task(&queue, "talk-a", "aaaa").is_err());
579 assert!(resume(&queue, tmp.path(), "talk-b", "20260101-000000-aaaa").is_err());
580 }
581
582 #[test]
583 fn abandon_needs_the_exact_owner_message_and_a_matching_quote() {
584 let tmp = tempfile::tempdir().unwrap();
585 let queue = Queue::at(tmp.path().join("queue"));
586 let talks = Talks::at(tmp.path().join("talks"));
587 talk(&talks, "talk-a");
588 let mut t = filed(&queue, "talk-a", "work");
589 t.runs.push("20260101-000000-aaaa".into());
590 queue.put(&mut t).unwrap();
591 let run = "20260101-000000-aaaa";
592 assert!(
594 abandon(
595 &queue,
596 &talks,
597 tmp.path(),
598 "talk-a",
599 run,
600 Some("abandon-run: x")
601 )
602 .is_err()
603 );
604 assert!(abandon(&queue, &talks, tmp.path(), "talk-a", run, None).is_err());
605 assert!(
607 abandon(
608 &queue,
609 &talks,
610 tmp.path(),
611 "talk-a",
612 "20260101-000000-bbbb",
613 None
614 )
615 .is_err()
616 );
617 }
618}