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