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:
Henry Park
2026-02-28 19:58:07 -08:00
committed by GitHub
co-authored by Claude Sonnet 4.6 Nick Pismenkov
parent afb49597ac
commit 6481448d50
14 changed files with 508 additions and 605 deletions
+51 -2
View File
@@ -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()))
}
+1 -1
View File
@@ -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};
+6 -1
View File
@@ -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());
}