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