Skip to main content

magi/
consult.rs

1//! Handing an open question to the chat conversation its task came from.
2//!
3//! A task filed by the standing chat (`Source::Agent` with
4//! [`crate::queue::CHAT_NODE`], whose `run` is the conversation's id) can raise
5//! a question the owner would rather talk through where the task started. The
6//! owner passes it on; the chat agent carries out the owner's decision with
7//! `magi answer`, or puts the decision points to the owner in the conversation.
8//!
9//! Three rules, each with a reason:
10//!
11//! - **One place decides.** [`origin_talk`] is the only judge of "does this
12//!   question have a chat to ask"; the web view and both entry points use it.
13//!   [`associated_talk`] resolves typed ownership links for navigation,
14//!   independently of consultation eligibility.
15//! - **Not a choice.** The hand-over never goes into `Question::choices` and
16//!   never touches `Question::answer`: the question stays `Open`, its
17//!   validation and the choice actions stay as they were.
18//! - **No new seat, no new waiter.** The text is queued as a draft of the
19//!   existing talk, so the chat's own turn machinery (its session, its turn
20//!   gate) runs it exactly as if the owner had typed it.
21
22use anyhow::{Context, Result, bail};
23use jiff::Timestamp;
24
25use crate::ask::{ChatConsult, Question, Questions};
26use crate::config::Config;
27use crate::queue::Task;
28use crate::talk::{self, Talk, Talks};
29
30/// The conversation `q` may be handed to, if any.
31///
32/// Requires an open question whose task was filed from a chat. The chat may
33/// be closed: [`begin`] reopens it. Release notices are left out.
34pub 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
43/// The existing conversation associated with a question, regardless of whether
44/// the question is still eligible for consultation.
45pub fn associated_talk(tasks: &[Task], talks: &[Talk], q: &Question) -> Option<Talk> {
46    associated_talk_with_watches(tasks, talks, q, &[])
47}
48
49/// A typed ownership link, shared by association and the web's selective loader.
50#[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    // Review-start reconciliation is filed under a task id, unlike normal
61    // review questions; both the node and seat identify that producer.
62    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    // Duplicate records are ambiguous even if they currently name one owner.
73    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
82/// Explicit links may fill a missing owner, but conflicting owners fail closed.
83/// This navigation-only resolution never changes consultation eligibility.
84pub(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
106/// The talk `start` descends from: the id recorded on a task
107/// ([`Task::chat_talk`]) wins, so deleted ancestors do not matter; tasks
108/// written before it existed are resolved by walking the ancestry.
109///
110/// Walk the provenance of `start` to the task a chat filed.
111///
112/// A follow-up goes to the task its merged run served: `FollowUp::origin_task`
113/// when recorded (a missing one ends the walk - guessing another task could
114/// hand the question to an unrelated chat), else the task whose `runs` hold
115/// `FollowUp::run`. At most `MAX_FOLLOWUP_GENERATION + 1` tasks are looked at,
116/// each once, so a cycle ends in `None`.
117pub 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
136/// Is any question handed to talk `talk_id` still open?
137///
138/// Decided from the question store, not from the newest turn's wording: the
139/// owner's decision usually arrives in a later turn that carries no hand-over
140/// heading. Answered or abandoned questions and ended consultations stop
141/// counting.
142pub fn pending_consults(questions: &Questions, talk_id: &str) -> bool {
143    questions.list().iter().any(|q| active_consult(q, talk_id))
144}
145
146/// Consultation activity is separate from the question's waiting state.
147pub 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/// A non-answer never wakes a waiter or executes a choice action.
155#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
156pub struct ConsultOutcome {
157    /// The owner's outcome, with no execution effect.
158    pub text: String,
159    /// Originating conversation.
160    pub talk: String,
161    /// Time consultation ended.
162    pub at: Timestamp,
163}
164
165/// Store the owner's alternative as a note and end only the consultation:
166/// the question stays open and its waiter stays held.
167pub 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
184/// `magi answer --reply-other` on question `id` (a full id): [`record_other`]
185/// under the store's lock, then what to print. Shared by the terminal's direct
186/// path and a chat turn's control bridge.
187pub 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
195/// Record the hand-over and queue the question as the chat's next message.
196///
197/// Returns `false` (and changes nothing) when the question was already handed
198/// over. The question is not otherwise touched: still `Open`, no thread turn.
199/// When queueing fails the record is withdrawn, so the owner can try again.
200pub 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    // Serialize dispatch with answers, but never wait for conversation locks
222    // under the expiring question lock. Each retry reloads the latest question.
223    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/// What [`start_turn`] did.
262#[derive(Debug, PartialEq, Eq)]
263pub enum Started {
264    /// The question was handed over and the chat's turn ran to its end.
265    Answered,
266    /// Another process holds the talk's turn lease; nothing was changed.
267    Busy,
268    /// The question had already been handed to the chat; nothing was changed.
269    Nothing,
270}
271
272/// [`begin`], then run the chat's turn here, under the talk's own lease.
273///
274/// The lease is taken *before* anything is written, so a talk somebody else is
275/// running leaves no consult record and no draft behind (`Started::Busy`) and
276/// the caller can simply try again later. A turn that fails after `begin`
277/// leaves the record and the draft, so the chat can resume it.
278pub 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
298/// Run the talk's turns under `lease` until no draft is owed.
299///
300/// Other starters that found the lease held queued drafts and left them to
301/// us, so drain until nothing is left (as the web's drain loop does). A failed
302/// turn is kept and reported at the end, not returned at once: drafts accepted
303/// meanwhile are still owed an answer.
304pub 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        // A draft queued after the last drain, before the lease is gone, was
322        // left to us by a starter that found the lease held. Release first,
323        // then look again: whoever queues later either sees no lease (and
324        // starts its own turn) or is seen by this re-check.
325        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        // Somebody else took the lease meanwhile: they drain it.
333        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
344/// Try the short cross-process draft-publication fence for one talk.
345pub 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
349/// Serialize queue/record -> worker acknowledgement against a drainer's
350/// generation scan + promotion. Never hold this fence across an agent call.
351///
352/// Lock order deliberately differs: a drainer owns TurnLease then waits here;
353/// ingress owns publication but only *tries* TurnLease and queues when busy.
354/// Ingress must never wait for TurnLease while holding publication.
355pub 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
364/// Reserve and launch a detached drainer for an explicitly submitted draft.
365///
366/// Every submission has its own durable reservation. A shared key would lose
367/// arrivals between the previous drainer's last read and its job settlement.
368/// The talk's existing turn lease, not the job key, serializes CLI sessions.
369pub 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
373/// Continue an operator turn already persisted by the HTTP admission path.
374/// Reserve this job before releasing the caller's turn lease.
375pub 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
436/// Includes startup reservations, before the child acquires the turn lease.
437pub 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
446/// Task-authored transcript posts do not answer a submitted owner turn.
447fn 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
458/// Hidden worker entry point. The original talk/session and cross-process
459/// lease stay authoritative even when an older worker is still responding.
460pub 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            // Reload the draft after ownership is acquired: a notice must not
491            // turn an unsent operator draft into an automatic submission.
492            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                        // Sequence zero belongs to pre-ordering records. In
506                        // that case relinquish rather than infer from time.
507                        && 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                // A submitted immediate turn is already in the transcript,
536                // but its child has not acquired the lease yet. Do not let a
537                // queued follow-up overtake it during process startup.
538                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            // Exactly one invocation per job. A submission accepted by the
565            // successor UI already owns a successor-generation reservation;
566            // never pull it into this old worker after the current reply.
567            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
623/// Finite service work outlives the scheduler, but never pins a service host
624/// to an obsolete executable. Its next tick is started by the new scheduler.
625pub 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        // Adoption is by operation key, not by the new scheduler's config
635        // payload/schema. An old invocation owns its started request.
636        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            // The original watcher reloaded settings each lap. Do this cheap
644            // admission read here so disabled/idle services never fork a CLI.
645            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
673/// One leased service tick. Existing question leases, seat records, and release
674/// receipts remain the only authorities for delivery and once-only effects.
675pub 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    // Preserve the delay after completion even when the scheduler changes
736    // executable or restarts while this tick is still running.
737    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    /// A `kind = "command"` agent running `script`; `LEASE` names the turn
1058    /// lease file so the script can look at it while the turn is in flight.
1059    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    /// Run `start_turn` with `script` as the agent and hand back its result,
1078    /// the store and the talk, for the caller to claim the lease afterwards.
1079    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        // exit 7 if the lease file is missing while the agent runs.
1101        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        // Not filed from a chat.
1141        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        // The conversation is gone, or no longer takes turns.
1148        assert!(hit(std::slice::from_ref(&chat), &[], &q).is_none());
1149        // A closed conversation is reopened by `begin`, so it still qualifies.
1150        assert!(hit(std::slice::from_ref(&chat), &[closed], &q).is_some());
1151        // No task owns the question.
1152        assert!(hit(&[], std::slice::from_ref(&talk), &q).is_none());
1153        // Merge approvals can be consulted; release notices cannot.
1154        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        // A settled question has nothing left to ask.
1171        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(&notice, &talk), Some(talk.id.clone()));
1209        assert!(origin_talk(tasks, &[talk], &notice).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        // Neither the chat task nor f1 is in the queue any more.
1325        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        // The recorded id still has to name an open chat.
1329        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        // And the old JSON shape reads with the field absent.
1342        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        // Queued once, so the first-operator-turn title is not involved.
1436        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    /// What `magi answer --reply` does under the question lock.
1458    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        // The chat records the owner's "merge it" as the approval; nothing
1534        // else is needed. The land approval waiter consumes it unchanged.
1535        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        // An expired approval cannot be answered.
1540        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}