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