use std::collections::BTreeSet;
use std::sync::Arc;
use std::time::Duration;
use aion_core::{
ActivityId, ContentType, InterventionCapabilities, InterventionCommand, InterventionOutcome,
Payload, RunId, WorkflowId,
};
use aion_integrations::contract::DynAgentHarness;
use aion_integrations::spec::AgentRunSpec;
use liminal::protocol::WorkerRegistration;
use liminal_sdk::{PushClient, PushWriter, PushedFrame};
use serde::{Deserialize, Serialize};
use tokio::sync::mpsc;
use crate::activity::ActivityRegistry;
use crate::config::WorkerConfig;
use crate::context::ActivityContext;
use crate::error::WorkerError;
use crate::protocol::ActivityTask;
use crate::runtime::agent::spawn_dyn_agent;
use crate::runtime::intervention::{ControlRegistry, SessionKey};
use crate::runtime::liminal_drain::{DrainBinding, LiveWriter, spawn_event_drain};
use crate::runtime::liminal_redial::{RedialBackoff, ServeResult};
use crate::runtime::loop_::{ActivityDispatcher, DispatchOutcome};
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct DispatchRequest {
pub activity_type: String,
pub workflow_id: WorkflowId,
pub ordinal: u64,
pub run_id: Option<RunId>,
pub input: Vec<u8>,
#[serde(default = "first_attempt")]
pub attempt: u32,
#[serde(default)]
pub labels: std::collections::BTreeMap<String, String>,
#[serde(default)]
pub heartbeat_window_ms: u64,
}
const fn first_attempt() -> u32 {
1
}
pub const WORKER_LIVENESS_CHANNEL: &str = "aion.worker.liveness";
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct WorkerLivenessBeat {
pub workflow_id: WorkflowId,
pub ordinal: u64,
}
pub const WORKER_CAPABILITIES_CHANNEL: &str = "aion.worker.capabilities";
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct WorkerCapabilitiesAnnouncement {
pub capabilities: InterventionCapabilities,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct DispatchResponse {
pub workflow_id: WorkflowId,
pub ordinal: u64,
pub run_id: Option<RunId>,
pub outcome: Result<String, String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct InterventionRequest {
pub intervention: InterventionCommand,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct InterventionReply {
pub outcome: InterventionOutcome,
}
const RECV_POLL: Duration = Duration::from_millis(100);
#[derive(Clone)]
pub struct AgentHarnessConfig {
harness: Arc<dyn DynAgentHarness>,
agent_activity_types: BTreeSet<String>,
capabilities: InterventionCapabilities,
}
impl AgentHarnessConfig {
#[must_use]
pub fn new(
harness: Arc<dyn DynAgentHarness>,
agent_activity_types: impl IntoIterator<Item = impl Into<String>>,
capabilities: InterventionCapabilities,
) -> Self {
Self {
harness,
agent_activity_types: agent_activity_types.into_iter().map(Into::into).collect(),
capabilities,
}
}
#[must_use]
pub fn agent_activity_types(&self) -> &BTreeSet<String> {
&self.agent_activity_types
}
}
impl std::fmt::Debug for AgentHarnessConfig {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("AgentHarnessConfig")
.field("agent_activity_types", &self.agent_activity_types)
.field("capabilities", &self.capabilities)
.finish_non_exhaustive()
}
}
pub struct LiminalActivityWorker {
client: PushClient,
registry: Arc<ActivityRegistry>,
control: ControlRegistry,
agent_harness: Option<Arc<dyn DynAgentHarness>>,
agent_activity_types: BTreeSet<String>,
agent_capabilities: InterventionCapabilities,
live_writer: LiveWriter,
drain_backoff: RedialBackoff,
}
impl std::fmt::Debug for LiminalActivityWorker {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("LiminalActivityWorker")
.field("client", &self.client)
.finish_non_exhaustive()
}
}
impl LiminalActivityWorker {
pub fn connect(
address: &str,
config: &WorkerConfig,
registry: Arc<ActivityRegistry>,
) -> Result<Self, WorkerError> {
Self::connect_advertising(address, config, registry, &BTreeSet::new())
}
pub fn connect_advertising(
address: &str,
config: &WorkerConfig,
registry: Arc<ActivityRegistry>,
agent_types: &BTreeSet<String>,
) -> Result<Self, WorkerError> {
let mut registration = registration_from(config, ®istry);
for agent_type in agent_types {
if !registration.activity_types.contains(agent_type) {
registration.activity_types.push(agent_type.clone());
}
}
let client = PushClient::connect_with_registration(address, registration)
.map_err(|error| transport_error(&error))?;
let live_writer = LiveWriter::seeded(client.writer_handle());
let drain_backoff = RedialBackoff::new(
config.reconnect.initial_backoff,
config.reconnect.max_backoff,
);
Ok(Self {
client,
registry,
control: ControlRegistry::new(),
agent_harness: None,
agent_activity_types: BTreeSet::new(),
agent_capabilities: InterventionCapabilities::none(),
live_writer,
drain_backoff,
})
}
#[must_use]
pub fn with_agent_harness(
mut self,
harness: Arc<dyn DynAgentHarness>,
agent_activity_types: impl IntoIterator<Item = impl Into<String>>,
capabilities: InterventionCapabilities,
) -> Self {
self.agent_harness = Some(harness);
self.agent_activity_types = agent_activity_types.into_iter().map(Into::into).collect();
self.agent_capabilities = capabilities;
self
}
#[must_use]
pub fn with_agent_config(self, config: Option<AgentHarnessConfig>) -> Self {
match config {
Some(config) => self.with_agent_harness(
config.harness,
config.agent_activity_types,
config.capabilities,
),
None => self,
}
}
#[must_use]
pub(crate) fn with_live_writer(mut self, slot: LiveWriter) -> Self {
slot.set(self.client.writer_handle());
self.live_writer = slot;
self
}
#[must_use]
pub fn drives_agent_activity(&self, activity_type: &str) -> bool {
self.is_agent_activity(activity_type)
}
#[must_use]
pub fn control_registry(&self) -> &ControlRegistry {
&self.control
}
pub async fn serve_one(&self) -> Result<bool, WorkerError> {
match self.client.recv_timeout(RECV_POLL) {
Ok(frame) => {
self.handle_pushed_frame(frame).await?;
Ok(true)
}
Err(error) if is_recv_timeout(&error) => Ok(false),
Err(error) => Err(transport_error(&error)),
}
}
fn announce_capabilities(&self) {
if self.agent_capabilities.supported.is_empty() {
return;
}
let announcement = WorkerCapabilitiesAnnouncement {
capabilities: self.agent_capabilities.clone(),
};
match serde_json::to_vec(&announcement) {
Ok(payload) => {
if let Err(error) = self
.client
.writer_handle()
.publish(WORKER_CAPABILITIES_CHANNEL, payload)
{
tracing::warn!(%error, "failed to announce intervention capabilities");
}
}
Err(error) => {
tracing::warn!(%error, "failed to encode WorkerCapabilitiesAnnouncement");
}
}
}
pub async fn serve_until<Stop>(&self, mut stop: Stop) -> Result<(), WorkerError>
where
Stop: FnMut() -> bool + Send,
{
self.announce_capabilities();
while !stop() {
self.serve_one().await?;
}
Ok(())
}
pub(crate) async fn serve_until_drop<Stop>(&self, mut stop: Stop) -> ServeResult
where
Stop: FnMut() -> bool + Send,
{
self.announce_capabilities();
let mut served_work = false;
while !stop() {
match self.serve_one().await {
Ok(true) => served_work = true,
Ok(false) => {}
Err(_) => return ServeResult::Dropped { served_work },
}
}
ServeResult::Stopped
}
async fn handle_pushed_frame(&self, frame: PushedFrame) -> Result<(), WorkerError> {
let correlation_id = frame.correlation_id();
if let Ok(request) = serde_json::from_slice::<InterventionRequest>(frame.payload()) {
let outcome = self.control.deliver(request.intervention).await;
let reply = InterventionReply { outcome };
let payload = serde_json::to_vec(&reply).map_err(WorkerError::encode)?;
return self
.client
.reply(correlation_id, payload)
.map_err(|error| transport_error(&error));
}
let request: DispatchRequest =
serde_json::from_slice(frame.payload()).map_err(WorkerError::decode)?;
tracing::info!(
activity_type = %request.activity_type,
workflow_id = %request.workflow_id,
ordinal = request.ordinal,
attempt = request.attempt,
serves = ?self.registry.activity_types(),
agent_types = ?self.agent_activity_types,
"received dispatch push"
);
if self.is_agent_activity(&request.activity_type) {
self.spawn_agent_dispatch(correlation_id, request);
return Ok(());
}
let response = self.execute(&request).await?;
let payload = serde_json::to_vec(&response).map_err(WorkerError::encode)?;
self.client
.reply(correlation_id, payload)
.map_err(|error| transport_error(&error))
}
fn spawn_agent_dispatch(&self, correlation_id: u64, request: DispatchRequest) {
let Some(harness) = self.agent_harness.clone() else {
return;
};
let control = self.control.clone();
let capabilities = self.agent_capabilities.clone();
let writer = self.client.writer_handle();
let drain = DrainBinding::new(self.live_writer.clone(), self.drain_backoff);
std::thread::spawn(move || {
let Ok(runtime) = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
else {
tracing::warn!("agent dispatch: failed to build runtime for the agent run");
return;
};
runtime.block_on(run_agent_dispatch(
harness,
control,
capabilities,
writer,
drain,
correlation_id,
request,
));
});
}
async fn execute(&self, request: &DispatchRequest) -> Result<DispatchResponse, WorkerError> {
let attempt = request.attempt;
let activity_id = ActivityId::from_sequence_position(request.ordinal);
let task = ActivityTask {
workflow_id: request.workflow_id.clone(),
activity_id: activity_id.clone(),
run_id: request.run_id.clone(),
activity_type: request.activity_type.clone(),
attempt,
input: Payload::new(ContentType::Json, request.input.clone()),
labels: request.labels.clone(),
};
let _liveness = spawn_liveness_pump(self.client.writer_handle(), request);
let (event_sender, drain) = spawn_event_drain(DrainBinding::new(
self.live_writer.clone(),
self.drain_backoff,
));
let (context, cancellation) = ActivityContext::for_workflow_with_events(
Some(request.workflow_id.clone()),
activity_id,
attempt,
None,
Some(event_sender),
);
drop(cancellation);
let outcome = self.registry.dispatch(task, context).await;
drain.finish().await;
let outcome = match outcome {
Ok(outcome) => wire_outcome(outcome),
Err(error) => Err(format!("retryable:{error}")),
};
Ok(DispatchResponse {
workflow_id: request.workflow_id.clone(),
ordinal: request.ordinal,
run_id: request.run_id.clone(),
outcome,
})
}
fn is_agent_activity(&self, activity_type: &str) -> bool {
self.agent_harness.is_some() && self.agent_activity_types.contains(activity_type)
}
}
async fn run_agent_dispatch(
harness: Arc<dyn DynAgentHarness>,
control: ControlRegistry,
capabilities: InterventionCapabilities,
writer: PushWriter,
drain: DrainBinding,
correlation_id: u64,
request: DispatchRequest,
) {
let attempt = request.attempt;
let activity_id = ActivityId::from_sequence_position(request.ordinal);
let session_key = SessionKey::new(request.workflow_id.clone(), activity_id.clone(), attempt);
let (control_tx, control_rx) = mpsc::unbounded_channel();
let _session_guard = control.register(session_key, control_tx, capabilities);
let _liveness = spawn_liveness_pump(writer.clone(), &request);
let spec = AgentRunSpec::new(
request.workflow_id.clone(),
activity_id,
attempt,
request.activity_type.clone(),
Payload::new(ContentType::Json, request.input.clone()),
);
let (event_sender, drain) = spawn_event_drain(drain);
let outcome = spawn_dyn_agent(harness.as_ref(), spec, event_sender, Some(control_rx)).await;
drain.finish().await;
let outcome =
outcome.unwrap_or_else(|error| crate::runtime::agent::harness_error_to_outcome(&error));
let response = DispatchResponse {
workflow_id: request.workflow_id.clone(),
ordinal: request.ordinal,
run_id: request.run_id.clone(),
outcome: wire_outcome(outcome),
};
match serde_json::to_vec(&response) {
Ok(payload) => {
if let Err(error) = writer.reply(correlation_id, payload) {
tracing::warn!(%error, "agent dispatch: failed to reply DispatchResponse");
}
}
Err(error) => tracing::warn!(%error, "agent dispatch: failed to encode DispatchResponse"),
}
}
fn spawn_liveness_pump(writer: PushWriter, request: &DispatchRequest) -> Option<LivenessPump> {
if request.heartbeat_window_ms == 0 {
return None;
}
let period = crate::runtime::loop_::liveness_pump_interval(Duration::from_millis(
request.heartbeat_window_ms,
));
let beat = WorkerLivenessBeat {
workflow_id: request.workflow_id.clone(),
ordinal: request.ordinal,
};
let payload = match serde_json::to_vec(&beat) {
Ok(payload) => payload,
Err(error) => {
tracing::warn!(%error, "liveness pump: failed to encode WorkerLivenessBeat");
return None;
}
};
let handle = tokio::spawn(async move {
let mut ticks = tokio::time::interval(period);
ticks.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
loop {
ticks.tick().await;
if let Err(error) = writer.publish(WORKER_LIVENESS_CHANNEL, payload.clone()) {
tracing::warn!(%error, "liveness pump: failed to publish beat; stopping");
return;
}
}
});
Some(LivenessPump { handle })
}
struct LivenessPump {
handle: tokio::task::JoinHandle<()>,
}
impl Drop for LivenessPump {
fn drop(&mut self) {
self.handle.abort();
}
}
fn result_string(output: &Payload) -> String {
String::from_utf8_lossy(output.bytes()).into_owned()
}
fn wire_outcome(outcome: DispatchOutcome) -> Result<String, String> {
match outcome {
DispatchOutcome::Completed { output } => Ok(result_string(&output)),
DispatchOutcome::Failed { failure } => {
let prefix = if failure.is_retryable() {
"retryable"
} else {
"terminal"
};
Err(format!("{prefix}:{}", failure.message))
}
}
}
fn is_recv_timeout(error: &liminal_sdk::SdkError) -> bool {
error
.to_string()
.contains("no server push arrived within the timeout")
}
fn registration_from(config: &WorkerConfig, registry: &ActivityRegistry) -> WorkerRegistration {
let node = if config.node.is_empty() {
None
} else {
Some(config.node.clone())
};
WorkerRegistration {
namespaces: config.namespaces.clone(),
task_queue: config.task_queue.clone(),
node,
activity_types: registry.activity_types().into_iter().collect(),
identity: config.identity.clone(),
}
}
fn transport_error(error: &liminal_sdk::SdkError) -> WorkerError {
WorkerError::Transport {
source: tonic::Status::unavailable(format!("liminal worker transport error: {error}")),
}
}
#[cfg(test)]
mod tests {
use std::time::Duration;
use super::{DispatchRequest, DispatchResponse, registration_from};
use crate::activity::ActivityRegistry;
use crate::config::WorkerConfig;
use aion_core::{RunId, WorkflowId};
use uuid::Uuid;
fn worker_config(node: &str) -> Result<WorkerConfig, Box<dyn std::error::Error>> {
Ok(WorkerConfig::builder()
.endpoint("127.0.0.1:0")
.task_queue("gpu")
.identity("worker-a")
.max_concurrency(1)
.reconnect_initial_backoff(Duration::from_millis(5))
.reconnect_max_backoff(Duration::from_millis(20))
.reconnect_max_attempts(3)
.namespaces([String::from("remote"), String::from("payments")])
.node(node)
.build()?)
}
fn two_activity_registry() -> Result<ActivityRegistry, Box<dyn std::error::Error>> {
let registry = ActivityRegistry::new()
.register_activity("charge-card", |_input: serde_json::Value, _ctx| {
Box::pin(async move { Ok(serde_json::json!({})) })
})?
.register_activity("refund", |_input: serde_json::Value, _ctx| {
Box::pin(async move { Ok(serde_json::json!({})) })
})?;
Ok(registry)
}
#[test]
fn registration_carries_config_and_registry_activity_types()
-> Result<(), Box<dyn std::error::Error>> {
let config = worker_config("box-7")?;
let registry = two_activity_registry()?;
let registration = registration_from(&config, ®istry);
assert_eq!(
registration.namespaces,
vec![String::from("remote"), String::from("payments")]
);
assert_eq!(registration.task_queue, "gpu");
assert_eq!(registration.node, Some(String::from("box-7")));
assert_eq!(registration.identity, "worker-a");
assert_eq!(
registration.activity_types,
vec![String::from("charge-card"), String::from("refund")],
"activity types come from the bound registry, sorted"
);
Ok(())
}
#[test]
fn registration_empty_node_is_unpinned() -> Result<(), Box<dyn std::error::Error>> {
let config = worker_config("")?;
let registry = two_activity_registry()?;
let registration = registration_from(&config, ®istry);
assert_eq!(registration.node, None);
Ok(())
}
#[test]
fn dispatch_request_round_trips_through_json() -> Result<(), Box<dyn std::error::Error>> {
let request = DispatchRequest {
activity_type: "charge-card".to_owned(),
workflow_id: WorkflowId::new(Uuid::new_v4()),
ordinal: 7,
run_id: Some(RunId::new(Uuid::new_v4())),
input: br#"{"amount":42}"#.to_vec(),
attempt: 3,
labels: std::collections::BTreeMap::from([("region".to_owned(), "apac".to_owned())]),
heartbeat_window_ms: 30_000,
};
let bytes = serde_json::to_vec(&request)?;
let decoded: DispatchRequest = serde_json::from_slice(&bytes)?;
assert_eq!(decoded, request);
let json = String::from_utf8(bytes)?;
for field in [
"activity_type",
"workflow_id",
"ordinal",
"run_id",
"input",
"attempt",
"labels",
"heartbeat_window_ms",
] {
assert!(json.contains(field), "wire JSON must carry `{field}`");
}
Ok(())
}
#[test]
fn dispatch_request_without_new_fields_decodes_as_first_delivery()
-> Result<(), Box<dyn std::error::Error>> {
let old_frame = serde_json::json!({
"activity_type": "charge-card",
"workflow_id": WorkflowId::new(Uuid::new_v4()),
"ordinal": 7,
"run_id": null,
"input": [123, 125],
});
let decoded: DispatchRequest = serde_json::from_value(old_frame)?;
assert_eq!(
decoded.attempt, 1,
"a pre-attempt frame is a first delivery"
);
assert!(
decoded.labels.is_empty(),
"a pre-labels frame has no labels"
);
assert_eq!(
decoded.heartbeat_window_ms, 0,
"a pre-window frame arms no liveness pump"
);
Ok(())
}
#[test]
fn worker_liveness_beat_round_trips_through_json() -> Result<(), Box<dyn std::error::Error>> {
let beat = super::WorkerLivenessBeat {
workflow_id: WorkflowId::new(Uuid::new_v4()),
ordinal: 9,
};
let bytes = serde_json::to_vec(&beat)?;
let decoded: super::WorkerLivenessBeat = serde_json::from_slice(&bytes)?;
assert_eq!(decoded, beat);
let json = String::from_utf8(bytes)?;
for field in ["workflow_id", "ordinal"] {
assert!(json.contains(field), "beat JSON must carry `{field}`");
}
assert_eq!(super::WORKER_LIVENESS_CHANNEL, "aion.worker.liveness");
Ok(())
}
#[test]
fn worker_capabilities_announcement_round_trips_through_json()
-> Result<(), Box<dyn std::error::Error>> {
let announcement = super::WorkerCapabilitiesAnnouncement {
capabilities: aion_core::InterventionCapabilities {
supported: vec![
aion_core::InterventionPrimitive::InjectMessage,
aion_core::InterventionPrimitive::Cancel,
],
},
};
let bytes = serde_json::to_vec(&announcement)?;
let decoded: super::WorkerCapabilitiesAnnouncement = serde_json::from_slice(&bytes)?;
assert_eq!(decoded, announcement);
let json = String::from_utf8(bytes)?;
for field in ["capabilities", "supported"] {
assert!(json.contains(field), "wire JSON must carry `{field}`");
}
assert_eq!(
super::WORKER_CAPABILITIES_CHANNEL,
"aion.worker.capabilities"
);
Ok(())
}
#[test]
fn intervention_request_demuxes_from_a_dispatch_request()
-> Result<(), Box<dyn std::error::Error>> {
use super::{InterventionReply, InterventionRequest};
use aion_core::{
ActivityId, InjectPriority, InterventionCommand, InterventionKind, InterventionOutcome,
};
let command = InterventionCommand {
workflow_id: WorkflowId::new(Uuid::nil()),
activity_id: ActivityId::from_sequence_position(3),
attempt: 1,
issued_by: Some("operator".to_owned()),
issued_at: chrono::Utc::now(),
kind: InterventionKind::InjectMessage {
text: "steer".to_owned(),
priority: InjectPriority::Interrupt,
},
};
let request = InterventionRequest {
intervention: command,
};
let bytes = serde_json::to_vec(&request)?;
assert_eq!(
serde_json::from_slice::<InterventionRequest>(&bytes)?,
request
);
let dispatch = DispatchRequest {
activity_type: "charge-card".to_owned(),
workflow_id: WorkflowId::new(Uuid::new_v4()),
ordinal: 7,
run_id: None,
input: b"{}".to_vec(),
attempt: 1,
labels: std::collections::BTreeMap::new(),
heartbeat_window_ms: 0,
};
let dispatch_bytes = serde_json::to_vec(&dispatch)?;
assert!(
serde_json::from_slice::<InterventionRequest>(&dispatch_bytes).is_err(),
"a dispatch frame must never decode as an intervention"
);
let reply = InterventionReply {
outcome: InterventionOutcome::Applied,
};
let reply_bytes = serde_json::to_vec(&reply)?;
assert_eq!(
serde_json::from_slice::<InterventionReply>(&reply_bytes)?,
reply
);
Ok(())
}
#[test]
fn dispatch_response_round_trips_both_outcomes() -> Result<(), Box<dyn std::error::Error>> {
let workflow_id = WorkflowId::new(Uuid::new_v4());
let ok = DispatchResponse {
workflow_id: workflow_id.clone(),
ordinal: 0,
run_id: None,
outcome: Ok(r#"{"charged":true}"#.to_owned()),
};
let err = DispatchResponse {
workflow_id,
ordinal: 1,
run_id: None,
outcome: Err("boom".to_owned()),
};
for response in [ok, err] {
let bytes = serde_json::to_vec(&response)?;
let decoded: DispatchResponse = serde_json::from_slice(&bytes)?;
assert_eq!(decoded, response);
}
Ok(())
}
}