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