Skip to main content

magi/
chat_control.rs

1//! Direct task and run control for the standing chat: `magi task control ...`,
2//! and a chat seat's `magi answer --reply` / `--reply-other`.
3//!
4//! A chat agent carries out the owner's requests by command, with no question
5//! or offered choice in between: resume a run, hold or release a task, retry a
6//! task with a changed instruction, rerun the failed checks of a pull request,
7//! abandon a dead run, and record the owner's choice for a decision the
8//! machinery waits on (a merge included). The owner's request in the chat is
9//! the authority: no quote, fixed phrase or "only tasks this chat filed" scope
10//! applies, and a mistake is reverted rather than prevented. Every command goes
11//! through the transitions the rest of magi already uses ([`crate::resume`],
12//! [`Task::hold_manual`], [`Task::release_operator`], [`Task::edit`],
13//! [`crate::daemon::rerun_checks_once`], [`crate::driver::abandon_proven_dead`],
14//! [`crate::ask::Question::answer`]); this module only checks *who is asking*
15//! and that the target is consistent. A chat seat's answers go through the
16//! same bridge ([`Operation::Answer`], [`Operation::ReplyOther`]) because a
17//! read-only turn's sandbox may refuse magi's stores; the launcher records
18//! them with exactly the terminal's functions.
19//!
20//! - **Authorization.** `MAGI_NODE=chat` and `MAGI_RUN` equal to the exact id
21//!   of an open conversation ([`crate::chat_watch::authorize`]).
22//! - **Targets.** Any task (full or unambiguous-prefix id) and any recorded
23//!   run (full run id). Ids are checked against the queue, and a task is
24//!   re-read under its claim, never trusted from a lookup made before it.
25//! - **Resume starts nothing here.** It pins the owning task to the run and
26//!   queues it; the loop's ordinary start logic resumes it under the graph's
27//!   execution lease, so a second driver cannot appear.
28//! - **Abandon needs a proven-dead driver.** Liveness is judged by the shared
29//!   judge under the owner lock; an unknown or live driver is refused.
30//! - **A merge waits for the owner.** It is recorded only when the owner asks
31//!   for it; the land driver then merges the saved head with
32//!   `--match-head-commit`.
33
34use std::path::Path;
35
36use anyhow::{Context, Result, bail, ensure};
37
38use crate::queue::{Queue, Task, TaskStatus};
39use crate::talk::{Talk, Talks};
40
41#[cfg(any(unix, windows))]
42pub(crate) mod bridge;
43
44/// Only these operations cross the standing turn's inherited capability.
45/// No shell commands or calling conversation identities are input.
46#[derive(Debug, serde::Serialize, serde::Deserialize)]
47#[serde(tag = "operation", deny_unknown_fields)]
48pub enum Operation {
49    /// File a new task through the launcher, including from read-only chats.
50    Add {
51        /// Validated filing inputs, without task state or a caller-selected id.
52        filing: Box<Filing>,
53    },
54    /// Mark a task complete using the terminal's transition.
55    Done {
56        /// Task id or unambiguous prefix.
57        task: String,
58    },
59    /// Change a task's queue priority.
60    Priority {
61        /// Task id or unambiguous prefix.
62        task: String,
63        /// New priority.
64        priority: i32,
65    },
66    /// 既存 run の再開を queue に登録する。
67    Resume {
68        /// 完全な run ID。
69        run: String,
70    },
71    /// task を手動保留する。
72    Hold {
73        /// task ID または一意な接頭辞。
74        task: String,
75        /// 保留理由。
76        reason: String,
77    },
78    /// task を保留から解放する。
79    Release {
80        /// task ID または一意な接頭辞。
81        task: String,
82    },
83    /// 指示を変更して再試行する。
84    Retry {
85        /// task ID または一意な接頭辞。
86        task: String,
87        /// 新しいタイトル、省略時は指示から生成する。
88        title: Option<String>,
89        /// 新しい指示全文。
90        instruction: String,
91    },
92    /// PR の失敗した workflow を再実行する。
93    RerunChecks {
94        /// 完全な run ID。
95        run: String,
96        /// 冪等性を保証するリクエスト ID。
97        request: String,
98        /// 観測した PR head。
99        head: String,
100        /// 再実行対象の workflow run ID。
101        workflows: Vec<String>,
102    },
103    /// タスクチャットで所有者の判断を記録する(マージを含む)。
104    Decide {
105        /// 質問 ID(完全または一意な接頭辞)。
106        question: String,
107        /// 記録された選択肢のラベル。
108        choice: String,
109        /// 「use my text」で公開する置換タイトルそのもの。
110        #[serde(default)]
111        title: Option<String>,
112    },
113    /// `magi answer --reply`: the owner's answer to a question, as an ordinary
114    /// answer ([`crate::ask::record_answer`]).
115    Answer {
116        /// Full question id, resolved by the CLI.
117        question: String,
118        /// An offered choice (label or alias), or the free-text answer.
119        reply: String,
120        /// The literal replacement title for the posting gate's `use my text`.
121        #[serde(default)]
122        title: Option<String>,
123    },
124    /// `magi answer --reply-other`: the owner's decision outside the offered
125    /// choices, saved as a note that ends the consultation
126    /// ([`crate::consult::reply_other`]).
127    ReplyOther {
128        /// Full question id, resolved by the CLI.
129        question: String,
130        /// The owner's outcome.
131        text: String,
132    },
133    /// 保存された計画を作成、または適用する。
134    ReconcileRetention {
135        /// 対象タスク ID。
136        task: String,
137        /// 保護する実装の完全な run ID。
138        authority: String,
139        /// 適用する保存済み計画 ID(省略時は計画のみ)。
140        apply: Option<String>,
141    },
142    /// 生存が否定された run を破棄する。
143    Abandon {
144        /// 完全な run ID。
145        run: String,
146    },
147}
148
149/// Inputs prepared by `magi task add` before saving a fresh queue record.
150#[derive(Debug, serde::Serialize, serde::Deserialize)]
151#[serde(deny_unknown_fields)]
152pub struct Filing {
153    /// Task title.
154    pub title: String,
155    /// Complete task instruction.
156    pub instruction: String,
157    /// Validated absolute repository path.
158    pub repo: std::path::PathBuf,
159    /// Filing provenance prepared by the CLI.
160    pub source: crate::queue::Source,
161    /// Resolved implementation imports.
162    pub sources: crate::refs::Sources,
163    /// Optional initial hold reason.
164    pub hold: Option<String>,
165    /// Queue priority.
166    pub priority: i32,
167    /// Use one implementer.
168    pub solo: bool,
169    /// Prioritize execution.
170    pub urgent: bool,
171    /// Optional implementer count.
172    pub implementers: Option<usize>,
173    /// Absolute attachment paths to copy into the queue.
174    pub attach: Vec<std::path::PathBuf>,
175}
176
177/// Whether this process inherited a launcher control channel.
178/// Provenance-only CLI calls without a channel retain their ordinary local route.
179pub fn has_channel() -> bool {
180    #[cfg(any(unix, windows))]
181    {
182        std::env::var_os(bridge::CHANNEL_ENV).is_some()
183    }
184    #[cfg(not(any(unix, windows)))]
185    {
186        false
187    }
188}
189
190/// The CLI sends only a typed operation when it inherited a turn capability.
191/// Outside a launched turn the existing, authorized local route remains usable.
192pub async fn dispatch(
193    queue: &Queue,
194    home: &Path,
195    talks: &Talks,
196    talk: &str,
197    operation: Operation,
198) -> Result<String> {
199    #[cfg(unix)]
200    if std::env::var_os(bridge::CHANNEL_ENV).is_some() {
201        // The launcher sets this flag for writable seats, including effective
202        // unsandboxed chat turns. execute() still enforces talk authorization.
203        match bridge::request(&operation) {
204            Err(error)
205                if error.is::<bridge::Unavailable>()
206                    && std::env::var("MAGI_ALLOW_WRITE").as_deref() == Ok("1") => {}
207            result => return result,
208        }
209    }
210    #[cfg(windows)]
211    if std::env::var_os(bridge::CHANNEL_ENV).is_some() {
212        return bridge::request(&operation).await;
213    }
214    execute(queue, home, talks, talk, operation).await
215}
216
217async fn execute(
218    queue: &Queue,
219    home: &Path,
220    talks: &Talks,
221    talk: &str,
222    operation: Operation,
223) -> Result<String> {
224    authorize(talks, talk, crate::queue::CHAT_NODE)?;
225    Ok(match operation {
226        Operation::Add { filing } => {
227            let mut task = Task::new(filing.title, filing.instruction, filing.repo, filing.source);
228            task.sources = filing.sources;
229            if let Some(reason) = filing.hold {
230                task.hold_manual(Some(reason));
231            }
232            task.priority = filing.priority;
233            task.solo = filing.solo;
234            task.urgent = filing.urgent;
235            if filing.implementers.is_some() {
236                task.overrides = Some(crate::queue::RunOverrides {
237                    candidates: filing.implementers,
238                    ..Default::default()
239                });
240            }
241            if filing.attach.is_empty() {
242                queue.put(&mut task)?;
243            } else {
244                queue.attach_and_put(&mut task, &filing.attach)?;
245            }
246            serde_json::to_string(&task)?
247        }
248        Operation::Done { task } => {
249            let t = done(queue, home, &task)?;
250            format!("done {} {}", t.short(), t.title)
251        }
252        Operation::Priority { task, priority } => {
253            let t = set_priority(queue, &task, priority)?;
254            format!("{} priority now {} - {}", t.short(), t.priority, t.title)
255        }
256        Operation::Resume { run } => {
257            let t = resume(queue, home, &run)?;
258            format!(
259                "{}; the loop (`magi serve`) resumes the run under its execution lease",
260                describe(&t, "resume queued for")
261            )
262        }
263        Operation::Hold { task, reason } => describe(&hold(queue, &task, &reason)?, "held"),
264        Operation::Release { task } => describe(&release(queue, &task)?, "released"),
265        Operation::Retry {
266            task,
267            title,
268            instruction,
269        } => describe(
270            &retry(queue, &task, title, &instruction)?,
271            "retry queued for",
272        ),
273        Operation::RerunChecks {
274            run,
275            request,
276            head,
277            workflows,
278        } => {
279            let ids = rerun_checks(queue, home, talk, &run, &request, &head, &workflows).await?;
280            format!("requested a rerun of workflow run(s) {}", ids.join(", "))
281        }
282        Operation::Decide {
283            question,
284            choice,
285            title,
286        } => {
287            let q = record_decision(home, &question, &choice, title.as_deref())?;
288            format!("recorded `{choice}` for decision {}", q.short())
289        }
290        Operation::Answer {
291            question,
292            reply,
293            title,
294        } => crate::ask::record_answer(
295            &crate::ask::Questions::at(home.join("questions")),
296            &question,
297            &reply,
298            title.as_deref(),
299        )?,
300        Operation::ReplyOther { question, text } => crate::consult::reply_other(
301            &crate::ask::Questions::at(home.join("questions")),
302            &question,
303            &text,
304        )?,
305        Operation::ReconcileRetention {
306            task,
307            authority,
308            apply,
309        } => {
310            let path = Box::pin(crate::retention::reconcile::run_chat(
311                home,
312                &task,
313                &authority,
314                apply.as_deref(),
315                crate::retention::reconcile::ChatRequest {
316                    talks,
317                    conversation: talk,
318                },
319            ))
320            .await?;
321            if apply.is_none() {
322                // Return the complete saved evidence through the capability bridge.
323                crate::retention::reconcile::chat_summary(&path)?
324            } else {
325                format!(
326                    "Applied saved plan {}",
327                    apply.as_deref().unwrap_or_default()
328                )
329            }
330        }
331        Operation::Abandon { run } => {
332            abandon(queue, home, &run)?;
333            format!("abandoned run {run}")
334        }
335    })
336}
337
338/// Record the owner's choice for a decision from an open standing chat. The
339/// chat agent interprets the owner's request directly, a merge included; no
340/// quote, consent phrase or task ownership is required. See [`record_decision`].
341pub fn decide(
342    talks: &Talks,
343    home: &Path,
344    talk: &str,
345    question: &str,
346    choice: &str,
347) -> Result<crate::ask::Question> {
348    authorize(talks, talk, crate::queue::CHAT_NODE)?;
349    record_decision(home, question, choice, None)
350}
351
352/// Answer an open decision whose answer the machinery consumes
353/// ([`crate::task_chat::awaits_decision`]: a merge or release approval, the
354/// posting gate, triage, a divergence reconcile, a structured choice action).
355/// The question must still be open and `choice` one of its offered choices;
356/// the consumer behind it keeps its own state and transition checks.
357fn record_decision(
358    home: &Path,
359    question: &str,
360    choice: &str,
361    title: Option<&str>,
362) -> Result<crate::ask::Question> {
363    use crate::ask::{Answer, Questions};
364    let questions = Questions::at(home.join("questions"));
365    let id = questions.resolve_id(question)?;
366    let q = questions.get(&id)?;
367    ensure!(
368        crate::task_chat::awaits_decision(&q),
369        "question {} is not a decision the machinery waits on; answer it with `magi answer`",
370        q.short()
371    );
372    let (q, ()) = questions.update(&q.id, |r| {
373        ensure!(
374            r.status.open(),
375            "question {} is already {}",
376            r.short(),
377            r.status.as_str()
378        );
379        let reply = r.resolve_reply(choice)?.to_owned();
380        let replacement =
381            r.node == crate::github_text::ASK_NODE && reply == crate::github_text::USE_MY_TEXT;
382        ensure!(
383            replacement || title.is_none(),
384            "--title is only valid for a publication decision's `use my text` choice"
385        );
386        let title = if replacement {
387            let title =
388                title.context("use my text needs --title with the literal replacement title")?;
389            ensure!(
390                !title.trim().is_empty(),
391                "replacement title must not be empty"
392            );
393            Some(title.to_owned())
394        } else {
395            None
396        };
397        let answer = if r.free_text() {
398            Answer::Text(reply)
399        } else {
400            Answer::Choice(reply)
401        };
402        r.publication_title = title;
403        r.answer(answer)
404    })?;
405    Ok(q)
406}
407
408/// The conversation this process speaks for, or why it may not act.
409pub fn authorize(talks: &Talks, run: &str, node: &str) -> Result<Talk> {
410    if node != crate::queue::CHAT_NODE || run.trim().is_empty() {
411        bail!("task control requires MAGI_NODE=chat and MAGI_RUN from a standing chat");
412    }
413    let id = crate::chat_watch::authorize(talks, run, node)?;
414    talks.get(&id)
415}
416
417/// The task that recorded `run` (a full run id).
418fn run_task(queue: &Queue, run: &str) -> Result<Task> {
419    ensure!(
420        crate::run::is_run_id(run),
421        "an execution command needs the full run id"
422    );
423    queue
424        .list()
425        .into_iter()
426        .find(|t| t.runs.iter().any(|r| r == run))
427        .with_context(|| format!("run {run} belongs to no task"))
428}
429
430/// Claim a task from a full or unambiguous-prefix id and read it under the
431/// claim, never from a lookup made before it.
432fn claimed(queue: &Queue, reference: &str) -> Result<(crate::queue::Claim, Task)> {
433    let id = queue.resolve_id(reference)?;
434    let claim = queue
435        .claim(&id)
436        .with_context(|| format!("task {id} is claimed by a running daemon"))?;
437    let task = queue.get(&id)?;
438    Ok((claim, task))
439}
440
441fn check_hold_release(task: &Task, operation: &str) -> Result<()> {
442    match task.status {
443        TaskStatus::Queued
444        | TaskStatus::Held
445        | TaskStatus::Failed
446        | TaskStatus::Blocked
447        | TaskStatus::Parked => Ok(()),
448        TaskStatus::Done => bail!(
449            "cannot {operation} task {} (done): completed work must not be restarted; create a new task instead",
450            task.id
451        ),
452        TaskStatus::Running => bail!(
453            "cannot {operation} task {} (running): its run may still be active; use resume or abandon for the existing run instead",
454            task.id
455        ),
456    }
457}
458
459/// Mark a task complete under its queue claim, as `magi task done` does.
460pub fn done(queue: &Queue, home: &Path, reference: &str) -> Result<Task> {
461    let (_claim, mut task) = claimed(queue, reference)?;
462    task.succeed();
463    // Manual completion also retires earlier attempts when the latest one landed.
464    crate::daemon::supersede_prior_runs(&task, home);
465    queue.put(&mut task)?;
466    Ok(task)
467}
468
469/// Change priority under the same claim and state check as the terminal route.
470pub fn set_priority(queue: &Queue, reference: &str, priority: i32) -> Result<Task> {
471    let (_claim, mut task) = claimed(queue, reference)?;
472    task.set_priority(priority)?;
473    queue.put(&mut task)?;
474    Ok(task)
475}
476
477/// Hold a task for the owner.
478pub fn hold(queue: &Queue, reference: &str, reason: &str) -> Result<Task> {
479    let (_claim, mut task) = claimed(queue, reference)?;
480    check_hold_release(&task, "hold")?;
481    let reason = reason.trim();
482    task.hold_manual((!reason.is_empty()).then(|| reason.to_owned()));
483    queue.put(&mut task)?;
484    Ok(task)
485}
486
487/// Release a task (the same transition as `magi task release`: the attempt
488/// count restarts, the history stays). An operator hold is released too: the
489/// owner asked for it.
490pub fn release(queue: &Queue, reference: &str) -> Result<Task> {
491    let (_claim, mut task) = claimed(queue, reference)?;
492    check_hold_release(&task, "release")?;
493    task.release_operator("chat", None);
494    queue.put(&mut task)?;
495    Ok(task)
496}
497
498/// Change a queued or held task's instruction and queue it again, in one
499/// save under one claim. Only what [`Task::edit`] allows can be retried; the
500/// attempt count follows [`Task::release`] (restarts), while runs, passes and
501/// the resumption history are kept.
502pub fn retry(
503    queue: &Queue,
504    reference: &str,
505    title: Option<String>,
506    instruction: &str,
507) -> Result<Task> {
508    ensure!(
509        !instruction.trim().is_empty(),
510        "the new instruction is empty"
511    );
512    let (_claim, mut task) = claimed(queue, reference)?;
513    let title = title.unwrap_or_else(|| crate::queue::title_from(instruction, 72));
514    task.edit(title, instruction.to_owned())?;
515    task.release_operator("chat", None);
516    queue.put(&mut task)?;
517    Ok(task)
518}
519
520/// Resume a stopped, blocked or parked run (its task's latest). Only queues
521/// the pinned resume; returns the owning task.
522pub fn resume(queue: &Queue, home: &Path, run: &str) -> Result<Task> {
523    let task = run_task(queue, run)?;
524    ensure!(
525        task.runs.last().map(String::as_str) == Some(run),
526        "only the task's latest run can be resumed"
527    );
528    crate::resume::validate(home, run)?;
529    ensure!(
530        crate::resume::pin_queued(queue, run, None, None)?,
531        "no queued task owns run {run}"
532    );
533    queue.get(&task.id)
534}
535
536/// チャットのリクエスト単位で受付記録を残し、共通遷移でチェックを再実行する。
537pub async fn rerun_checks(
538    queue: &Queue,
539    home: &Path,
540    talk: &str,
541    run: &str,
542    request: &str,
543    head: &str,
544    workflows: &[String],
545) -> Result<Vec<String>> {
546    run_task(queue, run)?;
547    let state = crate::run::RunState::load_under(run, home)?;
548    let pr = &state
549        .pr
550        .as_ref()
551        .context("the run recorded no pull request")?
552        .url;
553    ensure!(
554        crate::ask::valid_asset_name(request),
555        "request must be a safe filename of at most 64 bytes"
556    );
557    let dir = home.join("chat-controls").join(talk);
558    std::fs::create_dir_all(&dir)?;
559    let lock = std::fs::OpenOptions::new()
560        .create(true)
561        .truncate(false)
562        .write(true)
563        .open(dir.join(format!("{request}.lock")))?;
564    crate::pr_head::try_lock_file(&lock)?;
565    let path = dir.join(format!("{request}.json"));
566    let action = serde_json::json!({
567        "run": run, "repo": state.repo, "pr": pr, "head": head, "runs": workflows
568    });
569    if path.exists() {
570        let prior: serde_json::Value = serde_json::from_slice(&std::fs::read(&path)?)?;
571        ensure!(
572            prior["action"] == action,
573            "request ID already binds a different operation"
574        );
575        ensure!(
576            prior["status"] == "applied",
577            "previous request has an uncertain result; inspect its receipt before a new request"
578        );
579        return Ok(workflows.to_vec());
580    }
581    let record = |status: &str| -> Result<()> {
582        let tmp = path.with_extension("tmp");
583        std::fs::write(
584            &tmp,
585            serde_json::to_vec(&serde_json::json!({
586                "action": action, "status": status
587            }))?,
588        )?;
589        std::fs::rename(tmp, &path)?;
590        Ok(())
591    };
592    let operation = format!("chat:{talk}:{request}");
593    crate::daemon::rerun_checks_once(home, &operation, &state.repo, pr, head, workflows, || {
594        record("started")
595    })
596    .await?;
597    record("applied")?;
598    Ok(workflows.to_vec())
599}
600
601/// Abandon a recorded run whose driver is proven gone. No confirmation phrase
602/// is needed; an unknown or live driver is refused by
603/// [`crate::driver::abandon_proven_dead`], so a working driver is never broken.
604pub fn abandon(queue: &Queue, home: &Path, run: &str) -> Result<()> {
605    run_task(queue, run)?;
606    crate::driver::abandon_proven_dead(home, run)
607}
608
609/// One line a person or agent can read after a successful command.
610pub fn describe(task: &Task, what: &str) -> String {
611    let status = match task.status {
612        TaskStatus::Queued => "queued",
613        _ => task.status.as_str(),
614    };
615    format!("{what} {} {} ({status})", task.short(), task.title)
616}
617
618#[cfg(test)]
619mod tests {
620    use super::*;
621    use crate::queue::Source;
622    use jiff::Timestamp;
623
624    fn talk(talks: &Talks, id: &str) -> Talk {
625        let mut t: Talk = serde_json::from_value(serde_json::json!({
626            "schema":1,"id":id,"repo":".","agent":"worker","status":"open","turns":[],
627            "created_at":Timestamp::now(),"updated_at":Timestamp::now(),
628            "seat":crate::agent::SeatState::new("talk","worker",1)
629        }))
630        .unwrap();
631        talks.put(&mut t).unwrap();
632        t
633    }
634
635    fn filed(queue: &Queue, talk: &str, text: &str) -> Task {
636        let mut t = Task::new(
637            text.into(),
638            text.into(),
639            ".".into(),
640            Source::Agent {
641                run: talk.into(),
642                node: crate::queue::CHAT_NODE.into(),
643            },
644        );
645        queue.put(&mut t).unwrap();
646        t
647    }
648
649    #[test]
650    fn only_the_chat_seat_of_an_open_talk_is_authorized() {
651        let tmp = tempfile::tempdir().unwrap();
652        let talks = Talks::at(tmp.path().join("talks"));
653        let t = talk(&talks, "talk-a");
654        assert!(authorize(&talks, &t.id, "chat").is_ok());
655        assert!(authorize(&talks, &t.id, "deputy").is_err());
656        assert!(authorize(&talks, &t.id, "").is_err());
657        assert!(authorize(&talks, "", "chat").is_err());
658        assert!(
659            authorize(&talks, "talk-", "chat").is_err(),
660            "prefix is not an id"
661        );
662    }
663
664    #[test]
665    fn tasks_of_any_origin_are_controlled_on_request() {
666        let tmp = tempfile::tempdir().unwrap();
667        let queue = Queue::at(tmp.path().join("queue"));
668        let theirs = filed(&queue, "talk-b", "theirs");
669        let mut hand = Task::new("h".into(), "h".into(), ".".into(), Source::Human);
670        hand.hold_manual(Some("operator".into()));
671        queue.put(&mut hand).unwrap();
672        // Another chat's task and an operator-held human task are the owner's
673        // work too: the request goes through without any ownership scope.
674        assert_eq!(
675            hold(&queue, &theirs.id, "wait").unwrap().status,
676            TaskStatus::Held
677        );
678        assert_eq!(
679            release(&queue, &hand.id).unwrap().status,
680            TaskStatus::Queued
681        );
682        let again = retry(&queue, &theirs.id, None, "new").unwrap();
683        assert_eq!(again.instruction, "new");
684        assert_eq!(again.status, TaskStatus::Queued);
685    }
686
687    #[test]
688    fn hold_then_release_keeps_history_and_restarts_attempts() {
689        let tmp = tempfile::tempdir().unwrap();
690        let queue = Queue::at(tmp.path().join("queue"));
691        let mut t = filed(&queue, "talk-a", "work");
692        t.attempts = 3;
693        t.runs.push("20260101-000000-aaaa".into());
694        queue.put(&mut t).unwrap();
695        let held = hold(&queue, &t.id, "owner asked").unwrap();
696        assert_eq!(held.status, TaskStatus::Held);
697        assert_eq!(held.hold_reason.as_deref(), Some("owner asked"));
698        let back = release(&queue, &t.id).unwrap();
699        assert_eq!(back.status, TaskStatus::Queued);
700        assert_eq!(back.attempts, 0);
701        assert_eq!(back.runs, t.runs);
702        assert!(back.resumption.is_some());
703    }
704
705    #[test]
706    fn hold_and_release_refuse_done_and_running_without_saving() {
707        let tmp = tempfile::tempdir().unwrap();
708        let queue = Queue::at(tmp.path().join("queue"));
709        for status in [TaskStatus::Done, TaskStatus::Running] {
710            let mut t = filed(&queue, "talk-a", "work");
711            t.start("20260101-000000-aaaa".into());
712            if status == TaskStatus::Done {
713                t.succeed();
714            }
715            queue.put(&mut t).unwrap();
716            let before = std::fs::read(queue.path_of(&t.id)).unwrap();
717            for operation in ["hold", "release"] {
718                let error = if operation == "hold" {
719                    hold(&queue, &t.id, "owner asked")
720                } else {
721                    release(&queue, &t.id)
722                }
723                .unwrap_err()
724                .to_string();
725                assert!(error.contains(operation), "{error}");
726                assert!(error.contains(&t.id), "{error}");
727                assert!(error.contains(status.as_str()), "{error}");
728                let guidance = if status == TaskStatus::Done {
729                    "create a new task"
730                } else {
731                    "use resume or abandon"
732                };
733                assert!(error.contains(guidance), "{error}");
734                assert_eq!(std::fs::read(queue.path_of(&t.id)).unwrap(), before);
735            }
736        }
737    }
738
739    #[test]
740    fn hold_and_release_allow_unfinished_inactive_tasks() {
741        let tmp = tempfile::tempdir().unwrap();
742        let queue = Queue::at(tmp.path().join("queue"));
743        for status in [
744            TaskStatus::Queued,
745            TaskStatus::Held,
746            TaskStatus::Failed,
747            TaskStatus::Blocked,
748            TaskStatus::Parked,
749        ] {
750            for operation in ["hold", "release"] {
751                let mut t = filed(&queue, "talk-a", "work");
752                t.status = status;
753                t.hold_reason = Some("old reason".into());
754                t.attempts = 3;
755                t.runs.push("20260101-000000-aaaa".into());
756                queue.put(&mut t).unwrap();
757                let changed = if operation == "hold" {
758                    let held = hold(&queue, &t.id, " new reason ").unwrap();
759                    assert_eq!(held.status, TaskStatus::Held);
760                    assert_eq!(held.hold_reason.as_deref(), Some("new reason"));
761                    assert_eq!(held.attempts, t.attempts);
762                    held
763                } else {
764                    let released = release(&queue, &t.id).unwrap();
765                    assert_eq!(released.status, TaskStatus::Queued);
766                    assert_eq!(released.attempts, 0);
767                    assert_eq!(released.resumption.as_ref().unwrap().source, "chat");
768                    released
769                };
770                assert_eq!(changed.runs, t.runs);
771                assert_eq!(queue.get(&t.id).unwrap().status, changed.status);
772            }
773        }
774    }
775
776    #[test]
777    fn retry_edits_and_requeues_in_one_save() {
778        let tmp = tempfile::tempdir().unwrap();
779        let queue = Queue::at(tmp.path().join("queue"));
780        let mut t = filed(&queue, "talk-a", "old text");
781        t.runs.push("20260101-000000-aaaa".into());
782        t.attempts = 2;
783        t.hold_manual(Some("stopped".into()));
784        queue.put(&mut t).unwrap();
785        let again = retry(&queue, &t.id, None, "new text").unwrap();
786        assert_eq!(again.instruction, "new text");
787        assert_eq!(again.status, TaskStatus::Queued);
788        assert_eq!(again.attempts, 0);
789        assert_eq!(again.runs, t.runs, "run history is kept");
790        assert_eq!(again.resumption.as_ref().unwrap().source, "chat");
791        assert_eq!(again.resumption.as_ref().unwrap().instruction, "new text");
792        // A running task cannot be edited: nothing is changed.
793        let mut running = filed(&queue, "talk-a", "busy");
794        running.start("20260101-000000-bbbb".into());
795        queue.put(&mut running).unwrap();
796        assert!(retry(&queue, &running.id, None, "x").is_err());
797        assert_eq!(queue.get(&running.id).unwrap().instruction, "busy");
798        assert!(retry(&queue, &t.id, None, "  ").is_err());
799    }
800
801    #[test]
802    fn execution_commands_need_the_full_id_of_a_recorded_run() {
803        let tmp = tempfile::tempdir().unwrap();
804        let queue = Queue::at(tmp.path().join("queue"));
805        let mut t = filed(&queue, "talk-b", "work");
806        t.runs.push("20260101-000000-aaaa".into());
807        queue.put(&mut t).unwrap();
808        assert_eq!(
809            run_task(&queue, "20260101-000000-aaaa").unwrap().id,
810            t.id,
811            "any chat may address another chat's run"
812        );
813        assert!(run_task(&queue, "aaaa").is_err());
814        assert!(run_task(&queue, "20260101-000000-bbbb").is_err());
815        assert!(resume(&queue, tmp.path(), "aaaa").is_err());
816    }
817
818    #[test]
819    fn abandon_needs_no_phrase_but_refuses_unrecorded_and_unknown_runs() {
820        let tmp = tempfile::tempdir().unwrap();
821        let queue = Queue::at(tmp.path().join("queue"));
822        let mut t = filed(&queue, "talk-b", "work");
823        t.runs.push("20260101-000000-aaaa".into());
824        queue.put(&mut t).unwrap();
825        // A run no task recorded is refused before liveness is judged.
826        assert!(abandon(&queue, tmp.path(), "20260101-000000-bbbb").is_err());
827        // A recorded run whose driver is not proven dead (here: no state to
828        // judge at all) is never abandoned.
829        assert!(abandon(&queue, tmp.path(), "20260101-000000-aaaa").is_err());
830    }
831}