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.** Tasks that conversation filed itself ([`Task::filed_by_chat`])
15//!   or whose decision destination is its persisted [`Task::chat`], and their
16//!   recorded runs. An inherited `origin_chat` alone 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    /// タスクチャットで所有者の判断を記録する(マージを含む)。
79    Decide {
80        /// 質問 ID(完全または一意な接頭辞)。
81        question: String,
82        /// 記録された選択肢のラベル。
83        choice: String,
84        /// 互換性のための任意の引用(配送済みの判断では不要)。
85        quote: Option<String>,
86        /// 「use my text」で公開する置換タイトルそのもの。
87        #[serde(default)]
88        title: Option<String>,
89    },
90    /// 個別確認後、生存が否定された run を破棄する。
91    Abandon {
92        /// 完全な run ID。
93        run: String,
94        /// 最新の所有者メッセージ全文。
95        quote: Option<String>,
96    },
97}
98
99/// The CLI sends only a typed operation when it inherited a turn capability.
100/// Outside a launched turn the existing, authorized local route remains usable.
101pub 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        // The launcher sets this flag for writable seats, including effective
111        // unsandboxed chat turns. execute() still enforces talk authorization.
112        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
192/// Record a routed decision for the task's own chat. The chat agent interprets
193/// the owner's request directly; no quote or additional consent evidence is
194/// required. Existing answer dispatch and transition checks still apply.
195pub 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
301/// The exact owner instruction that confirms abandoning `run`.
302pub fn abandon_instruction(run: &str) -> String {
303    format!("abandon-run: {run}")
304}
305
306/// The conversation this process speaks for, or why it may not act.
307pub 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
315/// Whether `task` was filed by exactly this conversation.
316fn owned(task: &Task, talk: &str) -> bool {
317    task.filed_by_chat() == Some(talk) || task.chat.as_deref() == Some(talk)
318}
319
320/// A task of this conversation, from a full or unambiguous-prefix id.
321pub 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
332/// The task of this conversation that recorded `run` (a full run id).
333pub 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    // The relation is re-checked under the claim, never trusted from a
351    // lookup made before it.
352    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
374/// Hold a task of this conversation for the owner.
375pub 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
385/// Release a task of this conversation (the same transition as `magi task
386/// release`: the attempt count restarts, the history stays).
387pub 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
396/// Change a queued or held task's instruction and queue it again, in one
397/// save under one claim. Only what [`Task::edit`] allows can be retried; the
398/// attempt count follows [`Task::release`] (restarts), while runs, passes and
399/// the resumption history are kept.
400pub 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
420/// Resume a stopped, blocked or parked run of this conversation's task. Only
421/// queues the pinned resume; returns the owning task.
422pub 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
436/// チャットのリクエスト単位で受付記録を残し、共通遷移でチェックを再実行する。
437pub 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
501/// Abandon a run of this conversation's task whose driver is proven gone.
502/// `quote` must be the entire latest persisted owner send, and that send must
503/// be exactly [`abandon_instruction`]. The evidence guard is held through the
504/// abandon, so the owner cannot retract in between.
505pub 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
526/// One line a person or agent can read after a successful command.
527pub 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        // A running task cannot be edited: nothing is changed.
702        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        // No owner message at all.
734        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        // An unrelated run is out of scope before anything else.
747        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}