PicoBot/src/tools/complete_scheduled_run.rs
xiaoxixi d9ad58b84b feat(scheduler): unify scheduled task execution and delivery
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.
2026-08-21 14:59:02 +08:00

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
);
}
}