Skip to main content

capsule_runtime/
harness_session.rs

1//! The map from an A2A context to the harness session that holds its conversation:
2//! `~/.murmur/conversations/<record>/<context-id>/harness-session.json`.
3//!
4//! On `inference.transport: process` the harness owns the conversation. The runtime keeps no
5//! message list for it — [`crate::conversation`] writes no `conversation.jsonl`, and a hook granted
6//! `capabilities.conversation.read` reads an empty page — and remembers exactly one thing per
7//! context: the opaque session id the harness answers to. That is the whole of this module.
8//!
9//! Three properties decide the layout:
10//!
11//! * **Beside the record it replaces.** One file per context, in the directory an `http` capsule
12//!   would have put `conversation.jsonl` in, so `<record>` and `<context-id>` mean the same thing
13//!   on both transports and `context.record_store` points both at the same place.
14//! * **Opaque.** `session_id` is whatever the harness calls its session. `harness` and `driver` are
15//!   written for a person reading the directory; nothing compares them. Whether a session still
16//!   exists is a question only the harness can answer, and it answers it by failing the turn.
17//! * **Never fatal, and never silently discarded.** A root that cannot be resolved or written
18//!   costs the conversation its memory beyond the running capsule, reported once and survived. A
19//!   session the harness cannot find fails the turn — see [`crate::errors::RuntimeError`]'s
20//!   `HarnessSessionGone` — rather than starting a new conversation behind the person's back.
21
22use std::{
23    collections::HashMap,
24    path::{Path, PathBuf},
25    sync::Mutex,
26};
27
28use crate::workdir_fs::WorkdirRoot;
29
30/// The map file inside `<record>/<context-id>/`, beside where `conversation.jsonl` would be.
31pub(crate) const HARNESS_SESSION_FILE_NAME: &str = "harness-session.json";
32
33/// The `type` value on the map file, and the only value one is recognised by. A file carrying
34/// anything else is another tool's, and reads as no entry at all.
35pub(crate) const HARNESS_SESSION_TYPE: &str = "murmur.harness-session";
36
37/// What the trace's `harness_session_forgotten` record names as the asker when the forget came
38/// from `mur run --forget-session`.
39pub(crate) const FORGET_BY_CLI: &str = "cli";
40
41/// What that record names as the asker when the forget arrived on an A2A message, as
42/// [`crate::a2a::IncomingMessage::forget_session`].
43pub(crate) const FORGET_BY_A2A: &str = "a2a";
44
45/// One context's harness session, as the file holds it.
46#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
47pub struct HarnessSessionEntry {
48    /// Always [`HARNESS_SESSION_TYPE`].
49    #[serde(rename = "type")]
50    pub kind: String,
51    /// What the harness calls this conversation. Opaque to the runtime, which only ever hands it
52    /// back.
53    pub session_id: String,
54    /// The harness the driver's `describe()` named when this entry was written.
55    pub harness: String,
56    /// The process driver artifact that drove it.
57    pub driver: String,
58    /// When this context first got a session, in milliseconds since the epoch. Survives every
59    /// later write for the same context.
60    pub created_ms: u64,
61    /// When the entry was last written, in milliseconds since the epoch.
62    pub updated_ms: u64,
63}
64
65/// What one launch knows about the harness sessions of the contexts it serves.
66///
67/// Holds the resolved root — `None` for a capsule that keeps no file, which is
68/// `context.record: off` or a host with no usable `HOME` — and what this launch has already seen.
69/// The in-memory half is what makes a capsule with no root still thread across the tasks of one
70/// life: it forgets on stop, which is exactly what "keeps no file" means.
71pub(crate) struct HarnessSessionMap {
72    root: Option<PathBuf>,
73    /// The session workdir every survived failure is reported into, beside stderr.
74    workdir: WorkdirRoot,
75    /// Context id → the session id this launch last saw for it.
76    seen: Mutex<HashMap<String, String>>,
77    /// A write failure is reported once and then survived silently: an unwritable home must not
78    /// produce one stderr line per task.
79    failure_reported: Mutex<bool>,
80}
81
82impl HarnessSessionMap {
83    pub(crate) fn new(root: Option<PathBuf>, workdir: &WorkdirRoot) -> Self {
84        Self {
85            root,
86            workdir: workdir.clone(),
87            seen: Mutex::new(HashMap::new()),
88            failure_reported: Mutex::new(false),
89        }
90    }
91
92    /// `~/.murmur/conversations/<record>`, or `None` for a launch that remembers only in memory.
93    pub(crate) fn root(&self) -> Option<&Path> {
94        self.root.as_deref()
95    }
96
97    /// The map file `context_id` is kept in, or `None` when this launch keeps none and when the
98    /// id is not a usable path segment.
99    ///
100    /// A context id arrives from an A2A client as well as from `mur run --context`, so it is
101    /// checked here rather than trusted: a rejected id costs that conversation its file and
102    /// nothing else.
103    pub(crate) fn entry_path(&self, context_id: &str) -> Option<PathBuf> {
104        let root = self.root()?;
105        if crate::state_store::validate_store_name(context_id).is_err() {
106            return None;
107        }
108        Some(entry_file(root, context_id))
109    }
110
111    /// The session id this context already has, or `None` when it has none.
112    ///
113    /// What this launch has already seen wins over the file: a task that has just been answered
114    /// is remembered even on a host where nothing could be written.
115    pub(crate) fn get(&self, context_id: &str) -> Option<String> {
116        if let Ok(seen) = self.seen.lock() {
117            if let Some(id) = seen.get(context_id) {
118                return Some(id.clone());
119            }
120        }
121        let entry = read_entry(&self.entry_path(context_id)?)?;
122        Some(entry.session_id)
123    }
124
125    /// Remember `session_id` for `context_id`, in memory and — when this launch has a root — in
126    /// its file. An empty id is not a session and is ignored.
127    ///
128    /// `created_ms` is carried over from whatever the file already held, so the first time a
129    /// context got a session survives every later turn in it.
130    pub(crate) fn put(&self, context_id: &str, session_id: &str, harness: &str, driver: &str) {
131        if session_id.is_empty() {
132            return;
133        }
134        if let Ok(mut seen) = self.seen.lock() {
135            seen.insert(context_id.to_string(), session_id.to_string());
136        }
137        let Some(path) = self.entry_path(context_id) else {
138            return;
139        };
140        let now_ms = crate::retention::now_ms();
141        let entry = HarnessSessionEntry {
142            kind: HARNESS_SESSION_TYPE.to_string(),
143            session_id: session_id.to_string(),
144            harness: harness.to_string(),
145            driver: driver.to_string(),
146            created_ms: read_entry(&path).map(|e| e.created_ms).unwrap_or(now_ms),
147            updated_ms: now_ms,
148        };
149        if let Err(reason) = write_entry(&path, &entry) {
150            self.report_once(&format!(
151                "[harness-session] the session id for context '{context_id}' could not be written \
152                 to {} ({reason}); this conversation is remembered only until this capsule stops",
153                path.display()
154            ));
155        }
156    }
157
158    /// Drop whatever session `context_id` has — from this launch's memory and from its file —
159    /// and return the id that was dropped, or `None` when the context had none.
160    ///
161    /// The only thing that removes a map entry. A person has to ask for it, on the request that
162    /// starts a turn, because dropping the id is what makes the next turn a new conversation
163    /// rather than a refusal; nothing in the runtime decides it on its own.
164    ///
165    /// A file that will not delete is reported through the same once-only path a failed write
166    /// takes, and the id still counts as dropped: this launch has forgotten it either way.
167    pub(crate) fn forget(&self, context_id: &str) -> Option<String> {
168        let dropped = self.get(context_id);
169        if let Ok(mut seen) = self.seen.lock() {
170            seen.remove(context_id);
171        }
172        if let Some(path) = self.entry_path(context_id) {
173            if let Err(reason) = remove_entry(&path) {
174                self.report_once(&format!(
175                    "[harness-session] the session id for context '{context_id}' could not be \
176                     removed from {} ({reason}); this context is forgotten only until this \
177                     capsule stops",
178                    path.display()
179                ));
180            }
181        }
182        dropped
183    }
184
185    fn report_once(&self, message: &str) {
186        if let Ok(mut reported) = self.failure_reported.lock() {
187            if *reported {
188                return;
189            }
190            *reported = true;
191        }
192        crate::conversation::report(&self.workdir, message);
193    }
194}
195
196/// `~/.murmur/conversations/<record>` for a launch whose harness owns the conversation, or `None`
197/// when nothing is kept on disk.
198///
199/// Three ways to have none, and all three are ordinary: a capsule that is not on
200/// `transport: process` (whose conversation the runtime records itself — see
201/// [`crate::runtime`]'s `resolve_conversation_root`), `context.record: off`, and a host whose home
202/// directory cannot be resolved. The last is reported and survived rather than refused: a host
203/// with no usable `HOME` must not become one where nothing launches.
204pub(crate) fn resolve_harness_session_root(
205    context: Option<&murmur_artifact::ContextConfig>,
206    capsule_name: &str,
207    inference: &murmur_artifact::InferenceConfig,
208    workdir: &WorkdirRoot,
209) -> Option<PathBuf> {
210    if inference.transport != "process" {
211        return None;
212    }
213    let record = crate::conversation::resolve_record_name(context, capsule_name)?;
214    match crate::conversation::record_root(&record) {
215        Ok(root) => Some(root),
216        Err(reason) => {
217            crate::conversation::report(
218                workdir,
219                &format!(
220                    "[harness-session] the conversation root for '{record}' could not be located \
221                     ({reason}); this capsule's conversations last only as long as it runs"
222                ),
223            );
224            None
225        }
226    }
227}
228
229/// The map file one context keeps under `root`. Resolves only — nothing is created and nothing is
230/// checked for existence — so a launch that never establishes a session leaves no directory
231/// behind.
232///
233/// `mur run --resume` asks whether this path exists before staging goes any further, which is the
234/// one question that separates "continue this conversation" from "start a new one and say nothing".
235pub(crate) fn entry_file(root: &Path, context_id: &str) -> PathBuf {
236    root.join(context_id).join(HARNESS_SESSION_FILE_NAME)
237}
238
239/// Mint the id a fresh conversation is offered to the harness under.
240///
241/// A bare hyphenated UUID, with none of the runtime's own `ses_` / `ctx_` prefixes: a harness that
242/// validates a caller-chosen session id expects a UUID, and one that mints its own says so with a
243/// `session-started` event that replaces this.
244pub(crate) fn new_harness_session_id() -> String {
245    uuid::Uuid::now_v7().to_string()
246}
247
248/// What `path` holds, or `None` for a file that does not exist, will not parse, is not a map file,
249/// or names no session.
250///
251/// Every one of those reads as "this context has no session", which starts a new conversation
252/// rather than handing the harness something it cannot use.
253pub(crate) fn read_entry(path: &Path) -> Option<HarnessSessionEntry> {
254    let raw = std::fs::read(path).ok()?;
255    let entry: HarnessSessionEntry = serde_json::from_slice(&raw).ok()?;
256    if entry.kind != HARNESS_SESSION_TYPE || entry.session_id.is_empty() {
257        return None;
258    }
259    Some(entry)
260}
261
262/// Remove the map file at `path`. A file that is already gone is the state this asks for, so it
263/// is not a failure; the context directory is left where it is, as the record store's own.
264fn remove_entry(path: &Path) -> Result<(), String> {
265    match std::fs::remove_file(path) {
266        Ok(()) => Ok(()),
267        Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
268        Err(err) => Err(err.to_string()),
269    }
270}
271
272/// Write `entry` whole, at `0600` under directories held at `0700`.
273fn write_entry(path: &Path, entry: &HarnessSessionEntry) -> Result<(), String> {
274    let dir = path
275        .parent()
276        .ok_or_else(|| format!("{} has no parent directory", path.display()))?;
277    crate::conversation::ensure_context_dirs(dir)?;
278    let contents = serde_json::to_vec(entry)
279        .map_err(|err| format!("failed to encode the harness session entry: {err}"))?;
280    crate::murmur_home::write_private_file(path, &contents)
281}
282
283#[cfg(test)]
284mod tests {
285    use super::*;
286    use crate::murmur_home::{mode_of, run_with_home};
287
288    const HARNESS: &str = "fixture-harness";
289    const DRIVER: &str = "fixture-process-driver";
290
291    fn map_in(home: &Path) -> HarnessSessionMap {
292        let session = home.join("session");
293        std::fs::create_dir_all(&session).unwrap();
294        HarnessSessionMap::new(
295            Some(home.join(".murmur/conversations/capsule")),
296            &crate::workdir_fs::WorkdirRoot::open(&session).unwrap(),
297        )
298    }
299
300    #[test]
301    fn a_minted_session_id_is_a_bare_uuid() {
302        let id = new_harness_session_id();
303        assert_eq!(id.len(), 36, "{id}");
304        assert!(
305            id.chars()
306                .all(|c| c == '-' || c.is_ascii_digit() || ('a'..='f').contains(&c)),
307            "{id}"
308        );
309        assert_ne!(id, new_harness_session_id());
310    }
311
312    #[test]
313    fn entry_file_sits_beside_the_record() {
314        assert_eq!(
315            entry_file(Path::new("/root"), "ctx_1"),
316            Path::new("/root/ctx_1/harness-session.json")
317        );
318    }
319
320    #[test]
321    fn a_context_id_that_is_not_a_path_segment_gets_no_file() {
322        let map = HarnessSessionMap::new(
323            Some(PathBuf::from("/root")),
324            &crate::workdir_fs::WorkdirRoot::open(Path::new("/tmp")).unwrap(),
325        );
326        assert_eq!(map.entry_path("../escape"), None);
327        assert_eq!(map.entry_path(".hidden"), None);
328        assert!(map.entry_path("ctx_1").is_some());
329    }
330
331    #[test]
332    fn a_launch_with_no_root_still_threads_in_memory() {
333        let map = HarnessSessionMap::new(
334            None,
335            &crate::workdir_fs::WorkdirRoot::open(Path::new("/tmp")).unwrap(),
336        );
337        assert_eq!(map.root(), None);
338        assert_eq!(map.entry_path("ctx_1"), None);
339        map.put("ctx_1", "sess-1", HARNESS, DRIVER);
340        assert_eq!(map.get("ctx_1").as_deref(), Some("sess-1"));
341        assert_eq!(map.get("ctx_2"), None);
342    }
343
344    #[test]
345    fn forgetting_a_context_with_no_entry_drops_nothing() {
346        let map = HarnessSessionMap::new(
347            None,
348            &crate::workdir_fs::WorkdirRoot::open(Path::new("/tmp")).unwrap(),
349        );
350        assert_eq!(map.forget("ctx_1"), None);
351    }
352
353    #[test]
354    fn a_launch_with_no_root_still_forgets_in_memory() {
355        let map = HarnessSessionMap::new(
356            None,
357            &crate::workdir_fs::WorkdirRoot::open(Path::new("/tmp")).unwrap(),
358        );
359        map.put("ctx_1", "sess-1", HARNESS, DRIVER);
360        assert_eq!(map.forget("ctx_1").as_deref(), Some("sess-1"));
361        assert_eq!(map.get("ctx_1"), None);
362        assert_eq!(map.forget("ctx_1"), None);
363    }
364
365    #[test]
366    fn an_empty_session_id_is_not_remembered() {
367        let map = HarnessSessionMap::new(
368            None,
369            &crate::workdir_fs::WorkdirRoot::open(Path::new("/tmp")).unwrap(),
370        );
371        map.put("ctx_1", "", HARNESS, DRIVER);
372        assert_eq!(map.get("ctx_1"), None);
373    }
374
375    #[test]
376    fn harness_session_round_trip_through_a_home() {
377        let home = tempfile::tempdir().unwrap();
378        run_with_home(
379            "harness_session::tests::harness_session_round_trip_inner",
380            home.path(),
381        );
382    }
383
384    #[test]
385    #[ignore = "run by harness_session_round_trip_through_a_home"]
386    fn harness_session_round_trip_inner() {
387        if !crate::murmur_home::in_scratch_home() {
388            return;
389        }
390        let home = PathBuf::from(std::env::var("HOME").unwrap());
391        let map = map_in(&home);
392        map.put("ctx_1", "sess-1", HARNESS, DRIVER);
393
394        let path = map.entry_path("ctx_1").unwrap();
395        let entry = read_entry(&path).expect("the entry is readable");
396        assert_eq!(entry.kind, HARNESS_SESSION_TYPE);
397        assert_eq!(entry.session_id, "sess-1");
398        assert_eq!(entry.harness, HARNESS);
399        assert_eq!(entry.driver, DRIVER);
400        assert_eq!(entry.created_ms, entry.updated_ms);
401
402        assert_eq!(mode_of(&path), 0o600);
403        for dir in [
404            path.parent().unwrap(),
405            path.parent().unwrap().parent().unwrap(),
406            path.parent().unwrap().parent().unwrap().parent().unwrap(),
407        ] {
408            assert_eq!(mode_of(dir), 0o700, "{}", dir.display());
409        }
410
411        // A second launch reads what the first wrote, with no memory of its own.
412        let reread = map_in(&home);
413        assert_eq!(reread.get("ctx_1").as_deref(), Some("sess-1"));
414
415        // A later write keeps the moment the context first got a session.
416        std::thread::sleep(std::time::Duration::from_millis(2));
417        reread.put("ctx_1", "sess-2", HARNESS, DRIVER);
418        let updated = read_entry(&path).unwrap();
419        assert_eq!(updated.session_id, "sess-2");
420        assert_eq!(updated.created_ms, entry.created_ms);
421        assert!(updated.updated_ms > entry.created_ms);
422
423        // Forgetting takes the file with it, and a third launch over the same home reads no
424        // session at all — which is what makes the next turn a new conversation.
425        assert_eq!(reread.forget("ctx_1").as_deref(), Some("sess-2"));
426        assert!(!path.exists(), "{}", path.display());
427        assert_eq!(map_in(&home).get("ctx_1"), None);
428        assert_eq!(reread.forget("ctx_1"), None);
429    }
430
431    #[test]
432    fn a_file_that_is_not_a_map_entry_reads_as_absent() {
433        let dir = tempfile::tempdir().unwrap();
434        let path = dir.path().join(HARNESS_SESSION_FILE_NAME);
435
436        std::fs::write(&path, "{ not json").unwrap();
437        assert_eq!(read_entry(&path), None);
438
439        std::fs::write(&path, r#"{"type":"something.else","session_id":"s"}"#).unwrap();
440        assert_eq!(read_entry(&path), None);
441
442        std::fs::write(
443            &path,
444            r#"{"type":"murmur.harness-session","session_id":"","harness":"h","driver":"d","created_ms":1,"updated_ms":1}"#,
445        )
446        .unwrap();
447        assert_eq!(read_entry(&path), None);
448
449        assert_eq!(read_entry(&dir.path().join("absent.json")), None);
450    }
451
452    #[test]
453    fn an_unwritable_root_is_reported_once_and_survived() {
454        let dir = tempfile::tempdir().unwrap();
455        // A file where the conversation root has to be a directory: every write beneath it fails.
456        let blocked = dir.path().join("blocked");
457        std::fs::write(&blocked, b"not a directory").unwrap();
458        let workdir = dir.path().join("session");
459        std::fs::create_dir_all(&workdir).unwrap();
460
461        let map = HarnessSessionMap::new(
462            Some(blocked.clone()),
463            &crate::workdir_fs::WorkdirRoot::open(&workdir).unwrap(),
464        );
465        map.put("ctx_1", "sess-1", HARNESS, DRIVER);
466        map.put("ctx_1", "sess-2", HARNESS, DRIVER);
467
468        // Nothing landed, the launch is still usable, and the log carries exactly one line.
469        assert!(!blocked.is_dir());
470        assert_eq!(map.get("ctx_1").as_deref(), Some("sess-2"));
471        let log = std::fs::read_to_string(workdir.join("logs/bootstrap.log")).unwrap_or_default();
472        assert_eq!(
473            log.matches("[harness-session]").count(),
474            1,
475            "one report, not one per task:\n{log}"
476        );
477    }
478}