Skip to main content

agentplane/runtime/
attention.rs

1//! One question: does anything on this plane need a person right now.
2//!
3//! **Why this exists as a call rather than as four predicates.** A report knows
4//! whether *it* found something — the sweep, the witness pass, the push run, a
5//! batch — and each says so with its own `needs_attention`. What none of them
6//! answers is the question an operator actually asks, which is about the plane
7//! and not about an operation they just ran. Asked that way, it also reaches the
8//! conditions no report has: a quarantine standing since last week, an
9//! obligation nobody accounted for, a run whose wait expired while the process
10//! that armed it was gone.
11//!
12//! **It is not a threshold.** Nothing here decides whether a backlog is large
13//! enough to page somebody; that is a deployment's policy and this crate keeps
14//! out of it. What this decides is the part a deployment cannot: *which*
15//! conditions mean a person, and where each one is read from.
16
17use crate::core::{RuntimeError, Timestamp};
18
19/// How many subject ids one condition lists.
20///
21/// A condition is a pointer to a backlog, not the backlog: the listing verb for
22/// each kind pages the rest. Ten is enough to act on the first ones without an
23/// answer that grows with the incident.
24pub const SUBJECTS_LISTED: usize = 10;
25
26/// What a person does about a condition, in the vocabulary of each surface
27/// that reports it.
28///
29/// Two strings rather than one, because the terminal and the operator API do
30/// not have the same verbs: a remedy is only useful in the words of the
31/// surface it was read on, and where that surface has no verb for it, it says
32/// which one does. A remedy with nothing to run says `no verb:` and why.
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub struct Remedy {
35    /// In `agentplane` verbs.
36    pub cli: &'static str,
37    /// In operator API routes, naming the `agentplane` verb where the API has
38    /// none.
39    pub http: &'static str,
40}
41
42/// One condition that currently wants a person, and what to do about it.
43#[derive(Debug, Clone, PartialEq, Eq)]
44pub struct Condition {
45    /// A stable key, so a script routes on the condition rather than on prose.
46    pub kind: &'static str,
47    /// How many were found **on the page that was read**. Paired with
48    /// [`at_least`](Self::at_least) so a ceiling reads as a ceiling rather than
49    /// as a total.
50    pub found: usize,
51    /// The page filled, so the real number is this or more.
52    pub at_least: bool,
53    /// The ids the remedy acts on — a run, a task, a case and obligation, a
54    /// message, a push registration — at most [`SUBJECTS_LISTED`], in the order
55    /// the backlog lists them: newest first for run conclusions, most overdue
56    /// first for waits and tasks.
57    ///
58    /// Every verb a remedy names takes an id, so a condition that counted its
59    /// subjects without naming them would send the operator to a second
60    /// listing before they could act on the first.
61    pub subjects: Vec<String>,
62    /// How many of [`found`](Self::found) are not in
63    /// [`subjects`](Self::subjects).
64    pub unlisted: usize,
65    /// What the person this reaches actually does.
66    pub remedy: Remedy,
67}
68
69/// What on this plane needs a person, condition by condition.
70///
71/// **Named rather than summed.** An operator acts on *which* one, and a count
72/// across conditions would be a number nobody can act on — three quarantines
73/// and one dead letter is not "four" of anything.
74#[derive(Debug, Clone, PartialEq, Eq, Default)]
75pub struct Attention {
76    /// Every condition that is currently true. Empty means nothing does.
77    pub conditions: Vec<Condition>,
78    /// What this pass could not establish, and why.
79    ///
80    /// A plane with no push store cannot answer about parked registrations, and
81    /// an empty `conditions` list would otherwise read as *nothing is wrong*
82    /// when the honest answer is *nobody looked*. The same rule `drill` and
83    /// `verify` are held to.
84    pub not_checked: Vec<&'static str>,
85}
86
87impl Attention {
88    /// Whether anything needs a person.
89    #[must_use]
90    pub fn any(&self) -> bool {
91        !self.conditions.is_empty()
92    }
93
94    /// Record a condition when anything was found.
95    ///
96    /// `found` is passed beside `subjects` because one condition — a failed
97    /// drill — has a verdict and no id to act on.
98    fn note(
99        &mut self,
100        kind: &'static str,
101        found: usize,
102        subjects: impl IntoIterator<Item = String>,
103        page: usize,
104        remedy: Remedy,
105    ) {
106        if found > 0 {
107            let subjects: Vec<String> = subjects.into_iter().take(SUBJECTS_LISTED).collect();
108            self.conditions.push(Condition {
109                kind,
110                found,
111                at_least: found >= page,
112                unlisted: found.saturating_sub(subjects.len()),
113                subjects,
114                remedy,
115            });
116        }
117    }
118}
119
120impl super::Runtime {
121    /// Does anything on this plane need a person right now, and which thing.
122    ///
123    /// **What counts is not a judgement made here twice.** It comes from the
124    /// run vocabulary: `failed`, `exhausted`, `withheld` and `quarantined` all
125    /// leave a run open, and three of them name a party who can answer — a
126    /// raised ceiling, a lifted withdrawal, a person deciding a doubt. `failed`
127    /// is the one a resume may clear alone, so it is listed only where the run
128    /// stands on landed work no compensation undid. Beside those sit
129    /// the backlogs that already have a listing and a verb that empties them.
130    ///
131    /// **The clock is the caller's** — the same decision the task listing and
132    /// the push sweep make — so a fixed instant makes this testable and a
133    /// deployment owns its cadence. `page` bounds every query, and a condition
134    /// says whether its page filled rather than pretending to a total.
135    ///
136    /// # Errors
137    ///
138    /// If a store this plane *does* hold is unreachable: a backlog that cannot
139    /// be read is not an empty one.
140    pub async fn attention(&self, at: Timestamp, page: usize) -> Result<Attention, RuntimeError> {
141        let mut out = Attention::default();
142        // Every run standing in a conclusion that waits on a person, for the
143        // reservation check below: those are the holds nothing will release.
144        let mut stopped = std::collections::BTreeSet::new();
145
146        // The condition key beside the outcome it reads, rather than derived
147        // from it by a second match with a catch-all: a fourth outcome added
148        // to this list would have taken the wildcard and been reported to an
149        // operator under the third one's name and the third one's remedy.
150        for (outcome, kind, remedy) in [
151            // Two causes share the outcome, and the reason on the run says
152            // which: an effect nobody can decide, or a resume refused because
153            // the declaration or policy bundle it was admitted under is not the
154            // one this plane holds.
155            (
156                "quarantined",
157                "run.quarantined",
158                Remedy {
159                    cli: "establish an undecided effect with `reconcile`, or bring back the \
160                          declaration or policy bundle the run was admitted under — then \
161                          `quarantine` to reopen or abandon",
162                    http: "establish an undecided effect with \
163                           `POST /runs/{run}/reconcile`, or bring back the declaration or \
164                           policy bundle the run was admitted under — then \
165                           `POST /runs/{run}/reopen` or `POST /runs/{run}/abandon`",
166                },
167            ),
168            // Not *abandon*: that answers a doubt, and an exhaustion is not
169            // one — `quarantine` refuses any run whose conclusion is
170            // something else. The operator who is not raising the ceiling is
171            // stopping the run.
172            (
173                "exhausted",
174                "run.exhausted",
175                Remedy {
176                    cli: "raise the ceiling — or, stopped by a tool's rate ceiling, wait for \
177                          its window to pass — and `replay`, or `cancel` the run",
178                    http: "raise the ceiling — or, stopped by a tool's rate ceiling, wait \
179                           for its window to pass — and resume the run with `agentplane \
180                           replay` (this API does not resume runs), or \
181                           `POST /runs/{run}/cancel`",
182                },
183            ),
184            (
185                "withheld",
186                "run.withheld",
187                Remedy {
188                    cli: "lift the withdrawal with `halt --lift`, then `replay` — or \
189                          `cancel` the run",
190                    http: "lift the withdrawal with `POST /halts/lift`, then resume the \
191                           run with `agentplane replay` — or `POST /runs/{run}/cancel`",
192                },
193            ),
194        ] {
195            let found = self
196                .store()
197                .runs_by_outcome(outcome, page)
198                .await
199                .map_err(RuntimeError::from_store)?;
200            stopped.extend(found.iter().copied());
201            out.note(
202                kind,
203                found.len(),
204                found.iter().map(ToString::to_string),
205                page,
206                remedy,
207            );
208        }
209
210        stopped.extend(self.failed_on_landed_work(&mut out, page).await?);
211        self.held_by_stopped_runs(&mut out, &stopped, page).await?;
212
213        let abandoned = self
214            .store()
215            .abandoned_runs(page)
216            .await
217            .map_err(RuntimeError::from_store)?;
218        out.note(
219            "run.abandoned",
220            abandoned.len(),
221            abandoned.iter().map(ToString::to_string),
222            page,
223            Remedy {
224                cli: "no verb: the recovery sweep resumes these; one that keeps \
225                      reappearing is a run nothing can drive",
226                http: "no verb: the recovery sweep resumes these; one that keeps \
227                       reappearing is a run nothing can drive",
228            },
229        );
230
231        // Only the ones whose instant has passed. A run waiting for next
232        // Tuesday is the system working, and a listing that called it a
233        // finding would cry wolf every time somebody scheduled something.
234        let overdue: Vec<String> = self
235            .store()
236            .waiting_runs(page)
237            .await
238            .map_err(RuntimeError::from_store)?
239            .into_iter()
240            .filter(|w| w.reason.until() <= at)
241            .map(|w| w.run.to_string())
242            .collect();
243        out.note(
244            "run.wait_expired",
245            overdue.len(),
246            overdue,
247            page,
248            Remedy {
249                cli: "`replay` the run: it reaches the announced wait and re-arms it",
250                http: "resume the run with `agentplane replay` (this API does not \
251                       resume runs): it reaches the announced wait and re-arms it",
252            },
253        );
254
255        self.backlogs(&mut out, page, at).await?;
256        Ok(out)
257    }
258
259    /// Failed runs standing on landed work nothing undid.
260    ///
261    /// `failed` is left off the run conclusions because a resume may clear
262    /// it — except where the run stands on landed work: the failing step's own
263    /// mutation stays in the world until somebody finishes the run or unwinds
264    /// it, and nothing drives either.
265    async fn failed_on_landed_work(
266        &self,
267        out: &mut Attention,
268        page: usize,
269    ) -> Result<Vec<crate::core::RunId>, RuntimeError> {
270        let failed = self
271            .store()
272            .runs_by_outcome("failed", page)
273            .await
274            .map_err(RuntimeError::from_store)?;
275        let mut landed = Vec::new();
276        for run in &failed {
277            if self.holds_landed_work(*run).await? {
278                landed.push(run.to_string());
279            }
280        }
281        // The page bounds the failed runs read, not the ones found: a full
282        // page of failures means there may be more on landed work, so the
283        // count is a floor whenever that page filled.
284        let ceiling = if failed.len() >= page {
285            landed.len()
286        } else {
287            page
288        };
289        out.note(
290            "run.failed_with_landed_work",
291            landed.len(),
292            landed,
293            ceiling,
294            Remedy {
295                cli: "`replay` the run to finish its work, or `cancel` it to unwind what \
296                      landed",
297                http: "resume the run with `agentplane replay` (this API does not resume \
298                       runs) to finish its work, or `POST /runs/{run}/cancel` to unwind \
299                       what landed",
300            },
301        );
302
303        Ok(failed)
304    }
305
306    /// Stopped runs still holding spend against the tenant's period.
307    ///
308    /// A reservation is released when its run concludes, so one held by a run
309    /// that waits on a person is capacity nobody gets back until that person
310    /// acts — and a period filled by them refuses every new run while
311    /// settling nothing. A suspended run waiting on its own wait is not here:
312    /// it will resume and spend what it holds, which is what holding it is for.
313    async fn held_by_stopped_runs(
314        &self,
315        out: &mut Attention,
316        stopped: &std::collections::BTreeSet<crate::core::RunId>,
317        page: usize,
318    ) -> Result<(), RuntimeError> {
319        let Some(quotas) = self.quota_store_if_wired() else {
320            out.not_checked.push(
321                "quota reservations — this plane holds no quota store, so whether a \
322                 stopped run holds part of the tenant's period was not established",
323            );
324            return Ok(());
325        };
326        let held = quotas
327            .reservations(page)
328            .await
329            .map_err(RuntimeError::Store)?;
330        let named: Vec<String> = held
331            .iter()
332            .filter(|h| stopped.contains(&h.run))
333            .map(|h| h.run.to_string())
334            .collect();
335        // The page bounds the holds read, so a full page is a floor.
336        let ceiling = if held.len() >= page {
337            named.len()
338        } else {
339            page
340        };
341        out.note(
342            "quota.held_by_stopped_run",
343            named.len(),
344            named,
345            ceiling,
346            Remedy {
347                cli: "each holds spend its tenant's period cannot admit against until it \
348                      concludes: answer the run's own condition — `replay` it to finish, \
349                      `cancel` it, or answer its `quarantine` — and what it did not spend \
350                      is released",
351                http: "each holds spend its tenant's period cannot admit against until it \
352                       concludes: resume it with `agentplane replay`, \
353                       `POST /runs/{run}/cancel` it, or `POST /runs/{run}/abandon` a \
354                       quarantine — and what it did not spend is released",
355            },
356        );
357        Ok(())
358    }
359
360    /// The four backlogs that live outside the journal, each with a listing and
361    /// a verb that empties it.
362    ///
363    /// Split from the run conclusions above because the two answer from
364    /// different places: those read the journal every plane has, and these read
365    /// stores a plane may not have been given — which is why every arm here has
366    /// a `None` that says so rather than passing over it.
367    #[allow(clippy::too_many_lines)]
368    async fn backlogs(
369        &self,
370        out: &mut Attention,
371        page: usize,
372        now: Timestamp,
373    ) -> Result<(), RuntimeError> {
374        match self.cases() {
375            Some(cases) => {
376                let breached = cases
377                    .breached(page)
378                    .await
379                    .map_err(RuntimeError::from_store)?;
380                out.note(
381                    "obligation.breached",
382                    breached.len(),
383                    breached.iter().map(|d| format!("{}/{}", d.case, d.name)),
384                    page,
385                    Remedy {
386                        cli: "`acknowledge` the breach, saying what was done about it \
387                              (each subject is <case>/<obligation>)",
388                        http: "`POST /obligations/acknowledge` the breach, saying what \
389                               was done about it (each subject is <case>/<obligation>)",
390                    },
391                );
392            }
393            None => out.not_checked.push(
394                "obligations — this plane holds no case store, so whether any went \
395                 unaccounted for was not established",
396            ),
397        }
398
399        // A recovery rehearsal that found unrecoverable references is the
400        // clearest "somebody must look" this plane has, and it stays true
401        // until a later drill passes — which is the point, not noise.
402        //
403        // Deliberately **not** raised for a plane that has never drilled: a
404        // fresh plane is not broken, and a condition that fires on every new
405        // deployment is one operators learn to clear without reading. Nor for
406        // `not_checked`, which on a plane with no blob store is permanent and
407        // would make this condition unclearable.
408        match self.cases() {
409            Some(cases) => {
410                let failed = cases
411                    .last_drill()
412                    .await
413                    .map_err(RuntimeError::from_store)?
414                    .is_some_and(|d| !d.sound);
415                out.note(
416                    "drill.failed",
417                    usize::from(failed),
418                    // A verdict, not a backlog: there is nothing to name.
419                    std::iter::empty(),
420                    // Not a page: there is one row, so it can never be a
421                    // ceiling, and reporting it as one would be a lie in the
422                    // shape of a number.
423                    usize::MAX,
424                    Remedy {
425                        cli: "the last recovery rehearsal found unrecoverable references — \
426                              re-run `drill` and resolve what it names",
427                        http: "the last recovery rehearsal found unrecoverable references \
428                               (`GET /drill` reads the verdict) — re-run it with \
429                               `agentplane drill` and resolve what it names",
430                    },
431                );
432            }
433            None => out.not_checked.push(
434                "recovery rehearsal — this plane holds no case store, so there is \
435                 nothing to drill and no verdict to read",
436            ),
437        }
438
439        match self.tasks() {
440            Some(tasks) => {
441                let overdue = tasks
442                    .overdue(now, page)
443                    .await
444                    .map_err(RuntimeError::from_store)?;
445                out.note(
446                    "task.overdue",
447                    overdue.len(),
448                    overdue.iter().map(|t| t.id.to_string()),
449                    page,
450                    Remedy {
451                        cli: "`decide` them. Escalation is not an operator act — the \
452                              sweeper widens the audience itself when the window closes",
453                        http: "`POST /tasks/{task}/decide` them. Escalation is not an \
454                               operator act — the sweeper widens the audience itself when \
455                               the window closes",
456                    },
457                );
458            }
459            None => out.not_checked.push(
460                "worklist — this plane holds no task store, so whether any approval is \
461                 overdue was not established",
462            ),
463        }
464
465        match self.events() {
466            Some(events) => {
467                let dead = events
468                    .dead_letters(page)
469                    .await
470                    .map_err(RuntimeError::from_store)?;
471                out.note(
472                    "event.dead_lettered",
473                    dead.len(),
474                    dead.iter()
475                        .map(|d| format!("{} {}", d.event.source, d.event.id)),
476                    page,
477                    Remedy {
478                        cli: "no verb: the correlation key belongs to the emitter, so \
479                              this is a diagnosis to carry to them (each subject is \
480                              <source> <id>)",
481                        http: "no verb: the correlation key belongs to the emitter, so \
482                               this is a diagnosis to carry to them — `GET /dead-letters` \
483                               lists what arrived",
484                    },
485                );
486            }
487            None => out.not_checked.push(
488                "dead letters — this plane holds no event store, so whether an inbound \
489                 message went unclaimed was not established",
490            ),
491        }
492
493        // Behind the feature, and *said* rather than silently absent: a build
494        // without outbound delivery has no parked registrations to find, and a
495        // reader comparing two planes' answers needs to know which of them
496        // could even look.
497        #[cfg(feature = "push")]
498        match self.push() {
499            Some(push) => {
500                let parked = push.parked(page).await.map_err(RuntimeError::from_store)?;
501                out.note(
502                    "push.parked",
503                    parked.len(),
504                    parked
505                        .iter()
506                        .map(|p| format!("{}/{}", p.config.task, p.config.id)),
507                    page,
508                    Remedy {
509                        cli: "fix the endpoint, then `rearm` the registration (each \
510                              subject is <run>/<id>)",
511                        http: "fix the endpoint, then `POST /push/rearm` the registration \
512                               (each subject is <run>/<id>)",
513                    },
514                );
515            }
516            None => out.not_checked.push(
517                "push registrations — this plane holds no push store, so whether a \
518                 worker gave up on one was not established",
519            ),
520        }
521        #[cfg(not(feature = "push"))]
522        out.not_checked
523            .push("push registrations — this build has no outbound delivery");
524
525        Ok(())
526    }
527}