mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-26 23:50:17 +00:00
feat(web): DB-backed Jobs tab + scheduler-dispatched local jobs (#436)
* feat(web): DB-backed Jobs tab, scheduler-dispatched local jobs, remove active-jobs-bar - Remove active-jobs-bar UI element (HTML, CSS, JS polling) - Move job handlers from server.rs to handlers/jobs.rs - Remove user_id scoping (single-user gateway) - Add list_agent_jobs() and agent_job_summary() to Database trait (both postgres and libsql backends) for non-sandbox job visibility - Wire SchedulerSlot into CreateJobTool so execute_local dispatches via scheduler (persists to DB + spawns worker) instead of creating phantom ContextManager-only jobs - Update /status and /list slash commands to read from DB for consistency with Jobs tab - Fix worker mark_completed: skip if already terminal or stuck - Add agent job cancel via DB update in both web handler and slash cmd - Add Stuck → Completed guard with tracing in worker completion path Co-Authored-By: Claude Sonnet 4.6 <[email protected]> * fix: address PR review comments - Log warning when get_context fails in worker completion path - Extract duplicated status-counting logic into AgentJobSummary::add_count() helper, used by both postgres and libsql backends Co-Authored-By: Claude Sonnet 4.6 <[email protected]> --------- Co-authored-by: Claude Sonnet 4.6 <[email protected]> Co-authored-by: Nick Pismenkov <[email protected]>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
Nick Pismenkov
parent
afb49597ac
commit
6481448d50
@@ -12,6 +12,7 @@ use std::time::Duration;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::Utc;
|
||||
use tokio::sync::RwLock;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::bootstrap::ironclaw_base_dir;
|
||||
@@ -25,6 +26,12 @@ use crate::orchestrator::job_manager::{ContainerJobManager, JobMode};
|
||||
use crate::secrets::SecretsStore;
|
||||
use crate::tools::tool::{ApprovalRequirement, Tool, ToolError, ToolOutput, require_str};
|
||||
|
||||
/// Lazy scheduler reference, filled after Agent::new creates the Scheduler.
|
||||
///
|
||||
/// Solves the chicken-and-egg: tools are registered before the Scheduler exists
|
||||
/// (Scheduler needs the ToolRegistry). Created empty, filled after Agent::new.
|
||||
pub type SchedulerSlot = Arc<RwLock<Option<Arc<crate::agent::Scheduler>>>>;
|
||||
|
||||
/// Resolve a job ID from a full UUID or a short prefix (like git short SHAs).
|
||||
///
|
||||
/// Tries full UUID parse first. If that fails, treats the input as a hex prefix
|
||||
@@ -73,6 +80,8 @@ async fn resolve_job_id(input: &str, context_manager: &ContextManager) -> Result
|
||||
/// job via the ContextManager. The LLM never needs to know the difference.
|
||||
pub struct CreateJobTool {
|
||||
context_manager: Arc<ContextManager>,
|
||||
/// Lazy scheduler for dispatching local (non-sandbox) jobs.
|
||||
scheduler_slot: Option<SchedulerSlot>,
|
||||
job_manager: Option<Arc<ContainerJobManager>>,
|
||||
store: Option<Arc<dyn Database>>,
|
||||
/// Broadcast sender for job events (used to subscribe a monitor).
|
||||
@@ -87,6 +96,7 @@ impl CreateJobTool {
|
||||
pub fn new(context_manager: Arc<ContextManager>) -> Self {
|
||||
Self {
|
||||
context_manager,
|
||||
scheduler_slot: None,
|
||||
job_manager: None,
|
||||
store: None,
|
||||
event_tx: None,
|
||||
@@ -118,6 +128,12 @@ impl CreateJobTool {
|
||||
self
|
||||
}
|
||||
|
||||
/// Inject a lazy scheduler slot for dispatching local (non-sandbox) jobs.
|
||||
pub fn with_scheduler_slot(mut self, slot: SchedulerSlot) -> Self {
|
||||
self.scheduler_slot = Some(slot);
|
||||
self
|
||||
}
|
||||
|
||||
/// Inject secrets store for credential validation.
|
||||
pub fn with_secrets(mut self, secrets: Arc<dyn SecretsStore + Send + Sync>) -> Self {
|
||||
self.secrets_store = Some(secrets);
|
||||
@@ -239,7 +255,8 @@ impl CreateJobTool {
|
||||
}
|
||||
}
|
||||
|
||||
/// Execute via in-memory ContextManager (no sandbox).
|
||||
/// Execute via Scheduler (persists to DB + spawns worker), or fall back to
|
||||
/// ContextManager-only if the scheduler isn't available yet.
|
||||
async fn execute_local(
|
||||
&self,
|
||||
title: &str,
|
||||
@@ -247,6 +264,38 @@ impl CreateJobTool {
|
||||
ctx: &JobContext,
|
||||
) -> Result<ToolOutput, ToolError> {
|
||||
let start = std::time::Instant::now();
|
||||
|
||||
// Use the scheduler if available — creates in ContextManager, persists
|
||||
// to DB, transitions to InProgress, and spawns a worker. The new job
|
||||
// runs independently with its own Worker and LLM context (not inheriting
|
||||
// the parent conversation). MaxJobsExceeded is returned as error JSON
|
||||
// so the LLM can report it to the user.
|
||||
if let Some(ref slot) = self.scheduler_slot
|
||||
&& let Some(ref scheduler) = *slot.read().await
|
||||
{
|
||||
return match scheduler
|
||||
.dispatch_job(&ctx.user_id, title, description, None)
|
||||
.await
|
||||
{
|
||||
Ok(job_id) => {
|
||||
let result = serde_json::json!({
|
||||
"job_id": job_id.to_string(),
|
||||
"title": title,
|
||||
"status": "in_progress",
|
||||
"message": format!("Created and scheduled job '{}'", title)
|
||||
});
|
||||
Ok(ToolOutput::success(result, start.elapsed()))
|
||||
}
|
||||
Err(e) => {
|
||||
let result = serde_json::json!({
|
||||
"error": e.to_string()
|
||||
});
|
||||
Ok(ToolOutput::success(result, start.elapsed()))
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Fallback: ContextManager-only (scheduler not yet initialized).
|
||||
match self
|
||||
.context_manager
|
||||
.create_job_for_user(&ctx.user_id, title, description)
|
||||
@@ -257,7 +306,7 @@ impl CreateJobTool {
|
||||
"job_id": job_id.to_string(),
|
||||
"title": title,
|
||||
"status": "pending",
|
||||
"message": format!("Created job '{}'", title)
|
||||
"message": format!("Created job '{}' (not scheduled — scheduler unavailable)", title)
|
||||
});
|
||||
Ok(ToolOutput::success(result, start.elapsed()))
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ pub use file::{ApplyPatchTool, ListDirTool, ReadFileTool, WriteFileTool};
|
||||
pub use http::HttpTool;
|
||||
pub use job::{
|
||||
CancelJobTool, CreateJobTool, JobEventsTool, JobPromptTool, JobStatusTool, ListJobsTool,
|
||||
PromptQueue,
|
||||
PromptQueue, SchedulerSlot,
|
||||
};
|
||||
pub use json::JsonTool;
|
||||
pub use memory::{MemoryReadTool, MemorySearchTool, MemoryTreeTool, MemoryWriteTool};
|
||||
|
||||
@@ -286,11 +286,13 @@ impl ToolRegistry {
|
||||
///
|
||||
/// Job tools allow the LLM to create, list, check status, and cancel jobs.
|
||||
/// When sandbox deps are provided, `create_job` automatically delegates to
|
||||
/// Docker containers. Otherwise it creates in-memory jobs via ContextManager.
|
||||
/// Docker containers. Otherwise it dispatches via the Scheduler (which
|
||||
/// persists to DB and spawns a worker).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn register_job_tools(
|
||||
&self,
|
||||
context_manager: Arc<ContextManager>,
|
||||
scheduler_slot: Option<crate::tools::builtin::SchedulerSlot>,
|
||||
job_manager: Option<Arc<ContainerJobManager>>,
|
||||
store: Option<Arc<dyn Database>>,
|
||||
job_event_tx: Option<
|
||||
@@ -301,6 +303,9 @@ impl ToolRegistry {
|
||||
secrets_store: Option<Arc<dyn SecretsStore + Send + Sync>>,
|
||||
) {
|
||||
let mut create_tool = CreateJobTool::new(Arc::clone(&context_manager));
|
||||
if let Some(slot) = scheduler_slot {
|
||||
create_tool = create_tool.with_scheduler_slot(slot);
|
||||
}
|
||||
if let Some(jm) = job_manager {
|
||||
create_tool = create_tool.with_sandbox(jm, store.clone());
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user