1use std::collections::HashMap;
45use std::path::PathBuf;
46use std::time::{Duration, Instant};
47
48use anyhow::Result;
49use jiff::Timestamp;
50
51use crate::agent::{self, Invocation, SeatState};
52use crate::ask::{Lease, Question, QuestionStatus, Questions, Waiter as Note, WaiterKind, Who};
53use crate::config::{AgentSpec, Config};
54use crate::notices::{self, Notice};
55use crate::prompt;
56use crate::run::RunState;
57
58pub const TICK: Duration = Duration::from_secs(5);
60
61const DELIVERY_TIMEOUT: Duration = Duration::from_secs(15 * 60);
65
66const RETRY_AFTER: Duration = Duration::from_secs(10 * 60);
70
71#[derive(serde::Serialize, serde::Deserialize)]
72struct Retry {
73 key: String,
74 until: i64,
75}
76
77fn retry_path(store: &Questions, id: &str) -> PathBuf {
78 store.root().join(format!("{id}.waiter-retry.json"))
79}
80
81fn retry_blocked(store: &Questions, id: &str, key: &str, now: Timestamp) -> bool {
82 std::fs::read(retry_path(store, id))
83 .ok()
84 .and_then(|bytes| serde_json::from_slice::<Retry>(&bytes).ok())
85 .is_some_and(|retry| retry.key == key && now.as_second() < retry.until)
86}
87
88#[derive(Debug, Clone, PartialEq, Eq)]
90pub enum Word {
91 Said(String),
93 Answered(String),
95}
96
97#[derive(Debug, Clone, PartialEq, Eq)]
99pub enum Action {
100 Idle,
102 Expire,
104 Deliver(Word),
106}
107
108pub fn decide(
118 q: &Question,
119 lease: Option<&Lease>,
120 seat_busy: bool,
121 default_timeout: u64,
122 now: Timestamp,
123) -> Action {
124 decide_owned(q, lease, seat_busy, default_timeout, now, false)
125}
126
127pub fn decide_owned(
137 q: &Question,
138 lease: Option<&Lease>,
139 seat_busy: bool,
140 default_timeout: u64,
141 now: Timestamp,
142 daemon_owns_action: bool,
143) -> Action {
144 if q.routed_task.is_some() || q.cwd.is_none() || lease.is_some_and(|l| l.fresh(now)) {
145 return Action::Idle;
146 }
147 match q.status {
148 QuestionStatus::Abandoned => Action::Idle,
149 QuestionStatus::Answered => match q.resolution() {
150 _ if q.chosen_action().is_some_and(|a| a.maintenance())
151 || (daemon_owns_action && q.chosen_action().is_some()) =>
152 {
153 Action::Idle
154 }
155 Some(a) if !q.answer_delivered && !seat_busy => Action::Deliver(Word::Answered(a)),
156 _ => Action::Idle,
157 },
158 QuestionStatus::Open => {
159 if let Some(said) = q.unread_from_owner() {
160 if seat_busy {
161 return Action::Idle;
162 }
163 return Action::Deliver(Word::Said(said));
164 }
165 let secs = if q.answer_timeout > 0 {
166 q.answer_timeout
167 } else {
168 default_timeout
169 };
170 let deadline = q.last_activity().saturating_add(secs as i64);
171 if now.as_second() > deadline {
172 Action::Expire
173 } else {
174 Action::Idle
175 }
176 }
177 }
178}
179
180struct Target {
182 coordination: Option<crate::config::Coordination>,
183 fetched_base: Option<crate::run::FetchedBase>,
184 spec: AgentSpec,
185 seat: SeatState,
186 cwd: PathBuf,
187 sessions: bool,
188 allow_write: bool,
189 language: String,
190}
191
192pub struct Waiter {
195 store: Questions,
196 home: PathBuf,
197 conduct_cfg: Option<Config>,
200 default_timeout: u64,
202 memo: HashMap<String, (String, Instant)>,
204}
205
206impl Waiter {
207 pub fn new(store: Questions, home: PathBuf, conduct_cfg: Option<Config>) -> Self {
209 let default_timeout = conduct_cfg
210 .as_ref()
211 .map_or(Config::default().graph.answer_timeout, |c| {
212 c.graph.answer_timeout
213 });
214 Self {
215 store,
216 home,
217 conduct_cfg,
218 default_timeout,
219 memo: HashMap::new(),
220 }
221 }
222
223 pub fn has_work(&self, now: Timestamp) -> bool {
225 self.store.list().iter().any(|q| {
226 let lease = self.store.read_lease(&q.id);
227 match decide_owned(
228 q,
229 lease.as_ref(),
230 false,
231 self.default_timeout,
232 now,
233 self.daemon_owns_action(q),
234 ) {
235 Action::Idle => false,
236 Action::Expire => true,
237 Action::Deliver(_) if q.deputy.is_some() => crate::deputy::exhausted_past_deadline(
238 q,
239 crate::deputy::can_start(self.conduct_cfg.as_ref(), crate::deputy::agent_of(q)),
240 self.default_timeout,
241 now,
242 ),
243 Action::Deliver(word) => {
244 let key = format!("{}:{:?}", q.thread.len(), word);
245 !self.seat_busy(q, now) && !retry_blocked(&self.store, &q.id, &key, now)
246 }
247 }
248 })
249 }
250
251 fn seat_busy(&self, q: &Question, now: Timestamp) -> bool {
253 if q.run == crate::conduct::NODE {
254 return crate::conduct::busy(&self.home);
255 }
256 RunState::load_under(&q.run, &self.home).is_ok_and(|s| {
257 s.seats_active()
258 .any(|(k, a)| *k == q.seat && a.remaining_secs(now) > 0)
259 })
260 }
261
262 fn daemon_owns_action(&self, q: &Question) -> bool {
265 q.chosen_action().is_some_and(|a| a.maintenance())
266 || crate::daemon::task_of_question(
267 &crate::queue::Queue::at(self.home.join("queue")).list(),
268 q,
269 )
270 .is_some_and(|t| crate::daemon::action_standing(t, q).daemon_owns())
271 }
272
273 pub async fn tick(&mut self, now: Timestamp, halt: &(dyn Fn() -> bool + Sync)) {
278 for q in self.store.list() {
279 if halt() {
280 return;
281 }
282 if crate::task_chat::routes(&q, &self.home) {
283 continue;
284 }
285 let lease = self.store.read_lease(&q.id);
286 let owned = self.daemon_owns_action(&q);
287 match decide_owned(&q, lease.as_ref(), false, self.default_timeout, now, owned) {
288 Action::Idle => {}
289 Action::Expire => self.expire(&q),
290 Action::Deliver(word) => {
291 if q.deputy.is_some() {
295 if crate::deputy::exhausted_past_deadline(
299 &q,
300 crate::deputy::can_start(
301 self.conduct_cfg.as_ref(),
302 crate::deputy::agent_of(&q),
303 ),
304 self.default_timeout,
305 now,
306 ) {
307 self.expire(&q);
308 }
309 continue;
310 }
311 if self.seat_busy(&q, now) {
312 continue;
313 }
314 let key = format!("{}:{:?}", q.thread.len(), word);
315 if retry_blocked(&self.store, &q.id, &key, now)
316 || matches!(self.memo.get(&q.id), Some((k, until)) if *k == key && Instant::now() < *until)
317 {
318 continue;
319 }
320 if let Err(e) = self.deliver(&q, &word, halt).await {
321 tracing::warn!("question {}: {e:#}", q.short());
322 }
323 }
324 }
325 }
326 }
327
328 fn expire(&self, q: &Question) {
331 let secs = if q.answer_timeout > 0 {
332 q.answer_timeout
333 } else {
334 self.default_timeout
335 };
336 let startable =
337 crate::deputy::can_start(self.conduct_cfg.as_ref(), crate::deputy::agent_of(q));
338 let why = format!("no answer within {}s of asking", secs.max(1));
339 let done = self.store.update(&q.id, |r| {
340 let now = Timestamp::now();
344 let lease = self.store.read_lease(&r.id);
345 if decide(r, lease.as_ref(), false, self.default_timeout, now) != Action::Expire
346 && !(lease.as_ref().is_none_or(|l| !l.fresh(now))
347 && crate::deputy::exhausted_past_deadline(
348 r,
349 startable,
350 self.default_timeout,
351 now,
352 ))
353 {
354 return Ok(false);
355 }
356 r.abandon(&why);
357 r.waiter = None;
358 Ok(true)
359 });
360 match done {
361 Ok((_, false)) => {}
362 Ok((_, true)) => {
363 self.store.drop_lease(&q.id);
364 tracing::warn!(
365 "question {} went unanswered for {secs}s with nobody waiting; \
366 it stays as the record of it",
367 q.short()
368 );
369 }
370 Err(e) => tracing::warn!("could not expire question {}: {e:#}", q.short()),
371 }
372 }
373
374 pub fn can_resume(&self, q: &Question) -> bool {
376 self.target(q).is_ok()
377 }
378
379 fn target(&self, q: &Question) -> std::result::Result<Target, String> {
381 let cwd = q
382 .cwd
383 .as_deref()
384 .map(PathBuf::from)
385 .filter(|p| p.is_dir())
386 .ok_or("the directory the agent was working in is gone")?;
387 let (cfg, seat, allow_write, fetched_base) = if q.run == crate::conduct::NODE {
388 let cfg = self
389 .conduct_cfg
390 .clone()
391 .ok_or("the conductor's configuration is not available")?;
392 let seat = crate::conduct::load_seat(&self.home)
393 .ok_or("the conductor has no recorded session")?;
394 (cfg, seat, false, None)
395 } else {
396 let state = RunState::load_under(&q.run, &self.home)
397 .map_err(|_| "the run that asked is gone".to_owned())?;
398 if !state.status.resumable() {
399 return Err(format!(
400 "the run that asked has already {}",
401 state.status.as_str()
402 ));
403 }
404 let seat = state
405 .seats
406 .get(&q.seat)
407 .cloned()
408 .ok_or("the run has no record of the asking seat")?;
409 let write = matches!(q.node.as_str(), "implement" | "fix");
410 (state.config, seat, write, state.fetched_base)
411 };
412 let spec = cfg
413 .agent(&seat.agent)
414 .map_err(|_| format!("agent `{}` is no longer in the roster", seat.agent))?
415 .clone();
416 if !agent::has_session(spec.kind, &seat, cfg.graph.sessions) {
417 return Err("the seat has no session to resume".to_owned());
418 }
419 Ok(Target {
420 coordination: cfg.coordination.clone(),
421 fetched_base,
422 spec,
423 seat,
424 cwd,
425 sessions: cfg.graph.sessions,
426 allow_write,
427 language: cfg.graph.language.clone(),
428 })
429 }
430
431 fn refuse(&mut self, q: &Question, key: String, why: &str) {
433 tracing::warn!("question {} cannot be delivered: {why}", q.short());
434 notices::raise_in(
435 &self.home,
436 Notice::warn(
437 &format!("question:{}", q.id),
438 format!(
439 "Question {} \"{}\": what you said cannot reach the agent \
440 ({why}). It is recorded, but nothing will read it.",
441 q.short(),
442 q.summary
443 ),
444 )
445 .link(if q.run_names_task() {
446 notices::Link::Task { id: q.run.clone() }
447 } else {
448 notices::Link::Run { id: q.run.clone() }
449 }),
450 );
451 let _ = self.store.update(&q.id, |r| {
452 r.waiter = None;
453 Ok(())
454 });
455 let retry = Retry {
456 key: key.clone(),
457 until: Timestamp::now()
458 .as_second()
459 .saturating_add(RETRY_AFTER.as_secs() as i64),
460 };
461 let written = serde_json::to_vec(&retry)
462 .map_err(anyhow::Error::from)
463 .and_then(|bytes| {
464 std::fs::write(retry_path(&self.store, &q.id), bytes).map_err(Into::into)
465 });
466 if let Err(error) = written {
467 tracing::warn!(
468 "question {} retry cooldown could not be saved: {error:#}",
469 q.short()
470 );
471 }
472 self.memo
473 .insert(q.id.clone(), (key, Instant::now() + RETRY_AFTER));
474 }
475
476 async fn deliver(
478 &mut self,
479 q: &Question,
480 word: &Word,
481 halt: &(dyn Fn() -> bool + Sync),
482 ) -> Result<()> {
483 let key = format!("{}:{:?}", q.thread.len(), word);
484 let mut target = match self.target(q) {
485 Ok(t) => t,
486 Err(why) => {
487 self.refuse(q, key, &why);
488 return Ok(());
489 }
490 };
491
492 let claim = self.store.root().join(format!("{}.claim", q.id));
493 if !take_claim(&claim, DELIVERY_TIMEOUT + Duration::from_secs(60)) {
494 return Ok(());
495 }
496 let _release = Release(claim);
497
498 let q = self.store.get(&q.id)?;
501 if crate::task_chat::routes(&q, &self.home) {
502 return Ok(());
503 }
504 let now = Timestamp::now();
505 let lease = self.store.read_lease(&q.id);
506 let owned = self.daemon_owns_action(&q);
507 let Action::Deliver(word) =
508 decide_owned(&q, lease.as_ref(), false, self.default_timeout, now, owned)
509 else {
510 return Ok(());
511 };
512 let snapshot = q.thread.len();
513
514 let queue = crate::queue::Queue::at(self.home.join("queue"));
520 let task_id = crate::daemon::task_of_question(&queue.list(), &q).map(|t| t.id.clone());
521 let _task_claim = match (&task_id, q.chosen_action()) {
525 (Some(id), Some(_)) => match queue.claim(id) {
526 Ok(c) => Some(c),
527 Err(e) => {
528 tracing::debug!(
529 "question {}: task claim not available ({e:#}), deferring delivery",
530 q.short()
531 );
532 return Ok(());
533 }
534 },
535 _ => None,
536 };
537 let task_still_ours = || {
540 if q.chosen_action().is_some_and(|a| a.maintenance()) {
541 false
542 } else {
543 match &task_id {
544 Some(id) => queue
545 .get(id)
546 .map(|t| !crate::daemon::action_standing(&t, &q).daemon_owns())
547 .unwrap_or(true),
548 None => true,
549 }
550 }
551 };
552 if !task_still_ours() {
553 return Ok(());
554 }
555
556 self.store.beat(&q.id, WaiterKind::Daemon);
557 if !task_still_ours() {
560 return Ok(());
561 }
562 self.store.update(&q.id, |r| {
563 r.waiter = Some(Note {
564 kind: WaiterKind::Daemon,
565 since: now,
566 });
567 Ok(())
568 })?;
569 tracing::info!(
570 "question {}: the asker is gone, resuming seat {} with the owner's word",
571 q.short(),
572 q.seat
573 );
574
575 let thread: Vec<(&str, &str)> = q
576 .thread
577 .iter()
578 .map(|t| {
579 (
580 if t.who == Who::Operator {
581 "operator"
582 } else {
583 "agent"
584 },
585 t.body.as_str(),
586 )
587 })
588 .collect();
589 let shown = match word {
592 Word::Said(_) => &thread[..q.delivered_turns.min(thread.len())],
593 Word::Answered(_) => &thread[..],
594 };
595 let owner_word = match &word {
596 Word::Said(s) => prompt::OwnerWord::Said(s),
597 Word::Answered(a) => prompt::OwnerWord::Answered(a),
598 };
599 let body = prompt::question_resumed(
600 &q.id,
601 &q.summary,
602 &q.detail,
603 shown,
604 &owner_word,
605 &target.language,
606 );
607
608 let artifacts = self.store.root().join(format!("{}.delivery", q.id));
609 let stem = format!("deliver-{}", now.as_second());
610 let cache_dir = None;
611 let inv = Invocation {
612 scope: crate::agent::InvocationScope::Repository,
613 coordination: target.coordination.as_ref(),
614 fetched_base: target.fetched_base.as_ref(),
615 cwd: &target.cwd,
616 prompt: &body,
617 timeout: DELIVERY_TIMEOUT,
618 allow_write: target.allow_write,
619 unsandboxed: false,
620 sessions: target.sessions,
621 artifacts: &artifacts,
622 stem: &stem,
623 run: &q.run,
624 node: &q.node,
625 cache_dir,
626 attachments: &[],
627 writable: &[],
628 };
629
630 let store = self.store.clone();
631 let id = q.id.clone();
632 let out = {
633 let fut = agent::invoke(&target.spec, &mut target.seat, &inv);
634 tokio::pin!(fut);
635 let mut beat = tokio::time::interval(Duration::from_secs(1));
636 let mut beats = 0u32;
637 loop {
638 tokio::select! {
639 r = &mut fut => break Some(r),
640 _ = beat.tick() => {
641 if halt() {
642 break None;
643 }
644 beats += 1;
645 if beats % 20 == 0 {
646 store.beat(&id, WaiterKind::Daemon);
647 }
648 }
649 }
650 }
651 };
652
653 let Some(out) = out else {
654 let _ = self.store.update(&q.id, |r| {
656 r.waiter = None;
657 Ok(())
658 });
659 return Ok(());
660 };
661 let out = match out {
662 Ok(o) if o.usable() => o,
663 other => {
664 let why = match other {
665 Ok(o) if o.timed_out => "the resumed turn timed out".to_owned(),
666 Ok(o) if o.quota_exhausted() => "the agent is out of quota".to_owned(),
667 Ok(o) => format!(
668 "the resumed turn failed ({})",
669 notices::exit_status(o.exit_code)
670 ),
671 Err(e) => format!("the agent could not be started: {e:#}"),
672 };
673 self.refuse(&q, key, &why);
674 return Ok(());
675 }
676 };
677
678 let text = out.text.trim().to_owned();
679 self.store.update(&q.id, |r| {
680 r.waiter = None;
681 match &word {
682 Word::Answered(_) => r.answer_delivered = true,
683 Word::Said(_) => {
684 r.delivered_turns = r.delivered_turns.max(snapshot);
685 let agent_spoke = r.thread[snapshot.min(r.thread.len())..]
690 .iter()
691 .any(|t| t.who == Who::Agent);
692 if !agent_spoke && r.thread.len() == snapshot && r.status.open() {
693 let choices = r.choices.clone();
694 r.reply(text, choices)?;
695 }
696 }
697 }
698 Ok(())
699 })?;
700 self.memo.remove(&q.id);
701 let _ = std::fs::remove_file(retry_path(&self.store, &q.id));
702 Ok(())
703 }
704}
705
706pub(crate) fn take_claim(path: &std::path::Path, stale_after: Duration) -> bool {
710 let retry = crate::lock_file::Retry::new(None);
711 let attempt = || retry.create(path).is_ok();
712 if attempt() {
713 return true;
714 }
715 let stale = std::fs::metadata(path)
716 .and_then(|m| m.modified())
717 .ok()
718 .and_then(|t| t.elapsed().ok())
719 .is_some_and(|age| age > stale_after);
720 if stale {
721 let _ = std::fs::remove_file(path);
722 return attempt();
723 }
724 false
725}
726
727pub(crate) struct Release(pub(crate) PathBuf);
729
730impl Drop for Release {
731 fn drop(&mut self) {
732 let _ = std::fs::remove_file(&self.0);
733 }
734}
735
736#[cfg(test)]
737mod tests {
738 use super::*;
739
740 #[test]
741 fn retry_backoff_survives_a_new_finite_waiter() {
742 let home = tempfile::tempdir().expect("home");
743 let store = Questions::at(home.path().join("questions"));
744 std::fs::create_dir_all(store.root()).expect("questions");
745 let retry = Retry {
746 key: "input".to_owned(),
747 until: 700,
748 };
749 std::fs::write(
750 retry_path(&store, "question"),
751 serde_json::to_vec(&retry).expect("retry"),
752 )
753 .expect("save retry");
754 let reopened = Questions::at(home.path().join("questions"));
755 assert!(retry_blocked(&reopened, "question", "input", ts(699)));
756 assert!(!retry_blocked(
757 &reopened,
758 "question",
759 "different input",
760 ts(699)
761 ));
762 assert!(!retry_blocked(&reopened, "question", "input", ts(700)));
763 }
764
765 fn ts(secs: i64) -> Timestamp {
766 Timestamp::from_second(secs).unwrap()
767 }
768
769 fn asked(at: i64) -> Question {
770 let mut q = Question::new(
771 "run".into(),
772 "implement".into(),
773 "impl-A".into(),
774 "which?".into(),
775 String::new(),
776 vec![],
777 );
778 q.cwd = Some("/tmp".into());
779 q.asked_at = ts(at);
780 q.answer_timeout = 1000;
781 q
782 }
783
784 fn lease(at: i64) -> Lease {
785 Lease {
786 kind: WaiterKind::Asker,
787 pid: 1,
788 beat_at: ts(at),
789 }
790 }
791
792 #[test]
793 fn a_fresh_lease_means_nobody_else_acts() {
794 let mut q = asked(0);
795 q.say("why?").unwrap();
796 assert_eq!(
797 decide(&q, Some(&lease(95)), false, 86_400, ts(100)),
798 Action::Idle
799 );
800 }
801
802 #[test]
803 fn a_stale_lease_delivers_what_the_owner_said() {
804 let mut q = asked(0);
805 q.say("why?").unwrap();
806 assert_eq!(
807 decide(&q, Some(&lease(0)), false, 86_400, ts(500)),
808 Action::Deliver(Word::Said("why?".into()))
809 );
810 assert_eq!(
811 decide(&q, None, true, 86_400, ts(500)),
812 Action::Idle,
813 "a seat still mid-turn is not resumed"
814 );
815 }
816
817 #[test]
818 fn an_answer_nobody_read_is_delivered_once() {
819 let mut q = asked(0);
820 q.choices = vec!["A".into()];
821 q.answer(crate::ask::Answer::Choice("A".into())).unwrap();
822 assert_eq!(
823 decide(&q, None, false, 86_400, ts(10)),
824 Action::Deliver(Word::Answered("A".into()))
825 );
826 q.answer_delivered = true;
827 assert_eq!(decide(&q, None, false, 86_400, ts(10)), Action::Idle);
828 }
829
830 #[test]
831 fn only_asked_questions_and_only_before_the_deadline() {
832 let mut q = asked(0);
833 assert_eq!(decide(&q, None, false, 86_400, ts(999)), Action::Idle);
834 assert_eq!(decide(&q, None, false, 86_400, ts(1001)), Action::Expire);
835 q.cwd = None;
836 assert_eq!(decide(&q, None, false, 86_400, ts(1001)), Action::Idle);
837 }
838
839 #[test]
840 fn the_deadline_runs_from_the_last_turn_not_from_asking() {
841 let mut q = asked(0);
842 q.thread.push(crate::ask::Turn {
843 who: Who::Agent,
844 body: "context".into(),
845 at: ts(4000),
846 note: None,
847 });
848 q.delivered_turns = 1;
849 assert_eq!(decide(&q, None, false, 86_400, ts(4500)), Action::Idle);
850 assert_eq!(decide(&q, None, false, 86_400, ts(5001)), Action::Expire);
851 }
852
853 #[test]
854 fn a_word_given_in_time_is_delivered_after_the_deadline() {
855 let mut q = asked(0);
856 q.say("wait").unwrap();
857 assert!(matches!(
858 decide(&q, None, false, 86_400, ts(5000)),
859 Action::Deliver(_)
860 ));
861 }
862
863 #[test]
864 fn an_action_answer_is_left_to_the_daemon() {
865 let mut q = asked(0);
866 q.choices = vec!["A".into()];
867 q.actions
868 .insert("A".into(), crate::ask::ChoiceAction::Requeue);
869 q.answer(crate::ask::Answer::Choice("A".into())).unwrap();
870 assert_eq!(
871 decide_owned(&q, None, false, 86_400, ts(10), true),
872 Action::Idle,
873 "the daemon applies the action; the waiter must not also resume the seat"
874 );
875 q.answer_delivered = true;
877 assert_eq!(
878 decide_owned(&q, None, false, 86_400, ts(10), true),
879 Action::Idle
880 );
881 q.answer_delivered = false;
883 assert_eq!(
884 decide_owned(&q, None, false, 86_400, ts(10), false),
885 Action::Deliver(Word::Answered("A".into()))
886 );
887 let mut plain = asked(0);
889 plain.choices = vec!["A".into()];
890 plain
891 .answer(crate::ask::Answer::Choice("A".into()))
892 .unwrap();
893 assert_eq!(
894 decide_owned(&plain, None, false, 86_400, ts(10), true),
895 Action::Deliver(Word::Answered("A".into()))
896 );
897 }
898}