Mnovich/temporal foreground tasks (#2895)
Co-authored-by: Carlos M. Lopez <carlopez@squareup.com>
This commit is contained in:
@@ -28,6 +28,7 @@ tokio = { version = "1.43", features = ["full"] }
|
||||
futures = "0.3"
|
||||
serde = { version = "1.0", features = ["derive"] } # For serialization
|
||||
serde_yaml = "0.9"
|
||||
tempfile = "3"
|
||||
etcetera = "0.8.0"
|
||||
reqwest = { version = "0.12.9", features = [
|
||||
"rustls-tls-native-roots",
|
||||
|
||||
@@ -509,6 +509,16 @@ enum Command {
|
||||
help = "Quiet mode. Suppress non-response output, printing only the model response to stdout"
|
||||
)]
|
||||
quiet: bool,
|
||||
|
||||
/// Scheduled job ID (used internally for scheduled executions)
|
||||
#[arg(
|
||||
long = "scheduled-job-id",
|
||||
value_name = "ID",
|
||||
help = "ID of the scheduled job that triggered this execution (internal use)",
|
||||
long_help = "Internal parameter used when this run command is executed by a scheduled job. This associates the session with the schedule for tracking purposes.",
|
||||
hide = true
|
||||
)]
|
||||
scheduled_job_id: Option<String>,
|
||||
},
|
||||
|
||||
/// Recipe utilities for validation and deeplinking
|
||||
@@ -662,6 +672,7 @@ pub async fn cli() -> Result<()> {
|
||||
settings: None,
|
||||
debug,
|
||||
max_tool_repetitions,
|
||||
scheduled_job_id: None,
|
||||
interactive: true,
|
||||
quiet: false,
|
||||
})
|
||||
@@ -709,6 +720,7 @@ pub async fn cli() -> Result<()> {
|
||||
params,
|
||||
explain,
|
||||
render_recipe,
|
||||
scheduled_job_id,
|
||||
quiet,
|
||||
}) => {
|
||||
let (input_config, session_settings) = match (
|
||||
@@ -808,6 +820,7 @@ pub async fn cli() -> Result<()> {
|
||||
settings: session_settings,
|
||||
debug,
|
||||
max_tool_repetitions,
|
||||
scheduled_job_id,
|
||||
interactive, // Use the interactive flag from the Run command
|
||||
quiet,
|
||||
})
|
||||
@@ -925,6 +938,7 @@ pub async fn cli() -> Result<()> {
|
||||
settings: None::<SessionSettings>,
|
||||
debug: false,
|
||||
max_tool_repetitions: None,
|
||||
scheduled_job_id: None,
|
||||
interactive: true, // Default case is always interactive
|
||||
quiet: false,
|
||||
})
|
||||
|
||||
@@ -44,6 +44,7 @@ pub async fn agent_generator(
|
||||
debug: false,
|
||||
max_tool_repetitions: None,
|
||||
interactive: false, // Benchmarking is non-interactive
|
||||
scheduled_job_id: None,
|
||||
quiet: false,
|
||||
})
|
||||
.await;
|
||||
|
||||
@@ -99,6 +99,7 @@ pub async fn handle_schedule_add(
|
||||
paused: false,
|
||||
current_session_id: None,
|
||||
process_start_time: None,
|
||||
execution_mode: Some("background".to_string()), // Default to background for CLI
|
||||
};
|
||||
|
||||
let scheduler_storage_path =
|
||||
|
||||
@@ -464,6 +464,7 @@ async fn process_message_streaming(
|
||||
id: session::Identifier::Path(session_file.clone()),
|
||||
working_dir: std::env::current_dir()?,
|
||||
schedule_id: None,
|
||||
execution_mode: None,
|
||||
};
|
||||
|
||||
// Get response from agent
|
||||
|
||||
@@ -40,6 +40,8 @@ pub struct SessionBuilderConfig {
|
||||
pub debug: bool,
|
||||
/// Maximum number of consecutive identical tool calls allowed
|
||||
pub max_tool_repetitions: Option<u32>,
|
||||
/// ID of the scheduled job that triggered this session (if any)
|
||||
pub scheduled_job_id: Option<String>,
|
||||
/// Whether this session will be used interactively (affects debugging prompts)
|
||||
pub interactive: bool,
|
||||
/// Quiet mode - suppress non-response output
|
||||
@@ -115,7 +117,7 @@ async fn offer_extension_debugging_help(
|
||||
std::env::temp_dir().join(format!("goose_debug_extension_{}.jsonl", extension_name));
|
||||
|
||||
// Create the debugging session
|
||||
let mut debug_session = Session::new(debug_agent, temp_session_file.clone(), false);
|
||||
let mut debug_session = Session::new(debug_agent, temp_session_file.clone(), false, None);
|
||||
|
||||
// Process the debugging request
|
||||
println!("{}", style("Analyzing the extension failure...").yellow());
|
||||
@@ -341,7 +343,12 @@ pub async fn build_session(session_config: SessionBuilderConfig) -> Session {
|
||||
}
|
||||
|
||||
// Create new session
|
||||
let mut session = Session::new(agent, session_file.clone(), session_config.debug);
|
||||
let mut session = Session::new(
|
||||
agent,
|
||||
session_file.clone(),
|
||||
session_config.debug,
|
||||
session_config.scheduled_job_id.clone(),
|
||||
);
|
||||
|
||||
// Add extensions if provided
|
||||
for extension_str in session_config.extensions {
|
||||
@@ -490,6 +497,7 @@ mod tests {
|
||||
settings: None,
|
||||
debug: true,
|
||||
max_tool_repetitions: Some(5),
|
||||
scheduled_job_id: None,
|
||||
interactive: true,
|
||||
quiet: false,
|
||||
};
|
||||
@@ -499,6 +507,7 @@ mod tests {
|
||||
assert_eq!(config.builtins.len(), 1);
|
||||
assert!(config.debug);
|
||||
assert_eq!(config.max_tool_repetitions, Some(5));
|
||||
assert!(config.scheduled_job_id.is_none());
|
||||
assert!(config.interactive);
|
||||
assert!(!config.quiet);
|
||||
}
|
||||
@@ -517,6 +526,7 @@ mod tests {
|
||||
assert!(config.additional_system_prompt.is_none());
|
||||
assert!(!config.debug);
|
||||
assert!(config.max_tool_repetitions.is_none());
|
||||
assert!(config.scheduled_job_id.is_none());
|
||||
assert!(!config.interactive);
|
||||
assert!(!config.quiet);
|
||||
}
|
||||
|
||||
@@ -51,6 +51,7 @@ pub struct Session {
|
||||
completion_cache: Arc<std::sync::RwLock<CompletionCache>>,
|
||||
debug: bool, // New field for debug mode
|
||||
run_mode: RunMode,
|
||||
scheduled_job_id: Option<String>, // ID of the scheduled job that triggered this session
|
||||
}
|
||||
|
||||
// Cache structure for completion data
|
||||
@@ -107,7 +108,12 @@ pub async fn classify_planner_response(
|
||||
}
|
||||
|
||||
impl Session {
|
||||
pub fn new(agent: Agent, session_file: PathBuf, debug: bool) -> Self {
|
||||
pub fn new(
|
||||
agent: Agent,
|
||||
session_file: PathBuf,
|
||||
debug: bool,
|
||||
scheduled_job_id: Option<String>,
|
||||
) -> Self {
|
||||
let messages = match session::read_messages(&session_file) {
|
||||
Ok(msgs) => msgs,
|
||||
Err(e) => {
|
||||
@@ -123,6 +129,7 @@ impl Session {
|
||||
completion_cache: Arc::new(std::sync::RwLock::new(CompletionCache::new())),
|
||||
debug,
|
||||
run_mode: RunMode::Normal,
|
||||
scheduled_job_id,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -307,7 +314,13 @@ impl Session {
|
||||
let provider = self.agent.provider().await?;
|
||||
|
||||
// Persist messages with provider for automatic description generation
|
||||
session::persist_messages(&self.session_file, &self.messages, Some(provider)).await?;
|
||||
session::persist_messages_with_schedule_id(
|
||||
&self.session_file,
|
||||
&self.messages,
|
||||
Some(provider),
|
||||
self.scheduled_job_id.clone(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Track the current directory and last instruction in projects.json
|
||||
let session_id = self
|
||||
@@ -413,10 +426,11 @@ impl Session {
|
||||
let provider = self.agent.provider().await?;
|
||||
|
||||
// Persist messages with provider for automatic description generation
|
||||
session::persist_messages(
|
||||
session::persist_messages_with_schedule_id(
|
||||
&self.session_file,
|
||||
&self.messages,
|
||||
Some(provider),
|
||||
self.scheduled_job_id.clone(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -600,10 +614,11 @@ impl Session {
|
||||
self.messages = summarized_messages;
|
||||
|
||||
// Persist the summarized messages
|
||||
session::persist_messages(
|
||||
session::persist_messages_with_schedule_id(
|
||||
&self.session_file,
|
||||
&self.messages,
|
||||
Some(provider),
|
||||
self.scheduled_job_id.clone(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -727,7 +742,8 @@ impl Session {
|
||||
id: session_id.clone(),
|
||||
working_dir: std::env::current_dir()
|
||||
.expect("failed to get current session working directory"),
|
||||
schedule_id: None,
|
||||
schedule_id: self.scheduled_job_id.clone(),
|
||||
execution_mode: None,
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
@@ -776,7 +792,7 @@ impl Session {
|
||||
Err(ToolError::ExecutionError("Tool call cancelled by user".to_string()))
|
||||
));
|
||||
self.messages.push(response_message);
|
||||
session::persist_messages(&self.session_file, &self.messages, None).await?;
|
||||
session::persist_messages_with_schedule_id(&self.session_file, &self.messages, None, self.scheduled_job_id.clone()).await?;
|
||||
|
||||
drop(stream);
|
||||
break;
|
||||
@@ -862,7 +878,8 @@ impl Session {
|
||||
id: session_id.clone(),
|
||||
working_dir: std::env::current_dir()
|
||||
.expect("failed to get current session working directory"),
|
||||
schedule_id: None,
|
||||
schedule_id: self.scheduled_job_id.clone(),
|
||||
execution_mode: None,
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
@@ -872,7 +889,7 @@ impl Session {
|
||||
self.messages.push(message.clone());
|
||||
|
||||
// No need to update description on assistant messages
|
||||
session::persist_messages(&self.session_file, &self.messages, None).await?;
|
||||
session::persist_messages_with_schedule_id(&self.session_file, &self.messages, None, self.scheduled_job_id.clone()).await?;
|
||||
|
||||
if interactive {output::hide_thinking()};
|
||||
let _ = progress_bars.hide();
|
||||
@@ -1006,7 +1023,13 @@ impl Session {
|
||||
self.messages.push(response_message);
|
||||
|
||||
// No need for description update here
|
||||
session::persist_messages(&self.session_file, &self.messages, None).await?;
|
||||
session::persist_messages_with_schedule_id(
|
||||
&self.session_file,
|
||||
&self.messages,
|
||||
None,
|
||||
self.scheduled_job_id.clone(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let prompt = format!(
|
||||
"The existing call to {} was interrupted. How would you like to proceed?",
|
||||
@@ -1015,7 +1038,13 @@ impl Session {
|
||||
self.messages.push(Message::assistant().with_text(&prompt));
|
||||
|
||||
// No need for description update here
|
||||
session::persist_messages(&self.session_file, &self.messages, None).await?;
|
||||
session::persist_messages_with_schedule_id(
|
||||
&self.session_file,
|
||||
&self.messages,
|
||||
None,
|
||||
self.scheduled_job_id.clone(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
output::render_message(&Message::assistant().with_text(&prompt), self.debug);
|
||||
} else {
|
||||
@@ -1029,8 +1058,13 @@ impl Session {
|
||||
self.messages.push(Message::assistant().with_text(prompt));
|
||||
|
||||
// No need for description update here
|
||||
session::persist_messages(&self.session_file, &self.messages, None)
|
||||
.await?;
|
||||
session::persist_messages_with_schedule_id(
|
||||
&self.session_file,
|
||||
&self.messages,
|
||||
None,
|
||||
self.scheduled_job_id.clone(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
output::render_message(
|
||||
&Message::assistant().with_text(prompt),
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
[package]
|
||||
name = "goose-scheduler-executor"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
goose = { path = "../goose" }
|
||||
mcp-core = { path = "../mcp-core" }
|
||||
anyhow = "1.0"
|
||||
tokio = { version = "1.0", features = ["full"] }
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
clap = { version = "4.0", features = ["derive"] }
|
||||
futures = "0.3"
|
||||
serde_json = "1.0"
|
||||
serde_yaml = "0.9"
|
||||
@@ -1,215 +0,0 @@
|
||||
use anyhow::{anyhow, Result};
|
||||
use clap::Parser;
|
||||
use goose::agents::{Agent, SessionConfig};
|
||||
use goose::config::Config;
|
||||
use goose::message::Message;
|
||||
use goose::providers::create;
|
||||
use goose::recipe::Recipe;
|
||||
use goose::session;
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use tracing::info;
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(author, version, about, long_about = None)]
|
||||
struct Args {
|
||||
/// Job ID for the scheduled job
|
||||
job_id: String,
|
||||
|
||||
/// Path to the recipe file to execute
|
||||
recipe_path: String,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
// Initialize tracing
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
|
||||
.init();
|
||||
|
||||
let args = Args::parse();
|
||||
|
||||
info!("Starting goose-scheduler-executor for job: {}", args.job_id);
|
||||
info!("Recipe path: {}", args.recipe_path);
|
||||
|
||||
// Execute the recipe and get session ID
|
||||
let session_id = execute_recipe(&args.job_id, &args.recipe_path).await?;
|
||||
|
||||
// Output session ID to stdout (this is what the Go service expects)
|
||||
println!("{}", session_id);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn execute_recipe(job_id: &str, recipe_path: &str) -> Result<String> {
|
||||
let recipe_path_buf = Path::new(recipe_path);
|
||||
|
||||
// Check if recipe file exists
|
||||
if !recipe_path_buf.exists() {
|
||||
return Err(anyhow!("Recipe file not found: {}", recipe_path));
|
||||
}
|
||||
|
||||
// Read and parse recipe
|
||||
let recipe_content = fs::read_to_string(recipe_path_buf)?;
|
||||
let recipe: Recipe = {
|
||||
let extension = recipe_path_buf
|
||||
.extension()
|
||||
.and_then(|os_str| os_str.to_str())
|
||||
.unwrap_or("yaml")
|
||||
.to_lowercase();
|
||||
|
||||
match extension.as_str() {
|
||||
"json" | "jsonl" => serde_json::from_str::<Recipe>(&recipe_content)
|
||||
.map_err(|e| anyhow!("Failed to parse JSON recipe '{}': {}", recipe_path, e))?,
|
||||
"yaml" | "yml" => serde_yaml::from_str::<Recipe>(&recipe_content)
|
||||
.map_err(|e| anyhow!("Failed to parse YAML recipe '{}': {}", recipe_path, e))?,
|
||||
_ => {
|
||||
return Err(anyhow!(
|
||||
"Unsupported recipe file extension '{}' for: {}",
|
||||
extension,
|
||||
recipe_path
|
||||
));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Create agent
|
||||
let agent = Agent::new();
|
||||
|
||||
// Get provider configuration
|
||||
let global_config = Config::global();
|
||||
let provider_name: String = global_config.get_param("GOOSE_PROVIDER").map_err(|_| {
|
||||
anyhow!("GOOSE_PROVIDER not configured. Run 'goose configure' or set env var.")
|
||||
})?;
|
||||
let model_name: String = global_config.get_param("GOOSE_MODEL").map_err(|_| {
|
||||
anyhow!("GOOSE_MODEL not configured. Run 'goose configure' or set env var.")
|
||||
})?;
|
||||
|
||||
let model_config = goose::model::ModelConfig::new(model_name);
|
||||
let provider = create(&provider_name, model_config)
|
||||
.map_err(|e| anyhow!("Failed to create provider '{}': {}", provider_name, e))?;
|
||||
|
||||
// Set provider on agent
|
||||
agent
|
||||
.update_provider(provider)
|
||||
.await
|
||||
.map_err(|e| anyhow!("Failed to set provider on agent: {}", e))?;
|
||||
|
||||
info!(
|
||||
"Agent configured with provider '{}' for job '{}'",
|
||||
provider_name, job_id
|
||||
);
|
||||
|
||||
// Generate session ID
|
||||
let session_id = session::generate_session_id();
|
||||
|
||||
// Check if recipe has a prompt
|
||||
let Some(prompt_text) = recipe.prompt else {
|
||||
info!(
|
||||
"Recipe '{}' has no prompt to execute for job '{}'",
|
||||
recipe_path, job_id
|
||||
);
|
||||
|
||||
// Create empty session for consistency
|
||||
let session_file_path = goose::session::storage::get_path(
|
||||
goose::session::storage::Identifier::Name(session_id.clone()),
|
||||
);
|
||||
|
||||
let metadata = goose::session::storage::SessionMetadata {
|
||||
working_dir: env::current_dir().unwrap_or_default(),
|
||||
description: "Empty job - no prompt".to_string(),
|
||||
schedule_id: Some(job_id.to_string()),
|
||||
message_count: 0,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
goose::session::storage::save_messages_with_metadata(&session_file_path, &metadata, &[])
|
||||
.map_err(|e| anyhow!("Failed to persist metadata for empty job: {}", e))?;
|
||||
|
||||
return Ok(session_id);
|
||||
};
|
||||
|
||||
// Create session configuration
|
||||
let current_dir =
|
||||
env::current_dir().map_err(|e| anyhow!("Failed to get current directory: {}", e))?;
|
||||
|
||||
let session_config = SessionConfig {
|
||||
id: goose::session::storage::Identifier::Name(session_id.clone()),
|
||||
working_dir: current_dir.clone(),
|
||||
schedule_id: Some(job_id.to_string()),
|
||||
};
|
||||
|
||||
// Execute the recipe
|
||||
let mut messages = vec![Message::user().with_text(prompt_text)];
|
||||
|
||||
info!("Executing recipe for job '{}' with prompt", job_id);
|
||||
|
||||
let mut stream = agent
|
||||
.reply(&messages, Some(session_config))
|
||||
.await
|
||||
.map_err(|e| anyhow!("Agent failed to reply for recipe '{}': {}", recipe_path, e))?;
|
||||
|
||||
// Process the response stream
|
||||
use futures::StreamExt;
|
||||
use goose::agents::AgentEvent;
|
||||
|
||||
while let Some(message_result) = stream.next().await {
|
||||
match message_result {
|
||||
Ok(AgentEvent::Message(msg)) => {
|
||||
if msg.role == mcp_core::role::Role::Assistant {
|
||||
info!("[Job {}] Assistant response received", job_id);
|
||||
}
|
||||
messages.push(msg);
|
||||
}
|
||||
Ok(AgentEvent::McpNotification(_)) => {
|
||||
// Handle notifications if needed
|
||||
}
|
||||
Ok(AgentEvent::ModelChange { .. }) => {
|
||||
// Model change events are informational, just continue
|
||||
}
|
||||
Err(e) => {
|
||||
return Err(anyhow!("Error receiving message from agent: {}", e));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Save session
|
||||
let session_file_path = goose::session::storage::get_path(
|
||||
goose::session::storage::Identifier::Name(session_id.clone()),
|
||||
);
|
||||
|
||||
// Try to read updated metadata, or create fallback
|
||||
match goose::session::storage::read_metadata(&session_file_path) {
|
||||
Ok(mut updated_metadata) => {
|
||||
updated_metadata.message_count = messages.len();
|
||||
goose::session::storage::save_messages_with_metadata(
|
||||
&session_file_path,
|
||||
&updated_metadata,
|
||||
&messages,
|
||||
)
|
||||
.map_err(|e| anyhow!("Failed to persist final messages: {}", e))?;
|
||||
}
|
||||
Err(_) => {
|
||||
let fallback_metadata = goose::session::storage::SessionMetadata {
|
||||
working_dir: current_dir,
|
||||
description: format!("Scheduled job: {}", job_id),
|
||||
schedule_id: Some(job_id.to_string()),
|
||||
message_count: messages.len(),
|
||||
..Default::default()
|
||||
};
|
||||
goose::session::storage::save_messages_with_metadata(
|
||||
&session_file_path,
|
||||
&fallback_metadata,
|
||||
&messages,
|
||||
)
|
||||
.map_err(|e| anyhow!("Failed to persist messages with fallback metadata: {}", e))?;
|
||||
}
|
||||
}
|
||||
|
||||
info!(
|
||||
"Finished executing job '{}', session: {}",
|
||||
job_id, session_id
|
||||
);
|
||||
Ok(session_id)
|
||||
}
|
||||
@@ -40,6 +40,7 @@ struct ChatRequest {
|
||||
messages: Vec<Message>,
|
||||
session_id: Option<String>,
|
||||
session_working_dir: String,
|
||||
scheduled_job_id: Option<String>,
|
||||
}
|
||||
|
||||
pub struct SseResponse {
|
||||
@@ -181,7 +182,8 @@ async fn handler(
|
||||
Some(SessionConfig {
|
||||
id: session::Identifier::Name(session_id.clone()),
|
||||
working_dir: PathBuf::from(session_working_dir),
|
||||
schedule_id: None,
|
||||
schedule_id: request.scheduled_job_id.clone(),
|
||||
execution_mode: None,
|
||||
}),
|
||||
)
|
||||
.await
|
||||
@@ -303,6 +305,7 @@ struct AskRequest {
|
||||
prompt: String,
|
||||
session_id: Option<String>,
|
||||
session_working_dir: String,
|
||||
scheduled_job_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
@@ -339,7 +342,8 @@ async fn ask_handler(
|
||||
Some(SessionConfig {
|
||||
id: session::Identifier::Name(session_id.clone()),
|
||||
working_dir: PathBuf::from(session_working_dir),
|
||||
schedule_id: None,
|
||||
schedule_id: request.scheduled_job_id.clone(),
|
||||
execution_mode: None,
|
||||
}),
|
||||
)
|
||||
.await
|
||||
@@ -578,6 +582,7 @@ mod tests {
|
||||
prompt: "test prompt".to_string(),
|
||||
session_id: Some("test-session".to_string()),
|
||||
session_working_dir: "test-working-dir".to_string(),
|
||||
scheduled_job_id: None,
|
||||
})
|
||||
.unwrap(),
|
||||
))
|
||||
|
||||
@@ -19,6 +19,8 @@ pub struct CreateScheduleRequest {
|
||||
id: String,
|
||||
recipe_source: String,
|
||||
cron: String,
|
||||
#[serde(default)]
|
||||
execution_mode: Option<String>, // "foreground" or "background"
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, utoipa::ToSchema)]
|
||||
@@ -124,6 +126,7 @@ async fn create_schedule(
|
||||
paused: false,
|
||||
current_session_id: None,
|
||||
process_start_time: None,
|
||||
execution_mode: req.execution_mode.or(Some("background".to_string())), // Default to background
|
||||
};
|
||||
scheduler
|
||||
.add_scheduled_job(job.clone())
|
||||
|
||||
@@ -574,7 +574,25 @@ impl Agent {
|
||||
let (mut tools, mut toolshim_tools, mut system_prompt) =
|
||||
self.prepare_tools_and_prompt().await?;
|
||||
|
||||
let goose_mode = config.get_param("GOOSE_MODE").unwrap_or("auto".to_string());
|
||||
// Get goose_mode from config, but override with execution_mode if provided in session config
|
||||
let mut goose_mode = config.get_param("GOOSE_MODE").unwrap_or("auto".to_string());
|
||||
|
||||
// If this is a scheduled job with an execution_mode, override the goose_mode
|
||||
if let Some(session_config) = &session {
|
||||
if let Some(execution_mode) = &session_config.execution_mode {
|
||||
// Map "foreground" to "auto" and "background" to "chat"
|
||||
goose_mode = match execution_mode.as_str() {
|
||||
"foreground" => "auto".to_string(),
|
||||
"background" => "chat".to_string(),
|
||||
_ => goose_mode,
|
||||
};
|
||||
tracing::info!(
|
||||
"Using execution_mode '{}' which maps to goose_mode '{}'",
|
||||
execution_mode,
|
||||
goose_mode
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let (tools_with_readonly_annotation, tools_without_annotation) =
|
||||
Self::categorize_tools_by_annotation(&tools);
|
||||
|
||||
@@ -144,6 +144,7 @@ pub fn manage_schedule_tool() -> Tool {
|
||||
"job_id": {"type": "string", "description": "Job identifier for operations on existing jobs"},
|
||||
"recipe_path": {"type": "string", "description": "Path to recipe file for create action"},
|
||||
"cron_expression": {"type": "string", "description": "A six field cron expression for create action"},
|
||||
"execution_mode": {"type": "string", "description": "Execution mode for create action: 'foreground' or 'background'", "enum": ["foreground", "background"], "default": "background"},
|
||||
"limit": {"type": "integer", "description": "Limit for sessions list", "default": 50},
|
||||
"session_id": {"type": "string", "description": "Session identifier for session_content action"}
|
||||
}
|
||||
|
||||
@@ -94,6 +94,20 @@ impl Agent {
|
||||
ToolError::ExecutionError("Missing 'cron_expression' parameter".to_string())
|
||||
})?;
|
||||
|
||||
// Get the execution_mode parameter, defaulting to "background" if not provided
|
||||
let execution_mode = arguments
|
||||
.get("execution_mode")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("background");
|
||||
|
||||
// Validate execution_mode is either "foreground" or "background"
|
||||
if execution_mode != "foreground" && execution_mode != "background" {
|
||||
return Err(ToolError::ExecutionError(format!(
|
||||
"Invalid execution_mode: {}. Must be 'foreground' or 'background'",
|
||||
execution_mode
|
||||
)));
|
||||
}
|
||||
|
||||
// Validate recipe file exists and is readable
|
||||
if !std::path::Path::new(recipe_path).exists() {
|
||||
return Err(ToolError::ExecutionError(format!(
|
||||
@@ -135,12 +149,13 @@ impl Agent {
|
||||
paused: false,
|
||||
current_session_id: None,
|
||||
process_start_time: None,
|
||||
execution_mode: Some(execution_mode.to_string()),
|
||||
};
|
||||
|
||||
match scheduler.add_scheduled_job(job).await {
|
||||
Ok(()) => Ok(vec![Content::text(format!(
|
||||
"Successfully created scheduled job '{}' for recipe '{}' with cron expression '{}'",
|
||||
job_id, recipe_path, cron_expression
|
||||
"Successfully created scheduled job '{}' for recipe '{}' with cron expression '{}' in {} mode",
|
||||
job_id, recipe_path, cron_expression, execution_mode
|
||||
))]),
|
||||
Err(e) => Err(ToolError::ExecutionError(format!(
|
||||
"Failed to create job: {}",
|
||||
|
||||
@@ -23,5 +23,7 @@ pub struct SessionConfig {
|
||||
/// Working directory for the session
|
||||
pub working_dir: PathBuf,
|
||||
/// ID of the schedule that triggered this session, if any
|
||||
pub schedule_id: Option<String>, // NEW
|
||||
pub schedule_id: Option<String>,
|
||||
/// Execution mode for scheduled jobs: "foreground" or "background"
|
||||
pub execution_mode: Option<String>,
|
||||
}
|
||||
|
||||
@@ -122,6 +122,8 @@ pub struct ScheduledJob {
|
||||
pub current_session_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub process_start_time: Option<DateTime<Utc>>,
|
||||
#[serde(default)]
|
||||
pub execution_mode: Option<String>, // "foreground" or "background"
|
||||
}
|
||||
|
||||
async fn persist_jobs_from_arc(
|
||||
@@ -1059,6 +1061,10 @@ async fn run_scheduled_job_internal(
|
||||
}
|
||||
tracing::info!("Agent configured with provider for job '{}'", job.id);
|
||||
|
||||
// Log the execution mode
|
||||
let execution_mode = job.execution_mode.as_deref().unwrap_or("background");
|
||||
tracing::info!("Job '{}' running in {} mode", job.id, execution_mode);
|
||||
|
||||
let session_id_for_return = session::generate_session_id();
|
||||
|
||||
// Update the job with the session ID if we have access to the jobs arc
|
||||
@@ -1091,6 +1097,7 @@ async fn run_scheduled_job_internal(
|
||||
id: crate::session::storage::Identifier::Name(session_id_for_return.clone()),
|
||||
working_dir: current_dir.clone(),
|
||||
schedule_id: Some(job.id.clone()),
|
||||
execution_mode: job.execution_mode.clone(),
|
||||
};
|
||||
|
||||
match agent
|
||||
@@ -1323,6 +1330,7 @@ mod tests {
|
||||
paused: false,
|
||||
current_session_id: None,
|
||||
process_start_time: None,
|
||||
execution_mode: Some("background".to_string()), // Default for test
|
||||
};
|
||||
|
||||
// Create the mock provider instance for the test
|
||||
|
||||
@@ -15,41 +15,33 @@ impl SchedulerType {
|
||||
pub fn from_config() -> Self {
|
||||
let config = Config::global();
|
||||
|
||||
// First check if alpha features are enabled
|
||||
// If not, always use legacy scheduler regardless of GOOSE_SCHEDULER_TYPE
|
||||
match config.get_param::<String>("ALPHA") {
|
||||
Ok(alpha_value) => {
|
||||
// Only proceed with temporal if alpha is explicitly enabled
|
||||
if alpha_value.to_lowercase() != "true" {
|
||||
tracing::info!("Alpha features disabled, using legacy scheduler");
|
||||
return SchedulerType::Legacy;
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
// No ALPHA env var means alpha features are disabled
|
||||
tracing::info!("No ALPHA environment variable found, using legacy scheduler");
|
||||
return SchedulerType::Legacy;
|
||||
}
|
||||
}
|
||||
// Debug logging to help troubleshoot environment variable issues
|
||||
tracing::debug!("Checking scheduler configuration...");
|
||||
|
||||
// Alpha is enabled, now check scheduler type preference
|
||||
// Check scheduler type preference from GOOSE_SCHEDULER_TYPE
|
||||
match config.get_param::<String>("GOOSE_SCHEDULER_TYPE") {
|
||||
Ok(scheduler_type) => match scheduler_type.to_lowercase().as_str() {
|
||||
"temporal" => SchedulerType::Temporal,
|
||||
"legacy" => SchedulerType::Legacy,
|
||||
_ => {
|
||||
tracing::warn!(
|
||||
"Unknown scheduler type '{}', defaulting to legacy scheduler",
|
||||
scheduler_type
|
||||
);
|
||||
SchedulerType::Legacy
|
||||
Ok(scheduler_type) => {
|
||||
tracing::debug!(
|
||||
"Found GOOSE_SCHEDULER_TYPE environment variable: '{}'",
|
||||
scheduler_type
|
||||
);
|
||||
match scheduler_type.to_lowercase().as_str() {
|
||||
"temporal" => SchedulerType::Temporal,
|
||||
"legacy" => SchedulerType::Legacy,
|
||||
_ => {
|
||||
tracing::warn!(
|
||||
"Unknown scheduler type '{}', defaulting to legacy scheduler",
|
||||
scheduler_type
|
||||
);
|
||||
SchedulerType::Legacy
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
Err(_) => {
|
||||
// When alpha is enabled but no explicit scheduler type is set,
|
||||
// default to temporal scheduler
|
||||
tracing::info!("Alpha enabled, defaulting to temporal scheduler");
|
||||
SchedulerType::Temporal
|
||||
tracing::debug!("GOOSE_SCHEDULER_TYPE environment variable not found");
|
||||
// When no explicit scheduler type is set, default to legacy scheduler
|
||||
tracing::info!("No scheduler type specified, defaulting to legacy scheduler");
|
||||
SchedulerType::Legacy
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -123,62 +115,38 @@ mod tests {
|
||||
use temp_env::with_vars;
|
||||
|
||||
#[test]
|
||||
fn test_scheduler_type_no_alpha_env() {
|
||||
// Test that without ALPHA env var, we always get Legacy scheduler
|
||||
with_vars(
|
||||
[
|
||||
("ALPHA", None::<&str>),
|
||||
("GOOSE_SCHEDULER_TYPE", Some("temporal")),
|
||||
],
|
||||
|| {
|
||||
let scheduler_type = SchedulerType::from_config();
|
||||
assert!(matches!(scheduler_type, SchedulerType::Legacy));
|
||||
},
|
||||
);
|
||||
fn test_scheduler_type_no_env() {
|
||||
// Test that without GOOSE_SCHEDULER_TYPE env var, we get Legacy scheduler
|
||||
with_vars([("GOOSE_SCHEDULER_TYPE", None::<&str>)], || {
|
||||
let scheduler_type = SchedulerType::from_config();
|
||||
assert!(matches!(scheduler_type, SchedulerType::Legacy));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_scheduler_type_alpha_false() {
|
||||
// Test that with ALPHA=false, we always get Legacy scheduler
|
||||
with_vars(
|
||||
[
|
||||
("ALPHA", Some("false")),
|
||||
("GOOSE_SCHEDULER_TYPE", Some("temporal")),
|
||||
],
|
||||
|| {
|
||||
let scheduler_type = SchedulerType::from_config();
|
||||
assert!(matches!(scheduler_type, SchedulerType::Legacy));
|
||||
},
|
||||
);
|
||||
fn test_scheduler_type_legacy() {
|
||||
// Test that with GOOSE_SCHEDULER_TYPE=legacy, we get Legacy scheduler
|
||||
with_vars([("GOOSE_SCHEDULER_TYPE", Some("legacy"))], || {
|
||||
let scheduler_type = SchedulerType::from_config();
|
||||
assert!(matches!(scheduler_type, SchedulerType::Legacy));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_scheduler_type_alpha_true_legacy() {
|
||||
// Test that with ALPHA=true and GOOSE_SCHEDULER_TYPE=legacy, we get Legacy scheduler
|
||||
with_vars(
|
||||
[
|
||||
("ALPHA", Some("true")),
|
||||
("GOOSE_SCHEDULER_TYPE", Some("legacy")),
|
||||
],
|
||||
|| {
|
||||
let scheduler_type = SchedulerType::from_config();
|
||||
assert!(matches!(scheduler_type, SchedulerType::Legacy));
|
||||
},
|
||||
);
|
||||
fn test_scheduler_type_temporal() {
|
||||
// Test that with GOOSE_SCHEDULER_TYPE=temporal, we get Temporal scheduler
|
||||
with_vars([("GOOSE_SCHEDULER_TYPE", Some("temporal"))], || {
|
||||
let scheduler_type = SchedulerType::from_config();
|
||||
assert!(matches!(scheduler_type, SchedulerType::Temporal));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_scheduler_type_alpha_true_unknown_scheduler_type() {
|
||||
// Test that with ALPHA=true and unknown scheduler type, we default to Legacy
|
||||
with_vars(
|
||||
[
|
||||
("ALPHA", Some("true")),
|
||||
("GOOSE_SCHEDULER_TYPE", Some("unknown")),
|
||||
],
|
||||
|| {
|
||||
let scheduler_type = SchedulerType::from_config();
|
||||
assert!(matches!(scheduler_type, SchedulerType::Legacy));
|
||||
},
|
||||
);
|
||||
fn test_scheduler_type_unknown() {
|
||||
// Test that with unknown scheduler type, we default to Legacy
|
||||
with_vars([("GOOSE_SCHEDULER_TYPE", Some("unknown"))], || {
|
||||
let scheduler_type = SchedulerType::from_config();
|
||||
assert!(matches!(scheduler_type, SchedulerType::Legacy));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,9 +3,10 @@ pub mod storage;
|
||||
|
||||
// Re-export common session types and functions
|
||||
pub use storage::{
|
||||
ensure_session_dir, generate_description, generate_session_id, get_most_recent_session,
|
||||
get_path, list_sessions, persist_messages, read_messages, read_metadata, update_metadata,
|
||||
Identifier, SessionMetadata,
|
||||
ensure_session_dir, generate_description, generate_description_with_schedule_id,
|
||||
generate_session_id, get_most_recent_session, get_path, list_sessions, persist_messages,
|
||||
persist_messages_with_schedule_id, read_messages, read_metadata, update_metadata, Identifier,
|
||||
SessionMetadata,
|
||||
};
|
||||
|
||||
pub use info::{get_session_info, SessionInfo};
|
||||
|
||||
@@ -437,6 +437,19 @@ pub async fn persist_messages(
|
||||
session_file: &Path,
|
||||
messages: &[Message],
|
||||
provider: Option<Arc<dyn Provider>>,
|
||||
) -> Result<()> {
|
||||
persist_messages_with_schedule_id(session_file, messages, provider, None).await
|
||||
}
|
||||
|
||||
/// Write messages to a session file with metadata, including an optional scheduled job ID
|
||||
///
|
||||
/// Overwrites the file with metadata as the first line, followed by all messages in JSONL format.
|
||||
/// If a provider is supplied, it will automatically generate a description when appropriate.
|
||||
pub async fn persist_messages_with_schedule_id(
|
||||
session_file: &Path,
|
||||
messages: &[Message],
|
||||
provider: Option<Arc<dyn Provider>>,
|
||||
schedule_id: Option<String>,
|
||||
) -> Result<()> {
|
||||
// Count user messages
|
||||
let user_message_count = messages
|
||||
@@ -448,11 +461,16 @@ pub async fn persist_messages(
|
||||
match provider {
|
||||
Some(provider) if user_message_count < 4 => {
|
||||
//generate_description is responsible for writing the messages
|
||||
generate_description(session_file, messages, provider).await
|
||||
generate_description_with_schedule_id(session_file, messages, provider, schedule_id)
|
||||
.await
|
||||
}
|
||||
_ => {
|
||||
// Read existing metadata
|
||||
let metadata = read_metadata(session_file)?;
|
||||
let mut metadata = read_metadata(session_file)?;
|
||||
// Update the schedule_id if provided
|
||||
if schedule_id.is_some() {
|
||||
metadata.schedule_id = schedule_id;
|
||||
}
|
||||
// Write the file with metadata and messages
|
||||
save_messages_with_metadata(session_file, &metadata, messages)
|
||||
}
|
||||
@@ -492,6 +510,19 @@ pub async fn generate_description(
|
||||
session_file: &Path,
|
||||
messages: &[Message],
|
||||
provider: Arc<dyn Provider>,
|
||||
) -> Result<()> {
|
||||
generate_description_with_schedule_id(session_file, messages, provider, None).await
|
||||
}
|
||||
|
||||
/// Generate a description for the session using the provider, including an optional scheduled job ID
|
||||
///
|
||||
/// This function is called when appropriate to generate a short description
|
||||
/// of the session based on the conversation history.
|
||||
pub async fn generate_description_with_schedule_id(
|
||||
session_file: &Path,
|
||||
messages: &[Message],
|
||||
provider: Arc<dyn Provider>,
|
||||
schedule_id: Option<String>,
|
||||
) -> Result<()> {
|
||||
// Create a special message asking for a 3-word description
|
||||
let mut description_prompt = "Based on the conversation so far, provide a concise description of this session in 4 words or less. This will be used for finding the session later in a UI with limited space - reply *ONLY* with the description".to_string();
|
||||
@@ -527,8 +558,11 @@ pub async fn generate_description(
|
||||
// Read current metadata
|
||||
let mut metadata = read_metadata(session_file)?;
|
||||
|
||||
// Update description
|
||||
// Update description and schedule_id
|
||||
metadata.description = description;
|
||||
if schedule_id.is_some() {
|
||||
metadata.schedule_id = schedule_id;
|
||||
}
|
||||
|
||||
// Update the file with the new metadata and existing messages
|
||||
save_messages_with_metadata(session_file, &metadata, messages)
|
||||
|
||||
@@ -16,8 +16,9 @@ use crate::session::storage::SessionMetadata;
|
||||
const TEMPORAL_SERVICE_STARTUP_TIMEOUT: Duration = Duration::from_secs(15);
|
||||
const TEMPORAL_SERVICE_HEALTH_CHECK_INTERVAL: Duration = Duration::from_millis(500);
|
||||
|
||||
// Default ports to try when discovering the service
|
||||
const DEFAULT_HTTP_PORTS: &[u16] = &[8080, 8081, 8082, 8083, 8084, 8085];
|
||||
// Default ports to try when discovering the service - using high, obscure ports
|
||||
// to avoid conflicts with common services
|
||||
const DEFAULT_HTTP_PORTS: &[u16] = &[58080, 58081, 58082, 58083, 58084, 58085];
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
struct JobRequest {
|
||||
@@ -25,6 +26,7 @@ struct JobRequest {
|
||||
job_id: Option<String>,
|
||||
cron: Option<String>,
|
||||
recipe_path: Option<String>,
|
||||
execution_mode: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
@@ -45,6 +47,7 @@ struct TemporalJobStatus {
|
||||
currently_running: bool,
|
||||
paused: bool,
|
||||
created_at: String,
|
||||
execution_mode: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
@@ -52,13 +55,14 @@ struct RunNowResponse {
|
||||
session_id: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct PortConfig {
|
||||
http_port: u16,
|
||||
temporal_port: u16,
|
||||
ui_port: u16,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct TemporalScheduler {
|
||||
http_client: Client,
|
||||
service_url: String,
|
||||
@@ -107,50 +111,61 @@ impl TemporalScheduler {
|
||||
port_config,
|
||||
});
|
||||
|
||||
// Start the status monitor to keep job statuses in sync
|
||||
if let Err(e) = final_scheduler.start_status_monitor().await {
|
||||
tracing::warn!("Failed to start status monitor: {}", e);
|
||||
}
|
||||
|
||||
info!("TemporalScheduler initialized successfully");
|
||||
Ok(final_scheduler)
|
||||
}
|
||||
|
||||
async fn discover_http_port(_http_client: &Client) -> Result<u16, SchedulerError> {
|
||||
// First, try to find a running service using pgrep and lsof
|
||||
if let Ok(port) = Self::find_temporal_service_port_from_processes() {
|
||||
info!(
|
||||
"Found Temporal service port {} from running processes",
|
||||
port
|
||||
);
|
||||
return Ok(port);
|
||||
}
|
||||
|
||||
// If no running service found, we need to find a free port to start the service on
|
||||
info!("No running Temporal service found, finding free port to start service");
|
||||
async fn discover_http_port(http_client: &Client) -> Result<u16, SchedulerError> {
|
||||
info!("Discovering Temporal service port...");
|
||||
|
||||
// Check PORT environment variable first
|
||||
if let Ok(port_str) = std::env::var("PORT") {
|
||||
if let Ok(port) = port_str.parse::<u16>() {
|
||||
if Self::is_port_free(port).await {
|
||||
info!("Using PORT environment variable: {}", port);
|
||||
if Self::is_temporal_service_running(http_client, port).await {
|
||||
info!(
|
||||
"Found running Temporal service on PORT environment variable: {}",
|
||||
port
|
||||
);
|
||||
return Ok(port);
|
||||
} else if Self::is_port_free(port).await {
|
||||
info!("Using PORT environment variable for new service: {}", port);
|
||||
return Ok(port);
|
||||
} else {
|
||||
warn!(
|
||||
"PORT environment variable {} is not free, finding alternative",
|
||||
"PORT environment variable {} is occupied by non-Temporal service",
|
||||
port
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Try to find a free port from the default list
|
||||
// Try to find an existing Temporal service on default ports
|
||||
for &port in DEFAULT_HTTP_PORTS {
|
||||
if Self::is_temporal_service_running(http_client, port).await {
|
||||
info!("Found existing Temporal service on port {}", port);
|
||||
return Ok(port);
|
||||
}
|
||||
}
|
||||
|
||||
// If no existing service found, find a free port to start a new one
|
||||
info!("No existing Temporal service found, finding free port to start new service");
|
||||
|
||||
for &port in DEFAULT_HTTP_PORTS {
|
||||
if Self::is_port_free(port).await {
|
||||
info!("Found free port {} for Temporal service", port);
|
||||
info!("Found free port {} for new Temporal service", port);
|
||||
return Ok(port);
|
||||
}
|
||||
}
|
||||
|
||||
// If all default ports are taken, find any free port in a reasonable range
|
||||
for port in 8086..8200 {
|
||||
for port in 58086..58200 {
|
||||
if Self::is_port_free(port).await {
|
||||
info!("Found free port {} for Temporal service", port);
|
||||
info!("Found free port {} for new Temporal service", port);
|
||||
return Ok(port);
|
||||
}
|
||||
}
|
||||
@@ -160,112 +175,51 @@ impl TemporalScheduler {
|
||||
))
|
||||
}
|
||||
|
||||
async fn is_port_free(port: u16) -> bool {
|
||||
use std::net::{SocketAddr, TcpListener};
|
||||
use std::time::Duration;
|
||||
/// Check if a Temporal service is running and responding on the given port
|
||||
async fn is_temporal_service_running(http_client: &Client, port: u16) -> bool {
|
||||
let health_url = format!("http://127.0.0.1:{}/health", port);
|
||||
|
||||
let addr: SocketAddr = format!("127.0.0.1:{}", port).parse().unwrap();
|
||||
|
||||
// First, try to bind to the port
|
||||
let listener_result = TcpListener::bind(addr);
|
||||
match listener_result {
|
||||
Ok(listener) => {
|
||||
// Successfully bound, so port was free
|
||||
drop(listener); // Release the port immediately
|
||||
|
||||
// Double-check by trying to connect to see if anything is actually listening
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_millis(500))
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
let test_url = format!("http://127.0.0.1:{}", port);
|
||||
match client.get(&test_url).send().await {
|
||||
Ok(_) => {
|
||||
// Something responded, so port is actually in use
|
||||
warn!(
|
||||
"Port {} appeared free but something is listening on it",
|
||||
port
|
||||
);
|
||||
false
|
||||
}
|
||||
Err(_) => {
|
||||
// Nothing responded, port is truly free
|
||||
true
|
||||
}
|
||||
}
|
||||
match http_client
|
||||
.get(&health_url)
|
||||
.timeout(Duration::from_millis(1000))
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(response) if response.status().is_success() => {
|
||||
info!("Confirmed Temporal service is running on port {}", port);
|
||||
true
|
||||
}
|
||||
Ok(response) => {
|
||||
info!(
|
||||
"Port {} is responding but not a healthy Temporal service (status: {})",
|
||||
port,
|
||||
response.status()
|
||||
);
|
||||
false
|
||||
}
|
||||
Err(_) => {
|
||||
// Could not bind, port is definitely in use
|
||||
// Port might be free or occupied by something else
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn find_temporal_service_port_from_processes() -> Result<u16, SchedulerError> {
|
||||
// Use pgrep to find temporal-service processes
|
||||
let pgrep_output = Command::new("pgrep")
|
||||
.arg("-f")
|
||||
.arg("temporal-service")
|
||||
.output()
|
||||
.map_err(|e| SchedulerError::SchedulerInternalError(format!("pgrep failed: {}", e)))?;
|
||||
async fn is_port_free(port: u16) -> bool {
|
||||
use std::net::{SocketAddr, TcpListener};
|
||||
|
||||
if !pgrep_output.status.success() {
|
||||
return Err(SchedulerError::SchedulerInternalError(
|
||||
"No temporal-service processes found".to_string(),
|
||||
));
|
||||
}
|
||||
let addr: SocketAddr = format!("127.0.0.1:{}", port).parse().unwrap();
|
||||
|
||||
let pids_str = String::from_utf8_lossy(&pgrep_output.stdout);
|
||||
let pids: Vec<&str> = pids_str
|
||||
.trim()
|
||||
.split('\n')
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect();
|
||||
|
||||
for pid in pids {
|
||||
// Use lsof to find listening ports for this PID
|
||||
let lsof_output = Command::new("lsof")
|
||||
.arg("-p")
|
||||
.arg(pid)
|
||||
.arg("-i")
|
||||
.arg("tcp")
|
||||
.arg("-P") // Show port numbers instead of service names
|
||||
.arg("-n") // Show IP addresses instead of hostnames
|
||||
.output();
|
||||
|
||||
if let Ok(output) = lsof_output {
|
||||
let lsof_str = String::from_utf8_lossy(&output.stdout);
|
||||
|
||||
// Look for HTTP API port (typically 8080-8999 range)
|
||||
for line in lsof_str.lines() {
|
||||
if line.contains("LISTEN") && line.contains("temporal-") {
|
||||
// Parse lines like: "temporal-service 12345 user 6u IPv4 0x... 0t0 TCP *:8081 (LISTEN)"
|
||||
let parts: Vec<&str> = line.split_whitespace().collect();
|
||||
|
||||
// Find the TCP part which contains the port
|
||||
for part in &parts {
|
||||
if part.starts_with("TCP") && part.contains(':') {
|
||||
// Extract port from TCP *:8081 or TCP 127.0.0.1:8081
|
||||
if let Some(port_str) = part.split(':').next_back() {
|
||||
if let Ok(port) = port_str.parse::<u16>() {
|
||||
// HTTP API ports are typically in 8080-8999 range
|
||||
if (8080..9000).contains(&port) {
|
||||
info!("Found HTTP API port {} for PID {}", port, pid);
|
||||
return Ok(port);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Try to bind to the port
|
||||
match TcpListener::bind(addr) {
|
||||
Ok(_listener) => {
|
||||
// Successfully bound, so port is free
|
||||
true
|
||||
}
|
||||
Err(_) => {
|
||||
// Could not bind, port is in use
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
Err(SchedulerError::SchedulerInternalError(
|
||||
"Could not find HTTP API port from temporal-service processes".to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
async fn fetch_port_config(&self) -> Result<PortConfig, SchedulerError> {
|
||||
@@ -305,7 +259,7 @@ impl TemporalScheduler {
|
||||
self.port_config.temporal_port
|
||||
}
|
||||
|
||||
/// Get the HTTP API port
|
||||
/// Get the HTTP API port
|
||||
pub fn get_http_port(&self) -> u16 {
|
||||
self.port_config.http_port
|
||||
}
|
||||
@@ -366,7 +320,7 @@ impl TemporalScheduler {
|
||||
command.process_group(0);
|
||||
}
|
||||
|
||||
let child = command.spawn().map_err(|e| {
|
||||
let mut child = command.spawn().map_err(|e| {
|
||||
SchedulerError::SchedulerInternalError(format!(
|
||||
"Failed to start Go temporal service: {}",
|
||||
e
|
||||
@@ -379,9 +333,6 @@ impl TemporalScheduler {
|
||||
pid, self.port_config.http_port
|
||||
);
|
||||
|
||||
// Don't wait for the child process - let it run independently
|
||||
std::mem::forget(child);
|
||||
|
||||
// Give the process a moment to start up
|
||||
sleep(Duration::from_millis(100)).await;
|
||||
|
||||
@@ -410,6 +361,12 @@ impl TemporalScheduler {
|
||||
}
|
||||
}
|
||||
|
||||
// Detach the child process by not waiting for it
|
||||
// This allows it to continue running independently
|
||||
std::thread::spawn(move || {
|
||||
let _ = child.wait();
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -535,6 +492,7 @@ impl TemporalScheduler {
|
||||
job_id: Some(job.id.clone()),
|
||||
cron: Some(job.cron.clone()),
|
||||
recipe_path: Some(job.source.clone()),
|
||||
execution_mode: job.execution_mode.clone(),
|
||||
};
|
||||
|
||||
let response = self.make_request(request).await?;
|
||||
@@ -554,6 +512,7 @@ impl TemporalScheduler {
|
||||
job_id: None,
|
||||
cron: None,
|
||||
recipe_path: None,
|
||||
execution_mode: None,
|
||||
};
|
||||
|
||||
let response = self.make_request(request).await?;
|
||||
@@ -572,6 +531,7 @@ impl TemporalScheduler {
|
||||
paused: tj.paused,
|
||||
current_session_id: None, // Not provided by Temporal service
|
||||
process_start_time: None, // Not provided by Temporal service
|
||||
execution_mode: tj.execution_mode,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
@@ -587,6 +547,7 @@ impl TemporalScheduler {
|
||||
job_id: Some(id.to_string()),
|
||||
cron: None,
|
||||
recipe_path: None,
|
||||
execution_mode: None,
|
||||
};
|
||||
|
||||
let response = self.make_request(request).await?;
|
||||
@@ -605,6 +566,7 @@ impl TemporalScheduler {
|
||||
job_id: Some(id.to_string()),
|
||||
cron: None,
|
||||
recipe_path: None,
|
||||
execution_mode: None,
|
||||
};
|
||||
|
||||
let response = self.make_request(request).await?;
|
||||
@@ -623,6 +585,7 @@ impl TemporalScheduler {
|
||||
job_id: Some(id.to_string()),
|
||||
cron: None,
|
||||
recipe_path: None,
|
||||
execution_mode: None,
|
||||
};
|
||||
|
||||
let response = self.make_request(request).await?;
|
||||
@@ -642,6 +605,7 @@ impl TemporalScheduler {
|
||||
job_id: Some(id.to_string()),
|
||||
cron: None,
|
||||
recipe_path: None,
|
||||
execution_mode: None,
|
||||
};
|
||||
|
||||
let response = self.make_request(request).await?;
|
||||
@@ -717,20 +681,172 @@ impl TemporalScheduler {
|
||||
|
||||
pub async fn update_schedule(
|
||||
&self,
|
||||
_sched_id: &str,
|
||||
_new_cron: String,
|
||||
sched_id: &str,
|
||||
new_cron: String,
|
||||
) -> Result<(), SchedulerError> {
|
||||
warn!("update_schedule() method not implemented for TemporalScheduler - delete and recreate job instead");
|
||||
Err(SchedulerError::SchedulerInternalError(
|
||||
"update_schedule not supported - delete and recreate job instead".to_string(),
|
||||
))
|
||||
tracing::info!(
|
||||
"TemporalScheduler: update_schedule() called for job '{}' with cron '{}'",
|
||||
sched_id,
|
||||
new_cron
|
||||
);
|
||||
|
||||
let request = JobRequest {
|
||||
action: "update".to_string(),
|
||||
job_id: Some(sched_id.to_string()),
|
||||
cron: Some(new_cron),
|
||||
recipe_path: None,
|
||||
execution_mode: None,
|
||||
};
|
||||
|
||||
let response = self.make_request(request).await?;
|
||||
|
||||
if response.success {
|
||||
info!("Successfully updated scheduled job: {}", sched_id);
|
||||
Ok(())
|
||||
} else {
|
||||
Err(SchedulerError::SchedulerInternalError(response.message))
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn kill_running_job(&self, _sched_id: &str) -> Result<(), SchedulerError> {
|
||||
warn!("kill_running_job() method not implemented for TemporalScheduler");
|
||||
Err(SchedulerError::SchedulerInternalError(
|
||||
"kill_running_job not supported by TemporalScheduler".to_string(),
|
||||
))
|
||||
pub async fn kill_running_job(&self, sched_id: &str) -> Result<(), SchedulerError> {
|
||||
tracing::info!(
|
||||
"TemporalScheduler: kill_running_job() called for job '{}'",
|
||||
sched_id
|
||||
);
|
||||
|
||||
let request = JobRequest {
|
||||
action: "kill_job".to_string(),
|
||||
job_id: Some(sched_id.to_string()),
|
||||
cron: None,
|
||||
recipe_path: None,
|
||||
execution_mode: None,
|
||||
};
|
||||
|
||||
let response = self.make_request(request).await?;
|
||||
|
||||
if response.success {
|
||||
info!("Successfully killed running job: {}", sched_id);
|
||||
Ok(())
|
||||
} else {
|
||||
Err(SchedulerError::SchedulerInternalError(response.message))
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn update_job_status_from_sessions(&self) -> Result<(), SchedulerError> {
|
||||
tracing::info!("TemporalScheduler: Checking job status based on session activity");
|
||||
|
||||
let jobs = self.list_scheduled_jobs().await?;
|
||||
|
||||
for job in jobs {
|
||||
if job.currently_running {
|
||||
// First, check with the Temporal service directly for the most accurate status
|
||||
let request = JobRequest {
|
||||
action: "status".to_string(),
|
||||
job_id: Some(job.id.clone()),
|
||||
cron: None,
|
||||
recipe_path: None,
|
||||
execution_mode: None,
|
||||
};
|
||||
|
||||
match self.make_request(request).await {
|
||||
Ok(response) => {
|
||||
if response.success {
|
||||
if let Some(jobs) = response.jobs {
|
||||
if let Some(temporal_job) = jobs.iter().find(|j| j.id == job.id) {
|
||||
// If Temporal service says it's not running, trust that
|
||||
if !temporal_job.currently_running {
|
||||
tracing::info!(
|
||||
"Temporal service reports job '{}' is not running",
|
||||
job.id
|
||||
);
|
||||
continue; // Job is already marked as not running by Temporal
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
"Failed to get status from Temporal service for job '{}': {}",
|
||||
job.id,
|
||||
e
|
||||
);
|
||||
// Fall back to session-based checking if Temporal service is unavailable
|
||||
}
|
||||
}
|
||||
|
||||
// Secondary check: look for recent session activity (more lenient timing)
|
||||
let recent_sessions = self.sessions(&job.id, 3).await?;
|
||||
let mut has_active_session = false;
|
||||
|
||||
for (session_name, _) in recent_sessions {
|
||||
let session_path = crate::session::storage::get_path(
|
||||
crate::session::storage::Identifier::Name(session_name),
|
||||
);
|
||||
|
||||
// Check if session file was modified recently (within last 5 minutes instead of 2)
|
||||
if let Ok(metadata) = std::fs::metadata(&session_path) {
|
||||
if let Ok(modified) = metadata.modified() {
|
||||
let modified_dt: DateTime<Utc> = modified.into();
|
||||
let now = Utc::now();
|
||||
let time_diff = now.signed_duration_since(modified_dt);
|
||||
|
||||
// Increased tolerance to 5 minutes to reduce false positives
|
||||
if time_diff.num_minutes() < 5 {
|
||||
has_active_session = true;
|
||||
tracing::debug!(
|
||||
"Found active session for job '{}' modified {} minutes ago",
|
||||
job.id,
|
||||
time_diff.num_minutes()
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Only mark as completed if both Temporal service check failed AND no recent session activity
|
||||
if !has_active_session {
|
||||
tracing::info!(
|
||||
"No active sessions found for job '{}' in the last 5 minutes, marking as completed",
|
||||
job.id
|
||||
);
|
||||
|
||||
let request = JobRequest {
|
||||
action: "mark_completed".to_string(),
|
||||
job_id: Some(job.id.clone()),
|
||||
cron: None,
|
||||
recipe_path: None,
|
||||
execution_mode: None,
|
||||
};
|
||||
|
||||
if let Err(e) = self.make_request(request).await {
|
||||
tracing::warn!("Failed to mark job '{}' as completed: {}", job.id, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Periodically check and update job statuses based on session activity
|
||||
pub async fn start_status_monitor(&self) -> Result<(), SchedulerError> {
|
||||
let scheduler_clone = self.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
let mut interval = tokio::time::interval(Duration::from_secs(60)); // Check every 60 seconds instead of 30
|
||||
|
||||
loop {
|
||||
interval.tick().await;
|
||||
|
||||
if let Err(e) = scheduler_clone.update_job_status_from_sessions().await {
|
||||
tracing::warn!("Failed to update job statuses: {}", e);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get_running_job_info(
|
||||
@@ -742,24 +858,62 @@ impl TemporalScheduler {
|
||||
sched_id
|
||||
);
|
||||
|
||||
// First check if the job is marked as currently running
|
||||
let jobs = self.list_scheduled_jobs().await?;
|
||||
let job = jobs.iter().find(|j| j.id == sched_id);
|
||||
// Get the current job status from Temporal service
|
||||
let request = JobRequest {
|
||||
action: "status".to_string(),
|
||||
job_id: Some(sched_id.to_string()),
|
||||
cron: None,
|
||||
recipe_path: None,
|
||||
execution_mode: None,
|
||||
};
|
||||
|
||||
if let Some(job) = job {
|
||||
if job.currently_running {
|
||||
// For now, we'll return a placeholder session ID and current time
|
||||
// In a more complete implementation, we would track the actual session ID
|
||||
// and start time from the Temporal workflow execution
|
||||
let session_id =
|
||||
format!("temporal-{}-{}", sched_id, chrono::Utc::now().timestamp());
|
||||
let start_time = chrono::Utc::now(); // This should be the actual start time
|
||||
Ok(Some((session_id, start_time)))
|
||||
let response = self.make_request(request).await?;
|
||||
|
||||
if response.success {
|
||||
if let Some(jobs) = response.jobs {
|
||||
if let Some(job) = jobs.iter().find(|j| j.id == sched_id) {
|
||||
if job.currently_running {
|
||||
// Try to get the actual session ID from recent sessions
|
||||
let recent_sessions = self.sessions(sched_id, 1).await?;
|
||||
|
||||
if let Some((session_name, _session_metadata)) = recent_sessions.first() {
|
||||
// Check if this session is still active by looking at the session file
|
||||
let session_path = crate::session::storage::get_path(
|
||||
crate::session::storage::Identifier::Name(session_name.clone()),
|
||||
);
|
||||
|
||||
// If the session file was modified recently (within last 5 minutes),
|
||||
// consider it as the current running session
|
||||
if let Ok(metadata) = std::fs::metadata(&session_path) {
|
||||
if let Ok(modified) = metadata.modified() {
|
||||
let modified_dt: DateTime<Utc> = modified.into();
|
||||
let now = Utc::now();
|
||||
let time_diff = now.signed_duration_since(modified_dt);
|
||||
|
||||
if time_diff.num_minutes() < 5 {
|
||||
// This looks like an active session
|
||||
return Ok(Some((session_name.clone(), modified_dt)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: return a temporal session ID with current time
|
||||
let session_id =
|
||||
format!("temporal-{}-{}", sched_id, Utc::now().timestamp());
|
||||
let start_time = Utc::now();
|
||||
Ok(Some((session_id, start_time)))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
} else {
|
||||
Err(SchedulerError::JobNotFound(sched_id.to_string()))
|
||||
}
|
||||
} else {
|
||||
Ok(None)
|
||||
Err(SchedulerError::JobNotFound(sched_id.to_string()))
|
||||
}
|
||||
} else {
|
||||
Err(SchedulerError::JobNotFound(sched_id.to_string()))
|
||||
Err(SchedulerError::SchedulerInternalError(response.message))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1021,17 +1175,43 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sessions_method_signature() {
|
||||
// This test verifies the method signature is correct at compile time
|
||||
// We just need to verify the method exists and can be called
|
||||
fn test_job_status_detection_improvements() {
|
||||
// Test that the new job status detection methods compile and work correctly
|
||||
use tokio::runtime::Runtime;
|
||||
|
||||
// This will fail to compile if the method doesn't exist or has wrong signature
|
||||
let _test_fn = |scheduler: &TemporalScheduler, id: &str, limit: usize| {
|
||||
// This is a compile-time check - we don't actually call it
|
||||
let _future = scheduler.sessions(id, limit);
|
||||
};
|
||||
let rt = Runtime::new().unwrap();
|
||||
rt.block_on(async {
|
||||
// This test verifies the improved job status detection compiles
|
||||
match TemporalScheduler::new().await {
|
||||
Ok(scheduler) => {
|
||||
// Test the new status update method
|
||||
match scheduler.update_job_status_from_sessions().await {
|
||||
Ok(()) => {
|
||||
println!("✅ update_job_status_from_sessions() works correctly");
|
||||
}
|
||||
Err(e) => {
|
||||
println!("⚠️ update_job_status_from_sessions() returned error (expected if no jobs): {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
println!("✅ sessions() method signature is correct");
|
||||
// Test the improved get_running_job_info method
|
||||
match scheduler.get_running_job_info("test-job").await {
|
||||
Ok(None) => {
|
||||
println!("✅ get_running_job_info() correctly returns None for non-existent job");
|
||||
}
|
||||
Ok(Some((session_id, start_time))) => {
|
||||
println!("✅ get_running_job_info() returned session info: {} at {}", session_id, start_time);
|
||||
}
|
||||
Err(e) => {
|
||||
println!("⚠️ get_running_job_info() returned error (expected): {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
println!("⚠️ Temporal services not running - method signature test passed: {}", e);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -361,6 +361,7 @@ impl ScheduleToolTestBuilder {
|
||||
paused: false,
|
||||
current_session_id: None,
|
||||
process_start_time: None,
|
||||
execution_mode: Some("background".to_string()),
|
||||
};
|
||||
{
|
||||
let mut jobs = self.scheduler.jobs.lock().await;
|
||||
|
||||
Reference in New Issue
Block a user