Replace the dual task/monitor model, NO_REPLY string protocol, and Agent self-delivery with a single Scheduled Run path: claim-time JobRun snapshots, isolated Root/named Agent execution, exactly-once complete_scheduled_run termination, and Scheduler-owned policy delivery through a persistent outbox. - SQLite v11: drop job_kind/model/delete_after_run, add job_runs with status/outcome joint constraints and delivery lease columns; one-shot BEGIN IMMEDIATE migration with atomic rollback. - Non-blocking JoinSet event loop with bounded run/delivery concurrency; terminal commit before any channel I/O; recover unfinished runs as unknown. - ExecutionOrigin::Scheduled propagates to descendants, completion sink is top-level only, background delegation downgrades to foreground. - Typed delivery receipts, fixed target_session_id, idempotent scheduled:<job_run_id> history insert. - New cron_runs read-only tool; cron_add/update drop kind/model; WebUI and Health consume the same JobRun projection. - Bump version to 1.22.0.
192 lines
6.3 KiB
Rust
192 lines
6.3 KiB
Rust
use async_trait::async_trait;
|
|
use serde_json::{Value, json};
|
|
|
|
use crate::storage::ScheduledOutcomeKind;
|
|
use crate::tools::{ScheduledOutcome, Tool, ToolExecutionContext, ToolOutput, ToolResult};
|
|
|
|
pub struct CompleteScheduledRunTool;
|
|
|
|
impl CompleteScheduledRunTool {
|
|
pub fn new() -> Self {
|
|
Self
|
|
}
|
|
}
|
|
|
|
impl Default for CompleteScheduledRunTool {
|
|
fn default() -> Self {
|
|
Self::new()
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl Tool for CompleteScheduledRunTool {
|
|
fn name(&self) -> &str {
|
|
"complete_scheduled_run"
|
|
}
|
|
|
|
fn description(&self) -> &str {
|
|
"Submit the single structured final outcome (ok, alert, failed, or refused) of an unattended scheduled run and end the run immediately."
|
|
}
|
|
|
|
fn parameters_schema(&self) -> Value {
|
|
json!({
|
|
"type": "object",
|
|
"properties": {
|
|
"outcome": {
|
|
"type": "string",
|
|
"enum": ["ok", "alert", "failed", "refused"],
|
|
"description": "ok: completed with nothing needing attention; alert: completed with actionable findings; failed: did not complete reliably; refused: denied for permission or safety reasons"
|
|
},
|
|
"message": {
|
|
"type": "string",
|
|
"minLength": 1,
|
|
"maxLength": 16384,
|
|
"description": "The user-facing result or notification body"
|
|
}
|
|
},
|
|
"required": ["outcome", "message"],
|
|
"additionalProperties": false
|
|
})
|
|
}
|
|
|
|
fn runtime_injected(&self) -> bool {
|
|
true
|
|
}
|
|
|
|
fn exclusive(&self) -> bool {
|
|
true
|
|
}
|
|
|
|
async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> {
|
|
self.execute_with_context(&ToolExecutionContext::default(), args)
|
|
.await
|
|
.map(|output| output.result)
|
|
}
|
|
|
|
async fn execute_with_context(
|
|
&self,
|
|
context: &ToolExecutionContext,
|
|
args: Value,
|
|
) -> anyhow::Result<ToolOutput> {
|
|
let Some(sink) = context.scheduled_completion.as_ref() else {
|
|
return Ok(failure(
|
|
"complete_scheduled_run is only available to the top-level scheduled Agent",
|
|
)
|
|
.into());
|
|
};
|
|
if !context.execution_origin.is_scheduled() {
|
|
return Ok(failure("scheduled completion context is invalid").into());
|
|
}
|
|
let Some(object) = args.as_object() else {
|
|
return Ok(failure("arguments must be an object").into());
|
|
};
|
|
if object
|
|
.keys()
|
|
.any(|key| key != "outcome" && key != "message")
|
|
{
|
|
return Ok(failure("unknown complete_scheduled_run argument").into());
|
|
}
|
|
let kind = match args.get("outcome").and_then(Value::as_str) {
|
|
Some("ok") => ScheduledOutcomeKind::Ok,
|
|
Some("alert") => ScheduledOutcomeKind::Alert,
|
|
Some("failed") => ScheduledOutcomeKind::Failed,
|
|
Some("refused") => ScheduledOutcomeKind::Refused,
|
|
Some(other) => return Ok(failure(format!("invalid scheduled outcome: {other}")).into()),
|
|
None => return Ok(failure("outcome is required").into()),
|
|
};
|
|
let message = args
|
|
.get("message")
|
|
.and_then(Value::as_str)
|
|
.unwrap_or_default()
|
|
.trim();
|
|
if message.is_empty() {
|
|
return Ok(failure("message must not be empty").into());
|
|
}
|
|
if message.chars().count() > 16_384 {
|
|
return Ok(failure("message exceeds 16384 characters").into());
|
|
}
|
|
let message = message.to_string();
|
|
if let Err(error) = sink.submit(ScheduledOutcome {
|
|
kind,
|
|
message: message.clone(),
|
|
}) {
|
|
return Ok(failure(error).into());
|
|
}
|
|
Ok(ToolResult {
|
|
success: true,
|
|
output: format!("scheduled run completed with outcome={}", kind.as_str()),
|
|
error: None,
|
|
}
|
|
.into())
|
|
}
|
|
}
|
|
|
|
fn failure(error: impl Into<String>) -> ToolResult {
|
|
ToolResult {
|
|
success: false,
|
|
output: String::new(),
|
|
error: Some(error.into()),
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::tools::{ExecutionOrigin, ScheduledCompletionSink};
|
|
use std::sync::Arc;
|
|
|
|
#[tokio::test]
|
|
async fn submits_exactly_once_in_scheduled_context() {
|
|
let sink = Arc::new(ScheduledCompletionSink::default());
|
|
let context = ToolExecutionContext::for_session("scheduled-run:1")
|
|
.with_execution_origin(ExecutionOrigin::Scheduled { job_run_id: 1 })
|
|
.with_scheduled_completion(sink.clone());
|
|
let tool = CompleteScheduledRunTool::new();
|
|
let first = tool
|
|
.execute_with_context(&context, json!({"outcome":"ok","message":"healthy"}))
|
|
.await
|
|
.unwrap();
|
|
assert!(first.result.success);
|
|
assert_eq!(sink.outcome().unwrap().message, "healthy");
|
|
let second = tool
|
|
.execute_with_context(&context, json!({"outcome":"alert","message":"again"}))
|
|
.await
|
|
.unwrap();
|
|
assert!(!second.result.success);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn rejects_empty_extra_and_oversized_arguments() {
|
|
let sink = Arc::new(ScheduledCompletionSink::default());
|
|
let context = ToolExecutionContext::for_session("scheduled-run:1")
|
|
.with_execution_origin(ExecutionOrigin::Scheduled { job_run_id: 1 })
|
|
.with_scheduled_completion(sink);
|
|
let tool = CompleteScheduledRunTool::new();
|
|
for args in [
|
|
json!({"outcome":"ok","message":" "}),
|
|
json!({"outcome":"ok","message":"fine","notify":false}),
|
|
json!({"outcome":"unknown","message":"fine"}),
|
|
] {
|
|
assert!(
|
|
!tool
|
|
.execute_with_context(&context, args)
|
|
.await
|
|
.unwrap()
|
|
.result
|
|
.success
|
|
);
|
|
}
|
|
assert!(
|
|
!tool
|
|
.execute_with_context(
|
|
&context,
|
|
json!({"outcome":"ok","message":"x".repeat(16_385)}),
|
|
)
|
|
.await
|
|
.unwrap()
|
|
.result
|
|
.success
|
|
);
|
|
}
|
|
}
|