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