mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 14:53:34 +00:00
* feat: complete multi-tenant isolation — per-user budgets, model selection, heartbeat cycling Finishes the remaining isolation work from phases 2–4 of #59: Phase 2 (DB scoping): Fix /status and /list commands to use _for_user DB variants instead of global queries that leaked cross-user job data. Phase 3 (Runtime isolation): Per-user workspace in routine engine's spawn_fire so lightweight routines run in the correct user context. Per-user daily cost tracking in CostGuard with configurable budget via MAX_COST_PER_USER_PER_DAY_CENTS. Multi-user heartbeat that cycles through all users with routines, auto-detected from GATEWAY_USER_TOKENS. Phase 4 (Provider/tools): Per-user model selection via preferred_model setting — looked up from SettingsStore on first iteration, threaded through ReasoningContext.model_override to CompletionRequest. Works with providers that support per-request model overrides (NearAI). Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: use selected_model setting key to match /model command persistence The dispatcher was reading "preferred_model" but the /model command (merged from staging) persists to "selected_model". Since set_setting is already per-user scoped, using the same key makes /model work as the per-user model override in multi-tenant mode. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: heartbeat hygiene, /model multi-tenant guard, RigAdapter model override Three follow-up fixes for multi-tenant isolation: 1. Multi-user heartbeat now runs memory hygiene per user before each heartbeat check, matching single-user heartbeat behavior. 2. /model command in multi-tenant mode only persists to per-user settings (selected_model) without calling set_model() on the shared LlmProvider. The per-request model_override in the dispatcher reads from the same setting. Added multi_tenant flag to AgentConfig (auto-detected from GATEWAY_USER_TOKENS). 3. RigAdapter now supports per-request model overrides by injecting the model name into rig-core's additional_params. OpenAI/Anthropic/Ollama API servers use last-key-wins for duplicate JSON keys, so the override takes effect via serde's flatten serialization order. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: address PR review — cost model attribution, heartbeat concurrency, pruning Fixes from review comments on #1614: - Cost tracking now uses the override model name (not active_model_name) when a per-user model override is active, for accurate attribution. - Multi-user heartbeat runs per-user checks concurrently via JoinSet instead of sequentially, preventing one slow user from blocking others. - Per-user failure counts tracked independently; users exceeding max_failures are skipped (matching single-user semantics). - per_user_daily_cost HashMap pruned on day rollover to prevent unbounded growth in long-lived deployments. - Doc comment fixed: says "routines" not "active routines". Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: /status ownership, model persistence scoping, heartbeat robustness Addresses second round of PR review on #1614: - /status <job_id> DB path now validates job.user_id == requesting user before returning data (was missing ownership check, security fix). - persist_selected_model takes user_id param instead of owner_id, and skips .env/TOML writes in multi-tenant mode (these are shared global files). handle_system_command now receives user_id from caller. - JoinSet collection handles Err(JoinError) explicitly instead of silently dropping panicked tasks. - Notification forwarder extracts owner_id from response metadata in multi-tenant mode for per-user routing instead of broadcasting to the agent owner. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: cost pricing, fire_manual workspace, heartbeat concurrency cap Round 3 review fixes: - Cost tracking passes None for cost_per_token when model override is active, letting CostGuard look up pricing by model name instead of using the default provider's rates (serrrfirat). - fire_manual() now uses per-user workspace, matching spawn_fire() pattern (serrrfirat). - Removed MULTI_TENANT env var — multi-tenant mode is auto-detected solely from GATEWAY_USER_TOKENS presence (serrrfirat + Copilot). - Multi-user heartbeat capped at 8 concurrent tasks to avoid flooding the LLM provider (serrrfirat + Copilot). - Fixed inject_model_override doc comment accuracy (Copilot). - Added comment explaining multi-tenant notification routing priority (Copilot). Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * feat: user-scoped webhook endpoint for multi-tenant isolation Adds POST /api/webhooks/u/{user_id}/{path} — a user-scoped webhook endpoint that filters the routine lookup by user_id, preventing cross-user webhook triggering when paths collide. The existing /api/webhooks/{path} endpoint remains unchanged for backward compatibility in single-user deployments. Changes: - get_webhook_routine_by_path gains user_id: Option<&str> param - Both postgres and libsql implementations add AND user_id = ? filter when user_id is provided - New webhook_trigger_user_scoped_handler extracts (user_id, path) from URL and passes to shared fire_webhook_inner logic - Route registered on public router (webhooks are called by external services that can't send bearer tokens) Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * feat: add TenantCtx for compile-time tenant isolation Implements zmanian's architectural proposal from #1614 review: two-tier scoped database access (TenantScope/AdminScope) so handler code cannot accidentally bypass tenant scoping. TenantScope (default): wraps user_id + Arc<dyn Database>, auto-binds user_id on every operation. ID-based lookups return None for cross- tenant resources. No escape hatch — forgetting to scope is a compile error. AdminScope (explicit opt-in): cross-tenant access for system-level components (heartbeat, routine engine, self-repair, scheduler, worker). TenantCtx bundles TenantScope + workspace + cost guard + per-user rate limiting. Constructed once per request in handle_message, threaded through all command handlers and ChatDelegate. Key changes: - New src/tenant.rs (~920 lines): TenantScope, AdminScope, TenantCtx, TenantRateState, TenantRateRegistry - All command handlers: user_id: &str → ctx: &TenantCtx - ChatDelegate: cost check/record/settings via self.tenant - System components: store field changed to AdminScope - Config: TENANT_MAX_LLM_CONCURRENT, TENANT_MAX_JOBS_CONCURRENT env vars - Fixes bug: /status <job_id> cross-tenant leak (now auto-filtered) Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> --------- Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
1187 lines
39 KiB
Rust
1187 lines
39 KiB
Rust
//! Job scheduler for parallel execution.
|
|
|
|
use std::collections::HashMap;
|
|
use std::sync::Arc;
|
|
use std::time::Duration;
|
|
|
|
use tokio::sync::{RwLock, mpsc, oneshot};
|
|
use tokio::task::JoinHandle;
|
|
use uuid::Uuid;
|
|
|
|
use crate::agent::task::{Task, TaskContext, TaskOutput};
|
|
use crate::config::AgentConfig;
|
|
use crate::context::{ContextManager, JobContext, JobState};
|
|
use crate::error::{Error, JobError};
|
|
use crate::extensions::ExtensionManager;
|
|
use crate::hooks::HookRegistry;
|
|
use crate::llm::LlmProvider;
|
|
use crate::safety::SafetyLayer;
|
|
use crate::tenant::AdminScope;
|
|
use crate::tools::{
|
|
ApprovalContext, ToolRegistry, autonomous_allowed_tool_names, autonomous_unavailable_error,
|
|
prepare_tool_params,
|
|
};
|
|
use crate::worker::job::{Worker, WorkerDeps};
|
|
|
|
/// Message to send to a worker.
|
|
#[derive(Debug)]
|
|
pub enum WorkerMessage {
|
|
/// Start working on the job.
|
|
Start,
|
|
/// Stop the job.
|
|
Stop,
|
|
/// Check health.
|
|
Ping,
|
|
/// Inject a follow-up user message into the worker's reasoning context.
|
|
UserMessage(String),
|
|
}
|
|
|
|
/// Status of a scheduled job.
|
|
#[derive(Debug)]
|
|
pub struct ScheduledJob {
|
|
pub handle: JoinHandle<()>,
|
|
pub tx: mpsc::Sender<WorkerMessage>,
|
|
}
|
|
|
|
/// Status of a scheduled sub-task.
|
|
struct ScheduledSubtask {
|
|
handle: JoinHandle<Result<TaskOutput, Error>>,
|
|
}
|
|
|
|
/// Shared scheduler-owned dependencies that are forwarded into autonomous runs.
|
|
pub struct SchedulerDeps {
|
|
pub tools: Arc<ToolRegistry>,
|
|
pub extension_manager: Option<Arc<ExtensionManager>>,
|
|
pub store: Option<AdminScope>,
|
|
pub hooks: Arc<HookRegistry>,
|
|
}
|
|
|
|
/// Schedules and manages parallel job execution.
|
|
pub struct Scheduler {
|
|
config: AgentConfig,
|
|
context_manager: Arc<ContextManager>,
|
|
llm: Arc<dyn LlmProvider>,
|
|
safety: Arc<SafetyLayer>,
|
|
tools: Arc<ToolRegistry>,
|
|
extension_manager: Option<Arc<ExtensionManager>>,
|
|
store: Option<AdminScope>,
|
|
hooks: Arc<HookRegistry>,
|
|
/// SSE manager for live job event streaming.
|
|
sse_tx: Option<Arc<crate::channels::web::sse::SseManager>>,
|
|
/// HTTP interceptor for trace recording/replay (propagated to workers).
|
|
http_interceptor: Option<Arc<dyn crate::llm::recording::HttpInterceptor>>,
|
|
/// Running jobs (main LLM-driven jobs).
|
|
jobs: Arc<RwLock<HashMap<Uuid, ScheduledJob>>>,
|
|
/// Running sub-tasks (tool executions, background tasks).
|
|
subtasks: Arc<RwLock<HashMap<Uuid, ScheduledSubtask>>>,
|
|
}
|
|
|
|
impl Scheduler {
|
|
/// Create a new scheduler.
|
|
pub fn new(
|
|
config: AgentConfig,
|
|
context_manager: Arc<ContextManager>,
|
|
llm: Arc<dyn LlmProvider>,
|
|
safety: Arc<SafetyLayer>,
|
|
deps: SchedulerDeps,
|
|
) -> Self {
|
|
Self {
|
|
config,
|
|
context_manager,
|
|
llm,
|
|
safety,
|
|
tools: deps.tools,
|
|
extension_manager: deps.extension_manager,
|
|
store: deps.store,
|
|
hooks: deps.hooks,
|
|
sse_tx: None,
|
|
http_interceptor: None,
|
|
jobs: Arc::new(RwLock::new(HashMap::new())),
|
|
subtasks: Arc::new(RwLock::new(HashMap::new())),
|
|
}
|
|
}
|
|
|
|
/// Set the SSE manager for live job event streaming.
|
|
pub fn set_sse_sender(&mut self, sse: Arc<crate::channels::web::sse::SseManager>) {
|
|
self.sse_tx = Some(sse);
|
|
}
|
|
|
|
/// Set the HTTP interceptor for trace recording/replay.
|
|
pub fn set_http_interceptor(
|
|
&mut self,
|
|
interceptor: Arc<dyn crate::llm::recording::HttpInterceptor>,
|
|
) {
|
|
self.http_interceptor = Some(interceptor);
|
|
}
|
|
|
|
/// Create, persist, and schedule a job in one shot.
|
|
///
|
|
/// This is the preferred entry point for dispatching new jobs. It:
|
|
/// 1. Creates the job context via `ContextManager`
|
|
/// 2. Optionally applies metadata (e.g. `max_iterations`)
|
|
/// 3. Persists the job to the database (so FK references from
|
|
/// `job_actions` / `llm_calls` work immediately)
|
|
/// 4. Schedules the job for worker execution
|
|
///
|
|
/// Returns the new job ID.
|
|
pub async fn dispatch_job(
|
|
&self,
|
|
user_id: &str,
|
|
title: &str,
|
|
description: &str,
|
|
metadata: Option<serde_json::Value>,
|
|
) -> Result<Uuid, JobError> {
|
|
let approval_context = self.autonomous_approval_context(user_id).await;
|
|
self.dispatch_job_inner(
|
|
user_id,
|
|
title,
|
|
description,
|
|
metadata,
|
|
Some(approval_context),
|
|
)
|
|
.await
|
|
}
|
|
|
|
/// Dispatch a job with an explicit approval context for autonomous execution.
|
|
///
|
|
/// Same as `dispatch_job`, but the worker will use the given `ApprovalContext`
|
|
/// to determine the explicit autonomous allowlist for that job.
|
|
pub async fn dispatch_job_with_context(
|
|
&self,
|
|
user_id: &str,
|
|
title: &str,
|
|
description: &str,
|
|
metadata: Option<serde_json::Value>,
|
|
approval_context: ApprovalContext,
|
|
) -> Result<Uuid, JobError> {
|
|
self.dispatch_job_inner(
|
|
user_id,
|
|
title,
|
|
description,
|
|
metadata,
|
|
Some(approval_context),
|
|
)
|
|
.await
|
|
}
|
|
|
|
/// Shared implementation for `dispatch_job` and `dispatch_job_with_context`.
|
|
async fn dispatch_job_inner(
|
|
&self,
|
|
user_id: &str,
|
|
title: &str,
|
|
description: &str,
|
|
metadata: Option<serde_json::Value>,
|
|
approval_context: Option<ApprovalContext>,
|
|
) -> Result<Uuid, JobError> {
|
|
let job_id = self
|
|
.context_manager
|
|
.create_job_for_user(user_id, title, description)
|
|
.await?;
|
|
|
|
// Apply metadata and token budget in a single atomic update.
|
|
// This prevents concurrent workers from observing partial state.
|
|
// Cap user-supplied max_tokens at the configured limit (Issue #815).
|
|
let user_max_tokens = metadata
|
|
.as_ref()
|
|
.and_then(|m| m.get("max_tokens"))
|
|
.and_then(|v| v.as_u64());
|
|
|
|
let max_tokens = user_max_tokens
|
|
.map(|user_val| {
|
|
if self.config.max_tokens_per_job == 0 {
|
|
// Config is "unlimited": use the user-supplied value directly.
|
|
user_val
|
|
} else {
|
|
std::cmp::min(user_val, self.config.max_tokens_per_job)
|
|
}
|
|
})
|
|
.unwrap_or(self.config.max_tokens_per_job);
|
|
|
|
// Apply both metadata and token budget in one closure (Issue #813: atomic update).
|
|
// Use update_context_and_get to ensure atomicity: no gap where concurrent workers
|
|
// can modify the context between update and DB persist (Issue #807).
|
|
let ctx = if let Some(meta) = metadata {
|
|
self.context_manager
|
|
.update_context_and_get(job_id, |ctx| {
|
|
ctx.metadata = meta;
|
|
if max_tokens > 0 {
|
|
ctx.max_tokens = max_tokens;
|
|
}
|
|
})
|
|
.await?
|
|
} else if max_tokens > 0 {
|
|
self.context_manager
|
|
.update_context_and_get(job_id, |ctx| {
|
|
ctx.max_tokens = max_tokens;
|
|
})
|
|
.await?
|
|
} else {
|
|
// No metadata or token budget to set; get the initial context
|
|
self.context_manager.get_context(job_id).await?
|
|
};
|
|
|
|
// Persist to DB before scheduling so the worker's FK references are valid.
|
|
// The context was read under the same lock as the update (atomic), preventing
|
|
// concurrent worker interference (Issue #807: non-transactional context updates).
|
|
if let Some(ref store) = self.store {
|
|
store.save_job(&ctx).await.map_err(|e| JobError::Failed {
|
|
id: job_id,
|
|
reason: format!("failed to persist job: {e}"),
|
|
})?;
|
|
}
|
|
|
|
self.schedule_with_context(job_id, approval_context).await?;
|
|
Ok(job_id)
|
|
}
|
|
|
|
async fn autonomous_approval_context(&self, user_id: &str) -> ApprovalContext {
|
|
ApprovalContext::autonomous_with_tools(
|
|
autonomous_allowed_tool_names(&self.tools, self.extension_manager.as_ref(), user_id)
|
|
.await,
|
|
)
|
|
}
|
|
|
|
/// Schedule a job for execution.
|
|
pub async fn schedule(&self, job_id: Uuid) -> Result<(), JobError> {
|
|
self.schedule_with_context(job_id, None).await
|
|
}
|
|
|
|
/// Schedule a job with an optional approval context.
|
|
async fn schedule_with_context(
|
|
&self,
|
|
job_id: Uuid,
|
|
approval_context: Option<ApprovalContext>,
|
|
) -> Result<(), JobError> {
|
|
// Hold write lock for the entire check-insert sequence to prevent
|
|
// TOCTOU races where two concurrent calls both pass the checks.
|
|
{
|
|
let mut jobs = self.jobs.write().await;
|
|
|
|
if jobs.contains_key(&job_id) {
|
|
return Ok(());
|
|
}
|
|
|
|
if jobs.len() >= self.config.max_parallel_jobs {
|
|
return Err(JobError::MaxJobsExceeded {
|
|
max: self.config.max_parallel_jobs,
|
|
});
|
|
}
|
|
|
|
// Transition job to in_progress
|
|
self.context_manager
|
|
.update_context(job_id, |ctx| {
|
|
ctx.transition_to(
|
|
JobState::InProgress,
|
|
Some("Scheduled for execution".to_string()),
|
|
)
|
|
})
|
|
.await?
|
|
.map_err(|s| JobError::ContextError {
|
|
id: job_id,
|
|
reason: s,
|
|
})?;
|
|
|
|
// Create worker channel
|
|
let (tx, rx) = mpsc::channel(16);
|
|
|
|
// Create worker with shared dependencies
|
|
let deps = WorkerDeps {
|
|
context_manager: self.context_manager.clone(),
|
|
llm: self.llm.clone(),
|
|
safety: self.safety.clone(),
|
|
tools: self.tools.clone(),
|
|
store: self.store.clone(),
|
|
hooks: self.hooks.clone(),
|
|
timeout: self.config.job_timeout,
|
|
use_planning: self.config.use_planning,
|
|
sse_tx: self.sse_tx.clone(),
|
|
approval_context,
|
|
http_interceptor: self.http_interceptor.clone(),
|
|
};
|
|
let worker = Worker::new(job_id, deps);
|
|
|
|
// Spawn worker task
|
|
let handle = tokio::spawn(async move {
|
|
if let Err(e) = worker.run(rx).await {
|
|
tracing::error!("Worker for job {} failed: {}", job_id, e);
|
|
}
|
|
});
|
|
|
|
// Start the worker
|
|
if tx.send(WorkerMessage::Start).await.is_err() {
|
|
tracing::error!(job_id = %job_id, "Worker died before receiving Start message");
|
|
}
|
|
|
|
// Insert while still holding the write lock
|
|
jobs.insert(job_id, ScheduledJob { handle, tx });
|
|
}
|
|
|
|
// Cleanup task for this job to avoid capacity leaks
|
|
let jobs = Arc::clone(&self.jobs);
|
|
tokio::spawn(async move {
|
|
loop {
|
|
let finished = {
|
|
let jobs_read = jobs.read().await;
|
|
match jobs_read.get(&job_id) {
|
|
Some(scheduled) => scheduled.handle.is_finished(),
|
|
None => true,
|
|
}
|
|
};
|
|
|
|
if finished {
|
|
jobs.write().await.remove(&job_id);
|
|
break;
|
|
}
|
|
|
|
tokio::time::sleep(Duration::from_secs(1)).await;
|
|
}
|
|
});
|
|
|
|
tracing::info!("Scheduled job {} for execution", job_id);
|
|
Ok(())
|
|
}
|
|
|
|
/// Schedule a sub-task from within a worker.
|
|
///
|
|
/// Sub-tasks are lightweight tasks that don't go through the full job lifecycle.
|
|
/// They're used for parallel tool execution and background computations.
|
|
///
|
|
/// Returns a oneshot receiver to get the result.
|
|
pub async fn spawn_subtask(
|
|
&self,
|
|
parent_id: Uuid,
|
|
task: Task,
|
|
) -> Result<oneshot::Receiver<Result<TaskOutput, Error>>, JobError> {
|
|
let task_id = Uuid::new_v4();
|
|
let (result_tx, result_rx) = oneshot::channel();
|
|
|
|
let handle = match task {
|
|
Task::Job { .. } => {
|
|
// Jobs should go through schedule(), not spawn_subtask
|
|
return Err(JobError::ContextError {
|
|
id: parent_id,
|
|
reason: "Use schedule() for Job tasks, not spawn_subtask()".to_string(),
|
|
});
|
|
}
|
|
|
|
Task::ToolExec {
|
|
parent_id: tool_parent_id,
|
|
tool_name,
|
|
params,
|
|
} => {
|
|
let tools = self.tools.clone();
|
|
let context_manager = self.context_manager.clone();
|
|
let safety = self.safety.clone();
|
|
|
|
// TODO: propagate parent job's ApprovalContext here when subtasks
|
|
// are used in autonomous/routine paths (currently only used in tests).
|
|
tokio::spawn(async move {
|
|
let result = Self::execute_tool_task(
|
|
tools,
|
|
context_manager,
|
|
safety,
|
|
None,
|
|
tool_parent_id,
|
|
&tool_name,
|
|
params,
|
|
)
|
|
.await;
|
|
|
|
// Send result (ignore if receiver dropped)
|
|
let _ = result_tx.send(result);
|
|
})
|
|
}
|
|
|
|
Task::Background { id: _, handler } => {
|
|
let ctx = TaskContext::new(task_id).with_parent(parent_id);
|
|
|
|
tokio::spawn(async move {
|
|
let result = handler.run(ctx).await;
|
|
let _ = result_tx.send(result);
|
|
})
|
|
}
|
|
};
|
|
|
|
// Track the subtask
|
|
self.subtasks.write().await.insert(
|
|
task_id,
|
|
ScheduledSubtask {
|
|
handle: tokio::spawn(async move {
|
|
// Wrap the handle to get its result
|
|
match handle.await {
|
|
Ok(()) => Err(Error::Job(JobError::ContextError {
|
|
id: task_id,
|
|
reason: "Subtask completed but result not captured".to_string(),
|
|
})),
|
|
Err(e) => Err(Error::Job(JobError::ContextError {
|
|
id: task_id,
|
|
reason: format!("Subtask panicked: {}", e),
|
|
})),
|
|
}
|
|
}),
|
|
},
|
|
);
|
|
|
|
// Cleanup task for subtask tracking
|
|
let subtasks = Arc::clone(&self.subtasks);
|
|
tokio::spawn(async move {
|
|
loop {
|
|
let finished = {
|
|
let subtasks_read = subtasks.read().await;
|
|
match subtasks_read.get(&task_id) {
|
|
Some(scheduled) => scheduled.handle.is_finished(),
|
|
None => true,
|
|
}
|
|
};
|
|
|
|
if finished {
|
|
subtasks.write().await.remove(&task_id);
|
|
break;
|
|
}
|
|
|
|
tokio::time::sleep(Duration::from_secs(1)).await;
|
|
}
|
|
});
|
|
|
|
tracing::debug!(
|
|
parent_id = %parent_id,
|
|
task_id = %task_id,
|
|
"Spawned subtask"
|
|
);
|
|
|
|
Ok(result_rx)
|
|
}
|
|
|
|
/// Schedule multiple tasks in parallel and wait for all to complete.
|
|
///
|
|
/// Returns results in the same order as the input tasks.
|
|
pub async fn spawn_batch(
|
|
&self,
|
|
parent_id: Uuid,
|
|
tasks: Vec<Task>,
|
|
) -> Vec<Result<TaskOutput, Error>> {
|
|
if tasks.is_empty() {
|
|
return Vec::new();
|
|
}
|
|
|
|
let mut receivers = Vec::with_capacity(tasks.len());
|
|
|
|
// Spawn all tasks
|
|
for task in tasks {
|
|
match self.spawn_subtask(parent_id, task).await {
|
|
Ok(rx) => receivers.push(Some(rx)),
|
|
Err(e) => {
|
|
// Store the error directly
|
|
receivers.push(None);
|
|
tracing::warn!(
|
|
parent_id = %parent_id,
|
|
error = %e,
|
|
"Failed to spawn subtask in batch"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Collect results
|
|
let mut results = Vec::with_capacity(receivers.len());
|
|
for rx in receivers {
|
|
let result = match rx {
|
|
Some(receiver) => match receiver.await {
|
|
Ok(task_result) => task_result,
|
|
Err(_) => Err(Error::Job(JobError::ContextError {
|
|
id: parent_id,
|
|
reason: "Subtask channel closed unexpectedly".to_string(),
|
|
})),
|
|
},
|
|
None => Err(Error::Job(JobError::ContextError {
|
|
id: parent_id,
|
|
reason: "Subtask failed to spawn".to_string(),
|
|
})),
|
|
};
|
|
results.push(result);
|
|
}
|
|
|
|
results
|
|
}
|
|
|
|
/// Execute a single tool as a subtask.
|
|
///
|
|
/// Performs scheduler-specific checks (approval, cancellation) then
|
|
/// delegates to the shared `execute_tool_with_safety` pipeline.
|
|
async fn execute_tool_task(
|
|
tools: Arc<ToolRegistry>,
|
|
context_manager: Arc<ContextManager>,
|
|
safety: Arc<SafetyLayer>,
|
|
approval_context: Option<ApprovalContext>,
|
|
job_id: Uuid,
|
|
tool_name: &str,
|
|
params: serde_json::Value,
|
|
) -> Result<TaskOutput, Error> {
|
|
let start = std::time::Instant::now();
|
|
|
|
// Get the tool for approval check
|
|
let tool = tools.get(tool_name).await.ok_or_else(|| {
|
|
Error::Tool(crate::error::ToolError::NotFound {
|
|
name: tool_name.to_string(),
|
|
})
|
|
})?;
|
|
|
|
// Get job context
|
|
let job_ctx: JobContext = context_manager.get_context(job_id).await?;
|
|
if job_ctx.state == JobState::Cancelled {
|
|
return Err(crate::error::ToolError::ExecutionFailed {
|
|
name: tool_name.to_string(),
|
|
reason: "Job is cancelled".to_string(),
|
|
}
|
|
.into());
|
|
}
|
|
|
|
let normalized_params = prepare_tool_params(tool.as_ref(), ¶ms);
|
|
|
|
// Scheduler-specific approval check
|
|
let requirement = tool.requires_approval(&normalized_params);
|
|
let blocked =
|
|
ApprovalContext::is_blocked_or_default(&approval_context, tool_name, requirement);
|
|
if blocked {
|
|
return Err(autonomous_unavailable_error(tool_name, &job_ctx.user_id).into());
|
|
}
|
|
|
|
// Delegate to shared tool execution pipeline
|
|
let output_str = crate::tools::execute::execute_tool_with_safety(
|
|
&tools, &safety, tool_name, params, &job_ctx,
|
|
)
|
|
.await?;
|
|
|
|
// Parse back to Value for TaskOutput; this should be infallible given
|
|
// `execute_tool_with_safety` uses `serde_json::to_string_pretty`, but if it
|
|
// ever fails we surface a clear error instead of silently changing types.
|
|
let result_value: serde_json::Value = serde_json::from_str(&output_str).map_err(|e| {
|
|
Error::Tool(crate::error::ToolError::ExecutionFailed {
|
|
name: tool_name.to_string(),
|
|
reason: format!("Failed to parse tool output as JSON: {}", e),
|
|
})
|
|
})?;
|
|
|
|
Ok(TaskOutput::new(result_value, start.elapsed()))
|
|
}
|
|
|
|
/// Stop a running job.
|
|
pub async fn stop(&self, job_id: Uuid) -> Result<(), JobError> {
|
|
let mut jobs = self.jobs.write().await;
|
|
|
|
if let Some(scheduled) = jobs.remove(&job_id) {
|
|
// Send stop signal
|
|
let _ = scheduled.tx.send(WorkerMessage::Stop).await;
|
|
|
|
// Give it a moment to clean up
|
|
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
|
|
|
|
// Abort if still running
|
|
if !scheduled.handle.is_finished() {
|
|
scheduled.handle.abort();
|
|
}
|
|
|
|
// Update job state
|
|
self.context_manager
|
|
.update_context(job_id, |ctx| {
|
|
if let Err(e) = ctx.transition_to(
|
|
JobState::Cancelled,
|
|
Some("Stopped by scheduler".to_string()),
|
|
) {
|
|
tracing::warn!(
|
|
job_id = %job_id,
|
|
error = %e,
|
|
"Failed to transition job to Cancelled state"
|
|
);
|
|
}
|
|
})
|
|
.await?;
|
|
|
|
// Persist cancellation (fire-and-forget)
|
|
if let Some(ref store) = self.store {
|
|
let store = store.clone();
|
|
tokio::spawn(async move {
|
|
if let Err(e) = store
|
|
.update_job_status(
|
|
job_id,
|
|
JobState::Cancelled,
|
|
Some("Stopped by scheduler"),
|
|
)
|
|
.await
|
|
{
|
|
tracing::warn!("Failed to persist cancellation for job {}: {}", job_id, e);
|
|
}
|
|
});
|
|
}
|
|
|
|
tracing::info!("Stopped job {}", job_id);
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Send a follow-up user message to a running job.
|
|
///
|
|
/// Returns `Ok(())` if the message was queued, `Err` if the job is not running.
|
|
pub async fn send_message(&self, job_id: Uuid, content: String) -> Result<(), JobError> {
|
|
// Clone the sender while holding the lock, then release before the
|
|
// async send to avoid blocking scheduler writes during backpressure.
|
|
let tx = {
|
|
let jobs = self.jobs.read().await;
|
|
let scheduled = jobs.get(&job_id).ok_or(JobError::NotFound { id: job_id })?;
|
|
scheduled.tx.clone()
|
|
};
|
|
tx.send(WorkerMessage::UserMessage(content))
|
|
.await
|
|
.map_err(|_| JobError::Failed {
|
|
id: job_id,
|
|
reason: "Worker channel closed".to_string(),
|
|
})?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Check if a job is running.
|
|
pub async fn is_running(&self, job_id: Uuid) -> bool {
|
|
self.jobs.read().await.contains_key(&job_id)
|
|
}
|
|
|
|
/// Get count of running jobs.
|
|
pub async fn running_count(&self) -> usize {
|
|
self.jobs.read().await.len()
|
|
}
|
|
|
|
/// Get count of running subtasks.
|
|
pub async fn subtask_count(&self) -> usize {
|
|
self.subtasks.read().await.len()
|
|
}
|
|
|
|
/// Get all running job IDs.
|
|
pub async fn running_jobs(&self) -> Vec<Uuid> {
|
|
self.jobs.read().await.keys().cloned().collect()
|
|
}
|
|
|
|
/// Clean up finished jobs and subtasks.
|
|
pub async fn cleanup_finished(&self) {
|
|
// Clean up jobs
|
|
{
|
|
let mut jobs = self.jobs.write().await;
|
|
let mut finished = Vec::new();
|
|
|
|
for (id, scheduled) in jobs.iter() {
|
|
if scheduled.handle.is_finished() {
|
|
finished.push(*id);
|
|
}
|
|
}
|
|
|
|
for id in finished {
|
|
jobs.remove(&id);
|
|
tracing::debug!("Cleaned up finished job {}", id);
|
|
}
|
|
}
|
|
|
|
// Clean up subtasks
|
|
{
|
|
let mut subtasks = self.subtasks.write().await;
|
|
let mut finished = Vec::new();
|
|
|
|
for (id, scheduled) in subtasks.iter() {
|
|
if scheduled.handle.is_finished() {
|
|
finished.push(*id);
|
|
}
|
|
}
|
|
|
|
for id in finished {
|
|
subtasks.remove(&id);
|
|
tracing::trace!("Cleaned up finished subtask {}", id);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Stop all jobs.
|
|
pub async fn stop_all(&self) {
|
|
let job_ids: Vec<Uuid> = self.jobs.read().await.keys().cloned().collect();
|
|
|
|
for job_id in job_ids {
|
|
let _ = self.stop(job_id).await;
|
|
}
|
|
|
|
// Abort all subtasks
|
|
let mut subtasks = self.subtasks.write().await;
|
|
for (_, scheduled) in subtasks.drain() {
|
|
scheduled.handle.abort();
|
|
}
|
|
}
|
|
|
|
/// Get access to the tools registry.
|
|
pub fn tools(&self) -> &Arc<ToolRegistry> {
|
|
&self.tools
|
|
}
|
|
|
|
/// Get access to the context manager.
|
|
pub fn context_manager(&self) -> &Arc<ContextManager> {
|
|
&self.context_manager
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::config::SafetyConfig;
|
|
use crate::llm::{
|
|
CompletionRequest, CompletionResponse, LlmError, LlmProvider, ToolCompletionRequest,
|
|
ToolCompletionResponse,
|
|
};
|
|
use crate::safety::SafetyLayer;
|
|
use crate::tools::{ApprovalRequirement, Tool, ToolError, ToolOutput};
|
|
use rust_decimal_macros::dec;
|
|
|
|
/// Minimal LLM provider stub for scheduler tests that don't exercise LLM calls.
|
|
struct StubLlm;
|
|
|
|
#[async_trait::async_trait]
|
|
impl LlmProvider for StubLlm {
|
|
fn model_name(&self) -> &str {
|
|
"stub"
|
|
}
|
|
fn cost_per_token(&self) -> (rust_decimal::Decimal, rust_decimal::Decimal) {
|
|
(dec!(0), dec!(0))
|
|
}
|
|
async fn complete(&self, _req: CompletionRequest) -> Result<CompletionResponse, LlmError> {
|
|
Err(LlmError::RequestFailed {
|
|
provider: "stub".into(),
|
|
reason: "not implemented".into(),
|
|
})
|
|
}
|
|
async fn complete_with_tools(
|
|
&self,
|
|
_req: ToolCompletionRequest,
|
|
) -> Result<ToolCompletionResponse, LlmError> {
|
|
Err(LlmError::RequestFailed {
|
|
provider: "stub".into(),
|
|
reason: "not implemented".into(),
|
|
})
|
|
}
|
|
}
|
|
|
|
/// Create a Scheduler for token-budget tests. The LLM stub will fail if a
|
|
/// worker actually tries to call it, but `dispatch_job` sets the token
|
|
/// budget *before* spawning the worker so we can inspect the context
|
|
/// immediately after dispatch.
|
|
fn make_test_scheduler(max_tokens_per_job: u64) -> Scheduler {
|
|
let config = AgentConfig {
|
|
name: "test".to_string(),
|
|
max_parallel_jobs: 5,
|
|
job_timeout: std::time::Duration::from_secs(30),
|
|
stuck_threshold: std::time::Duration::from_secs(300),
|
|
repair_check_interval: std::time::Duration::from_secs(3600),
|
|
max_repair_attempts: 0,
|
|
use_planning: false,
|
|
session_idle_timeout: std::time::Duration::from_secs(3600),
|
|
allow_local_tools: true,
|
|
max_cost_per_day_cents: None,
|
|
max_actions_per_hour: None,
|
|
max_cost_per_user_per_day_cents: None,
|
|
max_tool_iterations: 10,
|
|
auto_approve_tools: true,
|
|
default_timezone: "UTC".to_string(),
|
|
max_tokens_per_job,
|
|
multi_tenant: false,
|
|
max_llm_concurrent_per_user: None,
|
|
max_jobs_concurrent_per_user: None,
|
|
};
|
|
let cm = Arc::new(ContextManager::new(5));
|
|
let llm: Arc<dyn LlmProvider> = Arc::new(StubLlm);
|
|
let safety = Arc::new(SafetyLayer::new(&SafetyConfig {
|
|
max_output_length: 100_000,
|
|
injection_check_enabled: false,
|
|
}));
|
|
let tools = Arc::new(ToolRegistry::new());
|
|
let hooks = Arc::new(HookRegistry::default());
|
|
|
|
Scheduler::new(
|
|
config,
|
|
cm,
|
|
llm,
|
|
safety,
|
|
SchedulerDeps {
|
|
tools,
|
|
extension_manager: None,
|
|
store: None,
|
|
hooks,
|
|
},
|
|
)
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_dispatch_job_caps_user_max_tokens() {
|
|
let sched = make_test_scheduler(1000);
|
|
let meta = serde_json::json!({ "max_tokens": 5000 });
|
|
let job_id = sched
|
|
.dispatch_job("user1", "test", "desc", Some(meta))
|
|
.await
|
|
.unwrap();
|
|
|
|
let ctx = sched.context_manager.get_context(job_id).await.unwrap();
|
|
assert_eq!(ctx.max_tokens, 1000, "should cap at configured limit");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_dispatch_job_unlimited_config_preserves_user_tokens() {
|
|
let sched = make_test_scheduler(0); // 0 = unlimited
|
|
let meta = serde_json::json!({ "max_tokens": 5000 });
|
|
let job_id = sched
|
|
.dispatch_job("user1", "test", "desc", Some(meta))
|
|
.await
|
|
.unwrap();
|
|
|
|
let ctx = sched.context_manager.get_context(job_id).await.unwrap();
|
|
assert_eq!(
|
|
ctx.max_tokens, 5000,
|
|
"unlimited config should preserve user value"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_dispatch_job_no_user_tokens_uses_config() {
|
|
let sched = make_test_scheduler(2000);
|
|
let job_id = sched
|
|
.dispatch_job("user1", "test", "desc", None)
|
|
.await
|
|
.unwrap();
|
|
|
|
let ctx = sched.context_manager.get_context(job_id).await.unwrap();
|
|
assert_eq!(
|
|
ctx.max_tokens, 2000,
|
|
"should use config default when no user value"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_dispatch_job_atomic_metadata_and_tokens() {
|
|
let sched = make_test_scheduler(10_000);
|
|
let meta = serde_json::json!({
|
|
"max_tokens": 3000,
|
|
"custom_key": "custom_value"
|
|
});
|
|
let job_id = sched
|
|
.dispatch_job("user1", "test", "desc", Some(meta))
|
|
.await
|
|
.unwrap();
|
|
|
|
let ctx = sched.context_manager.get_context(job_id).await.unwrap();
|
|
assert_eq!(ctx.max_tokens, 3000, "should use user value within limit");
|
|
assert_eq!(
|
|
ctx.metadata.get("custom_key").and_then(|v| v.as_str()),
|
|
Some("custom_value"),
|
|
"metadata should be set atomically with token budget"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_dispatch_job_no_metadata_no_user_tokens_edge_case() {
|
|
// Edge case coverage: when metadata=None AND max_tokens=0 (config),
|
|
// the else branch calls get_context() directly (not update_context_and_get).
|
|
// This test verifies that path works correctly (Issue #807: full branch coverage).
|
|
let sched = make_test_scheduler(0); // 0 = unlimited, but user provides None
|
|
let job_id = sched
|
|
.dispatch_job("user1", "test", "desc", None) // None metadata
|
|
.await
|
|
.unwrap(); // safety: test code
|
|
|
|
let ctx = sched.context_manager.get_context(job_id).await.unwrap(); // safety: test code
|
|
// No metadata was set, should have default empty metadata
|
|
assert!(ctx.metadata.is_null() || ctx.metadata == serde_json::json!({})); // safety: test code
|
|
// No user tokens AND unlimited config means max_tokens stays at default
|
|
assert_eq!(ctx.max_tokens, 0, "unlimited config"); // safety: test code
|
|
}
|
|
|
|
#[test]
|
|
fn test_scheduler_creation() {
|
|
// Would need to mock dependencies for proper testing
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_spawn_batch_empty() {
|
|
// This test would need mock dependencies.
|
|
// For now just verify the empty case doesn't panic.
|
|
}
|
|
|
|
/// A tool that returns `UnlessAutoApproved`.
|
|
struct SoftApprovalTool;
|
|
|
|
#[async_trait::async_trait]
|
|
impl Tool for SoftApprovalTool {
|
|
fn name(&self) -> &str {
|
|
"soft_gate"
|
|
}
|
|
fn description(&self) -> &str {
|
|
"needs soft approval"
|
|
}
|
|
fn parameters_schema(&self) -> serde_json::Value {
|
|
serde_json::json!({"type": "object", "properties": {}})
|
|
}
|
|
async fn execute(
|
|
&self,
|
|
_params: serde_json::Value,
|
|
_ctx: &JobContext,
|
|
) -> Result<ToolOutput, ToolError> {
|
|
Ok(ToolOutput::text(
|
|
"soft_ok",
|
|
std::time::Instant::now().elapsed(),
|
|
))
|
|
}
|
|
fn requires_approval(&self, _params: &serde_json::Value) -> ApprovalRequirement {
|
|
ApprovalRequirement::UnlessAutoApproved
|
|
}
|
|
fn requires_sanitization(&self) -> bool {
|
|
false
|
|
}
|
|
}
|
|
|
|
/// A tool that returns `Always`.
|
|
struct HardApprovalTool;
|
|
|
|
#[async_trait::async_trait]
|
|
impl Tool for HardApprovalTool {
|
|
fn name(&self) -> &str {
|
|
"hard_gate"
|
|
}
|
|
fn description(&self) -> &str {
|
|
"needs hard approval"
|
|
}
|
|
fn parameters_schema(&self) -> serde_json::Value {
|
|
serde_json::json!({"type": "object", "properties": {}})
|
|
}
|
|
async fn execute(
|
|
&self,
|
|
_params: serde_json::Value,
|
|
_ctx: &JobContext,
|
|
) -> Result<ToolOutput, ToolError> {
|
|
Ok(ToolOutput::text(
|
|
"hard_ok",
|
|
std::time::Instant::now().elapsed(),
|
|
))
|
|
}
|
|
fn requires_approval(&self, _params: &serde_json::Value) -> ApprovalRequirement {
|
|
ApprovalRequirement::Always
|
|
}
|
|
fn requires_sanitization(&self) -> bool {
|
|
false
|
|
}
|
|
}
|
|
|
|
async fn setup_tools_and_job() -> (
|
|
Arc<ToolRegistry>,
|
|
Arc<ContextManager>,
|
|
Arc<SafetyLayer>,
|
|
Uuid,
|
|
) {
|
|
let registry = ToolRegistry::new();
|
|
registry.register(Arc::new(SoftApprovalTool)).await;
|
|
registry.register(Arc::new(HardApprovalTool)).await;
|
|
|
|
let cm = Arc::new(ContextManager::new(5));
|
|
let job_id = cm.create_job("test", "approval test").await.unwrap();
|
|
cm.update_context(job_id, |ctx| ctx.transition_to(JobState::InProgress, None))
|
|
.await
|
|
.unwrap()
|
|
.unwrap();
|
|
|
|
let safety = Arc::new(SafetyLayer::new(&SafetyConfig {
|
|
max_output_length: 100_000,
|
|
injection_check_enabled: false,
|
|
}));
|
|
|
|
(Arc::new(registry), cm, safety, job_id)
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_execute_tool_task_blocks_without_context() {
|
|
let (tools, cm, safety, job_id) = setup_tools_and_job().await;
|
|
|
|
// Without approval context, UnlessAutoApproved is blocked
|
|
let result = Scheduler::execute_tool_task(
|
|
tools.clone(),
|
|
cm.clone(),
|
|
safety.clone(),
|
|
None,
|
|
job_id,
|
|
"soft_gate",
|
|
serde_json::json!({}),
|
|
)
|
|
.await;
|
|
assert!(
|
|
result.is_err(),
|
|
"soft_gate should be blocked without context"
|
|
);
|
|
|
|
// Always is also blocked
|
|
let result = Scheduler::execute_tool_task(
|
|
tools,
|
|
cm,
|
|
safety,
|
|
None,
|
|
job_id,
|
|
"hard_gate",
|
|
serde_json::json!({}),
|
|
)
|
|
.await;
|
|
assert!(
|
|
result.is_err(),
|
|
"hard_gate should be blocked without context"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_execute_tool_task_autonomous_unblocks_soft() {
|
|
let (tools, cm, safety, job_id) = setup_tools_and_job().await;
|
|
|
|
// Autonomous execution only allows tools explicitly in scope.
|
|
let result = Scheduler::execute_tool_task(
|
|
tools.clone(),
|
|
cm.clone(),
|
|
safety.clone(),
|
|
Some(ApprovalContext::autonomous_with_tools([
|
|
"soft_gate".to_string()
|
|
])),
|
|
job_id,
|
|
"soft_gate",
|
|
serde_json::json!({}),
|
|
)
|
|
.await;
|
|
assert!(
|
|
result.is_ok(),
|
|
"soft_gate should pass with autonomous context"
|
|
);
|
|
|
|
// But still blocks Always
|
|
let result = Scheduler::execute_tool_task(
|
|
tools,
|
|
cm,
|
|
safety,
|
|
Some(ApprovalContext::autonomous()),
|
|
job_id,
|
|
"hard_gate",
|
|
serde_json::json!({}),
|
|
)
|
|
.await;
|
|
assert!(
|
|
result.is_err(),
|
|
"hard_gate should still be blocked without explicit permission"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_execute_tool_task_autonomous_with_permissions() {
|
|
let (tools, cm, safety, job_id) = setup_tools_and_job().await;
|
|
|
|
// Autonomous context with explicit permission for both tools.
|
|
let ctx = ApprovalContext::autonomous_with_tools([
|
|
"soft_gate".to_string(),
|
|
"hard_gate".to_string(),
|
|
]);
|
|
|
|
let result = Scheduler::execute_tool_task(
|
|
tools.clone(),
|
|
cm.clone(),
|
|
safety.clone(),
|
|
Some(ctx.clone()),
|
|
job_id,
|
|
"soft_gate",
|
|
serde_json::json!({}),
|
|
)
|
|
.await;
|
|
assert!(result.is_ok(), "soft_gate should pass");
|
|
|
|
let result = Scheduler::execute_tool_task(
|
|
tools,
|
|
cm,
|
|
safety,
|
|
Some(ctx),
|
|
job_id,
|
|
"hard_gate",
|
|
serde_json::json!({}),
|
|
)
|
|
.await;
|
|
assert!(
|
|
result.is_ok(),
|
|
"hard_gate should pass with explicit permission"
|
|
);
|
|
}
|
|
|
|
struct NormalizedApprovalTool;
|
|
|
|
#[async_trait::async_trait]
|
|
impl Tool for NormalizedApprovalTool {
|
|
fn name(&self) -> &str {
|
|
"normalized_gate"
|
|
}
|
|
fn description(&self) -> &str {
|
|
"approval depends on normalized params"
|
|
}
|
|
fn parameters_schema(&self) -> serde_json::Value {
|
|
serde_json::json!({
|
|
"type": "object",
|
|
"properties": {
|
|
"safe": { "type": "boolean" }
|
|
}
|
|
})
|
|
}
|
|
async fn execute(
|
|
&self,
|
|
_params: serde_json::Value,
|
|
_ctx: &JobContext,
|
|
) -> Result<ToolOutput, ToolError> {
|
|
Ok(ToolOutput::text(
|
|
"normalized_ok",
|
|
std::time::Instant::now().elapsed(),
|
|
))
|
|
}
|
|
fn requires_approval(&self, params: &serde_json::Value) -> ApprovalRequirement {
|
|
if params.get("safe").and_then(|v| v.as_bool()) == Some(true) {
|
|
ApprovalRequirement::Never
|
|
} else {
|
|
ApprovalRequirement::Always
|
|
}
|
|
}
|
|
fn requires_sanitization(&self) -> bool {
|
|
false
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_execute_tool_task_normalizes_params_before_approval() {
|
|
let registry = ToolRegistry::new();
|
|
registry.register(Arc::new(NormalizedApprovalTool)).await;
|
|
|
|
let cm = Arc::new(ContextManager::new(5));
|
|
let job_id = cm.create_job("test", "normalized approval").await.unwrap(); // safety: test-only setup
|
|
cm.update_context(job_id, |ctx| ctx.transition_to(JobState::InProgress, None))
|
|
.await
|
|
.unwrap() // safety: test-only setup
|
|
.unwrap(); // safety: test-only setup
|
|
|
|
let safety = Arc::new(SafetyLayer::new(&SafetyConfig {
|
|
max_output_length: 100_000,
|
|
injection_check_enabled: false,
|
|
}));
|
|
|
|
let result = Scheduler::execute_tool_task(
|
|
Arc::new(registry),
|
|
cm,
|
|
safety,
|
|
None,
|
|
job_id,
|
|
"normalized_gate",
|
|
serde_json::json!({"safe": "true"}),
|
|
)
|
|
.await;
|
|
|
|
#[rustfmt::skip]
|
|
assert!( // safety: test-only assertion
|
|
result.is_ok(),
|
|
"stringified boolean should normalize before approval: {result:?}"
|
|
);
|
|
}
|
|
}
|