use anyhow::{Context, Result};
use super::args::{self, AgentArgs, ConnectionSettings};
#[cfg(any(feature = "norn", feature = "acp"))]
use super::environment_report::report_absent_pass_through;
use super::surface::{self, AdvertisedSurface};
use crate::harness::ResolvedHarness;
use crate::settings::Environment;
use crate::worker_surface;
type Prepared = Option<Plan>;
struct Plan {
surface: AdvertisedSurface,
connection: ConnectionSettings,
launch: aion_awl::CompiledHarness,
harness: Option<ResolvedHarness>,
}
async fn prepare(args: &AgentArgs) -> Result<Prepared> {
let environment = Environment::from_process();
let document = args.document()?.to_path_buf();
let requested = args.requested_queue(&environment);
let derivation = document.clone();
let surface =
tokio::task::spawn_blocking(move || surface::derive(&derivation, requested.as_deref()))
.await
.context("the action-surface derivation task did not complete")??;
let declared = worker_surface::compile_harness(&document, &surface.task_queue)?;
let harness = match &declared {
Some(declared) => crate::harness::resolve_declared(declared, &document)?,
None => None,
};
#[cfg(any(feature = "norn", feature = "acp"))]
if let Some(resolved) = &harness {
report_absent_pass_through(
&mut std::io::stderr().lock(),
resolved.environment(),
&document,
&surface.task_queue,
)?;
}
if args.check {
let mut stdout = std::io::stdout().lock();
surface::print(
&mut stdout,
&surface,
harness.as_ref().map(|resolved| resolved.kind),
)?;
return Ok(None);
}
let Some(launch) = declared else {
return Err(no_harness_section(&surface, &document));
};
let connection = args::resolve(args, &environment)?;
Ok(Some(Plan {
surface,
connection,
launch,
harness,
}))
}
pub(super) fn no_harness_section(
surface: &AdvertisedSurface,
document: &std::path::Path,
) -> anyhow::Error {
anyhow::anyhow!(
"queue `{queue}` in {path} declares no `harness` section, so this document does not say \
how to launch an agent for it.\n\nAn agent worker's launch — the harness kind, the \
concurrency, the three reconnect settings, the agent's command, working directory, \
permission policy and shutdown grace, and the environment variables the agent \
subprocess is given — is declared in the document and nowhere else. There is no flag \
for any of it.\n\nAdd a `harness` section under `worker {queue}`, starting with its \
`kind` — write `kind acp` or `kind norn`. The kind is the one setting nothing can \
infer for you, and every other required setting is scoped to it. Then run `aion awl \
check {path}`: with a kind declared, the checker names every remaining setting in a \
single run, each with the reason it has no default.\n\nRun that check BEFORE the \
section exists and it reports the document valid. That is honest, not a \
contradiction: a queue with no `harness` section is a legitimate document — it is \
the shape of one served by a worker SDK build instead of by this verb — and with no \
kind declared there is no required set for the checker to scope to. This verb is \
what tells you the launch is missing; the checker is what tells you what the \
section still lacks.",
queue = surface.task_queue,
path = document.display()
)
}
#[cfg(all(any(feature = "norn", feature = "acp"), feature = "liminal-transport"))]
pub(crate) async fn run(args: &AgentArgs, namespace: &str) -> Result<()> {
let Some(plan) = prepare(args).await? else {
return Ok(());
};
let Some(harness) = plan.harness else {
anyhow::bail!(
"this `aion` build compiles an agent harness in, but none was resolved for queue \
`{}`; the harness selection must resolve before a worker can serve",
plan.surface.task_queue
);
};
serve(
&plan.surface,
&plan.connection,
&plan.launch,
&harness,
namespace,
)
.await
}
#[cfg(not(all(any(feature = "norn", feature = "acp"), feature = "liminal-transport")))]
pub(crate) async fn run(args: &AgentArgs, namespace: &str) -> Result<()> {
let Some(plan) = prepare(args).await? else {
return Ok(());
};
Err(unservable(
&plan.surface,
&plan.connection,
plan.harness.as_ref(),
namespace,
))
}
#[cfg(all(any(feature = "norn", feature = "acp"), feature = "liminal-transport"))]
const UNUSED_GRPC_ENDPOINT: &str =
"unused: an agent worker dials liminal candidates, not a gRPC endpoint";
#[cfg(all(any(feature = "norn", feature = "acp"), feature = "liminal-transport"))]
fn compose(
surface: &AdvertisedSurface,
harness: &ResolvedHarness,
) -> Result<aion_worker::AgentHarnessConfig> {
let advertised = surface.names();
let agent = aion_worker::AgentHarnessConfig::new(
crate::harness::compose::build(&harness.config),
advertised.iter().cloned(),
crate::harness::registration_capabilities(Some(harness.kind)),
)
.with_activity_descriptors(surface.descriptors.clone());
if agent.agent_activity_types() != &advertised {
anyhow::bail!(
"the composed harness would advertise {:?} but queue `{}` requires {:?}; \
refusing to register a surface that does not match the document",
agent.agent_activity_types(),
surface.task_queue,
advertised
);
}
Ok(agent)
}
#[cfg(all(any(feature = "norn", feature = "acp"), feature = "liminal-transport"))]
fn worker_config(
surface: &AdvertisedSurface,
settings: &ConnectionSettings,
launch: &aion_awl::CompiledHarness,
namespace: &str,
) -> Result<aion_worker::WorkerConfig> {
let mut builder = aion_worker::WorkerConfig::builder()
.endpoint(UNUSED_GRPC_ENDPOINT)
.namespace(namespace)
.task_queue(&surface.task_queue)
.identity(&settings.identity)
.max_concurrency(launch.concurrency)
.reconnect_initial_backoff(launch.reconnect_initial_backoff)
.reconnect_max_backoff(launch.reconnect_max_backoff)
.reconnect_max_attempts(launch.reconnect_max_attempts);
if let Some(node) = &settings.node {
builder = builder.node(node.clone());
}
builder.build().map_err(Into::into)
}
#[cfg(all(any(feature = "norn", feature = "acp"), feature = "liminal-transport"))]
async fn serve(
surface: &AdvertisedSurface,
settings: &ConnectionSettings,
launch: &aion_awl::CompiledHarness,
harness: &ResolvedHarness,
namespace: &str,
) -> Result<()> {
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use anyhow::bail;
aion_server::observability::tracing::init()?;
let advertised = surface.names();
let agent = compose(surface, harness)?;
let config = worker_config(surface, settings, launch, namespace)?;
let registry = Arc::new(aion_worker::ActivityRegistry::new());
let queue = surface.task_queue.clone();
let ready_queue = queue.clone();
let served = advertised.iter().cloned().collect::<Vec<_>>().join(", ");
let candidates = settings.addresses.join(", ");
let addresses = settings.addresses.clone();
let timing = aion_worker::RedialTiming::new(
launch.reconnect_initial_backoff,
launch.reconnect_max_backoff,
);
let stop = Arc::new(AtomicBool::new(false));
let serve_stop = Arc::clone(&stop);
let (finished, ended) = tokio::sync::oneshot::channel();
let thread = std::thread::Builder::new()
.name("aion-worker-agent".to_owned())
.spawn(move || {
let outcome = aion_worker::serve_with_redial(
addresses,
&config,
®istry,
timing,
serve_stop.as_ref(),
Some(&agent),
|| {
tracing::info!(
task_queue = %ready_queue,
actions = %served,
"agent worker registered; serving through the composed agent harness"
);
},
);
if let Err(unreported) = finished.send(outcome) {
tracing::error!(
outcome = ?unreported,
"agent worker serve loop finished after its reporter left, so its \
outcome could not be returned"
);
}
})
.context("failed to spawn the agent worker serve thread")?;
tracing::info!(
candidates = %candidates,
task_queue = %queue,
identity = %settings.identity,
namespace = %namespace,
harness = harness.kind.name(),
cwd = %harness.launch_directory_report(),
"agent worker dialing liminal candidates"
);
let signal_stop = Arc::clone(&stop);
let signals = tokio::spawn(async move {
match shutdown_signal().await {
Ok(()) => {
tracing::info!(
"shutdown signal received; finishing in-flight agent runs before exit"
);
signal_stop.store(true, Ordering::SeqCst);
}
Err(error) => tracing::error!(
%error,
"cannot listen for a shutdown signal, so a termination will not drain"
),
}
});
let reported = ended
.await
.context("the agent worker serve thread ended without reporting an outcome");
signals.abort();
let joined = thread.join();
let outcome = reported?;
if joined.is_err() {
bail!("the agent worker serve thread terminated unexpectedly");
}
match outcome {
Ok(()) if stop.load(Ordering::SeqCst) => Ok(()),
Ok(()) => bail!(
"the agent worker serve loop for queue `{queue}` ended without a shutdown request"
),
Err(error) => Err(anyhow::Error::new(error)
.context(format!("agent worker on queue `{queue}` stopped serving"))),
}
}
#[cfg(all(any(feature = "norn", feature = "acp"), feature = "liminal-transport"))]
async fn shutdown_signal() -> Result<()> {
#[cfg(unix)]
{
use tokio::signal::unix::{SignalKind, signal};
let mut terminate =
signal(SignalKind::terminate()).context("failed to listen for SIGTERM")?;
let mut interrupt =
signal(SignalKind::interrupt()).context("failed to listen for SIGINT")?;
tokio::select! {
_ = terminate.recv() => Ok(()),
_ = interrupt.recv() => Ok(()),
}
}
#[cfg(not(unix))]
{
tokio::signal::ctrl_c()
.await
.context("failed to listen for a shutdown signal")
}
}
#[cfg(not(any(feature = "norn", feature = "acp")))]
fn unservable(
surface: &AdvertisedSurface,
settings: &ConnectionSettings,
harness: Option<&ResolvedHarness>,
namespace: &str,
) -> anyhow::Error {
anyhow::anyhow!(
"this `aion` build has NO agent harness compiled in (--no-default-features without \
`norn`/`acp`), so it cannot serve queue `{}`{} ({}) as `{}` in namespace `{namespace}` \
on {}. Rebuild with the default features, or with `--features norn` / \
`--features acp`.",
surface.task_queue,
describe_harness(harness),
describe_actions(surface),
settings.identity,
settings.addresses.join(", ")
)
}
#[cfg(all(
any(feature = "norn", feature = "acp"),
not(feature = "liminal-transport")
))]
fn unservable(
surface: &AdvertisedSurface,
settings: &ConnectionSettings,
harness: Option<&ResolvedHarness>,
namespace: &str,
) -> anyhow::Error {
anyhow::anyhow!(
"this `aion` build composes an agent harness but has NO liminal worker transport \
compiled in, and the agent-harness seam exists only on that transport — so queue `{}`{} \
({}) cannot be served as `{}` in namespace `{namespace}` on {}. Rebuild with the \
default features, or add `--features liminal-transport`.",
surface.task_queue,
describe_harness(harness),
describe_actions(surface),
settings.identity,
settings.addresses.join(", ")
)
}
#[cfg(not(all(any(feature = "norn", feature = "acp"), feature = "liminal-transport")))]
fn describe_harness(harness: Option<&ResolvedHarness>) -> String {
harness.map_or_else(String::new, |resolved| {
format!(" with the `{}` harness", resolved.kind.name())
})
}
#[cfg(not(all(any(feature = "norn", feature = "acp"), feature = "liminal-transport")))]
fn describe_actions(surface: &AdvertisedSurface) -> String {
format!(
"{} action(s): {}",
surface.descriptors.len(),
surface.names().into_iter().collect::<Vec<_>>().join(", ")
)
}