Skip to main content

agent_engine/daemon/
pins.rs

1//! Pinned sessions come back after the daemon restarts.
2//!
3//! The **pin book** (`<config dir>/daemon-pins.json`) is the set of pinned
4//! journals for this profile. It is written through, never by a timer: the
5//! actor calls [`PinBook`] (as the host's [`PinSink`]) when the pin changes,
6//! when a pinned session moves to a new journal, and when a pinned session
7//! is ended on purpose. A daemon stop, reload or crash leaves it alone, and
8//! the next cold start continues every entry that is not live and pins it
9//! ([`restore_all`]). `SYNAPS_DAEMON_RESTORE_PINS=0` starts without that.
10//!
11//! The file lives in the config dir, not the run dir: older binaries unlink
12//! run-dir json they cannot parse, and the run dir may be tmpfs. Entries are
13//! parsed one at a time, so one bad (or newer) entry never loses the rest;
14//! an entry this build can't read is written back untouched, and a file
15//! from a newer schema version is read-only here.
16
17use std::io;
18use std::path::{Path, PathBuf};
19use std::sync::atomic::Ordering;
20use std::sync::Arc;
21use std::time::Duration;
22
23use chrono::{DateTime, Utc};
24use fs4::fs_std::FileExt;
25use serde::{Deserialize, Serialize};
26
27use super::DaemonState;
28use crate::session::{CompactionPolicyWire, PinSink, SessionCommand, SessionConfig, SessionHandle};
29
30pub const PINS_FILE: &str = "daemon-pins.json";
31pub const FILE_VERSION: u64 = 1;
32/// `0`/`false`/`off`/`no`: start the daemon without restoring pins.
33pub const RESTORE_ENV: &str = "SYNAPS_DAEMON_RESTORE_PINS";
34/// Restore attempts the daemon did not live through before an entry is
35/// given up as a crash loop (marked failed, not retried).
36pub const MAX_ATTEMPTS: u32 = 2;
37/// A journal held by an exiting process gets this long, once.
38pub const HELD_RECHECK: Duration = Duration::from_secs(2);
39/// One restore's `create` budget (MCP servers, extensions).
40pub const RESTORE_CREATE_TIMEOUT: Duration = Duration::from_secs(60);
41
42pub const STATE_WAITING: &str = "waiting";
43pub const STATE_FAILED: &str = "failed";
44
45/// One pinned journal. Never carries `SessionConfig` or settings.
46#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
47pub struct PinEntry {
48    pub journal_id: String,
49    #[serde(default, skip_serializing_if = "Option::is_none")]
50    pub name: Option<String>,
51    #[serde(default, skip_serializing_if = "Option::is_none")]
52    pub cwd: Option<PathBuf>,
53    #[serde(default)]
54    pub pinned_at: Option<DateTime<Utc>>,
55    /// `waiting` / `failed` after a restore that did not bring it back.
56    #[serde(default, skip_serializing_if = "Option::is_none")]
57    pub state: Option<String>,
58    #[serde(default, skip_serializing_if = "Option::is_none")]
59    pub reason: Option<String>,
60    /// The session's `/keepwarm` override (`off`/`auto`/`<N>h`), restored
61    /// with the pin; one this build can't parse falls back to the default.
62    #[serde(default, skip_serializing_if = "Option::is_none")]
63    pub cache_keepwarm: Option<String>,
64    /// Crash-loop marker: restores begun and not finished.
65    #[serde(default, skip_serializing_if = "is_zero")]
66    pub attempts: u32,
67    /// Fields from a newer build, kept as they were.
68    #[serde(flatten)]
69    pub extra: serde_json::Map<String, serde_json::Value>,
70}
71
72fn is_zero(n: &u32) -> bool {
73    *n == 0
74}
75
76impl PinEntry {
77    /// `waiting: held by pid 42, tui` / `failed: journal … is missing` /
78    /// `not restored yet`.
79    pub fn state_text(&self) -> String {
80        match (self.state.as_deref(), self.reason.as_deref()) {
81            (Some(s), Some(r)) => format!("{s}: {r}"),
82            (Some(s), None) => s.to_string(),
83            (None, _) => "not restored yet".to_string(),
84        }
85    }
86
87    /// Its name, else its journal id.
88    pub fn label(&self) -> &str {
89        self.name.as_deref().filter(|n| !n.is_empty()).unwrap_or(&self.journal_id)
90    }
91
92    fn set_state(&mut self, state: &str, reason: String) {
93        self.state = Some(state.to_string());
94        self.reason = Some(reason);
95    }
96
97    fn clear_state(&mut self) {
98        self.state = None;
99        self.reason = None;
100        self.attempts = 0;
101    }
102}
103
104/// The pin book of one config dir. Every change is a locked
105/// read-modify-write (`<file>.lock`), so the daemon and a CLI clearing an
106/// entry never lose each other's writes.
107#[derive(Debug, Clone)]
108pub struct PinBook {
109    path: PathBuf,
110}
111
112struct Raw {
113    version: u64,
114    entries: Vec<PinEntry>,
115    /// Entries this build can't parse, written back as they were.
116    unknown: Vec<serde_json::Value>,
117}
118
119impl PinBook {
120    pub fn at(path: impl Into<PathBuf>) -> Self {
121        Self { path: path.into() }
122    }
123
124    /// The active profile's book.
125    pub fn for_active_profile() -> Self {
126        Self::at(crate::config::get_active_config_dir().join(PINS_FILE))
127    }
128
129    pub fn path(&self) -> &Path {
130        &self.path
131    }
132
133    /// Every entry this build can read (missing or unreadable file: none).
134    pub fn load(&self) -> Vec<PinEntry> {
135        self.read().entries
136    }
137
138    fn read(&self) -> Raw {
139        let mut raw = Raw { version: FILE_VERSION, entries: Vec::new(), unknown: Vec::new() };
140        let Ok(text) = std::fs::read_to_string(&self.path) else { return raw };
141        let Ok(serde_json::Value::Object(top)) = serde_json::from_str::<serde_json::Value>(&text) else {
142            tracing::warn!(path = %self.path.display(), "pins: unreadable pin book; leaving it alone");
143            raw.version = u64::MAX; // read-only: never overwrite what we can't read
144            return raw;
145        };
146        raw.version = top.get("version").and_then(|v| v.as_u64()).unwrap_or(FILE_VERSION);
147        let list = top.get("pins").and_then(|v| v.as_array()).cloned().unwrap_or_default();
148        for v in list {
149            match serde_json::from_value::<PinEntry>(v.clone()) {
150                Ok(e) if !e.journal_id.is_empty() => raw.entries.push(e),
151                _ => raw.unknown.push(v),
152            }
153        }
154        raw
155    }
156
157    /// Locked read-modify-write. Writes only when `f` changed something;
158    /// an empty book never creates the file. `Err` when the file is from a
159    /// newer schema (or unreadable) and so read-only here.
160    pub fn update<R>(&self, f: impl FnOnce(&mut Vec<PinEntry>) -> R) -> io::Result<R> {
161        if let Some(dir) = self.path.parent() {
162            std::fs::create_dir_all(dir)?;
163        }
164        let mut lock_name = self.path.file_name().unwrap_or_default().to_os_string();
165        lock_name.push(".lock");
166        let lock = std::fs::OpenOptions::new()
167            .create(true)
168            .truncate(false)
169            .write(true)
170            .open(self.path.with_file_name(lock_name))?;
171        FileExt::lock_exclusive(&lock)?;
172        let mut raw = self.read();
173        let before = raw.entries.clone();
174        let out = f(&mut raw.entries);
175        if raw.entries == before {
176            return Ok(out);
177        }
178        if raw.version > FILE_VERSION {
179            return Err(io::Error::other(format!(
180                "{} is from a newer synaps (or unreadable); not changing it",
181                self.path.display()
182            )));
183        }
184        let mut pins: Vec<serde_json::Value> =
185            raw.entries.iter().filter_map(|e| serde_json::to_value(e).ok()).collect();
186        pins.extend(raw.unknown);
187        let body = serde_json::to_vec_pretty(&serde_json::json!({ "version": FILE_VERSION, "pins": pins }))
188            .map_err(io::Error::other)?;
189        super::registry::write_private_atomic(&self.path, &body)?;
190        Ok(out)
191    }
192
193    fn update_logged(&self, what: &str, f: impl FnOnce(&mut Vec<PinEntry>)) {
194        if let Err(e) = self.update(f) {
195            tracing::warn!(path = %self.path.display(), error = %e, "pins: {what} not written");
196        }
197    }
198
199    /// Drop the entry for `id` (journal id, exact). `true` if there was one.
200    pub fn remove(&self, id: &str) -> io::Result<bool> {
201        self.update(|v| {
202            let n = v.len();
203            v.retain(|e| e.journal_id != id);
204            v.len() != n
205        })
206    }
207
208    fn set(&self, journal_id: &str, f: impl FnOnce(&mut PinEntry)) {
209        self.update_logged("state", |v| {
210            if let Some(e) = v.iter_mut().find(|e| e.journal_id == journal_id) {
211                f(e);
212            }
213        });
214    }
215}
216
217impl PinSink for PinBook {
218    fn pinned(&self, id: &str, journal_id: &str, name: Option<&str>, cwd: Option<&Path>, cache_keepwarm: Option<&str>) {
219        self.update_logged("pin", |v| {
220            let at = v.iter().position(|e| e.journal_id == journal_id).or_else(|| v.iter().position(|e| e.journal_id == id));
221            let e = match at {
222                Some(i) => &mut v[i],
223                None => {
224                    v.push(PinEntry { pinned_at: Some(Utc::now()), ..Default::default() });
225                    v.last_mut().expect("just pushed")
226                }
227            };
228            e.journal_id = journal_id.to_string();
229            e.name = name.map(str::to_owned).or(e.name.take());
230            if let Some(cwd) = cwd {
231                e.cwd = Some(cwd.to_path_buf());
232            }
233            if let Some(kw) = cache_keepwarm {
234                e.cache_keepwarm = Some(kw.to_string());
235            }
236            e.clear_state();
237        });
238    }
239
240    fn unpinned(&self, id: &str, journal_id: &str) {
241        self.update_logged("unpin", |v| v.retain(|e| e.journal_id != journal_id && e.journal_id != id));
242    }
243
244    fn moved(&self, from: &str, to: &str) {
245        self.update_logged("move", |v| {
246            if let Some(e) = v.iter_mut().find(|e| e.journal_id == from) {
247                e.journal_id = to.to_string();
248            }
249        });
250    }
251
252    fn renamed(&self, journal_id: &str, name: Option<&str>) {
253        self.update_logged("rename", |v| {
254            if let Some(e) = v.iter_mut().find(|e| e.journal_id == journal_id) {
255                e.name = name.map(str::to_owned);
256            }
257        });
258    }
259}
260
261/// `SYNAPS_DAEMON_RESTORE_PINS` is not off.
262pub fn restore_enabled() -> bool {
263    !super::env_is_off(std::env::var(RESTORE_ENV).ok().as_deref())
264}
265
266/// The live session for a journal, if this daemon holds it (a lock
267/// placeholder does not count).
268pub fn live_for(state: &DaemonState, journal_id: &str) -> Option<SessionHandle> {
269    state
270        .live_sessions()
271        .into_iter()
272        .filter(|h| h.is_alive() && h.meta().locked_by.is_none())
273        .find(|h| h.id.as_str() == journal_id || h.journal_id() == journal_id)
274}
275
276/// Entries that are not live in this daemon: what listings show as
277/// waiting / failed / not restored yet.
278pub fn offline(entries: Vec<PinEntry>, live: &[crate::session::SessionMeta]) -> Vec<PinEntry> {
279    entries
280        .into_iter()
281        .filter(|e| !live.iter().any(|m| m.id.as_str() == e.journal_id || m.journal_id == e.journal_id))
282        .collect()
283}
284
285/// `daemon pin <ID|NAME>` against the book: exact journal id, a unique
286/// name, or a unique journal-id prefix of at least
287/// [`super::close::MIN_PREFIX_LEN`] characters.
288pub fn resolve_entry(entries: &[PinEntry], query: &str) -> Option<PinEntry> {
289    let q = query.trim();
290    if q.is_empty() {
291        return None;
292    }
293    if let Some(e) = entries.iter().find(|e| e.journal_id == q) {
294        return Some(e.clone());
295    }
296    let unique = |v: Vec<&PinEntry>| if v.len() == 1 { Some(v[0].clone()) } else { None };
297    unique(entries.iter().filter(|e| e.name.as_deref() == Some(q)).collect()).or_else(|| {
298        (q.chars().count() >= super::close::MIN_PREFIX_LEN)
299            .then(|| unique(entries.iter().filter(|e| e.journal_id.starts_with(q)).collect()))
300            .flatten()
301    })
302}
303
304/// Who holds `journal_id`'s lock, if anyone (probe: take and drop it).
305/// `create` publishes the session endpoint before it locks, so a held
306/// journal must be found before `create` runs.
307fn lock_holder(dir: &Path, journal_id: &str) -> Option<String> {
308    use agent_core::session_lock::{LockHolder, SessionLock, SessionLockError};
309    let me = LockHolder { pid: std::process::id(), kind: "daemon".into() };
310    match SessionLock::try_acquire(dir, journal_id, me) {
311        Ok(lock) => {
312            drop(lock);
313            None
314        }
315        Err(SessionLockError::Held { holder, .. }) => {
316            Some(holder.map_or_else(|| "another process".to_string(), |h| format!("pid {}, {}", h.pid, h.kind)))
317        }
318        // Compacted or I/O: `create` follows the chain or reports it.
319        Err(_) => None,
320    }
321}
322
323/// Outcome of one restore, for logs and `daemon pin`.
324#[derive(Debug, PartialEq, Eq)]
325pub enum Restored {
326    Live,
327    Waiting(String),
328    Failed(String),
329}
330
331/// Continue one entry into this daemon, pinned (`synaps --attach
332/// --continue <id>`'s create). Never steals a held journal; the attempt
333/// marker is on disk before `create`, so a daemon that dies inside it does
334/// not try again forever.
335pub async fn restore_one(state: &Arc<DaemonState>, entry: &PinEntry) -> Restored {
336    let id = entry.journal_id.as_str();
337    if let Some(h) = live_for(state, id) {
338        if !h.pinned() {
339            let _ = h.send(SessionCommand::KeepWarm { on: true }).await;
340        }
341        return Restored::Live;
342    }
343    let fail = |why: String| {
344        state.pins.set(id, |e| {
345            e.set_state(STATE_FAILED, why.clone());
346            e.attempts = 0;
347        });
348        Restored::Failed(why)
349    };
350    if entry.attempts >= MAX_ATTEMPTS {
351        let why = format!(
352            "the daemon died {} times while restoring it; not retried (`synaps daemon pin {id}` tries again, --off clears it)",
353            entry.attempts
354        );
355        state.pins.set(id, |e| e.set_state(STATE_FAILED, why.clone()));
356        return Restored::Failed(why);
357    }
358    let dir = agent_core::session_lock::sessions_dir();
359    if !dir.join(format!("{id}.json")).is_file() {
360        return fail(format!("journal {id} is missing (it never ran a turn, or was deleted)"));
361    }
362    let mut holder = lock_holder(&dir, id);
363    if holder.is_some() {
364        tokio::time::sleep(HELD_RECHECK).await;
365        holder = lock_holder(&dir, id);
366    }
367    if let Some(who) = holder {
368        let why = format!("held by {who}");
369        state.pins.set(id, |e| e.set_state(STATE_WAITING, why.clone()));
370        return Restored::Waiting(why);
371    }
372    state.pins.set(id, |e| e.attempts += 1);
373    let cfg = SessionConfig {
374        continue_session: Some(Some(id.to_string())),
375        cwd: entry.cwd.clone().filter(|c| c.is_dir()),
376        compaction_policy: CompactionPolicyWire::LinkedSuccessor,
377        await_extensions: false,
378        keep_warm: true,
379        ..Default::default()
380    };
381    match tokio::time::timeout(RESTORE_CREATE_TIMEOUT, state.create(cfg)).await {
382        // The actor's create wrote the entry back (pinned: state cleared).
383        Ok(Ok(h)) => {
384            // As reload does. No ping follows: with no request since the
385            // restore there is no cache anchor, so a period waits for the
386            // first real turn.
387            let kw = entry.cache_keepwarm.as_deref().filter(|m| crate::core::config::CacheKeepWarm::parse(m).is_some());
388            if let Some(mode) = kw {
389                let _ = h
390                    .send(SessionCommand::EngineCommand { id: u64::MAX, name: "keepwarm".into(), arg: mode.to_string() })
391                    .await;
392            }
393            tracing::info!(journal = id, session = %h.id, "daemon: pinned session restored");
394            Restored::Live
395        }
396        Ok(Err(e)) if super::reload::is_lock_held_error(&e) => {
397            let why = format!("held by {}", super::reload::parse_holder_from_error(&e));
398            state.pins.set(id, |e| {
399                e.set_state(STATE_WAITING, why.clone());
400                e.attempts = 0;
401            });
402            Restored::Waiting(why)
403        }
404        Ok(Err(e)) => fail(e),
405        Err(_) => fail(format!("create did not finish within {} s", RESTORE_CREATE_TIMEOUT.as_secs())),
406    }
407}
408
409/// Cold start: restore every entry not already live, one at a time (so the
410/// crash marker names the one entry in flight), after the daemon is ready.
411/// Holds the idle exit while it runs.
412pub async fn restore_all(state: Arc<DaemonState>) {
413    let entries = state.pins.load();
414    if entries.is_empty() {
415        return;
416    }
417    state.restoring.store(true, Ordering::SeqCst);
418    tracing::info!(pins = entries.len(), "daemon: restoring pinned sessions");
419    for entry in entries {
420        if state.shutdown.is_cancelled() {
421            break;
422        }
423        match restore_one(&state, &entry).await {
424            Restored::Live => {}
425            Restored::Waiting(why) | Restored::Failed(why) => {
426                tracing::warn!(journal = %entry.journal_id, reason = %why, "daemon: pinned session not restored");
427            }
428        }
429    }
430    state.restoring.store(false, Ordering::SeqCst);
431}
432
433/// `daemon pin` for a journal that is not live here: `--off` drops its
434/// entry, on restores it now (a fresh try: the crash marker is reset).
435/// `None` when the book has no such entry.
436pub async fn pin_offline(state: &Arc<DaemonState>, id: &str, on: bool) -> Option<Result<(), String>> {
437    let entry = state.pins.load().into_iter().find(|e| e.journal_id == id)?;
438    if !on {
439        return Some(state.pins.remove(id).map(|_| ()).map_err(|e| e.to_string()));
440    }
441    let entry = PinEntry { attempts: 0, ..entry };
442    state.pins.set(id, |e| e.attempts = 0);
443    Some(match restore_one(state, &entry).await {
444        Restored::Live => Ok(()),
445        Restored::Waiting(why) => Err(format!("not restored: {why}")),
446        Restored::Failed(why) => Err(format!("restore failed: {why}")),
447    })
448}
449
450#[cfg(test)]
451mod tests {
452    use super::*;
453
454    fn book() -> (tempfile::TempDir, PinBook) {
455        let dir = tempfile::tempdir().unwrap();
456        let book = PinBook::at(dir.path().join(PINS_FILE));
457        (dir, book)
458    }
459
460    #[test]
461    fn pin_unpin_and_move_write_through() {
462        let (_d, book) = book();
463        book.pinned("a", "a", Some("axon"), Some(Path::new("/tmp")), Some("6h"));
464        book.pinned("b", "b", None, None, None);
465        let v = book.load();
466        assert_eq!(v.iter().map(|e| e.journal_id.as_str()).collect::<Vec<_>>(), ["a", "b"]);
467        assert_eq!(v[0].name.as_deref(), Some("axon"));
468        assert!(v[0].pinned_at.is_some());
469        assert_eq!(v[0].cache_keepwarm.as_deref(), Some("6h"));
470        book.moved("a", "a2");
471        book.pinned("a", "a2", Some("axon"), None, None); // refresh: no duplicate
472        assert_eq!(book.load().iter().map(|e| e.journal_id.as_str()).collect::<Vec<_>>(), ["a2", "b"]);
473        book.unpinned("a", "a2");
474        assert_eq!(book.load().len(), 1);
475        #[cfg(unix)]
476        {
477            use std::os::unix::fs::PermissionsExt;
478            let mode = std::fs::metadata(book.path()).unwrap().permissions().mode() & 0o777;
479            assert_eq!(mode, 0o600);
480        }
481    }
482
483    #[test]
484    fn an_empty_book_never_creates_the_file() {
485        let (_d, book) = book();
486        book.unpinned("x", "x");
487        assert!(!book.path().exists());
488    }
489
490    #[test]
491    fn one_bad_entry_never_loses_the_rest_and_is_written_back() {
492        let (_d, book) = book();
493        std::fs::write(
494            book.path(),
495            r#"{"version":1,"pins":[
496                {"journal_id":"good","future_field":{"x":1}},
497                {"journal_id":42},
498                "garbage",
499                {"journal_id":"also","attempts":"lots"}
500            ],"future_top":true}"#,
501        )
502        .unwrap();
503        let v = book.load();
504        assert_eq!(v.len(), 1, "{v:?}");
505        assert_eq!(v[0].extra.get("future_field"), Some(&serde_json::json!({"x":1})));
506        book.pinned("new", "new", None, None, None);
507        let text = std::fs::read_to_string(book.path()).unwrap();
508        assert!(text.contains("future_field") && text.contains("garbage") && text.contains("\"lots\""), "{text}");
509        assert_eq!(book.load().len(), 2);
510    }
511
512    #[test]
513    fn a_newer_or_unreadable_file_is_read_only() {
514        let (_d, book) = book();
515        std::fs::write(book.path(), r#"{"version":9,"pins":[{"journal_id":"a"}]}"#).unwrap();
516        assert_eq!(book.load().len(), 1, "still readable");
517        assert!(book.remove("a").is_err());
518        assert!(std::fs::read_to_string(book.path()).unwrap().contains("\"version\":9"));
519        std::fs::write(book.path(), "{not json").unwrap();
520        assert!(book.load().is_empty());
521        book.pinned("b", "b", None, None, None);
522        assert_eq!(std::fs::read_to_string(book.path()).unwrap(), "{not json", "never overwritten");
523    }
524
525    #[test]
526    fn resolve_entry_by_id_name_or_long_prefix() {
527        let e = |id: &str, name: Option<&str>| PinEntry { journal_id: id.into(), name: name.map(Into::into), ..Default::default() };
528        let v = [e("20261025-101010-aaaa", Some("axon")), e("20261025-111111-bbbb", None)];
529        assert_eq!(resolve_entry(&v, "axon").map(|e| e.journal_id), Some("20261025-101010-aaaa".into()));
530        assert_eq!(resolve_entry(&v, "20261025-11").map(|e| e.journal_id), Some("20261025-111111-bbbb".into()));
531        assert!(resolve_entry(&v, "2026").is_none(), "too short");
532        assert!(resolve_entry(&v, "20261025-1").is_none(), "ambiguous");
533        assert!(resolve_entry(&v, "nope").is_none());
534    }
535
536    #[test]
537    fn state_text_reads_as_listed() {
538        let mut e = PinEntry { journal_id: "a".into(), ..Default::default() };
539        assert_eq!(e.state_text(), "not restored yet");
540        e.set_state(STATE_WAITING, "held by pid 7, tui".into());
541        assert_eq!(e.state_text(), "waiting: held by pid 7, tui");
542    }
543}