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
253/// `SYNAPS_DAEMON_RESTORE_PINS` is not off.
254pub fn restore_enabled() -> bool {
255    !super::env_is_off(std::env::var(RESTORE_ENV).ok().as_deref())
256}
257
258/// The live session for a journal, if this daemon holds it (a lock
259/// placeholder does not count).
260pub fn live_for(state: &DaemonState, journal_id: &str) -> Option<SessionHandle> {
261    state
262        .live_sessions()
263        .into_iter()
264        .filter(|h| h.is_alive() && h.meta().locked_by.is_none())
265        .find(|h| h.id.as_str() == journal_id || h.journal_id() == journal_id)
266}
267
268/// Entries that are not live in this daemon: what listings show as
269/// waiting / failed / not restored yet.
270pub fn offline(entries: Vec<PinEntry>, live: &[crate::session::SessionMeta]) -> Vec<PinEntry> {
271    entries
272        .into_iter()
273        .filter(|e| !live.iter().any(|m| m.id.as_str() == e.journal_id || m.journal_id == e.journal_id))
274        .collect()
275}
276
277/// `daemon pin <ID|NAME>` against the book: exact journal id, a unique
278/// name, or a unique journal-id prefix of at least
279/// [`super::close::MIN_PREFIX_LEN`] characters.
280pub fn resolve_entry(entries: &[PinEntry], query: &str) -> Option<PinEntry> {
281    let q = query.trim();
282    if q.is_empty() {
283        return None;
284    }
285    if let Some(e) = entries.iter().find(|e| e.journal_id == q) {
286        return Some(e.clone());
287    }
288    let unique = |v: Vec<&PinEntry>| if v.len() == 1 { Some(v[0].clone()) } else { None };
289    unique(entries.iter().filter(|e| e.name.as_deref() == Some(q)).collect()).or_else(|| {
290        (q.chars().count() >= super::close::MIN_PREFIX_LEN)
291            .then(|| unique(entries.iter().filter(|e| e.journal_id.starts_with(q)).collect()))
292            .flatten()
293    })
294}
295
296/// Who holds `journal_id`'s lock, if anyone (probe: take and drop it).
297/// `create` publishes the session endpoint before it locks, so a held
298/// journal must be found before `create` runs.
299fn lock_holder(dir: &Path, journal_id: &str) -> Option<String> {
300    use agent_core::session_lock::{LockHolder, SessionLock, SessionLockError};
301    let me = LockHolder { pid: std::process::id(), kind: "daemon".into() };
302    match SessionLock::try_acquire(dir, journal_id, me) {
303        Ok(lock) => {
304            drop(lock);
305            None
306        }
307        Err(SessionLockError::Held { holder, .. }) => {
308            Some(holder.map_or_else(|| "another process".to_string(), |h| format!("pid {}, {}", h.pid, h.kind)))
309        }
310        // Compacted or I/O: `create` follows the chain or reports it.
311        Err(_) => None,
312    }
313}
314
315/// Outcome of one restore, for logs and `daemon pin`.
316#[derive(Debug, PartialEq, Eq)]
317pub enum Restored {
318    Live,
319    Waiting(String),
320    Failed(String),
321}
322
323/// Continue one entry into this daemon, pinned (`synaps --attach
324/// --continue <id>`'s create). Never steals a held journal; the attempt
325/// marker is on disk before `create`, so a daemon that dies inside it does
326/// not try again forever.
327pub async fn restore_one(state: &Arc<DaemonState>, entry: &PinEntry) -> Restored {
328    let id = entry.journal_id.as_str();
329    if let Some(h) = live_for(state, id) {
330        if !h.pinned() {
331            let _ = h.send(SessionCommand::KeepWarm { on: true }).await;
332        }
333        return Restored::Live;
334    }
335    let fail = |why: String| {
336        state.pins.set(id, |e| {
337            e.set_state(STATE_FAILED, why.clone());
338            e.attempts = 0;
339        });
340        Restored::Failed(why)
341    };
342    if entry.attempts >= MAX_ATTEMPTS {
343        let why = format!(
344            "the daemon died {} times while restoring it; not retried (`synaps daemon pin {id}` tries again, --off clears it)",
345            entry.attempts
346        );
347        state.pins.set(id, |e| e.set_state(STATE_FAILED, why.clone()));
348        return Restored::Failed(why);
349    }
350    let dir = agent_core::session_lock::sessions_dir();
351    if !dir.join(format!("{id}.json")).is_file() {
352        return fail(format!("journal {id} is missing (it never ran a turn, or was deleted)"));
353    }
354    let mut holder = lock_holder(&dir, id);
355    if holder.is_some() {
356        tokio::time::sleep(HELD_RECHECK).await;
357        holder = lock_holder(&dir, id);
358    }
359    if let Some(who) = holder {
360        let why = format!("held by {who}");
361        state.pins.set(id, |e| e.set_state(STATE_WAITING, why.clone()));
362        return Restored::Waiting(why);
363    }
364    state.pins.set(id, |e| e.attempts += 1);
365    let cfg = SessionConfig {
366        continue_session: Some(Some(id.to_string())),
367        cwd: entry.cwd.clone().filter(|c| c.is_dir()),
368        compaction_policy: CompactionPolicyWire::LinkedSuccessor,
369        await_extensions: false,
370        keep_warm: true,
371        ..Default::default()
372    };
373    match tokio::time::timeout(RESTORE_CREATE_TIMEOUT, state.create(cfg)).await {
374        // The actor's create wrote the entry back (pinned: state cleared).
375        Ok(Ok(h)) => {
376            // As reload does. No ping follows: with no request since the
377            // restore there is no cache anchor, so a period waits for the
378            // first real turn.
379            let kw = entry.cache_keepwarm.as_deref().filter(|m| crate::core::config::CacheKeepWarm::parse(m).is_some());
380            if let Some(mode) = kw {
381                let _ = h
382                    .send(SessionCommand::EngineCommand { id: u64::MAX, name: "keepwarm".into(), arg: mode.to_string() })
383                    .await;
384            }
385            tracing::info!(journal = id, session = %h.id, "daemon: pinned session restored");
386            Restored::Live
387        }
388        Ok(Err(e)) if super::reload::is_lock_held_error(&e) => {
389            let why = format!("held by {}", super::reload::parse_holder_from_error(&e));
390            state.pins.set(id, |e| {
391                e.set_state(STATE_WAITING, why.clone());
392                e.attempts = 0;
393            });
394            Restored::Waiting(why)
395        }
396        Ok(Err(e)) => fail(e),
397        Err(_) => fail(format!("create did not finish within {} s", RESTORE_CREATE_TIMEOUT.as_secs())),
398    }
399}
400
401/// Cold start: restore every entry not already live, one at a time (so the
402/// crash marker names the one entry in flight), after the daemon is ready.
403/// Holds the idle exit while it runs.
404pub async fn restore_all(state: Arc<DaemonState>) {
405    let entries = state.pins.load();
406    if entries.is_empty() {
407        return;
408    }
409    state.restoring.store(true, Ordering::SeqCst);
410    tracing::info!(pins = entries.len(), "daemon: restoring pinned sessions");
411    for entry in entries {
412        if state.shutdown.is_cancelled() {
413            break;
414        }
415        match restore_one(&state, &entry).await {
416            Restored::Live => {}
417            Restored::Waiting(why) | Restored::Failed(why) => {
418                tracing::warn!(journal = %entry.journal_id, reason = %why, "daemon: pinned session not restored");
419            }
420        }
421    }
422    state.restoring.store(false, Ordering::SeqCst);
423}
424
425/// `daemon pin` for a journal that is not live here: `--off` drops its
426/// entry, on restores it now (a fresh try: the crash marker is reset).
427/// `None` when the book has no such entry.
428pub async fn pin_offline(state: &Arc<DaemonState>, id: &str, on: bool) -> Option<Result<(), String>> {
429    let entry = state.pins.load().into_iter().find(|e| e.journal_id == id)?;
430    if !on {
431        return Some(state.pins.remove(id).map(|_| ()).map_err(|e| e.to_string()));
432    }
433    let entry = PinEntry { attempts: 0, ..entry };
434    state.pins.set(id, |e| e.attempts = 0);
435    Some(match restore_one(state, &entry).await {
436        Restored::Live => Ok(()),
437        Restored::Waiting(why) => Err(format!("not restored: {why}")),
438        Restored::Failed(why) => Err(format!("restore failed: {why}")),
439    })
440}
441
442#[cfg(test)]
443mod tests {
444    use super::*;
445
446    fn book() -> (tempfile::TempDir, PinBook) {
447        let dir = tempfile::tempdir().unwrap();
448        let book = PinBook::at(dir.path().join(PINS_FILE));
449        (dir, book)
450    }
451
452    #[test]
453    fn pin_unpin_and_move_write_through() {
454        let (_d, book) = book();
455        book.pinned("a", "a", Some("axon"), Some(Path::new("/tmp")), Some("6h"));
456        book.pinned("b", "b", None, None, None);
457        let v = book.load();
458        assert_eq!(v.iter().map(|e| e.journal_id.as_str()).collect::<Vec<_>>(), ["a", "b"]);
459        assert_eq!(v[0].name.as_deref(), Some("axon"));
460        assert!(v[0].pinned_at.is_some());
461        assert_eq!(v[0].cache_keepwarm.as_deref(), Some("6h"));
462        book.moved("a", "a2");
463        book.pinned("a", "a2", Some("axon"), None, None); // refresh: no duplicate
464        assert_eq!(book.load().iter().map(|e| e.journal_id.as_str()).collect::<Vec<_>>(), ["a2", "b"]);
465        book.unpinned("a", "a2");
466        assert_eq!(book.load().len(), 1);
467        #[cfg(unix)]
468        {
469            use std::os::unix::fs::PermissionsExt;
470            let mode = std::fs::metadata(book.path()).unwrap().permissions().mode() & 0o777;
471            assert_eq!(mode, 0o600);
472        }
473    }
474
475    #[test]
476    fn an_empty_book_never_creates_the_file() {
477        let (_d, book) = book();
478        book.unpinned("x", "x");
479        assert!(!book.path().exists());
480    }
481
482    #[test]
483    fn one_bad_entry_never_loses_the_rest_and_is_written_back() {
484        let (_d, book) = book();
485        std::fs::write(
486            book.path(),
487            r#"{"version":1,"pins":[
488                {"journal_id":"good","future_field":{"x":1}},
489                {"journal_id":42},
490                "garbage",
491                {"journal_id":"also","attempts":"lots"}
492            ],"future_top":true}"#,
493        )
494        .unwrap();
495        let v = book.load();
496        assert_eq!(v.len(), 1, "{v:?}");
497        assert_eq!(v[0].extra.get("future_field"), Some(&serde_json::json!({"x":1})));
498        book.pinned("new", "new", None, None, None);
499        let text = std::fs::read_to_string(book.path()).unwrap();
500        assert!(text.contains("future_field") && text.contains("garbage") && text.contains("\"lots\""), "{text}");
501        assert_eq!(book.load().len(), 2);
502    }
503
504    #[test]
505    fn a_newer_or_unreadable_file_is_read_only() {
506        let (_d, book) = book();
507        std::fs::write(book.path(), r#"{"version":9,"pins":[{"journal_id":"a"}]}"#).unwrap();
508        assert_eq!(book.load().len(), 1, "still readable");
509        assert!(book.remove("a").is_err());
510        assert!(std::fs::read_to_string(book.path()).unwrap().contains("\"version\":9"));
511        std::fs::write(book.path(), "{not json").unwrap();
512        assert!(book.load().is_empty());
513        book.pinned("b", "b", None, None, None);
514        assert_eq!(std::fs::read_to_string(book.path()).unwrap(), "{not json", "never overwritten");
515    }
516
517    #[test]
518    fn resolve_entry_by_id_name_or_long_prefix() {
519        let e = |id: &str, name: Option<&str>| PinEntry { journal_id: id.into(), name: name.map(Into::into), ..Default::default() };
520        let v = [e("20261025-101010-aaaa", Some("axon")), e("20261025-111111-bbbb", None)];
521        assert_eq!(resolve_entry(&v, "axon").map(|e| e.journal_id), Some("20261025-101010-aaaa".into()));
522        assert_eq!(resolve_entry(&v, "20261025-11").map(|e| e.journal_id), Some("20261025-111111-bbbb".into()));
523        assert!(resolve_entry(&v, "2026").is_none(), "too short");
524        assert!(resolve_entry(&v, "20261025-1").is_none(), "ambiguous");
525        assert!(resolve_entry(&v, "nope").is_none());
526    }
527
528    #[test]
529    fn state_text_reads_as_listed() {
530        let mut e = PinEntry { journal_id: "a".into(), ..Default::default() };
531        assert_eq!(e.state_text(), "not restored yet");
532        e.set_state(STATE_WAITING, "held by pid 7, tui".into());
533        assert_eq!(e.state_text(), "waiting: held by pid 7, tui");
534    }
535}