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