1use std::path::{Path, PathBuf};
24use std::sync::atomic::Ordering;
25use std::sync::Arc;
26use std::time::Duration;
27
28use serde::{Deserialize, Serialize};
29
30use super::registry::{self, DaemonLock, DaemonPaths};
31use super::DaemonState;
32use crate::session::wire::{ClientFrame, CHECKPOINT_QUERY_ID, PROTOCOL_VERSION};
33use crate::session::{
34 CheckpointReason, SessionCommand, SessionConfig, SessionEventWire, SessionHandle,
35 SessionLifecycle, SessionReloadRecord, SessionSetting,
36};
37
38pub const RELOAD_STATE_ENV: &str = "SYNAPS_DAEMON_RELOAD_STATE";
40pub const LOCK_FD_ENV: &str = "SYNAPS_DAEMON_LOCK_FD";
42pub const SUBAGENT_ID_FLOOR_ENV: &str = "SYNAPS_DAEMON_SUBAGENT_ID_FLOOR";
46pub const DRAIN_SECS_ENV: &str = "SYNAPS_DAEMON_RELOAD_DRAIN_SECS";
48const DEFAULT_DRAIN: Duration = Duration::from_secs(30);
49const VERSION_PROBE_TIMEOUT: Duration = Duration::from_secs(5);
50const CHECKPOINT_BUDGET: Duration =
54 Duration::from_secs(crate::session::budgets::RELOAD_CHECKPOINT_BUDGET_SECS);
55pub const RETRY_AFTER_MS: u64 = 500;
57
58#[derive(Debug, Clone, Default)]
59pub struct ReloadRequest {
60 pub now: bool,
61 pub drain_secs: Option<u64>,
62 pub exe: Option<PathBuf>,
63}
64
65impl ReloadRequest {
66 pub fn from_frame(frame: &ClientFrame) -> Option<Self> {
67 match frame {
68 ClientFrame::Reload { now, drain_secs, exe } => {
69 Some(Self { now: *now, drain_secs: *drain_secs, exe: exe.clone() })
70 }
71 _ => None,
72 }
73 }
74}
75
76#[derive(Debug)]
77pub enum ReloadError {
78 Refused(String),
80 ExecFailed(String),
82}
83
84impl std::fmt::Display for ReloadError {
85 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
86 match self {
87 Self::Refused(w) => write!(f, "reload refused: {w}"),
88 Self::ExecFailed(e) => write!(f, "reload exec failed: {e}"),
89 }
90 }
91}
92
93#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
95pub struct PrintVersion {
96 pub binary_version: String,
97 pub protocol_version: u32,
98}
99
100impl PrintVersion {
101 pub fn current() -> Self {
102 Self {
103 binary_version: crate::session::wire::binary_version(),
104 protocol_version: PROTOCOL_VERSION,
105 }
106 }
107}
108
109#[derive(Debug, Clone, Serialize, Deserialize)]
111pub struct ReloadSession {
112 pub id: String,
113 pub journal_id: String,
114 pub config: SessionConfig,
115 pub keep_warm: bool,
116 pub lifecycle: SessionLifecycle,
117 #[serde(default)]
119 pub settings_replay: Vec<SessionSetting>,
120 #[serde(default)]
122 pub cache_keepwarm: Option<String>,
123 pub input_owner_kind: Option<crate::session::ClientKind>,
124 #[serde(default)]
127 pub workers: Vec<crate::runtime::subagent::CarriedWorker>,
128 #[serde(default)]
132 pub tool_activations: Vec<crate::tools::activation::CarriedActivation>,
133}
134
135#[derive(Debug, Clone, Serialize, Deserialize)]
137pub struct ReloadState {
138 pub generation: u64,
139 pub written_at: chrono::DateTime<chrono::Utc>,
140 pub sessions: Vec<ReloadSession>,
141 pub expected_clients: usize,
142 #[serde(default)]
147 pub next_subagent_id: u64,
148}
149
150impl ReloadState {
151 pub fn subagent_id_floor(&self) -> u64 {
154 let carried = self
155 .sessions
156 .iter()
157 .flat_map(|s| &s.workers)
158 .filter_map(|w| crate::runtime::subagent::handle_number(&w.id))
159 .max()
160 .map_or(0, |n| n + 1);
161 self.next_subagent_id.max(carried)
162 }
163}
164
165pub fn reload_state_path(paths: &DaemonPaths) -> PathBuf {
166 let stem = paths
167 .json
168 .file_stem()
169 .map(|s| s.to_string_lossy().into_owned())
170 .unwrap_or_else(|| "daemon".into());
171 paths.dir.join(format!("{stem}.reload.json"))
172}
173
174pub fn resolve_exe() -> PathBuf {
178 if let Some(a0) = std::env::args_os().next() {
179 let p = PathBuf::from(&a0);
180 if p.components().count() > 1 {
181 if let Ok(c) = p.canonicalize() {
182 return c;
183 }
184 }
185 }
186 std::env::current_exe().unwrap_or_else(|_| PathBuf::from("synaps"))
187}
188
189fn semver_tuple(s: &str) -> Option<(u64, u64, u64)> {
190 let core = s.split(['-', '+']).next()?;
191 let mut it = core.split('.').map(|p| p.parse::<u64>().ok());
192 Some((it.next()??, it.next()??, it.next()??))
193}
194
195pub fn version_gate(current: &PrintVersion, new: &PrintVersion) -> Result<(), String> {
199 if new.protocol_version < current.protocol_version {
200 return Err(format!(
201 "new binary speaks protocol {} < {}: reconnecting clients would be refused",
202 new.protocol_version, current.protocol_version
203 ));
204 }
205 match (semver_tuple(¤t.binary_version), semver_tuple(&new.binary_version)) {
206 (Some(c), Some(n)) if n < c => Err(format!(
207 "new binary {} is older than the running {} (reload is newer-or-equal only)",
208 new.binary_version, current.binary_version
209 )),
210 (Some(_), Some(_)) => Ok(()),
211 _ => Err(format!(
212 "cannot compare versions {:?} vs {:?}",
213 current.binary_version, new.binary_version
214 )),
215 }
216}
217
218#[cfg(unix)]
224pub fn validate_exe(exe: &Path) -> Result<PathBuf, String> {
225 use std::os::unix::fs::{MetadataExt, PermissionsExt};
226 let canon = exe
227 .canonicalize()
228 .map_err(|e| format!("--exe {}: {e}", exe.display()))?;
229 let md = std::fs::metadata(&canon).map_err(|e| format!("--exe {}: {e}", canon.display()))?;
230 if !md.is_file() {
231 return Err(format!("--exe {}: not a regular file", canon.display()));
232 }
233 let mode = md.permissions().mode();
234 if mode & 0o111 == 0 {
235 return Err(format!("--exe {}: not executable", canon.display()));
236 }
237 let uid = unsafe { libc::getuid() };
239 if md.uid() != uid && md.uid() != 0 {
240 return Err(format!(
241 "--exe {}: owned by uid {} (expected {} or root)",
242 canon.display(),
243 md.uid(),
244 uid
245 ));
246 }
247 if mode & 0o022 != 0 {
248 return Err(format!(
249 "--exe {}: group/world-writable (mode {:o})",
250 canon.display(),
251 mode & 0o777
252 ));
253 }
254 Ok(canon)
255}
256
257#[cfg(not(unix))]
258pub fn validate_exe(exe: &Path) -> Result<PathBuf, String> {
259 exe.canonicalize().map_err(|e| format!("--exe {}: {e}", exe.display()))
260}
261
262pub async fn probe_version(exe: &Path) -> Result<PrintVersion, String> {
264 let out = tokio::time::timeout(
265 VERSION_PROBE_TIMEOUT,
266 tokio::process::Command::new(exe)
267 .args(["daemon", "--print-version"])
268 .env("SYNAPS_DAEMON", "1")
269 .stdin(std::process::Stdio::null())
270 .stderr(std::process::Stdio::piped())
271 .stdout(std::process::Stdio::piped())
272 .output(),
273 )
274 .await
275 .map_err(|_| format!("{} --print-version timed out", exe.display()))?
276 .map_err(|e| format!("cannot run {}: {e}", exe.display()))?;
277 if !out.status.success() {
278 return Err(format!(
279 "{} --print-version exited {:?}: {}",
280 exe.display(),
281 out.status.code(),
282 String::from_utf8_lossy(&out.stderr).trim()
283 ));
284 }
285 serde_json::from_slice(&out.stdout)
286 .map_err(|e| format!("{} --print-version: unparsable output ({e})", exe.display()))
287}
288
289async fn checkpoint_one(handle: SessionHandle) -> Option<SessionReloadRecord> {
292 let mut rx = handle.subscribe();
293 if handle
294 .send(SessionCommand::Checkpoint { reason: CheckpointReason::Reload })
295 .await
296 .is_err()
297 {
298 return None;
299 }
300 let wait = async {
301 loop {
302 match rx.recv().await {
303 Ok(env) => {
304 if let SessionEventWire::QueryResult { id, value } = env.event {
305 if id == CHECKPOINT_QUERY_ID {
306 return serde_json::from_value(value["record"].clone()).ok();
307 }
308 }
309 }
310 Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
311 Err(tokio::sync::broadcast::error::RecvError::Closed) => return None,
312 }
313 }
314 };
315 tokio::time::timeout(CHECKPOINT_BUDGET, wait).await.unwrap_or(None)
316}
317
318fn record(handle: &SessionHandle, rec: Option<SessionReloadRecord>) -> ReloadSession {
322 let meta = handle.meta();
323 let journal_id = handle.journal_id();
324 if rec.is_none() {
325 tracing::warn!(
326 session = %handle.id,
327 "reload: no checkpoint reply in time — this session's workers are not carried: \
328 no archive is guaranteed and their parent will not be woken"
329 );
330 }
331 let (config, keep_warm, lifecycle, settings_replay, cache_keepwarm, model, workers, tool_activations) = match rec {
332 Some(r) => (
333 r.config,
334 r.keep_warm,
335 r.lifecycle,
336 r.settings_replay,
337 r.cache_keepwarm,
338 r.model,
339 r.workers,
340 r.tool_activations,
341 ),
342 None => (
343 SessionConfig {
344 cwd: meta.cwd.clone(),
345 memory_mode: handle.memory_mode(),
348 env: None,
349 env_stripped: Vec::new(),
350 ..SessionConfig::default()
351 },
352 handle.pinned(),
355 handle.lifecycle(),
356 Vec::new(),
357 handle.cache_keepwarm_override(),
360 handle.view().model.clone(),
361 Vec::new(),
364 Vec::new(),
366 ),
367 };
368 ReloadSession {
369 id: meta.id.as_str().to_string(),
370 journal_id: journal_id.clone(),
371 config: SessionConfig {
372 continue_session: Some(Some(journal_id)),
373 model_override: Some(model),
374 keep_warm,
375 name: None,
378 ..config
379 },
380 keep_warm,
381 lifecycle,
382 settings_replay,
383 cache_keepwarm,
384 input_owner_kind: None,
385 workers,
386 tool_activations,
387 }
388}
389
390pub struct Prepared {
395 pub exe: PathBuf,
396 pub rs_path: PathBuf,
397 pub generation: u64,
398}
399
400pub async fn prepare(
401 state: &Arc<DaemonState>,
402 paths: &DaemonPaths,
403 req: ReloadRequest,
404) -> Result<Prepared, ReloadError> {
405 let exe = req.exe.clone().unwrap_or_else(|| state.daemon_info().exe.unwrap_or_else(resolve_exe));
409 let exe = validate_exe(&exe).map_err(ReloadError::Refused)?;
410 let new = probe_version(&exe).await.map_err(ReloadError::Refused)?;
411 version_gate(&PrintVersion::current(), &new).map_err(ReloadError::Refused)?;
412 if state.reloading.swap(true, Ordering::SeqCst) {
413 return Err(ReloadError::Refused("a reload is already in progress".into()));
414 }
415 let generation = state.generation + 1;
416 tracing::info!(exe = %exe.display(), new = %new.binary_version, generation, "daemon: reload accepted");
417
418 let drain = if req.now {
420 Duration::ZERO
421 } else {
422 req.drain_secs
423 .map(Duration::from_secs)
424 .or_else(|| std::env::var(DRAIN_SECS_ENV).ok()?.parse().ok().map(Duration::from_secs))
425 .unwrap_or(DEFAULT_DRAIN)
426 };
427 let t0 = std::time::Instant::now();
428 while t0.elapsed() < drain {
429 let mut all_idle = true;
430 for h in state.live_sessions() {
431 if !super::session_is_idle(&h).await {
432 all_idle = false;
433 break;
434 }
435 }
436 if all_idle {
437 break;
438 }
439 tokio::time::sleep(Duration::from_millis(200)).await;
440 }
441 let handles = state.live_sessions();
442 let results = futures::future::join_all(handles.iter().cloned().map(checkpoint_one)).await;
443 let mut sessions = Vec::with_capacity(handles.len());
444 for (h, rec) in handles.iter().zip(results) {
445 if rec.is_none() {
446 tracing::warn!(session = %h.id, "reload: checkpoint did not confirm; recording from the handle");
447 }
448 sessions.push(record(h, rec));
449 }
450
451 let rs = ReloadState {
453 generation,
454 written_at: chrono::Utc::now(),
455 sessions,
456 expected_clients: state.connections.load(Ordering::SeqCst),
457 next_subagent_id: crate::tools::peek_next_subagent_id(),
459 };
460 let rs_path = reload_state_path(paths);
461 let written = serde_json::to_vec_pretty(&rs)
466 .map_err(|e| e.to_string())
467 .and_then(|body| registry::write_private_atomic(&rs_path, &body).map_err(|e| e.to_string()));
468 if let Err(e) = written {
469 abort_checkpoints(state).await;
472 state.reloading.store(false, Ordering::SeqCst);
473 return Err(ReloadError::ExecFailed(e));
474 }
475
476 state.reload_generation.store(generation, Ordering::SeqCst);
479 state.fire_announce();
480 let t1 = std::time::Instant::now();
481 while state.connections.load(Ordering::SeqCst) > 1 && t1.elapsed() < Duration::from_secs(crate::session::budgets::RELOAD_ANNOUNCE_WAIT_SECS) {
482 tokio::time::sleep(Duration::from_millis(20)).await;
483 }
484 Ok(Prepared { exe, rs_path, generation })
485}
486
487pub async fn exec(state: &Arc<DaemonState>, p: Prepared) -> ReloadError {
496 let previous = state.daemon_info();
501 let prev_generation = previous.generation;
502 let info = registry::DaemonInfo {
503 generation: p.generation,
504 started_at: chrono::Utc::now(),
505 exe: Some(p.exe.clone()),
506 ..previous.clone()
507 };
508 if let Err(e) = state.meta.publish(info) {
509 tracing::warn!(path = %state.paths.json.display(), error = %e, "daemon: daemon.json write failed at reload");
510 }
511 let _ = tokio::time::timeout(
512 Duration::from_secs(crate::session::budgets::RELOAD_EXT_SHUTDOWN_SECS),
513 async { state.host.ext_manager().write().await.shutdown_all().await },
514 )
515 .await;
516
517 let err = exec_self(&p.exe, &p.rs_path, state);
518 tracing::error!(error = %err, "daemon: reload exec failed; continuing on the old image");
519 if let Err(e) = state.meta.publish(previous) {
522 tracing::warn!(
523 path = %state.paths.json.display(),
524 error = %e,
525 "daemon: daemon.json write failed after the failed reload exec"
526 );
527 }
528 let _ = std::fs::remove_file(&p.rs_path);
529 state.reload_generation.store(prev_generation, Ordering::SeqCst);
530 state.reset_announce();
531 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
534 let _loader = crate::extensions::loader::spawn_discover_and_load(
535 Arc::clone(state.host.ext_manager()),
536 tx,
537 None,
538 );
539 let wait = async {
540 while let Some(ev) = rx.recv().await {
541 if let crate::extensions::loader::ExtensionLoaderEvent::Finished { loaded, failed } = ev {
542 tracing::info!(loaded = loaded.len(), failed = failed.len(), "daemon: extensions re-discovered after failed reload");
543 break;
544 }
545 }
546 };
547 if tokio::time::timeout(
548 Duration::from_secs(crate::session::budgets::RELOAD_EXT_REDISCOVERY_SECS),
549 wait,
550 )
551 .await
552 .is_err()
553 {
554 tracing::warn!("daemon: extension re-discovery still running; resuming the sessions");
555 }
556 abort_checkpoints(state).await;
560 state.reloading.store(false, Ordering::SeqCst);
561 ReloadError::ExecFailed(err)
562}
563
564#[cfg(unix)]
565fn exec_self(exe: &Path, rs_path: &Path, state: &DaemonState) -> String {
566 use std::os::unix::process::CommandExt;
567 let Some(lock_fd) = state.lock_fd.lock().unwrap_or_else(|e| e.into_inner()).as_ref().map(|l| l.raw_fd()) else {
568 return "no lock fd to inherit".into();
569 };
570 unsafe {
575 let flags = libc::fcntl(lock_fd, libc::F_GETFD);
576 if flags >= 0 {
577 libc::fcntl(lock_fd, libc::F_SETFD, flags & !libc::FD_CLOEXEC);
578 }
579 }
580 let mut args: Vec<std::ffi::OsString> = std::env::args_os().skip(1).collect();
581 if args.is_empty() || args.iter().all(|a| a != "daemon") {
582 args = vec!["daemon".into(), "--foreground".into()];
583 }
584 let mut cmd = std::process::Command::new(exe);
585 cmd.args(&args)
586 .env("SYNAPS_DAEMON", "1")
587 .env(LOCK_FD_ENV, lock_fd.to_string())
588 .env(RELOAD_STATE_ENV, rs_path)
589 .env(SUBAGENT_ID_FLOOR_ENV, crate::tools::peek_next_subagent_id().to_string());
590 let e = cmd.exec();
591 unsafe {
594 let flags = libc::fcntl(lock_fd, libc::F_GETFD);
595 if flags >= 0 {
596 libc::fcntl(lock_fd, libc::F_SETFD, flags | libc::FD_CLOEXEC);
597 }
598 }
599 e.to_string()
600}
601
602#[cfg(not(unix))]
603fn exec_self(_exe: &Path, _rs_path: &Path, _state: &DaemonState) -> String {
604 "reload is unix-only".into()
605}
606
607pub fn adopt_from_env() -> anyhow::Result<Option<(DaemonLock, ReloadState, PathBuf)>> {
611 let Some(rs_path) = std::env::var_os(RELOAD_STATE_ENV) else {
612 return Ok(None);
613 };
614 let rs_path = PathBuf::from(rs_path);
615 std::env::remove_var(RELOAD_STATE_ENV);
616 let fd: i32 = std::env::var(LOCK_FD_ENV)
617 .ok()
618 .and_then(|s| s.parse().ok())
619 .ok_or_else(|| anyhow::anyhow!("{RELOAD_STATE_ENV} set without {LOCK_FD_ENV}: refusing to start (the flock is the liveness oracle)"))?;
620 std::env::remove_var(LOCK_FD_ENV);
621 let id_floor = std::env::var(SUBAGENT_ID_FLOOR_ENV).ok().and_then(|s| s.parse::<u64>().ok());
622 std::env::remove_var(SUBAGENT_ID_FLOOR_ENV);
623 let lock = DaemonLock::adopt(fd)?;
624 let body = std::fs::read_to_string(&rs_path)?;
625 let mut rs: ReloadState = serde_json::from_str(&body)?;
626 if let Some(floor) = id_floor {
627 rs.next_subagent_id = rs.next_subagent_id.max(floor);
628 }
629 Ok(Some((lock, rs, rs_path)))
630}
631
632pub(crate) fn is_lock_held_error(e: &str) -> bool {
644 e.contains("is live in another process")
645}
646
647pub(crate) fn parse_holder_from_error(e: &str) -> String {
649 if let Some(start) = e.find("(pid ") {
651 let after = &e[start + 1..]; if let Some(end) = after.find(')') {
653 return after[..end].to_string();
654 }
655 }
656 "unknown holder".to_string()
657}
658
659async fn abort_checkpoints(state: &Arc<DaemonState>) {
665 let budget = Duration::from_secs(crate::session::budgets::RELOAD_ABORT_SEND_SECS);
666 let sends = state
667 .live_sessions()
668 .into_iter()
669 .map(|h| async move { send_reload_aborted(&h, budget).await });
670 futures::future::join_all(sends).await;
671}
672
673pub(crate) async fn send_reload_aborted(h: &SessionHandle, budget: Duration) -> bool {
677 use crate::session::TransportError;
678 let deadline = tokio::time::Instant::now() + budget;
679 loop {
680 match h.send(SessionCommand::ReloadAborted).await {
681 Ok(()) => return true,
682 Err(TransportError::Backpressure) if tokio::time::Instant::now() < deadline => {
683 tokio::time::sleep(Duration::from_millis(20)).await;
684 }
685 Err(e) => {
686 tracing::warn!(
687 session = %h.id,
688 error = %e,
689 "reload: could not tell the session the exec failed; its reload fence lifts at the backstop"
690 );
691 return false;
692 }
693 }
694 }
695}
696
697fn restore_config(saved: &SessionConfig, lifecycle: SessionLifecycle) -> SessionConfig {
704 SessionConfig {
705 await_extensions: false,
706 restore_parked: matches!(lifecycle, SessionLifecycle::Parked | SessionLifecycle::Parking),
707 ..saved.clone()
708 }
709}
710
711pub async fn rehydrate(state: &Arc<DaemonState>, rs: &ReloadState) {
712 crate::tools::seed_next_subagent_id(rs.subagent_id_floor());
715 for s in &rs.sessions {
716 let config = restore_config(&s.config, s.lifecycle);
717 let created = match state.create(config.clone()).await {
718 Ok(h) => Ok(h),
719 Err(e) if is_lock_held_error(&e) => {
722 let holder = parse_holder_from_error(&e);
723 tracing::warn!(
724 session = %s.id, holder = %holder,
725 "daemon: journal locked by another process; registering Parked placeholder"
726 );
727 let sid = crate::session::SessionId::from(s.id.as_str());
728 let (handle, _task) = crate::session::handle::locked_placeholder::spawn(
729 sid,
730 s.config.clone(),
731 holder,
732 );
733 state.insert(handle);
734 continue;
735 }
736 Err(e) if s.config.continue_session.is_some() => {
737 tracing::warn!(session = %s.id, error = %e, "daemon: journal not continuable; recreating fresh");
738 state
739 .create(SessionConfig {
740 continue_session: None,
741 ..config.clone()
742 })
743 .await
744 }
745 Err(e) => Err(e),
746 };
747 match created {
748 Ok(h) => {
749 let new_id = h.id.as_str().to_string();
750 let continued = h.meta().continued;
756 if continued && !s.tool_activations.is_empty() {
757 let _ = h
758 .send(SessionCommand::RestoreToolActivations {
759 activations: s.tool_activations.clone(),
760 })
761 .await;
762 }
763 if new_id != s.id {
764 state
765 .reload_aliases
766 .lock()
767 .unwrap_or_else(|e| e.into_inner())
768 .insert(s.id.clone(), new_id.clone());
769 }
770 for (i, setting) in s.settings_replay.iter().enumerate() {
772 let _ = h
773 .send(SessionCommand::Set {
774 id: u64::MAX - i as u64,
775 setting: setting.clone(),
776 })
777 .await;
778 }
779 if s.keep_warm {
780 let _ = h.send(SessionCommand::KeepWarm { on: true }).await;
781 }
782 if let Some(mode) = &s.cache_keepwarm {
783 let _ = h
784 .send(SessionCommand::EngineCommand {
785 id: u64::MAX,
786 name: "keepwarm".into(),
787 arg: mode.clone(),
788 })
789 .await;
790 }
791 if !s.workers.is_empty() {
794 let _ = h
795 .send(SessionCommand::AdoptWorkers { workers: s.workers.clone() })
796 .await;
797 }
798 if matches!(s.lifecycle, SessionLifecycle::Parked | SessionLifecycle::Parking) {
799 let _ = h.send(SessionCommand::Park).await;
800 } else {
801 let _ = h.send(SessionCommand::ArmPark).await;
805 }
806 tracing::info!(old = %s.id, new = %new_id, lifecycle = ?s.lifecycle, "daemon: session rehydrated after reload");
807 }
808 Err(e) => tracing::warn!(session = %s.id, error = %e, "daemon: session could not be rehydrated"),
809 }
810 }
811}
812
813#[cfg(test)]
814mod tests {
815 use super::*;
816
817 #[test]
820 fn reload_session_carries_tool_activations() {
821 use crate::tools::activation::{CarriedActivation, CarriedProvenance};
822 let carried = vec![CarriedActivation {
823 tool_id: "builtin:subagent_start".into(),
824 schema_digest: "cd".repeat(32),
825 provenance: CarriedProvenance::BuiltinRuntime,
826 }];
827 let s = ReloadSession {
828 id: "s".into(),
829 journal_id: "j".into(),
830 config: SessionConfig::default(),
831 keep_warm: false,
832 lifecycle: SessionLifecycle::Live,
833 settings_replay: Vec::new(),
834 cache_keepwarm: None,
835 input_owner_kind: None,
836 workers: Vec::new(),
837 tool_activations: carried.clone(),
838 };
839 let v = serde_json::to_value(&s).unwrap();
840 let back: ReloadSession = serde_json::from_value(v.clone()).unwrap();
841 assert_eq!(back.tool_activations, carried);
842 assert!(v["config"].get("restore_tool_activations").is_none());
844
845 let mut older = v;
846 older.as_object_mut().unwrap().remove("tool_activations");
847 let back: ReloadSession = serde_json::from_value(older).unwrap();
848 assert!(back.tool_activations.is_empty());
849 }
850
851 #[test]
854 fn reload_state_from_an_older_image_parses() {
855 let rs: ReloadState = serde_json::from_value(serde_json::json!({
856 "generation": 9,
857 "written_at": "2026-10-14T18:17:03Z",
858 "sessions": [],
859 "expected_clients": 1
860 }))
861 .unwrap();
862 assert_eq!(rs.next_subagent_id, 0);
863 assert_eq!(rs.subagent_id_floor(), 0);
864 }
865
866 #[test]
869 fn subagent_id_floor_covers_the_counter_and_every_carried_id() {
870 let worker = |id: &str| crate::runtime::subagent::CarriedWorker {
871 id: id.into(),
872 subagent_id: 0,
873 agent: String::new(),
874 status: "cancelled".into(),
875 archive_path: None,
876 stopped_by_reload: true,
877 wake_lost: false,
878 wake: true,
879 elapsed_secs: 0,
880 preview: String::new(),
881 };
882 let session = |workers| ReloadSession {
883 id: "s".into(),
884 journal_id: "j".into(),
885 config: SessionConfig::default(),
886 keep_warm: false,
887 lifecycle: SessionLifecycle::Live,
888 settings_replay: Vec::new(),
889 cache_keepwarm: None,
890 input_owner_kind: None,
891 workers,
892 tool_activations: Vec::new(),
893 };
894 let mut rs = ReloadState {
895 generation: 10,
896 written_at: chrono::Utc::now(),
897 sessions: vec![session(vec![worker("sa_288"), worker("sa_286")]), session(vec![])],
898 expected_clients: 0,
899 next_subagent_id: 0,
900 };
901 assert_eq!(rs.subagent_id_floor(), 289);
902 rs.next_subagent_id = 300;
903 assert_eq!(rs.subagent_id_floor(), 300);
904 let back: ReloadState = serde_json::from_slice(&serde_json::to_vec(&rs).unwrap()).unwrap();
906 assert_eq!(back.subagent_id_floor(), 300);
907 assert_eq!(back.sessions[0].workers.len(), 2);
908 }
909
910 fn pv(b: &str, p: u32) -> PrintVersion {
911 PrintVersion { binary_version: b.into(), protocol_version: p }
912 }
913
914 #[test]
915 fn gate_is_directional_newer_or_equal() {
916 let cur = pv("0.9.0", 2);
917 assert!(version_gate(&cur, &pv("0.9.0", 2)).is_ok(), "equal allowed");
918 assert!(version_gate(&cur, &pv("0.9.1", 2)).is_ok());
919 assert!(version_gate(&cur, &pv("1.0.0-rc1", 3)).is_ok());
920 assert!(version_gate(&cur, &pv("0.8.9", 2)).is_err(), "older refused");
921 assert!(version_gate(&cur, &pv("0.9.0", 1)).is_err(), "older protocol refused");
922 assert!(version_gate(&cur, &pv("garbage", 2)).is_err());
923 assert!(
926 version_gate(&pv("0.9.1", 3), &pv("0.10.0", 3)).is_ok(),
927 "0.10.0 is newer than 0.9.1"
928 );
929 assert!(
930 version_gate(&pv("0.10.0", 3), &pv("0.9.1", 3)).is_err(),
931 "0.9.1 is older than 0.10.0"
932 );
933 }
934
935 #[cfg(unix)]
936 #[test]
937 fn validate_exe_refuses_missing_dirs_non_exec_and_writable() {
938 use std::os::unix::fs::PermissionsExt;
939 let d = tempfile::tempdir().unwrap();
940 assert!(validate_exe(&d.path().join("nope")).unwrap_err().contains("nope"));
941 assert!(validate_exe(d.path()).unwrap_err().contains("not a regular file"));
942 let f = d.path().join("bin");
943 std::fs::write(&f, "#!/bin/sh\nexit 0\n").unwrap();
944 std::fs::set_permissions(&f, std::fs::Permissions::from_mode(0o644)).unwrap();
945 assert!(validate_exe(&f).unwrap_err().contains("not executable"));
946 std::fs::set_permissions(&f, std::fs::Permissions::from_mode(0o777)).unwrap();
947 assert!(validate_exe(&f).unwrap_err().contains("writable"));
948 std::fs::set_permissions(&f, std::fs::Permissions::from_mode(0o755)).unwrap();
949 assert_eq!(validate_exe(&f).unwrap(), f.canonicalize().unwrap());
950 let l = d.path().join("link");
952 std::os::unix::fs::symlink(&f, &l).unwrap();
953 assert_eq!(validate_exe(&l).unwrap(), f.canonicalize().unwrap());
954 }
955
956 #[test]
960 fn a_restore_does_not_wait_on_the_session_start_hook() {
961 let saved = SessionConfig {
962 model_override: Some("claude-sonnet-4-5".into()),
963 keep_warm: true,
964 await_extensions: true,
965 ..Default::default()
966 };
967 let restored = restore_config(&saved, SessionLifecycle::Live);
968 assert!(!restored.await_extensions);
969 assert!(!restored.restore_parked);
970 let as_saved = SessionConfig {
971 await_extensions: true,
972 ..restored
973 };
974 assert_eq!(
975 serde_json::to_value(&as_saved).unwrap(),
976 serde_json::to_value(&saved).unwrap()
977 );
978 for parked in [SessionLifecycle::Parked, SessionLifecycle::Parking] {
979 let restored = restore_config(&saved, parked);
980 assert!(restored.restore_parked, "{parked:?}");
981 assert!(!restored.await_extensions);
982 let wire = serde_json::to_value(&restored).unwrap();
983 assert!(wire.get("restore_parked").is_none(), "never on the wire");
984 }
985 }
986
987 #[test]
988 fn reload_state_path_follows_profile() {
989 let d = tempfile::tempdir().unwrap();
990 let p = registry::daemon_paths_in(d.path(), Some("work"));
991 assert!(reload_state_path(&p).ends_with("daemon-work.reload.json"), "{:?}", reload_state_path(&p));
992 let p = registry::daemon_paths_in(d.path(), None);
993 assert!(reload_state_path(&p).ends_with("daemon.reload.json"));
994 }
995
996 #[test]
1000 fn keepwarm_override_survives_an_unconfirmed_checkpoint() {
1001 use crate::core::config::CacheKeepWarm;
1002 use crate::session::handle::echo::{meta_for, view_for};
1003 let id = crate::session::SessionId("kw-reload".into());
1004 let (handle, ep) = SessionHandle::new(meta_for(&id), view_for());
1005 assert_eq!(record(&handle, None).cache_keepwarm, None, "no override yet");
1006 *ep.cache_keepwarm.lock().unwrap() = Some(CacheKeepWarm::Off);
1008 assert_eq!(record(&handle, None).cache_keepwarm.as_deref(), Some("off"));
1009 *ep.cache_keepwarm.lock().unwrap() = Some(CacheKeepWarm::Hours(6));
1010 assert_eq!(record(&handle, None).cache_keepwarm.as_deref(), Some("6h"));
1011 }
1012
1013 #[test]
1016 fn the_pin_survives_an_unconfirmed_checkpoint() {
1017 use crate::session::handle::echo::{meta_for, view_for};
1018 let id = crate::session::SessionId("pin-reload".into());
1019 let (handle, ep) = SessionHandle::new(meta_for(&id), view_for());
1020 assert!(!record(&handle, None).keep_warm);
1021 ep.keep_warm.store(true, std::sync::atomic::Ordering::Release);
1022 let r = record(&handle, None);
1023 assert!(r.keep_warm && r.config.keep_warm);
1024 }
1025
1026 #[test]
1031 fn an_unknown_memory_mode_reads_as_off() {
1032 use crate::session::SessionMemoryMode;
1033 let cfg = |v: serde_json::Value| {
1034 serde_json::from_value::<SessionConfig>(serde_json::json!({ "memory_mode": v }))
1035 };
1036 assert_eq!(cfg(serde_json::json!("off")).unwrap().memory_mode, Some(SessionMemoryMode::Off));
1037 assert_eq!(
1038 cfg(serde_json::json!("capture_and_recall")).unwrap().memory_mode,
1039 Some(SessionMemoryMode::CaptureAndRecall)
1040 );
1041 assert_eq!(
1042 cfg(serde_json::json!("recall_forever")).unwrap().memory_mode,
1043 Some(SessionMemoryMode::Off)
1044 );
1045 assert_eq!(cfg(serde_json::json!(7)).unwrap().memory_mode, Some(SessionMemoryMode::Off));
1046 assert_eq!(cfg(serde_json::Value::Null).unwrap().memory_mode, None);
1047 let missing = serde_json::from_value::<SessionConfig>(serde_json::json!({})).unwrap();
1048 assert_eq!(missing.memory_mode, None);
1049 let rs: ReloadState = serde_json::from_value(serde_json::json!({
1050 "generation": 2,
1051 "written_at": "2026-10-14T18:17:03Z",
1052 "sessions": [{
1053 "id": "s1", "journal_id": "s1", "keep_warm": false, "lifecycle": "live",
1054 "config": { "memory_mode": "recall_forever" }
1055 }],
1056 "expected_clients": 0
1057 }))
1058 .expect("the reload state still parses");
1059 assert_eq!(rs.sessions[0].config.memory_mode, Some(SessionMemoryMode::Off));
1060 }
1061
1062 #[test]
1065 fn a_restore_keeps_the_sessions_memory_choice() {
1066 use crate::session::SessionMemoryMode;
1067 for mode in [Some(SessionMemoryMode::Off), Some(SessionMemoryMode::CaptureOnly), None] {
1068 let saved = SessionConfig {
1069 memory_mode: mode,
1070 await_extensions: true,
1071 ..Default::default()
1072 };
1073 for lifecycle in [SessionLifecycle::Live, SessionLifecycle::Parked, SessionLifecycle::Parking] {
1074 let restored = restore_config(&saved, lifecycle);
1075 assert_eq!(restored.memory_mode, mode, "{lifecycle:?}");
1076 assert!(!restored.await_extensions, "{lifecycle:?}");
1077 }
1078 }
1079 }
1080
1081 #[test]
1084 fn memory_choice_survives_an_unconfirmed_checkpoint() {
1085 use crate::session::handle::echo::{meta_for, view_for};
1086 use crate::session::SessionMemoryMode;
1087 let id = crate::session::SessionId("mem-reload".into());
1088 let (handle, ep) = SessionHandle::new(meta_for(&id), view_for());
1089 assert_eq!(record(&handle, None).config.memory_mode, None);
1090 *ep.memory_mode.lock().unwrap() = Some(SessionMemoryMode::Off);
1091 assert_eq!(record(&handle, None).config.memory_mode, Some(SessionMemoryMode::Off));
1092 }
1093
1094 #[tokio::test]
1098 async fn a_locked_placeholder_keeps_its_memory_choice() {
1099 use crate::session::SessionMemoryMode;
1100 let sid = crate::session::SessionId("locked-mem".into());
1101 let config = SessionConfig {
1102 memory_mode: Some(SessionMemoryMode::Off),
1103 ..SessionConfig::default()
1104 };
1105 let (handle, task) =
1106 crate::session::handle::locked_placeholder::spawn(sid, config, "pid 1 (tui)".into());
1107 assert_eq!(record(&handle, None).config.memory_mode, Some(SessionMemoryMode::Off));
1108 task.abort();
1109 }
1110
1111 #[tokio::test]
1115 async fn reload_aborted_waits_for_room_in_a_full_queue() {
1116 use crate::session::handle::echo::{meta_for, view_for};
1117 let id = crate::session::SessionId("abort-full".into());
1118 let (handle, mut ep) = SessionHandle::new(meta_for(&id), view_for());
1119 while handle.send(SessionCommand::Save).await.is_ok() {}
1120 let drain = tokio::spawn(async move {
1121 tokio::time::sleep(Duration::from_millis(100)).await;
1122 let mut got_abort = false;
1123 while let Some(a) = ep.cmd_rx.recv().await {
1124 if matches!(a.cmd, SessionCommand::ReloadAborted) {
1125 got_abort = true;
1126 break;
1127 }
1128 }
1129 got_abort
1130 });
1131 assert!(send_reload_aborted(&handle, Duration::from_secs(5)).await);
1132 assert!(drain.await.unwrap(), "delivered");
1133 let id = crate::session::SessionId("abort-stuck".into());
1135 let (stuck, _ep) = SessionHandle::new(meta_for(&id), view_for());
1136 while stuck.send(SessionCommand::Save).await.is_ok() {}
1137 assert!(!send_reload_aborted(&stuck, Duration::from_millis(100)).await);
1138 }
1139}