feat(config): load and validate configuration in every binary
The invariants in CLAUDE.md were tested functions nobody called. There was no config type at all: --config was accepted and ignored by all three binaries, figment was an unused dependency, and the 124-line config.toml.tmpl was aspirational. assert_not_anthropic could not run at startup because nothing read the provider it checks. Add tireless_core::config, validated on construction so a Config in hand is a checked one, and tireless_agent::preflight, called by the worker at startup and by `tireless preflight` on demand — the same function, so the two cannot disagree. Move the Anthropic guard to tireless_core::policy where validate() can reach it; tireless_agent::opencode re-exports it so the documented path resolves. Also make /v1/ready honest. It returned "ok" unconditionally while its own doc comment promised dependency checks, so the deploy probe greened on a process that could do nothing. It now reports per-dependency state, with unwired ones saying not_implemented rather than ok. Tests parse the shipped template rather than a fixture, so template and code cannot drift apart silently. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013TxK1CWPkFXqdcXMJ4hVe6
This commit is contained in:
@@ -19,7 +19,9 @@
|
||||
pub mod checkout;
|
||||
pub mod claude;
|
||||
pub mod opencode;
|
||||
pub mod preflight;
|
||||
|
||||
pub use checkout::Checkout;
|
||||
pub use claude::ClaudeCodeExecutor;
|
||||
pub use opencode::{OpencodeExecutor, assert_not_anthropic};
|
||||
pub use preflight::Report as PreflightReport;
|
||||
|
||||
@@ -12,7 +12,11 @@
|
||||
//! uses the first-party binary. [`assert_not_anthropic`] enforces this at
|
||||
//! startup so the rule survives a config edit.
|
||||
|
||||
use tireless_entities::Error;
|
||||
/// The Anthropic guard now lives in [`tireless_core::policy`], where
|
||||
/// `Config::validate` can call it. Re-exported here because this is where a
|
||||
/// reader looking for it will come first, and because the constraint is about
|
||||
/// this lane even though the enforcement is about configuration.
|
||||
pub use tireless_core::policy::assert_not_anthropic;
|
||||
|
||||
/// Pinned for the same reason as the Claude Code package.
|
||||
pub const OPENCODE_PACKAGE: &str = "opencode-ai@1.4.7";
|
||||
@@ -28,74 +32,17 @@ pub struct OpencodeExecutor {
|
||||
pub timeout_seconds: u64,
|
||||
}
|
||||
|
||||
/// Provider identifiers that indicate an Anthropic backend.
|
||||
const ANTHROPIC_MARKERS: &[&str] = &["anthropic", "claude"];
|
||||
|
||||
/// Refuse to start if the OpenCode lane is configured against Anthropic.
|
||||
///
|
||||
/// Checked on both the provider id and the base URL host, because either can
|
||||
/// carry the intent. This is a terms-of-service constraint expressed as code:
|
||||
/// the comment explaining it can rot, a failing startup assertion cannot.
|
||||
pub fn assert_not_anthropic(provider: &str, base_url: &str) -> Result<(), Error> {
|
||||
let provider_lc = provider.to_ascii_lowercase();
|
||||
let url_lc = base_url.to_ascii_lowercase();
|
||||
|
||||
// Match provider ids on token boundaries so a self-hosted provider that
|
||||
// merely *serves* a Claude-compatible surface is not caught by accident,
|
||||
// while `anthropic`, `anthropic-oauth` and `claude-max` all are.
|
||||
let provider_hits = ANTHROPIC_MARKERS.iter().any(|m| {
|
||||
provider_lc
|
||||
.split(|c: char| !c.is_ascii_alphanumeric())
|
||||
.any(|part| part == *m)
|
||||
});
|
||||
|
||||
// The URL check is host-based: `api.anthropic.com` is disqualifying wherever
|
||||
// it appears, but a local gateway is not.
|
||||
let url_hits = url_lc.contains("anthropic.com");
|
||||
|
||||
if provider_hits || url_hits {
|
||||
return Err(Error::AnthropicViaOpencode(provider.to_string()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_house_helexa_backend_is_accepted() {
|
||||
assert!(assert_not_anthropic("lair-helexa", "http://hanzalova.internal:31313/v1").is_ok());
|
||||
assert!(
|
||||
assert_not_anthropic("lmstudio", "http://beast.hanzalova.internal:1234/v1").is_ok()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anthropic_providers_are_refused() {
|
||||
for provider in [
|
||||
"anthropic",
|
||||
"Anthropic",
|
||||
"anthropic-oauth",
|
||||
"claude",
|
||||
"claude-max",
|
||||
] {
|
||||
assert!(
|
||||
assert_not_anthropic(provider, "http://localhost/v1").is_err(),
|
||||
"{provider} should be refused"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_anthropic_api_host_is_refused_whatever_the_provider_is_called() {
|
||||
assert!(assert_not_anthropic("totally-fine", "https://api.anthropic.com/v1").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_local_gateway_serving_an_anthropic_compatible_surface_is_fine() {
|
||||
// cortex presents both OpenAI and Anthropic compatible APIs, but it is
|
||||
// local inference — no Anthropic subscription is involved.
|
||||
fn the_lane_guard_is_reachable_from_here() {
|
||||
// The implementation and its cases are tested in `tireless_core::policy`.
|
||||
// This asserts only that the documented path still resolves, so a reader
|
||||
// following `doc/plan/design.md` §3.3 or CLAUDE.md invariant 4 lands
|
||||
// somewhere real.
|
||||
assert!(assert_not_anthropic("lair-helexa", "http://hanzalova.internal:31313/v1").is_ok());
|
||||
assert!(assert_not_anthropic("anthropic", "http://localhost/v1").is_err());
|
||||
}
|
||||
}
|
||||
|
||||
138
crates/tireless-agent/src/preflight.rs
Normal file
138
crates/tireless-agent/src/preflight.rs
Normal file
@@ -0,0 +1,138 @@
|
||||
//! Everything checked before a service is allowed to start.
|
||||
//!
|
||||
//! One function, called by `tireless-worker` at startup and by `tireless
|
||||
//! preflight` on demand, so that what an operator can verify by hand is exactly
|
||||
//! what the service verifies for itself. A check that only one of them runs is a
|
||||
//! check that will disagree with the other at the worst moment.
|
||||
//!
|
||||
//! This module is why the constraints in `CLAUDE.md` are true rather than
|
||||
//! merely written down. Each one below names the invariant it enforces.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use tireless_core::{Config, PromptSet};
|
||||
use tireless_entities::{BillingMode, Error};
|
||||
|
||||
/// What preflight found. Everything here is either fatal — in which case
|
||||
/// [`run`] returned `Err` and this does not exist — or something the operator
|
||||
/// should be able to read off the journal's first lines.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Report {
|
||||
/// Which billing mode a Claude Code run will use, derived from the
|
||||
/// environment tireless is about to hand over.
|
||||
pub billing: BillingMode,
|
||||
/// Service account home, where Claude Code keeps its credentials.
|
||||
pub home: PathBuf,
|
||||
/// Whether a credential store exists. Not fatal for the poller, which spends
|
||||
/// nothing; fatal for the runner, which cannot work without it.
|
||||
pub claude_credentials: bool,
|
||||
/// OpenCode lane target, already asserted non-Anthropic by `Config::validate`.
|
||||
pub oc_provider: String,
|
||||
pub oc_model: String,
|
||||
/// True when the prompt set came from files on disk rather than the binary.
|
||||
pub prompts_overridden: bool,
|
||||
}
|
||||
|
||||
impl Report {
|
||||
/// Write the report to the journal. Called once, at startup, so that "what
|
||||
/// was this service actually configured to do" is answerable from logs
|
||||
/// alone rather than by reading a config file on a host.
|
||||
pub fn log(&self) {
|
||||
match self.billing {
|
||||
// Deliberately a warning: it means the subscription this whole
|
||||
// architecture exists to use is sitting unused, which is far more
|
||||
// likely to be an accident than a decision.
|
||||
BillingMode::ApiKey => tracing::warn!(
|
||||
"ANTHROPIC_API_KEY is set: Claude Code runs will bill \
|
||||
pay-as-you-go, not the subscription"
|
||||
),
|
||||
mode => tracing::info!(?mode, home = %self.home.display(), "Claude Code billing mode"),
|
||||
}
|
||||
tracing::info!(
|
||||
provider = %self.oc_provider,
|
||||
model = %self.oc_model,
|
||||
"OpenCode lane target (asserted non-Anthropic)"
|
||||
);
|
||||
if self.prompts_overridden {
|
||||
tracing::warn!(
|
||||
"system prompts are overridden from disk; the running behaviour is \
|
||||
not the behaviour of this build"
|
||||
);
|
||||
}
|
||||
if !self.claude_credentials {
|
||||
tracing::warn!(
|
||||
home = %self.home.display(),
|
||||
"no Claude Code credential store; the runner will refuse to start"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Load and check everything, or refuse.
|
||||
///
|
||||
/// Enforces, in order:
|
||||
///
|
||||
/// 1. **Invariant 4** — the OpenCode lane is not pointed at Anthropic.
|
||||
/// [`Config::validate`] calls
|
||||
/// [`tireless_core::policy::assert_not_anthropic`], so this cannot be
|
||||
/// bypassed by any caller holding a `Config`.
|
||||
/// 2. **Invariant 8** — the prompt set is internally consistent, including any
|
||||
/// file overrides.
|
||||
/// 3. **Invariants 2 and 3** — the credential store is *stat'd, never read*, and
|
||||
/// `ANTHROPIC_API_KEY` is only ever observed, never set.
|
||||
pub fn run(config_path: &str) -> Result<Report, Error> {
|
||||
let config = Config::load(config_path)?;
|
||||
|
||||
let prompts = PromptSet::resolve(&config.prompt)?;
|
||||
// Touch the loaded set so a future refactor cannot quietly stop using it.
|
||||
debug_assert!(!prompts.plan_cc.is_empty());
|
||||
|
||||
// Invariant 3: tireless *observes* this variable and never sets it. Its
|
||||
// presence selects pay-as-you-go; its absence selects the subscription. That
|
||||
// choice belongs to whoever wrote the unit environment file.
|
||||
let has_key = std::env::var_os("ANTHROPIC_API_KEY").is_some();
|
||||
|
||||
let home = std::env::var_os("HOME")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| config.work.root.clone());
|
||||
|
||||
Ok(Report {
|
||||
billing: crate::claude::expected_billing(has_key),
|
||||
// Invariant 2: this stats the file. Its contents are Claude Code's
|
||||
// business, and tireless has no reason ever to open it.
|
||||
claude_credentials: crate::claude::has_credentials(&home) || has_key,
|
||||
home,
|
||||
oc_provider: config.lane.oc.provider.clone(),
|
||||
oc_model: config.lane.oc.model.clone(),
|
||||
prompts_overridden: config.prompt.any(),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_missing_config_is_a_refusal_not_a_default() {
|
||||
// Starting on built-in defaults when the operator's config is absent
|
||||
// would mean a service running against settings nobody chose — including
|
||||
// a database and a forge it was never pointed at.
|
||||
assert!(run("/nonexistent/tireless/config.toml").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_api_key_counts_as_credentials_without_a_login() {
|
||||
// Pay-as-you-go is a supported mode (design.md §3.2), so a runner with a
|
||||
// key but no interactive login must be allowed to start. The reverse of
|
||||
// this test is what the runner's own bail covers.
|
||||
let report = Report {
|
||||
billing: BillingMode::ApiKey,
|
||||
home: PathBuf::from("/nonexistent"),
|
||||
claude_credentials: true,
|
||||
oc_provider: "lair-helexa".into(),
|
||||
oc_model: "Qwen/Qwen3.6-27B".into(),
|
||||
prompts_overridden: false,
|
||||
};
|
||||
assert!(report.claude_credentials);
|
||||
}
|
||||
}
|
||||
@@ -6,8 +6,9 @@
|
||||
use std::net::SocketAddr;
|
||||
|
||||
use anyhow::Context;
|
||||
use axum::{Router, routing::get};
|
||||
use axum::{Json, Router, extract::State, http::StatusCode, routing::get};
|
||||
use clap::Parser;
|
||||
use serde::Serialize;
|
||||
|
||||
/// Registered in `architecture/port-allocations.md`.
|
||||
const DEFAULT_PORT: u16 = 23296;
|
||||
@@ -27,9 +28,17 @@ async fn main() -> anyhow::Result<()> {
|
||||
init_tracing();
|
||||
let args = Args::parse();
|
||||
|
||||
// Validated here so a bad config fails the deploy at service start, next to
|
||||
// the journal that explains why, rather than on the first request.
|
||||
let config = tireless_core::Config::load(&args.config)
|
||||
.with_context(|| format!("invalid configuration at {}", args.config))?;
|
||||
|
||||
let app = Router::new()
|
||||
.route("/v1/health", get(health))
|
||||
.route("/v1/ready", get(ready));
|
||||
.route("/v1/ready", get(ready))
|
||||
.with_state(AppState {
|
||||
database_configured: !config.database.host.is_empty(),
|
||||
});
|
||||
|
||||
let addr: SocketAddr = args.bind.parse().context("invalid bind address")?;
|
||||
let listener = tokio::net::TcpListener::bind(addr)
|
||||
@@ -47,15 +56,68 @@ async fn main() -> anyhow::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct AppState {
|
||||
database_configured: bool,
|
||||
}
|
||||
|
||||
/// Liveness: the process is up and serving. Nothing more is claimed.
|
||||
async fn health() -> &'static str {
|
||||
"ok"
|
||||
}
|
||||
|
||||
/// Readiness is distinct from liveness: it reports whether dependencies (the
|
||||
/// database, the forge) are reachable, so a deploy health-probe fails loudly
|
||||
/// rather than greening on a process that cannot do any work.
|
||||
async fn ready() -> &'static str {
|
||||
"ok"
|
||||
/// Readiness is distinct from liveness: it reports whether the dependencies this
|
||||
/// build actually has are usable, so a deploy probe fails loudly rather than
|
||||
/// greening on a process that cannot do any work.
|
||||
///
|
||||
/// Each dependency reports its own state, and unimplemented ones say
|
||||
/// `not_implemented` rather than `ok`. A probe that returns a blanket "ok"
|
||||
/// before anything is wired teaches an operator to trust a signal that is not
|
||||
/// yet measuring anything — and the day it starts measuring is the day it stops
|
||||
/// being believed.
|
||||
async fn ready(State(state): State<AppState>) -> (StatusCode, Json<Readiness>) {
|
||||
let checks = Readiness {
|
||||
// Loading it was a precondition of starting, so reaching here proves it.
|
||||
config: Check::Ok,
|
||||
database: if state.database_configured {
|
||||
// Stage 1 replaces this with a real round-trip against the pool.
|
||||
Check::NotImplemented
|
||||
} else {
|
||||
Check::Failed
|
||||
},
|
||||
forge: Check::NotImplemented,
|
||||
};
|
||||
let code = if checks.serving() {
|
||||
StatusCode::OK
|
||||
} else {
|
||||
StatusCode::SERVICE_UNAVAILABLE
|
||||
};
|
||||
(code, Json(checks))
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct Readiness {
|
||||
config: Check,
|
||||
database: Check,
|
||||
forge: Check,
|
||||
}
|
||||
|
||||
impl Readiness {
|
||||
/// Whether the API can serve. A `NotImplemented` dependency does not block
|
||||
/// readiness — the endpoints that need it do not exist yet either — but it
|
||||
/// is reported so nobody mistakes silence for health.
|
||||
fn serving(&self) -> bool {
|
||||
!matches!(self.config, Check::Failed) && !matches!(self.database, Check::Failed)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
enum Check {
|
||||
Ok,
|
||||
Failed,
|
||||
/// Wired in a later stage. See doc/plan/design.md §7.
|
||||
NotImplemented,
|
||||
}
|
||||
|
||||
/// JSON logs under systemd, pretty logs on a TTY (§12 Observability).
|
||||
|
||||
@@ -86,8 +86,66 @@ enum JobAction {
|
||||
fn main() -> anyhow::Result<()> {
|
||||
tracing_subscriber::fmt().with_env_filter("info").init();
|
||||
let args = Args::parse();
|
||||
// Subcommand handlers land alongside the stages that give them something to
|
||||
// talk to; see doc/plan/design.md §7.
|
||||
println!("{:?} (config: {})", args.command, args.config);
|
||||
|
||||
match args.command {
|
||||
Command::Preflight => preflight(&args.config),
|
||||
// Remaining handlers land alongside the stages that give them something
|
||||
// to talk to; see doc/plan/design.md §7. Until then, say so rather than
|
||||
// printing something that looks like output.
|
||||
other => {
|
||||
anyhow::bail!(
|
||||
"`{}` is not implemented yet — see doc/plan/design.md §7 for which \
|
||||
stage lands it. `tireless preflight` works today.",
|
||||
subcommand_name(&other)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn subcommand_name(c: &Command) -> &'static str {
|
||||
match c {
|
||||
Command::Repo { .. } => "repo",
|
||||
Command::Job { .. } => "job",
|
||||
Command::Lanes => "lanes",
|
||||
Command::Preflight => "preflight",
|
||||
}
|
||||
}
|
||||
|
||||
/// Verify configuration and credentials without starting a service.
|
||||
///
|
||||
/// Runs exactly the checks `tireless-worker` runs at startup, by calling the
|
||||
/// same function — so "it passes preflight but the service will not start" is
|
||||
/// not a state that can exist.
|
||||
fn preflight(config: &str) -> anyhow::Result<()> {
|
||||
let report = tireless_agent::preflight::run(config)?;
|
||||
|
||||
println!("config {config}");
|
||||
println!("claude billing {:?}", report.billing);
|
||||
println!("claude home {}", report.home.display());
|
||||
println!(
|
||||
"claude login {}",
|
||||
if report.claude_credentials {
|
||||
"present"
|
||||
} else {
|
||||
"MISSING — the runner will refuse to start"
|
||||
}
|
||||
);
|
||||
println!(
|
||||
"opencode lane {} / {} (asserted non-Anthropic)",
|
||||
report.oc_provider, report.oc_model
|
||||
);
|
||||
println!(
|
||||
"system prompts {}",
|
||||
if report.prompts_overridden {
|
||||
"overridden from disk"
|
||||
} else {
|
||||
"built in"
|
||||
}
|
||||
);
|
||||
|
||||
if !report.claude_credentials {
|
||||
anyhow::bail!("preflight failed: no Claude Code credentials and no API key");
|
||||
}
|
||||
println!("\npreflight ok");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -12,7 +12,9 @@ tireless-entities = { workspace = true }
|
||||
|
||||
async-trait = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
figment = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
toml = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
|
||||
547
crates/tireless-core/src/config.rs
Normal file
547
crates/tireless-core/src/config.rs
Normal file
@@ -0,0 +1,547 @@
|
||||
//! The configuration every binary reads, and the checks it must pass.
|
||||
//!
|
||||
//! One type, loaded identically by `tireless-api`, `tireless-worker` and
|
||||
//! `tireless-cli`, because a constraint enforced by whichever binary remembered
|
||||
//! to call it is not enforced. [`Config::load`] validates before returning, so
|
||||
//! there is no way to hold a `Config` that has not been checked.
|
||||
//!
|
||||
//! ## What is here and what is not
|
||||
//!
|
||||
//! Values that decide *behaviour* live here and are reviewable in
|
||||
//! `/etc/tireless/config.toml`. **Secrets do not.** Tokens are named by the
|
||||
//! environment variable that carries them ([`ForgeGitea::token_env`]) rather
|
||||
//! than being written down, and `ANTHROPIC_API_KEY` appears nowhere in this file
|
||||
//! at all: tireless never sets it, and its presence in the unit environment is
|
||||
//! the operator's way of selecting pay-as-you-go over the subscription (see
|
||||
//! `doc/plan/design.md` §3.2).
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use chrono::Duration;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tireless_entities::{AgentKind, Error, LabelProtocol};
|
||||
|
||||
use crate::budget::LaneBudget;
|
||||
use crate::policy::assert_not_anthropic;
|
||||
|
||||
/// The whole of tireless's configuration.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Config {
|
||||
#[serde(default)]
|
||||
pub api: Api,
|
||||
pub database: Database,
|
||||
pub forge: Forge,
|
||||
#[serde(default)]
|
||||
pub poll: Poll,
|
||||
#[serde(default)]
|
||||
pub labels: LabelProtocol,
|
||||
#[serde(default)]
|
||||
pub prompt: PromptOverrides,
|
||||
#[serde(default)]
|
||||
pub work: Work,
|
||||
#[serde(default)]
|
||||
pub discover: Discover,
|
||||
pub lane: Lanes,
|
||||
#[serde(default)]
|
||||
pub quiet: Quiet,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Api {
|
||||
/// Loopback by default; nginx fronts it. See `asset/nginx/` for which host
|
||||
/// that nginx runs on and what it implies for this value.
|
||||
pub bind: String,
|
||||
}
|
||||
|
||||
impl Default for Api {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
bind: "127.0.0.1:23296".into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// mTLS, passwordless (`architecture/generic.md` §5). There is deliberately no
|
||||
/// password field; if one ever seems necessary, that is a signal to revisit the
|
||||
/// ident mapping instead.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Database {
|
||||
pub host: String,
|
||||
pub port: u16,
|
||||
pub database: String,
|
||||
pub user: String,
|
||||
pub client_cert: PathBuf,
|
||||
pub client_key: PathBuf,
|
||||
pub root_cert: PathBuf,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Forge {
|
||||
pub gitea: ForgeGitea,
|
||||
#[serde(default)]
|
||||
pub github: ForgeGitHub,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ForgeGitea {
|
||||
pub base_url: String,
|
||||
/// Name of the environment variable holding the bot account's token. The
|
||||
/// token itself is never written to config — it comes from
|
||||
/// `/etc/tireless/tireless.env`, 0640 root:tireless.
|
||||
pub token_env: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ForgeGitHub {
|
||||
pub enabled: bool,
|
||||
pub token_env: String,
|
||||
}
|
||||
|
||||
impl Default for ForgeGitHub {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
token_env: "GITHUB_TOKEN".into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Poll {
|
||||
/// Floor on per-repo poll interval. A repo may ask for a longer interval but
|
||||
/// not a shorter one — being unattended is not a licence to hammer a forge.
|
||||
pub min_interval_seconds: u32,
|
||||
pub default_interval_seconds: u32,
|
||||
/// Jitter added per repo so N repos do not all fire together.
|
||||
pub jitter_seconds: u32,
|
||||
}
|
||||
|
||||
impl Default for Poll {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
min_interval_seconds: 120,
|
||||
default_interval_seconds: 300,
|
||||
jitter_seconds: 30,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Poll {
|
||||
/// Apply the floor to a requested interval.
|
||||
pub fn clamp(&self, requested: u32) -> u32 {
|
||||
requested.max(self.min_interval_seconds)
|
||||
}
|
||||
}
|
||||
|
||||
/// Paths to prompt files that override the ones compiled into the binary.
|
||||
///
|
||||
/// Useful for iterating on plan quality without a redeploy. An override must
|
||||
/// declare the same `contract-version:` as the build, and the whole set is
|
||||
/// loaded together — see [`crate::prompt::PromptSet`].
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct PromptOverrides {
|
||||
pub discover_cc: Option<PathBuf>,
|
||||
pub plan_cc: Option<PathBuf>,
|
||||
pub implement_oc: Option<PathBuf>,
|
||||
pub implement_cc: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl PromptOverrides {
|
||||
pub fn any(&self) -> bool {
|
||||
self.discover_cc.is_some()
|
||||
|| self.plan_cc.is_some()
|
||||
|| self.implement_oc.is_some()
|
||||
|| self.implement_cc.is_some()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Work {
|
||||
/// Per-repo bare mirrors and per-job clones live here.
|
||||
pub root: PathBuf,
|
||||
/// Keep a failed job's clone this long so it can be inspected. Successful
|
||||
/// jobs are cleaned immediately.
|
||||
pub failed_retention_hours: u32,
|
||||
}
|
||||
|
||||
impl Default for Work {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
root: PathBuf::from("/var/lib/tireless"),
|
||||
failed_retention_hours: 72,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Cadence for the discovery lane.
|
||||
///
|
||||
/// Discovery is anchored to a tracking issue (`doc/plan/design.md` §2.6), so
|
||||
/// unlike planning and implementation it is *recurring* against the same issue.
|
||||
/// The cooldown is what stops it re-enqueueing on every poll: a survey that ran
|
||||
/// this morning has nothing new to say this afternoon, and asking it to look
|
||||
/// again costs a run from the most expensive lane in the system.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Discover {
|
||||
pub cooldown_hours: u32,
|
||||
/// Upper bound on proposals a single survey may open. A survey that wants to
|
||||
/// file forty issues has misunderstood the job, and the operator should find
|
||||
/// that out from a truncated list plus a note, not from their inbox.
|
||||
pub max_proposals_per_run: u32,
|
||||
}
|
||||
|
||||
impl Default for Discover {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
cooldown_hours: 168, // weekly
|
||||
max_proposals_per_run: 8,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Lanes {
|
||||
pub cc: LaneCc,
|
||||
pub oc: LaneOc,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct LaneCc {
|
||||
pub max_concurrent: u32,
|
||||
pub max_runs_per_window: u32,
|
||||
pub window_hours: u32,
|
||||
pub timeout_seconds: u64,
|
||||
pub model: String,
|
||||
pub failure_threshold: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct LaneOc {
|
||||
/// OpenCode provider id. Never an Anthropic one — see
|
||||
/// [`crate::policy::assert_not_anthropic`].
|
||||
pub provider: String,
|
||||
pub base_url: String,
|
||||
/// A model *name*, never a capability alias. An alias that starts resolving
|
||||
/// elsewhere would change how tireless implements plans between one job and
|
||||
/// the next, with no deploy and no signal.
|
||||
pub model: String,
|
||||
pub surface: OcSurface,
|
||||
pub max_concurrent: u32,
|
||||
pub max_runs_per_window: u32,
|
||||
pub window_hours: u32,
|
||||
pub timeout_seconds: u64,
|
||||
pub failure_threshold: u32,
|
||||
}
|
||||
|
||||
/// Which helexa API surface the OpenCode lane talks to.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum OcSurface {
|
||||
/// Preferred. `/no_think` is honoured here.
|
||||
ChatCompletions,
|
||||
/// `/v1/responses`. `/no_think` is ignored, and a small output budget can be
|
||||
/// spent entirely on the reasoning block, yielding `""` with
|
||||
/// `status: "incomplete"` (helexa#223).
|
||||
Responses,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Quiet {
|
||||
/// Local time `HH:MM`. Both ends required if either is set.
|
||||
pub from: Option<String>,
|
||||
pub until: Option<String>,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
/// Read and validate a config file, with `TIRELESS_`-prefixed environment
|
||||
/// overrides layered on top.
|
||||
pub fn load(path: impl AsRef<Path>) -> Result<Self, Error> {
|
||||
use figment::{
|
||||
Figment,
|
||||
providers::{Env, Format, Toml},
|
||||
};
|
||||
|
||||
let path = path.as_ref();
|
||||
let config: Self = Figment::new()
|
||||
.merge(Toml::file(path))
|
||||
.merge(Env::prefixed("TIRELESS_").split("__"))
|
||||
.extract()
|
||||
.map_err(|e| Error::Config(format!("{}: {e}", path.display())))?;
|
||||
|
||||
config.validate()?;
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
/// Parse from a string without touching the filesystem. For tests, and for
|
||||
/// anything that wants to check a candidate config before installing it.
|
||||
pub fn from_toml_str(s: &str) -> Result<Self, Error> {
|
||||
use figment::{
|
||||
Figment,
|
||||
providers::{Format, Toml},
|
||||
};
|
||||
|
||||
let config: Self = Figment::new()
|
||||
.merge(Toml::string(s))
|
||||
.extract()
|
||||
.map_err(|e| Error::Config(e.to_string()))?;
|
||||
config.validate()?;
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
/// Every check a configuration must pass before tireless will act on it.
|
||||
///
|
||||
/// Called by both constructors, so a `Config` in hand is a validated one.
|
||||
/// Failures here are startup failures by design: an unattended service that
|
||||
/// starts "successfully" and then cannot do anything, or does the wrong
|
||||
/// thing quietly, is worse than one that refuses to start.
|
||||
pub fn validate(&self) -> Result<(), Error> {
|
||||
// Terms of service. Not negotiable, and checked before anything else so
|
||||
// a config that breaches it cannot be partially accepted.
|
||||
assert_not_anthropic(&self.lane.oc.provider, &self.lane.oc.base_url)?;
|
||||
|
||||
if self.poll.min_interval_seconds == 0 {
|
||||
return Err(Error::Config(
|
||||
"poll.min_interval_seconds must be greater than zero; a floor of \
|
||||
zero is not a floor"
|
||||
.into(),
|
||||
));
|
||||
}
|
||||
if self.poll.default_interval_seconds < self.poll.min_interval_seconds {
|
||||
return Err(Error::Config(format!(
|
||||
"poll.default_interval_seconds ({}) is below poll.min_interval_seconds \
|
||||
({}), so the default would be silently clamped on every repo",
|
||||
self.poll.default_interval_seconds, self.poll.min_interval_seconds
|
||||
)));
|
||||
}
|
||||
|
||||
for (lane, concurrent, window_hours, threshold) in [
|
||||
(
|
||||
"cc",
|
||||
self.lane.cc.max_concurrent,
|
||||
self.lane.cc.window_hours,
|
||||
self.lane.cc.failure_threshold,
|
||||
),
|
||||
(
|
||||
"oc",
|
||||
self.lane.oc.max_concurrent,
|
||||
self.lane.oc.window_hours,
|
||||
self.lane.oc.failure_threshold,
|
||||
),
|
||||
] {
|
||||
if concurrent == 0 {
|
||||
return Err(Error::Config(format!(
|
||||
"lane.{lane}.max_concurrent is zero, which stops the lane \
|
||||
silently; disable it deliberately instead"
|
||||
)));
|
||||
}
|
||||
if window_hours == 0 {
|
||||
return Err(Error::Config(format!(
|
||||
"lane.{lane}.window_hours is zero, so the run budget would \
|
||||
never apply"
|
||||
)));
|
||||
}
|
||||
if threshold == 0 {
|
||||
return Err(Error::Config(format!(
|
||||
"lane.{lane}.failure_threshold is zero, which trips the \
|
||||
circuit breaker before the first run"
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
// A half-specified quiet window is almost certainly a typo, and the
|
||||
// failure it produces — running around the clock when the operator
|
||||
// believed otherwise — is invisible until the bill arrives.
|
||||
match (&self.quiet.from, &self.quiet.until) {
|
||||
(Some(_), None) | (None, Some(_)) => {
|
||||
return Err(Error::Config(
|
||||
"quiet window needs both `from` and `until`, or neither".into(),
|
||||
));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
if self.discover.max_proposals_per_run == 0 {
|
||||
return Err(Error::Config(
|
||||
"discover.max_proposals_per_run is zero, so every survey would \
|
||||
discard its own output"
|
||||
.into(),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The governor budget for a lane, as configured.
|
||||
pub fn lane_budget(&self, lane: AgentKind) -> LaneBudget {
|
||||
match lane {
|
||||
AgentKind::ClaudeCode => LaneBudget {
|
||||
lane,
|
||||
max_concurrent: self.lane.cc.max_concurrent,
|
||||
max_runs_per_window: self.lane.cc.max_runs_per_window,
|
||||
window: Duration::hours(self.lane.cc.window_hours as i64),
|
||||
},
|
||||
AgentKind::Opencode => LaneBudget {
|
||||
lane,
|
||||
max_concurrent: self.lane.oc.max_concurrent,
|
||||
max_runs_per_window: self.lane.oc.max_runs_per_window,
|
||||
window: Duration::hours(self.lane.oc.window_hours as i64),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub fn failure_threshold(&self, lane: AgentKind) -> u32 {
|
||||
match lane {
|
||||
AgentKind::ClaudeCode => self.lane.cc.failure_threshold,
|
||||
AgentKind::Opencode => self.lane.oc.failure_threshold,
|
||||
}
|
||||
}
|
||||
|
||||
/// libpq-style connection URL. Certs rotate every 24h (`generic.md` §11), so
|
||||
/// callers must be able to re-establish connections rather than assume a
|
||||
/// stable pool.
|
||||
pub fn database_url(&self) -> String {
|
||||
let d = &self.database;
|
||||
format!(
|
||||
"postgres://{user}@{host}:{port}/{db}\
|
||||
?sslmode=verify-full\
|
||||
&sslcert={cert}&sslkey={key}&sslrootcert={root}",
|
||||
user = d.user,
|
||||
host = d.host,
|
||||
port = d.port,
|
||||
db = d.database,
|
||||
cert = d.client_cert.display(),
|
||||
key = d.client_key.display(),
|
||||
root = d.root_cert.display(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The shipped template, with the deploy workflow's placeholder substituted.
|
||||
/// Using the real template rather than a fixture means a field added to one
|
||||
/// and not the other fails this test.
|
||||
fn shipped_template() -> String {
|
||||
include_str!("../../../asset/config/config.toml.tmpl")
|
||||
.replace("{{DEPLOY_HOST_FQDN}}", "bob.hanzalova.internal")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_shipped_template_is_a_valid_config() {
|
||||
// This is the file the deploy workflow renders and every unit reads. If
|
||||
// it does not parse, the first anyone knows is three services failing to
|
||||
// start on a host, with the previous binaries already replaced.
|
||||
Config::from_toml_str(&shipped_template()).expect("shipped template must load");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_template_matches_the_conservative_defaults() {
|
||||
// The template is documentation as much as configuration. If it drifts
|
||||
// from what the code considers sane, it teaches the wrong thing.
|
||||
let c = Config::from_toml_str(&shipped_template()).expect("load");
|
||||
let cc = c.lane_budget(AgentKind::ClaudeCode);
|
||||
let expected = LaneBudget::conservative(AgentKind::ClaudeCode);
|
||||
assert_eq!(cc.max_concurrent, expected.max_concurrent);
|
||||
assert_eq!(cc.max_runs_per_window, expected.max_runs_per_window);
|
||||
assert_eq!(cc.window, expected.window);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_config_pointing_opencode_at_anthropic_is_refused() {
|
||||
// Invariant 4. The whole point of putting this in validate() is that no
|
||||
// binary can forget to call it.
|
||||
let bad =
|
||||
shipped_template().replace(r#"provider = "lair-helexa""#, r#"provider = "anthropic""#);
|
||||
let err = Config::from_toml_str(&bad).expect_err("must be refused");
|
||||
assert!(matches!(err, Error::AnthropicViaOpencode(_)), "got {err:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_config_pointing_opencode_at_the_anthropic_api_host_is_refused() {
|
||||
let bad = shipped_template().replace(
|
||||
r#"base_url = "http://hanzalova.internal:31313/v1""#,
|
||||
r#"base_url = "https://api.anthropic.com/v1""#,
|
||||
);
|
||||
assert!(Config::from_toml_str(&bad).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unknown_key_is_refused_rather_than_ignored() {
|
||||
// `deny_unknown_fields` throughout: a typo in a key an operator believed
|
||||
// was taking effect is a silent misconfiguration, which is the failure
|
||||
// mode this whole module exists to prevent.
|
||||
let bad = format!("{}\nnonsense_key = true\n", shipped_template());
|
||||
assert!(Config::from_toml_str(&bad).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_half_specified_quiet_window_is_refused() {
|
||||
let bad = format!("{}\n[quiet]\nfrom = \"23:00\"\n", shipped_template());
|
||||
assert!(Config::from_toml_str(&bad).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_default_interval_below_the_floor_is_refused() {
|
||||
let bad = shipped_template().replace(
|
||||
"default_interval_seconds = 300",
|
||||
"default_interval_seconds = 30",
|
||||
);
|
||||
assert!(Config::from_toml_str(&bad).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zeroed_lane_limits_are_refused() {
|
||||
for (from, to) in [
|
||||
("max_concurrent = 1", "max_concurrent = 0"),
|
||||
("failure_threshold = 3", "failure_threshold = 0"),
|
||||
("window_hours = 5", "window_hours = 0"),
|
||||
] {
|
||||
let bad = shipped_template().replacen(from, to, 1);
|
||||
assert!(
|
||||
Config::from_toml_str(&bad).is_err(),
|
||||
"{to} should be refused"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_poll_floor_is_applied_upward_only() {
|
||||
let p = Poll::default();
|
||||
assert_eq!(p.clamp(30), p.min_interval_seconds);
|
||||
assert_eq!(p.clamp(3600), 3600);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_database_url_asks_for_full_certificate_verification() {
|
||||
// sslmode below verify-full would accept a certificate for the wrong
|
||||
// host, which defeats the point of ident-mapped mTLS.
|
||||
let c = Config::from_toml_str(&shipped_template()).expect("load");
|
||||
assert!(c.database_url().contains("sslmode=verify-full"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_anthropic_api_key_field_exists_anywhere_in_config() {
|
||||
// Invariant 3: the variable reaches Claude Code only if an operator put
|
||||
// it in the unit environment. A config field for it would make that a
|
||||
// tireless decision, which it must never be.
|
||||
let rendered = toml::to_string(&Config::from_toml_str(&shipped_template()).expect("load"))
|
||||
.expect("serialize");
|
||||
assert!(!rendered.to_lowercase().contains("anthropic_api_key"));
|
||||
assert!(!rendered.to_lowercase().contains("api_key"));
|
||||
}
|
||||
}
|
||||
@@ -5,12 +5,16 @@
|
||||
//! `tireless-agent` (process orchestration).
|
||||
|
||||
pub mod budget;
|
||||
pub mod config;
|
||||
pub mod plan;
|
||||
pub mod policy;
|
||||
pub mod port;
|
||||
pub mod prompt;
|
||||
pub mod routing;
|
||||
|
||||
pub use budget::{Governor, LaneBudget, LimitSignal, Verdict};
|
||||
pub use config::Config;
|
||||
pub use plan::{PlanDefect, implementation_order, validate};
|
||||
pub use policy::assert_not_anthropic;
|
||||
pub use prompt::{PromptSet, SYSTEM_PROMPT_CONTRACT_VERSION};
|
||||
pub use routing::{RouteDecision, route};
|
||||
|
||||
89
crates/tireless-core/src/policy.rs
Normal file
89
crates/tireless-core/src/policy.rs
Normal file
@@ -0,0 +1,89 @@
|
||||
//! Constraints that are not ours to relax.
|
||||
//!
|
||||
//! Everything in this module encodes a term of service or a safety property
|
||||
//! rather than a design preference. They live in core, next to the config that
|
||||
//! carries the values they check, so that there is exactly one place a
|
||||
//! configuration is admitted — see [`crate::config::Config::validate`].
|
||||
//!
|
||||
//! They were previously enforced by whichever binary remembered to call them,
|
||||
//! which is a way of saying they were not enforced.
|
||||
|
||||
use tireless_entities::Error;
|
||||
|
||||
/// Provider identifiers that indicate an Anthropic backend.
|
||||
const ANTHROPIC_MARKERS: &[&str] = &["anthropic", "claude"];
|
||||
|
||||
/// Refuse a configuration that points the OpenCode lane at Anthropic.
|
||||
///
|
||||
/// OpenCode is a third-party harness with its own provider clients. Driving an
|
||||
/// Anthropic *subscription* through one is the pattern Anthropic blocked in
|
||||
/// January 2026, and OpenCode was named explicitly. Anthropic work goes through
|
||||
/// the Claude Code lane, which spawns the first-party binary and lets it
|
||||
/// authenticate itself.
|
||||
///
|
||||
/// Checked on both the provider id and the base URL, because either can carry
|
||||
/// the intent. This is a terms-of-service constraint expressed as code: the
|
||||
/// comment explaining it can rot, a failing startup assertion cannot.
|
||||
pub fn assert_not_anthropic(provider: &str, base_url: &str) -> Result<(), Error> {
|
||||
let provider_lc = provider.to_ascii_lowercase();
|
||||
let url_lc = base_url.to_ascii_lowercase();
|
||||
|
||||
// Match provider ids on token boundaries so a self-hosted provider that
|
||||
// merely *serves* a Claude-compatible surface is not caught by accident,
|
||||
// while `anthropic`, `anthropic-oauth` and `claude-max` all are.
|
||||
let provider_hits = ANTHROPIC_MARKERS.iter().any(|m| {
|
||||
provider_lc
|
||||
.split(|c: char| !c.is_ascii_alphanumeric())
|
||||
.any(|part| part == *m)
|
||||
});
|
||||
|
||||
// The URL check is host-based: `api.anthropic.com` is disqualifying wherever
|
||||
// it appears, but a local gateway is not.
|
||||
let url_hits = url_lc.contains("anthropic.com");
|
||||
|
||||
if provider_hits || url_hits {
|
||||
return Err(Error::AnthropicViaOpencode(provider.to_string()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_house_helexa_backend_is_accepted() {
|
||||
assert!(assert_not_anthropic("lair-helexa", "http://hanzalova.internal:31313/v1").is_ok());
|
||||
assert!(
|
||||
assert_not_anthropic("lmstudio", "http://beast.hanzalova.internal:1234/v1").is_ok()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anthropic_providers_are_refused() {
|
||||
for provider in [
|
||||
"anthropic",
|
||||
"Anthropic",
|
||||
"anthropic-oauth",
|
||||
"claude",
|
||||
"claude-max",
|
||||
] {
|
||||
assert!(
|
||||
assert_not_anthropic(provider, "http://localhost/v1").is_err(),
|
||||
"{provider} should be refused"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_anthropic_api_host_is_refused_whatever_the_provider_is_called() {
|
||||
assert!(assert_not_anthropic("totally-fine", "https://api.anthropic.com/v1").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_local_gateway_serving_an_anthropic_compatible_surface_is_fine() {
|
||||
// cortex presents both OpenAI and Anthropic compatible APIs, but it is
|
||||
// local inference — no Anthropic subscription is involved.
|
||||
assert!(assert_not_anthropic("lair-helexa", "http://hanzalova.internal:31313/v1").is_ok());
|
||||
}
|
||||
}
|
||||
@@ -43,7 +43,9 @@ async fn main() -> anyhow::Result<()> {
|
||||
// Fail loudly at startup, not on the first job: an unattended service that
|
||||
// starts "successfully" and then cannot do anything is worse than one that
|
||||
// refuses to start.
|
||||
preflight().context("preflight checks failed")?;
|
||||
let report = tireless_agent::preflight::run(&args.config)
|
||||
.context("preflight checks failed; refusing to start")?;
|
||||
report.log();
|
||||
|
||||
match args.role {
|
||||
Role::Poll => {
|
||||
@@ -53,6 +55,18 @@ async fn main() -> anyhow::Result<()> {
|
||||
let id = id.unwrap_or_else(|| {
|
||||
std::env::var("HOSTNAME").unwrap_or_else(|_| "tireless-runner".into())
|
||||
});
|
||||
// The runner is the half that spends tokens, so it is the half that
|
||||
// must have a working agent login. The poller does not need one and
|
||||
// is not held back by its absence.
|
||||
if !report.claude_credentials {
|
||||
anyhow::bail!(
|
||||
"no Claude Code credential store at {}/.claude.json, and no \
|
||||
ANTHROPIC_API_KEY in the environment. Complete the interactive \
|
||||
login as the service account (script/infra-setup.sh step 1) \
|
||||
before starting the runner.",
|
||||
report.home.display()
|
||||
);
|
||||
}
|
||||
tracing::info!(config = %args.config, worker = %id, "runner starting");
|
||||
}
|
||||
}
|
||||
@@ -62,25 +76,6 @@ async fn main() -> anyhow::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Startup assertions that encode constraints we cannot afford to rediscover at
|
||||
/// runtime. See doc/plan/design.md §3.
|
||||
fn preflight() -> anyhow::Result<()> {
|
||||
// Report which billing mode Claude Code will use, so it is visible in the
|
||||
// journal from the first line rather than inferred from an invoice later.
|
||||
let has_key = std::env::var_os("ANTHROPIC_API_KEY").is_some();
|
||||
match tireless_agent::claude::expected_billing(has_key) {
|
||||
tireless_entities::BillingMode::ApiKey => tracing::warn!(
|
||||
"ANTHROPIC_API_KEY is set: Claude Code runs will bill pay-as-you-go, \
|
||||
not the subscription"
|
||||
),
|
||||
mode => tracing::info!(?mode, "Claude Code billing mode"),
|
||||
}
|
||||
|
||||
// The OpenCode lane must never be pointed at Anthropic. Read from config in
|
||||
// stage 5; asserted here so the guard exists from the first commit.
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn init_tracing() {
|
||||
use tracing_subscriber::{EnvFilter, fmt};
|
||||
let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"));
|
||||
|
||||
Reference in New Issue
Block a user