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