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
446fn recorded_answered(talk: &Talk, index: usize) -> bool {
448 index
449 .checked_add(1)
450 .and_then(|next| talk.turns.get(next..))
451 .is_some_and(|turns| {
452 turns
453 .iter()
454 .any(|turn| turn.who == talk::Who::Agent && turn.posted_by.is_none())
455 })
456}
457
458pub async fn run_turn_worker(home: &std::path::Path, payload: serde_json::Value) -> Result<()> {
461 let id = payload
462 .get("talk")
463 .and_then(serde_json::Value::as_str)
464 .context("chat worker has no talk id")?;
465 let automatic = payload
466 .get("automatic")
467 .and_then(serde_json::Value::as_bool)
468 .unwrap_or(false);
469 let recorded_turn = payload
470 .get("recorded_turn")
471 .and_then(serde_json::Value::as_u64)
472 .map(usize::try_from)
473 .transpose()?;
474 let talks = Talks::at(home.join("talks"));
475 let current = crate::worker::current_job(home)?;
476 loop {
477 let talk = talks.get(id)?;
478 if !talk.status.open()
479 || (recorded_turn.is_none()
480 && talk.pending.is_empty()
481 && talk.pending_attachments.is_empty())
482 {
483 return Ok(());
484 }
485 if automatic && !crate::chat_notify::pending_is_only_notices(&talk) {
486 return Ok(());
487 }
488 if let Some(lease) = talks.claim_turn(id)? {
489 let publication = publication_guard(&talks, id).await?;
490 if automatic && !crate::chat_notify::pending_is_only_notices(&talks.get(id)?) {
493 return Ok(());
494 }
495 let mut fresh = talks.get(id)?;
496 let active = crate::worker::active(home)?;
497 if recorded_turn.is_none() && current.as_ref().is_some_and(|current| {
498 active.iter().any(|job| {
499 job.kind == "chat" && job.id != current.id
500 && job.generation != current.generation
501 && job.payload.get("talk").and_then(serde_json::Value::as_str) == Some(id)
502 && job.payload.get("recorded_turn").and_then(serde_json::Value::as_u64).is_none()
503 && (!job.payload.get("automatic").and_then(serde_json::Value::as_bool).unwrap_or(false)
504 || crate::chat_notify::pending_is_only_notices(&fresh))
505 && job.sequence > 0
508 && (current.sequence == 0 || job.sequence > current.sequence)
509 })
510 }) {
511 return Ok(());
512 }
513 if recorded_turn.is_none()
514 && active.iter().any(|job| {
515 if job.kind != "chat"
516 || job.payload.get("talk").and_then(serde_json::Value::as_str) != Some(id)
517 {
518 return false;
519 }
520 let Some(index) = job
521 .payload
522 .get("recorded_turn")
523 .and_then(serde_json::Value::as_u64)
524 .and_then(|index| usize::try_from(index).ok())
525 else {
526 return false;
527 };
528 fresh
529 .turns
530 .get(index)
531 .is_some_and(|turn| turn.who == talk::Who::Operator)
532 && !recorded_answered(&fresh, index)
533 })
534 {
535 drop(publication);
539 drop(lease);
540 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
541 continue;
542 }
543 let (cfg, _) = Config::discover(&fresh.repo, None)?;
544 let text = if let Some(index) = recorded_turn {
545 let turn = fresh
546 .turns
547 .get(index)
548 .context("recorded chat turn disappeared")?;
549 anyhow::ensure!(
550 turn.who == talk::Who::Operator,
551 "recorded turn is not operator input"
552 );
553 if recorded_answered(&fresh, index) {
554 return Ok(());
555 }
556 turn.body.clone()
557 } else {
558 let Some(text) = talk::drain(&mut fresh, &talks)? else {
559 return Ok(());
560 };
561 text
562 };
563 drop(publication);
564 return talk::respond(&lease, &mut fresh, &talks, &cfg, &text).await;
568 }
569 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
570 }
571}
572
573#[derive(serde::Serialize, serde::Deserialize)]
574struct ServiceRequest {
575 service: String,
576 repo: std::path::PathBuf,
577 cfg: Option<Config>,
578 #[serde(default)]
579 explicit_config: Option<std::path::PathBuf>,
580}
581
582fn service_due(home: &std::path::Path, service: &str, now: Timestamp) -> bool {
583 let interval = match service {
584 "waiter" => crate::waiter::TICK,
585 "deputies" => crate::deputy::TICK,
586 "release-watch" => crate::release_watch::LAP,
587 _ => return false,
588 };
589 std::fs::read_to_string(home.join("service-ticks").join(service))
590 .ok()
591 .and_then(|text| text.parse::<i64>().ok())
592 .is_none_or(|last| now.as_second().saturating_sub(last) >= interval.as_secs() as i64)
593}
594
595fn service_has_work(
596 home: &std::path::Path,
597 repo: &std::path::Path,
598 cfg: Option<&Config>,
599 service: &str,
600 now: Timestamp,
601) -> bool {
602 match service {
603 "waiter" => crate::waiter::Waiter::new(
604 Questions::at(home.join("questions")),
605 home.to_path_buf(),
606 cfg.cloned(),
607 )
608 .has_work(now),
609 "deputies" => crate::deputy::Deputies::new(
610 Questions::at(home.join("questions")),
611 home.to_path_buf(),
612 cfg.cloned(),
613 repo.to_path_buf(),
614 cfg.map_or(0, |cfg| cfg.daemon.max_deputies),
615 std::sync::Arc::new(|| false),
616 )
617 .has_work(now),
618 "release-watch" => cfg.is_some_and(|cfg| cfg.daemon.release_stall_minutes > 0),
619 _ => false,
620 }
621}
622
623pub async fn start_services(
626 home: &std::path::Path,
627 repo: &std::path::Path,
628 cfg: Option<&Config>,
629 explicit_config: Option<&std::path::Path>,
630) -> Result<()> {
631 let active = crate::worker::active(home)?;
632 for service in ["waiter", "deputies", "release-watch"] {
633 let key = format!("service:{service}");
634 if active.iter().any(|job| job.key == key) {
637 continue;
638 }
639 if !service_due(home, service, Timestamp::now()) {
640 continue;
641 }
642 let service_cfg = if service == "release-watch" {
643 Config::discover(repo, explicit_config)
646 .ok()
647 .map(|(cfg, _)| cfg)
648 } else {
649 cfg.cloned()
650 };
651 if !service_has_work(home, repo, service_cfg.as_ref(), service, Timestamp::now()) {
652 let stamps = home.join("service-ticks");
653 std::fs::create_dir_all(&stamps)?;
654 std::fs::write(
655 stamps.join(service),
656 Timestamp::now().as_second().to_string(),
657 )?;
658 continue;
659 }
660 let payload = serde_json::to_value(ServiceRequest {
661 service: service.to_owned(),
662 repo: repo.to_path_buf(),
663 cfg: service_cfg,
664 explicit_config: explicit_config.map(std::path::Path::to_path_buf),
665 })?;
666 if let Err(error) = crate::worker::spawn(home, "service", &key, payload).await {
667 tracing::warn!("service {service} worker could not start: {error:#}");
668 }
669 }
670 Ok(())
671}
672
673pub async fn run_service_worker(home: &std::path::Path, payload: serde_json::Value) -> Result<()> {
676 let request: ServiceRequest = serde_json::from_value(payload)?;
677 anyhow::ensure!(
678 matches!(
679 request.service.as_str(),
680 "waiter" | "deputies" | "release-watch"
681 ),
682 "unknown conversation service"
683 );
684 let stamps = home.join("service-ticks");
685 std::fs::create_dir_all(&stamps)?;
686 std::fs::write(
687 stamps.join(&request.service),
688 Timestamp::now().as_second().to_string(),
689 )?;
690 let questions = Questions::at(home.join("questions"));
691 match request.service.as_str() {
692 "waiter" => {
693 crate::waiter::Waiter::new(questions, home.to_path_buf(), request.cfg)
694 .tick(Timestamp::now(), &|| false)
695 .await;
696 }
697 "deputies" => {
698 let max = request.cfg.as_ref().map_or_else(
699 || Config::default().daemon.max_deputies,
700 |cfg| cfg.daemon.max_deputies,
701 );
702 let deputies = crate::deputy::Deputies::new(
703 questions,
704 home.to_path_buf(),
705 request.cfg,
706 request.repo,
707 max,
708 std::sync::Arc::new(|| false),
709 );
710 deputies.dispatch(Timestamp::now()).await?;
711 }
712 "release-watch" => {
713 if let Ok((cfg, _)) =
714 Config::discover(&request.repo, request.explicit_config.as_deref())
715 {
716 let (repos, minutes) =
717 crate::daemon::release_watch_settings_from_config(home, &request.repo, &cfg);
718 if minutes > 0 {
719 crate::release_watch::Watcher::new(
720 Box::new(crate::release_watch::GhForge),
721 home.to_path_buf(),
722 )
723 .lap(
724 &repos,
725 (minutes as i64).saturating_mul(60),
726 Timestamp::now().as_second(),
727 &|| false,
728 )
729 .await;
730 }
731 }
732 }
733 _ => unreachable!("validated service"),
734 }
735 std::fs::write(
738 stamps.join(&request.service),
739 Timestamp::now().as_second().to_string(),
740 )?;
741 Ok(())
742}
743
744#[cfg(test)]
745mod tests {
746 use std::collections::BTreeMap;
747 use std::path::PathBuf;
748
749 use super::*;
750 use crate::config::{AgentKind, AgentSpec, Config};
751 use crate::queue::{CHAT_NODE, Source};
752 use crate::talk::TalkStatus;
753
754 #[test]
755 fn service_cadence_is_durable_and_keeps_the_watchers_own_interval() {
756 let home = tempfile::tempdir().expect("home");
757 let stamps = home.path().join("service-ticks");
758 std::fs::create_dir_all(&stamps).expect("stamps");
759 for service in ["waiter", "deputies", "release-watch"] {
760 std::fs::write(stamps.join(service), "100").expect("completion timestamp");
761 }
762 let at = |seconds| Timestamp::from_second(seconds).unwrap();
763 assert!(!service_due(home.path(), "waiter", at(104)));
764 assert!(service_due(home.path(), "waiter", at(105)));
765 assert!(!service_due(home.path(), "deputies", at(104)));
766 assert!(service_due(home.path(), "deputies", at(105)));
767 assert!(!service_due(home.path(), "release-watch", at(399)));
768 assert!(service_due(home.path(), "release-watch", at(400)));
769 }
770
771 #[test]
772 fn idle_services_do_not_need_detached_processes() {
773 let home = tempfile::tempdir().expect("home");
774 for service in ["waiter", "deputies", "release-watch"] {
775 assert!(!service_has_work(
776 home.path(),
777 home.path(),
778 None,
779 service,
780 Timestamp::now(),
781 ));
782 }
783 let mut disabled = Config::default();
784 disabled.daemon.release_stall_minutes = 0;
785 assert!(!service_has_work(
786 home.path(),
787 home.path(),
788 Some(&disabled),
789 "release-watch",
790 Timestamp::now(),
791 ));
792 }
793
794 #[test]
795 fn a_task_post_does_not_answer_an_admitted_owner_send() {
796 let (_home, talks, mut talk) = talks();
797 talk::record(&mut talk, &talks, "owner send", Vec::new()).expect("owner turn");
798 let index = talk.turns.len() - 1;
799 talk::post(&talks, &talk.id, "unrelated task report", "task-seat").expect("task post");
800 let mut fresh = talks.get(&talk.id).expect("persisted transcript");
801 assert!(!recorded_answered(&fresh, index));
802 let mut response = fresh.turns.last().expect("post").clone();
803 response.posted_by = None;
804 response.body = "actual persona response".to_owned();
805 fresh.turns.push(response);
806 assert!(recorded_answered(&fresh, index));
807 }
808
809 #[tokio::test]
810 async fn explicit_release_config_overrides_the_repository_without_launching_disabled_watchers()
811 {
812 let home = tempfile::tempdir().expect("home");
813 let repo = tempfile::tempdir().expect("repo");
814 let explicit = home.path().join("serve-config.toml");
815 std::fs::write(
816 repo.path().join("magi.toml"),
817 "[daemon]\nrelease_stall_minutes = 60\n",
818 )
819 .expect("repository watcher enabled");
820 std::fs::write(&explicit, "[daemon]\nrelease_stall_minutes = 0\n")
821 .expect("explicit watcher disabled");
822 start_services(home.path(), repo.path(), None, Some(&explicit))
823 .await
824 .expect("idle service admission");
825 let jobs = home.path().join("workers").join("jobs");
826 assert!(
827 std::fs::read_dir(jobs)
828 .map(|mut entries| entries.next().is_none())
829 .unwrap_or(true),
830 "serve --config must not launch the repository-configured watcher"
831 );
832 }
833
834 #[tokio::test]
835 async fn publication_waiting_drainer_does_not_block_busy_queue_admission() {
836 let (_home, talks, mut talk) = talks();
837 let ingress = publication_guard(&talks, &talk.id)
838 .await
839 .expect("ingress publication");
840 let turn = talks
841 .claim_turn(&talk.id)
842 .expect("turn claim")
843 .expect("drainer turn");
844 let id = talk.id.clone();
845 let waiting = publication_guard(&talks, &id);
846 tokio::pin!(waiting);
847 std::future::poll_fn(|cx| {
848 assert!(
849 waiting.as_mut().poll(cx).is_pending(),
850 "worker waits for publication"
851 );
852 std::task::Poll::Ready(())
853 })
854 .await;
855 assert!(
856 talks
857 .claim_turn(&talk.id)
858 .expect("nonblocking ingress claim")
859 .is_none()
860 );
861 talk::queue(&mut talk, &talks, "accepted while busy", Vec::new())
862 .expect("queue without owning turn");
863 assert_eq!(
864 talks.get(&talk.id).expect("durable pending").pending,
865 "accepted while busy"
866 );
867 drop(ingress);
868 let publication = tokio::time::timeout(std::time::Duration::from_secs(5), waiting)
869 .await
870 .expect("publication fence released")
871 .expect("drainer publication");
872 assert_eq!(
873 talk::drain(&mut talk, &talks).expect("drain").as_deref(),
874 Some("accepted while busy")
875 );
876 drop(publication);
877 assert!(
878 try_publication_guard(&talks, &talk.id)
879 .expect("released fence")
880 .is_some()
881 );
882 drop(turn);
883 }
884
885 #[tokio::test]
886 async fn a_pinning_failure_restores_the_recorded_send_for_manual_resume() {
887 let (_home, talks, mut talk) = talks();
888 let _publication = publication_guard(&talks, &talk.id)
889 .await
890 .expect("publication");
891 let _turn = talks
892 .claim_turn(&talk.id)
893 .expect("turn")
894 .expect("free turn");
895 talk::record(&mut talk, &talks, "keep this accepted send", Vec::new()).expect("record");
896 let workers = talks.home().join("workers");
897 std::fs::create_dir_all(&workers).expect("worker directory");
898 std::fs::write(workers.join("generations"), "block executable publication")
899 .expect("controlled pre-reservation failure");
900 let error = spawn_recorded_turn(&talks, &talk.id, talk.turns.len() - 1)
901 .await
902 .expect_err("pinning must fail");
903 assert!(crate::worker::not_started(&error));
904 assert!(error.to_string().contains("saved as a draft"));
905 let mut recovered = talks.get(&talk.id).expect("recovered draft");
906 assert!(
907 recovered.turns.is_empty(),
908 "no unanswered transcript orphan"
909 );
910 assert_eq!(recovered.pending, "keep this accepted send");
911 assert!(
912 crate::chat_notify::talks_to_start(&talks).is_empty(),
913 "recovery does not become an automatic retry"
914 );
915 assert_eq!(
916 talk::drain(&mut recovered, &talks)
917 .expect("manual resume")
918 .as_deref(),
919 Some("keep this accepted send")
920 );
921 }
922
923 #[test]
924 fn restoring_an_unstarted_send_retains_reply_attachments_and_later_drafts() {
925 let (_home, talks, mut talk) = talks();
926 talk::post(&talks, &talk.id, "original reply source", "source-task").expect("source");
927 let attachment = talk::Attachment {
928 id: "image".to_owned(),
929 name: "photo.png".to_owned(),
930 mime: "image/png".to_owned(),
931 bytes: 12,
932 };
933 talk::record_reply(
934 &mut talk,
935 &talks,
936 " preserve formatting\n\nowner send",
937 vec![attachment.clone()],
938 Some(talk::ReplyTarget {
939 turn: 0,
940 message: 0,
941 }),
942 )
943 .expect("record reply");
944 let index = talk.turns.len() - 1;
945 let recorded = talk.turns[index].clone();
946 talk::post(&talks, &talk.id, "unrelated concurrent post", "later-task").expect("post");
947 talk::queue(&mut talk, &talks, "later draft", Vec::new()).expect("later draft");
948 assert!(talk::restore_recorded_draft(&talks, &talk.id, index, &recorded).expect("restore"));
949 let mut recovered = talks.get(&talk.id).expect("recovered");
950 assert_eq!(recovered.turns.len(), 2, "task posts are retained");
951 assert_eq!(recovered.pending_messages[0], recorded.messages[0]);
952 assert_eq!(recovered.pending_messages[1].body, "later draft");
953 assert_eq!(recovered.pending_attachments, vec![attachment]);
954 assert_eq!(
955 recovered.pending,
956 " preserve formatting\n\nowner send\n\nlater draft"
957 );
958 assert!(
959 !talk::restore_recorded_draft(&talks, &talk.id, index, &recorded).expect("idempotent")
960 );
961 talk::drain(&mut recovered, &talks)
962 .expect("manual resume")
963 .expect("draft");
964 assert_eq!(
965 recovered.turns.last().expect("resumed").messages[0],
966 recorded.messages[0]
967 );
968 }
969
970 #[test]
971 fn a_started_response_is_never_rolled_back_into_a_draft() {
972 let (_home, talks, mut talk) = talks();
973 talk::record(&mut talk, &talks, "owner send", Vec::new()).expect("record");
974 let recorded = talk.turns[0].clone();
975 let mut response = recorded.clone();
976 response.who = talk::Who::Agent;
977 response.sender = None;
978 response.body = "already answered".to_owned();
979 talk.turns.push(response);
980 talks.put(&mut talk).expect("response");
981 assert!(
982 !talk::restore_recorded_draft(&talks, &talk.id, 0, &recorded).expect("refuse rollback")
983 );
984 let fresh = talks.get(&talk.id).expect("unchanged");
985 assert_eq!(fresh.turns.len(), 2);
986 assert!(fresh.pending.is_empty());
987 }
988
989 const RUN: &str = "20260902-000000-beef";
990
991 fn talks() -> (tempfile::TempDir, Talks, Talk) {
992 let tmp = tempfile::tempdir().expect("tempdir");
993 let store = Talks::at(tmp.path().join("talks"));
994 let cfg = Config {
995 agents: vec![AgentSpec {
996 id: "mock".to_owned(),
997 kind: AgentKind::Command,
998 model: None,
999 command: vec!["true".to_owned()],
1000 extra_args: Vec::new(),
1001 env: BTreeMap::new(),
1002 prompt_delivery: None,
1003 max_turn_secs: None,
1004 }],
1005 ..Config::default()
1006 };
1007 let talk = talk::begin(&store, &cfg, tmp.path().to_path_buf(), Some("mock")).expect("talk");
1008 (tmp, store, talk)
1009 }
1010
1011 fn task(source: Source) -> Task {
1012 let mut t = Task::new(
1013 "t".to_owned(),
1014 "Do it".to_owned(),
1015 PathBuf::from("/repo"),
1016 source,
1017 );
1018 t.start(RUN.to_owned());
1019 t
1020 }
1021
1022 fn from_chat(talk: &Talk) -> Task {
1023 task(Source::Agent {
1024 run: talk.id.clone(),
1025 node: CHAT_NODE.to_owned(),
1026 })
1027 }
1028
1029 fn question(node: &str) -> Question {
1030 Question::new(
1031 RUN.to_owned(),
1032 node.to_owned(),
1033 "impl-A".to_owned(),
1034 "Which backend?".to_owned(),
1035 "SQLite is simpler.".to_owned(),
1036 vec!["SQLite".to_owned(), "Redis".to_owned()],
1037 )
1038 }
1039
1040 #[tokio::test]
1041 async fn start_turn_is_busy_and_writes_nothing_while_the_lease_is_held() {
1042 let (tmp, store, talk) = talks();
1043 let questions = Questions::at(tmp.path().join("questions"));
1044 let mut q = question("implement");
1045 questions.put(&mut q).expect("put");
1046 let held = store.claim_turn(&talk.id).expect("claim").expect("first");
1047
1048 let got = start_turn(&questions, &store, &q, &talk, &Config::default())
1049 .await
1050 .expect("start");
1051 assert_eq!(got, Started::Busy);
1052 assert!(questions.get(&q.id).unwrap().consult.is_none());
1053 assert!(store.get(&talk.id).unwrap().pending.is_empty());
1054 drop(held);
1055 }
1056
1057 fn scripted(tmp: &std::path::Path, script: &str, lease: &std::path::Path) -> Config {
1060 let path = tmp.join("mock-consult-agent.sh");
1061 std::fs::write(&path, script).expect("write mock");
1062 Config {
1063 agents: vec![AgentSpec {
1064 id: "mock".to_owned(),
1065 kind: AgentKind::Command,
1066 model: None,
1067 command: vec!["sh".to_owned(), path.to_string_lossy().into_owned()],
1068 extra_args: Vec::new(),
1069 env: BTreeMap::from([("LEASE".to_owned(), lease.to_string_lossy().into_owned())]),
1070 prompt_delivery: None,
1071 max_turn_secs: None,
1072 }],
1073 ..Config::default()
1074 }
1075 }
1076
1077 async fn run_turn(
1080 script: &str,
1081 ) -> (
1082 tempfile::TempDir,
1083 Result<Started>,
1084 Questions,
1085 Question,
1086 Talk,
1087 ) {
1088 crate::run::set_home(crate::run::test_home());
1089 let (tmp, store, talk) = talks();
1090 let questions = Questions::at(tmp.path().join("questions"));
1091 let mut q = question("implement");
1092 questions.put(&mut q).expect("put");
1093 let cfg = scripted(tmp.path(), script, &store.turn_path(&talk.id));
1094 let got = start_turn(&questions, &store, &q, &talk, &cfg).await;
1095 (tmp, got, questions, q, talk)
1096 }
1097
1098 #[tokio::test]
1099 async fn the_lease_is_held_during_the_turn_and_free_once_it_ends() {
1100 let (tmp, got, _questions, _q, talk) =
1102 run_turn("#!/bin/sh\ncat >/dev/null\n[ -f \"$LEASE\" ] || exit 7\nprintf 'ok\\n'\n")
1103 .await;
1104 assert_eq!(got.expect("turn"), Started::Answered);
1105 let other = Talks::at(tmp.path().join("talks"));
1106 assert!(
1107 other.claim_turn(&talk.id).expect("claim").is_some(),
1108 "free once the turn ended"
1109 );
1110 }
1111
1112 #[tokio::test]
1113 async fn the_lease_is_free_again_after_a_failed_turn() {
1114 let (tmp, got, questions, q, talk) = run_turn("#!/bin/sh\ncat >/dev/null\nexit 3\n").await;
1115 assert!(got.is_err(), "the agent failed");
1116 assert!(
1117 questions.get(&q.id).unwrap().consult.is_some(),
1118 "the turn got as far as running"
1119 );
1120 let other = Talks::at(tmp.path().join("talks"));
1121 assert!(
1122 other.claim_turn(&talk.id).expect("claim").is_some(),
1123 "free after the failed turn"
1124 );
1125 }
1126
1127 #[test]
1128 fn origin_talk_is_decided_in_one_table() {
1129 let (_tmp, _store, talk) = talks();
1130 let mut closed = talk.clone();
1131 closed.status = TalkStatus::Closed;
1132 let chat = from_chat(&talk);
1133 let q = question("implement");
1134
1135 let hit = |tasks: &[Task], talks: &[Talk], q: &Question| origin_talk(tasks, talks, q);
1136 assert_eq!(
1137 hit(std::slice::from_ref(&chat), std::slice::from_ref(&talk), &q).map(|t| t.id),
1138 Some(talk.id.clone())
1139 );
1140 assert!(hit(&[task(Source::Human)], std::slice::from_ref(&talk), &q).is_none());
1142 let other = task(Source::Agent {
1143 run: talk.id.clone(),
1144 node: "implement".to_owned(),
1145 });
1146 assert!(hit(&[other], std::slice::from_ref(&talk), &q).is_none());
1147 assert!(hit(std::slice::from_ref(&chat), &[], &q).is_none());
1149 assert!(hit(std::slice::from_ref(&chat), &[closed], &q).is_some());
1151 assert!(hit(&[], std::slice::from_ref(&talk), &q).is_none());
1153 assert!(
1155 hit(
1156 std::slice::from_ref(&chat),
1157 std::slice::from_ref(&talk),
1158 &question(crate::land::APPROVAL_NODE)
1159 )
1160 .is_some()
1161 );
1162 assert!(
1163 hit(
1164 std::slice::from_ref(&chat),
1165 std::slice::from_ref(&talk),
1166 &question(crate::bump::NOTICE_NODE)
1167 )
1168 .is_none()
1169 );
1170 let mut answered = question("implement");
1172 answered
1173 .answer(crate::ask::Answer::Choice("Redis".to_owned()))
1174 .unwrap();
1175 assert!(hit(&[chat], &[talk], &answered).is_none());
1176 }
1177
1178 #[test]
1179 fn associated_chat_remains_navigable_after_consultation_and_settlement() {
1180 let (tmp, store, mut talk) = talks();
1181 let chat = from_chat(&talk);
1182 let tasks = std::slice::from_ref(&chat);
1183 let mut q = question("implement");
1184 let questions = Questions::at(tmp.path().join("questions"));
1185 questions.put(&mut q).unwrap();
1186 assert!(begin(&questions, &store, &q, &talk).unwrap());
1187 q = questions.get(&q.id).unwrap();
1188 let associated = |q: &Question, talk: &Talk| {
1189 associated_talk(tasks, std::slice::from_ref(talk), q).map(|t| t.id)
1190 };
1191 assert_eq!(associated(&q, &talk), Some(talk.id.clone()));
1192 assert!(q.status.open());
1193 assert!(q.answer.is_none());
1194 assert!(origin_talk(tasks, std::slice::from_ref(&talk), &q).is_some());
1195 q.answer(crate::ask::Answer::Choice("Redis".into()))
1196 .unwrap();
1197 assert_eq!(associated(&q, &talk), Some(talk.id.clone()));
1198 assert!(origin_talk(tasks, std::slice::from_ref(&talk), &q).is_none());
1199 talk.status = TalkStatus::Closed;
1200 assert_eq!(associated(&q, &talk), Some(talk.id.clone()));
1201 let mut q = question("implement");
1202 q.abandon("expired");
1203 assert_eq!(associated(&q, &talk), Some(talk.id.clone()));
1204 assert!(origin_talk(tasks, std::slice::from_ref(&talk), &q).is_none());
1205 assert!(associated_talk(tasks, &[], &q).is_none());
1206 assert!(associated_talk(&[], std::slice::from_ref(&talk), &q).is_none());
1207 let notice = question(crate::bump::NOTICE_NODE);
1208 assert_eq!(associated(¬ice, &talk), Some(talk.id.clone()));
1209 assert!(origin_talk(tasks, &[talk], ¬ice).is_none());
1210 }
1211
1212 #[test]
1213 fn approval_origin_requires_an_open_chat_task_and_question() {
1214 let (_tmp, _store, talk) = talks();
1215 let chat = from_chat(&talk);
1216 let mut q = question(crate::land::APPROVAL_NODE);
1217 assert!(origin_talk(&[task(Source::Human)], std::slice::from_ref(&talk), &q).is_none());
1218 assert!(
1219 origin_talk(
1220 &[task(Source::Agent {
1221 run: talk.id.clone(),
1222 node: "implement".into(),
1223 })],
1224 std::slice::from_ref(&talk),
1225 &q
1226 )
1227 .is_none()
1228 );
1229 assert!(origin_talk(std::slice::from_ref(&chat), &[], &q).is_none());
1230 let mut closed = talk.clone();
1231 closed.status = TalkStatus::Closed;
1232 assert!(origin_talk(std::slice::from_ref(&chat), &[closed], &q).is_some());
1233 q.abandon("expired");
1234 assert!(
1235 origin_talk(std::slice::from_ref(&chat), std::slice::from_ref(&talk), &q).is_none()
1236 );
1237 let mut q = question(crate::land::APPROVAL_NODE);
1238 q.answer(crate::ask::Answer::Choice("Redis".into()))
1239 .unwrap();
1240 assert!(origin_talk(&[chat], &[talk], &q).is_none());
1241 }
1242
1243 #[test]
1244 fn a_conductor_question_is_found_through_the_task_id() {
1245 let (_tmp, _store, talk) = talks();
1246 let chat = from_chat(&talk);
1247 let mut q = question(crate::conduct::NODE);
1248 q.run = chat.id.clone();
1249 assert!(origin_talk(&[chat], &[talk], &q).is_some());
1250 }
1251
1252 fn followup_of(parent: &Task, n: u32) -> Task {
1253 let mut t = Task::new(
1254 "f".to_owned(),
1255 "Fix".to_owned(),
1256 PathBuf::from("/repo"),
1257 Source::Agent {
1258 run: format!("merged-{n}"),
1259 node: "followup".to_owned(),
1260 },
1261 );
1262 t.start(format!("run-f{n}"));
1263 t.followup = Some(crate::queue::FollowUp {
1264 run: format!("merged-{n}"),
1265 origin_task: Some(parent.id.clone()),
1266 pr: "https://example.invalid/pr/1".to_owned(),
1267 findings: Vec::new(),
1268 generation: n,
1269 });
1270 t
1271 }
1272
1273 fn q_for(t: &Task) -> Question {
1274 let mut q = question("implement");
1275 q.run = t.runs[0].clone();
1276 q
1277 }
1278
1279 #[test]
1280 fn a_followup_traces_back_to_the_chat() {
1281 let (_tmp, _store, talk) = talks();
1282 let mut chat = from_chat(&talk);
1283 chat.runs = vec!["chat-run".to_owned()];
1284 let f1 = followup_of(&chat, 1);
1285 let f2 = followup_of(&f1, 2);
1286 let ts = [chat, f1.clone(), f2.clone()];
1287 let id = Some(talk.id.clone());
1288 let tk = std::slice::from_ref(&talk);
1289 assert_eq!(origin_talk(&ts, tk, &q_for(&f1)).map(|t| t.id), id);
1290 assert_eq!(origin_talk(&ts, tk, &q_for(&f2)).map(|t| t.id), id);
1291 }
1292
1293 #[test]
1294 fn a_followup_without_origin_task_is_found_through_runs() {
1295 let (_tmp, _store, talk) = talks();
1296 let mut chat = from_chat(&talk);
1297 chat.runs = vec!["merged-1".to_owned()];
1298 let mut f1 = followup_of(&chat, 1);
1299 f1.followup.as_mut().unwrap().origin_task = None;
1300 let q = q_for(&f1);
1301 assert!(origin_talk(&[chat, f1], &[talk], &q).is_some());
1302 }
1303
1304 #[test]
1305 fn a_dangling_origin_task_has_no_chat() {
1306 let (_tmp, _store, talk) = talks();
1307 let mut chat = from_chat(&talk);
1308 chat.runs = vec!["chat-run".to_owned()];
1309 let mut f1 = followup_of(&chat, 1);
1310 f1.followup.as_mut().unwrap().origin_task = Some("gone".to_owned());
1311 let q = q_for(&f1);
1312 assert!(origin_talk(&[chat, f1], &[talk], &q).is_none());
1313 }
1314
1315 #[test]
1316 fn a_recorded_chat_survives_deleted_ancestors() {
1317 let (_tmp, _store, talk) = talks();
1318 let chat = from_chat(&talk);
1319 assert_eq!(chat.origin_chat.as_deref(), Some(talk.id.as_str()));
1320 let mut f1 = followup_of(&chat, 1);
1321 f1.origin_chat = chat.origin_chat.clone();
1322 let mut f2 = followup_of(&f1, 2);
1323 f2.origin_chat = f1.origin_chat.clone();
1324 let q = q_for(&f2);
1326 let got = origin_talk(std::slice::from_ref(&f2), std::slice::from_ref(&talk), &q);
1327 assert_eq!(got.map(|t| t.id), Some(talk.id.clone()));
1328 assert!(origin_talk(&[f2], &[], &q).is_none());
1330 }
1331
1332 #[test]
1333 fn a_task_without_the_field_still_walks_the_ancestry() {
1334 let (_tmp, _store, talk) = talks();
1335 let mut chat = from_chat(&talk);
1336 chat.origin_chat = None;
1337 let mut f1 = followup_of(&chat, 1);
1338 f1.origin_chat = None;
1339 let q = q_for(&f1);
1340 assert!(origin_talk(&[chat, f1.clone()], std::slice::from_ref(&talk), &q).is_some());
1341 let mut v = serde_json::to_value(&f1).unwrap();
1343 v.as_object_mut().unwrap().remove("origin_chat");
1344 let back: Task = serde_json::from_value(v).unwrap();
1345 assert!(back.origin_chat.is_none());
1346 }
1347
1348 #[test]
1349 fn a_followup_cycle_ends_without_a_chat() {
1350 let (_tmp, _store, talk) = talks();
1351 let mut a = followup_of(&from_chat(&talk), 1);
1352 let mut b = followup_of(&a, 2);
1353 a.followup.as_mut().unwrap().origin_task = Some(b.id.clone());
1354 b.followup.as_mut().unwrap().origin_task = Some(a.id.clone());
1355 let q = q_for(&a);
1356 assert!(origin_talk(&[a, b], &[talk], &q).is_none());
1357 }
1358
1359 #[test]
1360 fn pending_consults_follow_the_store_not_the_turn_text() {
1361 let (tmp, store, talk) = talks();
1362 let questions = Questions::at(tmp.path().join("questions"));
1363 assert!(!pending_consults(&questions, &talk.id), "empty store");
1364
1365 let mut plain = question("implement");
1366 questions.put(&mut plain).unwrap();
1367 assert!(!pending_consults(&questions, &talk.id), "no consult record");
1368
1369 let mut q = question("implement");
1370 questions.put(&mut q).unwrap();
1371 begin(&questions, &store, &q, &talk).unwrap();
1372 assert!(pending_consults(&questions, &talk.id));
1373 assert!(!pending_consults(&questions, "other-talk"));
1374
1375 questions
1376 .update(&q.id, |q| {
1377 q.abandon("test");
1378 Ok(())
1379 })
1380 .unwrap();
1381 assert!(!pending_consults(&questions, &talk.id), "closed question");
1382 }
1383
1384 #[test]
1385 fn begin_reopens_a_closed_chat_before_queueing() {
1386 let (tmp, store, mut talk) = talks();
1387 let questions = Questions::at(tmp.path().join("questions"));
1388 let mut q = question("implement");
1389 questions.put(&mut q).unwrap();
1390 talk::close(&mut talk, &store).unwrap();
1391 assert!(!store.get(&talk.id).unwrap().status.open());
1392
1393 assert!(begin(&questions, &store, &q, &talk).unwrap());
1394
1395 let after = store.get(&talk.id).unwrap();
1396 assert!(after.status.open(), "the chat was reopened");
1397 assert!(after.pending.contains(&q.id), "{}", after.pending);
1398 assert!(questions.get(&q.id).unwrap().consult.is_some());
1399 }
1400
1401 #[test]
1402 fn begin_queues_once_and_leaves_the_question_open() {
1403 let (tmp, store, talk) = talks();
1404 let questions = Questions::at(tmp.path().join("questions"));
1405 let mut q = question("implement");
1406 questions.put(&mut q).unwrap();
1407
1408 assert!(begin(&questions, &store, &q, &talk).unwrap());
1409 assert!(!begin(&questions, &store, &q, &talk).unwrap(), "idempotent");
1410
1411 let after = questions.get(&q.id).unwrap();
1412 assert!(after.status.open());
1413 assert!(after.thread.is_empty());
1414 assert_eq!(after.choices, q.choices, "choices are not touched");
1415 assert_eq!(
1416 after.consult.as_ref().map(|c| c.talk.as_str()),
1417 Some(talk.id.as_str())
1418 );
1419
1420 let destination = store.get(&talk.id).unwrap();
1421 assert_eq!(destination.pending_messages.len(), 1);
1422 assert_eq!(
1423 destination.pending_messages[0].display,
1424 Some(crate::prompt::chat_consult_display(&q))
1425 );
1426 let queued = destination.pending;
1427 assert_eq!(
1428 queued.matches(&q.id).count(),
1429 1 + 3,
1430 "id once per use: {queued}"
1431 );
1432 assert!(queued.contains("Which backend?"));
1433 assert!(queued.contains("SQLite is simpler."));
1434 assert!(queued.contains("- Redis"));
1435 assert_eq!(
1437 queued.matches(crate::prompt::CHAT_CONSULT_HEADING).count(),
1438 1
1439 );
1440 }
1441
1442 #[test]
1443 fn chat_consult_has_each_marker_once() {
1444 use crate::prompt::{CHAT_CONSULT_END as E, CHAT_CONSULT_HEADING as H};
1445 for node in ["implement", crate::land::APPROVAL_NODE] {
1446 let mut q = question(node);
1447 let quoted = format!("{H} {E}");
1448 q.summary = quoted.clone();
1449 q.detail = quoted.clone();
1450 q.choices = vec![quoted.clone(), "b".into()];
1451 let s = crate::prompt::chat_consult(&q);
1452 assert_eq!(s.matches(H).count(), 1, "{s}");
1453 assert_eq!(s.matches(E).count(), 1, "{s}");
1454 }
1455 }
1456
1457 fn record(questions: &Questions, id: &str, reply: &str) -> Result<Question> {
1459 questions
1460 .update(id, |r| {
1461 let label = r.resolve_reply(reply)?.to_owned();
1462 r.answer(crate::ask::Answer::Choice(label))
1463 })
1464 .map(|(q, ())| q)
1465 }
1466
1467 #[test]
1468 fn a_consulted_posting_question_records_the_chats_choice_without_a_quote() {
1469 use crate::github_text::{ASK_NODE, POST_ORIGINAL, USE_FALLBACK, USE_REWRITE};
1470 for choice in [POST_ORIGINAL, USE_REWRITE] {
1471 let (tmp, store, talk) = talks();
1472 let questions = Questions::at(tmp.path().join("questions"));
1473 let mut q = question(ASK_NODE);
1474 q.choices = vec![USE_FALLBACK.into(), choice.into()];
1475 questions.put(&mut q).unwrap();
1476 assert!(begin(&questions, &store, &q, &talk).unwrap());
1477 let queued = store.get(&talk.id).unwrap().pending;
1478 assert!(queued.contains("magi answer"), "{queued}");
1479 assert!(!queued.contains("--quote"), "{queued}");
1480 let other = if choice == POST_ORIGINAL {
1481 USE_REWRITE
1482 } else {
1483 POST_ORIGINAL
1484 };
1485 assert!(record(&questions, &q.id, other).is_err(), "not offered");
1486 let answered = record(&questions, &q.id, choice).unwrap();
1487 assert_eq!(answered.resolution().as_deref(), Some(choice));
1488 assert!(
1489 record(&questions, &q.id, USE_FALLBACK).is_err(),
1490 "a question is answered once"
1491 );
1492 }
1493 }
1494
1495 #[test]
1496 fn merge_repair_consult_records_only_an_offered_choice() {
1497 use crate::land::{APPROVAL_NODE, APPROVE, HOLD, REPAIR};
1498 let (tmp, store, talk) = talks();
1499 let questions = Questions::at(tmp.path().join("questions"));
1500 let mut q = question(APPROVAL_NODE);
1501 q.choices = vec![APPROVE.into(), HOLD.into()];
1502 questions.put(&mut q).unwrap();
1503 begin(&questions, &store, &q, &talk).unwrap();
1504 assert!(record(&questions, &q.id, REPAIR).is_err(), "not offered");
1505 questions
1506 .update(&q.id, |r| {
1507 r.choices.push(REPAIR.into());
1508 Ok(())
1509 })
1510 .unwrap();
1511 let answered = record(&questions, &q.id, REPAIR).unwrap();
1512 assert_eq!(answered.resolution().as_deref(), Some(REPAIR));
1513 }
1514
1515 #[test]
1516 fn approval_consult_records_the_chats_merge_answer() {
1517 let (tmp, store, talk) = talks();
1518 let questions = Questions::at(tmp.path().join("questions"));
1519 let mut q = question(crate::land::APPROVAL_NODE);
1520 q.choices = vec![crate::land::APPROVE.into(), crate::land::HOLD.into()];
1521 questions.put(&mut q).unwrap();
1522 assert!(begin(&questions, &store, &q, &talk).unwrap());
1523 assert!(!begin(&questions, &store, &q, &talk).unwrap());
1524 q = questions.get(&q.id).unwrap();
1525 assert!(q.status.open());
1526 assert!(q.answer.is_none());
1527 assert!(q.thread.is_empty());
1528 let queued = store.get(&talk.id).unwrap().pending;
1529 assert!(queued.contains("Silence holds"), "{queued}");
1530 assert!(queued.contains("--reply merge"), "{queued}");
1531 assert!(!queued.contains("--quote"), "{queued}");
1532
1533 let answered = record(&questions, &q.id, crate::land::APPROVE).unwrap();
1536 assert_eq!(answered.resolution().as_deref(), Some(crate::land::APPROVE));
1537 assert!(record(&questions, &q.id, crate::land::HOLD).is_err());
1538
1539 let mut expired = question(crate::land::APPROVAL_NODE);
1541 expired.choices = q.choices.clone();
1542 expired.abandon("approval expired or head changed");
1543 assert!(
1544 expired
1545 .answer(crate::ask::Answer::Choice(crate::land::APPROVE.into()))
1546 .is_err()
1547 );
1548 }
1549
1550 #[test]
1551 fn reply_other_is_a_note_that_ends_only_the_consultation() {
1552 let (tmp, store, talk) = talks();
1553 let questions = Questions::at(tmp.path().join("questions"));
1554 let mut q = question("implement");
1555 questions.put(&mut q).unwrap();
1556 assert!(
1557 questions
1558 .update(&q.id, |r| record_other(r, "Use Postgres"))
1559 .is_err(),
1560 "nothing to end before consultation"
1561 );
1562 begin(&questions, &store, &q, &talk).unwrap();
1563 assert!(pending_consults(&questions, &talk.id));
1564 for refused in ["", " ", "SQLite"] {
1565 assert!(
1566 questions
1567 .update(&q.id, |r| record_other(r, refused))
1568 .is_err(),
1569 "{refused:?}"
1570 );
1571 }
1572 questions
1573 .update(&q.id, |r| record_other(r, "Use Postgres"))
1574 .unwrap();
1575 let after = questions.get(&q.id).unwrap();
1576 assert!(after.status.open());
1577 assert!(after.answer.is_none());
1578 let outcome = after.consult_outcome.unwrap();
1579 assert_eq!(outcome.text, "Use Postgres");
1580 assert_eq!(outcome.talk, talk.id);
1581 assert!(!pending_consults(&questions, &talk.id));
1582 }
1583
1584 #[test]
1585 fn begin_withdraws_its_record_when_the_talk_cannot_take_it() {
1586 let (tmp, store, talk) = talks();
1587 let questions = Questions::at(tmp.path().join("questions"));
1588 let mut q = question("implement");
1589 questions.put(&mut q).unwrap();
1590 std::fs::remove_dir_all(tmp.path().join("talks")).unwrap();
1591 assert!(begin(&questions, &store, &q, &talk).is_err());
1592 assert!(questions.get(&q.id).unwrap().consult.is_none());
1593 }
1594
1595 #[test]
1596 fn an_older_question_file_reads_without_a_consult() {
1597 let mut q = question("implement");
1598 q.schema = 5;
1599 let mut v = serde_json::to_value(&q).unwrap();
1600 v.as_object_mut().unwrap().remove("consult");
1601 let back: Question = serde_json::from_value(v).unwrap();
1602 assert!(back.consult.is_none());
1603 }
1604}