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}