Skip to main content

layover_core/help/
mod.rs

1//! Agents asking for help.
2//!
3//! A lights-out factory's worst failure is not a crash — a crash is loud. It is an agent that
4//! quietly cannot do the thing it was asked to do, produces something plausible anyway, and
5//! passes it downstream. The way out is for the agent to say so, in a form a human can act on
6//! without reconstructing the run.
7//!
8//! # Why this is not just a log line
9//!
10//! Logs are written for the run; a help request is written for a person who is not watching. It
11//! needs to survive the run, be findable without knowing which run produced it, and carry enough
12//! to act on. That makes it a record, not a message.
13//!
14//! # The text is not trusted
15//!
16//! `summary` and `detail` are written by an agent explaining why something did not work, and the
17//! commonest reason is a credential. They are therefore redacted and capped on the way in — see
18//! [`redact`] — because from here they go to disk, to an unauthenticated HTTP API and to a
19//! dashboard, and none of those can take it back.
20//!
21//! # What the shape is for
22//!
23//! Every field here exists to answer a question somebody asks when they open the dashboard and
24//! find work stopped: *what is stuck, why, was it fatal, and has it been happening?*
25//!
26//! The categories come from measurement rather than imagination. In a sibling project's help file,
27//! five of six entries were permission or access failures — a denied tool guard, a denied git
28//! read, an HTTP 422, a TLS handshake. [`Blocker::Access`] is the common case by a wide margin,
29//! and it is worth being able to filter for it.
30
31pub mod redact;
32
33use std::fmt;
34
35use jiff::Timestamp;
36use serde::{Deserialize, Serialize};
37
38use crate::agent::AgentName;
39use crate::flight::{ItineraryId, RunId};
40
41/// What kind of thing an agent is stuck on.
42///
43/// Coarse on purpose. The point is to make "half my runs are stuck on credentials" visible at a
44/// glance; the detail lives in the prose.
45#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize, Serialize)]
46#[serde(rename_all = "snake_case")]
47pub enum Blocker {
48    /// A credential is missing, expired, or refused. The single most common blocker in practice.
49    Access,
50    /// A tool, command or service the agent needed was unavailable or failed.
51    Tooling,
52    /// The instructions or the work item did not say enough to proceed.
53    Ambiguity,
54    /// The workspace was not in the state the agent needed: a missing checkout, a dirty tree.
55    Environment,
56    /// A decision that is not the agent's to make.
57    Decision,
58    /// None of the above.
59    Other,
60}
61
62impl Blocker {
63    /// Every category, in the order a dashboard should offer them.
64    pub const ALL: [Self; 6] = [
65        Self::Access,
66        Self::Tooling,
67        Self::Ambiguity,
68        Self::Environment,
69        Self::Decision,
70        Self::Other,
71    ];
72
73    /// The identifier used in query strings and JSON.
74    #[must_use]
75    pub fn slug(self) -> &'static str {
76        match self {
77            Self::Access => "access",
78            Self::Tooling => "tooling",
79            Self::Ambiguity => "ambiguity",
80            Self::Environment => "environment",
81            Self::Decision => "decision",
82            Self::Other => "other",
83        }
84    }
85
86    /// Parses a slug.
87    #[must_use]
88    pub fn from_slug(slug: &str) -> Option<Self> {
89        Self::ALL.into_iter().find(|kind| kind.slug() == slug)
90    }
91
92    /// A one-line description, used when prompting an agent to choose one.
93    #[must_use]
94    pub fn describe(self) -> &'static str {
95        match self {
96            Self::Access => "a credential or permission was missing, expired or refused",
97            Self::Tooling => "a command, tool or service was unavailable or failed",
98            Self::Ambiguity => "the instructions or the work item did not say enough",
99            Self::Environment => "the workspace was not in a usable state",
100            Self::Decision => "a choice that is not yours to make",
101            Self::Other => "something else",
102        }
103    }
104}
105
106impl fmt::Display for Blocker {
107    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
108        f.write_str(self.slug())
109    }
110}
111
112/// One agent asking a human for something.
113#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
114pub struct HelpRequest {
115    /// Who is asking.
116    pub agent: AgentName,
117    /// The run that raised it.
118    pub run: RunId,
119    /// The chain it belonged to.
120    pub itinerary: ItineraryId,
121    /// What kind of thing is in the way.
122    pub blocker: Blocker,
123    /// One line, for a list.
124    pub summary: String,
125    /// The whole explanation: what was tried, what happened, what is needed.
126    pub detail: String,
127    /// Whether this stopped the work or merely limited it.
128    ///
129    /// The distinction matters and is easy to lose. An agent can finish its task and still have
130    /// been unable to check something — that is worth reporting, and it is not an outage.
131    pub fatal: bool,
132    /// When it was raised.
133    pub at: Timestamp,
134    /// When a human marked it dealt with.
135    #[serde(default, skip_serializing_if = "Option::is_none")]
136    pub resolved_at: Option<Timestamp>,
137}
138
139impl HelpRequest {
140    /// Records a request.
141    #[must_use]
142    pub fn new(
143        agent: AgentName,
144        run: RunId,
145        itinerary: ItineraryId,
146        blocker: Blocker,
147        summary: impl Into<String>,
148        detail: impl Into<String>,
149        at: Timestamp,
150    ) -> Self {
151        Self {
152            agent,
153            run,
154            itinerary,
155            blocker,
156            summary: redact::detail(&summary.into()),
157            detail: redact::detail(&detail.into()),
158            fatal: false,
159            at,
160            resolved_at: None,
161        }
162    }
163
164    /// Marks the request as having stopped the work.
165    #[must_use]
166    pub fn fatal(mut self) -> Self {
167        self.fatal = true;
168        self
169    }
170
171    /// Marks it dealt with.
172    #[must_use]
173    pub fn resolved(mut self, at: Timestamp) -> Self {
174        self.resolved_at = Some(at);
175        self
176    }
177
178    /// Returns `true` when nobody has dealt with this yet.
179    #[must_use]
180    pub fn is_open(&self) -> bool {
181        self.resolved_at.is_none()
182    }
183
184    /// Returns `true` when this asks for the same thing as `other`.
185    ///
186    /// Used to keep a recurring blocker from being filed on every run. A factory whose
187    /// credentials expired does not need forty identical requests; it needs one, and a count.
188    #[must_use]
189    pub fn is_same_ask_as(&self, other: &Self) -> bool {
190        self.agent == other.agent
191            && self.blocker == other.blocker
192            && crate::learning::says_the_same_thing(&self.summary, &other.summary)
193    }
194}
195
196#[cfg(test)]
197mod tests {
198    use super::*;
199
200    fn at(rfc3339: &str) -> Timestamp {
201        rfc3339.parse().expect("valid timestamp")
202    }
203
204    fn request(agent: &str, blocker: Blocker, summary: &str) -> HelpRequest {
205        HelpRequest::new(
206            agent.into(),
207            RunId::generate(),
208            ItineraryId::generate(),
209            blocker,
210            summary,
211            "tried X, got Y, need Z",
212            at("2026-09-16T10:00:00Z"),
213        )
214    }
215
216    #[test]
217    fn a_request_starts_open_and_not_fatal() {
218        let asked = request("publisher", Blocker::Access, "the ADO token is expired");
219
220        assert!(asked.is_open());
221        assert!(
222            !asked.fatal,
223            "an agent can be limited without being stopped, and the default should not overstate"
224        );
225    }
226
227    #[test]
228    fn a_blocked_run_and_a_limited_one_are_distinguishable() {
229        // An agent can finish its task and still have been unable to check something. Reporting
230        // that is useful; treating it as an outage is not.
231        let stopped = request("publisher", Blocker::Access, "no token at all").fatal();
232        let limited = request(
233            "reviewer",
234            Blocker::Tooling,
235            "could not read one linked file",
236        );
237
238        assert!(stopped.fatal);
239        assert!(!limited.fatal);
240    }
241
242    #[test]
243    fn resolving_closes_it() {
244        let asked = request("publisher", Blocker::Access, "token expired")
245            .resolved(at("2026-09-16T11:00:00Z"));
246
247        assert!(!asked.is_open());
248        assert_eq!(asked.resolved_at, Some(at("2026-09-16T11:00:00Z")));
249    }
250
251    #[test]
252    fn the_same_blocker_asked_twice_is_recognised() {
253        // A factory whose credentials expired does not need forty identical requests. It needs
254        // one, and a count.
255        let first = request("publisher", Blocker::Access, "The ADO token has expired");
256        let again = request("publisher", Blocker::Access, "the ADO token  has expired.");
257
258        assert!(first.is_same_ask_as(&again));
259    }
260
261    #[test]
262    fn a_different_agent_or_category_is_a_different_ask() {
263        let publisher = request("publisher", Blocker::Access, "the ADO token is expired");
264
265        assert!(!publisher.is_same_ask_as(&request(
266            "developer",
267            Blocker::Access,
268            "the ADO token is expired"
269        )));
270        assert!(!publisher.is_same_ask_as(&request(
271            "publisher",
272            Blocker::Tooling,
273            "the ADO token is expired"
274        )));
275    }
276
277    #[test]
278    fn categories_round_trip_and_describe_themselves() {
279        for blocker in Blocker::ALL {
280            assert_eq!(Blocker::from_slug(blocker.slug()), Some(blocker));
281            assert!(!blocker.describe().is_empty());
282        }
283
284        assert_eq!(Blocker::from_slug("vibes"), None);
285    }
286
287    #[test]
288    fn a_request_serialises_to_one_readable_line() {
289        let line = serde_json::to_string(&request("publisher", Blocker::Access, "token expired"))
290            .expect("serialises");
291
292        assert!(!line.contains('\n'));
293        assert!(line.contains(r#""blocker":"access""#), "{line}");
294        assert!(!line.contains("resolved_at"), "absent, not null: {line}");
295    }
296}