1use std::{
23 collections::HashMap,
24 path::{Path, PathBuf},
25 sync::Mutex,
26};
27
28use crate::workdir_fs::WorkdirRoot;
29
30pub(crate) const HARNESS_SESSION_FILE_NAME: &str = "harness-session.json";
32
33pub(crate) const HARNESS_SESSION_TYPE: &str = "murmur.harness-session";
36
37pub(crate) const FORGET_BY_CLI: &str = "cli";
40
41pub(crate) const FORGET_BY_A2A: &str = "a2a";
44
45#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
47pub struct HarnessSessionEntry {
48 #[serde(rename = "type")]
50 pub kind: String,
51 pub session_id: String,
54 pub harness: String,
56 pub driver: String,
58 pub created_ms: u64,
61 pub updated_ms: u64,
63}
64
65pub(crate) struct HarnessSessionMap {
72 root: Option<PathBuf>,
73 workdir: WorkdirRoot,
75 seen: Mutex<HashMap<String, String>>,
77 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 pub(crate) fn root(&self) -> Option<&Path> {
94 self.root.as_deref()
95 }
96
97 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 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 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 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
196pub(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
229pub(crate) fn entry_file(root: &Path, context_id: &str) -> PathBuf {
236 root.join(context_id).join(HARNESS_SESSION_FILE_NAME)
237}
238
239pub(crate) fn new_harness_session_id() -> String {
245 uuid::Uuid::now_v7().to_string()
246}
247
248pub(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
262fn 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
272fn 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 let reread = map_in(&home);
413 assert_eq!(reread.get("ctx_1").as_deref(), Some("sess-1"));
414
415 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 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 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 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}