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    /// 保存された計画を作成、または所有者の個別承認で適用する。
91    ReconcileRetention {
92        /// 対象タスク ID。
93        task: String,
94        /// 保護する実装の完全な run ID。
95        authority: String,
96        /// 適用する保存済み計画 ID(省略時は計画のみ)。
97        apply: Option<String>,
98        /// 最新の所有者メッセージ全文。
99        quote: Option<String>,
100    },
101    /// 個別確認後、生存が否定された run を破棄する。
102    Abandon {
103        /// 完全な run ID。
104        run: String,
105        /// 最新の所有者メッセージ全文。
106        quote: Option<String>,
107    },
108}
109
110/// The CLI sends only a typed operation when it inherited a turn capability.
111/// Outside a launched turn the existing, authorized local route remains usable.
112pub async fn dispatch(
113    queue: &Queue,
114    home: &Path,
115    talks: &Talks,
116    talk: &str,
117    operation: Operation,
118) -> Result<String> {
119    #[cfg(unix)]
120    if std::env::var_os(bridge::CHANNEL_ENV).is_some() {
121        // The launcher sets this flag for writable seats, including effective
122        // unsandboxed chat turns. execute() still enforces talk authorization.
123        match bridge::request(&operation) {
124            Err(error)
125                if error.is::<bridge::Unavailable>()
126                    && std::env::var("MAGI_ALLOW_WRITE").as_deref() == Ok("1") => {}
127            result => return result,
128        }
129    }
130    #[cfg(windows)]
131    if std::env::var_os(bridge::CHANNEL_ENV).is_some() {
132        return bridge::request(&operation).await;
133    }
134    execute(queue, home, talks, talk, operation).await
135}
136
137async fn execute(
138    queue: &Queue,
139    home: &Path,
140    talks: &Talks,
141    talk: &str,
142    operation: Operation,
143) -> Result<String> {
144    authorize(talks, talk, crate::queue::CHAT_NODE)?;
145    ensure!(
146        !crate::consult::pending_consults(&crate::ask::Questions::at(home.join("questions")), talk),
147        "task control is excluded during consultation"
148    );
149    Ok(match operation {
150        Operation::Resume { run } => {
151            let t = resume(queue, home, talk, &run)?;
152            format!(
153                "{}; the loop (`magi serve`) resumes the run under its execution lease",
154                describe(&t, "resume queued for")
155            )
156        }
157        Operation::Hold { task, reason } => describe(&hold(queue, talk, &task, &reason)?, "held"),
158        Operation::Release { task } => describe(&release(queue, talk, &task)?, "released"),
159        Operation::Retry {
160            task,
161            title,
162            instruction,
163        } => describe(
164            &retry(queue, talk, &task, title, &instruction)?,
165            "retry queued for",
166        ),
167        Operation::RerunChecks {
168            run,
169            request,
170            head,
171            workflows,
172        } => {
173            let ids = rerun_checks(queue, home, talk, &run, &request, &head, &workflows).await?;
174            format!("requested a rerun of workflow run(s) {}", ids.join(", "))
175        }
176        Operation::Decide {
177            question,
178            choice,
179            quote,
180            title,
181        } => {
182            let q = record_decision(
183                queue,
184                talks,
185                home,
186                talk,
187                &question,
188                &choice,
189                DecisionInput {
190                    quote: quote.as_deref(),
191                    title: title.as_deref(),
192                },
193            )?;
194            format!("recorded `{choice}` for decision {}", q.short())
195        }
196        Operation::ReconcileRetention {
197            task,
198            authority,
199            apply,
200            quote,
201        } => {
202            let path = Box::pin(crate::retention::reconcile::run_chat(
203                home,
204                &task,
205                &authority,
206                apply.as_deref(),
207                crate::retention::reconcile::ChatRequest {
208                    talks,
209                    conversation: talk,
210                    quote: quote.as_deref(),
211                },
212            ))
213            .await?;
214            if apply.is_none() {
215                // Return the complete saved evidence through the capability bridge.
216                crate::retention::reconcile::chat_summary(&path)?
217            } else {
218                format!(
219                    "Applied saved plan {}",
220                    apply.as_deref().unwrap_or_default()
221                )
222            }
223        }
224        Operation::Abandon { run, quote } => {
225            abandon(queue, talks, home, talk, &run, quote.as_deref())?;
226            format!("abandoned run {run}")
227        }
228    })
229}
230
231/// Record a routed decision for the task's own chat. The chat agent interprets
232/// the owner's request directly; no quote or additional consent evidence is
233/// required. Existing answer dispatch and transition checks still apply.
234pub fn decide(
235    queue: &Queue,
236    talks: &Talks,
237    home: &Path,
238    talk: &str,
239    question: &str,
240    choice: &str,
241    quote: Option<&str>,
242) -> Result<crate::ask::Question> {
243    record_decision(
244        queue,
245        talks,
246        home,
247        talk,
248        question,
249        choice,
250        DecisionInput { quote, title: None },
251    )
252}
253
254struct DecisionInput<'a> {
255    quote: Option<&'a str>,
256    title: Option<&'a str>,
257}
258
259fn record_decision(
260    queue: &Queue,
261    talks: &Talks,
262    home: &Path,
263    talk: &str,
264    question: &str,
265    choice: &str,
266    input: DecisionInput<'_>,
267) -> Result<crate::ask::Question> {
268    use crate::ask::{Answer, Questions};
269    let questions = Questions::at(home.join("questions"));
270    let id = questions.resolve_id(question)?;
271    let q = questions.get(&id)?;
272    ensure!(
273        q.routed_chat.as_deref() == Some(talk) && crate::task_chat::awaits_decision(&q),
274        "question {} is not a decision routed to this conversation",
275        q.short()
276    );
277    let task_id = q
278        .routed_task
279        .clone()
280        .context("question is not routed to a task")?;
281    ensure!(
282        owned(&queue.get(&task_id)?, talk),
283        "the task behind this decision is not owned by this conversation"
284    );
285    let deadline = std::time::Instant::now() + std::time::Duration::from_secs(20);
286    let (q, guard) = loop {
287        let mut busy = false;
288        let updated = questions.update(&q.id, |r| {
289            let guard = match talks.try_answer_guard()? {
290                Some(g) => g,
291                None => {
292                    busy = true;
293                    bail!("conversation evidence is locked by another process");
294                }
295            };
296            ensure!(
297                r.status.open(),
298                "question {} is already {}",
299                r.short(),
300                r.status.as_str()
301            );
302            let reply = r.resolve_reply(choice)?.to_owned();
303            let replacement = r.node == crate::github_text::ASK_NODE
304                && reply == crate::github_text::USE_MY_TEXT;
305            ensure!(replacement || input.title.is_none(), "--title is only valid for a publication decision's `use my text` choice");
306            let title = if replacement {
307                let title = input.title.context("use my text needs --title with the literal replacement title (no quote evidence required)")?;
308                ensure!(!title.trim().is_empty(), "replacement title must not be empty");
309                Some(title.to_owned())
310            } else { None };
311            let answer = if r.free_text() {
312                Answer::Text(reply.clone())
313            } else {
314                Answer::Choice(reply.clone())
315            };
316            crate::consult::refuse_operator_held(r, crate::queue::CHAT_NODE, &queue.list())?;
317            r.answer_authority = crate::consult::authorize_answer(
318                r,
319                talks,
320                &questions,
321                talk,
322                crate::queue::CHAT_NODE,
323                &reply,
324                input.quote,
325            )?;
326            r.publication_title = title;
327            r.answer(answer)?;
328            Ok(guard)
329        });
330        if busy && std::time::Instant::now() < deadline {
331            std::thread::sleep(std::time::Duration::from_millis(10));
332            continue;
333        }
334        break updated?;
335    };
336    drop(guard);
337    Ok(q)
338}
339
340/// The exact owner instruction that confirms abandoning `run`.
341pub fn abandon_instruction(run: &str) -> String {
342    format!("abandon-run: {run}")
343}
344
345/// The conversation this process speaks for, or why it may not act.
346pub fn authorize(talks: &Talks, run: &str, node: &str) -> Result<Talk> {
347    if node != crate::queue::CHAT_NODE || run.trim().is_empty() {
348        bail!("task control requires MAGI_NODE=chat and MAGI_RUN from a standing chat");
349    }
350    let id = crate::chat_watch::authorize(talks, run, node)?;
351    talks.get(&id)
352}
353
354/// Whether `task` was filed by exactly this conversation.
355fn owned(task: &Task, talk: &str) -> bool {
356    task.filed_by_chat() == Some(talk) || task.chat.as_deref() == Some(talk)
357}
358
359/// A task of this conversation, from a full or unambiguous-prefix id.
360pub fn owned_task(queue: &Queue, talk: &str, reference: &str) -> Result<Task> {
361    let id = queue.resolve_id(reference)?;
362    let task = queue.get(&id)?;
363    ensure!(
364        owned(&task, talk),
365        "task {} was not filed by this conversation",
366        task.short()
367    );
368    Ok(task)
369}
370
371/// The task of this conversation that recorded `run` (a full run id).
372pub fn owned_run_task(queue: &Queue, talk: &str, run: &str) -> Result<Task> {
373    ensure!(
374        crate::run::is_run_id(run),
375        "an execution command needs the full run id"
376    );
377    queue
378        .list()
379        .into_iter()
380        .find(|t| owned(t, talk) && t.runs.iter().any(|r| r == run))
381        .with_context(|| format!("run {run} belongs to no task filed by this conversation"))
382}
383
384fn claimed(queue: &Queue, id: &str, talk: &str) -> Result<(crate::queue::Claim, Task)> {
385    let claim = queue
386        .claim(id)
387        .with_context(|| format!("task {id} is claimed by a running daemon"))?;
388    let task = queue.get(id)?;
389    // The relation is re-checked under the claim, never trusted from a
390    // lookup made before it.
391    ensure!(owned(&task, talk), "task is not filed by this conversation");
392    Ok((claim, task))
393}
394
395fn check_hold_release(task: &Task, operation: &str) -> Result<()> {
396    match task.status {
397        TaskStatus::Queued
398        | TaskStatus::Held
399        | TaskStatus::Failed
400        | TaskStatus::Blocked
401        | TaskStatus::Parked => Ok(()),
402        TaskStatus::Done => bail!(
403            "cannot {operation} task {} (done): completed work must not be restarted; create a new task instead",
404            task.id
405        ),
406        TaskStatus::Running => bail!(
407            "cannot {operation} task {} (running): its run may still be active; use resume or abandon for the existing run instead",
408            task.id
409        ),
410    }
411}
412
413/// Hold a task of this conversation for the owner.
414pub fn hold(queue: &Queue, talk: &str, reference: &str, reason: &str) -> Result<Task> {
415    let id = owned_task(queue, talk, reference)?.id;
416    let (_claim, mut task) = claimed(queue, &id, talk)?;
417    check_hold_release(&task, "hold")?;
418    let reason = reason.trim();
419    task.hold_manual((!reason.is_empty()).then(|| reason.to_owned()));
420    queue.put(&mut task)?;
421    Ok(task)
422}
423
424/// Release a task of this conversation (the same transition as `magi task
425/// release`: the attempt count restarts, the history stays).
426pub fn release(queue: &Queue, talk: &str, reference: &str) -> Result<Task> {
427    let id = owned_task(queue, talk, reference)?.id;
428    let (_claim, mut task) = claimed(queue, &id, talk)?;
429    check_hold_release(&task, "release")?;
430    task.release_operator("chat", None);
431    queue.put(&mut task)?;
432    Ok(task)
433}
434
435/// Change a queued or held task's instruction and queue it again, in one
436/// save under one claim. Only what [`Task::edit`] allows can be retried; the
437/// attempt count follows [`Task::release`] (restarts), while runs, passes and
438/// the resumption history are kept.
439pub fn retry(
440    queue: &Queue,
441    talk: &str,
442    reference: &str,
443    title: Option<String>,
444    instruction: &str,
445) -> Result<Task> {
446    ensure!(
447        !instruction.trim().is_empty(),
448        "the new instruction is empty"
449    );
450    let id = owned_task(queue, talk, reference)?.id;
451    let (_claim, mut task) = claimed(queue, &id, talk)?;
452    let title = title.unwrap_or_else(|| crate::queue::title_from(instruction, 72));
453    task.edit(title, instruction.to_owned())?;
454    task.release_operator("chat", None);
455    queue.put(&mut task)?;
456    Ok(task)
457}
458
459/// Resume a stopped, blocked or parked run of this conversation's task. Only
460/// queues the pinned resume; returns the owning task.
461pub fn resume(queue: &Queue, home: &Path, talk: &str, run: &str) -> Result<Task> {
462    let task = owned_run_task(queue, talk, run)?;
463    ensure!(
464        task.runs.last().map(String::as_str) == Some(run),
465        "only the task's latest run can be resumed"
466    );
467    crate::resume::validate(home, run)?;
468    ensure!(
469        crate::resume::pin_queued(queue, run, None, None)?,
470        "no queued task owns run {run}"
471    );
472    queue.get(&task.id)
473}
474
475/// チャットのリクエスト単位で受付記録を残し、共通遷移でチェックを再実行する。
476pub async fn rerun_checks(
477    queue: &Queue,
478    home: &Path,
479    talk: &str,
480    run: &str,
481    request: &str,
482    head: &str,
483    workflows: &[String],
484) -> Result<Vec<String>> {
485    owned_run_task(queue, talk, run)?;
486    let state = crate::run::RunState::load_under(run, home)?;
487    let pr = &state
488        .pr
489        .as_ref()
490        .context("the run recorded no pull request")?
491        .url;
492    ensure!(
493        crate::ask::valid_asset_name(request),
494        "request must be a safe filename of at most 64 bytes"
495    );
496    let dir = home.join("chat-controls").join(talk);
497    std::fs::create_dir_all(&dir)?;
498    let lock = std::fs::OpenOptions::new()
499        .create(true)
500        .truncate(false)
501        .write(true)
502        .open(dir.join(format!("{request}.lock")))?;
503    crate::pr_head::try_lock_file(&lock)?;
504    let path = dir.join(format!("{request}.json"));
505    let action = serde_json::json!({
506        "run": run, "repo": state.repo, "pr": pr, "head": head, "runs": workflows
507    });
508    if path.exists() {
509        let prior: serde_json::Value = serde_json::from_slice(&std::fs::read(&path)?)?;
510        ensure!(
511            prior["action"] == action,
512            "request ID already binds a different operation"
513        );
514        ensure!(
515            prior["status"] == "applied",
516            "previous request has an uncertain result; inspect its receipt before a new request"
517        );
518        return Ok(workflows.to_vec());
519    }
520    let record = |status: &str| -> Result<()> {
521        let tmp = path.with_extension("tmp");
522        std::fs::write(
523            &tmp,
524            serde_json::to_vec(&serde_json::json!({
525                "action": action, "status": status
526            }))?,
527        )?;
528        std::fs::rename(tmp, &path)?;
529        Ok(())
530    };
531    let operation = format!("chat:{talk}:{request}");
532    crate::daemon::rerun_checks_once(home, &operation, &state.repo, pr, head, workflows, || {
533        record("started")
534    })
535    .await?;
536    record("applied")?;
537    Ok(workflows.to_vec())
538}
539
540/// Abandon a run of this conversation's task whose driver is proven gone.
541/// `quote` must be the entire latest persisted owner send, and that send must
542/// be exactly [`abandon_instruction`]. The evidence guard is held through the
543/// abandon, so the owner cannot retract in between.
544pub fn abandon(
545    queue: &Queue,
546    talks: &Talks,
547    home: &Path,
548    talk_id: &str,
549    run: &str,
550    quote: Option<&str>,
551) -> Result<()> {
552    owned_run_task(queue, talk_id, run)?;
553    let _evidence = talks.answer_guard()?;
554    let talk = talks.get(talk_id)?;
555    let expected = abandon_instruction(run);
556    let latest = crate::consult::latest_owner_instruction(&talk);
557    ensure!(
558        latest == Some(expected.as_str()) && quote == Some(expected.as_str()),
559        "abandoning a run needs individual owner confirmation: the owner's latest persisted \
560         message must be exactly `{expected}` and --quote must repeat it"
561    );
562    crate::driver::abandon_proven_dead(home, run)
563}
564
565/// One line a person or agent can read after a successful command.
566pub fn describe(task: &Task, what: &str) -> String {
567    let status = match task.status {
568        TaskStatus::Queued => "queued",
569        _ => task.status.as_str(),
570    };
571    format!("{what} {} {} ({status})", task.short(), task.title)
572}
573
574#[cfg(test)]
575mod tests {
576    use super::*;
577    use crate::queue::Source;
578    use jiff::Timestamp;
579
580    fn talk(talks: &Talks, id: &str) -> Talk {
581        let mut t: Talk = serde_json::from_value(serde_json::json!({
582            "schema":1,"id":id,"repo":".","agent":"worker","status":"open","turns":[],
583            "created_at":Timestamp::now(),"updated_at":Timestamp::now(),
584            "seat":crate::agent::SeatState::new("talk","worker",1)
585        }))
586        .unwrap();
587        talks.put(&mut t).unwrap();
588        t
589    }
590
591    fn filed(queue: &Queue, talk: &str, text: &str) -> Task {
592        let mut t = Task::new(
593            text.into(),
594            text.into(),
595            ".".into(),
596            Source::Agent {
597                run: talk.into(),
598                node: crate::queue::CHAT_NODE.into(),
599            },
600        );
601        queue.put(&mut t).unwrap();
602        t
603    }
604
605    #[test]
606    fn only_the_chat_seat_of_an_open_talk_is_authorized() {
607        let tmp = tempfile::tempdir().unwrap();
608        let talks = Talks::at(tmp.path().join("talks"));
609        let t = talk(&talks, "talk-a");
610        assert!(authorize(&talks, &t.id, "chat").is_ok());
611        assert!(authorize(&talks, &t.id, "deputy").is_err());
612        assert!(authorize(&talks, &t.id, "").is_err());
613        assert!(authorize(&talks, "", "chat").is_err());
614        assert!(
615            authorize(&talks, "talk-", "chat").is_err(),
616            "prefix is not an id"
617        );
618    }
619
620    #[test]
621    fn tasks_of_other_origins_are_out_of_scope() {
622        let tmp = tempfile::tempdir().unwrap();
623        let queue = Queue::at(tmp.path().join("queue"));
624        let mine = filed(&queue, "talk-a", "mine");
625        let theirs = filed(&queue, "talk-b", "theirs");
626        let mut hand = Task::new("h".into(), "h".into(), ".".into(), Source::Human);
627        queue.put(&mut hand).unwrap();
628        assert!(hold(&queue, "talk-a", &mine.id, "wait").is_ok());
629        assert!(hold(&queue, "talk-a", &theirs.id, "x").is_err());
630        assert!(release(&queue, "talk-a", &hand.id).is_err());
631        assert!(retry(&queue, "talk-a", &theirs.id, None, "new").is_err());
632        assert_eq!(queue.get(&theirs.id).unwrap().status, TaskStatus::Queued);
633    }
634
635    #[test]
636    fn hold_then_release_keeps_history_and_restarts_attempts() {
637        let tmp = tempfile::tempdir().unwrap();
638        let queue = Queue::at(tmp.path().join("queue"));
639        let mut t = filed(&queue, "talk-a", "work");
640        t.attempts = 3;
641        t.runs.push("20260101-000000-aaaa".into());
642        queue.put(&mut t).unwrap();
643        let held = hold(&queue, "talk-a", &t.id, "owner asked").unwrap();
644        assert_eq!(held.status, TaskStatus::Held);
645        assert_eq!(held.hold_reason.as_deref(), Some("owner asked"));
646        let back = release(&queue, "talk-a", &t.id).unwrap();
647        assert_eq!(back.status, TaskStatus::Queued);
648        assert_eq!(back.attempts, 0);
649        assert_eq!(back.runs, t.runs);
650        assert!(back.resumption.is_some());
651    }
652
653    #[test]
654    fn hold_and_release_refuse_done_and_running_without_saving() {
655        let tmp = tempfile::tempdir().unwrap();
656        let queue = Queue::at(tmp.path().join("queue"));
657        for status in [TaskStatus::Done, TaskStatus::Running] {
658            let mut t = filed(&queue, "talk-a", "work");
659            t.start("20260101-000000-aaaa".into());
660            if status == TaskStatus::Done {
661                t.succeed();
662            }
663            queue.put(&mut t).unwrap();
664            let before = std::fs::read(queue.path_of(&t.id)).unwrap();
665            for operation in ["hold", "release"] {
666                let error = if operation == "hold" {
667                    hold(&queue, "talk-a", &t.id, "owner asked")
668                } else {
669                    release(&queue, "talk-a", &t.id)
670                }
671                .unwrap_err()
672                .to_string();
673                assert!(error.contains(operation), "{error}");
674                assert!(error.contains(&t.id), "{error}");
675                assert!(error.contains(status.as_str()), "{error}");
676                let guidance = if status == TaskStatus::Done {
677                    "create a new task"
678                } else {
679                    "use resume or abandon"
680                };
681                assert!(error.contains(guidance), "{error}");
682                assert_eq!(std::fs::read(queue.path_of(&t.id)).unwrap(), before);
683            }
684        }
685    }
686
687    #[test]
688    fn hold_and_release_allow_unfinished_inactive_tasks() {
689        let tmp = tempfile::tempdir().unwrap();
690        let queue = Queue::at(tmp.path().join("queue"));
691        for status in [
692            TaskStatus::Queued,
693            TaskStatus::Held,
694            TaskStatus::Failed,
695            TaskStatus::Blocked,
696            TaskStatus::Parked,
697        ] {
698            for operation in ["hold", "release"] {
699                let mut t = filed(&queue, "talk-a", "work");
700                t.status = status;
701                t.hold_reason = Some("old reason".into());
702                t.attempts = 3;
703                t.runs.push("20260101-000000-aaaa".into());
704                queue.put(&mut t).unwrap();
705                let changed = if operation == "hold" {
706                    let held = hold(&queue, "talk-a", &t.id, " new reason ").unwrap();
707                    assert_eq!(held.status, TaskStatus::Held);
708                    assert_eq!(held.hold_reason.as_deref(), Some("new reason"));
709                    assert_eq!(held.attempts, t.attempts);
710                    held
711                } else {
712                    let released = release(&queue, "talk-a", &t.id).unwrap();
713                    assert_eq!(released.status, TaskStatus::Queued);
714                    assert_eq!(released.attempts, 0);
715                    assert_eq!(released.resumption.as_ref().unwrap().source, "chat");
716                    released
717                };
718                assert_eq!(changed.runs, t.runs);
719                assert_eq!(queue.get(&t.id).unwrap().status, changed.status);
720            }
721        }
722    }
723
724    #[test]
725    fn retry_edits_and_requeues_in_one_save() {
726        let tmp = tempfile::tempdir().unwrap();
727        let queue = Queue::at(tmp.path().join("queue"));
728        let mut t = filed(&queue, "talk-a", "old text");
729        t.runs.push("20260101-000000-aaaa".into());
730        t.attempts = 2;
731        t.hold_manual(Some("stopped".into()));
732        queue.put(&mut t).unwrap();
733        let again = retry(&queue, "talk-a", &t.id, None, "new text").unwrap();
734        assert_eq!(again.instruction, "new text");
735        assert_eq!(again.status, TaskStatus::Queued);
736        assert_eq!(again.attempts, 0);
737        assert_eq!(again.runs, t.runs, "run history is kept");
738        assert_eq!(again.resumption.as_ref().unwrap().source, "chat");
739        assert_eq!(again.resumption.as_ref().unwrap().instruction, "new text");
740        // A running task cannot be edited: nothing is changed.
741        let mut running = filed(&queue, "talk-a", "busy");
742        running.start("20260101-000000-bbbb".into());
743        queue.put(&mut running).unwrap();
744        assert!(retry(&queue, "talk-a", &running.id, None, "x").is_err());
745        assert_eq!(queue.get(&running.id).unwrap().instruction, "busy");
746        assert!(retry(&queue, "talk-a", &t.id, None, "  ").is_err());
747    }
748
749    #[test]
750    fn execution_commands_need_a_full_run_id_of_an_owned_task() {
751        let tmp = tempfile::tempdir().unwrap();
752        let queue = Queue::at(tmp.path().join("queue"));
753        let mut t = filed(&queue, "talk-a", "work");
754        t.runs.push("20260101-000000-aaaa".into());
755        queue.put(&mut t).unwrap();
756        assert!(owned_run_task(&queue, "talk-a", "20260101-000000-aaaa").is_ok());
757        assert!(owned_run_task(&queue, "talk-b", "20260101-000000-aaaa").is_err());
758        assert!(owned_run_task(&queue, "talk-a", "aaaa").is_err());
759        assert!(resume(&queue, tmp.path(), "talk-b", "20260101-000000-aaaa").is_err());
760    }
761
762    #[test]
763    fn abandon_needs_the_exact_owner_message_and_a_matching_quote() {
764        let tmp = tempfile::tempdir().unwrap();
765        let queue = Queue::at(tmp.path().join("queue"));
766        let talks = Talks::at(tmp.path().join("talks"));
767        talk(&talks, "talk-a");
768        let mut t = filed(&queue, "talk-a", "work");
769        t.runs.push("20260101-000000-aaaa".into());
770        queue.put(&mut t).unwrap();
771        let run = "20260101-000000-aaaa";
772        // No owner message at all.
773        assert!(
774            abandon(
775                &queue,
776                &talks,
777                tmp.path(),
778                "talk-a",
779                run,
780                Some("abandon-run: x")
781            )
782            .is_err()
783        );
784        assert!(abandon(&queue, &talks, tmp.path(), "talk-a", run, None).is_err());
785        // An unrelated run is out of scope before anything else.
786        assert!(
787            abandon(
788                &queue,
789                &talks,
790                tmp.path(),
791                "talk-a",
792                "20260101-000000-bbbb",
793                None
794            )
795            .is_err()
796        );
797    }
798}