Skip to main content

magi/
consult.rs

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