1use anyhow::{Context, Result, bail};
23use jiff::Timestamp;
24
25use crate::ask::{ChatConsult, Question, Questions};
26use crate::config::Config;
27use crate::queue::Task;
28use crate::talk::{self, Talk, Talks};
29
30pub fn origin_talk(tasks: &[Task], talks: &[Talk], q: &Question) -> Option<Talk> {
35 if !q.status.open() || q.node == crate::bump::NOTICE_NODE {
36 return None;
37 }
38 let task = crate::daemon::task_of_question(tasks, q)?;
39 let chat = chat_talk_of(tasks, task)?;
40 talks.iter().find(|talk| talk.id == chat).cloned()
41}
42
43pub fn associated_talk(tasks: &[Task], talks: &[Talk], q: &Question) -> Option<Talk> {
46 associated_talk_with_watches(tasks, talks, q, &[])
47}
48
49#[derive(Clone, Copy)]
51pub(crate) enum QuestionTaskRef<'a> {
52 Task(&'a str),
53 Run(&'a str),
54}
55
56pub(crate) fn question_task_refs<'a>(
57 q: &'a Question,
58 watches: &'a [crate::release_watch::WatchState],
59) -> Vec<QuestionTaskRef<'a>> {
60 let names_task = q.run_names_task()
63 || (q.node == crate::reconcile::NODE && q.seat == crate::reconcile::SEAT);
64 let mut refs = vec![if names_task {
65 QuestionTaskRef::Task(&q.run)
66 } else {
67 QuestionTaskRef::Run(&q.run)
68 }];
69 let mut matches = watches
70 .iter()
71 .filter(|w| w.question.as_deref() == Some(&q.id));
72 if let Some(watch) = matches.next()
74 && matches.next().is_none()
75 {
76 refs.extend(watch.run.as_deref().map(QuestionTaskRef::Run));
77 refs.extend(watch.held_task.as_deref().map(QuestionTaskRef::Task));
78 }
79 refs
80}
81
82pub(crate) fn associated_talk_with_watches(
85 tasks: &[Task],
86 talks: &[Talk],
87 q: &Question,
88 watches: &[crate::release_watch::WatchState],
89) -> Option<Talk> {
90 let mut owner: Option<&Task> = None;
91 for reference in question_task_refs(q, watches) {
92 for task in tasks.iter().filter(|task| match reference {
93 QuestionTaskRef::Task(id) => task.id == id,
94 QuestionTaskRef::Run(id) => task.runs.iter().any(|run| run == id),
95 }) {
96 if owner.is_some_and(|previous| previous.id != task.id) {
97 return None;
98 }
99 owner = Some(task);
100 }
101 }
102 let chat = chat_talk_of(tasks, owner?)?;
103 talks.iter().find(|talk| talk.id == chat).cloned()
104}
105
106pub fn chat_talk_of(tasks: &[Task], start: &Task) -> Option<String> {
118 let mut seen = std::collections::HashSet::new();
119 let mut cur = start;
120 for _ in 0..=crate::followup::MAX_FOLLOWUP_GENERATION {
121 if !seen.insert(cur.id.as_str()) {
122 return None;
123 }
124 if let Some(id) = cur.chat_talk() {
125 return Some(id.to_owned());
126 }
127 let f = cur.followup.as_ref()?;
128 cur = match &f.origin_task {
129 Some(id) => tasks.iter().find(|t| &t.id == id)?,
130 None => tasks.iter().find(|t| t.runs.contains(&f.run))?,
131 };
132 }
133 None
134}
135
136pub fn pending_consults(questions: &Questions, talk_id: &str) -> bool {
143 questions.list().iter().any(|q| active_consult(q, talk_id))
144}
145
146pub fn active_consult(q: &Question, talk_id: &str) -> bool {
148 q.routed_task.is_none()
149 && q.status.open()
150 && q.consult_outcome.is_none()
151 && q.consult.as_ref().is_some_and(|c| c.talk == talk_id)
152}
153
154#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
156pub struct ConsultOutcome {
157 pub text: String,
159 pub talk: String,
161 pub at: Timestamp,
163}
164
165pub fn record_other(q: &mut Question, text: &str) -> Result<()> {
168 if text.trim().is_empty() || q.free_text() || q.choices.iter().any(|c| c == text) {
169 bail!("--reply-other requires a nonempty outcome outside the offered choices");
170 }
171 let talk = match &q.consult {
172 Some(consult) if active_consult(q, &consult.talk) => consult.talk.clone(),
173 _ => bail!("question {} has no active consultation to end", q.short()),
174 };
175 q.schema = crate::ask::SCHEMA;
176 q.consult_outcome = Some(ConsultOutcome {
177 text: text.to_owned(),
178 talk,
179 at: Timestamp::now(),
180 });
181 Ok(())
182}
183
184pub fn reply_other(store: &Questions, id: &str, text: &str) -> Result<String> {
188 let (q, ()) = store.update(id, |r| record_other(r, text))?;
189 Ok(format!(
190 "consultation ended for {}; decision recorded as a note. Question remains open and its waiter remains held; no choice action executed.",
191 q.short()
192 ))
193}
194
195pub fn begin(questions: &Questions, talks: &Talks, q: &Question, talk: &Talk) -> Result<bool> {
201 let (q, fresh) = questions.update(&q.id, |r| {
202 anyhow::ensure!(
203 r.routed_task.is_none(),
204 "task decision is delivered by its own chat turn"
205 );
206 if !r.status.open() {
207 bail!("question {} is already {}", r.short(), r.status.as_str());
208 }
209 if r.consult.is_some() {
210 return Ok(false);
211 }
212 r.consult = Some(ChatConsult {
213 talk: talk.id.clone(),
214 at: Timestamp::now(),
215 });
216 Ok(true)
217 })?;
218 if !fresh {
219 return Ok(false);
220 }
221 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(20);
224 let dispatch = loop {
225 let mut busy = false;
226 let updated = questions.update(&q.id, |r| {
227 if !r.status.open() || r.consult != q.consult {
228 return Ok(false);
229 }
230 if talk::try_queue_consult(
231 talks,
232 &talk.id,
233 &crate::prompt::chat_consult(r),
234 Some(crate::prompt::chat_consult_display(r)),
235 )?
236 .is_none()
237 {
238 busy = true;
239 bail!("the conversation store is locked by another process");
240 }
241 Ok(true)
242 });
243 if busy && std::time::Instant::now() < deadline {
244 std::thread::sleep(std::time::Duration::from_millis(10));
245 continue;
246 }
247 break updated;
248 };
249 if let Err(e) = dispatch {
250 let _ = questions.update(&q.id, |r| {
251 if r.status.open() && r.consult == q.consult {
252 r.consult = None;
253 }
254 Ok(())
255 });
256 return Err(e).context("queue the question into the chat");
257 }
258 Ok(dispatch?.1)
259}
260
261#[derive(Debug, PartialEq, Eq)]
263pub enum Started {
264 Answered,
266 Busy,
268 Nothing,
270}
271
272pub async fn start_turn(
279 questions: &Questions,
280 talks: &Talks,
281 q: &Question,
282 talk: &Talk,
283 cfg: &Config,
284) -> Result<Started> {
285 if q.consult.is_some() {
286 return Ok(Started::Nothing);
287 }
288 let Some(lease) = talks.claim_turn(&talk.id)? else {
289 return Ok(Started::Busy);
290 };
291 if !begin(questions, talks, q, talk)? {
292 return Ok(Started::Nothing);
293 }
294 drain_turns(talks, &talk.id, lease, cfg).await?;
295 Ok(Started::Answered)
296}
297
298pub async fn drain_turns(
305 talks: &Talks,
306 talk_id: &str,
307 mut lease: talk::TurnLease,
308 cfg: &Config,
309) -> Result<()> {
310 let mut talk = talks.get(talk_id)?;
311 let mut failed = None;
312 loop {
313 while let Some(text) = talk::drain(&mut talk, talks)? {
314 if let Err(e) = talk::respond(&lease, &mut talk, talks, cfg, &text).await {
315 failed.get_or_insert(e);
316 }
317 if !lease.beat()? {
318 bail!("the turn lease for chat {} was lost", talk.short());
319 }
320 }
321 drop(lease);
326 talk = talks.get(&talk.id)?;
327 let owed = talk.status.open()
328 && (!talk.pending.is_empty() || !talk.pending_attachments.is_empty());
329 if !owed {
330 break;
331 }
332 let Some(again) = talks.claim_turn(&talk.id)? else {
334 break;
335 };
336 lease = again;
337 }
338 if let Some(e) = failed {
339 return Err(e);
340 }
341 Ok(())
342}
343
344pub fn try_publication_guard(talks: &Talks, talk_id: &str) -> Result<Option<crate::worker::Lease>> {
346 crate::worker::try_lease(talks.home(), &format!("chat-publication:{talk_id}"))
347}
348
349pub async fn publication_guard(talks: &Talks, talk_id: &str) -> Result<crate::worker::Lease> {
356 loop {
357 if let Some(guard) = try_publication_guard(talks, talk_id)? {
358 return Ok(guard);
359 }
360 tokio::time::sleep(std::time::Duration::from_millis(20)).await;
361 }
362}
363
364pub async fn spawn_turn(talks: &Talks, talk_id: &str) -> Result<crate::worker::Job> {
370 spawn_turn_with_source(talks, talk_id, false, None).await
371}
372
373pub async fn spawn_recorded_turn(
376 talks: &Talks,
377 talk_id: &str,
378 turn: usize,
379) -> Result<crate::worker::Job> {
380 spawn_turn_with_source(talks, talk_id, false, Some(turn)).await
381}
382
383pub(crate) async fn spawn_notice_turn(talks: &Talks, talk_id: &str) -> Result<crate::worker::Job> {
384 spawn_turn_with_source(talks, talk_id, true, None).await
385}
386
387async fn spawn_turn_with_source(
388 talks: &Talks,
389 talk_id: &str,
390 automatic: bool,
391 recorded_turn: Option<usize>,
392) -> Result<crate::worker::Job> {
393 let talk = talks.get(talk_id)?;
394 let recorded = recorded_turn
395 .map(|index| {
396 talk.turns
397 .get(index)
398 .context("recorded chat admission disappeared")
399 })
400 .transpose()?;
401 let key = format!(
402 "chat:{}:{}",
403 talk.id,
404 crate::rng::SplitMix64::new(crate::rng::entropy()).uuid_v4()
405 );
406 let result = crate::worker::spawn(
407 talks.home(),
408 "chat",
409 &key,
410 serde_json::json!({
411 "talk": &talk.id, "automatic": automatic, "recorded_turn": recorded_turn,
412 }),
413 )
414 .await;
415 match result {
416 Err(error) if recorded.is_some() && crate::worker::not_started(&error) => {
417 let index = recorded_turn.expect("recorded snapshot");
418 match talk::restore_recorded_draft(
419 talks, &talk.id, index, recorded.expect("recorded snapshot"),
420 ) {
421 Ok(true) => Err(error.context(
422 "the agent never started; your send is saved as a draft — use Resume draft",
423 )),
424 Ok(false) => Err(error.context(
425 "the agent never started, but the transcript changed before draft restoration; the recorded send was retained",
426 )),
427 Err(restore) => Err(error.context(format!(
428 "the agent never started and its recorded send was retained; draft restoration failed: {restore:#}",
429 ))),
430 }
431 }
432 other => other,
433 }
434}
435
436pub fn turn_worker_active(talks: &Talks, talk_id: &str) -> bool {
438 crate::worker::active(talks.home()).is_ok_and(|jobs| {
439 jobs.iter().any(|job| {
440 job.kind == "chat"
441 && job.payload.get("talk").and_then(serde_json::Value::as_str) == Some(talk_id)
442 })
443 })
444}
445
446pub fn turn_worker_observed(talks: &Talks, talk_id: &str) -> bool {
449 crate::worker::observe_active(talks.home()).is_ok_and(|jobs| {
450 jobs.iter().any(|job| {
451 job.kind == "chat"
452 && job.payload.get("talk").and_then(serde_json::Value::as_str) == Some(talk_id)
453 })
454 })
455}
456
457fn recorded_answered(talk: &Talk, index: usize) -> bool {
459 index
460 .checked_add(1)
461 .and_then(|next| talk.turns.get(next..))
462 .is_some_and(|turns| {
463 turns
464 .iter()
465 .any(|turn| turn.who == talk::Who::Agent && turn.posted_by.is_none())
466 })
467}
468
469pub async fn run_turn_worker(home: &std::path::Path, payload: serde_json::Value) -> Result<()> {
472 let id = payload
473 .get("talk")
474 .and_then(serde_json::Value::as_str)
475 .context("chat worker has no talk id")?;
476 let automatic = payload
477 .get("automatic")
478 .and_then(serde_json::Value::as_bool)
479 .unwrap_or(false);
480 let recorded_turn = payload
481 .get("recorded_turn")
482 .and_then(serde_json::Value::as_u64)
483 .map(usize::try_from)
484 .transpose()?;
485 let talks = Talks::at(home.join("talks"));
486 let current = crate::worker::current_job(home)?;
487 loop {
488 let talk = talks.get(id)?;
489 if !talk.status.open()
490 || (recorded_turn.is_none()
491 && talk.pending.is_empty()
492 && talk.pending_attachments.is_empty())
493 {
494 return Ok(());
495 }
496 if automatic && !crate::chat_notify::pending_is_only_notices(&talk) {
497 return Ok(());
498 }
499 if let Some(lease) = talks.claim_turn(id)? {
500 let publication = publication_guard(&talks, id).await?;
501 if automatic && !crate::chat_notify::pending_is_only_notices(&talks.get(id)?) {
504 return Ok(());
505 }
506 let mut fresh = talks.get(id)?;
507 let active = crate::worker::active(home)?;
508 if recorded_turn.is_none() && current.as_ref().is_some_and(|current| {
509 active.iter().any(|job| {
510 job.kind == "chat" && job.id != current.id
511 && job.generation != current.generation
512 && job.payload.get("talk").and_then(serde_json::Value::as_str) == Some(id)
513 && job.payload.get("recorded_turn").and_then(serde_json::Value::as_u64).is_none()
514 && (!job.payload.get("automatic").and_then(serde_json::Value::as_bool).unwrap_or(false)
515 || crate::chat_notify::pending_is_only_notices(&fresh))
516 && job.sequence > 0
519 && (current.sequence == 0 || job.sequence > current.sequence)
520 })
521 }) {
522 return Ok(());
523 }
524 if recorded_turn.is_none()
525 && active.iter().any(|job| {
526 if job.kind != "chat"
527 || job.payload.get("talk").and_then(serde_json::Value::as_str) != Some(id)
528 {
529 return false;
530 }
531 let Some(index) = job
532 .payload
533 .get("recorded_turn")
534 .and_then(serde_json::Value::as_u64)
535 .and_then(|index| usize::try_from(index).ok())
536 else {
537 return false;
538 };
539 fresh
540 .turns
541 .get(index)
542 .is_some_and(|turn| turn.who == talk::Who::Operator)
543 && !recorded_answered(&fresh, index)
544 })
545 {
546 drop(publication);
550 drop(lease);
551 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
552 continue;
553 }
554 let (cfg, _) = Config::discover(&fresh.repo, None)?;
555 let text = if let Some(index) = recorded_turn {
556 let turn = fresh
557 .turns
558 .get(index)
559 .context("recorded chat turn disappeared")?;
560 anyhow::ensure!(
561 turn.who == talk::Who::Operator,
562 "recorded turn is not operator input"
563 );
564 if recorded_answered(&fresh, index) {
565 return Ok(());
566 }
567 turn.body.clone()
568 } else {
569 let Some(text) = talk::drain(&mut fresh, &talks)? else {
570 return Ok(());
571 };
572 text
573 };
574 drop(publication);
575 return talk::respond(&lease, &mut fresh, &talks, &cfg, &text).await;
579 }
580 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
581 }
582}
583
584#[derive(serde::Serialize, serde::Deserialize)]
585struct ServiceRequest {
586 service: String,
587 repo: std::path::PathBuf,
588 cfg: Option<Config>,
589 #[serde(default)]
590 explicit_config: Option<std::path::PathBuf>,
591}
592
593fn service_due(home: &std::path::Path, service: &str, now: Timestamp) -> bool {
594 let interval = match service {
595 "waiter" => crate::waiter::TICK,
596 "deputies" => crate::deputy::TICK,
597 "release-watch" => crate::release_watch::LAP,
598 _ => return false,
599 };
600 std::fs::read_to_string(home.join("service-ticks").join(service))
601 .ok()
602 .and_then(|text| text.parse::<i64>().ok())
603 .is_none_or(|last| now.as_second().saturating_sub(last) >= interval.as_secs() as i64)
604}
605
606fn service_has_work(
607 home: &std::path::Path,
608 repo: &std::path::Path,
609 cfg: Option<&Config>,
610 service: &str,
611 now: Timestamp,
612) -> bool {
613 match service {
614 "waiter" => crate::waiter::Waiter::new(
615 Questions::at(home.join("questions")),
616 home.to_path_buf(),
617 cfg.cloned(),
618 )
619 .has_work(now),
620 "deputies" => crate::deputy::Deputies::new(
621 Questions::at(home.join("questions")),
622 home.to_path_buf(),
623 cfg.cloned(),
624 repo.to_path_buf(),
625 cfg.map_or(0, |cfg| cfg.daemon.max_deputies),
626 std::sync::Arc::new(|| false),
627 )
628 .has_work(now),
629 "release-watch" => cfg.is_some_and(|cfg| cfg.daemon.release_stall_minutes > 0),
630 _ => false,
631 }
632}
633
634pub async fn start_services(
637 home: &std::path::Path,
638 repo: &std::path::Path,
639 cfg: Option<&Config>,
640 explicit_config: Option<&std::path::Path>,
641) -> Result<()> {
642 let active = crate::worker::active(home)?;
643 for service in ["waiter", "deputies", "release-watch"] {
644 let key = format!("service:{service}");
645 if active.iter().any(|job| job.key == key) {
648 continue;
649 }
650 if !service_due(home, service, Timestamp::now()) {
651 continue;
652 }
653 let service_cfg = if service == "release-watch" {
654 Config::discover(repo, explicit_config)
657 .ok()
658 .map(|(cfg, _)| cfg)
659 } else {
660 cfg.cloned()
661 };
662 if !service_has_work(home, repo, service_cfg.as_ref(), service, Timestamp::now()) {
663 let stamps = home.join("service-ticks");
664 std::fs::create_dir_all(&stamps)?;
665 std::fs::write(
666 stamps.join(service),
667 Timestamp::now().as_second().to_string(),
668 )?;
669 continue;
670 }
671 let payload = serde_json::to_value(ServiceRequest {
672 service: service.to_owned(),
673 repo: repo.to_path_buf(),
674 cfg: service_cfg,
675 explicit_config: explicit_config.map(std::path::Path::to_path_buf),
676 })?;
677 if let Err(error) = crate::worker::spawn(home, "service", &key, payload).await {
678 tracing::warn!("service {service} worker could not start: {error:#}");
679 }
680 }
681 Ok(())
682}
683
684pub async fn run_service_worker(home: &std::path::Path, payload: serde_json::Value) -> Result<()> {
687 let request: ServiceRequest = serde_json::from_value(payload)?;
688 anyhow::ensure!(
689 matches!(
690 request.service.as_str(),
691 "waiter" | "deputies" | "release-watch"
692 ),
693 "unknown conversation service"
694 );
695 let stamps = home.join("service-ticks");
696 std::fs::create_dir_all(&stamps)?;
697 std::fs::write(
698 stamps.join(&request.service),
699 Timestamp::now().as_second().to_string(),
700 )?;
701 let questions = Questions::at(home.join("questions"));
702 match request.service.as_str() {
703 "waiter" => {
704 crate::waiter::Waiter::new(questions, home.to_path_buf(), request.cfg)
705 .tick(Timestamp::now(), &|| false)
706 .await;
707 }
708 "deputies" => {
709 let max = request.cfg.as_ref().map_or_else(
710 || Config::default().daemon.max_deputies,
711 |cfg| cfg.daemon.max_deputies,
712 );
713 let deputies = crate::deputy::Deputies::new(
714 questions,
715 home.to_path_buf(),
716 request.cfg,
717 request.repo,
718 max,
719 std::sync::Arc::new(|| false),
720 );
721 deputies.dispatch(Timestamp::now()).await?;
722 }
723 "release-watch" => {
724 if let Ok((cfg, _)) =
725 Config::discover(&request.repo, request.explicit_config.as_deref())
726 {
727 let (repos, minutes) =
728 crate::daemon::release_watch_settings_from_config(home, &request.repo, &cfg);
729 if minutes > 0 {
730 crate::release_watch::Watcher::new(
731 Box::new(crate::release_watch::GhForge),
732 home.to_path_buf(),
733 )
734 .lap(
735 &repos,
736 (minutes as i64).saturating_mul(60),
737 Timestamp::now().as_second(),
738 &|| false,
739 )
740 .await;
741 }
742 }
743 }
744 _ => unreachable!("validated service"),
745 }
746 std::fs::write(
749 stamps.join(&request.service),
750 Timestamp::now().as_second().to_string(),
751 )?;
752 Ok(())
753}
754
755#[cfg(test)]
756mod tests {
757 use std::collections::BTreeMap;
758 use std::path::PathBuf;
759
760 use super::*;
761 use crate::config::{AgentKind, AgentSpec, Config};
762 use crate::queue::{CHAT_NODE, Source};
763 use crate::talk::TalkStatus;
764
765 #[test]
766 fn service_cadence_is_durable_and_keeps_the_watchers_own_interval() {
767 let home = tempfile::tempdir().expect("home");
768 let stamps = home.path().join("service-ticks");
769 std::fs::create_dir_all(&stamps).expect("stamps");
770 for service in ["waiter", "deputies", "release-watch"] {
771 std::fs::write(stamps.join(service), "100").expect("completion timestamp");
772 }
773 let at = |seconds| Timestamp::from_second(seconds).unwrap();
774 assert!(!service_due(home.path(), "waiter", at(104)));
775 assert!(service_due(home.path(), "waiter", at(105)));
776 assert!(!service_due(home.path(), "deputies", at(104)));
777 assert!(service_due(home.path(), "deputies", at(105)));
778 assert!(!service_due(home.path(), "release-watch", at(399)));
779 assert!(service_due(home.path(), "release-watch", at(400)));
780 }
781
782 #[test]
783 fn idle_services_do_not_need_detached_processes() {
784 let home = tempfile::tempdir().expect("home");
785 for service in ["waiter", "deputies", "release-watch"] {
786 assert!(!service_has_work(
787 home.path(),
788 home.path(),
789 None,
790 service,
791 Timestamp::now(),
792 ));
793 }
794 let mut disabled = Config::default();
795 disabled.daemon.release_stall_minutes = 0;
796 assert!(!service_has_work(
797 home.path(),
798 home.path(),
799 Some(&disabled),
800 "release-watch",
801 Timestamp::now(),
802 ));
803 }
804
805 #[test]
806 fn a_task_post_does_not_answer_an_admitted_owner_send() {
807 let (_home, talks, mut talk) = talks();
808 talk::record(&mut talk, &talks, "owner send", Vec::new()).expect("owner turn");
809 let index = talk.turns.len() - 1;
810 talk::post(&talks, &talk.id, "unrelated task report", "task-seat").expect("task post");
811 let mut fresh = talks.get(&talk.id).expect("persisted transcript");
812 assert!(!recorded_answered(&fresh, index));
813 let mut response = fresh.turns.last().expect("post").clone();
814 response.posted_by = None;
815 response.body = "actual persona response".to_owned();
816 fresh.turns.push(response);
817 assert!(recorded_answered(&fresh, index));
818 }
819
820 #[tokio::test]
821 async fn explicit_release_config_overrides_the_repository_without_launching_disabled_watchers()
822 {
823 let home = tempfile::tempdir().expect("home");
824 let repo = tempfile::tempdir().expect("repo");
825 let explicit = home.path().join("serve-config.toml");
826 std::fs::write(
827 repo.path().join("magi.toml"),
828 "[daemon]\nrelease_stall_minutes = 60\n",
829 )
830 .expect("repository watcher enabled");
831 std::fs::write(&explicit, "[daemon]\nrelease_stall_minutes = 0\n")
832 .expect("explicit watcher disabled");
833 start_services(home.path(), repo.path(), None, Some(&explicit))
834 .await
835 .expect("idle service admission");
836 let jobs = home.path().join("workers").join("jobs");
837 assert!(
838 std::fs::read_dir(jobs)
839 .map(|mut entries| entries.next().is_none())
840 .unwrap_or(true),
841 "serve --config must not launch the repository-configured watcher"
842 );
843 }
844
845 #[tokio::test]
846 async fn publication_waiting_drainer_does_not_block_busy_queue_admission() {
847 let (_home, talks, mut talk) = talks();
848 let ingress = publication_guard(&talks, &talk.id)
849 .await
850 .expect("ingress publication");
851 let turn = talks
852 .claim_turn(&talk.id)
853 .expect("turn claim")
854 .expect("drainer turn");
855 let id = talk.id.clone();
856 let waiting = publication_guard(&talks, &id);
857 tokio::pin!(waiting);
858 std::future::poll_fn(|cx| {
859 assert!(
860 waiting.as_mut().poll(cx).is_pending(),
861 "worker waits for publication"
862 );
863 std::task::Poll::Ready(())
864 })
865 .await;
866 assert!(
867 talks
868 .claim_turn(&talk.id)
869 .expect("nonblocking ingress claim")
870 .is_none()
871 );
872 talk::queue(&mut talk, &talks, "accepted while busy", Vec::new())
873 .expect("queue without owning turn");
874 assert_eq!(
875 talks.get(&talk.id).expect("durable pending").pending,
876 "accepted while busy"
877 );
878 drop(ingress);
879 let publication = tokio::time::timeout(std::time::Duration::from_secs(5), waiting)
880 .await
881 .expect("publication fence released")
882 .expect("drainer publication");
883 assert_eq!(
884 talk::drain(&mut talk, &talks).expect("drain").as_deref(),
885 Some("accepted while busy")
886 );
887 drop(publication);
888 assert!(
889 try_publication_guard(&talks, &talk.id)
890 .expect("released fence")
891 .is_some()
892 );
893 drop(turn);
894 }
895
896 #[tokio::test]
897 async fn a_pinning_failure_restores_the_recorded_send_for_manual_resume() {
898 let (_home, talks, mut talk) = talks();
899 let _publication = publication_guard(&talks, &talk.id)
900 .await
901 .expect("publication");
902 let _turn = talks
903 .claim_turn(&talk.id)
904 .expect("turn")
905 .expect("free turn");
906 talk::record(&mut talk, &talks, "keep this accepted send", Vec::new()).expect("record");
907 let workers = talks.home().join("workers");
908 std::fs::create_dir_all(&workers).expect("worker directory");
909 std::fs::write(workers.join("generations"), "block executable publication")
910 .expect("controlled pre-reservation failure");
911 let error = spawn_recorded_turn(&talks, &talk.id, talk.turns.len() - 1)
912 .await
913 .expect_err("pinning must fail");
914 assert!(crate::worker::not_started(&error));
915 assert!(error.to_string().contains("saved as a draft"));
916 let mut recovered = talks.get(&talk.id).expect("recovered draft");
917 assert!(
918 recovered.turns.is_empty(),
919 "no unanswered transcript orphan"
920 );
921 assert_eq!(recovered.pending, "keep this accepted send");
922 assert!(
923 crate::chat_notify::talks_to_start(&talks).is_empty(),
924 "recovery does not become an automatic retry"
925 );
926 assert_eq!(
927 talk::drain(&mut recovered, &talks)
928 .expect("manual resume")
929 .as_deref(),
930 Some("keep this accepted send")
931 );
932 }
933
934 #[test]
935 fn restoring_an_unstarted_send_retains_reply_attachments_and_later_drafts() {
936 let (_home, talks, mut talk) = talks();
937 talk::post(&talks, &talk.id, "original reply source", "source-task").expect("source");
938 let attachment = talk::Attachment {
939 id: "image".to_owned(),
940 name: "photo.png".to_owned(),
941 mime: "image/png".to_owned(),
942 bytes: 12,
943 };
944 talk::record_reply(
945 &mut talk,
946 &talks,
947 " preserve formatting\n\nowner send",
948 vec![attachment.clone()],
949 Some(talk::ReplyTarget {
950 turn: 0,
951 message: 0,
952 }),
953 )
954 .expect("record reply");
955 let index = talk.turns.len() - 1;
956 let recorded = talk.turns[index].clone();
957 talk::post(&talks, &talk.id, "unrelated concurrent post", "later-task").expect("post");
958 talk::queue(&mut talk, &talks, "later draft", Vec::new()).expect("later draft");
959 assert!(talk::restore_recorded_draft(&talks, &talk.id, index, &recorded).expect("restore"));
960 let mut recovered = talks.get(&talk.id).expect("recovered");
961 assert_eq!(recovered.turns.len(), 2, "task posts are retained");
962 assert_eq!(recovered.pending_messages[0], recorded.messages[0]);
963 assert_eq!(recovered.pending_messages[1].body, "later draft");
964 assert_eq!(recovered.pending_attachments, vec![attachment]);
965 assert_eq!(
966 recovered.pending,
967 " preserve formatting\n\nowner send\n\nlater draft"
968 );
969 assert!(
970 !talk::restore_recorded_draft(&talks, &talk.id, index, &recorded).expect("idempotent")
971 );
972 talk::drain(&mut recovered, &talks)
973 .expect("manual resume")
974 .expect("draft");
975 assert_eq!(
976 recovered.turns.last().expect("resumed").messages[0],
977 recorded.messages[0]
978 );
979 }
980
981 #[test]
982 fn a_started_response_is_never_rolled_back_into_a_draft() {
983 let (_home, talks, mut talk) = talks();
984 talk::record(&mut talk, &talks, "owner send", Vec::new()).expect("record");
985 let recorded = talk.turns[0].clone();
986 let mut response = recorded.clone();
987 response.who = talk::Who::Agent;
988 response.sender = None;
989 response.body = "already answered".to_owned();
990 talk.turns.push(response);
991 talks.put(&mut talk).expect("response");
992 assert!(
993 !talk::restore_recorded_draft(&talks, &talk.id, 0, &recorded).expect("refuse rollback")
994 );
995 let fresh = talks.get(&talk.id).expect("unchanged");
996 assert_eq!(fresh.turns.len(), 2);
997 assert!(fresh.pending.is_empty());
998 }
999
1000 const RUN: &str = "20260902-000000-beef";
1001
1002 fn talks() -> (tempfile::TempDir, Talks, Talk) {
1003 let tmp = tempfile::tempdir().expect("tempdir");
1004 let store = Talks::at(tmp.path().join("talks"));
1005 let cfg = Config {
1006 agents: vec![AgentSpec {
1007 id: "mock".to_owned(),
1008 kind: AgentKind::Command,
1009 model: None,
1010 command: vec!["true".to_owned()],
1011 extra_args: Vec::new(),
1012 env: BTreeMap::new(),
1013 prompt_delivery: None,
1014 max_turn_secs: None,
1015 }],
1016 ..Config::default()
1017 };
1018 let talk = talk::begin(&store, &cfg, tmp.path().to_path_buf(), Some("mock")).expect("talk");
1019 (tmp, store, talk)
1020 }
1021
1022 fn task(source: Source) -> Task {
1023 let mut t = Task::new(
1024 "t".to_owned(),
1025 "Do it".to_owned(),
1026 PathBuf::from("/repo"),
1027 source,
1028 );
1029 t.start(RUN.to_owned());
1030 t
1031 }
1032
1033 fn from_chat(talk: &Talk) -> Task {
1034 task(Source::Agent {
1035 run: talk.id.clone(),
1036 node: CHAT_NODE.to_owned(),
1037 })
1038 }
1039
1040 fn question(node: &str) -> Question {
1041 Question::new(
1042 RUN.to_owned(),
1043 node.to_owned(),
1044 "impl-A".to_owned(),
1045 "Which backend?".to_owned(),
1046 "SQLite is simpler.".to_owned(),
1047 vec!["SQLite".to_owned(), "Redis".to_owned()],
1048 )
1049 }
1050
1051 #[tokio::test]
1052 async fn start_turn_is_busy_and_writes_nothing_while_the_lease_is_held() {
1053 let (tmp, store, talk) = talks();
1054 let questions = Questions::at(tmp.path().join("questions"));
1055 let mut q = question("implement");
1056 questions.put(&mut q).expect("put");
1057 let held = store.claim_turn(&talk.id).expect("claim").expect("first");
1058
1059 let got = start_turn(&questions, &store, &q, &talk, &Config::default())
1060 .await
1061 .expect("start");
1062 assert_eq!(got, Started::Busy);
1063 assert!(questions.get(&q.id).unwrap().consult.is_none());
1064 assert!(store.get(&talk.id).unwrap().pending.is_empty());
1065 drop(held);
1066 }
1067
1068 fn scripted(tmp: &std::path::Path, script: &str, lease: &std::path::Path) -> Config {
1071 let path = tmp.join("mock-consult-agent.sh");
1072 std::fs::write(&path, script).expect("write mock");
1073 Config {
1074 agents: vec![AgentSpec {
1075 id: "mock".to_owned(),
1076 kind: AgentKind::Command,
1077 model: None,
1078 command: vec!["sh".to_owned(), path.to_string_lossy().into_owned()],
1079 extra_args: Vec::new(),
1080 env: BTreeMap::from([("LEASE".to_owned(), lease.to_string_lossy().into_owned())]),
1081 prompt_delivery: None,
1082 max_turn_secs: None,
1083 }],
1084 ..Config::default()
1085 }
1086 }
1087
1088 async fn run_turn(
1091 script: &str,
1092 ) -> (
1093 tempfile::TempDir,
1094 Result<Started>,
1095 Questions,
1096 Question,
1097 Talk,
1098 ) {
1099 crate::run::set_home(crate::run::test_home());
1100 let (tmp, store, talk) = talks();
1101 let questions = Questions::at(tmp.path().join("questions"));
1102 let mut q = question("implement");
1103 questions.put(&mut q).expect("put");
1104 let cfg = scripted(tmp.path(), script, &store.turn_path(&talk.id));
1105 let got = start_turn(&questions, &store, &q, &talk, &cfg).await;
1106 (tmp, got, questions, q, talk)
1107 }
1108
1109 #[tokio::test]
1110 async fn the_lease_is_held_during_the_turn_and_free_once_it_ends() {
1111 let (tmp, got, _questions, _q, talk) =
1113 run_turn("#!/bin/sh\ncat >/dev/null\n[ -f \"$LEASE\" ] || exit 7\nprintf 'ok\\n'\n")
1114 .await;
1115 assert_eq!(got.expect("turn"), Started::Answered);
1116 let other = Talks::at(tmp.path().join("talks"));
1117 assert!(
1118 other.claim_turn(&talk.id).expect("claim").is_some(),
1119 "free once the turn ended"
1120 );
1121 }
1122
1123 #[tokio::test]
1124 async fn the_lease_is_free_again_after_a_failed_turn() {
1125 let (tmp, got, questions, q, talk) = run_turn("#!/bin/sh\ncat >/dev/null\nexit 3\n").await;
1126 assert!(got.is_err(), "the agent failed");
1127 assert!(
1128 questions.get(&q.id).unwrap().consult.is_some(),
1129 "the turn got as far as running"
1130 );
1131 let other = Talks::at(tmp.path().join("talks"));
1132 assert!(
1133 other.claim_turn(&talk.id).expect("claim").is_some(),
1134 "free after the failed turn"
1135 );
1136 }
1137
1138 #[test]
1139 fn origin_talk_is_decided_in_one_table() {
1140 let (_tmp, _store, talk) = talks();
1141 let mut closed = talk.clone();
1142 closed.status = TalkStatus::Closed;
1143 let chat = from_chat(&talk);
1144 let q = question("implement");
1145
1146 let hit = |tasks: &[Task], talks: &[Talk], q: &Question| origin_talk(tasks, talks, q);
1147 assert_eq!(
1148 hit(std::slice::from_ref(&chat), std::slice::from_ref(&talk), &q).map(|t| t.id),
1149 Some(talk.id.clone())
1150 );
1151 assert!(hit(&[task(Source::Human)], std::slice::from_ref(&talk), &q).is_none());
1153 let other = task(Source::Agent {
1154 run: talk.id.clone(),
1155 node: "implement".to_owned(),
1156 });
1157 assert!(hit(&[other], std::slice::from_ref(&talk), &q).is_none());
1158 assert!(hit(std::slice::from_ref(&chat), &[], &q).is_none());
1160 assert!(hit(std::slice::from_ref(&chat), &[closed], &q).is_some());
1162 assert!(hit(&[], std::slice::from_ref(&talk), &q).is_none());
1164 assert!(
1166 hit(
1167 std::slice::from_ref(&chat),
1168 std::slice::from_ref(&talk),
1169 &question(crate::land::APPROVAL_NODE)
1170 )
1171 .is_some()
1172 );
1173 assert!(
1174 hit(
1175 std::slice::from_ref(&chat),
1176 std::slice::from_ref(&talk),
1177 &question(crate::bump::NOTICE_NODE)
1178 )
1179 .is_none()
1180 );
1181 let mut answered = question("implement");
1183 answered
1184 .answer(crate::ask::Answer::Choice("Redis".to_owned()))
1185 .unwrap();
1186 assert!(hit(&[chat], &[talk], &answered).is_none());
1187 }
1188
1189 #[test]
1190 fn associated_chat_remains_navigable_after_consultation_and_settlement() {
1191 let (tmp, store, mut talk) = talks();
1192 let chat = from_chat(&talk);
1193 let tasks = std::slice::from_ref(&chat);
1194 let mut q = question("implement");
1195 let questions = Questions::at(tmp.path().join("questions"));
1196 questions.put(&mut q).unwrap();
1197 assert!(begin(&questions, &store, &q, &talk).unwrap());
1198 q = questions.get(&q.id).unwrap();
1199 let associated = |q: &Question, talk: &Talk| {
1200 associated_talk(tasks, std::slice::from_ref(talk), q).map(|t| t.id)
1201 };
1202 assert_eq!(associated(&q, &talk), Some(talk.id.clone()));
1203 assert!(q.status.open());
1204 assert!(q.answer.is_none());
1205 assert!(origin_talk(tasks, std::slice::from_ref(&talk), &q).is_some());
1206 q.answer(crate::ask::Answer::Choice("Redis".into()))
1207 .unwrap();
1208 assert_eq!(associated(&q, &talk), Some(talk.id.clone()));
1209 assert!(origin_talk(tasks, std::slice::from_ref(&talk), &q).is_none());
1210 talk.status = TalkStatus::Closed;
1211 assert_eq!(associated(&q, &talk), Some(talk.id.clone()));
1212 let mut q = question("implement");
1213 q.abandon("expired");
1214 assert_eq!(associated(&q, &talk), Some(talk.id.clone()));
1215 assert!(origin_talk(tasks, std::slice::from_ref(&talk), &q).is_none());
1216 assert!(associated_talk(tasks, &[], &q).is_none());
1217 assert!(associated_talk(&[], std::slice::from_ref(&talk), &q).is_none());
1218 let notice = question(crate::bump::NOTICE_NODE);
1219 assert_eq!(associated(¬ice, &talk), Some(talk.id.clone()));
1220 assert!(origin_talk(tasks, &[talk], ¬ice).is_none());
1221 }
1222
1223 #[test]
1224 fn approval_origin_requires_an_open_chat_task_and_question() {
1225 let (_tmp, _store, talk) = talks();
1226 let chat = from_chat(&talk);
1227 let mut q = question(crate::land::APPROVAL_NODE);
1228 assert!(origin_talk(&[task(Source::Human)], std::slice::from_ref(&talk), &q).is_none());
1229 assert!(
1230 origin_talk(
1231 &[task(Source::Agent {
1232 run: talk.id.clone(),
1233 node: "implement".into(),
1234 })],
1235 std::slice::from_ref(&talk),
1236 &q
1237 )
1238 .is_none()
1239 );
1240 assert!(origin_talk(std::slice::from_ref(&chat), &[], &q).is_none());
1241 let mut closed = talk.clone();
1242 closed.status = TalkStatus::Closed;
1243 assert!(origin_talk(std::slice::from_ref(&chat), &[closed], &q).is_some());
1244 q.abandon("expired");
1245 assert!(
1246 origin_talk(std::slice::from_ref(&chat), std::slice::from_ref(&talk), &q).is_none()
1247 );
1248 let mut q = question(crate::land::APPROVAL_NODE);
1249 q.answer(crate::ask::Answer::Choice("Redis".into()))
1250 .unwrap();
1251 assert!(origin_talk(&[chat], &[talk], &q).is_none());
1252 }
1253
1254 #[test]
1255 fn a_conductor_question_is_found_through_the_task_id() {
1256 let (_tmp, _store, talk) = talks();
1257 let chat = from_chat(&talk);
1258 let mut q = question(crate::conduct::NODE);
1259 q.run = chat.id.clone();
1260 assert!(origin_talk(&[chat], &[talk], &q).is_some());
1261 }
1262
1263 fn followup_of(parent: &Task, n: u32) -> Task {
1264 let mut t = Task::new(
1265 "f".to_owned(),
1266 "Fix".to_owned(),
1267 PathBuf::from("/repo"),
1268 Source::Agent {
1269 run: format!("merged-{n}"),
1270 node: "followup".to_owned(),
1271 },
1272 );
1273 t.start(format!("run-f{n}"));
1274 t.followup = Some(crate::queue::FollowUp {
1275 run: format!("merged-{n}"),
1276 origin_task: Some(parent.id.clone()),
1277 pr: "https://example.invalid/pr/1".to_owned(),
1278 findings: Vec::new(),
1279 generation: n,
1280 });
1281 t
1282 }
1283
1284 fn q_for(t: &Task) -> Question {
1285 let mut q = question("implement");
1286 q.run = t.runs[0].clone();
1287 q
1288 }
1289
1290 #[test]
1291 fn a_followup_traces_back_to_the_chat() {
1292 let (_tmp, _store, talk) = talks();
1293 let mut chat = from_chat(&talk);
1294 chat.runs = vec!["chat-run".to_owned()];
1295 let f1 = followup_of(&chat, 1);
1296 let f2 = followup_of(&f1, 2);
1297 let ts = [chat, f1.clone(), f2.clone()];
1298 let id = Some(talk.id.clone());
1299 let tk = std::slice::from_ref(&talk);
1300 assert_eq!(origin_talk(&ts, tk, &q_for(&f1)).map(|t| t.id), id);
1301 assert_eq!(origin_talk(&ts, tk, &q_for(&f2)).map(|t| t.id), id);
1302 }
1303
1304 #[test]
1305 fn a_followup_without_origin_task_is_found_through_runs() {
1306 let (_tmp, _store, talk) = talks();
1307 let mut chat = from_chat(&talk);
1308 chat.runs = vec!["merged-1".to_owned()];
1309 let mut f1 = followup_of(&chat, 1);
1310 f1.followup.as_mut().unwrap().origin_task = None;
1311 let q = q_for(&f1);
1312 assert!(origin_talk(&[chat, f1], &[talk], &q).is_some());
1313 }
1314
1315 #[test]
1316 fn a_dangling_origin_task_has_no_chat() {
1317 let (_tmp, _store, talk) = talks();
1318 let mut chat = from_chat(&talk);
1319 chat.runs = vec!["chat-run".to_owned()];
1320 let mut f1 = followup_of(&chat, 1);
1321 f1.followup.as_mut().unwrap().origin_task = Some("gone".to_owned());
1322 let q = q_for(&f1);
1323 assert!(origin_talk(&[chat, f1], &[talk], &q).is_none());
1324 }
1325
1326 #[test]
1327 fn a_recorded_chat_survives_deleted_ancestors() {
1328 let (_tmp, _store, talk) = talks();
1329 let chat = from_chat(&talk);
1330 assert_eq!(chat.origin_chat.as_deref(), Some(talk.id.as_str()));
1331 let mut f1 = followup_of(&chat, 1);
1332 f1.origin_chat = chat.origin_chat.clone();
1333 let mut f2 = followup_of(&f1, 2);
1334 f2.origin_chat = f1.origin_chat.clone();
1335 let q = q_for(&f2);
1337 let got = origin_talk(std::slice::from_ref(&f2), std::slice::from_ref(&talk), &q);
1338 assert_eq!(got.map(|t| t.id), Some(talk.id.clone()));
1339 assert!(origin_talk(&[f2], &[], &q).is_none());
1341 }
1342
1343 #[test]
1344 fn a_task_without_the_field_still_walks_the_ancestry() {
1345 let (_tmp, _store, talk) = talks();
1346 let mut chat = from_chat(&talk);
1347 chat.origin_chat = None;
1348 let mut f1 = followup_of(&chat, 1);
1349 f1.origin_chat = None;
1350 let q = q_for(&f1);
1351 assert!(origin_talk(&[chat, f1.clone()], std::slice::from_ref(&talk), &q).is_some());
1352 let mut v = serde_json::to_value(&f1).unwrap();
1354 v.as_object_mut().unwrap().remove("origin_chat");
1355 let back: Task = serde_json::from_value(v).unwrap();
1356 assert!(back.origin_chat.is_none());
1357 }
1358
1359 #[test]
1360 fn a_followup_cycle_ends_without_a_chat() {
1361 let (_tmp, _store, talk) = talks();
1362 let mut a = followup_of(&from_chat(&talk), 1);
1363 let mut b = followup_of(&a, 2);
1364 a.followup.as_mut().unwrap().origin_task = Some(b.id.clone());
1365 b.followup.as_mut().unwrap().origin_task = Some(a.id.clone());
1366 let q = q_for(&a);
1367 assert!(origin_talk(&[a, b], &[talk], &q).is_none());
1368 }
1369
1370 #[test]
1371 fn pending_consults_follow_the_store_not_the_turn_text() {
1372 let (tmp, store, talk) = talks();
1373 let questions = Questions::at(tmp.path().join("questions"));
1374 assert!(!pending_consults(&questions, &talk.id), "empty store");
1375
1376 let mut plain = question("implement");
1377 questions.put(&mut plain).unwrap();
1378 assert!(!pending_consults(&questions, &talk.id), "no consult record");
1379
1380 let mut q = question("implement");
1381 questions.put(&mut q).unwrap();
1382 begin(&questions, &store, &q, &talk).unwrap();
1383 assert!(pending_consults(&questions, &talk.id));
1384 assert!(!pending_consults(&questions, "other-talk"));
1385
1386 questions
1387 .update(&q.id, |q| {
1388 q.abandon("test");
1389 Ok(())
1390 })
1391 .unwrap();
1392 assert!(!pending_consults(&questions, &talk.id), "closed question");
1393 }
1394
1395 #[test]
1396 fn begin_reopens_a_closed_chat_before_queueing() {
1397 let (tmp, store, mut talk) = talks();
1398 let questions = Questions::at(tmp.path().join("questions"));
1399 let mut q = question("implement");
1400 questions.put(&mut q).unwrap();
1401 talk::close(&mut talk, &store).unwrap();
1402 assert!(!store.get(&talk.id).unwrap().status.open());
1403
1404 assert!(begin(&questions, &store, &q, &talk).unwrap());
1405
1406 let after = store.get(&talk.id).unwrap();
1407 assert!(after.status.open(), "the chat was reopened");
1408 assert!(after.pending.contains(&q.id), "{}", after.pending);
1409 assert!(questions.get(&q.id).unwrap().consult.is_some());
1410 }
1411
1412 #[test]
1413 fn begin_queues_once_and_leaves_the_question_open() {
1414 let (tmp, store, talk) = talks();
1415 let questions = Questions::at(tmp.path().join("questions"));
1416 let mut q = question("implement");
1417 questions.put(&mut q).unwrap();
1418
1419 assert!(begin(&questions, &store, &q, &talk).unwrap());
1420 assert!(!begin(&questions, &store, &q, &talk).unwrap(), "idempotent");
1421
1422 let after = questions.get(&q.id).unwrap();
1423 assert!(after.status.open());
1424 assert!(after.thread.is_empty());
1425 assert_eq!(after.choices, q.choices, "choices are not touched");
1426 assert_eq!(
1427 after.consult.as_ref().map(|c| c.talk.as_str()),
1428 Some(talk.id.as_str())
1429 );
1430
1431 let destination = store.get(&talk.id).unwrap();
1432 assert_eq!(destination.pending_messages.len(), 1);
1433 assert_eq!(
1434 destination.pending_messages[0].display,
1435 Some(crate::prompt::chat_consult_display(&q))
1436 );
1437 let queued = destination.pending;
1438 assert_eq!(
1439 queued.matches(&q.id).count(),
1440 1 + 3,
1441 "id once per use: {queued}"
1442 );
1443 assert!(queued.contains("Which backend?"));
1444 assert!(queued.contains("SQLite is simpler."));
1445 assert!(queued.contains("- Redis"));
1446 assert_eq!(
1448 queued.matches(crate::prompt::CHAT_CONSULT_HEADING).count(),
1449 1
1450 );
1451 }
1452
1453 #[test]
1454 fn chat_consult_has_each_marker_once() {
1455 use crate::prompt::{CHAT_CONSULT_END as E, CHAT_CONSULT_HEADING as H};
1456 for node in ["implement", crate::land::APPROVAL_NODE] {
1457 let mut q = question(node);
1458 let quoted = format!("{H} {E}");
1459 q.summary = quoted.clone();
1460 q.detail = quoted.clone();
1461 q.choices = vec![quoted.clone(), "b".into()];
1462 let s = crate::prompt::chat_consult(&q);
1463 assert_eq!(s.matches(H).count(), 1, "{s}");
1464 assert_eq!(s.matches(E).count(), 1, "{s}");
1465 }
1466 }
1467
1468 fn record(questions: &Questions, id: &str, reply: &str) -> Result<Question> {
1470 questions
1471 .update(id, |r| {
1472 let label = r.resolve_reply(reply)?.to_owned();
1473 r.answer(crate::ask::Answer::Choice(label))
1474 })
1475 .map(|(q, ())| q)
1476 }
1477
1478 #[test]
1479 fn a_consulted_posting_question_records_the_chats_choice_without_a_quote() {
1480 use crate::github_text::{ASK_NODE, POST_ORIGINAL, USE_FALLBACK, USE_REWRITE};
1481 for choice in [POST_ORIGINAL, USE_REWRITE] {
1482 let (tmp, store, talk) = talks();
1483 let questions = Questions::at(tmp.path().join("questions"));
1484 let mut q = question(ASK_NODE);
1485 q.choices = vec![USE_FALLBACK.into(), choice.into()];
1486 questions.put(&mut q).unwrap();
1487 assert!(begin(&questions, &store, &q, &talk).unwrap());
1488 let queued = store.get(&talk.id).unwrap().pending;
1489 assert!(queued.contains("magi answer"), "{queued}");
1490 assert!(!queued.contains("--quote"), "{queued}");
1491 let other = if choice == POST_ORIGINAL {
1492 USE_REWRITE
1493 } else {
1494 POST_ORIGINAL
1495 };
1496 assert!(record(&questions, &q.id, other).is_err(), "not offered");
1497 let answered = record(&questions, &q.id, choice).unwrap();
1498 assert_eq!(answered.resolution().as_deref(), Some(choice));
1499 assert!(
1500 record(&questions, &q.id, USE_FALLBACK).is_err(),
1501 "a question is answered once"
1502 );
1503 }
1504 }
1505
1506 #[test]
1507 fn merge_repair_consult_records_only_an_offered_choice() {
1508 use crate::land::{APPROVAL_NODE, APPROVE, HOLD, REPAIR};
1509 let (tmp, store, talk) = talks();
1510 let questions = Questions::at(tmp.path().join("questions"));
1511 let mut q = question(APPROVAL_NODE);
1512 q.choices = vec![APPROVE.into(), HOLD.into()];
1513 questions.put(&mut q).unwrap();
1514 begin(&questions, &store, &q, &talk).unwrap();
1515 assert!(record(&questions, &q.id, REPAIR).is_err(), "not offered");
1516 questions
1517 .update(&q.id, |r| {
1518 r.choices.push(REPAIR.into());
1519 Ok(())
1520 })
1521 .unwrap();
1522 let answered = record(&questions, &q.id, REPAIR).unwrap();
1523 assert_eq!(answered.resolution().as_deref(), Some(REPAIR));
1524 }
1525
1526 #[test]
1527 fn approval_consult_records_the_chats_merge_answer() {
1528 let (tmp, store, talk) = talks();
1529 let questions = Questions::at(tmp.path().join("questions"));
1530 let mut q = question(crate::land::APPROVAL_NODE);
1531 q.choices = vec![crate::land::APPROVE.into(), crate::land::HOLD.into()];
1532 questions.put(&mut q).unwrap();
1533 assert!(begin(&questions, &store, &q, &talk).unwrap());
1534 assert!(!begin(&questions, &store, &q, &talk).unwrap());
1535 q = questions.get(&q.id).unwrap();
1536 assert!(q.status.open());
1537 assert!(q.answer.is_none());
1538 assert!(q.thread.is_empty());
1539 let queued = store.get(&talk.id).unwrap().pending;
1540 assert!(queued.contains("Silence holds"), "{queued}");
1541 assert!(queued.contains("--reply merge"), "{queued}");
1542 assert!(!queued.contains("--quote"), "{queued}");
1543
1544 let answered = record(&questions, &q.id, crate::land::APPROVE).unwrap();
1547 assert_eq!(answered.resolution().as_deref(), Some(crate::land::APPROVE));
1548 assert!(record(&questions, &q.id, crate::land::HOLD).is_err());
1549
1550 let mut expired = question(crate::land::APPROVAL_NODE);
1552 expired.choices = q.choices.clone();
1553 expired.abandon("approval expired or head changed");
1554 assert!(
1555 expired
1556 .answer(crate::ask::Answer::Choice(crate::land::APPROVE.into()))
1557 .is_err()
1558 );
1559 }
1560
1561 #[test]
1562 fn reply_other_is_a_note_that_ends_only_the_consultation() {
1563 let (tmp, store, talk) = talks();
1564 let questions = Questions::at(tmp.path().join("questions"));
1565 let mut q = question("implement");
1566 questions.put(&mut q).unwrap();
1567 assert!(
1568 questions
1569 .update(&q.id, |r| record_other(r, "Use Postgres"))
1570 .is_err(),
1571 "nothing to end before consultation"
1572 );
1573 begin(&questions, &store, &q, &talk).unwrap();
1574 assert!(pending_consults(&questions, &talk.id));
1575 for refused in ["", " ", "SQLite"] {
1576 assert!(
1577 questions
1578 .update(&q.id, |r| record_other(r, refused))
1579 .is_err(),
1580 "{refused:?}"
1581 );
1582 }
1583 questions
1584 .update(&q.id, |r| record_other(r, "Use Postgres"))
1585 .unwrap();
1586 let after = questions.get(&q.id).unwrap();
1587 assert!(after.status.open());
1588 assert!(after.answer.is_none());
1589 let outcome = after.consult_outcome.unwrap();
1590 assert_eq!(outcome.text, "Use Postgres");
1591 assert_eq!(outcome.talk, talk.id);
1592 assert!(!pending_consults(&questions, &talk.id));
1593 }
1594
1595 #[test]
1596 fn begin_withdraws_its_record_when_the_talk_cannot_take_it() {
1597 let (tmp, store, talk) = talks();
1598 let questions = Questions::at(tmp.path().join("questions"));
1599 let mut q = question("implement");
1600 questions.put(&mut q).unwrap();
1601 std::fs::remove_dir_all(tmp.path().join("talks")).unwrap();
1602 assert!(begin(&questions, &store, &q, &talk).is_err());
1603 assert!(questions.get(&q.id).unwrap().consult.is_none());
1604 }
1605
1606 #[test]
1607 fn an_older_question_file_reads_without_a_consult() {
1608 let mut q = question("implement");
1609 q.schema = 5;
1610 let mut v = serde_json::to_value(&q).unwrap();
1611 v.as_object_mut().unwrap().remove("consult");
1612 let back: Question = serde_json::from_value(v).unwrap();
1613 assert!(back.consult.is_none());
1614 }
1615}