Skip to main content

magi/
consult.rs

1//! Handing an open question to the chat conversation its task came from.
2//!
3//! A task filed by the standing chat (`Source::Agent` with
4//! [`crate::queue::CHAT_NODE`], whose `run` is the conversation's id) can raise
5//! a question the owner would rather talk through where the task started. The
6//! owner passes it on; the chat agent either answers it with `magi answer` or
7//! puts the decision points to the owner in the conversation.
8//!
9//! Three rules, each with a reason:
10//!
11//! - **One place decides.** [`origin_talk`] is the only judge of "does this
12//!   question have a chat to ask"; the web view and both entry points use it.
13//!   [`associated_talk`] resolves typed ownership links for navigation,
14//!   independently of consultation eligibility.
15//! - **Not a choice.** The hand-over never goes into `Question::choices` and
16//!   never touches `Question::answer`: the question stays `Open`, its
17//!   validation and the choice actions stay as they were.
18//! - **No new seat, no new waiter.** The text is queued as a draft of the
19//!   existing talk, so the chat's own turn machinery (its session, its turn
20//!   gate) runs it exactly as if the owner had typed it.
21
22use anyhow::{Context, Result, bail};
23use jiff::Timestamp;
24
25use crate::ask::{ChatConsult, Question, Questions};
26use crate::config::Config;
27use crate::queue::{CHAT_NODE, Task};
28use crate::talk::{self, Talk, Talks};
29
30/// The conversation `q` may be handed to, if any.
31///
32/// Requires an open question whose task was filed from a chat. The chat may
33/// be closed: [`begin`] reopens it. Release notices are left out. Merge
34/// approvals may be discussed, but answering from chat requires the owner's latest words.
35pub fn origin_talk(tasks: &[Task], talks: &[Talk], q: &Question) -> Option<Talk> {
36    if !q.status.open() || q.node == crate::bump::NOTICE_NODE {
37        return None;
38    }
39    let task = crate::daemon::task_of_question(tasks, q)?;
40    let chat = chat_talk_of(tasks, task)?;
41    talks.iter().find(|talk| talk.id == chat).cloned()
42}
43
44/// The existing conversation associated with a question, regardless of whether
45/// the question is still eligible for consultation.
46pub fn associated_talk(tasks: &[Task], talks: &[Talk], q: &Question) -> Option<Talk> {
47    associated_talk_with_watches(tasks, talks, q, &[])
48}
49
50/// A typed ownership link, shared by association and the web's selective loader.
51#[derive(Clone, Copy)]
52pub(crate) enum QuestionTaskRef<'a> {
53    Task(&'a str),
54    Run(&'a str),
55}
56
57pub(crate) fn question_task_refs<'a>(
58    q: &'a Question,
59    watches: &'a [crate::release_watch::WatchState],
60) -> Vec<QuestionTaskRef<'a>> {
61    // Review-start reconciliation is filed under a task id, unlike normal
62    // review questions; both the node and seat identify that producer.
63    let names_task = q.run_names_task()
64        || (q.node == crate::reconcile::NODE && q.seat == crate::reconcile::SEAT);
65    let mut refs = vec![if names_task {
66        QuestionTaskRef::Task(&q.run)
67    } else {
68        QuestionTaskRef::Run(&q.run)
69    }];
70    let mut matches = watches
71        .iter()
72        .filter(|w| w.question.as_deref() == Some(&q.id));
73    // Duplicate records are ambiguous even if they currently name one owner.
74    if let Some(watch) = matches.next()
75        && matches.next().is_none()
76    {
77        refs.extend(watch.run.as_deref().map(QuestionTaskRef::Run));
78        refs.extend(watch.held_task.as_deref().map(QuestionTaskRef::Task));
79    }
80    refs
81}
82
83/// Explicit links may fill a missing owner, but conflicting owners fail closed.
84/// This navigation-only resolution never changes consultation eligibility.
85pub(crate) fn associated_talk_with_watches(
86    tasks: &[Task],
87    talks: &[Talk],
88    q: &Question,
89    watches: &[crate::release_watch::WatchState],
90) -> Option<Talk> {
91    let mut owner: Option<&Task> = None;
92    for reference in question_task_refs(q, watches) {
93        for task in tasks.iter().filter(|task| match reference {
94            QuestionTaskRef::Task(id) => task.id == id,
95            QuestionTaskRef::Run(id) => task.runs.iter().any(|run| run == id),
96        }) {
97            if owner.is_some_and(|previous| previous.id != task.id) {
98                return None;
99            }
100            owner = Some(task);
101        }
102    }
103    let chat = chat_talk_of(tasks, owner?)?;
104    talks.iter().find(|talk| talk.id == chat).cloned()
105}
106
107/// The talk `start` descends from: the id recorded on a task
108/// ([`Task::chat_talk`]) wins, so deleted ancestors do not matter; tasks
109/// written before it existed are resolved by walking the ancestry.
110///
111/// Walk the provenance of `start` to the task a chat filed.
112///
113/// A follow-up goes to the task its merged run served: `FollowUp::origin_task`
114/// when recorded (a missing one ends the walk - guessing another task could
115/// hand the question to an unrelated chat), else the task whose `runs` hold
116/// `FollowUp::run`. At most `MAX_FOLLOWUP_GENERATION + 1` tasks are looked at,
117/// each once, so a cycle ends in `None`.
118pub fn chat_talk_of(tasks: &[Task], start: &Task) -> Option<String> {
119    let mut seen = std::collections::HashSet::new();
120    let mut cur = start;
121    for _ in 0..=crate::followup::MAX_FOLLOWUP_GENERATION {
122        if !seen.insert(cur.id.as_str()) {
123            return None;
124        }
125        if let Some(id) = cur.chat_talk() {
126            return Some(id.to_owned());
127        }
128        let f = cur.followup.as_ref()?;
129        cur = match &f.origin_task {
130            Some(id) => tasks.iter().find(|t| &t.id == id)?,
131            None => tasks.iter().find(|t| t.runs.contains(&f.run))?,
132        };
133    }
134    None
135}
136
137/// Is any question handed to talk `talk_id` still open?
138///
139/// Decided from the question store, not from the newest turn's wording: the
140/// owner's decision usually arrives in a later turn that carries no hand-over
141/// heading, and `magi answer` still has to be able to write then. Answered or
142/// abandoned questions and ended consultations stop counting, restoring normal
143/// permissions on the next turn.
144pub fn pending_consults(questions: &Questions, talk_id: &str) -> bool {
145    questions.list().iter().any(|q| active_consult(q, talk_id))
146}
147
148/// Consultation activity is separate from the question's waiting state.
149pub fn active_consult(q: &Question, talk_id: &str) -> bool {
150    q.routed_task.is_none()
151        && q.status.open()
152        && q.consult_outcome.is_none()
153        && q.consult.as_ref().is_some_and(|c| c.talk == talk_id)
154}
155
156/// A non-answer never wakes a waiter or executes a choice action.
157#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
158pub struct ConsultOutcome {
159    /// Literal owner outcome, with no execution effect.
160    pub text: String,
161    /// Complete latest owner send used as evidence.
162    pub instruction: String,
163    /// Originating conversation.
164    pub talk: String,
165    /// Persisted conversation turn index.
166    pub turn: usize,
167    /// Persisted send index within the turn.
168    pub message: usize,
169    /// Time consultation ended.
170    pub at: Timestamp,
171}
172
173/// Closed inventory of additional consultation operations. Every execution
174/// argument is bound by the owner's complete instruction, not just a task id.
175#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
176#[serde(rename_all = "snake_case", deny_unknown_fields)]
177pub enum TaskOperation {
178    /// File work in the conversation's repository with default execution options.
179    Add {
180        /// Complete task text.
181        instruction: String,
182        /// Whether to use one implementer.
183        solo: bool,
184    },
185    /// Release a held or finished task.
186    Release {
187        /// Full resolved task id.
188        task: String,
189    },
190    /// Hold a task for the owner.
191    Hold {
192        /// Full resolved task id.
193        task: String,
194        /// Complete hold reason.
195        reason: String,
196    },
197    /// Mark work finished without running it.
198    Done {
199        /// Full resolved task id.
200        task: String,
201    },
202    /// Adjust the queue priority.
203    Priority {
204        /// Full resolved task id.
205        task: String,
206        /// New priority.
207        priority: i32,
208    },
209}
210
211impl TaskOperation {
212    /// A literal, unambiguous individual instruction; JSON avoids shell parsing.
213    pub fn instruction(&self) -> String {
214        format!(
215            "task-operation: {}",
216            serde_json::to_string(self).expect("task operation")
217        )
218    }
219}
220
221fn delivered_instruction<'a>(
222    q: &Question,
223    talk: &'a Talk,
224    quote: Option<&str>,
225) -> Result<(usize, usize, &'a str)> {
226    if !active_consult(q, &talk.id) {
227        bail!("no active consultation in this conversation");
228    }
229    let (turn, message, words) = current_instruction(talk)
230        .context("no current persisted owner instruction; drain newer messages first")?;
231    if quote != Some(words) {
232        bail!("--quote must equal the entire latest persisted owner message");
233    }
234    let delivery = talk
235        .turns
236        .iter()
237        .position(|t| {
238            t.who == talk::Who::Operator
239                && t.body.contains(crate::prompt::CHAT_CONSULT_HEADING)
240                && t.body.contains(&q.id)
241        })
242        .context("question has not been delivered")?;
243    let consult = q.consult.as_ref().expect("active consult");
244    if turn < delivery
245        || talk.turns[turn].at < consult.at
246        || (turn == delivery
247            && !talk.turns[turn].messages[..message].iter().any(|m| {
248                m.body.contains(crate::prompt::CHAT_CONSULT_HEADING) && m.body.contains(&q.id)
249            }))
250    {
251        bail!("owner instruction must follow consultation delivery");
252    }
253    Ok((turn, message, words))
254}
255
256/// Caller holds the conversation evidence lock until the queue mutation ends.
257pub fn authorize_task_operation(
258    questions: &Questions,
259    talk: &Talk,
260    operation: &TaskOperation,
261    quote: Option<&str>,
262) -> Result<()> {
263    let expected = operation.instruction();
264    if !questions.list().iter().any(|q| {
265        delivered_instruction(q, talk, quote).is_ok_and(|(_, _, words)| {
266            words
267                .strip_prefix("task-operation: ")
268                .and_then(|json| serde_json::from_str::<TaskOperation>(json).ok())
269                .as_ref()
270                == Some(operation)
271        })
272    }) {
273        bail!(
274            "task operation requires individual post-delivery owner evidence and --quote of the entire latest send; required instruction: {expected}"
275        );
276    }
277    Ok(())
278}
279
280/// Store an evidenced alternative and end only the consultation. Caller keeps
281/// the conversation evidence lock until Questions::update publishes the note.
282pub fn record_other(q: &mut Question, talk: &Talk, text: &str, quote: Option<&str>) -> Result<()> {
283    if text.trim().is_empty() || q.free_text() || q.choices.iter().any(|c| c == text) {
284        bail!("--reply-other requires a nonempty outcome outside the offered choices");
285    }
286    let (turn, message, words) = delivered_instruction(q, talk, quote)?;
287    // A note is the owner's literal decision, never an assistant paraphrase
288    // that could smuggle an execution authorization into retained evidence.
289    if words != text {
290        bail!("--reply-other must equal the owner's entire latest message");
291    }
292    q.schema = crate::ask::SCHEMA;
293    q.consult_outcome = Some(ConsultOutcome {
294        text: text.to_owned(),
295        instruction: words.to_owned(),
296        talk: talk.id.clone(),
297        turn,
298        message,
299        at: Timestamp::now(),
300    });
301    Ok(())
302}
303
304/// Record the hand-over and queue the question as the chat's next message.
305///
306/// Returns `false` (and changes nothing) when the question was already handed
307/// over. The question is not otherwise touched: still `Open`, no thread turn.
308/// When queueing fails the record is withdrawn, so the owner can try again.
309pub fn begin(questions: &Questions, talks: &Talks, q: &Question, talk: &Talk) -> Result<bool> {
310    let (q, fresh) = questions.update(&q.id, |r| {
311        anyhow::ensure!(
312            r.routed_task.is_none(),
313            "task decision is delivered by its own chat turn"
314        );
315        if !r.status.open() {
316            bail!("question {} is already {}", r.short(), r.status.as_str());
317        }
318        if r.consult.is_some() {
319            return Ok(false);
320        }
321        r.consult = Some(ChatConsult {
322            talk: talk.id.clone(),
323            at: Timestamp::now(),
324        });
325        Ok(true)
326    })?;
327    if !fresh {
328        return Ok(false);
329    }
330    // Serialize dispatch with answers, but never wait for conversation locks
331    // under the expiring question lock. Each retry reloads the latest question.
332    let deadline = std::time::Instant::now() + std::time::Duration::from_secs(20);
333    let dispatch = loop {
334        let mut busy = false;
335        let updated = questions.update(&q.id, |r| {
336            if !r.status.open() || r.consult != q.consult {
337                return Ok(false);
338            }
339            if talk::try_queue_consult(
340                talks,
341                &talk.id,
342                &crate::prompt::chat_consult(r),
343                Some(crate::prompt::chat_consult_display(r)),
344            )?
345            .is_none()
346            {
347                busy = true;
348                bail!("the conversation store is locked by another process");
349            }
350            Ok(true)
351        });
352        if busy && std::time::Instant::now() < deadline {
353            std::thread::sleep(std::time::Duration::from_millis(10));
354            continue;
355        }
356        break updated;
357    };
358    if let Err(e) = dispatch {
359        let _ = questions.update(&q.id, |r| {
360            if r.status.open() && r.consult == q.consult {
361                r.consult = None;
362            }
363            Ok(())
364        });
365        return Err(e).context("queue the question into the chat");
366    }
367    Ok(dispatch?.1)
368}
369
370/// Refuse a chat approval answer whose task the operator held, as
371/// `ask_settle_cmd` does for a deputy: such a hold is lifted by the operator's
372/// button. The task is the one `q.run` names, directly or as one of its runs;
373/// none found (an unreadable task is not listed) does not refuse. The caller
374/// reads the queue before taking the question lock, so a hold placed in between
375/// is missed for that one call.
376pub fn refuse_operator_held(q: &Question, node: &str, tasks: &[crate::queue::Task]) -> Result<()> {
377    if node != CHAT_NODE || QuestionKind::of(q) != QuestionKind::Merge {
378        return Ok(());
379    }
380    let release_run = if q.run.is_empty() && q.node == crate::bump::NOTICE_NODE {
381        crate::run::try_home()
382            .and_then(|home| crate::release_watch::state_for_question(&home, &q.id))
383            .and_then(|state| state.run)
384    } else {
385        None
386    };
387    let run = release_run.as_deref().unwrap_or(&q.run);
388    if let Some(task) = tasks
389        .iter()
390        .find(|t| t.id == run || t.runs.iter().any(|id| id == run))
391        && task.operator_held()
392    {
393        bail!(
394            "task {} is held by the operator; an answer cannot settle it",
395            task.short()
396        );
397    }
398    Ok(())
399}
400
401/// Authorization policy is selected by the question's node, never its reply.
402#[derive(Clone, Copy, PartialEq, Eq)]
403pub enum QuestionKind {
404    /// A question without a guarded public decision.
405    Ordinary,
406    /// Approval to merge a change.
407    Merge,
408    /// Confirmation of the text to publish.
409    Posting,
410}
411
412/// Owner evidence retained with an answer, independent of its consultation destination.
413#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
414pub struct AnswerAuthority {
415    /// Conversation containing the instruction.
416    pub talk: String,
417    /// Zero-based persisted turn position.
418    pub turn: usize,
419    /// Zero-based send position within that turn.
420    pub message: usize,
421    /// Full instruction, not a substring selected by the assistant.
422    pub instruction: String,
423    /// Stable fingerprint of the complete instruction.
424    pub instruction_fingerprint: String,
425    /// Immediately preceding persona reply, for a batch instruction.
426    pub recommendation_turn: Option<usize>,
427    /// Fingerprint of the complete persona reply used as the batch manifest.
428    pub recommendation_fingerprint: Option<String>,
429    /// Exact decision covered by this evidence.
430    pub question: String,
431    /// Exact offered choice covered by the instruction.
432    pub choice: String,
433    /// Execution facts validated when this answer was authorized.
434    #[serde(default)]
435    pub question_fingerprint: String,
436    /// The conversation agent interpreted this complete owner send as consent
437    /// to the single persisted internal recovery recommendation.
438    #[serde(default, skip_serializing_if = "Option::is_none")]
439    pub recovery_interpretation: Option<String>,
440}
441
442/// Immutable recommendation-time binding to question choices and targets.
443#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
444pub struct RecommendationManifest {
445    /// Fingerprint of the full persona reply.
446    pub body: String,
447    /// Full question IDs and fingerprints of their execution-relevant facts.
448    pub questions: std::collections::BTreeMap<String, String>,
449    /// Canonical choice and structured effect captured with the persona reply.
450    /// Old transcripts cannot acquire this binding retrospectively.
451    #[serde(default)]
452    pub recovery: std::collections::BTreeMap<String, RecoveryRecommendation>,
453}
454
455/// Execution binding published with a single internal recovery recommendation.
456#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
457pub struct RecoveryRecommendation {
458    /// Canonical stored label, independent of any alias used in the reply.
459    pub choice: String,
460    /// Structured effect; absent only for issuer-defined legacy keep-held.
461    pub action: Option<crate::ask::ChoiceAction>,
462    /// Run owning the question at recommendation time.
463    pub run: String,
464}
465
466/// Fingerprint of execution-relevant question facts, independent of answer state.
467pub fn question_fingerprint(q: &Question) -> String {
468    let facts = serde_json::json!({
469        "id": q.id, "run": q.run, "node": q.node, "seat": q.seat,
470        "summary": q.summary, "detail": q.detail, "choices": q.choices,
471        "actions": q.actions, "merge_target": q.merge_target,
472        "posting_comparison": q.posting_comparison,
473        "decision_context": q.decision_context, "triage_generation": q.triage_generation,
474    });
475    crate::github_text::fingerprint("", &facts.to_string())
476}
477
478/// Replacement title explicitly supplied with its question/content binding.
479/// The final posting gate still vets this literal text before publication.
480pub fn owner_title<'a>(q: &Question, instruction: &'a str) -> Option<&'a str> {
481    if instruction.starts_with("    ") || instruction.starts_with('\t') {
482        return None;
483    }
484    let (command, title) = instruction.trim().split_once('\n')?;
485    let expected = format!(
486        "{}: {} {}",
487        q.id,
488        crate::github_text::USE_MY_TEXT,
489        question_fingerprint(q)
490    );
491    (command == expected && !title.trim().is_empty() && !title.contains('\n'))
492        .then_some(title.trim())
493}
494
495/// Capture at publication, never reconstruct new authority from old history.
496pub fn recommendation_manifest(body: &str, questions: &Questions) -> RecommendationManifest {
497    let snapshot = questions.list();
498    RecommendationManifest {
499        body: crate::github_text::fingerprint("", body),
500        recovery: snapshot
501            .iter()
502            .filter_map(|q| {
503                let choice = recommended_choice(body, q, questions)?;
504                (q.status.open() && contextual_recovery(q, choice)).then(|| {
505                    (
506                        q.id.clone(),
507                        RecoveryRecommendation {
508                            choice: choice.into(),
509                            action: q.actions.get(choice).cloned(),
510                            run: q.run.clone(),
511                        },
512                    )
513                })
514            })
515            .collect(),
516        questions: snapshot
517            .iter()
518            .filter(|q| q.status.open() && recommended_choice(body, q, questions).is_some())
519            .map(|q| (q.id.clone(), question_fingerprint(q)))
520            .collect(),
521    }
522}
523
524/// Validate an answer under the question lock. The caller must also retain
525/// [`Talks::answer_guard`] from before this read until the answer is persisted.
526/// Consultation is a delivery route, not the source of authority for an
527/// explicit, independently bound owner decision.
528pub fn authorize_answer(
529    q: &Question,
530    talks: &Talks,
531    questions: &Questions,
532    run: &str,
533    node: &str,
534    reply: &str,
535    quote: Option<&str>,
536) -> Result<Option<AnswerAuthority>> {
537    if node != CHAT_NODE {
538        return Ok(None);
539    }
540    // Before delivery, preserve already-supported legacy consultation replies.
541    if q.routed_task.is_some() && q.routed_chat.is_some() {
542        validate_routed_answer(q, talks, run, reply)?;
543        return Ok(None);
544    }
545    if reply == crate::land::HOLD && q.actions.contains_key(reply) {
546        bail!("hold cannot execute a question action");
547    }
548    let talk = talks.get(run)?;
549    if reply != crate::land::HOLD
550        && current_instruction(&talk).is_some_and(|(_, _, words)| {
551            words.starts_with("    ")
552                || words.starts_with('\t')
553                || words.trim_start().starts_with("```")
554                || words.trim_start().starts_with('>')
555        })
556    {
557        bail!("quoted or code-block owner text is not an explicit instruction for this action");
558    }
559    if let Some(evidence) = choice_authority(q, questions, &talk, reply, quote) {
560        validate_effect(
561            q,
562            reply,
563            &evidence.instruction,
564            quote.unwrap_or_default(),
565            true,
566        )?;
567        // A running original consultation can still be preparing a decision.
568        // Holds are safe to interrupt; other choices wait for that turn to stop.
569        if reply != crate::land::HOLD
570            && let Some(consult) = &q.consult
571            && consult.talk != run
572            && talks.turn_held(&consult.talk)
573        {
574            bail!("original consultation is active; wait for its turn to finish before answering");
575        }
576        return Ok(Some(evidence));
577    }
578    if current_instruction(&talk).is_some_and(|(_, _, words)| batch_instruction(words)) {
579        validate_effect(
580            q,
581            reply,
582            current_instruction(&talk).unwrap().2,
583            quote.unwrap_or_default(),
584            true,
585        )?;
586        bail!(
587            "batch authorization requires a current, unambiguous recommendation for this exact offered choice and unchanged question target/content"
588        );
589    }
590    // Preserve already supported consulted-chat answers. Independent evidence
591    // is required when consultation delivery cannot bind this conversation.
592    if q.consult.as_ref().is_some_and(|c| c.talk == run) {
593        validate_answer(q, talks, run, node, reply, quote)?;
594        if QuestionKind::of(q) == QuestionKind::Ordinary && consent(q, reply) != Consent::Delegable
595        {
596            let (_, _, instruction) = current_instruction(&talk)
597                .context("this action requires a current explicit owner instruction")?;
598            validate_effect(q, reply, instruction, quote.unwrap_or_default(), false)?;
599        }
600        return Ok(None);
601    }
602    if let Some((_, _, instruction)) = current_instruction(&talk) {
603        validate_effect(q, reply, instruction, quote.unwrap_or_default(), true)?;
604    }
605    bail!("{}", diagnose_authorization_failure(q, &talk, reply, quote))
606}
607
608/// Explain a refused binding without changing the authorization policy.
609fn diagnose_authorization_failure(
610    q: &Question,
611    talk: &Talk,
612    reply: &str,
613    quote: Option<&str>,
614) -> &'static str {
615    if !q.free_text() && !q.choices.iter().any(|choice| choice == reply) {
616        return "alias mismatch: resolve an offered label or supported action alias with `magi answer --list`";
617    }
618    let queued_owner = (!talk.pending.is_empty() || !talk.pending_messages.is_empty())
619        && (talk.pending_messages.is_empty()
620            || talk
621                .pending_messages
622                .iter()
623                .any(|send| send.sender != Some(talk::Sender::Magi)));
624    if queued_owner {
625        return "stale persisted evidence: a queued owner send may retract consent; wait for it to be drained and read it again";
626    }
627    let Some((turn, _, instruction)) = current_instruction(talk) else {
628        return "missing context: no current persisted owner send; read the conversation again";
629    };
630    if quote.is_some_and(|quote| quote != instruction) {
631        return "stale persisted evidence: --quote must preserve the entire latest owner send; read it again";
632    }
633    if talk.turns[turn].at < q.asked_at {
634        return "stale persisted evidence: the latest owner send predates this question; obtain a current decision";
635    }
636    if contextual_recovery(q, reply) {
637        return "insufficient consent: bind an explicit question/action reply, or interpret approval of a single saved recommendation with --recovery-consent <entire latest owner send>; clarify only if the owner's intent is ambiguous";
638    }
639    "insufficient consent: answer requires --quote of the entire latest owner send bound to this question ID and exact offered choice, or explicit authorization of the immediately preceding question-by-question recommendations; persisted evidence must be current"
640}
641
642/// Only structured internal recovery effects qualify. Approval and publication
643/// questions never acquire contextual consent, even if actions were attached.
644fn contextual_recovery(q: &Question, reply: &str) -> bool {
645    QuestionKind::of(q) == QuestionKind::Ordinary
646        && consent(q, reply) == Consent::Delegable
647        && (matches!(q.actions.get(reply), Some(crate::ask::ChoiceAction::Resume { run }) if !run.is_empty())
648            || matches!(q.actions.get(reply), Some(crate::ask::ChoiceAction::Hold))
649            || crate::triage::contextual_keep_held(q, reply))
650}
651
652/// Agent-mediated interpretation, bound to a full real owner send and the
653/// immutable recommendation. Call under the same locks as `authorize_answer`.
654/// This does not execute recovery; the daemon retains all ownership and merge gates.
655pub fn authorize_recovery(
656    q: &Question,
657    talks: &Talks,
658    questions: &Questions,
659    seat: (&str, &str),
660    reply: &str,
661    instruction: &str,
662) -> Result<Option<AnswerAuthority>> {
663    let (run, node) = seat;
664    if node != CHAT_NODE || !contextual_recovery(q, reply) {
665        bail!(
666            "insufficient consent: contextual interpretation is limited to structured internal resume/keep-held actions; use this decision's explicit consent workflow"
667        );
668    }
669    let talk = talks.get(run)?;
670    let evidence = recovery_authority(q, questions, &talk, reply, instruction)?;
671    if q.consult
672        .as_ref()
673        .is_some_and(|c| c.talk != run && talks.turn_held(&c.talk))
674    {
675        bail!("original consultation is active; wait for its turn to finish before answering");
676    }
677    Ok(Some(evidence))
678}
679
680fn recovery_authority(
681    q: &Question,
682    questions: &Questions,
683    talk: &Talk,
684    reply: &str,
685    instruction: &str,
686) -> Result<AnswerAuthority> {
687    let (turn, message, latest) = current_instruction(talk)
688        .context("missing context: wait for the current owner send to be persisted and drained")?;
689    if instruction != latest {
690        bail!(
691            "stale persisted evidence: the complete latest owner send differs; read the conversation again, including any retraction"
692        );
693    }
694    let owner_sends = talk.turns[turn]
695        .messages
696        .iter()
697        .filter(|send| talk::sender(&talk.turns[turn], Some(send)) != talk::Sender::Magi)
698        .count();
699    if owner_sends != 1
700        || latest.trim().is_empty()
701        || latest.starts_with("    ")
702        || latest.starts_with('\t')
703        || latest.trim_start().starts_with(['>', '`'])
704    {
705        bail!("insufficient consent: quoted text or multiple owner sends require clarification");
706    }
707    let index = talk.turns[..turn]
708        .iter()
709        .rposition(|t| talk::sender(t, None) != talk::Sender::Magi)
710        .context("missing context: present one explicit recovery recommendation first")?;
711    let recommendation = &talk.turns[index];
712    let manifest = talk.recommendations.get(&index).context(
713        "missing context: no persisted recommendation binding; present the recommendation now",
714    )?;
715    if recommendation.who != talk::Who::Agent
716        || talk::sender(recommendation, None) != talk::Sender::Persona
717        || recommendation.posted_by.is_some()
718        || manifest.body != crate::github_text::fingerprint("", &recommendation.body)
719        || manifest.questions.get(&q.id) != Some(&question_fingerprint(q))
720        || recommendation.at < q.asked_at
721        || talk.turns[turn].at < recommendation.at
722        || !q.status.open()
723    {
724        bail!(
725            "stale persisted evidence: recommendation or question target changed; present the current recommendation again"
726        );
727    }
728    if manifest.questions.len() != 1 {
729        bail!(
730            "ambiguous recovery context: multiple recommended decisions; clarify which question and action the owner intends"
731        );
732    }
733    let binding = manifest.recovery.get(&q.id)
734        .context("missing context: no structured recovery binding was saved; present a current recommendation")?;
735    if binding.run != q.run || binding.action.as_ref() != q.actions.get(&binding.choice) {
736        bail!("stale persisted evidence: the recommended recovery target changed");
737    }
738    if binding.choice != reply
739        || recommended_choice(&recommendation.body, q, questions) != Some(reply)
740    {
741        bail!("alias mismatch: the selected action differs from the persisted recommendation");
742    }
743    Ok(AnswerAuthority {
744        talk: talk.id.clone(),
745        turn,
746        message,
747        instruction: latest.into(),
748        instruction_fingerprint: crate::github_text::fingerprint("", latest),
749        recommendation_turn: Some(index),
750        recommendation_fingerprint: Some(manifest.body.clone()),
751        question: q.id.clone(),
752        choice: reply.into(),
753        question_fingerprint: question_fingerprint(q),
754        recovery_interpretation: Some(
755            "owner approved the single current recovery recommendation".into(),
756        ),
757    })
758}
759
760/// Whether this turn needs the question-store write grant. Uses the same
761/// persisted evidence and effect policy as the CLI; the CLI rechecks on write.
762pub fn authorized_answers(questions: &Questions, talk: &Talk) -> bool {
763    let quote = current_instruction(talk).map(|(_, _, body)| body);
764    questions.list().iter().any(|q| {
765        if q.free_text() {
766            return quote
767                .and_then(|s| s.split_once(':'))
768                .is_some_and(|(_, reply)| {
769                    choice_authority(q, questions, talk, reply.trim(), quote).is_some()
770                });
771        }
772        q.choices.iter().any(|choice| {
773            if contextual_recovery(q, choice)
774                && quote.is_some_and(|words| {
775                    recovery_authority(q, questions, talk, choice, words).is_ok()
776                })
777            {
778                return true;
779            }
780            choice_authority(q, questions, talk, choice, quote).is_some_and(|e| {
781                validate_effect(q, choice, &e.instruction, quote.unwrap_or_default(), true).is_ok()
782            })
783        })
784    })
785}
786
787/// The complete standing-turn write grant, also used by sandbox regressions.
788/// Consultations and bound owner decisions need question/evidence locks;
789/// active consultations additionally need queue storage. Evidenced `done` and
790/// dismissal effects need their exact run history/state directories. Never
791/// grant the whole home or lift the sandbox.
792pub fn answer_write_roots(
793    questions: &Questions,
794    talks: &Talks,
795    talk: &Talk,
796) -> Vec<std::path::PathBuf> {
797    if !pending_consults(questions, &talk.id) && !authorized_answers(questions, talk) {
798        return Vec::new();
799    }
800    let Some(home) = questions.root().parent() else {
801        return Vec::new();
802    };
803    let quote = current_instruction(talk).map(|(_, _, words)| words);
804    let mut roots = std::collections::BTreeSet::from([
805        questions.root().to_path_buf(),
806        talks.answer_lock_root(),
807    ]);
808    if pending_consults(questions, &talk.id) {
809        roots.insert(home.join("queue"));
810        if let Some(words) = quote
811            && let Some(json) = words.strip_prefix("task-operation: ")
812            && let Ok(op @ TaskOperation::Done { .. }) = serde_json::from_str::<TaskOperation>(json)
813            && authorize_task_operation(questions, talk, &op, quote).is_ok()
814            && let TaskOperation::Done { task } = op
815            && let Ok(task) = crate::queue::Queue::at(home.join("queue")).get(&task)
816        {
817            for run in task.runs.iter().filter(|id| crate::run::is_run_id(id)) {
818                let path = home.join("runs").join(run);
819                if path.is_dir() {
820                    roots.insert(path);
821                }
822            }
823        }
824    }
825    for q in questions.list() {
826        if q.status.open()
827            && q.node == crate::land::APPROVAL_NODE
828            && q.choices
829                .iter()
830                .any(|choice| choice == crate::merge_decision::DISMISS)
831            && crate::run::is_run_id(&q.run)
832            && authorize_answer(
833                &q,
834                talks,
835                questions,
836                &talk.id,
837                CHAT_NODE,
838                crate::merge_decision::DISMISS,
839                quote,
840            )
841            .is_ok()
842        {
843            roots.insert(home.join("queue"));
844            roots.insert(home.join("runs").join(&q.run));
845        }
846    }
847    roots.into_iter().collect()
848}
849
850/// Compatibility name for callers of the earlier hold-only grant.
851pub fn authorized_holds(questions: &Questions, talk: &Talk) -> bool {
852    authorized_answers(questions, talk)
853}
854
855/// Queue state is only a diagnostic lead, never proof of lock ownership.
856pub(crate) fn investigation_holder_hint(q: &Question, tasks: &[Task]) -> String {
857    let task = tasks.iter().find(|task| {
858        !q.run.is_empty()
859            && (task.id == q.run || task.runs.contains(&q.run))
860            && task.status == crate::queue::TaskStatus::Running
861    });
862    match task {
863        Some(task) => format!(
864            "Lock owner unknown. Related task {} is running and may hold the repository lock; inspect it with `magi task show {}`. ",
865            task.id, task.id
866        ),
867        None => "Lock owner unknown. ".into(),
868    }
869}
870
871/// Action consent is separate from consultation routing. This inventory uses
872/// execution effects (including structured actions), not only question nodes.
873#[derive(Debug, Clone, Copy, PartialEq, Eq)]
874pub enum Consent {
875    /// Explicit single or batch instruction is sufficient.
876    Delegable,
877    /// Exact PR/head approval is required.
878    Merge,
879    /// Exact offered consequence must be confirmed individually.
880    Consequence,
881    /// Chosen public text needs explicit confirmation.
882    Posting,
883}
884
885/// Execution effects shared with question deputies. The issuer and seat are
886/// part of the policy: identical labels can have different consumers.
887pub fn destructive_effect(q: &Question, label: &str) -> bool {
888    if label == crate::land::HOLD && !q.actions.contains_key(label) {
889        return false;
890    }
891    // Conductor metadata describes the requested operation independently of
892    // generated choice wording. Completing a task or changing settings needs
893    // individual consent even when no structured ChoiceAction was attached.
894    if q.node == crate::conduct::NODE
895        && q.decision_context
896            .as_deref()
897            .and_then(|context| serde_json::from_str::<serde_json::Value>(context).ok())
898            .is_some_and(|context| {
899                matches!(
900                    context
901                        .get(3)
902                        .and_then(|v| v.get(0))
903                        .and_then(|v| v.as_str()),
904                    Some("complete" | "configure")
905                )
906            })
907    {
908        return true;
909    }
910    if matches!(
911        q.actions.get(label),
912        Some(crate::ask::ChoiceAction::Done | crate::ask::ChoiceAction::AbandonRun { .. })
913    ) {
914        return true;
915    }
916    let at = q.choices.iter().position(|c| c == label);
917    // By label, not position: the retry question orders its choices differently.
918    if q.node == crate::github_text::ASK_NODE {
919        return matches!(
920            label,
921            crate::github_text::POST_ORIGINAL
922                | crate::github_text::USE_REWRITE
923                | crate::github_text::USE_MY_TEXT
924        );
925    }
926    match q.node.as_str() {
927        crate::triage::NODE => at == Some(2),
928        crate::triage::DEPS_NODE => matches!(at, Some(1 | 2)),
929        n => n == crate::reconcile::NODE && q.seat == crate::reconcile::SEAT,
930    }
931}
932
933/// Classify every offered decision, including generic structured actions.
934pub fn consent(q: &Question, choice: &str) -> Consent {
935    if choice == crate::land::APPROVE {
936        Consent::Merge
937    } else if q.node == crate::github_text::ASK_NODE
938        && matches!(
939            choice,
940            crate::github_text::POST_ORIGINAL
941                | crate::github_text::USE_REWRITE
942                | crate::github_text::USE_MY_TEXT
943        )
944    {
945        Consent::Posting
946    } else if choice == crate::merge_decision::WITHDRAW
947        || crate::merge_decision::selected_agent(choice).is_some()
948        || destructive_effect(q, choice)
949        || matches!(q.actions.get(choice), Some(crate::ask::ChoiceAction::Done))
950    {
951        Consent::Consequence
952    } else {
953        Consent::Delegable
954    }
955}
956
957/// Deputies use their authenticated question thread rather than a talk.
958/// A generic batch instruction cannot replace action-specific confirmation.
959pub(crate) fn validate_batch_effect(
960    q: &Question,
961    choice: &str,
962    message: &str,
963    quote: &str,
964) -> Result<()> {
965    if batch_instruction(message) {
966        validate_effect(q, choice, message, quote, false)?;
967        bail!(
968            "a deputy's one-question thread has no persisted question-by-question recommendation manifest; use the active standing conversation's bound owner evidence rather than inferring a choice from generic batch words"
969        );
970    }
971    Ok(())
972}
973
974/// Validate action-specific evidence independently of its delivery route.
975pub(crate) fn validate_effect(
976    q: &Question,
977    choice: &str,
978    instruction: &str,
979    quote: &str,
980    bound: bool,
981) -> Result<()> {
982    let direct = format!("{}: {}", q.id, choice);
983    match consent(q, choice) {
984        Consent::Delegable => {}
985        Consent::Merge => {
986            let target = q.merge_target.as_ref().context("merge requires the specific reviewed PR and head binding; this legacy card has no recorded target")?;
987            let token = format!("merge {}", target.head);
988            if quote.trim() != instruction.trim()
989                || !(instruction.trim() == token
990                    || instruction.trim() == format!("{}: {token}", q.id))
991            {
992                bail!(
993                    "merge requires separate explicit confirmation of this specific reviewed PR and head: `{}: {token}`; a batch recommendation is insufficient",
994                    q.id
995                );
996            }
997        }
998        Consent::Consequence => {
999            if quote.trim() != instruction.trim()
1000                || !(instruction.trim() == direct || (!bound && instruction.trim() == choice))
1001            {
1002                bail!(
1003                    "`{choice}` requires an individual owner confirmation of the exact offered consequence: `{direct}`; batch authorization is insufficient"
1004                );
1005            }
1006        }
1007        Consent::Posting => {
1008            if bound {
1009                let token = format!("{}: {} {}", q.id, choice, question_fingerprint(q));
1010                let content_matches = if choice == crate::github_text::USE_MY_TEXT {
1011                    owner_title(q, instruction).is_some()
1012                } else {
1013                    instruction.trim() == token
1014                };
1015                if !content_matches || quote.trim() != instruction.trim() {
1016                    bail!(
1017                        "posting confirmation requires owner confirmation bound to the exact question and saved content: `{token}`; `use my text` additionally requires a newline and the literal replacement title; batch authorization is insufficient"
1018                    );
1019                }
1020            } else if !crate::github_text::posting_authorized(instruction, quote, choice) {
1021                bail!(
1022                    "posting confirmation requires an explicit, unconditional instruction for the chosen text; batch authorization is insufficient"
1023                );
1024            }
1025        }
1026    }
1027    Ok(())
1028}
1029
1030/// The entire latest persisted owner send, when it is a usable instruction.
1031pub(crate) fn latest_owner_instruction(talk: &Talk) -> Option<&str> {
1032    current_instruction(talk).map(|(_, _, words)| words)
1033}
1034
1035/// Exact latest owner evidence for individually confirmed destructive controls.
1036pub(crate) fn exact_owner_authorization(
1037    talk: &Talk,
1038    expected: &str,
1039    quote: Option<&str>,
1040) -> Result<serde_json::Value> {
1041    let (turn, message, words) = current_instruction(talk)
1042        .context("no current persisted owner instruction; drain newer messages first")?;
1043    anyhow::ensure!(
1044        words == expected && quote == Some(expected),
1045        "requires the latest individual owner message and --quote exactly `{expected}`"
1046    );
1047    Ok(serde_json::json!({"turn":turn,"message":message,"instruction":words}))
1048}
1049
1050fn current_instruction(talk: &Talk) -> Option<(usize, usize, &str)> {
1051    // A newer queued send can retract the instruction. Wait until it is
1052    // drained rather than letting the running seat use older words. Explicitly
1053    // generated sends neither authorize nor retract an owner instruction.
1054    let queued_owner = (!talk.pending.is_empty() || !talk.pending_messages.is_empty())
1055        && (talk.pending_messages.is_empty()
1056            || talk
1057                .pending_messages
1058                .iter()
1059                .any(|m| m.sender != Some(talk::Sender::Magi)));
1060    if !talk.status.open() || queued_owner {
1061        return None;
1062    }
1063    for (index, turn) in talk.turns.iter().enumerate().rev() {
1064        if talk::sender(turn, None) == talk::Sender::Magi {
1065            continue;
1066        }
1067        // A persona reply consumes the conversational turn. Never reuse an
1068        // older instruction just because a generated post followed that reply.
1069        if turn.who != talk::Who::Operator || turn.posted_by.is_some() {
1070            return None;
1071        }
1072        let latest = turn
1073            .messages
1074            .iter()
1075            .enumerate()
1076            .rfind(|(_, send)| talk::sender(turn, Some(send)) == talk::Sender::Operator);
1077        let Some((message, send)) = latest else {
1078            if !turn.messages.is_empty()
1079                && turn
1080                    .messages
1081                    .iter()
1082                    .all(|m| m.sender == Some(talk::Sender::Magi))
1083            {
1084                continue;
1085            }
1086            return None;
1087        };
1088        // Older real sends can be reconstructed from recorded boundaries,
1089        // never from a guessed last paragraph. The whole-message grammar also
1090        // excludes generated legacy wrappers.
1091        if send.sender.is_none()
1092            && (turn.breaks.is_none()
1093                || turn
1094                    .messages
1095                    .iter()
1096                    .map(|m| m.body.as_str())
1097                    .collect::<Vec<_>>()
1098                    .join("\n\n")
1099                    != turn.body)
1100        {
1101            return None;
1102        }
1103        return Some((index, message, &send.body));
1104    }
1105    None
1106}
1107
1108fn choice_authority(
1109    q: &Question,
1110    questions: &Questions,
1111    talk: &Talk,
1112    reply: &str,
1113    quote: Option<&str>,
1114) -> Option<AnswerAuthority> {
1115    if !q.status.open()
1116        || !(q.free_text() || q.choices.iter().any(|c| c == reply))
1117        || (reply == crate::land::HOLD && q.actions.contains_key(reply))
1118    {
1119        return None;
1120    }
1121    let (turn, message, instruction) = current_instruction(talk)?;
1122    if reply != crate::land::HOLD
1123        && (instruction.starts_with("    ") || instruction.starts_with('\t'))
1124    {
1125        return None;
1126    }
1127    if talk.turns[turn].at < q.asked_at {
1128        return None;
1129    }
1130    let direct = direct_choice(instruction, q, questions, reply)
1131        || (reply == crate::github_text::USE_MY_TEXT
1132            && q.node == crate::github_text::ASK_NODE
1133            && owner_title(q, instruction).is_some())
1134        || (consent(q, reply) == Consent::Posting
1135            && instruction.trim() == format!("{}: {} {}", q.id, reply, question_fingerprint(q)))
1136        || (consent(q, reply) == Consent::Merge
1137            && q.merge_target
1138                .as_ref()
1139                .is_some_and(|t| instruction.trim() == format!("{}: merge {}", q.id, t.head)));
1140    let quote_matches = if contextual_recovery(q, reply) {
1141        quote == Some(instruction)
1142    } else {
1143        quote.map(str::trim) == Some(instruction.trim())
1144    };
1145    let persisted_recovery = quote.is_none() && direct && contextual_recovery(q, reply);
1146    if !quote_matches && !persisted_recovery {
1147        return None;
1148    }
1149    let recommendation_turn = if direct {
1150        None
1151    } else {
1152        if message != 0 || q.free_text() || !batch_instruction(instruction) {
1153            return None;
1154        }
1155        let index = turn.checked_sub(1)?;
1156        let recommendation = &talk.turns[index];
1157        if recommendation.who != talk::Who::Agent
1158            || talk::sender(recommendation, None) != talk::Sender::Persona
1159            || recommendation.posted_by.is_some()
1160            || recommendation.at < q.asked_at
1161            || recommended_choice(&recommendation.body, q, questions) != Some(reply)
1162        {
1163            return None;
1164        }
1165        if let Some(manifest) = talk.recommendations.get(&index) {
1166            if manifest.body != crate::github_text::fingerprint("", &recommendation.body)
1167                || manifest.questions.get(&q.id) != Some(&question_fingerprint(q))
1168            {
1169                return None;
1170            }
1171        } else if reply != crate::land::HOLD {
1172            // Old transcripts can prove a safe hold, but cannot establish what
1173            // mutable action/target was shown before manifests were persisted.
1174            return None;
1175        }
1176        Some(index)
1177    };
1178    Some(AnswerAuthority {
1179        talk: talk.id.clone(),
1180        turn,
1181        message,
1182        instruction: instruction.to_owned(),
1183        instruction_fingerprint: crate::github_text::fingerprint("", instruction),
1184        recommendation_turn,
1185        recommendation_fingerprint: recommendation_turn
1186            .map(|i| crate::github_text::fingerprint("", &talk.turns[i].body)),
1187        question: q.id.clone(),
1188        choice: reply.into(),
1189        question_fingerprint: question_fingerprint(q),
1190        recovery_interpretation: None,
1191    })
1192}
1193
1194fn batch_instruction(text: &str) -> bool {
1195    // Whole-message grammar: substrings in conditional, reported, quoted or
1196    // retracted instructions cannot match. Keep this deliberately bounded.
1197    let compact: String = text.chars().filter(|c| !c.is_whitespace()).collect();
1198    let mut compact = compact.trim_end_matches(['!', '!', '。', '.', 'ー', '〜', '~']);
1199    let prefixes = [
1200        "おっけー",
1201        "オッケー〜",
1202        "オッケー",
1203        "おっけ〜",
1204        "おけ",
1205        "オケ",
1206        "ok",
1207        "OK",
1208        "Ok",
1209        "ありがとうございます",
1210        "ありがとう",
1211        "ありがと",
1212        "了解です",
1213        "了解",
1214        "はい",
1215        "じゃあ",
1216        "では",
1217    ];
1218    while let Some(rest) = prefixes
1219        .iter()
1220        .find_map(|prefix| compact.strip_prefix(prefix))
1221    {
1222        compact = rest.trim_start_matches(['!', '!', '、', ',', '。', '.', 'ー', '〜', '~']);
1223    }
1224    matches!(compact, "それぞれ回答して" | "それぞれ回答してください")
1225        || matches!(
1226            text.trim()
1227                .trim_end_matches(['.', '!'])
1228                .to_ascii_lowercase()
1229                .as_str(),
1230            "answer each as recommended" | "answer all as recommended"
1231        )
1232}
1233
1234fn refers_to(reference: &str, q: &Question, questions: &Questions) -> bool {
1235    reference == q.id
1236        || (reference.len() >= 4 && questions.resolve_id(reference).is_ok_and(|id| id == q.id))
1237}
1238
1239fn direct_choice(text: &str, q: &Question, questions: &Questions, reply: &str) -> bool {
1240    // One complete instruction, binding both the question and the offered
1241    // choice. Other actions (including closing the PR) are never inferred.
1242    let text = text.trim();
1243    let binding = text.split_once(':').or_else(|| {
1244        if contextual_recovery(q, reply) {
1245            text.split_once(char::is_whitespace)
1246        } else {
1247            None
1248        }
1249    });
1250    let Some((reference, choice)) = binding else {
1251        return false;
1252    };
1253    let reference = reference.trim().trim_matches('`');
1254    (reference == q.id
1255        || ((reply == crate::land::HOLD || contextual_recovery(q, reply))
1256            && refers_to(reference, q, questions)))
1257        && (choice.trim() == reply
1258            || (contextual_recovery(q, reply)
1259                && q.resolve_reply(choice.trim())
1260                    .is_ok_and(|resolved| resolved == reply)))
1261}
1262
1263fn recommended_choice<'a>(body: &str, q: &'a Question, questions: &Questions) -> Option<&'a str> {
1264    // A row must match the entire offered label, including prefixes such as
1265    // `dismiss:` or `reimplement:`. Prefix/substring matches confer no authority.
1266    // A row must select exactly one offered choice, rather than list options.
1267    // Repeated or conflicting rows are ambiguous even if one recommends hold.
1268    let mut rows = Vec::new();
1269    // Use the same Markdown parser as the transcript renderer. Quoted lazy
1270    // continuations, nested fences and indented examples are not recommendations.
1271    use comrak::nodes::NodeValue;
1272    let arena = comrak::Arena::new();
1273    let mut options = comrak::Options::default();
1274    options.extension.table = true;
1275    let document = comrak::parse_document(&arena, body, &options);
1276    let lines: Vec<_> = body.lines().collect();
1277    let mut eligible = std::collections::BTreeSet::new();
1278    for node in document.descendants() {
1279        let data = node.data.borrow();
1280        if node
1281            .ancestors()
1282            .any(|n| matches!(n.data.borrow().value, NodeValue::BlockQuote))
1283        {
1284            continue;
1285        }
1286        // A heading, introduction or trailing paragraph may qualify several
1287        // rows without naming any question. Do not guess its scope: batch
1288        // authority requires the whole unquoted recommendation to be clear.
1289        if matches!(
1290            data.value,
1291            NodeValue::Heading(_) | NodeValue::Paragraph | NodeValue::TableRow(_)
1292        ) && lines
1293            .get(data.sourcepos.start.line - 1..data.sourcepos.end.line)
1294            .is_some_and(|block| conditional_recommendation(&block.join(" ")))
1295        {
1296            return None;
1297        }
1298        if matches!(
1299            data.value,
1300            NodeValue::Paragraph | NodeValue::TableRow(false)
1301        ) {
1302            eligible.extend(data.sourcepos.start.line..=data.sourcepos.end.line);
1303        }
1304    }
1305    for (index, line) in body.lines().enumerate() {
1306        if !eligible.contains(&(index + 1)) {
1307            continue;
1308        }
1309        let line = line.trim();
1310        let tokens: Vec<&str> = line
1311            .split(|c: char| !c.is_ascii_alphanumeric() && c != '-')
1312            .collect();
1313        if !tokens.iter().any(|r| refers_to(r, q, questions)) {
1314            continue;
1315        }
1316        if line.starts_with('|') {
1317            let cells: Vec<_> = line
1318                .trim_matches('|')
1319                .split('|')
1320                .map(|c| c.trim().trim_matches('`'))
1321                .collect();
1322            let references = cells.iter().filter(|c| refers_to(c, q, questions)).count();
1323            let other_question = cells
1324                .iter()
1325                .any(|c| c.len() >= 4 && questions.resolve_id(c).is_ok_and(|id| id != q.id));
1326            let choices: Vec<_> = cells
1327                .iter()
1328                .filter_map(|c| q.resolve_reply(c).ok())
1329                .collect();
1330            if other_question || references != 1 || choices.len() != 1 {
1331                return None;
1332            }
1333            rows.push(
1334                q.choices
1335                    .iter()
1336                    .find(|choice| choice.as_str() == choices[0])?
1337                    .as_str(),
1338            );
1339            continue;
1340        }
1341        let row = line.trim_start_matches(['-', '*', ' ']);
1342        let row = row.trim_start_matches('`');
1343        let end = row
1344            .find(|c: char| !c.is_ascii_alphanumeric() && c != '-')
1345            .unwrap_or(row.len());
1346        let (reference, rest) = row.split_at(end);
1347        let rest = rest.trim_start_matches('`').trim_start();
1348        let choice = rest
1349            .strip_prefix(':')
1350            .or_else(|| rest.strip_prefix('→'))
1351            .or_else(|| rest.strip_prefix('|'))
1352            .map(str::trim);
1353        if !refers_to(reference, q, questions) {
1354            return None;
1355        }
1356        let choice = choice?;
1357        // Preserve the legacy safe-hold wording without granting PR closure.
1358        let choice = if choice == "hold、その後PRを閉じる" {
1359            crate::land::HOLD
1360        } else {
1361            choice.trim_matches('`')
1362        };
1363        rows.push(
1364            q.choices
1365                .iter()
1366                .find(|offered| {
1367                    q.resolve_reply(choice)
1368                        .is_ok_and(|resolved| offered.as_str() == resolved)
1369                })?
1370                .as_str(),
1371        );
1372    }
1373    (rows.len() == 1).then(|| rows[0])
1374}
1375
1376fn conditional_recommendation(line: &str) -> bool {
1377    let lower = line.to_lowercase();
1378    lower.split(|c: char| !c.is_ascii_alphabetic()).any(|word| {
1379        matches!(
1380            word,
1381            "if" | "unless"
1382                | "when"
1383                | "maybe"
1384                | "conditional"
1385                | "otherwise"
1386                | "only"
1387                | "except"
1388                | "or"
1389                | "either"
1390                | "provided"
1391                | "assuming"
1392                | "depending"
1393        )
1394    }) || [
1395        "ただし",
1396        "場合",
1397        "なら",
1398        "たら",
1399        "限り",
1400        "のみ",
1401        "かも",
1402        "次第",
1403        "条件",
1404        "または",
1405        "もしくは",
1406        "あるいは",
1407        "れば",
1408        "とき",
1409        "際",
1410        "時だけ",
1411        "必要に応じて",
1412    ]
1413    .iter()
1414    .any(|word| line.contains(word))
1415}
1416
1417impl QuestionKind {
1418    /// Select the policy from the stored question node.
1419    pub fn of(q: &Question) -> Self {
1420        if crate::deputy::merge_gated(q) {
1421            return Self::Merge;
1422        }
1423        match q.node.as_str() {
1424            crate::github_text::ASK_NODE => Self::Posting,
1425            _ => Self::Ordinary,
1426        }
1427    }
1428}
1429
1430/// Routed decisions use the chat agent's interpretation of the owner's request,
1431/// while retaining conversation ownership and open-record checks. Legacy
1432/// consultation consent below is unchanged.
1433fn validate_routed_answer(q: &Question, talks: &Talks, run: &str, reply: &str) -> Result<()> {
1434    anyhow::ensure!(
1435        q.routed_chat.as_deref() == Some(run),
1436        "decision belongs to a different chat"
1437    );
1438    anyhow::ensure!(q.status.open(), "decision is no longer open");
1439    anyhow::ensure!(talks.get(run)?.status.open(), "the task chat is closed");
1440    let home = talks.root().parent().context("talk store has no home")?;
1441    let queue = crate::queue::Queue::at(home.join("queue"));
1442    crate::chat_control::owned_task(
1443        &queue,
1444        run,
1445        q.routed_task.as_deref().context("decision has no task")?,
1446    )?;
1447    anyhow::ensure!(
1448        q.free_text() || q.choices.iter().any(|c| c == reply),
1449        "decision requires an offered choice"
1450    );
1451    Ok(())
1452}
1453
1454/// Validate guarded decisions answered by a running chat. Terminal answers
1455/// retain their existing behavior. Merge and posting policies both require
1456/// evidence from the correct conversation; posting also checks the selected text.
1457pub fn validate_answer(
1458    q: &Question,
1459    talks: &Talks,
1460    run: &str,
1461    node: &str,
1462    reply: &str,
1463    quote: Option<&str>,
1464) -> Result<()> {
1465    if node == CHAT_NODE && q.routed_task.is_some() && q.routed_chat.is_some() {
1466        return validate_routed_answer(q, talks, run, reply);
1467    }
1468    let kind = QuestionKind::of(q);
1469    if node != CHAT_NODE || kind == QuestionKind::Ordinary {
1470        return Ok(());
1471    }
1472    let posting = kind == QuestionKind::Posting;
1473    if posting && !q.choices.iter().any(|c| c == reply) {
1474        bail!("posting confirmation requires one of the offered choices");
1475    }
1476    if posting && reply == crate::github_text::USE_MY_TEXT {
1477        bail!(
1478            "use my text requires current question-bound owner evidence with the content fingerprint, a newline and the literal replacement title; an old question-thread title or hold is insufficient"
1479        );
1480    }
1481    if posting
1482        && !matches!(
1483            reply,
1484            crate::github_text::POST_ORIGINAL | crate::github_text::USE_REWRITE
1485        )
1486    {
1487        return Ok(());
1488    }
1489    let subject = if posting {
1490        "posting confirmation"
1491    } else {
1492        "merge approval"
1493    };
1494    let consult = q
1495        .consult
1496        .as_ref()
1497        .with_context(|| format!("{subject} was not handed to a chat"))?;
1498    if consult.talk != run {
1499        bail!("{subject} belongs to a different chat");
1500    }
1501    if !q.status.open() {
1502        bail!("{subject} is no longer open");
1503    }
1504    let talk = talks.get(run)?;
1505    if !talk.status.open() {
1506        bail!("the consulted chat is closed");
1507    }
1508    // Only words the owner wrote after this question was handed over count.
1509    // The generated hand-over text is stored as an operator turn (possibly
1510    // coalesced with owner replies), so it is cut out by its own markers.
1511    let at = talk
1512        .turns
1513        .iter()
1514        .rposition(|t| {
1515            t.who == talk::Who::Operator
1516                && t.body.contains(crate::prompt::CHAT_CONSULT_HEADING)
1517                && t.body.contains(&q.id)
1518        })
1519        .context("the question was not delivered to the chat yet")?;
1520    // A reply still waiting in `pending` is newer than every stored turn.
1521    let queued = latest_owner_message(
1522        &talk.pending,
1523        talk.pending_breaks.as_deref(),
1524        &talk.pending_messages,
1525        None,
1526        posting,
1527    );
1528    let stored = talk.turns[at..]
1529        .iter()
1530        .enumerate()
1531        .rev()
1532        .filter(|(_, t)| t.who == talk::Who::Operator)
1533        .find_map(|(i, t)| {
1534            latest_owner_message(
1535                &t.body,
1536                t.breaks.as_deref(),
1537                &t.messages,
1538                (i == 0).then_some(q.id.as_str()),
1539                posting,
1540            )
1541        });
1542    let latest = queued
1543        .or(stored)
1544        .context("no owner message after the question was handed to the chat")?;
1545    let quote = quote
1546        .map(str::trim)
1547        .filter(|s| !s.is_empty())
1548        .with_context(|| {
1549            format!("chat {subject} requires --quote from the owner's latest message")
1550        })?;
1551    if posting {
1552        if !latest.contains(quote) {
1553            bail!("posting confirmation requires a verbatim quote of the latest owner message");
1554        }
1555        if !crate::github_text::posting_authorized(&latest, quote, reply) {
1556            bail!(
1557                "posting confirmation requires an explicit, unconditional instruction for the chosen text; ask the owner to clarify"
1558            );
1559        }
1560        return Ok(());
1561    }
1562    if reply == crate::land::APPROVE && batch_instruction(&latest) {
1563        bail!(
1564            "a batch answer instruction is insufficient for merge; merging requires a separate explicit \
1565             confirmation of this specific reviewed PR and head through the existing approval driver"
1566        );
1567    }
1568    let valid = if reply == crate::land::APPROVE {
1569        match &q.merge_target {
1570            Some(target) => {
1571                latest.trim() == format!("merge {}", target.head) && quote == latest.trim()
1572            }
1573            None => latest.contains(quote),
1574        }
1575    } else {
1576        q.choices.iter().any(|c| c == reply)
1577            && crate::merge_decision::exact_owner_choice(reply, &latest, quote)
1578    };
1579    if !valid {
1580        if reply == crate::land::APPROVE {
1581            if let Some(target) = &q.merge_target {
1582                bail!(
1583                    "merge requires the latest whole owner message and --quote to be exactly `merge {}` for the displayed PR/head",
1584                    target.head
1585                );
1586            }
1587            bail!("merge requires a verbatim quote of the latest explicit owner merge instruction");
1588        }
1589        bail!(
1590            "`{reply}` requires the latest whole owner message and --quote to equal this exact offered choice, or current question-bound owner evidence under the action-specific consent policy"
1591        );
1592    }
1593    Ok(())
1594}
1595
1596/// The owner's newest message in a turn body (or the pending draft).
1597///
1598/// Replies queued while a turn runs are joined with a blank line, which an
1599/// owner's own blank lines cannot be told apart from, so `talk::queue` records
1600/// where each message starts (`breaks`) and the newest message is taken whole.
1601/// With no usable record (a turn stored before it existed) the only safe cut is
1602/// the last paragraph: that can reject a multi-paragraph approval, and can
1603/// accept a `hold` that was only the last paragraph of a longer message, which
1604/// is harmless because silence is a hold.
1605#[cfg(test)]
1606fn latest_message(
1607    body: &str,
1608    breaks: Option<&[usize]>,
1609    after_block_of: Option<&str>,
1610) -> Option<String> {
1611    latest_message_for_policy(body, breaks, after_block_of, false)
1612}
1613
1614fn latest_owner_message(
1615    body: &str,
1616    breaks: Option<&[usize]>,
1617    messages: &[talk::Message],
1618    after: Option<&str>,
1619    posting: bool,
1620) -> Option<String> {
1621    if messages.is_empty() {
1622        return latest_message_for_policy(body, breaks, after, posting);
1623    }
1624    let first = after.map_or(0, |id| {
1625        messages
1626            .iter()
1627            .rposition(|m| {
1628                m.body.contains(crate::prompt::CHAT_CONSULT_HEADING) && m.body.contains(id)
1629            })
1630            .unwrap_or(0)
1631    });
1632    // A legacy message may span several sends. Only map the recorded cuts
1633    // when the message bodies still match the enclosing body exactly.
1634    let aligned = messages
1635        .iter()
1636        .map(|m| m.body.as_str())
1637        .collect::<Vec<_>>()
1638        .join("\n\n")
1639        == body;
1640    let valid_breaks = breaks.filter(|cuts| {
1641        cuts.windows(2).all(|w| w[0] < w[1])
1642            && cuts.iter().all(|&o| {
1643                o >= 2 && o <= body.len() && body.is_char_boundary(o) && body[..o].ends_with("\n\n")
1644            })
1645    });
1646    let mut offset = 0;
1647    let mut latest = None;
1648    for (i, message) in messages.iter().enumerate() {
1649        let end = offset + message.body.len();
1650        if i >= first && message.sender != Some(talk::Sender::Magi) {
1651            let local_breaks = aligned.then_some(valid_breaks).flatten().map(|cuts| {
1652                cuts.iter()
1653                    .copied()
1654                    .filter(|&cut| cut > offset && cut <= end)
1655                    .map(|cut| cut - offset)
1656                    .collect::<Vec<_>>()
1657            });
1658            let message_breaks = if message.sender.is_some() {
1659                Some(&[][..])
1660            } else {
1661                local_breaks.as_deref()
1662            };
1663            if let Some(words) = latest_message_for_policy(
1664                &message.body,
1665                message_breaks,
1666                after.filter(|_| i == first),
1667                posting,
1668            ) {
1669                latest = Some(words);
1670            }
1671        }
1672        offset = end + 2;
1673    }
1674    latest
1675}
1676
1677fn latest_message_for_policy(
1678    body: &str,
1679    breaks: Option<&[usize]>,
1680    after_block_of: Option<&str>,
1681    posting: bool,
1682) -> Option<String> {
1683    let cuts = breaks.filter(|b| {
1684        b.windows(2).all(|w| w[0] < w[1])
1685            && b.iter().all(|&o| {
1686                o >= 2 && o <= body.len() && body.is_char_boundary(o) && body[..o].ends_with("\n\n")
1687            })
1688    });
1689    let Some(cuts) = cuts else {
1690        if is_completion_notice(body) {
1691            return None;
1692        }
1693        let words = owner_words_for_policy(body, after_block_of, posting);
1694        // Legacy/invalid boundary records cannot distinguish paragraphs from
1695        // separate owner replies. Posting must not promote a reported final
1696        // paragraph into an instruction. Merge keeps its existing policy.
1697        if posting && words.contains("\n\n") {
1698            // Keep this as the newest evidence (with no usable quote), so an
1699            // ambiguous queued reply cannot fall back to an older instruction.
1700            return Some(String::new());
1701        }
1702        if posting {
1703            return (!words.trim().is_empty()).then_some(words);
1704        }
1705        return words
1706            .rsplit("\n\n")
1707            .map(str::trim)
1708            .find(|p| !p.is_empty())
1709            .map(str::to_owned);
1710    };
1711    let mut starts = vec![0];
1712    starts.extend_from_slice(cuts);
1713    let messages: Vec<&str> = starts
1714        .iter()
1715        .enumerate()
1716        .map(|(i, &s)| {
1717            let e = starts.get(i + 1).map_or(body.len(), |&n| n - 2);
1718            &body[s..e]
1719        })
1720        .collect();
1721    // The message carrying the hand-over: everything before it is older.
1722    let first = after_block_of.map_or(0, |id| {
1723        messages
1724            .iter()
1725            .rposition(|m| m.contains(crate::prompt::CHAT_CONSULT_HEADING) && m.contains(id))
1726            .unwrap_or(0)
1727    });
1728    messages
1729        .iter()
1730        .enumerate()
1731        .skip(first)
1732        .rev()
1733        .filter(|(_, m)| !is_completion_notice(m))
1734        .map(|(i, m)| owner_words_for_policy(m, after_block_of.filter(|_| i == first), posting))
1735        .find(|w| !w.trim().is_empty())
1736}
1737
1738/// Is this message magi's own task or watch completion notice? It is never
1739/// the owner's word.
1740fn is_completion_notice(message: &str) -> bool {
1741    crate::prompt::is_completion_notice(message)
1742}
1743
1744/// Length of a consult stored before quoted markers were defused, which may
1745/// quote the end phrase or a whole earlier consult in its detail. The closer is
1746/// the generated tail's full final sentence (an owner reply does not repeat
1747/// it), and a real heading met before it opens a nested block that needs its
1748/// own closer. A block that never closes runs to the end of the text, which can
1749/// only exclude more.
1750fn legacy_len(rest: &str) -> usize {
1751    use crate::prompt::CHAT_CONSULT_HEADING;
1752    const CLOSERS: [&str; 2] = [
1753        "cannot be revived. Do not edit the repository.",
1754        "make: do not edit the repository.",
1755    ];
1756    let mut events: Vec<(usize, usize)> = Vec::new(); // (at, 0 = open, else closer length)
1757    let first = rest.find(CHAT_CONSULT_HEADING).unwrap_or(0);
1758    for (i, _) in rest.match_indices(CHAT_CONSULT_HEADING) {
1759        if i > first
1760            && rest[..i].ends_with("# ")
1761            && rest[..i - 2].ends_with('\n')
1762            && rest[i + CHAT_CONSULT_HEADING.len()..].starts_with("\n\nThe operator passed you")
1763        {
1764            events.push((i, 0));
1765        }
1766    }
1767    for c in CLOSERS {
1768        events.extend(rest.match_indices(c).map(|(i, _)| (i, c.len())));
1769    }
1770    events.sort_unstable();
1771    let mut depth = 1usize;
1772    for (at, len) in events {
1773        if len == 0 {
1774            depth += 1;
1775        } else {
1776            depth -= 1;
1777            if depth == 0 {
1778                return at + len;
1779            }
1780        }
1781    }
1782    rest.len()
1783}
1784
1785/// The owner's own words in an operator turn, with magi's generated hand-over
1786/// text cut out. `drain` stores a queued consult as an operator turn, and
1787/// `talk::queue` joins an owner reply sent meanwhile onto the same draft, so
1788/// the turn's origin cannot be told from the turn as a whole. Each generated
1789/// block runs from its [`CHAT_CONSULT_HEADING`](crate::prompt::CHAT_CONSULT_HEADING)
1790/// to the first [`CHAT_CONSULT_END`](crate::prompt::CHAT_CONSULT_END) after it
1791/// (quoted markers are defused by `prompt::chat_consult`; a consult stored
1792/// before that, recognised by its opening sentence, ends at the last one before
1793/// the next heading); with no end the
1794/// block runs to the next heading. Only the blocks are cut: owner replies
1795/// between them are kept, in order, even when they contain the phrase. With `after_block_of` (the turn
1796/// carrying the hand-over itself) everything up to the end of the last block
1797/// naming that question id predates the hand-over and is dropped.
1798#[cfg(test)]
1799fn owner_words(body: &str, after_block_of: Option<&str>) -> String {
1800    owner_words_for_policy(body, after_block_of, false)
1801}
1802
1803fn owner_words_for_policy(body: &str, after_block_of: Option<&str>, posting: bool) -> String {
1804    // Remove generated blocks without removing Markdown code indentation from
1805    // the owner's evidence. Merge retains its existing whitespace handling.
1806    let trim = |text: &str| -> String {
1807        if posting {
1808            text.trim_matches(['\r', '\n']).to_owned()
1809        } else {
1810            text.trim().to_owned()
1811        }
1812    };
1813    use crate::prompt::{CHAT_CONSULT_END, CHAT_CONSULT_HEADING};
1814    // A real heading is the generated one: `# ` at a line start, followed by
1815    // the fixed opening sentence. A heading phrase quoted inside a question's
1816    // detail is not a boundary; it stays inside the block it is part of.
1817    let heads: Vec<usize> = body
1818        .match_indices(CHAT_CONSULT_HEADING)
1819        .map(|(i, _)| i)
1820        .filter(|&i| {
1821            let line_start = body[..i]
1822                .strip_suffix("# ")
1823                .is_some_and(|b| b.is_empty() || b.ends_with('\n'));
1824            line_start
1825                && body[i + CHAT_CONSULT_HEADING.len()..].starts_with("\n\nThe operator passed you")
1826        })
1827        .collect();
1828    if heads.is_empty() {
1829        return trim(body);
1830    }
1831    // (start, end) of each generated block, the heading's own "# " included.
1832    // `chat_consult` defuses every marker quoted in a question's text, so the
1833    // first end phrase after a heading is the block's own. A block with no end
1834    // before the next heading runs up to that heading (or the text's end).
1835    // Turns stored before the markers were defused may still quote an end
1836    // phrase; their tail is not recognised as part of the block.
1837    let mut blocks: Vec<(usize, usize)> = Vec::new();
1838    for &h in &heads {
1839        let start = if body[..h].ends_with("# ") { h - 2 } else { h };
1840        if blocks.last().is_some_and(|&(_, e)| start < e) {
1841            continue; // quoted inside the block above
1842        }
1843        let rest = &body[h..];
1844        let head = rest.split("\n\n## ").next().unwrap_or(rest);
1845        let end = if head.contains(crate::prompt::CHAT_CONSULT_DEFUSED) {
1846            // Current format: quoted markers are defused, so the first end
1847            // phrase is the block's own.
1848            rest.find(CHAT_CONSULT_END)
1849                .map_or(body.len(), |p| h + p + CHAT_CONSULT_END.len())
1850        } else {
1851            h + legacy_len(rest)
1852        };
1853        blocks.push((start, end));
1854    }
1855    let mut from = 0;
1856    if let Some(id) = after_block_of {
1857        if let Some(&(_, end)) = blocks.iter().rfind(|&&(s, e)| body[s..e].contains(id)) {
1858            from = end;
1859        } else if let Some(&(_, end)) = blocks.last() {
1860            from = end;
1861        }
1862    }
1863    let mut parts = Vec::new();
1864    let mut at = from;
1865    for &(s, e) in &blocks {
1866        if s >= at {
1867            parts.push(trim(&body[at..s]));
1868        }
1869        at = at.max(e);
1870    }
1871    parts.push(trim(&body[at..]));
1872    parts
1873        .into_iter()
1874        .filter(|p| !p.trim().is_empty())
1875        .collect::<Vec<_>>()
1876        .join("\n\n")
1877}
1878
1879/// What [`start_turn`] did.
1880#[derive(Debug, PartialEq, Eq)]
1881pub enum Started {
1882    /// The question was handed over and the chat's turn ran to its end.
1883    Answered,
1884    /// Another process holds the talk's turn lease; nothing was changed.
1885    Busy,
1886    /// The question had already been handed to the chat; nothing was changed.
1887    Nothing,
1888}
1889
1890/// [`begin`], then run the chat's turn here, under the talk's own lease.
1891///
1892/// The lease is taken *before* anything is written, so a talk somebody else is
1893/// running leaves no consult record and no draft behind (`Started::Busy`) and
1894/// the caller can simply try again later. A turn that fails after `begin`
1895/// leaves the record and the draft, so the chat can resume it.
1896pub async fn start_turn(
1897    questions: &Questions,
1898    talks: &Talks,
1899    q: &Question,
1900    talk: &Talk,
1901    cfg: &Config,
1902) -> Result<Started> {
1903    if q.consult.is_some() {
1904        return Ok(Started::Nothing);
1905    }
1906    let Some(lease) = talks.claim_turn(&talk.id)? else {
1907        return Ok(Started::Busy);
1908    };
1909    if !begin(questions, talks, q, talk)? {
1910        return Ok(Started::Nothing);
1911    }
1912    drain_turns(talks, &talk.id, lease, cfg).await?;
1913    Ok(Started::Answered)
1914}
1915
1916/// Run the talk's turns under `lease` until no draft is owed.
1917///
1918/// Other starters that found the lease held queued drafts and left them to
1919/// us, so drain until nothing is left (as the web's drain loop does). A failed
1920/// turn is kept and reported at the end, not returned at once: drafts accepted
1921/// meanwhile are still owed an answer.
1922pub async fn drain_turns(
1923    talks: &Talks,
1924    talk_id: &str,
1925    mut lease: talk::TurnLease,
1926    cfg: &Config,
1927) -> Result<()> {
1928    let mut talk = talks.get(talk_id)?;
1929    let mut failed = None;
1930    loop {
1931        while let Some(text) = talk::drain(&mut talk, talks)? {
1932            if let Err(e) = talk::respond(&lease, &mut talk, talks, cfg, &text).await {
1933                failed.get_or_insert(e);
1934            }
1935            if !lease.beat()? {
1936                bail!("the turn lease for chat {} was lost", talk.short());
1937            }
1938        }
1939        // A draft queued after the last drain, before the lease is gone, was
1940        // left to us by a starter that found the lease held. Release first,
1941        // then look again: whoever queues later either sees no lease (and
1942        // starts its own turn) or is seen by this re-check.
1943        drop(lease);
1944        talk = talks.get(&talk.id)?;
1945        let owed = talk.status.open()
1946            && (!talk.pending.is_empty() || !talk.pending_attachments.is_empty());
1947        if !owed {
1948            break;
1949        }
1950        // Somebody else took the lease meanwhile: they drain it.
1951        let Some(again) = talks.claim_turn(&talk.id)? else {
1952            break;
1953        };
1954        lease = again;
1955    }
1956    if let Some(e) = failed {
1957        return Err(e);
1958    }
1959    Ok(())
1960}
1961
1962/// Try the short cross-process draft-publication fence for one talk.
1963pub fn try_publication_guard(talks: &Talks, talk_id: &str) -> Result<Option<crate::worker::Lease>> {
1964    crate::worker::try_lease(talks.home(), &format!("chat-publication:{talk_id}"))
1965}
1966
1967/// Serialize queue/record -> worker acknowledgement against a drainer's
1968/// generation scan + promotion. Never hold this fence across an agent call.
1969///
1970/// Lock order deliberately differs: a drainer owns TurnLease then waits here;
1971/// ingress owns publication but only *tries* TurnLease and queues when busy.
1972/// Ingress must never wait for TurnLease while holding publication.
1973pub async fn publication_guard(talks: &Talks, talk_id: &str) -> Result<crate::worker::Lease> {
1974    loop {
1975        if let Some(guard) = try_publication_guard(talks, talk_id)? {
1976            return Ok(guard);
1977        }
1978        tokio::time::sleep(std::time::Duration::from_millis(20)).await;
1979    }
1980}
1981
1982/// Reserve and launch a detached drainer for an explicitly submitted draft.
1983///
1984/// Every submission has its own durable reservation. A shared key would lose
1985/// arrivals between the previous drainer's last read and its job settlement.
1986/// The talk's existing turn lease, not the job key, serializes CLI sessions.
1987pub async fn spawn_turn(talks: &Talks, talk_id: &str) -> Result<crate::worker::Job> {
1988    spawn_turn_with_source(talks, talk_id, false, None).await
1989}
1990
1991/// Continue an operator turn already persisted by the HTTP admission path.
1992/// Reserve this job before releasing the caller's turn lease.
1993pub async fn spawn_recorded_turn(
1994    talks: &Talks,
1995    talk_id: &str,
1996    turn: usize,
1997) -> Result<crate::worker::Job> {
1998    spawn_turn_with_source(talks, talk_id, false, Some(turn)).await
1999}
2000
2001pub(crate) async fn spawn_notice_turn(talks: &Talks, talk_id: &str) -> Result<crate::worker::Job> {
2002    spawn_turn_with_source(talks, talk_id, true, None).await
2003}
2004
2005async fn spawn_turn_with_source(
2006    talks: &Talks,
2007    talk_id: &str,
2008    automatic: bool,
2009    recorded_turn: Option<usize>,
2010) -> Result<crate::worker::Job> {
2011    let talk = talks.get(talk_id)?;
2012    let recorded = recorded_turn
2013        .map(|index| {
2014            talk.turns
2015                .get(index)
2016                .context("recorded chat admission disappeared")
2017        })
2018        .transpose()?;
2019    let key = format!(
2020        "chat:{}:{}",
2021        talk.id,
2022        crate::rng::SplitMix64::new(crate::rng::entropy()).uuid_v4()
2023    );
2024    let result = crate::worker::spawn(
2025        talks.home(),
2026        "chat",
2027        &key,
2028        serde_json::json!({
2029            "talk": &talk.id, "automatic": automatic, "recorded_turn": recorded_turn,
2030        }),
2031    )
2032    .await;
2033    match result {
2034        Err(error) if recorded.is_some() && crate::worker::not_started(&error) => {
2035            let index = recorded_turn.expect("recorded snapshot");
2036            match talk::restore_recorded_draft(
2037                talks, &talk.id, index, recorded.expect("recorded snapshot"),
2038            ) {
2039                Ok(true) => Err(error.context(
2040                    "the agent never started; your send is saved as a draft — use Resume draft",
2041                )),
2042                Ok(false) => Err(error.context(
2043                    "the agent never started, but the transcript changed before draft restoration; the recorded send was retained",
2044                )),
2045                Err(restore) => Err(error.context(format!(
2046                    "the agent never started and its recorded send was retained; draft restoration failed: {restore:#}",
2047                ))),
2048            }
2049        }
2050        other => other,
2051    }
2052}
2053
2054/// Includes startup reservations, before the child acquires the turn lease.
2055pub fn turn_worker_active(talks: &Talks, talk_id: &str) -> bool {
2056    crate::worker::active(talks.home()).is_ok_and(|jobs| {
2057        jobs.iter().any(|job| {
2058            job.kind == "chat"
2059                && job.payload.get("talk").and_then(serde_json::Value::as_str) == Some(talk_id)
2060        })
2061    })
2062}
2063
2064/// Task-authored transcript posts do not answer a submitted owner turn.
2065fn recorded_answered(talk: &Talk, index: usize) -> bool {
2066    index
2067        .checked_add(1)
2068        .and_then(|next| talk.turns.get(next..))
2069        .is_some_and(|turns| {
2070            turns
2071                .iter()
2072                .any(|turn| turn.who == talk::Who::Agent && turn.posted_by.is_none())
2073        })
2074}
2075
2076/// Hidden worker entry point. The original talk/session and cross-process
2077/// lease stay authoritative even when an older worker is still responding.
2078pub async fn run_turn_worker(home: &std::path::Path, payload: serde_json::Value) -> Result<()> {
2079    let id = payload
2080        .get("talk")
2081        .and_then(serde_json::Value::as_str)
2082        .context("chat worker has no talk id")?;
2083    let automatic = payload
2084        .get("automatic")
2085        .and_then(serde_json::Value::as_bool)
2086        .unwrap_or(false);
2087    let recorded_turn = payload
2088        .get("recorded_turn")
2089        .and_then(serde_json::Value::as_u64)
2090        .map(usize::try_from)
2091        .transpose()?;
2092    let talks = Talks::at(home.join("talks"));
2093    let current = crate::worker::current_job(home)?;
2094    loop {
2095        let talk = talks.get(id)?;
2096        if !talk.status.open()
2097            || (recorded_turn.is_none()
2098                && talk.pending.is_empty()
2099                && talk.pending_attachments.is_empty())
2100        {
2101            return Ok(());
2102        }
2103        if automatic && !crate::chat_notify::pending_is_only_notices(&talk) {
2104            return Ok(());
2105        }
2106        if let Some(lease) = talks.claim_turn(id)? {
2107            let publication = publication_guard(&talks, id).await?;
2108            // Reload the draft after ownership is acquired: a notice must not
2109            // turn an unsent operator draft into an automatic submission.
2110            if automatic && !crate::chat_notify::pending_is_only_notices(&talks.get(id)?) {
2111                return Ok(());
2112            }
2113            let mut fresh = talks.get(id)?;
2114            let active = crate::worker::active(home)?;
2115            if recorded_turn.is_none() && current.as_ref().is_some_and(|current| {
2116                active.iter().any(|job| {
2117                    job.kind == "chat" && job.id != current.id
2118                        && job.generation != current.generation
2119                        && job.payload.get("talk").and_then(serde_json::Value::as_str) == Some(id)
2120                        && job.payload.get("recorded_turn").and_then(serde_json::Value::as_u64).is_none()
2121                        && (!job.payload.get("automatic").and_then(serde_json::Value::as_bool).unwrap_or(false)
2122                            || crate::chat_notify::pending_is_only_notices(&fresh))
2123                        // Sequence zero belongs to pre-ordering records. In
2124                        // that case relinquish rather than infer from time.
2125                        && job.sequence > 0
2126                        && (current.sequence == 0 || job.sequence > current.sequence)
2127                })
2128            }) {
2129                return Ok(());
2130            }
2131            if recorded_turn.is_none()
2132                && active.iter().any(|job| {
2133                    if job.kind != "chat"
2134                        || job.payload.get("talk").and_then(serde_json::Value::as_str) != Some(id)
2135                    {
2136                        return false;
2137                    }
2138                    let Some(index) = job
2139                        .payload
2140                        .get("recorded_turn")
2141                        .and_then(serde_json::Value::as_u64)
2142                        .and_then(|index| usize::try_from(index).ok())
2143                    else {
2144                        return false;
2145                    };
2146                    fresh
2147                        .turns
2148                        .get(index)
2149                        .is_some_and(|turn| turn.who == talk::Who::Operator)
2150                        && !recorded_answered(&fresh, index)
2151                })
2152            {
2153                // A submitted immediate turn is already in the transcript,
2154                // but its child has not acquired the lease yet. Do not let a
2155                // queued follow-up overtake it during process startup.
2156                drop(publication);
2157                drop(lease);
2158                tokio::time::sleep(std::time::Duration::from_millis(100)).await;
2159                continue;
2160            }
2161            let (cfg, _) = Config::discover(&fresh.repo, None)?;
2162            let text = if let Some(index) = recorded_turn {
2163                let turn = fresh
2164                    .turns
2165                    .get(index)
2166                    .context("recorded chat turn disappeared")?;
2167                anyhow::ensure!(
2168                    turn.who == talk::Who::Operator,
2169                    "recorded turn is not operator input"
2170                );
2171                if recorded_answered(&fresh, index) {
2172                    return Ok(());
2173                }
2174                turn.body.clone()
2175            } else {
2176                let Some(text) = talk::drain(&mut fresh, &talks)? else {
2177                    return Ok(());
2178                };
2179                text
2180            };
2181            drop(publication);
2182            // Exactly one invocation per job. A submission accepted by the
2183            // successor UI already owns a successor-generation reservation;
2184            // never pull it into this old worker after the current reply.
2185            return talk::respond(&lease, &mut fresh, &talks, &cfg, &text).await;
2186        }
2187        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
2188    }
2189}
2190
2191#[derive(serde::Serialize, serde::Deserialize)]
2192struct ServiceRequest {
2193    service: String,
2194    repo: std::path::PathBuf,
2195    cfg: Option<Config>,
2196    #[serde(default)]
2197    explicit_config: Option<std::path::PathBuf>,
2198}
2199
2200fn service_due(home: &std::path::Path, service: &str, now: Timestamp) -> bool {
2201    let interval = match service {
2202        "waiter" => crate::waiter::TICK,
2203        "deputies" => crate::deputy::TICK,
2204        "release-watch" => crate::release_watch::LAP,
2205        _ => return false,
2206    };
2207    std::fs::read_to_string(home.join("service-ticks").join(service))
2208        .ok()
2209        .and_then(|text| text.parse::<i64>().ok())
2210        .is_none_or(|last| now.as_second().saturating_sub(last) >= interval.as_secs() as i64)
2211}
2212
2213fn service_has_work(
2214    home: &std::path::Path,
2215    repo: &std::path::Path,
2216    cfg: Option<&Config>,
2217    service: &str,
2218    now: Timestamp,
2219) -> bool {
2220    match service {
2221        "waiter" => crate::waiter::Waiter::new(
2222            Questions::at(home.join("questions")),
2223            home.to_path_buf(),
2224            cfg.cloned(),
2225        )
2226        .has_work(now),
2227        "deputies" => crate::deputy::Deputies::new(
2228            Questions::at(home.join("questions")),
2229            home.to_path_buf(),
2230            cfg.cloned(),
2231            repo.to_path_buf(),
2232            cfg.map_or(0, |cfg| cfg.daemon.max_deputies),
2233            std::sync::Arc::new(|| false),
2234        )
2235        .has_work(now),
2236        "release-watch" => cfg.is_some_and(|cfg| cfg.daemon.release_stall_minutes > 0),
2237        _ => false,
2238    }
2239}
2240
2241/// Finite service work outlives the scheduler, but never pins a service host
2242/// to an obsolete executable. Its next tick is started by the new scheduler.
2243pub async fn start_services(
2244    home: &std::path::Path,
2245    repo: &std::path::Path,
2246    cfg: Option<&Config>,
2247    explicit_config: Option<&std::path::Path>,
2248) -> Result<()> {
2249    let active = crate::worker::active(home)?;
2250    for service in ["waiter", "deputies", "release-watch"] {
2251        let key = format!("service:{service}");
2252        // Adoption is by operation key, not by the new scheduler's config
2253        // payload/schema. An old invocation owns its started request.
2254        if active.iter().any(|job| job.key == key) {
2255            continue;
2256        }
2257        if !service_due(home, service, Timestamp::now()) {
2258            continue;
2259        }
2260        let service_cfg = if service == "release-watch" {
2261            // The original watcher reloaded settings each lap. Do this cheap
2262            // admission read here so disabled/idle services never fork a CLI.
2263            Config::discover(repo, explicit_config)
2264                .ok()
2265                .map(|(cfg, _)| cfg)
2266        } else {
2267            cfg.cloned()
2268        };
2269        if !service_has_work(home, repo, service_cfg.as_ref(), service, Timestamp::now()) {
2270            let stamps = home.join("service-ticks");
2271            std::fs::create_dir_all(&stamps)?;
2272            std::fs::write(
2273                stamps.join(service),
2274                Timestamp::now().as_second().to_string(),
2275            )?;
2276            continue;
2277        }
2278        let payload = serde_json::to_value(ServiceRequest {
2279            service: service.to_owned(),
2280            repo: repo.to_path_buf(),
2281            cfg: service_cfg,
2282            explicit_config: explicit_config.map(std::path::Path::to_path_buf),
2283        })?;
2284        if let Err(error) = crate::worker::spawn(home, "service", &key, payload).await {
2285            tracing::warn!("service {service} worker could not start: {error:#}");
2286        }
2287    }
2288    Ok(())
2289}
2290
2291/// One leased service tick. Existing question leases, seat records, and release
2292/// receipts remain the only authorities for delivery and once-only effects.
2293pub async fn run_service_worker(home: &std::path::Path, payload: serde_json::Value) -> Result<()> {
2294    let request: ServiceRequest = serde_json::from_value(payload)?;
2295    anyhow::ensure!(
2296        matches!(
2297            request.service.as_str(),
2298            "waiter" | "deputies" | "release-watch"
2299        ),
2300        "unknown conversation service"
2301    );
2302    let stamps = home.join("service-ticks");
2303    std::fs::create_dir_all(&stamps)?;
2304    std::fs::write(
2305        stamps.join(&request.service),
2306        Timestamp::now().as_second().to_string(),
2307    )?;
2308    let questions = Questions::at(home.join("questions"));
2309    match request.service.as_str() {
2310        "waiter" => {
2311            crate::waiter::Waiter::new(questions, home.to_path_buf(), request.cfg)
2312                .tick(Timestamp::now(), &|| false)
2313                .await;
2314        }
2315        "deputies" => {
2316            let max = request.cfg.as_ref().map_or_else(
2317                || Config::default().daemon.max_deputies,
2318                |cfg| cfg.daemon.max_deputies,
2319            );
2320            let deputies = crate::deputy::Deputies::new(
2321                questions,
2322                home.to_path_buf(),
2323                request.cfg,
2324                request.repo,
2325                max,
2326                std::sync::Arc::new(|| false),
2327            );
2328            deputies.dispatch(Timestamp::now()).await?;
2329        }
2330        "release-watch" => {
2331            if let Ok((cfg, _)) =
2332                Config::discover(&request.repo, request.explicit_config.as_deref())
2333            {
2334                let (repos, minutes) =
2335                    crate::daemon::release_watch_settings_from_config(home, &request.repo, &cfg);
2336                if minutes > 0 {
2337                    crate::release_watch::Watcher::new(
2338                        Box::new(crate::release_watch::GhForge),
2339                        home.to_path_buf(),
2340                    )
2341                    .lap(
2342                        &repos,
2343                        (minutes as i64).saturating_mul(60),
2344                        Timestamp::now().as_second(),
2345                        &|| false,
2346                    )
2347                    .await;
2348                }
2349            }
2350        }
2351        _ => unreachable!("validated service"),
2352    }
2353    // Preserve the delay after completion even when the scheduler changes
2354    // executable or restarts while this tick is still running.
2355    std::fs::write(
2356        stamps.join(&request.service),
2357        Timestamp::now().as_second().to_string(),
2358    )?;
2359    Ok(())
2360}
2361
2362#[cfg(test)]
2363mod tests {
2364    use std::collections::BTreeMap;
2365    use std::path::PathBuf;
2366
2367    use super::*;
2368    use crate::config::{AgentKind, AgentSpec, Config};
2369    use crate::queue::Source;
2370    use crate::talk::TalkStatus;
2371
2372    #[test]
2373    fn service_cadence_is_durable_and_keeps_the_watchers_own_interval() {
2374        let home = tempfile::tempdir().expect("home");
2375        let stamps = home.path().join("service-ticks");
2376        std::fs::create_dir_all(&stamps).expect("stamps");
2377        for service in ["waiter", "deputies", "release-watch"] {
2378            std::fs::write(stamps.join(service), "100").expect("completion timestamp");
2379        }
2380        let at = |seconds| Timestamp::from_second(seconds).unwrap();
2381        assert!(!service_due(home.path(), "waiter", at(104)));
2382        assert!(service_due(home.path(), "waiter", at(105)));
2383        assert!(!service_due(home.path(), "deputies", at(104)));
2384        assert!(service_due(home.path(), "deputies", at(105)));
2385        assert!(!service_due(home.path(), "release-watch", at(399)));
2386        assert!(service_due(home.path(), "release-watch", at(400)));
2387    }
2388
2389    #[test]
2390    fn idle_services_do_not_need_detached_processes() {
2391        let home = tempfile::tempdir().expect("home");
2392        for service in ["waiter", "deputies", "release-watch"] {
2393            assert!(!service_has_work(
2394                home.path(),
2395                home.path(),
2396                None,
2397                service,
2398                Timestamp::now(),
2399            ));
2400        }
2401        let mut disabled = Config::default();
2402        disabled.daemon.release_stall_minutes = 0;
2403        assert!(!service_has_work(
2404            home.path(),
2405            home.path(),
2406            Some(&disabled),
2407            "release-watch",
2408            Timestamp::now(),
2409        ));
2410    }
2411
2412    #[test]
2413    fn a_task_post_does_not_answer_an_admitted_owner_send() {
2414        let (_home, talks, mut talk) = talks();
2415        talk::record(&mut talk, &talks, "owner send", Vec::new()).expect("owner turn");
2416        let index = talk.turns.len() - 1;
2417        talk::post(&talks, &talk.id, "unrelated task report", "task-seat").expect("task post");
2418        let mut fresh = talks.get(&talk.id).expect("persisted transcript");
2419        assert!(!recorded_answered(&fresh, index));
2420        let mut response = fresh.turns.last().expect("post").clone();
2421        response.posted_by = None;
2422        response.body = "actual persona response".to_owned();
2423        fresh.turns.push(response);
2424        assert!(recorded_answered(&fresh, index));
2425    }
2426
2427    #[tokio::test]
2428    async fn explicit_release_config_overrides_the_repository_without_launching_disabled_watchers()
2429    {
2430        let home = tempfile::tempdir().expect("home");
2431        let repo = tempfile::tempdir().expect("repo");
2432        let explicit = home.path().join("serve-config.toml");
2433        std::fs::write(
2434            repo.path().join("magi.toml"),
2435            "[daemon]\nrelease_stall_minutes = 60\n",
2436        )
2437        .expect("repository watcher enabled");
2438        std::fs::write(&explicit, "[daemon]\nrelease_stall_minutes = 0\n")
2439            .expect("explicit watcher disabled");
2440        start_services(home.path(), repo.path(), None, Some(&explicit))
2441            .await
2442            .expect("idle service admission");
2443        let jobs = home.path().join("workers").join("jobs");
2444        assert!(
2445            std::fs::read_dir(jobs)
2446                .map(|mut entries| entries.next().is_none())
2447                .unwrap_or(true),
2448            "serve --config must not launch the repository-configured watcher"
2449        );
2450    }
2451
2452    #[tokio::test]
2453    async fn publication_waiting_drainer_does_not_block_busy_queue_admission() {
2454        let (_home, talks, mut talk) = talks();
2455        let ingress = publication_guard(&talks, &talk.id)
2456            .await
2457            .expect("ingress publication");
2458        let turn = talks
2459            .claim_turn(&talk.id)
2460            .expect("turn claim")
2461            .expect("drainer turn");
2462        let id = talk.id.clone();
2463        let waiting = publication_guard(&talks, &id);
2464        tokio::pin!(waiting);
2465        std::future::poll_fn(|cx| {
2466            assert!(
2467                waiting.as_mut().poll(cx).is_pending(),
2468                "worker waits for publication"
2469            );
2470            std::task::Poll::Ready(())
2471        })
2472        .await;
2473        assert!(
2474            talks
2475                .claim_turn(&talk.id)
2476                .expect("nonblocking ingress claim")
2477                .is_none()
2478        );
2479        talk::queue(&mut talk, &talks, "accepted while busy", Vec::new())
2480            .expect("queue without owning turn");
2481        assert_eq!(
2482            talks.get(&talk.id).expect("durable pending").pending,
2483            "accepted while busy"
2484        );
2485        drop(ingress);
2486        let publication = tokio::time::timeout(std::time::Duration::from_secs(5), waiting)
2487            .await
2488            .expect("publication fence released")
2489            .expect("drainer publication");
2490        assert_eq!(
2491            talk::drain(&mut talk, &talks).expect("drain").as_deref(),
2492            Some("accepted while busy")
2493        );
2494        drop(publication);
2495        assert!(
2496            try_publication_guard(&talks, &talk.id)
2497                .expect("released fence")
2498                .is_some()
2499        );
2500        drop(turn);
2501    }
2502
2503    #[tokio::test]
2504    async fn a_pinning_failure_restores_the_recorded_send_for_manual_resume() {
2505        let (_home, talks, mut talk) = talks();
2506        let _publication = publication_guard(&talks, &talk.id)
2507            .await
2508            .expect("publication");
2509        let _turn = talks
2510            .claim_turn(&talk.id)
2511            .expect("turn")
2512            .expect("free turn");
2513        talk::record(&mut talk, &talks, "keep this accepted send", Vec::new()).expect("record");
2514        let workers = talks.home().join("workers");
2515        std::fs::create_dir_all(&workers).expect("worker directory");
2516        std::fs::write(workers.join("generations"), "block executable publication")
2517            .expect("controlled pre-reservation failure");
2518        let error = spawn_recorded_turn(&talks, &talk.id, talk.turns.len() - 1)
2519            .await
2520            .expect_err("pinning must fail");
2521        assert!(crate::worker::not_started(&error));
2522        assert!(error.to_string().contains("saved as a draft"));
2523        let mut recovered = talks.get(&talk.id).expect("recovered draft");
2524        assert!(
2525            recovered.turns.is_empty(),
2526            "no unanswered transcript orphan"
2527        );
2528        assert_eq!(recovered.pending, "keep this accepted send");
2529        assert!(
2530            crate::chat_notify::talks_to_start(&talks).is_empty(),
2531            "recovery does not become an automatic retry"
2532        );
2533        assert_eq!(
2534            talk::drain(&mut recovered, &talks)
2535                .expect("manual resume")
2536                .as_deref(),
2537            Some("keep this accepted send")
2538        );
2539    }
2540
2541    #[test]
2542    fn restoring_an_unstarted_send_retains_reply_attachments_and_later_drafts() {
2543        let (_home, talks, mut talk) = talks();
2544        talk::post(&talks, &talk.id, "original reply source", "source-task").expect("source");
2545        let attachment = talk::Attachment {
2546            id: "image".to_owned(),
2547            name: "photo.png".to_owned(),
2548            mime: "image/png".to_owned(),
2549            bytes: 12,
2550        };
2551        talk::record_reply(
2552            &mut talk,
2553            &talks,
2554            "  preserve formatting\n\nowner send",
2555            vec![attachment.clone()],
2556            Some(talk::ReplyTarget {
2557                turn: 0,
2558                message: 0,
2559            }),
2560        )
2561        .expect("record reply");
2562        let index = talk.turns.len() - 1;
2563        let recorded = talk.turns[index].clone();
2564        talk::post(&talks, &talk.id, "unrelated concurrent post", "later-task").expect("post");
2565        talk::queue(&mut talk, &talks, "later draft", Vec::new()).expect("later draft");
2566        assert!(talk::restore_recorded_draft(&talks, &talk.id, index, &recorded).expect("restore"));
2567        let mut recovered = talks.get(&talk.id).expect("recovered");
2568        assert_eq!(recovered.turns.len(), 2, "task posts are retained");
2569        assert_eq!(recovered.pending_messages[0], recorded.messages[0]);
2570        assert_eq!(recovered.pending_messages[1].body, "later draft");
2571        assert_eq!(recovered.pending_attachments, vec![attachment]);
2572        assert_eq!(
2573            recovered.pending,
2574            "  preserve formatting\n\nowner send\n\nlater draft"
2575        );
2576        assert!(
2577            !talk::restore_recorded_draft(&talks, &talk.id, index, &recorded).expect("idempotent")
2578        );
2579        talk::drain(&mut recovered, &talks)
2580            .expect("manual resume")
2581            .expect("draft");
2582        assert_eq!(
2583            recovered.turns.last().expect("resumed").messages[0],
2584            recorded.messages[0]
2585        );
2586    }
2587
2588    #[test]
2589    fn a_started_response_is_never_rolled_back_into_a_draft() {
2590        let (_home, talks, mut talk) = talks();
2591        talk::record(&mut talk, &talks, "owner send", Vec::new()).expect("record");
2592        let recorded = talk.turns[0].clone();
2593        let mut response = recorded.clone();
2594        response.who = talk::Who::Agent;
2595        response.sender = None;
2596        response.body = "already answered".to_owned();
2597        talk.turns.push(response);
2598        talks.put(&mut talk).expect("response");
2599        assert!(
2600            !talk::restore_recorded_draft(&talks, &talk.id, 0, &recorded).expect("refuse rollback")
2601        );
2602        let fresh = talks.get(&talk.id).expect("unchanged");
2603        assert_eq!(fresh.turns.len(), 2);
2604        assert!(fresh.pending.is_empty());
2605    }
2606
2607    const RUN: &str = "20260902-000000-beef";
2608
2609    fn talks() -> (tempfile::TempDir, Talks, Talk) {
2610        let tmp = tempfile::tempdir().expect("tempdir");
2611        let store = Talks::at(tmp.path().join("talks"));
2612        let cfg = Config {
2613            agents: vec![AgentSpec {
2614                id: "mock".to_owned(),
2615                kind: AgentKind::Command,
2616                model: None,
2617                command: vec!["true".to_owned()],
2618                extra_args: Vec::new(),
2619                env: BTreeMap::new(),
2620                prompt_delivery: None,
2621                max_turn_secs: None,
2622            }],
2623            ..Config::default()
2624        };
2625        let talk = talk::begin(&store, &cfg, tmp.path().to_path_buf(), Some("mock")).expect("talk");
2626        (tmp, store, talk)
2627    }
2628
2629    fn task(source: Source) -> Task {
2630        let mut t = Task::new(
2631            "t".to_owned(),
2632            "Do it".to_owned(),
2633            PathBuf::from("/repo"),
2634            source,
2635        );
2636        t.start(RUN.to_owned());
2637        t
2638    }
2639
2640    fn from_chat(talk: &Talk) -> Task {
2641        task(Source::Agent {
2642            run: talk.id.clone(),
2643            node: CHAT_NODE.to_owned(),
2644        })
2645    }
2646
2647    #[test]
2648    fn investigation_holder_hint_is_advisory_and_requires_a_related_running_task() {
2649        let mut q = question("implement");
2650        let mut related = task(Source::Human);
2651        let check = |q: &Question, tasks: &[Task]| investigation_holder_hint(q, tasks);
2652        let hint = check(&q, std::slice::from_ref(&related));
2653        assert!(hint.contains("Lock owner unknown"));
2654        assert!(hint.contains("may hold"));
2655        assert!(hint.contains(&format!("magi task show {}", related.id)));
2656        assert_eq!(check(&q, &[]), "Lock owner unknown. ");
2657        q.run = "unrelated".into();
2658        assert_eq!(check(&q, std::slice::from_ref(&related)), check(&q, &[]));
2659        q.run = related.id.clone();
2660        assert!(check(&q, std::slice::from_ref(&related)).contains("may hold"));
2661        related.hold_manual(None);
2662        assert_eq!(check(&q, std::slice::from_ref(&related)), check(&q, &[]));
2663        related.status = crate::queue::TaskStatus::Running;
2664        q.run.clear();
2665        assert_eq!(check(&q, &[related]), check(&q, &[]));
2666    }
2667
2668    fn question(node: &str) -> Question {
2669        Question::new(
2670            RUN.to_owned(),
2671            node.to_owned(),
2672            "impl-A".to_owned(),
2673            "Which backend?".to_owned(),
2674            "SQLite is simpler.".to_owned(),
2675            vec!["SQLite".to_owned(), "Redis".to_owned()],
2676        )
2677    }
2678
2679    #[test]
2680    fn batch_instruction_normalizes_repeated_japanese_prefixes() {
2681        for instruction in [
2682            "おっけーそれぞれ回答してー",
2683            "了解です、ありがとう!では、それぞれ回答してください。",
2684            "ありがとうございます!はい、じゃあそれぞれ回答して〜",
2685            "オッケー〜!おけ、それぞれ回答して",
2686            "OK!それぞれ回答して",
2687            "answer all as recommended!",
2688        ] {
2689            assert!(batch_instruction(instruction), "{instruction}");
2690        }
2691        for instruction in [
2692            "了解です",
2693            "ありがとう!それぞれ回答してもいい?",
2694            "もし大丈夫ならそれぞれ回答して",
2695            "それぞれ回答して、いや待って",
2696            "「それぞれ回答して」",
2697        ] {
2698            assert!(!batch_instruction(instruction), "{instruction}");
2699        }
2700    }
2701
2702    #[test]
2703    fn recommendation_returns_only_one_exact_offered_choice() {
2704        let tmp = tempfile::tempdir().unwrap();
2705        let questions = Questions::at(tmp.path().join("questions"));
2706        let mut q = question("implement");
2707        questions.put(&mut q).unwrap();
2708        let table = |rows: &str| format!("| Question | Choice |\n| --- | --- |\n{rows}");
2709        let row = format!("| {} | SQLite |", q.id);
2710        assert_eq!(
2711            recommended_choice(&table(&row), &q, &questions),
2712            Some("SQLite")
2713        );
2714        assert_eq!(
2715            recommended_choice(&format!("{}: Redis", q.id), &q, &questions),
2716            Some("Redis")
2717        );
2718        for body in [
2719            table(&format!("{row}\n| {} | Redis |", q.id)),
2720            table(&format!("{row}\n{row}")),
2721            table(&format!("| {} | SQLite | Redis |", q.id)),
2722            table(&format!("| {} | SQLite maybe |", q.id)),
2723            format!("> {}: SQLite", q.id),
2724            format!("If approved:\n\n{}: SQLite", q.id),
2725            "No recommendation".into(),
2726        ] {
2727            assert_eq!(recommended_choice(&body, &q, &questions), None, "{body}");
2728        }
2729    }
2730
2731    #[test]
2732    fn chat_answer_is_refused_for_an_operator_held_task() {
2733        let mut task = crate::queue::Task::new(
2734            "t".into(),
2735            "i".into(),
2736            std::path::PathBuf::from("."),
2737            crate::queue::Source::Human,
2738        );
2739        task.runs.push("run-1".into());
2740        let mut q = question(crate::land::APPROVAL_NODE);
2741        q.run = "run-1".into();
2742        let check = |t: &crate::queue::Task, node: &str| {
2743            refuse_operator_held(&q, node, std::slice::from_ref(t))
2744        };
2745        assert!(check(&task, CHAT_NODE).is_ok());
2746        task.hold_machine(None);
2747        assert!(check(&task, CHAT_NODE).is_ok());
2748        task.hold_manual(None);
2749        assert!(check(&task, CHAT_NODE).is_err());
2750        // Not a chat's answer, or another question: untouched.
2751        assert!(check(&task, "implement").is_ok());
2752        assert!(refuse_operator_held(&q, CHAT_NODE, &[]).is_ok());
2753    }
2754
2755    #[tokio::test]
2756    async fn start_turn_is_busy_and_writes_nothing_while_the_lease_is_held() {
2757        let (tmp, store, talk) = talks();
2758        let questions = Questions::at(tmp.path().join("questions"));
2759        let mut q = question("implement");
2760        questions.put(&mut q).expect("put");
2761        let held = store.claim_turn(&talk.id).expect("claim").expect("first");
2762
2763        let got = start_turn(&questions, &store, &q, &talk, &Config::default())
2764            .await
2765            .expect("start");
2766        assert_eq!(got, Started::Busy);
2767        assert!(questions.get(&q.id).unwrap().consult.is_none());
2768        assert!(store.get(&talk.id).unwrap().pending.is_empty());
2769        drop(held);
2770    }
2771
2772    /// A `kind = "command"` agent running `script`; `LEASE` names the turn
2773    /// lease file so the script can look at it while the turn is in flight.
2774    fn scripted(tmp: &std::path::Path, script: &str, lease: &std::path::Path) -> Config {
2775        let path = tmp.join("mock-consult-agent.sh");
2776        std::fs::write(&path, script).expect("write mock");
2777        Config {
2778            agents: vec![AgentSpec {
2779                id: "mock".to_owned(),
2780                kind: AgentKind::Command,
2781                model: None,
2782                command: vec!["sh".to_owned(), path.to_string_lossy().into_owned()],
2783                extra_args: Vec::new(),
2784                env: BTreeMap::from([("LEASE".to_owned(), lease.to_string_lossy().into_owned())]),
2785                prompt_delivery: None,
2786                max_turn_secs: None,
2787            }],
2788            ..Config::default()
2789        }
2790    }
2791
2792    /// Run `start_turn` with `script` as the agent and hand back its result,
2793    /// the store and the talk, for the caller to claim the lease afterwards.
2794    async fn run_turn(
2795        script: &str,
2796    ) -> (
2797        tempfile::TempDir,
2798        Result<Started>,
2799        Questions,
2800        Question,
2801        Talk,
2802    ) {
2803        crate::run::set_home(crate::run::test_home());
2804        let (tmp, store, talk) = talks();
2805        let questions = Questions::at(tmp.path().join("questions"));
2806        let mut q = question("implement");
2807        questions.put(&mut q).expect("put");
2808        let cfg = scripted(tmp.path(), script, &store.turn_path(&talk.id));
2809        let got = start_turn(&questions, &store, &q, &talk, &cfg).await;
2810        (tmp, got, questions, q, talk)
2811    }
2812
2813    #[tokio::test]
2814    async fn the_lease_is_held_during_the_turn_and_free_once_it_ends() {
2815        // exit 7 if the lease file is missing while the agent runs.
2816        let (tmp, got, _questions, _q, talk) =
2817            run_turn("#!/bin/sh\ncat >/dev/null\n[ -f \"$LEASE\" ] || exit 7\nprintf 'ok\\n'\n")
2818                .await;
2819        assert_eq!(got.expect("turn"), Started::Answered);
2820        let other = Talks::at(tmp.path().join("talks"));
2821        assert!(
2822            other.claim_turn(&talk.id).expect("claim").is_some(),
2823            "free once the turn ended"
2824        );
2825    }
2826
2827    #[tokio::test]
2828    async fn the_lease_is_free_again_after_a_failed_turn() {
2829        let (tmp, got, questions, q, talk) = run_turn("#!/bin/sh\ncat >/dev/null\nexit 3\n").await;
2830        assert!(got.is_err(), "the agent failed");
2831        assert!(
2832            questions.get(&q.id).unwrap().consult.is_some(),
2833            "the turn got as far as running"
2834        );
2835        let other = Talks::at(tmp.path().join("talks"));
2836        assert!(
2837            other.claim_turn(&talk.id).expect("claim").is_some(),
2838            "free after the failed turn"
2839        );
2840    }
2841
2842    #[test]
2843    fn origin_talk_is_decided_in_one_table() {
2844        let (_tmp, _store, talk) = talks();
2845        let mut closed = talk.clone();
2846        closed.status = TalkStatus::Closed;
2847        let chat = from_chat(&talk);
2848        let q = question("implement");
2849
2850        let hit = |tasks: &[Task], talks: &[Talk], q: &Question| origin_talk(tasks, talks, q);
2851        assert_eq!(
2852            hit(std::slice::from_ref(&chat), std::slice::from_ref(&talk), &q).map(|t| t.id),
2853            Some(talk.id.clone())
2854        );
2855        // Not filed from a chat.
2856        assert!(hit(&[task(Source::Human)], std::slice::from_ref(&talk), &q).is_none());
2857        let other = task(Source::Agent {
2858            run: talk.id.clone(),
2859            node: "implement".to_owned(),
2860        });
2861        assert!(hit(&[other], std::slice::from_ref(&talk), &q).is_none());
2862        // The conversation is gone, or no longer takes turns.
2863        assert!(hit(std::slice::from_ref(&chat), &[], &q).is_none());
2864        // A closed conversation is reopened by `begin`, so it still qualifies.
2865        assert!(hit(std::slice::from_ref(&chat), &[closed], &q).is_some());
2866        // No task owns the question.
2867        assert!(hit(&[], std::slice::from_ref(&talk), &q).is_none());
2868        // Merge approvals can be consulted; release notices cannot.
2869        assert!(
2870            hit(
2871                std::slice::from_ref(&chat),
2872                std::slice::from_ref(&talk),
2873                &question(crate::land::APPROVAL_NODE)
2874            )
2875            .is_some()
2876        );
2877        assert!(
2878            hit(
2879                std::slice::from_ref(&chat),
2880                std::slice::from_ref(&talk),
2881                &question(crate::bump::NOTICE_NODE)
2882            )
2883            .is_none()
2884        );
2885        // A settled question has nothing left to ask.
2886        let mut answered = question("implement");
2887        answered
2888            .answer(crate::ask::Answer::Choice("Redis".to_owned()))
2889            .unwrap();
2890        assert!(hit(&[chat], &[talk], &answered).is_none());
2891    }
2892
2893    #[test]
2894    fn associated_chat_remains_navigable_after_consultation_and_settlement() {
2895        let (tmp, store, mut talk) = talks();
2896        let chat = from_chat(&talk);
2897        let tasks = std::slice::from_ref(&chat);
2898        let mut q = question("implement");
2899        let questions = Questions::at(tmp.path().join("questions"));
2900        questions.put(&mut q).unwrap();
2901        assert!(begin(&questions, &store, &q, &talk).unwrap());
2902        q = questions.get(&q.id).unwrap();
2903        let associated = |q: &Question, talk: &Talk| {
2904            associated_talk(tasks, std::slice::from_ref(talk), q).map(|t| t.id)
2905        };
2906        assert_eq!(associated(&q, &talk), Some(talk.id.clone()));
2907        assert!(q.status.open());
2908        assert!(q.answer.is_none());
2909        assert!(origin_talk(tasks, std::slice::from_ref(&talk), &q).is_some());
2910        q.answer(crate::ask::Answer::Choice("Redis".into()))
2911            .unwrap();
2912        assert_eq!(associated(&q, &talk), Some(talk.id.clone()));
2913        assert!(origin_talk(tasks, std::slice::from_ref(&talk), &q).is_none());
2914        talk.status = TalkStatus::Closed;
2915        assert_eq!(associated(&q, &talk), Some(talk.id.clone()));
2916        let mut q = question("implement");
2917        q.abandon("expired");
2918        assert_eq!(associated(&q, &talk), Some(talk.id.clone()));
2919        assert!(origin_talk(tasks, std::slice::from_ref(&talk), &q).is_none());
2920        assert!(associated_talk(tasks, &[], &q).is_none());
2921        assert!(associated_talk(&[], std::slice::from_ref(&talk), &q).is_none());
2922        let notice = question(crate::bump::NOTICE_NODE);
2923        assert_eq!(associated(&notice, &talk), Some(talk.id.clone()));
2924        assert!(origin_talk(tasks, &[talk], &notice).is_none());
2925    }
2926
2927    #[test]
2928    fn approval_origin_requires_an_open_chat_task_and_question() {
2929        let (_tmp, _store, talk) = talks();
2930        let chat = from_chat(&talk);
2931        let mut q = question(crate::land::APPROVAL_NODE);
2932        assert!(origin_talk(&[task(Source::Human)], std::slice::from_ref(&talk), &q).is_none());
2933        assert!(
2934            origin_talk(
2935                &[task(Source::Agent {
2936                    run: talk.id.clone(),
2937                    node: "implement".into(),
2938                })],
2939                std::slice::from_ref(&talk),
2940                &q
2941            )
2942            .is_none()
2943        );
2944        assert!(origin_talk(std::slice::from_ref(&chat), &[], &q).is_none());
2945        let mut closed = talk.clone();
2946        closed.status = TalkStatus::Closed;
2947        assert!(origin_talk(std::slice::from_ref(&chat), &[closed], &q).is_some());
2948        q.abandon("expired");
2949        assert!(
2950            origin_talk(std::slice::from_ref(&chat), std::slice::from_ref(&talk), &q).is_none()
2951        );
2952        let mut q = question(crate::land::APPROVAL_NODE);
2953        q.answer(crate::ask::Answer::Choice("Redis".into()))
2954            .unwrap();
2955        assert!(origin_talk(&[chat], &[talk], &q).is_none());
2956    }
2957
2958    #[test]
2959    fn a_conductor_question_is_found_through_the_task_id() {
2960        let (_tmp, _store, talk) = talks();
2961        let chat = from_chat(&talk);
2962        let mut q = question(crate::conduct::NODE);
2963        q.run = chat.id.clone();
2964        assert!(origin_talk(&[chat], &[talk], &q).is_some());
2965    }
2966
2967    fn followup_of(parent: &Task, n: u32) -> Task {
2968        let mut t = Task::new(
2969            "f".to_owned(),
2970            "Fix".to_owned(),
2971            PathBuf::from("/repo"),
2972            Source::Agent {
2973                run: format!("merged-{n}"),
2974                node: "followup".to_owned(),
2975            },
2976        );
2977        t.start(format!("run-f{n}"));
2978        t.followup = Some(crate::queue::FollowUp {
2979            run: format!("merged-{n}"),
2980            origin_task: Some(parent.id.clone()),
2981            pr: "https://example.invalid/pr/1".to_owned(),
2982            findings: Vec::new(),
2983            generation: n,
2984        });
2985        t
2986    }
2987
2988    fn q_for(t: &Task) -> Question {
2989        let mut q = question("implement");
2990        q.run = t.runs[0].clone();
2991        q
2992    }
2993
2994    #[test]
2995    fn a_followup_traces_back_to_the_chat() {
2996        let (_tmp, _store, talk) = talks();
2997        let mut chat = from_chat(&talk);
2998        chat.runs = vec!["chat-run".to_owned()];
2999        let f1 = followup_of(&chat, 1);
3000        let f2 = followup_of(&f1, 2);
3001        let ts = [chat, f1.clone(), f2.clone()];
3002        let id = Some(talk.id.clone());
3003        let tk = std::slice::from_ref(&talk);
3004        assert_eq!(origin_talk(&ts, tk, &q_for(&f1)).map(|t| t.id), id);
3005        assert_eq!(origin_talk(&ts, tk, &q_for(&f2)).map(|t| t.id), id);
3006    }
3007
3008    #[test]
3009    fn a_followup_without_origin_task_is_found_through_runs() {
3010        let (_tmp, _store, talk) = talks();
3011        let mut chat = from_chat(&talk);
3012        chat.runs = vec!["merged-1".to_owned()];
3013        let mut f1 = followup_of(&chat, 1);
3014        f1.followup.as_mut().unwrap().origin_task = None;
3015        let q = q_for(&f1);
3016        assert!(origin_talk(&[chat, f1], &[talk], &q).is_some());
3017    }
3018
3019    #[test]
3020    fn a_dangling_origin_task_has_no_chat() {
3021        let (_tmp, _store, talk) = talks();
3022        let mut chat = from_chat(&talk);
3023        chat.runs = vec!["chat-run".to_owned()];
3024        let mut f1 = followup_of(&chat, 1);
3025        f1.followup.as_mut().unwrap().origin_task = Some("gone".to_owned());
3026        let q = q_for(&f1);
3027        assert!(origin_talk(&[chat, f1], &[talk], &q).is_none());
3028    }
3029
3030    #[test]
3031    fn a_recorded_chat_survives_deleted_ancestors() {
3032        let (_tmp, _store, talk) = talks();
3033        let chat = from_chat(&talk);
3034        assert_eq!(chat.origin_chat.as_deref(), Some(talk.id.as_str()));
3035        let mut f1 = followup_of(&chat, 1);
3036        f1.origin_chat = chat.origin_chat.clone();
3037        let mut f2 = followup_of(&f1, 2);
3038        f2.origin_chat = f1.origin_chat.clone();
3039        // Neither the chat task nor f1 is in the queue any more.
3040        let q = q_for(&f2);
3041        let got = origin_talk(std::slice::from_ref(&f2), std::slice::from_ref(&talk), &q);
3042        assert_eq!(got.map(|t| t.id), Some(talk.id.clone()));
3043        // The recorded id still has to name an open chat.
3044        assert!(origin_talk(&[f2], &[], &q).is_none());
3045    }
3046
3047    #[test]
3048    fn a_task_without_the_field_still_walks_the_ancestry() {
3049        let (_tmp, _store, talk) = talks();
3050        let mut chat = from_chat(&talk);
3051        chat.origin_chat = None;
3052        let mut f1 = followup_of(&chat, 1);
3053        f1.origin_chat = None;
3054        let q = q_for(&f1);
3055        assert!(origin_talk(&[chat, f1.clone()], std::slice::from_ref(&talk), &q).is_some());
3056        // And the old JSON shape reads with the field absent.
3057        let mut v = serde_json::to_value(&f1).unwrap();
3058        v.as_object_mut().unwrap().remove("origin_chat");
3059        let back: Task = serde_json::from_value(v).unwrap();
3060        assert!(back.origin_chat.is_none());
3061    }
3062
3063    #[test]
3064    fn a_followup_cycle_ends_without_a_chat() {
3065        let (_tmp, _store, talk) = talks();
3066        let mut a = followup_of(&from_chat(&talk), 1);
3067        let mut b = followup_of(&a, 2);
3068        a.followup.as_mut().unwrap().origin_task = Some(b.id.clone());
3069        b.followup.as_mut().unwrap().origin_task = Some(a.id.clone());
3070        let q = q_for(&a);
3071        assert!(origin_talk(&[a, b], &[talk], &q).is_none());
3072    }
3073
3074    #[test]
3075    fn pending_consults_follow_the_store_not_the_turn_text() {
3076        let (tmp, store, talk) = talks();
3077        let questions = Questions::at(tmp.path().join("questions"));
3078        assert!(!pending_consults(&questions, &talk.id), "empty store");
3079
3080        let mut plain = question("implement");
3081        questions.put(&mut plain).unwrap();
3082        assert!(!pending_consults(&questions, &talk.id), "no consult record");
3083
3084        let mut q = question("implement");
3085        questions.put(&mut q).unwrap();
3086        begin(&questions, &store, &q, &talk).unwrap();
3087        assert!(pending_consults(&questions, &talk.id));
3088        assert!(!pending_consults(&questions, "other-talk"));
3089
3090        questions
3091            .update(&q.id, |q| {
3092                q.abandon("test");
3093                Ok(())
3094            })
3095            .unwrap();
3096        assert!(!pending_consults(&questions, &talk.id), "closed question");
3097    }
3098
3099    #[test]
3100    fn begin_reopens_a_closed_chat_before_queueing() {
3101        let (tmp, store, mut talk) = talks();
3102        let questions = Questions::at(tmp.path().join("questions"));
3103        let mut q = question("implement");
3104        questions.put(&mut q).unwrap();
3105        talk::close(&mut talk, &store).unwrap();
3106        assert!(!store.get(&talk.id).unwrap().status.open());
3107
3108        assert!(begin(&questions, &store, &q, &talk).unwrap());
3109
3110        let after = store.get(&talk.id).unwrap();
3111        assert!(after.status.open(), "the chat was reopened");
3112        assert!(after.pending.contains(&q.id), "{}", after.pending);
3113        assert!(questions.get(&q.id).unwrap().consult.is_some());
3114    }
3115
3116    #[test]
3117    fn begin_queues_once_and_leaves_the_question_open() {
3118        let (tmp, store, talk) = talks();
3119        let questions = Questions::at(tmp.path().join("questions"));
3120        let mut q = question("implement");
3121        questions.put(&mut q).unwrap();
3122
3123        assert!(begin(&questions, &store, &q, &talk).unwrap());
3124        assert!(!begin(&questions, &store, &q, &talk).unwrap(), "idempotent");
3125
3126        let after = questions.get(&q.id).unwrap();
3127        assert!(after.status.open());
3128        assert!(after.thread.is_empty());
3129        assert_eq!(after.choices, q.choices, "choices are not touched");
3130        assert_eq!(
3131            after.consult.as_ref().map(|c| c.talk.as_str()),
3132            Some(talk.id.as_str())
3133        );
3134
3135        let destination = store.get(&talk.id).unwrap();
3136        assert_eq!(destination.pending_messages.len(), 1);
3137        assert_eq!(
3138            destination.pending_messages[0].display,
3139            Some(crate::prompt::chat_consult_display(&q))
3140        );
3141        let queued = destination.pending;
3142        assert_eq!(
3143            queued.matches(&q.id).count(),
3144            2 + 1,
3145            "id once per use: {queued}"
3146        );
3147        assert!(queued.contains("Which backend?"));
3148        assert!(queued.contains("SQLite is simpler."));
3149        assert!(queued.contains("- Redis"));
3150        // Queued once, so the first-operator-turn title is not involved.
3151        assert_eq!(
3152            queued.matches(crate::prompt::CHAT_CONSULT_HEADING).count(),
3153            1
3154        );
3155    }
3156
3157    fn block(id: &str, detail: &str) -> String {
3158        let detail = crate::prompt::defuse(detail);
3159        format!(
3160            "# {}\n\nThe operator passed you a question `{id}`. {}\n\n{detail}\n\n{}",
3161            crate::prompt::CHAT_CONSULT_HEADING,
3162            crate::prompt::CHAT_CONSULT_DEFUSED,
3163            crate::prompt::CHAT_CONSULT_END
3164        )
3165    }
3166
3167    #[test]
3168    fn owner_words_keeps_replies_between_generated_blocks() {
3169        let body = format!("{}\n\nhold\n\n{}", block("q-bbb", "x"), block("q-ccc", "y"));
3170        assert_eq!(owner_words(&body, None), "hold");
3171        let body = format!(
3172            "{}\n\nmerge it now\n\n{}\n\nhold\n\n{}",
3173            block("q-aaa", "x"),
3174            block("q-bbb", "y"),
3175            block("q-ccc", "z")
3176        );
3177        assert_eq!(owner_words(&body, None), "merge it now\n\nhold");
3178        assert_eq!(owner_words(&body, Some("q-aaa")), "merge it now\n\nhold");
3179        assert_eq!(owner_words(&body, Some("q-bbb")), "hold");
3180        assert_eq!(
3181            owner_words(&format!("early\n\n{}", block("q-aaa", "x")), Some("q-aaa")),
3182            ""
3183        );
3184    }
3185
3186    #[test]
3187    fn owner_words_ignores_a_heading_quoted_in_a_detail() {
3188        let detail = format!(
3189            "{}\n\nmerge it now\n\n{}",
3190            crate::prompt::CHAT_CONSULT_END,
3191            crate::prompt::CHAT_CONSULT_HEADING
3192        );
3193        assert_eq!(owner_words(&block("q-bbb", &detail), None), "");
3194        assert_eq!(owner_words(&block("q-bbb", &detail), Some("q-bbb")), "");
3195        let mut q = question(crate::land::APPROVAL_NODE);
3196        q.detail = detail;
3197        assert_eq!(owner_words(&crate::prompt::chat_consult(&q), None), "");
3198    }
3199
3200    #[test]
3201    fn owner_words_keeps_a_quoted_full_consultation_inside_its_block() {
3202        let mut q = question(crate::land::APPROVAL_NODE);
3203        q.detail = "quoted".into();
3204        let inner = crate::prompt::chat_consult(&q);
3205        let mut b = question(crate::land::APPROVAL_NODE);
3206        b.detail = format!("{inner}\n\nmerge it now\n\n{inner}");
3207        let body = crate::prompt::chat_consult(&b);
3208        assert_eq!(owner_words(&body, None), "");
3209        assert_eq!(owner_words(&format!("{body}\n\nhold"), None), "hold");
3210    }
3211
3212    #[test]
3213    fn owner_words_does_not_leak_a_detail_quoting_the_end_phrase() {
3214        let detail = format!("see: {} --reply merge", crate::prompt::CHAT_CONSULT_END);
3215        let body = format!("{}\n\nhold", block("q-aaa", &detail));
3216        assert_eq!(owner_words(&body, Some("q-aaa")), "hold");
3217        assert_eq!(owner_words(&body, None), "hold");
3218    }
3219
3220    #[test]
3221    fn owner_words_excludes_a_consult_quoted_with_an_end_phrase() {
3222        let mut c = question(crate::land::APPROVAL_NODE);
3223        c.detail = "inner".into();
3224        let mut b = question("implement");
3225        b.detail = format!(
3226            "{}\n\nmerge it now\n\n{}",
3227            crate::prompt::CHAT_CONSULT_END,
3228            crate::prompt::chat_consult(&c)
3229        );
3230        let body = crate::prompt::chat_consult(&b);
3231        assert_eq!(owner_words(&body, None), "");
3232    }
3233
3234    #[test]
3235    fn owner_words_keeps_a_reply_containing_the_end_phrase() {
3236        let mut b = question("implement");
3237        b.detail = "x".into();
3238        let body = format!(
3239            "{}\n\nhold, and do not {}",
3240            crate::prompt::chat_consult(&b),
3241            crate::prompt::CHAT_CONSULT_END
3242        );
3243        assert_eq!(
3244            owner_words(&body, None),
3245            format!("hold, and do not {}", crate::prompt::CHAT_CONSULT_END)
3246        );
3247    }
3248
3249    /// A consult as stored before quoted markers were defused.
3250    fn legacy_consult(node: &str, raw_detail: &str) -> String {
3251        let mut q = question(node);
3252        q.detail = "@@".into();
3253        crate::prompt::chat_consult(&q)
3254            .replace("@@", raw_detail)
3255            .replace(crate::prompt::CHAT_CONSULT_DEFUSED, "It is still open.")
3256            // Keep the historical closer; current delivery has a different policy.
3257            .replace("additional evidenced task operations follow the protocol above. Do not edit the repository.", "make: do not edit the repository.")
3258    }
3259
3260    #[test]
3261    fn owner_words_excludes_a_legacy_consult_quoting_the_end_phrase() {
3262        for node in [crate::land::APPROVAL_NODE, "implement"] {
3263            let detail = format!("see {} and more", crate::prompt::CHAT_CONSULT_END);
3264            assert_eq!(owner_words(&legacy_consult(node, &detail), None), "");
3265        }
3266    }
3267
3268    #[test]
3269    fn owner_words_excludes_a_legacy_consult_nesting_a_legacy_consult() {
3270        let inner = legacy_consult("implement", "x");
3271        let detail = format!(
3272            "{}\n\nmerge it now\n\n{inner}",
3273            crate::prompt::CHAT_CONSULT_END
3274        );
3275        let body = legacy_consult(crate::land::APPROVAL_NODE, &detail);
3276        assert_eq!(owner_words(&body, None), "");
3277        assert_eq!(owner_words(&format!("{body}\n\nhold"), None), "hold");
3278    }
3279
3280    #[test]
3281    fn owner_words_keeps_a_reply_after_a_legacy_consult_containing_the_end_phrase() {
3282        let body = format!(
3283            "{}\n\nhold, and do not {}",
3284            legacy_consult("implement", "x"),
3285            crate::prompt::CHAT_CONSULT_END
3286        );
3287        assert_eq!(
3288            owner_words(&body, None),
3289            format!("hold, and do not {}", crate::prompt::CHAT_CONSULT_END)
3290        );
3291    }
3292
3293    #[test]
3294    fn sender_generated_posts_cannot_authorize_an_owner_action() {
3295        let messages = vec![
3296            talk::Message {
3297                display: None,
3298                sender: Some(talk::Sender::Operator),
3299                body: "hold".into(),
3300                attachments: vec![],
3301                reply: None,
3302            },
3303            talk::Message {
3304                display: None,
3305                sender: Some(talk::Sender::Magi),
3306                body: "Merge this pull request now".into(),
3307                attachments: vec![],
3308                reply: None,
3309            },
3310        ];
3311        assert_eq!(
3312            latest_owner_message("", None, &messages, None, true).as_deref(),
3313            Some("hold")
3314        );
3315        assert_eq!(
3316            latest_owner_message("", None, &messages[1..], None, true),
3317            None
3318        );
3319    }
3320
3321    #[test]
3322    fn legacy_message_metadata_preserves_latest_owner_send() {
3323        let body = "merge it now\n\nhold";
3324        let cuts = ["merge it now\n\n".len()];
3325        let legacy = talk::Message {
3326            display: None,
3327            sender: None,
3328            body: body.into(),
3329            attachments: vec![],
3330            reply: None,
3331        };
3332        for posting in [false, true] {
3333            assert_eq!(
3334                latest_owner_message(
3335                    body,
3336                    Some(&cuts),
3337                    std::slice::from_ref(&legacy),
3338                    None,
3339                    posting
3340                )
3341                .as_deref(),
3342                Some("hold")
3343            );
3344            let mixed_body = format!("{body}\n\nautomatic report");
3345            let messages = [
3346                legacy.clone(),
3347                talk::Message {
3348                    display: None,
3349                    sender: Some(talk::Sender::Magi),
3350                    body: "automatic report".into(),
3351                    attachments: vec![],
3352                    reply: None,
3353                },
3354            ];
3355            assert_eq!(
3356                latest_owner_message(&mixed_body, Some(&cuts), &messages, None, posting).as_deref(),
3357                Some("hold")
3358            );
3359        }
3360        // Missing boundaries keep the conservative legacy posting policy.
3361        assert_eq!(
3362            latest_owner_message(body, None, &[legacy], None, true).as_deref(),
3363            Some("")
3364        );
3365    }
3366
3367    #[test]
3368    fn approval_rejects_legacy_coalesced_retraction_with_message_metadata() {
3369        let (tmp, store, mut talk) = talks();
3370        let questions = Questions::at(tmp.path().join("questions"));
3371        let mut q = question(crate::land::APPROVAL_NODE);
3372        q.choices = vec![crate::land::APPROVE.into(), crate::land::HOLD.into()];
3373        questions.put(&mut q).unwrap();
3374        assert!(begin(&questions, &store, &q, &talk).unwrap());
3375        q = questions.get(&q.id).unwrap();
3376        talk = store.get(&talk.id).unwrap();
3377        talk::drain(&mut talk, &store).unwrap();
3378        talk.pending = "merge it now\n\nhold".into();
3379        talk.pending_breaks = Some(vec!["merge it now\n\n".len()]);
3380        talk.pending_messages = vec![talk::Message {
3381            display: None,
3382            sender: None,
3383            body: talk.pending.clone(),
3384            attachments: vec![],
3385            reply: None,
3386        }];
3387        store.put(&mut talk).unwrap();
3388        for drained in [false, true] {
3389            if drained {
3390                talk::drain(&mut talk, &store).unwrap();
3391            }
3392            assert!(
3393                validate_answer(
3394                    &q,
3395                    &store,
3396                    &talk.id,
3397                    CHAT_NODE,
3398                    "merge",
3399                    Some("merge it now")
3400                )
3401                .is_err()
3402            );
3403            assert!(validate_answer(&q, &store, &talk.id, CHAT_NODE, "hold", Some("hold")).is_ok());
3404        }
3405    }
3406
3407    #[test]
3408    fn latest_message_keeps_message_boundaries() {
3409        let one = "Merge this pull request now\n\nThe checks look good";
3410        assert_eq!(latest_message(one, Some(&[]), None).as_deref(), Some(one));
3411        let hold = "Explain what this option means:\n\nhold";
3412        assert_eq!(latest_message(hold, Some(&[]), None).as_deref(), Some(hold));
3413        // Two queued replies: the second is the newest, whole.
3414        let two = "merge it now\n\nhold";
3415        let at = "merge it now\n\n".len();
3416        assert_eq!(
3417            latest_message(two, Some(&[at]), None).as_deref(),
3418            Some("hold")
3419        );
3420        // No record (stored before breaks existed): last paragraph.
3421        assert_eq!(
3422            latest_message(one, None, None).as_deref(),
3423            Some("The checks look good")
3424        );
3425        // A record that does not fit the body is ignored.
3426        assert_eq!(
3427            latest_message(two, Some(&[999]), None).as_deref(),
3428            Some("hold")
3429        );
3430    }
3431
3432    #[test]
3433    fn watched_jobs_never_supply_the_owners_latest_message() {
3434        let notice =
3435            crate::prompt::chat_watch_notice("watch-id", "exited with status 0", "merge it now");
3436        assert_eq!(latest_message(&notice, Some(&[]), None), None);
3437        assert_eq!(latest_message(&notice, None, None), None);
3438        let owner = "hold";
3439        let combined = format!("{owner}\n\n{notice}");
3440        assert_eq!(
3441            latest_message(&combined, Some(&[owner.len() + 2]), None).as_deref(),
3442            Some(owner)
3443        );
3444        let combined = format!("{notice}\n\n{owner}");
3445        assert_eq!(
3446            latest_message(&combined, Some(&[notice.len() + 2]), None).as_deref(),
3447            Some(owner)
3448        );
3449    }
3450
3451    #[test]
3452    fn chat_consult_has_each_marker_once() {
3453        use crate::prompt::{CHAT_CONSULT_END as E, CHAT_CONSULT_HEADING as H};
3454        for node in ["implement", crate::land::APPROVAL_NODE] {
3455            let mut q = question(node);
3456            let quoted = format!("{H} {E}");
3457            q.summary = quoted.clone();
3458            q.detail = quoted.clone();
3459            q.choices = vec![quoted.clone(), "b".into()];
3460            let s = crate::prompt::chat_consult(&q);
3461            assert_eq!(s.matches(H).count(), 1, "{s}");
3462            assert_eq!(s.matches(E).count(), 1, "{s}");
3463        }
3464    }
3465
3466    #[test]
3467    fn a_chat_posts_the_original_text_only_on_an_unhedged_quote() {
3468        use crate::github_text::{ASK_NODE, POST_ORIGINAL, USE_FALLBACK};
3469        let (tmp, store, mut talk) = talks();
3470        let questions = Questions::at(tmp.path().join("questions"));
3471        let mut q = question(ASK_NODE);
3472        q.choices = vec![USE_FALLBACK.into(), POST_ORIGINAL.into()];
3473        questions.put(&mut q).unwrap();
3474        assert!(begin(&questions, &store, &q, &talk).unwrap());
3475        q = questions.get(&q.id).unwrap();
3476        let queued = store.get(&talk.id).unwrap().pending;
3477        let owner_turn = |body: &str| talk::Turn {
3478            sender: None,
3479            breaks: Some(Vec::new()),
3480            messages: Vec::new(),
3481            who: talk::Who::Operator,
3482            body: body.into(),
3483            at: Timestamp::now(),
3484            attachments: Vec::new(),
3485            usage: None,
3486            posted_by: None,
3487        };
3488        talk.turns.push(owner_turn(&queued));
3489        talk.pending.clear();
3490        let check = |q: &Question, run: &str, reply: &str, quote: Option<&str>| {
3491            validate_answer(q, &store, run, CHAT_NODE, reply, quote)
3492        };
3493        store.put(&mut talk).unwrap();
3494        // No owner word after the hand-over: nothing to quote.
3495        assert!(check(&q, &talk.id, POST_ORIGINAL, Some("post it")).is_err());
3496        talk.turns.push(owner_turn("Post the original, maybe?"));
3497        store.put(&mut talk).unwrap();
3498        assert!(check(&q, &talk.id, POST_ORIGINAL, Some("Post the original")).is_err());
3499        talk.turns
3500            .push(owner_turn("Post the original text as it is."));
3501        store.put(&mut talk).unwrap();
3502        assert!(check(&q, &talk.id, POST_ORIGINAL, None).is_err());
3503        assert!(check(&q, &talk.id, POST_ORIGINAL, Some("never said")).is_err());
3504        assert!(check(&q, &talk.id, POST_ORIGINAL, Some("Post the original text")).is_ok());
3505        // The safe default stays unguarded, as before.
3506        assert!(check(&q, &talk.id, USE_FALLBACK, None).is_ok());
3507    }
3508
3509    #[test]
3510    fn a_chat_posts_the_rewrite_only_on_an_unhedged_quote() {
3511        use crate::github_text::{ASK_NODE, USE_FALLBACK, USE_REWRITE};
3512        let (tmp, store, mut talk) = talks();
3513        let questions = Questions::at(tmp.path().join("questions"));
3514        let mut q = question(ASK_NODE);
3515        q.choices = vec![USE_FALLBACK.into(), USE_REWRITE.into()];
3516        questions.put(&mut q).unwrap();
3517        assert!(begin(&questions, &store, &q, &talk).unwrap());
3518        q = questions.get(&q.id).unwrap();
3519        let queued = store.get(&talk.id).unwrap().pending;
3520        let owner_turn = |body: &str| talk::Turn {
3521            sender: None,
3522            breaks: Some(Vec::new()),
3523            messages: Vec::new(),
3524            who: talk::Who::Operator,
3525            body: body.into(),
3526            at: Timestamp::now(),
3527            attachments: Vec::new(),
3528            usage: None,
3529            posted_by: None,
3530        };
3531        talk.turns.push(owner_turn(&queued));
3532        talk.pending.clear();
3533        let check = |q: &Question, run: &str, reply: &str, quote: Option<&str>| {
3534            validate_answer(q, &store, run, CHAT_NODE, reply, quote)
3535        };
3536        store.put(&mut talk).unwrap();
3537        // No owner word after the hand-over: nothing to quote.
3538        assert!(check(&q, &talk.id, USE_REWRITE, Some("post it")).is_err());
3539        talk.turns.push(owner_turn("Use the rewrite, maybe?"));
3540        store.put(&mut talk).unwrap();
3541        assert!(check(&q, &talk.id, USE_REWRITE, Some("Use the rewrite")).is_err());
3542        talk.turns
3543            .push(owner_turn("Use the rewrite text as it is."));
3544        store.put(&mut talk).unwrap();
3545        assert!(check(&q, &talk.id, USE_REWRITE, None).is_err());
3546        assert!(check(&q, &talk.id, USE_REWRITE, Some("never said")).is_err());
3547        assert!(check(&q, &talk.id, USE_REWRITE, Some("Use the rewrite text")).is_ok());
3548        // The safe default stays unguarded, as before.
3549        assert!(check(&q, &talk.id, USE_FALLBACK, None).is_ok());
3550    }
3551
3552    #[test]
3553    fn merge_repair_consult_requires_offered_choice_and_exact_latest_owner_request() {
3554        use crate::land::{APPROVAL_NODE, APPROVE, HOLD, REPAIR};
3555        let (tmp, store, mut talk) = talks();
3556        let questions = Questions::at(tmp.path().join("questions"));
3557        let mut q = question(APPROVAL_NODE);
3558        q.choices = vec![APPROVE.into(), HOLD.into(), REPAIR.into()];
3559        questions.put(&mut q).unwrap();
3560        begin(&questions, &store, &q, &talk).unwrap();
3561        q = questions.get(&q.id).unwrap();
3562        assert!(validate_answer(&q, &store, &talk.id, CHAT_NODE, REPAIR, Some(REPAIR)).is_err());
3563        talk = store.get(&talk.id).unwrap();
3564        talk.turns.push(talk::Turn {
3565            sender: None,
3566            breaks: Some(Vec::new()),
3567            messages: Vec::new(),
3568            posted_by: None,
3569            who: talk::Who::Operator,
3570            body: talk.pending.clone(),
3571            at: Timestamp::now(),
3572            attachments: Vec::new(),
3573            usage: None,
3574        });
3575        talk.pending_messages.clear(); // This fixture now replaces the generated draft.
3576        talk.pending = "maybe fix findings and re-review".into();
3577        store.put(&mut talk).unwrap();
3578        assert!(validate_answer(&q, &store, &talk.id, CHAT_NODE, REPAIR, Some(REPAIR)).is_err());
3579        talk.pending = REPAIR.into();
3580        talk.pending_breaks = Some(Vec::new());
3581        store.put(&mut talk).unwrap();
3582        assert!(validate_answer(&q, &store, &talk.id, CHAT_NODE, REPAIR, Some(REPAIR)).is_ok());
3583        q.choices.retain(|c| c != REPAIR);
3584        assert!(validate_answer(&q, &store, &talk.id, CHAT_NODE, REPAIR, Some(REPAIR)).is_err());
3585    }
3586
3587    #[test]
3588    fn posting_consult_saves_only_authorized_offered_choices() {
3589        use crate::github_text::{ASK_NODE, POST_ORIGINAL, USE_FALLBACK, USE_REWRITE};
3590        for choice in [POST_ORIGINAL, USE_REWRITE] {
3591            let (tmp, store, mut talk) = talks();
3592            let questions = Questions::at(tmp.path().join("questions"));
3593            let mut q = question(ASK_NODE);
3594            q.choices = vec![USE_FALLBACK.into(), choice.into()];
3595            questions.put(&mut q).unwrap();
3596            begin(&questions, &store, &q, &talk).unwrap();
3597            q = questions.get(&q.id).unwrap();
3598            let queued = store.get(&talk.id).unwrap().pending;
3599            assert!(queued.contains("public posting confirmation"));
3600            assert!(queued.contains("not a merge approval"));
3601            assert!(
3602                queued.contains("`use fallback` choice can be saved once the owner has chosen it")
3603            );
3604            assert!(queued.contains("--quote <verbatim owner words>"));
3605            assert!(!queued.contains("answer it yourself with"));
3606            talk.turns.push(talk::Turn {
3607                sender: None,
3608                breaks: Some(Vec::new()),
3609                messages: Vec::new(),
3610                who: talk::Who::Operator,
3611                body: queued,
3612                at: Timestamp::now(),
3613                attachments: Vec::new(),
3614                usage: None,
3615                posted_by: None,
3616            });
3617            talk.pending.clear();
3618            store.put(&mut talk).unwrap();
3619            let save = |reply: &str, quote: Option<&str>, run: &str| {
3620                questions.update(&q.id, |r| {
3621                    validate_answer(r, &store, run, CHAT_NODE, reply, quote)?;
3622                    r.answer(crate::ask::Answer::Choice(reply.into()))
3623                })
3624            };
3625            // No owner reply, nor text from the generated question, is evidence.
3626            assert!(save(choice, Some(choice), &talk.id).is_err());
3627            let clear = if choice == POST_ORIGINAL {
3628                "元の説明のまま投稿して"
3629            } else {
3630                "Use the rewrite text as it is."
3631            };
3632            talk.pending = clear.into();
3633            store.put(&mut talk).unwrap();
3634            for quote in [None, Some("forged quote")] {
3635                let error = save(choice, quote, &talk.id).unwrap_err().to_string();
3636                assert!(error.contains("posting confirmation"), "{error}");
3637                assert!(
3638                    !error.contains("merge") && !error.contains("hold"),
3639                    "{error}"
3640                );
3641            }
3642            assert!(save(choice, Some(clear), "another-chat").is_err());
3643            let other = if choice == POST_ORIGINAL {
3644                USE_REWRITE
3645            } else {
3646                POST_ORIGINAL
3647            };
3648            assert!(save(other, Some(clear), &talk.id).is_err());
3649            let mut unconsulted = q.clone();
3650            unconsulted.consult = None;
3651            assert!(
3652                validate_answer(
3653                    &unconsulted,
3654                    &store,
3655                    &talk.id,
3656                    CHAT_NODE,
3657                    choice,
3658                    Some(clear)
3659                )
3660                .is_err()
3661            );
3662            let mut expired = q.clone();
3663            expired.abandon("expired");
3664            assert!(
3665                validate_answer(&expired, &store, &talk.id, CHAT_NODE, choice, Some(clear))
3666                    .is_err()
3667            );
3668            talk.status = TalkStatus::Closed;
3669            store.put(&mut talk).unwrap();
3670            assert!(save(choice, Some(clear), &talk.id).is_err());
3671            talk.status = TalkStatus::Open;
3672            for rejected in [
3673                "Post original was the option I saw.",
3674                "Use rewrite was the option I saw.",
3675                "Post the original, maybe?",
3676                "Use the rewrite only if checks pass.",
3677                "Post original. Cancel that.",
3678                "Do not post original.",
3679                "Use the rewrite when checks pass.",
3680                "元の説明で投稿してください。ただし検査が通ったら。",
3681                "元の説明で投稿しないで。",
3682                "まだ元の説明で投稿してください。",
3683                "ログには「元の説明で投稿しました」と記録されています。",
3684                "ログには「修正版で投稿しました」と記録されています。",
3685            ] {
3686                talk.pending = rejected.into();
3687                store.put(&mut talk).unwrap();
3688                let error = save(choice, Some(rejected), &talk.id)
3689                    .unwrap_err()
3690                    .to_string();
3691                assert!(
3692                    !error.contains("merge") && !error.contains("hold"),
3693                    "{error}"
3694                );
3695                assert!(save(choice, Some(clear), &talk.id).is_err(), "stale quote");
3696                let after = questions.get(&q.id).unwrap();
3697                assert!(after.status.open());
3698                assert!(after.answer.is_none());
3699            }
3700            let command = if choice == POST_ORIGINAL {
3701                "Post original"
3702            } else {
3703                "Use rewrite"
3704            };
3705            // A newer report must also block fallback to an older instruction.
3706            let mut earlier = talk.turns[0].clone();
3707            earlier.body = clear.into();
3708            talk.turns.push(earlier);
3709            for reported in [
3710                format!("The log contains:\n```\n{command}\n```"),
3711                format!("The log contains:\n\"\n{command}\n\""),
3712                format!("The log says: “Reported. {command}.”"),
3713                format!("The log says: “\n{command}\n”"),
3714            ]
3715            .into_iter()
3716            .chain(crate::github_text::reported_posting_messages(command))
3717            {
3718                talk.pending.clear();
3719                talk.pending_messages.clear();
3720                store.put(&mut talk).unwrap();
3721                talk::queue(&mut talk, &store, &reported, Vec::new()).unwrap();
3722                assert_eq!(talk.pending, reported);
3723                // Check both current message boundaries and legacy records.
3724                for breaks in [Some(Vec::new()), None] {
3725                    talk.pending_breaks = breaks;
3726                    store.put(&mut talk).unwrap();
3727                    for quote in [command, reported.as_str()] {
3728                        assert!(save(choice, Some(quote), &talk.id).is_err());
3729                        let after = questions.get(&q.id).unwrap();
3730                        assert!(after.status.open());
3731                        assert!(after.answer.is_none());
3732                    }
3733                }
3734                // Drained transcript evidence must retain the same context.
3735                talk::drain(&mut talk, &store).unwrap();
3736                for quote in [command, reported.as_str()] {
3737                    assert!(save(choice, Some(quote), &talk.id).is_err());
3738                    let after = questions.get(&q.id).unwrap();
3739                    assert!(after.status.open());
3740                    assert!(after.answer.is_none());
3741                }
3742            }
3743            let natural = if choice == POST_ORIGINAL {
3744                [
3745                    "OK, post the original",
3746                    "Yes, post original",
3747                    "I'd like to post the original",
3748                    "元の説明で投稿してください。",
3749                ]
3750            } else {
3751                [
3752                    "OK, use the rewrite",
3753                    "Yes, use rewrite",
3754                    "I'd like to use the rewrite",
3755                    "修正版で投稿してください。",
3756                ]
3757            };
3758            for words in natural {
3759                talk.pending = words.into();
3760                store.put(&mut talk).unwrap();
3761                validate_answer(&q, &store, &talk.id, CHAT_NODE, choice, Some(words)).unwrap();
3762            }
3763            talk.pending = clear.into();
3764            store.put(&mut talk).unwrap();
3765            save(choice, Some(clear), &talk.id).unwrap();
3766            assert_eq!(
3767                questions.get(&q.id).unwrap().resolution().as_deref(),
3768                Some(choice)
3769            );
3770        }
3771    }
3772
3773    #[test]
3774    fn approval_consult_waits_for_latest_owner_confirmation() {
3775        let (tmp, store, mut talk) = talks();
3776        let questions = Questions::at(tmp.path().join("questions"));
3777        let mut q = question(crate::land::APPROVAL_NODE);
3778        q.choices = vec![crate::land::APPROVE.into(), crate::land::HOLD.into()];
3779        questions.put(&mut q).unwrap();
3780        assert!(begin(&questions, &store, &q, &talk).unwrap());
3781        assert!(!begin(&questions, &store, &q, &talk).unwrap());
3782        q = questions.get(&q.id).unwrap();
3783        assert!(q.status.open());
3784        assert!(q.answer.is_none());
3785        assert!(q.thread.is_empty());
3786        let queued = store.get(&talk.id).unwrap().pending;
3787        assert!(queued.contains("Never answer it yourself"));
3788        assert!(queued.contains("Silence holds"));
3789        assert!(queued.contains("--reply merge --quote"));
3790        assert!(!queued.contains("answer it yourself with"));
3791
3792        let owner_turn = |body: &str| talk::Turn {
3793            sender: None,
3794            breaks: Some(Vec::new()),
3795            messages: Vec::new(),
3796            posted_by: None,
3797            who: talk::Who::Operator,
3798            body: body.into(),
3799            at: Timestamp::now(),
3800            attachments: Vec::new(),
3801            usage: None,
3802        };
3803        // An approval the owner gave before the hand-over is not reusable.
3804        let mut old = talk.clone();
3805        old.turns
3806            .insert(0, owner_turn("Please merge PR 12 after review"));
3807        store.put(&mut old).unwrap();
3808        talk.turns = old.turns.clone();
3809        // The drained consult draft is stored as an operator turn; it must not
3810        // count as the owner's words even though it contains "merge".
3811        talk.turns.push(owner_turn(&queued));
3812        store.put(&mut talk).unwrap();
3813        assert!(validate_answer(&q, &store, &talk.id, CHAT_NODE, "merge", Some("merge")).is_err());
3814        assert!(
3815            validate_answer(
3816                &q,
3817                &store,
3818                &talk.id,
3819                CHAT_NODE,
3820                "merge",
3821                Some("Please merge PR 12")
3822            )
3823            .is_err()
3824        );
3825        // An owner reply coalesced onto the same draft still counts.
3826        let n = talk.turns.len();
3827        talk.turns[n - 1].body = format!("{queued}\n\nmerge it now");
3828        talk.turns[n - 1].breaks = Some(vec![queued.len() + 2]);
3829        store.put(&mut talk).unwrap();
3830        assert!(
3831            validate_answer(
3832                &q,
3833                &store,
3834                &talk.id,
3835                CHAT_NODE,
3836                "merge",
3837                Some("merge it now")
3838            )
3839            .is_ok()
3840        );
3841        // A later retraction in the same coalesced turn wins.
3842        talk.turns[n - 1].body = format!("{queued}\n\nmerge it now\n\nhold");
3843        talk.turns[n - 1].breaks = Some(vec![queued.len() + 2, queued.len() + 2 + 14]);
3844        store.put(&mut talk).unwrap();
3845        assert!(
3846            validate_answer(
3847                &q,
3848                &store,
3849                &talk.id,
3850                CHAT_NODE,
3851                "merge",
3852                Some("merge it now")
3853            )
3854            .is_err()
3855        );
3856        assert!(validate_answer(&q, &store, &talk.id, CHAT_NODE, "hold", Some("hold")).is_ok());
3857        // So does a reply still waiting in the draft.
3858        talk.turns[n - 1].body = format!("{queued}\n\nmerge it now");
3859        talk.turns[n - 1].breaks = Some(vec![queued.len() + 2]);
3860        talk.pending = "hold".into();
3861        store.put(&mut talk).unwrap();
3862        assert!(
3863            validate_answer(
3864                &q,
3865                &store,
3866                &talk.id,
3867                CHAT_NODE,
3868                "merge",
3869                Some("merge it now")
3870            )
3871            .is_err()
3872        );
3873        // Another question's hand-over queued after the retraction hides nothing.
3874        talk.pending = format!("hold\n\n{queued}");
3875        store.put(&mut talk).unwrap();
3876        assert!(
3877            validate_answer(
3878                &q,
3879                &store,
3880                &talk.id,
3881                CHAT_NODE,
3882                "merge",
3883                Some("merge it now")
3884            )
3885            .is_err()
3886        );
3887        talk.pending.clear();
3888        talk.turns[n - 1].body = queued.clone();
3889        store.put(&mut talk).unwrap();
3890        talk.turns
3891            .push(owner_turn("Merge this pull request please"));
3892        store.put(&mut talk).unwrap();
3893        let check = |q: &Question, run: &str, reply: &str, quote: Option<&str>| {
3894            validate_answer(q, &store, run, CHAT_NODE, reply, quote)
3895        };
3896        assert!(check(&q, "other-talk", "merge", Some("Merge this")).is_err());
3897        assert!(check(&q, &talk.id, "merge", None).is_err());
3898        assert!(check(&q, &talk.id, "merge", Some("never said")).is_err());
3899        assert!(
3900            check(
3901                &q,
3902                &talk.id,
3903                "merge",
3904                Some("Merge this pull request please")
3905            )
3906            .is_ok()
3907        );
3908        assert!(check(&q, &talk.id, "hold", Some("hold")).is_err());
3909        talk.turns
3910            .push(owner_turn("Wait, explain the checks first"));
3911        store.put(&mut talk).unwrap();
3912        assert!(
3913            check(
3914                &q,
3915                &talk.id,
3916                "merge",
3917                Some("Merge this pull request please")
3918            )
3919            .is_err()
3920        );
3921        talk.turns.push(owner_turn("Please hold"));
3922        store.put(&mut talk).unwrap();
3923        assert!(check(&q, &talk.id, "hold", Some("hold")).is_err());
3924        talk.turns.push(owner_turn("hold"));
3925        store.put(&mut talk).unwrap();
3926        assert!(check(&q, &talk.id, "hold", Some("hold")).is_ok());
3927        talk.turns
3928            .push(owner_turn("Merge this pull request please"));
3929        store.put(&mut talk).unwrap();
3930        q.abandon("approval expired or head changed");
3931        assert!(
3932            check(
3933                &q,
3934                &talk.id,
3935                "merge",
3936                Some("Merge this pull request please")
3937            )
3938            .is_err()
3939        );
3940        assert!(
3941            q.answer(crate::ask::Answer::Choice("merge".into()))
3942                .is_err()
3943        );
3944    }
3945
3946    #[test]
3947    fn begin_withdraws_its_record_when_the_talk_cannot_take_it() {
3948        let (tmp, store, talk) = talks();
3949        let questions = Questions::at(tmp.path().join("questions"));
3950        let mut q = question("implement");
3951        questions.put(&mut q).unwrap();
3952        std::fs::remove_dir_all(tmp.path().join("talks")).unwrap();
3953        assert!(begin(&questions, &store, &q, &talk).is_err());
3954        assert!(questions.get(&q.id).unwrap().consult.is_none());
3955    }
3956
3957    #[test]
3958    fn an_older_question_file_reads_without_a_consult() {
3959        let mut q = question("implement");
3960        q.schema = 5;
3961        let mut v = serde_json::to_value(&q).unwrap();
3962        v.as_object_mut().unwrap().remove("consult");
3963        let back: Question = serde_json::from_value(v).unwrap();
3964        assert!(back.consult.is_none());
3965    }
3966}