Skip to main content

magi/
chat_control.rs

1//! Direct task and run control for the standing chat: `magi task control ...`.
2//!
3//! A chat agent can resume a run, hold or release a task, retry a task with a
4//! changed instruction, rerun the failed checks of a pull request and abandon
5//! a dead run by command, with no question or offered choice in between. Every
6//! command goes through the transitions the rest of magi already uses
7//! ([`crate::resume`], [`Task::hold_manual`], [`Task::release_operator`],
8//! [`Task::edit`], [`crate::daemon::rerun_checks_once`],
9//! [`crate::driver::abandon_proven_dead`]); this module only decides *who may
10//! ask* and *about what*.
11//!
12//! - **Authorization.** `MAGI_NODE=chat` and `MAGI_RUN` equal to the exact id
13//!   of an open conversation ([`crate::chat_watch::authorize`]).
14//! - **Scope.** Only tasks that conversation filed itself
15//!   ([`Task::filed_by_chat`]) and the runs recorded in their `runs`. A
16//!   follow-up's inherited `origin_chat` does not count. Task ids and run ids
17//!   are checked against the queue under the task's claim, not trusted.
18//! - **Resume starts nothing here.** It pins the owning task to the run and
19//!   queues it; the loop's ordinary start logic resumes it under the graph's
20//!   execution lease, so a second driver cannot appear.
21//! - **Abandon is individually confirmed.** The owner's latest persisted send
22//!   must be exactly `abandon-run: <full run id>` and `--quote` must equal it;
23//!   liveness is then judged by the shared judge under the owner lock.
24//! - **Merging is not here.** It stays behind the approval gate.
25
26use 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/// Only these operations cross the standing turn's inherited capability.
37/// Paths, commands, merge decisions and conversation identities are not input.
38#[derive(Debug, serde::Serialize, serde::Deserialize)]
39#[serde(tag = "operation", deny_unknown_fields)]
40pub enum Operation {
41    /// 既存 run の再開を queue に登録する。
42    Resume {
43        /// 完全な run ID。
44        run: String,
45    },
46    /// task を手動保留する。
47    Hold {
48        /// task ID または一意な接頭辞。
49        task: String,
50        /// 保留理由。
51        reason: String,
52    },
53    /// task を保留から解放する。
54    Release {
55        /// task ID または一意な接頭辞。
56        task: String,
57    },
58    /// 指示を変更して再試行する。
59    Retry {
60        /// task ID または一意な接頭辞。
61        task: String,
62        /// 新しいタイトル、省略時は指示から生成する。
63        title: Option<String>,
64        /// 新しい指示全文。
65        instruction: String,
66    },
67    /// PR の失敗した workflow を再実行する。
68    RerunChecks {
69        /// 完全な run ID。
70        run: String,
71        /// 冪等性を保証するリクエスト ID。
72        request: String,
73        /// 観測した PR head。
74        head: String,
75        /// 再実行対象の workflow run ID。
76        workflows: Vec<String>,
77    },
78    /// 個別確認後、生存が否定された run を破棄する。
79    Abandon {
80        /// 完全な run ID。
81        run: String,
82        /// 最新の所有者メッセージ全文。
83        quote: Option<String>,
84    },
85}
86
87/// The CLI sends only a typed operation when it inherited a turn capability.
88/// Outside a launched turn the existing, authorized local route remains usable.
89pub 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        // The launcher sets this flag for writable seats, including effective
99        // unsandboxed chat turns. execute() still enforces talk authorization.
100        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
160/// The exact owner instruction that confirms abandoning `run`.
161pub fn abandon_instruction(run: &str) -> String {
162    format!("abandon-run: {run}")
163}
164
165/// The conversation this process speaks for, or why it may not act.
166pub 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
174/// Whether `task` was filed by exactly this conversation.
175fn owned(task: &Task, talk: &str) -> bool {
176    task.filed_by_chat() == Some(talk)
177}
178
179/// A task of this conversation, from a full or unambiguous-prefix id.
180pub 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
191/// The task of this conversation that recorded `run` (a full run id).
192pub 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    // The relation is re-checked under the claim, never trusted from a
210    // lookup made before it.
211    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
233/// Hold a task of this conversation for the owner.
234pub 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
244/// Release a task of this conversation (the same transition as `magi task
245/// release`: the attempt count restarts, the history stays).
246pub 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
255/// Change a queued or held task's instruction and queue it again, in one
256/// save under one claim. Only what [`Task::edit`] allows can be retried; the
257/// attempt count follows [`Task::release`] (restarts), while runs, passes and
258/// the resumption history are kept.
259pub 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
279/// Resume a stopped, blocked or parked run of this conversation's task. Only
280/// queues the pinned resume; returns the owning task.
281pub 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
295/// チャットのリクエスト単位で受付記録を残し、共通遷移でチェックを再実行する。
296pub 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
360/// Abandon a run of this conversation's task whose driver is proven gone.
361/// `quote` must be the entire latest persisted owner send, and that send must
362/// be exactly [`abandon_instruction`]. The evidence guard is held through the
363/// abandon, so the owner cannot retract in between.
364pub 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
385/// One line a person or agent can read after a successful command.
386pub 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        // A running task cannot be edited: nothing is changed.
561        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        // No owner message at all.
593        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        // An unrelated run is out of scope before anything else.
606        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}