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