#![cfg_attr(
not(test),
deny(clippy::panic, clippy::unwrap_used, clippy::expect_used)
)]
#![deny(missing_docs)]
#![deny(clippy::undocumented_unsafe_blocks)]
mod control_plane;
mod daemonize;
mod fs_watcher;
mod lifecycle;
mod protection;
mod server;
mod uds_security;
mod webhook;
use std::path::PathBuf;
use std::sync::Arc;
use anyhow::{Context, Result, bail};
use clap::{Parser, Subcommand};
use futures::FutureExt;
use tonic::transport::Server;
use tracing::{error, info};
use agent_orchestrator::events::insert_event;
use agent_orchestrator::service::system::{clear_worker_stop_signal, worker_stop_signal_path};
use agent_orchestrator::state::{InnerState, task_semaphore};
use orchestrator_proto::OrchestratorServiceServer;
use orchestrator_scheduler::scheduler::safety::RestartRequestedError;
use orchestrator_scheduler::scheduler::{
RunningTask, load_task_summary, register_running_task, run_task_loop, shutdown_running_tasks,
unregister_running_task,
};
use orchestrator_scheduler::service::task::{SchedulerTaskEnqueuer, claim_next_pending_task};
#[derive(Debug, Parser)]
#[command(name = "orchestratord", version, about = "Agent Orchestrator daemon")]
struct Args {
#[arg(short = 'f', long = "foreground")]
foreground: bool,
#[arg(long = "bind")]
bind: Option<String>,
#[cfg(feature = "dev-insecure")]
#[arg(long = "insecure-bind")]
insecure_bind: Option<String>,
#[arg(long = "workers", default_value_t = 1)]
workers: usize,
#[arg(long = "control-plane-dir")]
control_plane_dir: Option<PathBuf>,
#[arg(long = "event-retention-days", default_value_t = 30)]
event_retention_days: u32,
#[arg(long = "event-cleanup-interval-secs", default_value_t = 3600)]
event_cleanup_interval_secs: u64,
#[arg(long = "event-archive-enabled")]
event_archive_enabled: bool,
#[arg(long = "event-archive-dir")]
event_archive_dir: Option<PathBuf>,
#[arg(long = "log-retention-days", default_value_t = 30)]
log_retention_days: u32,
#[arg(long = "task-retention-days", default_value_t = 0)]
task_retention_days: u32,
#[arg(
long = "webhook-bind",
default_value = "127.0.0.1:19090",
env = "ORCHESTRATOR_WEBHOOK_BIND"
)]
webhook_bind: String,
#[arg(long = "webhook-secret", env = "ORCHESTRATOR_WEBHOOK_SECRET")]
webhook_secret: Option<String>,
#[arg(
long = "webhook-allow-unsigned",
env = "ORCHESTRATOR_WEBHOOK_ALLOW_UNSIGNED",
default_value_t = false
)]
webhook_allow_unsigned: bool,
#[arg(long = "stall-timeout-mins", default_value_t = 30)]
stall_timeout_mins: u64,
#[command(subcommand)]
command: Option<Commands>,
}
#[derive(Debug, Subcommand)]
enum Commands {
#[command(subcommand)]
ControlPlane(ControlPlaneCommands),
WebhookSecret {
#[arg(long = "control-plane-dir")]
control_plane_dir: Option<PathBuf>,
},
}
#[derive(Debug, Subcommand)]
enum ControlPlaneCommands {
IssueClient {
#[arg(long = "bind")]
bind: String,
#[arg(long = "subject")]
subject: String,
#[arg(long = "role", default_value = "operator")]
role: control_plane::Role,
#[arg(long = "home")]
home: Option<PathBuf>,
#[arg(long = "control-plane-dir")]
control_plane_dir: Option<PathBuf>,
},
}
fn main() -> Result<()> {
let args = Args::parse();
let use_ansi = if args.foreground || args.command.is_some() {
true
} else {
let data_dir = agent_orchestrator::config_load::data_dir();
let log_path = data_dir.join("daemon.log");
daemonize::daemonize(&log_path)?;
false
};
let filter = if let Ok(level_str) = std::env::var("ORCHESTRATOR_LOG") {
let level = agent_orchestrator::config::LogLevel::parse(&level_str).unwrap_or_default();
tracing_subscriber::EnvFilter::new(level.as_tracing_level().to_string())
} else {
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info"))
};
let format = std::env::var("ORCHESTRATOR_LOG_FORMAT")
.ok()
.and_then(|f| agent_orchestrator::config::LoggingFormat::parse(&f))
.unwrap_or_default();
match format {
agent_orchestrator::config::LoggingFormat::Json => {
let subscriber = tracing_subscriber::fmt()
.json()
.with_target(false)
.with_ansi(false)
.with_env_filter(filter)
.finish();
tracing::subscriber::set_global_default(subscriber)
.context("failed to set tracing subscriber")?;
}
agent_orchestrator::config::LoggingFormat::Pretty => {
let subscriber = tracing_subscriber::fmt()
.with_target(false)
.with_ansi(use_ansi)
.with_env_filter(filter)
.finish();
tracing::subscriber::set_global_default(subscriber)
.context("failed to set tracing subscriber")?;
}
}
{
let data_dir = agent_orchestrator::config_load::data_dir();
let crash_log = data_dir.join("daemon_crash.log");
let default_hook = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
if let Ok(mut f) = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&crash_log)
{
use std::io::Write;
let ts = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let _ = writeln!(f, "[epoch={ts}] {info}");
}
default_hook(info);
}));
}
let rt = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.context("failed to build tokio runtime")?;
if let Some(command) = args.command {
return handle_subcommand(command);
}
rt.block_on(async move {
let state = agent_orchestrator::service::bootstrap::init_state_async_with_enqueuer(
false,
std::sync::Arc::new(SchedulerTaskEnqueuer),
)
.await
.context("failed to initialize orchestrator state")?;
let inner = state.inner.clone();
inner.daemon_runtime.set_configured_workers(args.workers);
let incarnation = agent_orchestrator::persistence::repository::daemon_meta::increment_incarnation(
&inner.async_database,
)
.await
.unwrap_or(0);
inner.daemon_runtime.set_incarnation(incarnation);
let socket_path = lifecycle::socket_path(&inner.data_dir);
let pid_path = lifecycle::pid_path(&inner.data_dir);
let stale_pid_detected = lifecycle::detect_stale_pid(&pid_path);
if let Some(existing_pid) = lifecycle::detect_running_daemon(&pid_path) {
anyhow::bail!(
"another orchestratord is already running (PID {existing_pid}); \
not starting a second instance"
);
}
lifecycle::write_pid_file(&pid_path)?;
info!(
socket = %socket_path.display(),
pid_file = %pid_path.display(),
version = env!("CARGO_PKG_VERSION"),
git_hash = env!("BUILD_GIT_HASH"),
incarnation,
"orchestratord starting"
);
emit_daemon_event(
&inner,
"daemon_incarnation_started",
serde_json::json!({
"incarnation": incarnation,
"version": env!("CARGO_PKG_VERSION"),
"git_hash": env!("BUILD_GIT_HASH"),
}),
)
.await;
if stale_pid_detected {
info!("stale PID file detected — previous daemon likely crashed");
emit_daemon_event(
&inner,
"daemon_crash_recovered",
serde_json::json!({ "source": "stale_pid_detection" }),
)
.await;
}
match inner.task_repo.recover_orphaned_running_items().await {
Ok(recovered) => {
for (task_id, item_ids) in &recovered {
info!(
task_id = %task_id,
items = item_ids.len(),
"recovered orphaned running items"
);
emit_daemon_event(
&inner,
"orphaned_items_recovered",
serde_json::json!({
"task_id": task_id,
"recovered_item_ids": item_ids,
"count": item_ids.len(),
}),
)
.await;
}
if !recovered.is_empty() {
let total: usize = recovered.iter().map(|(_, ids)| ids.len()).sum();
info!(
tasks = recovered.len(),
items = total,
"startup orphan recovery complete"
);
inner.worker_notify.notify_waiters();
}
}
Err(e) => {
error!(error = %e, "failed to recover orphaned running items at startup");
}
}
let _ = clear_worker_stop_signal(&inner);
let (shutdown_tx, shutdown_rx) = tokio::sync::watch::channel(false);
let (restart_tx, restart_rx) =
tokio::sync::watch::channel::<Option<std::path::PathBuf>>(None);
let supervisor_handle = {
let sup_state = inner.clone();
let sup_shutdown = shutdown_rx.clone();
let worker_count = args.workers;
tokio::spawn(worker_supervisor(
sup_state,
worker_count,
sup_shutdown,
restart_tx,
))
};
info!(workers = args.workers, "worker supervisor started");
{
let (engine, handle) =
agent_orchestrator::trigger_engine::TriggerEngine::new(inner.clone());
if let Ok(mut guard) = inner.trigger_engine_handle.lock() {
*guard = Some(handle);
}
let trig_shutdown = shutdown_rx.clone();
tokio::spawn(async move {
engine.run(trig_shutdown).await;
});
}
{
let (fs_handle, fs_reload_rx) = fs_watcher::new_handle();
if let Ok(mut guard) = inner.fs_watcher_reload_tx.lock() {
*guard = Some(fs_handle.reload_tx.clone());
}
let fs_state = inner.clone();
let fs_shutdown = shutdown_rx.clone();
tokio::spawn(async move {
fs_watcher::run_fs_watcher(fs_state, fs_reload_rx, fs_shutdown).await;
});
}
{
let drain_state = inner.clone();
let mut drain_shutdown = shutdown_rx.clone();
tokio::spawn(async move {
let mut interval = tokio::time::interval(std::time::Duration::from_secs(10));
loop {
tokio::select! {
_ = interval.tick() => {
agent_orchestrator::agent_lifecycle::drain_timeout_sweep(&drain_state).await;
}
_ = drain_shutdown.changed() => {
break;
}
}
}
});
}
if args.event_retention_days > 0 {
let cleanup_state = inner.clone();
let mut cleanup_shutdown = shutdown_rx.clone();
let retention_days = args.event_retention_days;
let archive_enabled = args.event_archive_enabled;
let archive_dir = args
.event_archive_dir
.clone()
.unwrap_or_else(|| inner.data_dir.join("archive/events"));
let interval_secs = args.event_cleanup_interval_secs;
info!(
retention_days,
interval_secs, archive_enabled, "event cleanup sweep started"
);
tokio::spawn(async move {
let mut interval = tokio::time::interval(std::time::Duration::from_secs(interval_secs));
loop {
tokio::select! {
_ = interval.tick() => {
let result = if archive_enabled {
agent_orchestrator::event_cleanup::archive_events(
&cleanup_state.async_database,
&archive_dir,
retention_days,
1000,
)
.await
} else {
agent_orchestrator::event_cleanup::cleanup_old_events(
&cleanup_state.async_database,
retention_days,
1000,
)
.await
};
if let Err(e) = result {
tracing::warn!(error = %e, "event cleanup sweep failed");
}
}
_ = cleanup_shutdown.changed() => {
break;
}
}
}
});
}
if args.log_retention_days > 0 || args.task_retention_days > 0 {
let lifecycle_state = inner.clone();
let mut lifecycle_shutdown = shutdown_rx.clone();
let log_days = args.log_retention_days;
let task_days = args.task_retention_days;
let interval_secs = args.event_cleanup_interval_secs;
info!(
log_retention_days = log_days,
task_retention_days = task_days,
interval_secs,
"data lifecycle sweep started"
);
tokio::spawn(async move {
let mut interval =
tokio::time::interval(std::time::Duration::from_secs(interval_secs));
loop {
tokio::select! {
_ = interval.tick() => {
if log_days > 0 {
if let Err(e) = agent_orchestrator::log_cleanup::cleanup_old_logs(
&lifecycle_state.async_database,
&lifecycle_state.logs_dir,
log_days,
).await {
tracing::warn!(error = %e, "log cleanup sweep failed");
}
}
if task_days > 0 {
if let Err(e) = agent_orchestrator::task_cleanup::cleanup_old_tasks(
&lifecycle_state.async_database,
&lifecycle_state.logs_dir,
task_days,
50,
).await {
tracing::warn!(error = %e, "task cleanup sweep failed");
}
}
}
_ = lifecycle_shutdown.changed() => {
break;
}
}
}
});
}
if args.stall_timeout_mins > 0 {
let stall_state = inner.clone();
let mut stall_shutdown = shutdown_rx.clone();
let stall_threshold_secs = args.stall_timeout_mins * 60;
info!(
stall_timeout_mins = args.stall_timeout_mins,
"stall detection sweep started"
);
tokio::spawn(async move {
let mut interval =
tokio::time::interval(std::time::Duration::from_secs(300));
loop {
tokio::select! {
_ = interval.tick() => {
let active_task_ids: std::collections::HashSet<String> = {
let running = stall_state.running.lock().await;
running.keys().cloned().collect()
};
match stall_state.task_repo.recover_stalled_running_items(stall_threshold_secs, active_task_ids).await {
Ok(recovered) => {
for (task_id, item_ids) in &recovered {
for item_id in item_ids {
emit_daemon_event(
&stall_state,
"item_stall_recovered",
serde_json::json!({
"task_id": task_id,
"item_id": item_id,
"stall_threshold_secs": stall_threshold_secs,
}),
)
.await;
}
}
if !recovered.is_empty() {
stall_state.worker_notify.notify_waiters();
}
}
Err(e) => {
tracing::warn!(error = %e, "stall detection sweep failed");
}
}
}
_ = stall_shutdown.changed() => {
break;
}
}
}
});
}
let webhook_bind = args.webhook_bind.as_str();
if webhook_bind != "none" {
let addr: std::net::SocketAddr = webhook_bind
.parse()
.context("invalid --webhook-bind address (use \"none\" to disable)")?;
let webhook_secret = args.webhook_secret.clone().or_else(|| {
let derived = control_plane::derive_webhook_secret(
&inner.data_dir,
args.control_plane_dir.as_deref(),
);
if derived.is_some() {
info!(%addr, "webhook secret derived from control-plane CA certificate");
}
derived
});
if webhook_secret.is_none() {
if addr.ip().is_loopback() {
info!(
%addr,
"webhook server on loopback without signature verification \
(safe for local development)"
);
} else if args.webhook_allow_unsigned {
tracing::warn!(
%addr,
"webhook server starting without signature verification on a \
non-loopback address (--webhook-allow-unsigned); this is insecure"
);
} else {
bail!(
"refusing to start webhook server on {addr} without signature \
verification.\n\n\
The webhook server is bound to a non-loopback address but no \
HMAC secret is configured. This would accept unsigned requests \
from the network.\n\n\
Options:\n \
1. Set --webhook-secret <secret> or ORCHESTRATOR_WEBHOOK_SECRET\n \
2. Configure control-plane PKI (auto-derives a secret)\n \
3. Use --webhook-bind 127.0.0.1:19090 for local-only access\n \
4. Pass --webhook-allow-unsigned to override this check\n \
5. Set --webhook-bind none to disable the webhook server"
);
}
}
let wh_state = webhook::WebhookState {
inner: inner.clone(),
secret: webhook_secret,
};
let router = webhook::router(wh_state);
let listener = tokio::net::TcpListener::bind(addr)
.await
.with_context(|| format!("failed to bind webhook on {addr}"))?;
info!(%addr, "webhook HTTP server started");
let mut wh_shutdown = shutdown_rx.clone();
tokio::spawn(async move {
axum::serve(listener, router)
.with_graceful_shutdown(async move {
let _ = wh_shutdown.changed().await;
})
.await
.ok();
});
} else {
info!("webhook HTTP server disabled (--webhook-bind none)");
}
let shutdown_notify = Arc::new(tokio::sync::Notify::new());
let protection = Arc::new(protection::ControlPlaneProtection::load_or_bootstrap(
&inner.data_dir,
&inner.db_path,
args.control_plane_dir.as_deref(),
)?);
let uds_policy = uds_security::load_uds_policy(
&inner.data_dir,
args.control_plane_dir.as_deref(),
)?;
let service = server::OrchestratorServer::new(
inner.clone(),
shutdown_notify.clone(),
None,
uds_policy,
);
let shutdown_fut = {
let inner2 = inner.clone();
let mut restart_rx2 = restart_rx.clone();
let notify = shutdown_notify.clone();
async move {
tokio::select! {
result = lifecycle::shutdown_signal(inner2) => {
if let Err(error) = result {
tracing::error!(%error, "failed to initialize shutdown signal handling");
}
}
_ = restart_rx2.changed() => {}
_ = notify.notified() => {
tracing::info!("shutdown triggered via RPC");
}
}
}
};
if let Some(addr) = args.bind.as_deref() {
let addr = addr.parse().context("invalid bind address")?;
let secure = control_plane::prepare_secure_server(
&inner.data_dir,
&inner.db_path,
&addr,
args.control_plane_dir.as_deref(),
)?;
info!(%addr, "listening on TCP");
Server::builder()
.layer(protection.clone().layer())
.tls_config(secure.tls)?
.add_service(
OrchestratorServiceServer::new(server::OrchestratorServer::new(
inner.clone(),
shutdown_notify.clone(),
Some(secure.security),
None,
))
.max_encoding_message_size(64 * 1024 * 1024),
)
.serve_with_shutdown(addr, shutdown_fut)
.await
.context("gRPC server error")?;
} else {
#[cfg(feature = "dev-insecure")]
let insecure_addr = args.insecure_bind.as_deref();
#[cfg(not(feature = "dev-insecure"))]
let insecure_addr: Option<&str> = None;
if let Some(addr) = insecure_addr {
let addr = addr.parse().context("invalid insecure bind address")?;
info!(%addr, "listening on insecure TCP");
tracing::warn!("insecure TCP control-plane enabled; use only for local development");
Server::builder()
.layer(protection.clone().layer())
.add_service(
OrchestratorServiceServer::new(service)
.max_encoding_message_size(64 * 1024 * 1024),
)
.serve_with_shutdown(addr, shutdown_fut)
.await
.context("gRPC server error")?;
} else {
use tokio::net::UnixListener;
let _ = std::fs::remove_file(&socket_path);
let uds = UnixListener::bind(&socket_path).context("failed to bind UDS")?;
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(
&socket_path,
std::fs::Permissions::from_mode(0o600),
)
.context("failed to set UDS socket permissions to 0600")?;
}
use futures::StreamExt;
let uds_stream =
tokio_stream::wrappers::UnixListenerStream::new(uds).filter_map(
|result| async {
match result {
Ok(stream) => match uds_security::validate_peer(&stream) {
Ok(peer) => {
Some(Ok(uds_security::UdsStream::new(stream, peer)))
}
Err(e) => {
tracing::warn!(error = %e, "rejected UDS connection");
None
}
},
Err(e) => Some(Err(e)),
}
},
);
info!(socket = %socket_path.display(), mode = "0600", "listening on UDS");
emit_daemon_event(&inner, "daemon_socket_ready", serde_json::json!({
"socket": socket_path.to_string_lossy(),
})).await;
Server::builder()
.layer(protection.clone().layer())
.add_service(
OrchestratorServiceServer::new(service)
.max_encoding_message_size(64 * 1024 * 1024),
)
.serve_with_incoming_shutdown(uds_stream, shutdown_fut)
.await
.context("gRPC server error")?;
}
}
emit_daemon_event(&inner, "daemon_shutdown_requested", serde_json::json!({
"reason": shutdown_reason(&inner, restart_rx.borrow().as_ref()),
}))
.await;
info!("signalling workers to shut down");
inner.daemon_runtime.request_shutdown();
let _ = shutdown_tx.send(true);
let _ = clear_worker_stop_signal(&inner);
let draining_tasks = agent_orchestrator::service::daemon::runtime_snapshot(&inner).running_tasks;
if draining_tasks > 0 {
emit_daemon_event(&inner, "task_drain_started", serde_json::json!({
"running_tasks": draining_tasks,
"timeout_ms": 5_000_u64,
}))
.await;
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
let remaining = agent_orchestrator::service::daemon::runtime_snapshot(&inner).running_tasks;
if remaining > 0 {
shutdown_running_tasks(inner.clone()).await;
}
emit_daemon_event(&inner, "task_drain_completed", serde_json::json!({
"remaining_after_grace": remaining,
"forced_task_count": remaining,
}))
.await;
}
match tokio::time::timeout(std::time::Duration::from_secs(30), supervisor_handle).await {
Ok(Ok(())) => {
info!("all workers stopped");
}
Ok(Err(e)) => {
error!(error = %e, "worker supervisor panicked");
}
Err(_) => {
error!("timed out waiting for workers to drain (30s)");
}
}
if let Some(binary_path) = restart_rx.borrow().clone() {
info!(binary = %binary_path.display(), "exec-ing new daemon binary");
match inner
.task_repo
.pause_restart_pending_tasks_and_items()
.await
{
Ok(count) if count > 0 => {
info!(count, "reset restart-pending items before exec");
}
Err(e) => {
error!(
error = %e,
"failed to reset restart-pending items before exec"
);
}
_ => {}
}
use std::os::unix::process::CommandExt;
let err = std::process::Command::new(&binary_path)
.args(std::env::args_os().skip(1))
.exec();
error!("exec failed: {}", err);
std::process::exit(1);
}
inner.daemon_runtime.mark_stopped();
lifecycle::cleanup(&socket_path, &pid_path);
emit_daemon_event(&inner, "daemon_shutdown_completed", serde_json::json!({
"reason": shutdown_reason(&inner, restart_rx.borrow().as_ref()),
}))
.await;
info!("orchestratord stopped");
Ok(())
})
}
fn handle_subcommand(command: Commands) -> Result<()> {
match command {
Commands::ControlPlane(ControlPlaneCommands::IssueClient {
bind,
subject,
role,
home,
control_plane_dir,
}) => {
let state = agent_orchestrator::service::bootstrap::init_state(false)
.context("failed to initialize orchestrator state")?;
let addr = bind.parse().context("invalid bind address")?;
let home = home
.or_else(|| std::env::var_os("HOME").map(PathBuf::from))
.ok_or_else(|| anyhow::anyhow!("HOME is not set; pass --home explicitly"))?;
let client_dir = control_plane::issue_client_materials(
&state.inner.data_dir,
&addr,
control_plane_dir.as_deref(),
&home,
&subject,
role,
)?;
println!("{}", client_dir.display());
Ok(())
}
Commands::WebhookSecret { control_plane_dir } => {
let data_dir = agent_orchestrator::config_load::data_dir();
match control_plane::derive_webhook_secret(&data_dir, control_plane_dir.as_deref()) {
Some(secret) => {
println!("{secret}");
Ok(())
}
None => {
bail!(
"no control-plane CA certificate found; \
run the daemon with --bind first to bootstrap PKI"
)
}
}
}
}
}
enum WorkerIterationOutcome {
Continue,
Shutdown,
RestartRequested(std::path::PathBuf),
}
async fn worker_iteration(
state: &Arc<InnerState>,
worker_num: usize,
shutdown: &mut tokio::sync::watch::Receiver<bool>,
is_busy: &mut bool,
) -> WorkerIterationOutcome {
let stop_path = worker_stop_signal_path(state);
let poll_interval = std::time::Duration::from_millis(2000);
if *shutdown.borrow() {
return WorkerIterationOutcome::Shutdown;
}
if stop_path.exists() {
info!(worker = worker_num, "stop signal detected, exiting");
return WorkerIterationOutcome::Shutdown;
}
let permit = match task_semaphore().clone().acquire_owned().await {
Ok(p) => p,
Err(_) => {
info!(worker = worker_num, "semaphore closed, exiting");
return WorkerIterationOutcome::Shutdown;
}
};
match claim_next_pending_task(state).await {
Ok(Some(task_id)) => {
info!(worker = worker_num, %task_id, "claimed task");
let runtime = RunningTask::new();
state.daemon_runtime.worker_became_busy();
*is_busy = true;
emit_daemon_event(
state,
"worker_state_changed",
serde_json::json!({
"worker_id": worker_num,
"from_state": "idle",
"to_state": "busy",
"task_id": task_id,
}),
)
.await;
let _ = register_running_task(state, &task_id, runtime.clone()).await;
let run_result = run_task_loop(state.clone(), &task_id, runtime).await;
unregister_running_task(state, &task_id).await;
if let Some(binary_path) = state.daemon_runtime.take_deferred_restart() {
let running_count = {
let running = state.running.lock().await;
running.len()
};
if running_count == 0 {
info!(
worker = worker_num,
"all tasks drained, executing deferred restart"
);
state.daemon_runtime.request_shutdown();
return WorkerIterationOutcome::RestartRequested(binary_path);
} else {
state.daemon_runtime.set_deferred_restart(binary_path);
}
}
state.daemon_runtime.worker_became_idle();
*is_busy = false;
emit_daemon_event(
state,
"worker_state_changed",
serde_json::json!({
"worker_id": worker_num,
"from_state": "busy",
"to_state": "idle",
"task_id": task_id,
}),
)
.await;
match run_result {
Ok(()) => {
if let Ok(summary) = load_task_summary(state, &task_id).await {
info!(worker = worker_num, %task_id, status = %summary.status, "task finished");
}
}
Err(e) => {
if let Some(restart) = e.downcast_ref::<RestartRequestedError>() {
let other_running = {
let running = state.running.lock().await;
running
.keys()
.filter(|id| id.as_str() != task_id.as_str())
.count()
};
if other_running == 0 {
info!(worker = worker_num, "restart requested, signalling daemon");
state.daemon_runtime.request_shutdown();
return WorkerIterationOutcome::RestartRequested(
restart.binary_path.clone(),
);
} else {
info!(
worker = worker_num,
other_tasks = other_running,
"deferring restart until other tasks complete"
);
state
.daemon_runtime
.set_deferred_restart(restart.binary_path.clone());
}
}
error!(worker = worker_num, %task_id, error = %e, "task failed");
}
}
drop(permit);
}
Ok(None) => {
drop(permit);
tokio::select! {
_ = state.worker_notify.notified() => {}
_ = tokio::time::sleep(poll_interval) => {}
_ = shutdown.changed() => {}
}
}
Err(e) => {
drop(permit);
error!(worker = worker_num, error = %e, "claim error");
tokio::select! {
_ = tokio::time::sleep(std::time::Duration::from_secs(5)) => {}
_ = shutdown.changed() => {}
}
}
}
WorkerIterationOutcome::Continue
}
async fn worker_loop(
state: Arc<InnerState>,
worker_idx: usize,
mut shutdown: tokio::sync::watch::Receiver<bool>,
restart_tx: tokio::sync::watch::Sender<Option<std::path::PathBuf>>,
) {
let worker_num = worker_idx + 1;
state.daemon_runtime.worker_started();
emit_daemon_event(
&state,
"worker_state_changed",
serde_json::json!({
"worker_id": worker_num,
"from_state": "new",
"to_state": "idle",
}),
)
.await;
info!(worker = worker_num, "worker started");
let mut is_busy = false;
loop {
if *shutdown.borrow() {
break;
}
let result = std::panic::AssertUnwindSafe(worker_iteration(
&state,
worker_num,
&mut shutdown,
&mut is_busy,
))
.catch_unwind()
.await;
match result {
Ok(WorkerIterationOutcome::Continue) => {}
Ok(WorkerIterationOutcome::Shutdown) => break,
Ok(WorkerIterationOutcome::RestartRequested(binary_path)) => {
let _ = restart_tx.send(Some(binary_path));
break;
}
Err(_panic) => {
error!(worker = worker_num, "worker iteration panicked, recovering");
state.daemon_runtime.record_worker_restart();
if is_busy {
state.daemon_runtime.worker_became_idle();
is_busy = false;
}
emit_daemon_event(
&state,
"worker_panic_recovered",
serde_json::json!({ "worker_id": worker_num }),
)
.await;
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
continue;
}
}
}
state.daemon_runtime.worker_stopped(false);
emit_daemon_event(
&state,
"worker_state_changed",
serde_json::json!({
"worker_id": worker_num,
"from_state": "idle",
"to_state": "stopped",
}),
)
.await;
info!(worker = worker_num, "worker stopped");
}
async fn worker_supervisor(
state: Arc<InnerState>,
worker_count: usize,
mut shutdown: tokio::sync::watch::Receiver<bool>,
restart_tx: tokio::sync::watch::Sender<Option<std::path::PathBuf>>,
) {
let mut handles: Vec<(usize, tokio::task::JoinHandle<()>)> = Vec::with_capacity(worker_count);
for idx in 0..worker_count {
let rx = shutdown.clone();
let st = state.clone();
let rtx = restart_tx.clone();
let handle = tokio::spawn(worker_loop(st, idx, rx, rtx));
handles.push((idx, handle));
}
info!(workers = worker_count, "initial workers spawned");
let health_interval = std::time::Duration::from_secs(30);
loop {
tokio::select! {
_ = tokio::time::sleep(health_interval) => {}
_ = shutdown.changed() => {
break;
}
}
if *shutdown.borrow() {
break;
}
let mut respawn_indices = Vec::new();
for (idx, (worker_idx, handle)) in handles.iter().enumerate() {
if handle.is_finished() {
info!(
worker = worker_idx + 1,
"detected dead worker, scheduling respawn"
);
respawn_indices.push((idx, *worker_idx));
}
}
for (vec_idx, worker_idx) in respawn_indices.into_iter().rev() {
let (_, old_handle) = handles.remove(vec_idx);
if let Err(e) = old_handle.await {
error!(worker = worker_idx + 1, error = %e, "dead worker had panicked");
}
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
if *shutdown.borrow() {
break;
}
let rx = shutdown.clone();
let st = state.clone();
let rtx = restart_tx.clone();
let handle = tokio::spawn(worker_loop(st, worker_idx, rx, rtx));
handles.push((worker_idx, handle));
state.daemon_runtime.record_worker_restart();
emit_daemon_event(
&state,
"worker_respawned",
serde_json::json!({ "worker_id": worker_idx + 1 }),
)
.await;
info!(worker = worker_idx + 1, "worker respawned by supervisor");
}
let live = handles.iter().filter(|(_, h)| !h.is_finished()).count();
if live < worker_count {
tracing::warn!(
live_workers = live,
configured = worker_count,
"live workers below configured count"
);
}
}
for (worker_idx, handle) in handles {
if let Err(e) = handle.await {
error!(worker = worker_idx + 1, error = %e, "worker panicked during shutdown");
}
}
}
async fn emit_daemon_event(state: &InnerState, event_type: &str, payload: serde_json::Value) {
let _ = insert_event(state, "", None, event_type, payload.clone()).await;
state.emit_event("", None, event_type, payload);
}
fn shutdown_reason(
state: &InnerState,
restart_binary: Option<&std::path::PathBuf>,
) -> &'static str {
if restart_binary.is_some() {
"restart"
} else if worker_stop_signal_path(state).exists() {
"external_stop_signal"
} else if state.daemon_runtime.snapshot().shutdown_requested {
"shutdown"
} else {
"unknown"
}
}