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}