Skip to main content

harness_loop/
events.rs

1//! What wakes a host that is not in the middle of a run.
2//!
3//! A run is a loop the *model* drives; between runs nothing does. A background
4//! job that finishes, or runs long, has no one to tell — the agent said "I'll
5//! let you know when CI is done" and then the REPL sat in `read_line`. The fix
6//! is on the host side: every source of "something happened" (keyboard, job
7//! table, later a scheduler or webhook) feeds one queue of [`HostEvent`]s, and
8//! the host's event loop decides what each does:
9//!
10//! - **idle** — start a run with the event as its input;
11//! - **busy** — park it in a [`NoticeGuide`], which hands it to the model
12//!   before its next call, without waiting for the run to end.
13//!
14//! The guide only ever *appends* a user turn to the end of the history. It
15//! never edits what is already there, so a provider's prompt cache keeps
16//! matching the prefix.
17
18use async_trait::async_trait;
19use harness_core::{
20    Block, Context, Execution, Guide, GuideError, GuideId, GuideScope, Turn, TurnRole, World,
21};
22use harness_tools::shell::JobEvent;
23use std::sync::{Arc, Mutex};
24use std::time::Duration;
25
26/// One thing that happened to the host. Not `harness_core::Event`, which is
27/// the hook enum fired from *inside* a run; these arrive from outside it.
28#[derive(Debug, Clone)]
29#[non_exhaustive]
30pub enum HostEvent {
31    /// A line the person typed.
32    Input(String),
33    /// A background job ended.
34    JobExited {
35        id: u64,
36        program: String,
37        exit_status: i32,
38        running_for: Duration,
39        tail: String,
40    },
41    /// A background job is still running past the time it was expected to take.
42    JobOverdue {
43        id: u64,
44        program: String,
45        running_for: Duration,
46        tail: String,
47    },
48}
49
50impl From<JobEvent> for HostEvent {
51    fn from(ev: JobEvent) -> Self {
52        match ev {
53            JobEvent::Exited {
54                id,
55                program,
56                exit_status,
57                running_for,
58                tail,
59            } => HostEvent::JobExited {
60                id,
61                program,
62                exit_status,
63                running_for,
64                tail,
65            },
66            JobEvent::Overdue {
67                id,
68                program,
69                running_for,
70                tail,
71            } => HostEvent::JobOverdue {
72                id,
73                program,
74                running_for,
75                tail,
76            },
77            // `JobEvent` is non_exhaustive; a kind this host does not know is
78            // reported as what it is rather than dropped.
79            other => HostEvent::Input(format!("[unrecognised job event: {other:?}]")),
80        }
81    }
82}
83
84impl HostEvent {
85    /// One line for the person at the terminal; `None` for typed input, which
86    /// they already saw. E.g. `job 3 (gh) exited 0 after 21m`.
87    pub fn headline(&self) -> Option<String> {
88        match self {
89            HostEvent::JobExited {
90                id,
91                program,
92                exit_status,
93                running_for,
94                ..
95            } => Some(format!(
96                "job {id} ({program}) exited {exit_status} after {}",
97                fmt_elapsed(*running_for)
98            )),
99            HostEvent::JobOverdue {
100                id,
101                program,
102                running_for,
103                ..
104            } => Some(format!(
105                "job {id} ({program}) still running after {}",
106                fmt_elapsed(*running_for)
107            )),
108            _ => None,
109        }
110    }
111
112    /// The text the model reads for this event: what happened, the tail of the
113    /// job's output, and what is expected of it. `None` for typed input, which
114    /// is already the model's input as it stands.
115    pub fn notice(&self) -> Option<String> {
116        let (head, tail, ask) = match self {
117            HostEvent::JobExited {
118                tail, exit_status, ..
119            } => (
120                self.headline()?,
121                tail,
122                if *exit_status == 0 {
123                    "Tell the user the result.".to_string()
124                } else {
125                    "Tell the user it failed and why, from the output.".to_string()
126                },
127            ),
128            HostEvent::JobOverdue { id, tail, .. } => (
129                self.headline()?,
130                tail,
131                // Overdue is a question, not a verdict: the job may be fine.
132                format!(
133                    "Check it with shell_job_status (id {id}); tell the user whether it is \
134                     progressing, and kill it with shell_job_kill if it is stuck."
135                ),
136            ),
137            _ => return None,
138        };
139        let mut s = format!("[background job notice] {head}.");
140        if !tail.trim().is_empty() {
141            s.push_str(&format!("\nLast output:\n{}", tail.trim_end()));
142        }
143        s.push('\n');
144        s.push_str(&ask);
145        Some(s)
146    }
147}
148
149/// `45s`, `21m`, `1h05m` — coarse on purpose, it is read at a glance.
150pub fn fmt_elapsed(d: Duration) -> String {
151    let s = d.as_secs();
152    match s {
153        0..=59 => format!("{s}s"),
154        60..=3599 => format!("{}m", s / 60),
155        _ => format!("{}h{:02}m", s / 3600, (s % 3600) / 60),
156    }
157}
158
159/// Notices waiting for the model, delivered before its next call.
160///
161/// Share one `Arc<NoticeGuide>`: register a clone on the loop with
162/// `with_guide`, keep the other to [`push`](Self::push) from the host's event
163/// loop. Delivery is append-only — one user turn at the end of the history.
164pub struct NoticeGuide {
165    id: GuideId,
166    scope: GuideScope,
167    state: Mutex<NoticeState>,
168}
169
170#[derive(Default)]
171struct NoticeState {
172    pending: Vec<String>,
173    /// What has been handed to the model since the host last asked. A run's
174    /// context is thrown away when it ends, so the host needs this to keep the
175    /// notices in the conversation it carries into the next run.
176    delivered: Vec<String>,
177}
178
179impl NoticeGuide {
180    pub fn new() -> Arc<Self> {
181        Arc::new(Self {
182            id: "host-notices".into(),
183            scope: GuideScope::Always,
184            state: Mutex::new(NoticeState::default()),
185        })
186    }
187
188    /// Queue a notice for the model's next call.
189    pub fn push(&self, text: impl Into<String>) {
190        self.state.lock().unwrap().pending.push(text.into());
191    }
192
193    /// Anything not yet shown to the model? A run that ends with notices
194    /// still pending never gave the model the chance to read them.
195    pub fn has_pending(&self) -> bool {
196        !self.state.lock().unwrap().pending.is_empty()
197    }
198
199    /// Take what is still waiting (e.g. to use as the next run's input).
200    pub fn take_pending(&self) -> Vec<String> {
201        std::mem::take(&mut self.state.lock().unwrap().pending)
202    }
203
204    /// Take what the guide has delivered since the last call.
205    pub fn take_delivered(&self) -> Vec<String> {
206        std::mem::take(&mut self.state.lock().unwrap().delivered)
207    }
208}
209
210#[async_trait]
211impl Guide for NoticeGuide {
212    fn id(&self) -> &GuideId {
213        &self.id
214    }
215    fn kind(&self) -> Execution {
216        Execution::Computational
217    }
218    fn scope(&self) -> &GuideScope {
219        &self.scope
220    }
221    async fn apply(&self, _ctx: &mut Context, _w: &World) -> Result<(), GuideError> {
222        Ok(())
223    }
224    async fn apply_before_iter(&self, ctx: &mut Context, _w: &World) -> Result<(), GuideError> {
225        let mut st = self.state.lock().unwrap();
226        if st.pending.is_empty() {
227            return Ok(());
228        }
229        let notices = std::mem::take(&mut st.pending);
230        ctx.history.push(Turn {
231            role: TurnRole::User,
232            blocks: vec![Block::Text(notices.join("\n\n"))],
233        });
234        st.delivered.extend(notices);
235        Ok(())
236    }
237}
238
239#[cfg(test)]
240mod tests {
241    use super::*;
242    use harness_core::Task;
243
244    fn ctx() -> Context {
245        Context::new(Task {
246            description: "t".into(),
247            source: None,
248            deadline: None,
249        })
250    }
251
252    fn exited(code: i32) -> HostEvent {
253        HostEvent::JobExited {
254            id: 3,
255            program: "gh".into(),
256            exit_status: code,
257            running_for: Duration::from_secs(21 * 60 + 5),
258            tail: "all checks passed\n".into(),
259        }
260    }
261
262    #[test]
263    fn headline_is_one_short_line() {
264        assert_eq!(
265            exited(0).headline().unwrap(),
266            "job 3 (gh) exited 0 after 21m"
267        );
268        assert_eq!(HostEvent::Input("hi".into()).headline(), None);
269        assert_eq!(fmt_elapsed(Duration::from_secs(45)), "45s");
270        assert_eq!(fmt_elapsed(Duration::from_secs(3900)), "1h05m");
271    }
272
273    #[test]
274    fn notice_carries_the_tail_and_what_to_do() {
275        let n = exited(1).notice().unwrap();
276        assert!(n.contains("job 3 (gh) exited 1"));
277        assert!(n.contains("all checks passed"));
278        assert!(n.contains("failed"));
279        let overdue = HostEvent::JobOverdue {
280            id: 7,
281            program: "make".into(),
282            running_for: Duration::from_secs(600),
283            tail: String::new(),
284        };
285        let n = overdue.notice().unwrap();
286        assert!(n.contains("shell_job_status (id 7)"));
287        assert!(!n.contains("Last output"));
288        assert!(HostEvent::Input("x".into()).notice().is_none());
289    }
290
291    #[tokio::test]
292    async fn guide_appends_one_user_turn_and_only_when_pending() {
293        let g = NoticeGuide::new();
294        let w = harness_context::default_world(".");
295        let mut c = ctx();
296        c.history.push(Turn {
297            role: TurnRole::User,
298            blocks: vec![Block::Text("earlier".into())],
299        });
300
301        g.apply_before_iter(&mut c, &w).await.unwrap();
302        assert_eq!(c.history.len(), 1, "nothing pending, nothing appended");
303
304        g.push("one");
305        g.push("two");
306        assert!(g.has_pending());
307        g.apply_before_iter(&mut c, &w).await.unwrap();
308        assert_eq!(c.history.len(), 2);
309        assert_eq!(c.history[0].blocks.len(), 1, "earlier history untouched");
310        assert!(matches!(c.history[1].role, TurnRole::User));
311        let Block::Text(t) = &c.history[1].blocks[0] else {
312            panic!("expected text")
313        };
314        assert!(t.contains("one") && t.contains("two"));
315        assert!(!g.has_pending());
316        assert_eq!(g.take_delivered(), vec!["one", "two"]);
317        assert!(g.take_delivered().is_empty());
318    }
319}