syn-daemon: add periodic sync scheduler driver
Build Debug APK / build (push) Failing after 56s

syn-daemon never ran the background scheduler (rust/lib/src/scheduler): no
periodic timer, no startup trigger, and its /syn/node-admin/1 handler had
no live JobRunner wired in, so even an explicit remote TriggerSync had
nothing to act on. A headless node — the canonical Mirror-policy target
per docs/sync-management.md's remote-administration workflow — would sync
catalogues (peers push/pull metadata directly) but never proactively pull
content of its own accord.

Add rust/syn-daemon/src/scheduler_driver.rs: a periodic-timer loop spawned
from server::serve once NodeRuntime is up, mirroring gui-app's driver but
without a P2P-readiness wait (the daemon's NodeRuntime is already running
by the time this spawns). It also wires the daemon's JobRunner into
P2PNode::set_job_runner, so remote TriggerSync/ListJobs/CancelJob now have
a live runner to act on.

Update ADR 0012 with an addendum noting the daemon driver and the
still-open gap: no DaemonCommand to trigger a tick manually from the
CLI/TUI, only via the timer or a remote node-admin call.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0134Qxsmkvtr8hSGkhbT1nTg
This commit is contained in:
Greg Shuflin
2026-08-05 23:55:32 -07:00
co-authored by Claude Sonnet 5
parent b3ff99cd49
commit 22afa72242
4 changed files with 116 additions and 0 deletions
@@ -65,3 +65,25 @@ it's already running and skip (not fail) the tick otherwise.
not hand-rolled checks) is what makes it safe to run this unattended on a
phone at all — this was a hard prerequisite, not an afterthought, before
any Android scheduler driver could ship.
## Update (2026-08-05): `syn-daemon` driver added
`syn-daemon` (the headless-server modality — the canonical `Mirror`-policy
target per `docs/sync-management.md`'s remote-administration section) had no
driver at all until now: no periodic timer, no startup trigger, and its
`/syn/node-admin/1` handler was never wired to a `JobRunner`, so even an
explicit remote `TriggerSync` had nothing live to act on. A node whose
policy said `Mirror` would sync catalogues (peers push/pull metadata
directly) but never proactively fetch content — indistinguishable, from a
user setting `Mirror` on it, from the feature not working.
`rust/syn-daemon/src/scheduler_driver.rs` fixes this: a periodic-timer loop
spawned from `server::serve` after `NodeRuntime::start`, closest in shape to
desktop's driver (no network-state gating — a VPS has no metered-connection
or battery-charge notion to gate on, same reasoning ADR gave for desktop).
It also calls `P2PNode::set_job_runner`, so remote `TriggerSync`/`ListJobs`/
`CancelJob` now have a live `JobRunner` to act on rather than finding
`JobRunnerCell` empty. Manually triggering a daemon's tick from the CLI/TUI
(rather than only remotely, via node-admin, or waiting for the timer) is not
yet wired — `DaemonCommand` has no scheduler-facing variant — and remains
open work.
+1
View File
@@ -1,6 +1,7 @@
pub mod client;
pub mod commands;
pub mod protocol;
pub mod scheduler_driver;
pub mod server;
pub const DAEMON_SOCK_FILE: &str = ".daemon.sock";
+85
View File
@@ -0,0 +1,85 @@
//! Drives the sync scheduler (`synchronicity::scheduler`) on its own tokio
//! task — the headless-daemon counterpart of `rust/gui-app/src/scheduler_driver.rs`
//! (desktop's periodic-timer loop) and Android's `SchedulerWorker`
//! (`docs/sync-management.md` phase 3). Without this, a `syn-daemon` node
//! (e.g. a VPS meant to `Mirror` a document type) never proactively pulls
//! anything: it only ever answers requests other nodes make of it, and
//! `SetSyncPolicy`/`TriggerSync` issued via `/syn/node-admin/1` had nothing
//! local to act on `TriggerSync` with.
//!
//! Simpler than desktop's driver: `syn-daemon`'s [`NodeRuntime`] is already
//! running by the time [`spawn`] is called (`server::serve` starts it
//! first), so there's no "wait for the P2P endpoint to come up" step, and
//! there's exactly one node to drive rather than one per GUI window.
use std::sync::Arc;
use std::time::Duration;
use log::{info, warn};
use synchronicity::jobs::JobOrigin;
use synchronicity::jobs::runner::JobRunner;
use synchronicity::node::NodeRuntime;
use synchronicity::scheduler;
/// Floor under a user-configured interval, so a fat-fingered tiny value
/// can't turn this into a busy loop (matches `gui-app`'s driver).
const MIN_INTERVAL: Duration = Duration::from_secs(60);
/// Spawn the scheduler's periodic-timer driver on the daemon's tokio
/// runtime. Fire-and-forget: runs for the daemon's lifetime, alongside the
/// axum server `serve` runs on the same runtime.
///
/// Also wires `job_runner` into the node-admin protocol handler
/// ([`P2PNode::set_job_runner`](synchronicity::p2p::P2PNode::set_job_runner))
/// so a remote `TriggerSync`/`ListJobs`/`CancelJob` call shares this same
/// runner — and its active-job dedup — rather than finding an unwired
/// `JobRunnerCell` and being unable to act.
pub fn spawn(runtime: &NodeRuntime, job_runner: Arc<JobRunner>) {
runtime.p2p.set_job_runner(Arc::clone(&job_runner));
let store = Arc::clone(&runtime.store);
let endpoint = runtime.p2p.endpoint();
tokio::spawn(async move {
loop {
let settings = store
.settings_store
.batch(|tx| store.settings_store.scheduler_settings(tx))
.unwrap_or_default();
if settings.enabled {
match store.settings_store.local_node_pubkey() {
Ok(Some(local_node)) => {
let summary = scheduler::run_all_passes(
&endpoint,
Arc::clone(&store),
&job_runner,
local_node,
JobOrigin::Scheduler,
)
.await;
match summary {
Some(summary) => info!(
"scheduler: tick complete — {} peer(s) synced, {} thumbnail(s) + \
{} artifact(s) fetched, {} blob(s) for {} document(s)",
summary.catalogue.peers_synced,
summary.catalogue_fetch.thumbnails_received,
summary.catalogue_fetch.artifacts_received,
summary.content.blobs_fetched,
summary.content.wanted_documents,
),
// A remote `TriggerSync` or another tick is already
// mid-run — this tick has nothing to add.
None => info!(
"scheduler: tick skipped, a background sync is already running"
),
}
}
Ok(None) => warn!("scheduler: node not initialized, skipping this tick"),
Err(e) => warn!("scheduler: failed to load local node pubkey: {e}"),
}
}
let interval = Duration::from_secs(settings.interval_secs as u64).max(MIN_INTERVAL);
tokio::time::sleep(interval).await;
}
});
}
+8
View File
@@ -7,6 +7,7 @@ use axum::extract::State;
use axum::routing::{get, post};
use axum::{Json, Router};
use log::info;
use synchronicity::jobs::runner::JobRunner;
use synchronicity::log::LogConfig;
use synchronicity::node::NodeRuntime;
use synchronicity::stores::Store;
@@ -18,6 +19,7 @@ use serde_json::json;
use crate::commands;
use crate::protocol::{CommandResponse, DaemonCommand};
use crate::scheduler_driver;
struct AppState {
runtime: NodeRuntime,
@@ -239,6 +241,12 @@ async fn serve(store_path: &Path, socket_path: &Path) -> Result<()> {
}
};
// Without this, the daemon only ever answers requests other nodes make
// of it — a `Mirror`-policy VPS would never proactively pull the content
// its own policy wants (see `scheduler_driver`'s doc comment).
let job_runner = Arc::new(JobRunner::new(runtime.store.job_store.clone()));
scheduler_driver::spawn(&runtime, job_runner);
let (shutdown_tx, shutdown_rx) = watch::channel(false);
let state = Arc::new(AppState { runtime, shutdown_tx });