Skip to main content

kcode_session_control_state/
lib.rs

1//! Typed state and startup compaction for one `.session-control` journal.
2//!
3//! Application policy and coordination remain with the composing session owner.
4
5use std::{
6    collections::BTreeMap,
7    fs::File,
8    path::{Path, PathBuf},
9};
10
11use anyhow::Context as _;
12use kcode_session_control_journal::{Journal, Record};
13use serde::{Deserialize, Serialize};
14use serde_json::{Map, Value};
15
16const LIFECYCLE_SIDEBAND: &str = "session_lifecycle";
17const COMMAND_SIDEBAND: &str = "session_command";
18const STOP_SIDEBAND: &str = "session_stop";
19const CONTROL_EXTENSION: &str = "session-control";
20
21#[derive(Clone, Copy, Debug, Eq, PartialEq)]
22pub enum OpenMode {
23    CreateNew,
24    OpenOrCreate,
25    ExistingOnly,
26}
27
28#[derive(Clone, Debug, Default)]
29pub struct ControlProjection {
30    pub lifecycle: Option<SessionRecord>,
31    pub commands: BTreeMap<String, SessionCommand>,
32    pub stop_requests: BTreeMap<String, SessionStopRequest>,
33}
34
35#[derive(Clone, Debug)]
36pub enum ControlUpdate {
37    Lifecycle(SessionRecord),
38    Command(SessionCommand),
39    StopRequest(SessionStopRequest),
40}
41
42impl ControlUpdate {
43    /// Projects lifecycle state to the authoritative control fields.
44    ///
45    /// Command and stop updates are returned unchanged.
46    pub fn projected(mut self) -> Self {
47        if let Self::Lifecycle(record) = &mut self {
48            record.state = control_state(&record.state);
49        }
50        self
51    }
52}
53
54#[derive(Clone, Debug, Deserialize, Serialize)]
55pub struct SessionRecord {
56    pub id: String,
57    pub phase: String,
58    pub started_at: String,
59    pub updated_at: String,
60    pub state: Value,
61    pub provenance_id: Option<String>,
62    pub version: i64,
63    pub last_user_message_at: Option<String>,
64    pub ended_at: Option<String>,
65    pub ingress_failure_count: i64,
66    pub ingress_failures: Value,
67    pub ingress_next_attempt_at: Option<String>,
68    #[serde(default, skip_serializing_if = "is_false")]
69    pub summary: bool,
70}
71
72#[derive(Clone, Debug, Deserialize, Serialize)]
73#[serde(rename_all = "camelCase")]
74pub struct SessionCommand {
75    pub id: String,
76    pub conversation_id: String,
77    pub sequence: i64,
78    pub kind: String,
79    pub payload: Value,
80    pub status: String,
81    pub cancel_requested: bool,
82    pub outcome: Option<Value>,
83    pub created_at: String,
84    pub processing_started_at: Option<String>,
85    pub completed_at: Option<String>,
86    pub idempotency_id: String,
87}
88
89#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
90#[serde(rename_all = "camelCase")]
91pub struct SessionStopRequest {
92    pub id: String,
93    pub session_id: String,
94    pub scope: String,
95    pub status: String,
96    pub outcome: Option<Value>,
97    pub requested_at: String,
98    pub completed_at: Option<String>,
99    pub idempotency_id: String,
100}
101
102pub struct SessionControl {
103    directory: PathBuf,
104    path: PathBuf,
105    journal: Journal,
106}
107
108impl SessionControl {
109    pub fn open(
110        directory: impl AsRef<Path>,
111        session_id: &str,
112        mode: OpenMode,
113    ) -> anyhow::Result<Option<Self>> {
114        let directory = directory.as_ref().to_path_buf();
115        let path = control_path(&directory, session_id);
116        let journal = match mode {
117            OpenMode::CreateNew => Journal::create(path.clone())?,
118            OpenMode::OpenOrCreate => match Journal::open(path.clone())? {
119                Some(journal) => journal,
120                None => Journal::create(path.clone())?,
121            },
122            OpenMode::ExistingOnly => {
123                let Some(journal) = Journal::open(path.clone())? else {
124                    return Ok(None);
125                };
126                journal
127            }
128        };
129        Ok(Some(Self {
130            directory,
131            path,
132            journal,
133        }))
134    }
135
136    pub fn projection(&self) -> ControlProjection {
137        project_records(self.journal.records())
138    }
139
140    pub fn append(
141        &mut self,
142        recorded_at: impl Into<String>,
143        update: ControlUpdate,
144    ) -> anyhow::Result<ControlUpdate> {
145        let update = update.projected();
146        let (kind, value) = match &update {
147            ControlUpdate::Lifecycle(record) => (
148                LIFECYCLE_SIDEBAND,
149                serde_json::to_value(record).context("encoding session lifecycle record")?,
150            ),
151            ControlUpdate::Command(command) => (
152                COMMAND_SIDEBAND,
153                serde_json::to_value(command).context("encoding session command record")?,
154            ),
155            ControlUpdate::StopRequest(request) => (
156                STOP_SIDEBAND,
157                serde_json::to_value(request).context("encoding session stop record")?,
158            ),
159        };
160        self.journal.append(kind, recorded_at, value)?;
161        Ok(update)
162    }
163
164    pub fn delete(self) -> anyhow::Result<()> {
165        let Self {
166            directory,
167            path,
168            journal,
169        } = self;
170        drop(journal);
171        if path.exists() {
172            std::fs::remove_file(&path).with_context(|| format!("removing {}", path.display()))?;
173            sync_directory(&directory)?;
174        }
175        Ok(())
176    }
177
178    pub fn compact_directory(directory: impl AsRef<Path>) -> anyhow::Result<()> {
179        let directory = directory.as_ref();
180        let mut paths = std::fs::read_dir(directory)?
181            .filter_map(Result::ok)
182            .map(|entry| entry.path())
183            .filter(|path| {
184                path.extension().and_then(|value| value.to_str()) == Some(CONTROL_EXTENSION)
185            })
186            .collect::<Vec<_>>();
187        paths.sort();
188        for path in paths {
189            compact_journal(&path)?;
190        }
191        Ok(())
192    }
193}
194
195fn is_false(value: &bool) -> bool {
196    !*value
197}
198
199fn control_path(directory: &Path, session_id: &str) -> PathBuf {
200    directory.join(format!("{session_id}.{CONTROL_EXTENSION}"))
201}
202
203fn project_records(records: &[Record]) -> ControlProjection {
204    let latest_lifecycle = records
205        .iter()
206        .rev()
207        .find(|record| record.kind == LIFECYCLE_SIDEBAND)
208        .and_then(|record| serde_json::from_value(record.value.clone()).ok());
209    let mut commands = BTreeMap::new();
210    let mut stop_requests = BTreeMap::new();
211    for record in records {
212        match record.kind.as_str() {
213            COMMAND_SIDEBAND => {
214                if let Ok(command) = serde_json::from_value::<SessionCommand>(record.value.clone())
215                {
216                    commands.insert(command.id.clone(), command);
217                }
218            }
219            STOP_SIDEBAND => {
220                if let Ok(request) =
221                    serde_json::from_value::<SessionStopRequest>(record.value.clone())
222                {
223                    stop_requests.insert(request.id.clone(), request);
224                }
225            }
226            _ => {}
227        }
228    }
229    ControlProjection {
230        lifecycle: latest_lifecycle,
231        commands,
232        stop_requests,
233    }
234}
235
236fn control_state(value: &Value) -> Value {
237    const KEYS: &[&str] = &[
238        "format",
239        "version",
240        "stateVersion",
241        "sessionId",
242        "sessionType",
243        "sourceSessionType",
244        "channel",
245        "freeTime",
246        "selfTimeIntent",
247        "orchestration",
248        "provenanceId",
249        "rustLibSessionId",
250        "rootNodeIds",
251        "referenceRootNodeIds",
252        "startedAt",
253        "pendingTurn",
254        "pendingExternalEventId",
255        "roundsUsed",
256        "completed",
257        "sessionObjectId",
258        "commitReceipt",
259        "commitAuthor",
260        "providerModel",
261        "kwebPlan",
262        "startIdempotencyId",
263        "ingressSource",
264        "firstUserMessage",
265        "boxCount",
266        "eventCount",
267        "chatendMetadata",
268        "sessionStatus",
269        "historyIngress",
270    ];
271    let mut output = Map::new();
272    for key in KEYS {
273        if let Some(item) = value.get(*key) {
274            if *key == "commitReceipt" && item.is_null() {
275                continue;
276            }
277            let item = if *key == "historyIngress" {
278                control_state(item)
279            } else {
280                item.clone()
281            };
282            output.insert((*key).into(), item);
283        }
284    }
285    Value::Object(output)
286}
287
288fn compact_journal(path: &Path) -> anyhow::Result<()> {
289    let original_bytes = std::fs::metadata(path)
290        .with_context(|| format!("reading metadata for {}", path.display()))?
291        .len();
292    if original_bytes >= 16 * 1024 * 1024 {
293        tracing::info!(
294            path = %path.display(),
295            original_bytes,
296            "Compacting legacy Session History control journal"
297        );
298    }
299
300    let mut journal = Journal::open(path.to_path_buf())?
301        .with_context(|| format!("session-control journal {} disappeared", path.display()))?;
302    let repaired_bytes = std::fs::metadata(path)?.len();
303    let tail_repaired = repaired_bytes != original_bytes;
304    let mut latest_lifecycle = None;
305    let mut latest_commands = BTreeMap::<String, (u64, Record)>::new();
306    let mut latest_stop_requests = BTreeMap::<String, (u64, Record)>::new();
307    let mut retained_other = Vec::new();
308    let mut needs_rewrite = false;
309
310    for (sequence, mut record) in journal.records().iter().cloned().enumerate() {
311        let sequence = sequence as u64;
312        match record.kind.as_str() {
313            LIFECYCLE_SIDEBAND => {
314                if let Some(state) = record.value.get_mut("state") {
315                    let projected = control_state(state);
316                    if *state != projected {
317                        *state = projected;
318                        needs_rewrite = true;
319                    }
320                }
321                if latest_lifecycle.replace((sequence, record)).is_some() {
322                    needs_rewrite = true;
323                }
324            }
325            COMMAND_SIDEBAND => {
326                let id = record
327                    .value
328                    .get("id")
329                    .and_then(Value::as_str)
330                    .context("session command record has no ID")?
331                    .to_owned();
332                if latest_commands.insert(id, (sequence, record)).is_some() {
333                    needs_rewrite = true;
334                }
335            }
336            STOP_SIDEBAND => {
337                let id = record
338                    .value
339                    .get("id")
340                    .and_then(Value::as_str)
341                    .context("session stop record has no ID")?
342                    .to_owned();
343                if latest_stop_requests
344                    .insert(id, (sequence, record))
345                    .is_some()
346                {
347                    needs_rewrite = true;
348                }
349            }
350            _ => retained_other.push((sequence, record)),
351        }
352    }
353
354    if needs_rewrite {
355        let mut retained = retained_other;
356        retained.extend(latest_lifecycle);
357        retained.extend(latest_commands.into_values());
358        retained.extend(latest_stop_requests.into_values());
359        retained.sort_by_key(|(sequence, _)| *sequence);
360        journal.replace(retained.into_iter().map(|(_, record)| record))?;
361    }
362
363    if needs_rewrite || tail_repaired {
364        tracing::info!(
365            path = %path.display(),
366            original_bytes,
367            compacted_bytes = std::fs::metadata(path)?.len(),
368            "Compacted Session History control journal"
369        );
370    }
371    Ok(())
372}
373
374fn sync_directory(path: &Path) -> anyhow::Result<()> {
375    File::open(path)
376        .with_context(|| format!("opening directory {} for sync", path.display()))?
377        .sync_all()
378        .with_context(|| format!("syncing directory {}", path.display()))
379}
380
381#[cfg(test)]
382mod tests {
383    use std::{
384        fs::{self, OpenOptions},
385        io::Write as _,
386        sync::atomic::{AtomicU64, Ordering},
387        time::{SystemTime, UNIX_EPOCH},
388    };
389
390    use serde_json::json;
391
392    use super::*;
393
394    static NEXT_ROOT: AtomicU64 = AtomicU64::new(0);
395
396    fn root(label: &str) -> PathBuf {
397        let path = std::env::temp_dir().join(format!(
398            "kcode-session-control-state-{label}-{}-{}-{}",
399            std::process::id(),
400            SystemTime::now()
401                .duration_since(UNIX_EPOCH)
402                .unwrap()
403                .as_nanos(),
404            NEXT_ROOT.fetch_add(1, Ordering::Relaxed),
405        ));
406        fs::create_dir(&path).unwrap();
407        path
408    }
409
410    fn lifecycle(id: &str, version: i64, state: Value) -> SessionRecord {
411        SessionRecord {
412            id: id.into(),
413            phase: "active".into(),
414            started_at: "2026-08-02T00:00:00Z".into(),
415            updated_at: format!("2026-08-02T00:00:0{version}Z"),
416            state,
417            provenance_id: None,
418            version,
419            last_user_message_at: None,
420            ended_at: None,
421            ingress_failure_count: 0,
422            ingress_failures: json!([]),
423            ingress_next_attempt_at: None,
424            summary: false,
425        }
426    }
427
428    fn command(id: &str, status: &str, sequence: i64) -> SessionCommand {
429        SessionCommand {
430            id: id.into(),
431            conversation_id: "session-1".into(),
432            sequence,
433            kind: "message".into(),
434            payload: json!({"text":"hello"}),
435            status: status.into(),
436            cancel_requested: false,
437            outcome: None,
438            created_at: "2026-08-02T00:00:00Z".into(),
439            processing_started_at: None,
440            completed_at: None,
441            idempotency_id: format!("command-{id}"),
442        }
443    }
444
445    fn stop(id: &str, status: &str) -> SessionStopRequest {
446        SessionStopRequest {
447            id: id.into(),
448            session_id: "session-1".into(),
449            scope: "turn".into(),
450            status: status.into(),
451            outcome: None,
452            requested_at: "2026-08-02T00:00:00Z".into(),
453            completed_at: None,
454            idempotency_id: format!("stop-{id}"),
455        }
456    }
457
458    #[test]
459    fn opening_modes_preserve_create_and_absence_distinctions() {
460        let root = root("open-modes");
461        assert!(
462            SessionControl::open(&root, "missing", OpenMode::ExistingOnly)
463                .unwrap()
464                .is_none()
465        );
466
467        let created = SessionControl::open(&root, "new", OpenMode::CreateNew)
468            .unwrap()
469            .unwrap();
470        assert!(control_path(&root, "new").is_file());
471        assert!(SessionControl::open(&root, "new", OpenMode::CreateNew).is_err());
472        drop(created);
473
474        let opened = SessionControl::open(&root, "new", OpenMode::OpenOrCreate)
475            .unwrap()
476            .unwrap();
477        drop(opened);
478        let created_on_absence = SessionControl::open(&root, "other", OpenMode::OpenOrCreate)
479            .unwrap()
480            .unwrap();
481        assert!(control_path(&root, "other").is_file());
482        drop(created_on_absence);
483        fs::remove_dir_all(root).unwrap();
484    }
485
486    #[test]
487    fn typed_append_projects_recursive_lifecycle_state_and_latest_values() {
488        let root = root("typed-projection");
489        let mut control = SessionControl::open(&root, "session-1", OpenMode::CreateNew)
490            .unwrap()
491            .unwrap();
492        let persisted = control
493            .append(
494                "t1",
495                ControlUpdate::Lifecycle(lifecycle(
496                    "session-1",
497                    1,
498                    json!({
499                        "sessionType":"conversation",
500                        "firstUserMessage":"hello",
501                        "boxCount":2,
502                        "eventCount":3,
503                        "chatendText":"discard",
504                        "commitReceipt":null,
505                        "historyIngress":{
506                            "completed":true,
507                            "commitReceipt":{"sessionObjectId":"A1234567"},
508                            "boxes":{"1":{"text":"discard"}},
509                            "chatendText":"discard"
510                        }
511                    }),
512                )),
513            )
514            .unwrap();
515        let ControlUpdate::Lifecycle(persisted) = persisted else {
516            panic!("append changed update kind");
517        };
518        assert_eq!(persisted.state["sessionType"], "conversation");
519        assert_eq!(persisted.state["firstUserMessage"], "hello");
520        assert_eq!(persisted.state["boxCount"], 2);
521        assert_eq!(persisted.state["eventCount"], 3);
522        assert!(persisted.state.get("chatendText").is_none());
523        assert!(persisted.state.get("commitReceipt").is_none());
524        assert_eq!(
525            persisted.state["historyIngress"]["commitReceipt"]["sessionObjectId"],
526            "A1234567"
527        );
528        assert!(
529            persisted.state["historyIngress"]
530                .get("chatendText")
531                .is_none()
532        );
533        assert!(persisted.state["historyIngress"].get("boxes").is_none());
534
535        control
536            .append(
537                "t2",
538                ControlUpdate::Lifecycle(lifecycle(
539                    "session-1",
540                    2,
541                    json!({"sessionType":"conversation","pendingTurn":true}),
542                )),
543            )
544            .unwrap();
545        control
546            .append("t3", ControlUpdate::Command(command("a", "pending", 1)))
547            .unwrap();
548        control
549            .append("t4", ControlUpdate::Command(command("a", "complete", 1)))
550            .unwrap();
551        control
552            .append("t5", ControlUpdate::Command(command("b", "pending", 2)))
553            .unwrap();
554        control
555            .append("t6", ControlUpdate::StopRequest(stop("s", "pending")))
556            .unwrap();
557        control
558            .append("t7", ControlUpdate::StopRequest(stop("s", "complete")))
559            .unwrap();
560
561        let projection = control.projection();
562        assert_eq!(projection.lifecycle.unwrap().version, 2);
563        assert_eq!(projection.commands.len(), 2);
564        assert_eq!(projection.commands["a"].status, "complete");
565        assert_eq!(projection.commands["b"].status, "pending");
566        assert_eq!(projection.stop_requests.len(), 1);
567        assert_eq!(projection.stop_requests["s"].status, "complete");
568        drop(control);
569
570        let reopened = SessionControl::open(&root, "session-1", OpenMode::ExistingOnly)
571            .unwrap()
572            .unwrap();
573        assert_eq!(reopened.projection().lifecycle.unwrap().version, 2);
574        drop(reopened);
575        fs::remove_dir_all(root).unwrap();
576    }
577
578    #[test]
579    fn startup_compaction_retains_unknown_kinds_and_survivor_order() {
580        let root = root("compaction-order");
581        let path = control_path(&root, "session-1");
582        let mut journal = Journal::create(path.clone()).unwrap();
583        journal
584            .append("unknown-first", "t0", json!({"value":0}))
585            .unwrap();
586        journal
587            .append(
588                LIFECYCLE_SIDEBAND,
589                "t1",
590                serde_json::to_value(lifecycle(
591                    "session-1",
592                    1,
593                    json!({"sessionType":"conversation","chatendText":"old"}),
594                ))
595                .unwrap(),
596            )
597            .unwrap();
598        journal
599            .append(
600                COMMAND_SIDEBAND,
601                "t2",
602                serde_json::to_value(command("a", "pending", 1)).unwrap(),
603            )
604            .unwrap();
605        journal
606            .append("unknown-middle", "t3", json!({"value":3}))
607            .unwrap();
608        journal
609            .append(
610                LIFECYCLE_SIDEBAND,
611                "t4",
612                serde_json::to_value(lifecycle(
613                    "session-1",
614                    2,
615                    json!({
616                        "sessionType":"conversation",
617                        "pendingTurn":true,
618                        "historyIngress":{"chatendText":"discard","completed":true}
619                    }),
620                ))
621                .unwrap(),
622            )
623            .unwrap();
624        journal
625            .append(
626                STOP_SIDEBAND,
627                "t5",
628                serde_json::to_value(stop("s", "pending")).unwrap(),
629            )
630            .unwrap();
631        journal
632            .append(
633                COMMAND_SIDEBAND,
634                "t6",
635                serde_json::to_value(command("a", "complete", 1)).unwrap(),
636            )
637            .unwrap();
638        journal
639            .append("unknown-last", "t7", json!({"value":7}))
640            .unwrap();
641        journal
642            .append(
643                STOP_SIDEBAND,
644                "t8",
645                serde_json::to_value(stop("s", "complete")).unwrap(),
646            )
647            .unwrap();
648        drop(journal);
649
650        SessionControl::compact_directory(&root).unwrap();
651
652        let compacted = Journal::open(path).unwrap().unwrap();
653        let kinds = compacted
654            .records()
655            .iter()
656            .map(|record| record.kind.as_str())
657            .collect::<Vec<_>>();
658        assert_eq!(
659            kinds,
660            [
661                "unknown-first",
662                "unknown-middle",
663                LIFECYCLE_SIDEBAND,
664                COMMAND_SIDEBAND,
665                "unknown-last",
666                STOP_SIDEBAND,
667            ]
668        );
669        let projected = project_records(compacted.records());
670        assert_eq!(projected.lifecycle.as_ref().unwrap().version, 2);
671        assert_eq!(
672            projected.lifecycle.unwrap().state["historyIngress"]["completed"],
673            true
674        );
675        assert_eq!(projected.commands["a"].status, "complete");
676        assert_eq!(projected.stop_requests["s"].status, "complete");
677        drop(compacted);
678        fs::remove_dir_all(root).unwrap();
679    }
680
681    #[test]
682    fn malformed_typed_records_keep_the_existing_tolerance_and_errors() {
683        let root = root("malformed");
684        let path = control_path(&root, "session-1");
685        let mut journal = Journal::create(path.clone()).unwrap();
686        journal
687            .append(
688                LIFECYCLE_SIDEBAND,
689                "t1",
690                serde_json::to_value(lifecycle("session-1", 1, json!({}))).unwrap(),
691            )
692            .unwrap();
693        journal
694            .append(LIFECYCLE_SIDEBAND, "t2", json!({"not":"a lifecycle"}))
695            .unwrap();
696        journal
697            .append(COMMAND_SIDEBAND, "t3", json!({"id":"partial"}))
698            .unwrap();
699        drop(journal);
700
701        let control = SessionControl::open(&root, "session-1", OpenMode::ExistingOnly)
702            .unwrap()
703            .unwrap();
704        let projection = control.projection();
705        assert!(projection.lifecycle.is_none());
706        assert!(projection.commands.is_empty());
707        drop(control);
708
709        assert!(SessionControl::compact_directory(&root).is_ok());
710
711        let malformed_path = control_path(&root, "missing-id");
712        let mut malformed = Journal::create(malformed_path).unwrap();
713        malformed
714            .append(COMMAND_SIDEBAND, "t1", json!({"status":"pending"}))
715            .unwrap();
716        drop(malformed);
717        assert!(
718            SessionControl::compact_directory(&root)
719                .unwrap_err()
720                .to_string()
721                .contains("session command record has no ID")
722        );
723        fs::remove_dir_all(root).unwrap();
724    }
725
726    #[test]
727    fn opening_repairs_incomplete_tail_but_rejects_complete_corruption() {
728        let root = root("integrity");
729        let path = control_path(&root, "tail");
730        let mut control = SessionControl::open(&root, "tail", OpenMode::CreateNew)
731            .unwrap()
732            .unwrap();
733        control
734            .append(
735                "t1",
736                ControlUpdate::Lifecycle(lifecycle("tail", 1, json!({}))),
737            )
738            .unwrap();
739        drop(control);
740        let complete = fs::read(&path).unwrap();
741        let mut file = OpenOptions::new().append(true).open(&path).unwrap();
742        file.write_all(b"incomplete tail").unwrap();
743        file.sync_all().unwrap();
744        drop(file);
745        let repaired = SessionControl::open(&root, "tail", OpenMode::ExistingOnly)
746            .unwrap()
747            .unwrap();
748        assert_eq!(repaired.projection().lifecycle.unwrap().version, 1);
749        drop(repaired);
750        assert_eq!(fs::read(&path).unwrap(), complete);
751
752        let corrupt_path = control_path(&root, "corrupt");
753        let mut corrupt = SessionControl::open(&root, "corrupt", OpenMode::CreateNew)
754            .unwrap()
755            .unwrap();
756        corrupt
757            .append(
758                "t1",
759                ControlUpdate::Lifecycle(lifecycle("corrupt", 1, json!({}))),
760            )
761            .unwrap();
762        drop(corrupt);
763        let mut bytes = fs::read(&corrupt_path).unwrap();
764        bytes[0] = if bytes[0] == b'0' { b'1' } else { b'0' };
765        fs::write(&corrupt_path, bytes).unwrap();
766        assert!(SessionControl::open(&root, "corrupt", OpenMode::ExistingOnly).is_err());
767        fs::remove_dir_all(root).unwrap();
768    }
769
770    #[test]
771    fn delete_removes_only_the_control_file() {
772        let root = root("delete");
773        let unrelated = root.join("keep.session-log");
774        fs::write(&unrelated, b"log").unwrap();
775        let control = SessionControl::open(&root, "session-1", OpenMode::CreateNew)
776            .unwrap()
777            .unwrap();
778        let path = control_path(&root, "session-1");
779        control.delete().unwrap();
780        assert!(!path.exists());
781        assert_eq!(fs::read(unrelated).unwrap(), b"log");
782        fs::remove_dir_all(root).unwrap();
783    }
784}