magi/deputy.rs
1//! Deputies: the seat that waits on a conductor's question, so the conductor
2//! never has to - and on land's merge approval, whose free-text reply used to
3//! reach nobody either.
4//!
5//! [`crate::conduct`] files a question for the operator and moves on; it is
6//! non-blocking by construction, because one task's question must not park the
7//! whole polling loop. The price used to be that nothing at all was waiting on
8//! it: the owner's free-text reply landed in a thread no agent read, while the
9//! phone said "waiting for the agent". Question f1dc on task 684d sat open
10//! forever on a "setup done".
11//!
12//! A deputy is the answer. For each open conductor question, `magi serve`
13//! runs one short-lived agent seat that is handed what the conductor knew
14//! ([`brief`]: the task, why it asked, what each option leads to), blocks on
15//! the question with `magi ask --wait`, answers back in the thread with
16//! `magi ask --thread`, and - when the owner's own words clearly pick an
17//! option - records that with `magi ask --settle`. Applying the outcome is not
18//! the deputy's job: an answered conductor question goes through the daemon's
19//! existing `resolve_blockers` / action path, exactly as one tapped on the
20//! phone does.
21//!
22//! # Invariants
23//!
24//! - **One seat per question, keyed by seat name.** `deputy-<question id>`,
25//! never the conductor's shared seat (`conduct/seat.json`, overwritten by
26//! every cycle) and never an agent id.
27//! - **Persisted on the question** ([`crate::ask::Deputy`]), always through
28//! [`Questions::update`]: the owner's say and answer write the same file. A
29//! restarted daemon resumes the seat only when [`agent::has_session`] says
30//! it can; otherwise it starts a fresh seat and re-sends the whole context,
31//! never assuming memory.
32//! - **Bounded.** At most `daemon.max_deputies` at once; at most
33//! [`MAX_STARTS`] starts per question, and a restart does not reset that. A
34//! deputy never outlives the question's `answer_timeout`: the waiter retires
35//! the question at its deadline ([`crate::waiter`]) and the task goes to a
36//! machine hold (`daemon::resolve_blockers`).
37//! - **Never a second agent on one question.** A fresh lease or the claim file
38//! means somebody is already on it.
39//! - **A merge approval is served too, but stays land's.** Its deputy has no
40//! `cwd` (so the waiter never touches it), its deadline is `asked_at +
41//! answer_timeout` and never moves on a reply ([`deadline`]), and the only
42//! thing that retires it is `daemon::land_resume_state`. A say alone never
43//! merges: `--settle` accepts `merge` only for the owner's own word `merge`.
44//! - **A release-watch question is served too, and stays the watcher's.** The
45//! escalation, local-mode approval and failed-release questions
46//! ([`crate::release_watch`], node `release-bump`, seat `release-watch`) have
47//! no asker, so a say reached nobody. Its deputy has no `cwd` either, a
48//! fixed `asked_at + answer_timeout` deadline ([`deadline`]), and applies
49//! nothing: `--settle` records a choice and the watcher applies it on its next
50//! lap. A local approval's `merge` / `hold` go through the merge-approval
51//! rules ([`merge_gated`]). A deputy never closes or merges the pull request.
52//! - **Every open question the owner can say something to has a listener.**
53//! [`Kind::Triage`] serves the triage questions; [`Kind::Generic`] is the
54//! fallback for any other question with choices and no `cwd` (the divergence
55//! question today, a node nobody has written yet tomorrow). It is decided by
56//! the missing `cwd`, never by node name. Only a conductor question is ever
57//! given a `cwd` ([`Deputies::attach`]); the other kinds would otherwise be
58//! resumed and expired by the waiter beside the deputy. A test enumerates
59//! every node filed without an asker, so a new kind of question cannot ship
60//! without one.
61//! - **No new authority.** A deputy does not edit, merge or touch the queue,
62//! and `--settle` accepts only an offered label backed by a verbatim quote
63//! of the owner, never on a task the operator holds.
64
65use std::collections::{BTreeMap, HashMap, HashSet};
66use std::path::{Path, PathBuf};
67use std::sync::Arc;
68use std::time::{Duration, Instant};
69
70use anyhow::{Context as _, Result};
71use jiff::Timestamp;
72use tokio::task::JoinSet;
73
74use crate::agent::{self, Invocation, SeatState};
75use crate::ask::{ChoiceAction, Deputy, Question, Questions, Waiter as Note, WaiterKind, Who};
76use crate::config::Config;
77use crate::notices::{self, Notice};
78use crate::prompt;
79
80/// Node name a deputy runs under (`MAGI_NODE`); `magi ask --settle` accepts
81/// it and nothing else.
82pub const NODE: &str = "deputy";
83
84/// How often the runner looks at the store.
85pub const TICK: Duration = Duration::from_secs(5);
86
87/// Most turns ever started for one question. A deputy that keeps ending
88/// without an answer is a problem for the owner, not something to retry until
89/// the quota is gone.
90pub const MAX_STARTS: u32 = 3;
91
92/// Least gap between two starts for one question.
93const RESTART_AFTER: Duration = Duration::from_secs(30);
94
95/// Added to the question's remaining time for the invocation's wall clock, so
96/// the deputy's own `magi ask --wait` reaches the deadline first.
97const SLACK_SECS: u64 = 120;
98
99/// A claim file older than this was left by a process that died.
100const CLAIM_STALE: Duration = Duration::from_secs(60);
101
102/// How long the context-handover turn may take.
103const HANDOVER_TIMEOUT: Duration = Duration::from_secs(10 * 60);
104
105/// Stops the deputy when true: the daemon is parking.
106pub type Halt = Arc<dyn Fn() -> bool + Send + Sync>;
107
108/// What the conductor knew about one question, for its deputy.
109///
110/// `reason` is the conductor's own one-line reasoning (may be empty). Each
111/// choice is listed with what picking it does: an attached action, or - for
112/// the usual conductor question - that the answer is recorded on the task and
113/// the task is unblocked with it in its instructions.
114pub fn brief(
115 task_id: &str,
116 reason: &str,
117 choices: &[String],
118 actions: &BTreeMap<String, ChoiceAction>,
119) -> String {
120 let reason = reason.trim();
121 let mut s = format!(
122 "Task {task_id} (`magi task show {task_id}`) is blocked on this question. \
123 The conductor asked because: {}\n\n\
124 When the owner answers, magi records the question and the answer on the \
125 task and unblocks it, and the answer becomes part of the instructions of \
126 the task's next attempt.",
127 if reason.is_empty() {
128 "(it recorded no reasoning)"
129 } else {
130 reason
131 }
132 );
133 if !choices.is_empty() {
134 s.push_str("\n\nWhat each option does:");
135 for c in choices {
136 match actions.get(c) {
137 Some(a) => s.push_str(&format!("\n- `{c}`: also {}", a.describe())),
138 None => s.push_str(&format!(
139 "\n- `{c}`: recorded on the task as the answer and the task is unblocked"
140 )),
141 }
142 }
143 }
144 s
145}
146
147/// What a deputy serves: the question kinds it is attached to.
148#[derive(Debug, Clone, Copy, PartialEq, Eq)]
149pub enum Kind {
150 /// A conductor's question ([`crate::conduct::NODE`]).
151 Conduct,
152 /// The merge approval ([`crate::land::APPROVAL_NODE`]).
153 Land,
154 /// A question the release watcher filed ([`crate::release_watch`]).
155 Release,
156 /// A triage question about a held task or a stuck dependency root
157 /// ([`crate::triage::NODE`], [`crate::triage::DEPS_NODE`]).
158 Triage,
159 /// Any other question nobody asked from inside a run (no `cwd`) that offers
160 /// choices: the fallback, so a node nobody has met yet still has a listener.
161 Generic,
162}
163
164/// The kind of question a deputy serves for `q`, `None` for every other.
165///
166/// The fallback is decided by the absence of a `cwd`, never by the node name: a
167/// question filed with `magi ask` inside a run records its `cwd` and has an
168/// asker (and the waiter), and the node a seat asks from is the seat's own name
169/// (a reviewer's `review` is also the divergence question's node). A question
170/// with no choices cannot be settled and is a notice, not something to answer.
171pub fn kind_of(q: &Question) -> Option<Kind> {
172 match q.node.as_str() {
173 crate::conduct::NODE => Some(Kind::Conduct),
174 crate::land::APPROVAL_NODE => Some(Kind::Land),
175 // The seat matters: `bump` files choice-less notices on the same node,
176 // which nobody can answer and which must not cost a deputy.
177 crate::bump::NOTICE_NODE if q.seat == "release-watch" => Some(Kind::Release),
178 crate::bump::NOTICE_NODE => None,
179 crate::triage::NODE | crate::triage::DEPS_NODE => Some(Kind::Triage),
180 _ if q.cwd.is_none() && !q.choices.is_empty() => Some(Kind::Generic),
181 _ => None,
182 }
183}
184
185/// What a deputy is told about a question of no known kind: only what the
186/// question itself stored.
187pub fn generic_brief(
188 q: &Question,
189 actions: &BTreeMap<String, ChoiceAction>,
190 home: &Path,
191) -> String {
192 // `Question::run` may also hold a task id; only a run id can be shown.
193 let run = crate::run::is_run_id(&q.run).then_some(q.run.as_str());
194 let knows = if run.is_some() {
195 "You know what the question itself says and what you can learn from \
196 its run by the read-only investigation described below."
197 } else {
198 "You know only what the question itself says."
199 };
200 let mut s = format!(
201 "This question was filed by magi (node `{}`, seat `{}`) with no agent \
202 waiting on it. Magi records the owner's answer and the component that \
203 asked applies it; you apply nothing. {knows} A choice whose effect you \
204 cannot read is not yours to guess: do not settle it, ask the owner \
205 what they mean with `--thread` instead.",
206 q.node, q.seat
207 );
208 if let Some(run) = run {
209 let artifacts = home.join("runs").join(run).join("artifacts");
210 s.push_str(&format!(
211 "\n\nThis question concerns run `{run}`. You may investigate it on \
212 your own, read-only: run `magi show {run}` and read files under \
213 `{}`. When the owner asks about details (for example which part \
214 was wrong), look it up yourself and answer; do not reply that you \
215 cannot see the details, and do not tell the owner to run `magi show` \
216 themselves. Investigating never changes anything: edit, commit, push \
217 and apply nothing. If the run cannot be read, say exactly that and do \
218 not guess at the cause or at what an option does.",
219 artifacts.display()
220 ));
221 }
222 if q.node == crate::github_text::ASK_NODE {
223 s.push_str("\n\n");
224 s.push_str(&crate::github_text::deputy_brief());
225 }
226 if !q.choices.is_empty() {
227 s.push_str("\n\nWhat each option does:");
228 for c in &q.choices {
229 match actions.get(c) {
230 Some(a) => s.push_str(&format!("\n- `{c}`: also {}", a.describe())),
231 None => s.push_str(&format!(
232 "\n- `{c}`: recorded as the answer (nothing more is known)"
233 )),
234 }
235 }
236 }
237 s
238}
239
240/// Is picking `label` on `q` something that cannot be taken back, so that
241/// `--settle` must hold the owner's words to the same mechanical standard as a
242/// merge (`land::unhedged`)? Discarding a task deletes it; a divergence answer
243/// drops commits.
244pub fn destructive(q: &Question, label: &str) -> bool {
245 crate::consult::destructive_effect(q, label)
246}
247
248/// Does `--settle` hold `q` to the merge-approval rules (the verbatim quote of
249/// the latest message for `merge`, the whole message for `hold`)? A merge
250/// approval, and any other served question that offers `merge` (the release
251/// watcher's local-mode approval merges just as irreversibly).
252pub fn merge_gated(q: &Question) -> bool {
253 q.node == crate::land::APPROVAL_NODE
254 || (kind_of(q).is_some_and(|k| k != Kind::Conduct)
255 && q.choices.iter().any(|c| c == crate::land::APPROVE))
256}
257
258/// Does `q`'s clock run from `asked_at` and never move on a reply? True for the
259/// questions that something other than the waiter retires: a merge approval
260/// (land) and a release-watch question (the watcher, by silence being a hold).
261pub fn fixed_clock(q: &Question) -> bool {
262 matches!(kind_of(q), Some(Kind::Land | Kind::Release)) || q.node == crate::github_text::ASK_NODE
263}
264
265/// Second after which nobody is to be started or kept on `q`.
266///
267/// A conductor question runs from its last activity (`magi ask --thread`
268/// re-arms it, and the waiter retires it). A merge approval never moves: it
269/// runs from `asked_at`, exactly where `daemon::land_resume_state` abandons it,
270/// so a conversation cannot stretch the hold and land stays the only place that
271/// retires one.
272pub fn deadline(q: &Question, default_timeout: u64) -> i64 {
273 let secs = if q.answer_timeout > 0 {
274 q.answer_timeout
275 } else {
276 default_timeout
277 };
278 let from = if fixed_clock(q) {
279 q.asked_at.as_second()
280 } else {
281 q.last_activity()
282 };
283 from.saturating_add(secs as i64)
284}
285
286/// Dispatch and text admission share the fixed-clock and unread-word rules.
287pub fn restart_in_time(q: &Question, default_timeout: u64, now: Timestamp) -> bool {
288 now.as_second() <= deadline(q, default_timeout) || q.unread_from_owner().is_some()
289}
290
291/// Can `magi serve` start a deputy at all under `cfg`? Not when deputies are
292/// switched off (`daemon.max_deputies = 0`) or the config could not be read.
293///
294/// Also false when the agent the deputy would run as cannot be resolved
295/// (`agent` is the deputy's recorded agent, empty when it has none): `turn`
296/// fails on that before it counts a start, so it would never reach
297/// [`MAX_STARTS`].
298pub fn can_start(cfg: Option<&Config>, agent: &str) -> bool {
299 cfg.is_some_and(|c| {
300 c.daemon.max_deputies > 0
301 && ((!agent.is_empty() && c.agent(agent).is_ok()) || c.resolve_roles().is_ok())
302 })
303}
304
305/// The agent a question's deputy records, empty when it has none yet.
306pub fn agent_of(q: &Question) -> &str {
307 q.deputy.as_ref().map_or("", |d| d.agent.as_str())
308}
309
310/// Has this question's deputy run out of starts, or can none ever start, with
311/// the deadline gone?
312///
313/// Then nothing will ever read an unread say, and the waiter must retire the
314/// question anyway instead of deferring to a deputy that no longer starts.
315/// `startable` is [`can_start`]; a fresh lease is the caller's to check.
316pub fn exhausted_past_deadline(
317 q: &Question,
318 startable: bool,
319 default_timeout: u64,
320 now: Timestamp,
321) -> bool {
322 q.status.open()
323 && q.deputy
324 .as_ref()
325 .is_some_and(|d| d.starts >= MAX_STARTS || !startable)
326 && now.as_second() > deadline(q, default_timeout)
327}
328
329/// The deputy runner: its own task inside `magi serve`, beside the waiter.
330pub struct Deputies {
331 store: Questions,
332 home: PathBuf,
333 cfg: Option<Config>,
334 /// Working directory for a question that recorded none.
335 fallback_repo: PathBuf,
336 max: usize,
337 halt: Halt,
338 tasks: JoinSet<String>,
339 inflight: HashSet<String>,
340 /// Earliest next start per question.
341 memo: HashMap<String, Instant>,
342}
343
344impl Deputies {
345 /// A runner over `store`, with at most `max` deputies at once.
346 pub fn new(
347 store: Questions,
348 home: PathBuf,
349 cfg: Option<Config>,
350 fallback_repo: PathBuf,
351 max: usize,
352 halt: Halt,
353 ) -> Self {
354 Self {
355 store,
356 home,
357 cfg,
358 fallback_repo,
359 max,
360 halt,
361 tasks: JoinSet::new(),
362 inflight: HashSet::new(),
363 memo: HashMap::new(),
364 }
365 }
366
367 fn default_timeout(&self) -> u64 {
368 self.cfg
369 .as_ref()
370 .map_or(Config::default().graph.answer_timeout, |c| {
371 c.graph.answer_timeout
372 })
373 }
374
375 fn reap(&mut self) {
376 while let Some(done) = self.tasks.try_join_next() {
377 if let Ok(id) = done {
378 self.inflight.remove(&id);
379 }
380 }
381 if self.tasks.is_empty() {
382 self.inflight.clear();
383 }
384 }
385
386 /// Give a question the record a deputy needs: the deputy itself (with a
387 /// brief rebuilt from what the question stored, when it was filed before
388 /// deputies existed - a lost reason is not invented) and the deadline.
389 ///
390 /// A conductor question also gets the working directory the waiter and
391 /// `magi ask --wait` use. Every other kind never does: `cwd` is what makes a
392 /// question the waiter's (it would resume and expire it beside the deputy),
393 /// and a release-watch question, a merge approval, a triage question and a
394 /// fallback one have no asker to resume.
395 fn attach(&self, q: &Question, kind: Kind) -> Option<Question> {
396 let default_timeout = self.default_timeout();
397 let repo = self.fallback_repo.to_string_lossy().into_owned();
398 let state = match kind {
399 Kind::Land => crate::run::RunState::load(&q.run).ok(),
400 Kind::Conduct | Kind::Release | Kind::Triage | Kind::Generic => None,
401 };
402 // The deadline land itself enforces for this run.
403 let timeout = state
404 .as_ref()
405 .map_or(default_timeout, |s| s.config.graph.answer_timeout);
406 self.store
407 .update(&q.id, |r| {
408 if r.deputy.is_none() {
409 r.deputy = Some(Deputy::new(match kind {
410 Kind::Conduct => brief(&r.run, &r.detail, &r.choices, &r.actions),
411 Kind::Land => crate::land::deputy_brief(r, state.as_ref()),
412 Kind::Release => crate::release_watch::deputy_brief(r, &self.home),
413 Kind::Triage => crate::triage::deputy_brief(
414 r,
415 &crate::queue::Queue::at(self.home.join("queue")),
416 ),
417 Kind::Generic => generic_brief(r, &r.actions, &self.home),
418 }));
419 }
420 if kind == Kind::Conduct && r.cwd.is_none() {
421 r.cwd = Some(repo.clone());
422 }
423 if r.answer_timeout == 0 {
424 r.answer_timeout = match kind {
425 Kind::Conduct => default_timeout,
426 Kind::Land => timeout,
427 Kind::Release | Kind::Triage | Kind::Generic => default_timeout,
428 };
429 }
430 Ok(())
431 })
432 .map(|(r, ())| r)
433 .map_err(|e| tracing::warn!("question {}: cannot attach a deputy: {e:#}", q.short()))
434 .ok()
435 }
436
437 /// Look at every open conductor question once and start a deputy on each
438 /// that has none, within the limits. Returns without waiting for them.
439 pub fn tick(&mut self, now: Timestamp) {
440 self.reap();
441 for q in self.store.list() {
442 if (self.halt)() {
443 return;
444 }
445 let Some(kind) = kind_of(&q) else {
446 continue;
447 };
448 if !q.status.open() {
449 continue;
450 }
451 let needs_cwd = kind == Kind::Conduct && q.cwd.is_none();
452 let q = if q.deputy.is_none() || needs_cwd || q.answer_timeout == 0 {
453 match self.attach(&q, kind) {
454 Some(q) => q,
455 None => continue,
456 }
457 } else {
458 q
459 };
460 let Some(dep) = q.deputy.as_ref() else {
461 continue;
462 };
463 if self.inflight.contains(&q.id)
464 || self.store.read_lease(&q.id).is_some_and(|l| l.fresh(now))
465 {
466 continue;
467 }
468 if dep.starts >= MAX_STARTS {
469 continue;
470 }
471 // Past the deadline the waiter retires the question; a say that
472 // arrived in time is still read first.
473 if !restart_in_time(&q, self.default_timeout(), now) {
474 continue;
475 }
476 if self.inflight.len() >= self.max || !can_start(self.cfg.as_ref(), dep.agent.as_str())
477 {
478 continue;
479 }
480 if matches!(self.memo.get(&q.id), Some(until) if Instant::now() < *until) {
481 continue;
482 }
483 self.memo
484 .insert(q.id.clone(), Instant::now() + RESTART_AFTER);
485 self.inflight.insert(q.id.clone());
486 let job = Job {
487 store: self.store.clone(),
488 home: self.home.clone(),
489 cfg: self.cfg.clone(),
490 fallback_repo: self.fallback_repo.clone(),
491 halt: Arc::clone(&self.halt),
492 };
493 let id = q.id.clone();
494 self.tasks.spawn(async move {
495 if let Err(e) = job.turn(&id).await {
496 tracing::warn!("deputy for question {}: {e:#}", crate::ask::short_id(&id));
497 }
498 id
499 });
500 }
501 }
502
503 /// Wait for every deputy turn in flight. The daemon never calls this; the
504 /// tests do, to look at the record once a turn is over.
505 pub async fn drain(&mut self) {
506 while let Some(done) = self.tasks.join_next().await {
507 if let Ok(id) = done {
508 self.inflight.remove(&id);
509 }
510 }
511 self.inflight.clear();
512 }
513}
514
515/// Everything one deputy turn needs, owned so it can run detached.
516struct Job {
517 store: Questions,
518 home: PathBuf,
519 cfg: Option<Config>,
520 fallback_repo: PathBuf,
521 halt: Halt,
522}
523
524impl Job {
525 /// Run one invocation, beating the lease; `None` when the daemon is
526 /// parking and the turn was dropped.
527 async fn drive(
528 &self,
529 spec: &crate::config::AgentSpec,
530 seat: &mut SeatState,
531 inv: &Invocation<'_>,
532 id: &str,
533 ) -> Option<Result<agent::AgentOutput>> {
534 let fut = agent::invoke(spec, seat, inv);
535 tokio::pin!(fut);
536 let mut beat = tokio::time::interval(Duration::from_secs(1));
537 let mut beats = 0u32;
538 loop {
539 tokio::select! {
540 r = &mut fut => break Some(r),
541 _ = beat.tick() => {
542 if (self.halt)() {
543 break None;
544 }
545 beats += 1;
546 if beats % 20 == 0 {
547 self.store.beat(id, WaiterKind::Deputy);
548 }
549 }
550 }
551 }
552 }
553
554 /// The question settled or expired during the handover: release it without
555 /// starting the long turn. The start stays counted - the handover ran.
556 fn park_quietly(&self, id: &str) {
557 let _ = self.store.update(id, |r| {
558 r.waiter = None;
559 Ok(())
560 });
561 self.store.drop_lease(id);
562 }
563
564 /// Parking: the turn is dropped and the start refunded. The seat stays as
565 /// last persisted - after the handover turn, resumable.
566 fn park(&self, id: &str) {
567 let _ = self.store.update(id, |r| {
568 if let Some(d) = r.deputy.as_mut() {
569 d.starts = d.starts.saturating_sub(1);
570 }
571 r.waiter = None;
572 Ok(())
573 });
574 self.store.drop_lease(id);
575 }
576
577 async fn turn(&self, id: &str) -> Result<()> {
578 let claim = self.store.root().join(format!("{id}.deputy-claim"));
579 // The claim only covers the decision to start and the write that records
580 // it, so a daemon that dies holding it blocks a restart for a minute,
581 // not for a turn's length. The lease guards the turn itself.
582 if !crate::waiter::take_claim(&claim, CLAIM_STALE) {
583 return Ok(());
584 }
585 let release = crate::waiter::Release(claim);
586
587 // Decided again under the claim, on the record as it is now.
588 let q = self.store.get(id)?;
589 let now = Timestamp::now();
590 let Some(dep) = q.deputy.clone() else {
591 return Ok(());
592 };
593 if !q.status.open()
594 || dep.starts >= MAX_STARTS
595 || self.store.read_lease(&q.id).is_some_and(|l| l.fresh(now))
596 {
597 return Ok(());
598 }
599 let cfg = self
600 .cfg
601 .as_ref()
602 .context("the deputy's configuration is not available")?;
603 let spec = match cfg.agent(&dep.agent) {
604 Ok(s) if !dep.agent.is_empty() => s.clone(),
605 _ => {
606 cfg.resolve_roles()
607 .context("resolving the deputy's agent")?
608 .conductor
609 }
610 };
611 // The same CLI conversation when it can be resumed; otherwise a fresh
612 // seat - and either way the prompt carries the whole context.
613 let key = crate::ask::deputy_seat_key(&q.id);
614 let (mut seat, resumed) = match dep.seat.clone() {
615 Some(s)
616 if s.agent == spec.id && agent::has_session(spec.kind, &s, cfg.graph.sessions) =>
617 {
618 (s, true)
619 }
620 _ => (SeatState::new(&key, &spec.id, crate::rng::entropy()), false),
621 };
622 // A release-watch question has no `cwd`; its watch record names the
623 // checkout the pull request belongs to.
624 let recorded = q.cwd.clone().or_else(|| match kind_of(&q) {
625 Some(Kind::Release) => {
626 crate::release_watch::state_for_question(&self.home, &q.id).map(|st| st.repo)
627 }
628 // These record a task id in `run` (or a run id for a divergence's
629 // sibling kinds): the task's repository, when it can be found.
630 Some(Kind::Triage | Kind::Generic) => crate::queue::Queue::at(self.home.join("queue"))
631 .get(&q.run)
632 .ok()
633 .map(|t| t.repo.to_string_lossy().into_owned())
634 .filter(|r| !r.is_empty()),
635 _ => None,
636 });
637 let cwd = recorded
638 .as_deref()
639 .map(PathBuf::from)
640 .filter(|p| p.is_dir())
641 .unwrap_or_else(|| self.fallback_repo.clone());
642
643 // A settle's note is part of what the seat said, so a resumed seat
644 // reads its own report back.
645 let bodies: Vec<String> = q
646 .thread
647 .iter()
648 .map(|t| match &t.note {
649 Some(n) => format!("{}\n(note: {n})", t.body),
650 None => t.body.clone(),
651 })
652 .collect();
653 let thread: Vec<(&str, &str)> = q
654 .thread
655 .iter()
656 .zip(&bodies)
657 .map(|(t, body)| {
658 (
659 if t.who == Who::Operator {
660 "operator"
661 } else {
662 "agent"
663 },
664 body.as_str(),
665 )
666 })
667 .collect();
668 let read = &thread[..q.delivered_turns.min(thread.len())];
669 let unread = q.unread_from_owner();
670 let snapshot = q.thread.len();
671 let current_brief = format!(
672 "{}\n\n{}",
673 dep.brief,
674 crate::queue::dependency_facts(
675 &crate::queue::Queue::at(self.home.join("queue")),
676 &self.store
677 )
678 );
679 let body = prompt::deputy(&prompt::DeputyPrompt {
680 id: &q.id,
681 summary: &q.summary,
682 detail: &q.detail,
683 brief: ¤t_brief,
684 choices: &q.choices,
685 thread: read,
686 unread: unread.as_deref(),
687 resumed,
688 handover: false,
689 kind: kind_of(&q).unwrap_or(Kind::Conduct),
690 language: &cfg.graph.language,
691 });
692
693 // Recorded before the turn starts, so a daemon that dies mid-turn
694 // leaves a start counted and the seat on the record.
695 self.store.beat(&q.id, WaiterKind::Deputy);
696 let starts = dep.starts + 1;
697 let first = seat.clone();
698 self.store.update(&q.id, |r| {
699 if let Some(d) = r.deputy.as_mut() {
700 d.agent = spec.id.clone();
701 d.seat = Some(first);
702 d.starts = starts;
703 }
704 r.waiter = Some(Note {
705 kind: WaiterKind::Deputy,
706 since: now,
707 });
708 Ok(())
709 })?;
710 drop(release);
711 tracing::info!(
712 "question {}: deputy seat {} {} (start {starts}/{MAX_STARTS})",
713 q.short(),
714 seat.key,
715 if resumed { "resuming" } else { "starting" }
716 );
717
718 let left = (deadline(&q, cfg.graph.answer_timeout) - now.as_second()).max(0) as u64;
719 let artifacts = self.store.root().join(format!("{}.deputy", q.id));
720 let cache_dir = cfg.cache_dir();
721 // `magi ask` writes the question record and its lock, which a read-only
722 // sandbox refuses, so the seat cannot be read-only. It is told never to
723 // edit anything; the daemon, not the deputy, applies outcomes.
724 let allow_write = true;
725 // The question store is outside the repository, and `magi ask` writes it.
726 // A merge approval's deputy also files follow-up tasks with `magi task
727 // add`, which writes the queue; that is the one other place it writes.
728 let writable = [self.store.root().to_path_buf(), self.home.join("queue")];
729 macro_rules! invocation {
730 ($prompt:expr, $stem:expr, $timeout:expr) => {
731 Invocation {
732 cwd: &cwd,
733 prompt: $prompt,
734 timeout: $timeout,
735 allow_write,
736 unsandboxed: false,
737 sessions: cfg.graph.sessions,
738 artifacts: &artifacts,
739 stem: $stem,
740 // The question's own run key (the task id) is what lets this
741 // seat's `magi ask` pass the ownership check on a conductor
742 // question.
743 run: &q.run,
744 node: NODE,
745 cache_dir: cache_dir.as_deref(),
746 attachments: &[],
747 writable: &writable,
748 }
749 };
750 }
751
752 // A fresh seat first takes a short turn that ends: `agent::invoke` only
753 // learns the CLI's session id when it returns, and the real turn blocks
754 // in `magi ask` for hours, so a daemon stopped mid-wait would otherwise
755 // leave nothing to resume. This turn persists the seat before the wait.
756 let mut early = None;
757 if !resumed && cfg.graph.sessions {
758 let hbody = prompt::deputy(&prompt::DeputyPrompt {
759 id: &q.id,
760 summary: &q.summary,
761 detail: &q.detail,
762 brief: ¤t_brief,
763 choices: &q.choices,
764 thread: read,
765 unread: None,
766 resumed: false,
767 handover: true,
768 kind: kind_of(&q).unwrap_or(Kind::Conduct),
769 language: &cfg.graph.language,
770 });
771 let hstem = format!("handover-{starts}");
772 // Bounded by the question's own deadline, not only by its own cap: the
773 // lease it beats would otherwise keep an expired question alive.
774 let hlimit = HANDOVER_TIMEOUT.min(Duration::from_secs(left.max(1)));
775 let hinv = invocation!(&hbody, &hstem, hlimit);
776 let Some(done) = self.drive(&spec, &mut seat, &hinv, &q.id).await else {
777 self.park(&q.id);
778 return Ok(());
779 };
780 let kept = seat.clone();
781 self.store.update(&q.id, |r| {
782 if let Some(d) = r.deputy.as_mut() {
783 d.seat = Some(kept);
784 }
785 Ok(())
786 })?;
787 if !matches!(&done, Ok(o) if o.usable()) {
788 early = Some(done);
789 }
790 }
791
792 // The handover may have been slow: look at the question again, and
793 // measure the long turn from what is left now.
794 let left = if early.is_none() && !resumed && cfg.graph.sessions {
795 let now = Timestamp::now();
796 let again = self.store.get(&q.id)?;
797 let left = (deadline(&again, cfg.graph.answer_timeout) - now.as_second()).max(0) as u64;
798 if !again.status.open() || (left == 0 && again.unread_from_owner().is_none()) {
799 self.park_quietly(&q.id);
800 return Ok(());
801 }
802 left
803 } else {
804 left
805 };
806 let out = match early {
807 Some(done) => done,
808 None => {
809 let stem = format!("turn-{starts}");
810 let timeout = Duration::from_secs(left.max(60) + SLACK_SECS);
811 let inv = invocation!(&body, &stem, timeout);
812 match self.drive(&spec, &mut seat, &inv, &q.id).await {
813 Some(out) => out,
814 None => {
815 self.park(&q.id);
816 return Ok(());
817 }
818 }
819 }
820 };
821
822 let (text, why) = match out {
823 Ok(o) if o.usable() => (Some(o.text.trim().to_owned()), None),
824 Ok(o) if o.timed_out => (None, Some("its turn timed out".to_owned())),
825 Ok(o) if o.quota_exhausted() => (None, Some("the agent is out of quota".to_owned())),
826 Ok(o) => (
827 None,
828 Some(format!("its turn failed (exit {:?})", o.exit_code)),
829 ),
830 Err(e) => (None, Some(format!("the agent could not be started: {e:#}"))),
831 };
832 let kept = seat.clone();
833 self.store.update(&q.id, |r| {
834 if let Some(d) = r.deputy.as_mut() {
835 d.seat = Some(kept);
836 }
837 r.waiter = None;
838 // Retire ownership under the same lock used by text admission.
839 self.store.drop_lease(&q.id);
840 // The owner's words were in the prompt. An agent that read them but
841 // answered in prose instead of `magi ask --thread` still spoke;
842 // keep it on the record so the owner reads it.
843 if let Some(text) = text
844 && unread.is_some()
845 && !text.is_empty()
846 && r.status.open()
847 && r.thread.len() == snapshot
848 {
849 r.delivered_turns = r.delivered_turns.max(snapshot);
850 let choices = r.choices.clone();
851 r.reply(text, choices)?;
852 }
853 Ok(())
854 })?;
855 if let Some(why) = why {
856 tracing::warn!("question {}: the deputy ended: {why}", q.short());
857 notices::raise_in(
858 &self.home,
859 Notice::warn(
860 &format!("deputy-turn:{}", q.id),
861 format!(
862 "Question {} \"{}\": its follow-up agent stopped ({why}).",
863 q.short(),
864 q.summary
865 ),
866 ),
867 );
868 }
869 Ok(())
870 }
871}
872
873/// The runner's loop, until `stop` is asked for.
874pub async fn run(mut deputies: Deputies, stop: crate::daemon::Stop) {
875 while !stop.stopped() {
876 deputies.tick(Timestamp::now());
877 tokio::time::sleep(TICK).await;
878 }
879}