Skip to main content

mecha_core/
runmarker.rs

1//! "Is a run in flight, and please stop it" — as two files in a directory.
2//!
3//! Lifted out of `trigger.rs` when `mecha tasks work` needed the same thing,
4//! because the mechanism is four subtle rules and two copies is two places for
5//! one of them to rot:
6//!
7//! - **A marker, not the flock.** The obvious way to ask "is it running?" is to
8//!   try to claim the lock and see — but that acquires and drops it, so a UI
9//!   polling the question would occasionally hold the lock at the instant a
10//!   scheduler tried to fire and cause a spurious overlap skip. Watching must
11//!   never perturb what is watched.
12//! - **A marker whose process is gone is a crashed run, not a running one.**
13//!   It is cleaned up and reported absent, so a hard kill cannot leave
14//!   something looking permanently busy in every surface that asks. That rests
15//!   entirely on the pid range check in [`crate::process_alive`]: `kill(-1, 0)`
16//!   succeeds and would report every dead run as alive.
17//! - **Cancel is a file, never a signal.** The run may be inside a caller's own
18//!   process — a trigger firing in the daemon — where SIGTERM would take the
19//!   whole scheduler down. The runner polls for the file and cancels its own
20//!   token, which stops at the next safe point with the partial answer intact:
21//!   the same path as Ctrl-C and the timeout, rather than a kill that discards
22//!   the very thing cancellation exists to preserve.
23//! - **Clearing removes both files.** A cancel that arrives as a run is ending
24//!   must not be left lying around to kill the *next* one.
25
26use anyhow::Result;
27use chrono::{DateTime, Utc};
28use serde::{Deserialize, Serialize};
29use std::path::{Path, PathBuf};
30
31/// Who is running something right now.
32#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct RunMarker {
34    pub pid: u32,
35    pub started_at: DateTime<Utc>,
36    /// The scheduled slot this run is accounting for, when it has one.
37    /// Absent for anything a person started by hand.
38    #[serde(default, skip_serializing_if = "Option::is_none")]
39    pub slot: Option<DateTime<Utc>>,
40    /// The transcript this run is writing, when it has one.
41    ///
42    /// **So another process can ask "does a live run own this session?"
43    /// without asking the run.** A `Conversation` — messages and taint — lives
44    /// in the memory of the process holding it and the session JSONL has one
45    /// writer, so anything that would pick a transcript up (`mecha chat
46    /// --resume`, `/api/resume`) has to be able to find out that somebody
47    /// already has it. The in-process check those surfaces already do cannot
48    /// see a detached child, and the board can only say a run is in flight,
49    /// not which file it is appending to.
50    ///
51    /// Defaulted on load, like every other field written to a store that
52    /// outlives a release: a marker from a run that started before this field
53    /// existed reads as "no session named", which is what it was.
54    #[serde(default, skip_serializing_if = "Option::is_none")]
55    pub session: Option<String>,
56}
57
58/// A directory of run markers, keyed by whatever the caller calls its runs.
59pub struct RunMarkers {
60    dir: PathBuf,
61}
62
63impl RunMarkers {
64    pub fn new(dir: impl Into<PathBuf>) -> Self {
65        RunMarkers { dir: dir.into() }
66    }
67
68    pub fn dir(&self) -> &Path {
69        &self.dir
70    }
71
72    fn marker_path(&self, name: &str) -> PathBuf {
73        self.dir.join(format!("{name}.running"))
74    }
75
76    fn cancel_path(&self, name: &str) -> PathBuf {
77        self.dir.join(format!("{name}.cancel"))
78    }
79
80    fn steer_path(&self, name: &str) -> PathBuf {
81        self.dir.join(format!("{name}.steer"))
82    }
83
84    /// Announce that a run has started, for anything that wants to *display*
85    /// whether one is in flight.
86    pub fn mark_running(&self, name: &str, slot: Option<DateTime<Utc>>) -> Result<()> {
87        self.mark_running_for(name, slot, None)
88    }
89
90    /// The same, naming the transcript this run is writing — see
91    /// [`RunMarker::session`] for why anything else would have to ask the run.
92    pub fn mark_running_for(
93        &self,
94        name: &str,
95        slot: Option<DateTime<Utc>>,
96        session: Option<&str>,
97    ) -> Result<()> {
98        crate::create_private_dir(&self.dir)?;
99        // **A run starts uncancelled, whatever was left lying around.**
100        // `clear` removes both files, but a cancel written in the window
101        // between `request_cancel`'s liveness check and the previous run's
102        // `clear` survives it — as does one left by a SIGKILL or a reboot.
103        // `cancel_requested` is a bare existence check, so the next run would
104        // stop itself two seconds in and report a near-empty partial that
105        // looks exactly like a model giving up.
106        let _ = std::fs::remove_file(self.cancel_path(name));
107        // **And uninstructed, for the same reason.** A steer queued in the
108        // window before the previous run's `clear`, or left by a kill, would
109        // otherwise be drained into the *next* run's first turn — an
110        // instruction about work that is already over, arriving as though the
111        // owner had just typed it.
112        let _ = std::fs::remove_file(self.steer_path(name));
113        let marker = RunMarker {
114            pid: std::process::id(),
115            started_at: Utc::now(),
116            slot,
117            session: session.map(str::to_string),
118        };
119        let path = self.marker_path(name);
120        let tmp = path.with_extension("running.tmp");
121        std::fs::write(&tmp, serde_json::to_string(&marker)?)?;
122        std::fs::rename(&tmp, &path)?;
123        Ok(())
124    }
125
126    /// Clear the marker and any unclaimed cancel request.
127    pub fn clear(&self, name: &str) {
128        let _ = std::fs::remove_file(self.marker_path(name));
129        let _ = std::fs::remove_file(self.cancel_path(name));
130        let _ = std::fs::remove_file(self.steer_path(name));
131    }
132
133    /// Which live run, if any, is writing this transcript.
134    ///
135    /// **The cross-process half of "one conversation, one writer".** Every
136    /// surface that picks a session back up already refuses to mint a twin of
137    /// one *this* process holds; none of them could see a detached child, so
138    /// resuming a delegation mid-flight would have given one JSONL two
139    /// writers — the child appending its turns and the reader appending the
140    /// owner's. Dead markers are swept by [`Self::running`] on the way past,
141    /// so a crashed run does not lock its transcript out forever.
142    ///
143    /// Returns the run's name (a task id, here), because a caller that has to
144    /// refuse should be able to say what it is refusing for.
145    pub fn live_writer_of(&self, session: &str) -> Option<String> {
146        let names: Vec<String> = std::fs::read_dir(&self.dir)
147            .ok()?
148            .filter_map(|e| e.ok())
149            .filter_map(|e| {
150                e.file_name()
151                    .to_str()
152                    .and_then(|n| n.strip_suffix(".running"))
153                    .map(str::to_string)
154            })
155            .collect();
156        names.into_iter().find(|name| {
157            self.running(name)
158                .and_then(|m| m.session)
159                .is_some_and(|s| s == session)
160        })
161    }
162
163    /// Queue an instruction for the run in flight, to be folded into the
164    /// message carrying its next tool results.
165    ///
166    /// **Appended, never overwritten.** Two instructions typed a second apart
167    /// are two things the owner meant; a file that held only the newest would
168    /// drop the first silently, which is the failure a queue exists to
169    /// prevent. One JSON string per line, so a newline in the text cannot
170    /// split one instruction into two.
171    ///
172    /// `false` when nothing is running, exactly as [`Self::request_cancel`]
173    /// reports it — a steer written for a run that will never read it is not
174    /// a queued instruction, it is a file waiting to ambush the next run.
175    pub fn queue_steer(&self, name: &str, text: &str) -> Result<bool> {
176        if self.running(name).is_none() {
177            return Ok(false);
178        }
179        crate::create_private_dir(&self.dir)?;
180        let mut line = serde_json::to_string(text)?;
181        line.push('\n');
182        use std::io::Write;
183        let mut f = std::fs::OpenOptions::new()
184            .create(true)
185            .append(true)
186            .open(self.steer_path(name))?;
187        f.write_all(line.as_bytes())?;
188        Ok(true)
189    }
190
191    /// Take everything queued, leaving nothing behind.
192    ///
193    /// **Drained rather than read**, because this module has already learned
194    /// what a file left lying around does: a steer that survived its own
195    /// delivery would be re-folded into every later turn, so one sentence
196    /// would arrive again and again for the rest of the run.
197    ///
198    /// A line that will not parse is skipped rather than failing the drain —
199    /// the alternative is one malformed byte silencing every instruction
200    /// behind it, and the caller is a poller with nowhere to report to.
201    pub fn take_steer(&self, name: &str) -> Vec<String> {
202        let path = self.steer_path(name);
203        let Ok(text) = std::fs::read_to_string(&path) else {
204            return Vec::new();
205        };
206        let _ = std::fs::remove_file(&path);
207        text.lines()
208            .filter_map(|l| serde_json::from_str::<String>(l).ok())
209            .collect()
210    }
211
212    /// The run in flight, if there is one.
213    pub fn running(&self, name: &str) -> Option<RunMarker> {
214        let text = std::fs::read_to_string(self.marker_path(name)).ok()?;
215        let marker: RunMarker = serde_json::from_str(&text).ok()?;
216        if crate::process_alive(marker.pid) {
217            Some(marker)
218        } else {
219            self.clear(name);
220            None
221        }
222    }
223
224    /// Ask the run in flight to stop. `false` when there is nothing to stop,
225    /// so a caller can say so rather than pretending it did something.
226    pub fn request_cancel(&self, name: &str) -> Result<bool> {
227        if self.running(name).is_none() {
228            return Ok(false);
229        }
230        crate::create_private_dir(&self.dir)?;
231        std::fs::write(self.cancel_path(name), Utc::now().to_rfc3339())?;
232        Ok(true)
233    }
234
235    /// Has a cancel been requested for the run in flight?
236    pub fn cancel_requested(&self, name: &str) -> bool {
237        self.cancel_path(name).exists()
238    }
239}
240
241#[cfg(test)]
242mod tests {
243    use super::*;
244
245    fn scratch(name: &str) -> PathBuf {
246        let dir = std::env::temp_dir().join(format!(
247            "mecha-runmarker-{name}-{}-{:?}",
248            std::process::id(),
249            std::thread::current().id()
250        ));
251        let _ = std::fs::remove_dir_all(&dir);
252        dir
253    }
254
255    #[test]
256    fn a_marker_reports_its_own_run_and_clears() {
257        let m = RunMarkers::new(scratch("basic"));
258        assert!(m.running("a").is_none());
259        m.mark_running("a", None).unwrap();
260        assert_eq!(m.running("a").unwrap().pid, std::process::id());
261        m.clear("a");
262        assert!(m.running("a").is_none());
263    }
264
265    /// A hard kill must not leave something looking busy forever. This is the
266    /// check that rests on the pid range guard: `kill(-1, 0)` succeeds, so a
267    /// naive `process_alive` would report every dead run as alive.
268    #[test]
269    fn a_marker_whose_process_is_gone_reads_as_not_running() {
270        let m = RunMarkers::new(scratch("dead"));
271        crate::create_private_dir(m.dir()).unwrap();
272        // pid 0 is never a live process this could be, and is exactly the
273        // value the range check exists to refuse.
274        std::fs::write(
275            m.dir().join("a.running"),
276            serde_json::json!({"pid": 0, "started_at": Utc::now().to_rfc3339()}).to_string(),
277        )
278        .unwrap();
279        assert!(m.running("a").is_none(), "a dead pid is not a running run");
280        assert!(
281            !m.dir().join("a.running").exists(),
282            "and the stale marker is swept on the way past"
283        );
284    }
285
286    /// The cross-process half of "one conversation, one writer": a reader in
287    /// another process can find out that a live run owns a transcript, which
288    /// is the only thing standing between `resume` and two writers on one
289    /// JSONL. A dead marker must not lock a transcript out forever, so the
290    /// sweep in `running` is load-bearing here too.
291    #[test]
292    fn a_live_marker_names_the_transcript_it_is_writing() {
293        let m = RunMarkers::new(scratch("writer"));
294        m.mark_running_for("task-1", None, Some("20260826T1200-abc"))
295            .unwrap();
296        assert_eq!(
297            m.live_writer_of("20260826T1200-abc").as_deref(),
298            Some("task-1"),
299            "the owner is findable by the file it is writing"
300        );
301        assert!(
302            m.live_writer_of("20260826T1200-other").is_none(),
303            "and only that file"
304        );
305        m.clear("task-1");
306        assert!(
307            m.live_writer_of("20260826T1200-abc").is_none(),
308            "a finished run releases its transcript"
309        );
310    }
311
312    /// A marker written before the field existed reads as naming no session,
313    /// which is what it was — the store outlives the release, so a missing
314    /// field must load rather than fail the record.
315    #[test]
316    fn a_marker_without_a_session_still_loads() {
317        let m = RunMarkers::new(scratch("oldmarker"));
318        crate::create_private_dir(m.dir()).unwrap();
319        std::fs::write(
320            m.dir().join("t.running"),
321            serde_json::json!({"pid": std::process::id(), "started_at": Utc::now().to_rfc3339()})
322                .to_string(),
323        )
324        .unwrap();
325        assert!(m.running("t").is_some(), "it is still a running run");
326        assert!(m.live_writer_of("anything").is_none());
327    }
328
329    /// Two instructions typed a second apart are two things the owner meant.
330    /// Overwriting would drop the first silently, which is the one failure a
331    /// queue exists to prevent.
332    #[test]
333    fn steers_queue_up_and_drain_exactly_once() {
334        let m = RunMarkers::new(scratch("steer"));
335        m.mark_running("t", None).unwrap();
336        assert!(m.queue_steer("t", "check the dates first").unwrap());
337        assert!(m.queue_steer("t", "and use\nthe short form").unwrap());
338        assert_eq!(
339            m.take_steer("t"),
340            vec!["check the dates first", "and use\nthe short form"],
341            "both, in order, and a newline does not split one into two"
342        );
343        assert!(
344            m.take_steer("t").is_empty(),
345            "drained, or one sentence arrives on every later turn for the rest                  of the run"
346        );
347    }
348
349    /// A steer for a run that is not there is not a queued instruction — it
350    /// is a file waiting to ambush the next run, which is exactly what the
351    /// stale-cancel test above was written for.
352    #[test]
353    fn a_steer_needs_a_run_to_steer_and_never_outlives_one() {
354        let m = RunMarkers::new(scratch("steerstale"));
355        assert!(
356            !m.queue_steer("t", "too late").unwrap(),
357            "nothing running, so nothing queued — and the caller is told"
358        );
359        m.mark_running("t", None).unwrap();
360        assert!(m.take_steer("t").is_empty(), "and nothing was written");
361
362        // The shape a kill leaves: a steer with no run to consume it.
363        m.queue_steer("t", "from the run that died").unwrap();
364        m.mark_running("t", None).unwrap();
365        assert!(
366            m.take_steer("t").is_empty(),
367            "a new run starts uninstructed, like it starts uncancelled"
368        );
369    }
370
371    /// **The board can outlive its run, and this is the witness.** `tasks
372    /// work` restores a task's status on every exit path it controls; a
373    /// `SIGKILL` controls none of them, so the graph goes on saying the agent
374    /// holds the task forever and every surface reading only the board
375    /// renders a dead run as one in flight. The marker answers it locally and
376    /// within seconds, which is the whole reason a surface should ask.
377    #[test]
378    fn a_task_the_board_says_is_held_can_have_no_run_behind_it() {
379        let m = RunMarkers::new(scratch("stalled"));
380        m.mark_running_for("task-1", None, Some("s-1")).unwrap();
381        assert!(
382            m.running("task-1").is_some(),
383            "while the run lives, the board's claim is corroborated"
384        );
385
386        // What a kill leaves: the marker's process is gone and nothing
387        // restored the board. Written as a dead pid rather than by killing
388        // something, for the same reason the test above is.
389        crate::create_private_dir(m.dir()).unwrap();
390        std::fs::write(
391            m.dir().join("task-1.running"),
392            serde_json::json!({"pid": 0, "started_at": Utc::now().to_rfc3339(), "session": "s-1"})
393                .to_string(),
394        )
395        .unwrap();
396        assert!(
397            m.running("task-1").is_none(),
398            "the claim is now uncorroborated, and a reader can say so"
399        );
400        assert!(
401            m.live_writer_of("s-1").is_none(),
402            "and the transcript it was writing is free — a killed run must not \
403             lock its own conversation out of being resumed"
404        );
405    }
406
407    /// A cancel that outlived the run it was meant for must not reach the
408    /// next one. Fails on the old `mark_running`, which wrote the marker and
409    /// left whatever cancel was already there.
410    #[test]
411    fn a_stale_cancel_does_not_reach_the_next_run() {
412        let m = RunMarkers::new(scratch("stalecancel"));
413        crate::create_private_dir(m.dir()).unwrap();
414        // The shape a kill or a lost race leaves behind: a cancel with no
415        // marker beside it.
416        std::fs::write(m.dir().join("a.cancel"), "whenever").unwrap();
417        assert!(m.cancel_requested("a"));
418
419        m.mark_running("a", None).unwrap();
420        assert!(
421            !m.cancel_requested("a"),
422            "the new run must not inherit the old run's stop"
423        );
424    }
425
426    #[test]
427    fn cancelling_nothing_says_so_rather_than_pretending() {
428        let m = RunMarkers::new(scratch("nothing"));
429        assert!(!m.request_cancel("a").unwrap());
430        assert!(!m.cancel_requested("a"));
431    }
432
433    /// A cancel arriving as a run ends must not be left lying around to kill
434    /// the next one.
435    #[test]
436    fn clearing_removes_an_unclaimed_cancel_too() {
437        let m = RunMarkers::new(scratch("stale"));
438        m.mark_running("a", None).unwrap();
439        assert!(m.request_cancel("a").unwrap());
440        assert!(m.cancel_requested("a"));
441        m.clear("a");
442        assert!(!m.cancel_requested("a"), "the next run starts uncancelled");
443    }
444}