Nightly Build / build (push) Successful in 6m27s
Rework projects from single-user leftovers into shareable endeavours.
- DB: move `projects` from the owner bucket to the registry (system.db,
not encrypted); add `owner_user_id` + `slug` (drop free `path`); new
`project_members(project_id, user_id, can_write)` mirroring
`shared_folder_members`. Drop `project_tickets` entirely. Only user↔agent
conversations stay encrypted (per-user DB) — each member keeps a private
project chat. Registry home dissolves the cross-DB-FK problem.
- Filesystem/container: on disk `{WD}/projects/{owner_userid}/{slug}`,
agent/container path `projects/{owner_username}/{slug}`. Two-segment routing
in UserFs (ProjectMount + host_base_and_tail arm) and a second loop in
build_user_fs; read-only members get a :ro mount. Reuse the shared-folder
remount machinery (refresh_user_shared_folders -> refresh_user_mounts).
- Remove the ticket system: ProjectTicketManager, UserContext.tickets, its
wiring, and the project_tickets references in scheduled_jobs/cron.
- API: repoint handlers to the registry pool + membership scoping. Sharing is
self-service — owner or any write-member may add/remove members and set
read/write; only the owner deletes; the owner cannot be removed. New
POST/DELETE /api/projects/{id}/members[/{user_id}]. Seed `@fs_any allow
projects/*`; build_runtime_run_context sets working_directory to the agent
path and drops the host-path allow_fs_writes.
- Frontend: create form without the free path field, owner/read-write badges;
the detail page becomes header + description + sharing panel + Open chat + a
file-explorer placeholder (the future primary surface). i18n en/it/fr.
686 lines
27 KiB
Rust
686 lines
27 KiB
Rust
use std::str::FromStr;
|
|
use std::sync::Arc;
|
|
|
|
use anyhow::Result;
|
|
use chrono::{DateTime, Local, Utc};
|
|
use chrono_tz::Tz;
|
|
use cron::Schedule;
|
|
use sqlx::SqlitePool;
|
|
use tokio::sync::mpsc;
|
|
use tokio::time::Duration;
|
|
use tracing::{error, info};
|
|
|
|
use core_api::system_bus::{SystemEvent, SystemEventBus};
|
|
|
|
use crate::chat_hub::ChatHub;
|
|
use crate::db::chat_sessions;
|
|
use crate::db::scheduled_jobs::{self, ScheduledJob};
|
|
use crate::session::manager::ChatSessionManager;
|
|
|
|
pub struct TaskManager {
|
|
pool: Arc<SqlitePool>,
|
|
tz: Option<Tz>,
|
|
session: std::sync::OnceLock<Arc<ChatSessionManager>>,
|
|
hub: std::sync::OnceLock<Arc<ChatHub>>,
|
|
self_arc: std::sync::OnceLock<Arc<Self>>,
|
|
system_bus: Arc<SystemEventBus>,
|
|
}
|
|
|
|
/// Returns `(next_utc, is_single)` where `is_single` is `true` when the
|
|
/// schedule has no second fire time after the first — i.e. the expression
|
|
/// can only ever fire once. Falls back to system local time when `tz` is `None`.
|
|
fn next_fire_and_single(schedule: &Schedule, tz: Option<Tz>) -> Option<(DateTime<Utc>, bool)> {
|
|
if let Some(tz) = tz {
|
|
let mut it = schedule.upcoming(tz);
|
|
let first = it.next()?.with_timezone(&Utc);
|
|
Some((first, it.next().is_none()))
|
|
} else {
|
|
let mut it = schedule.upcoming(Local);
|
|
let first = it.next()?.with_timezone(&Utc);
|
|
Some((first, it.next().is_none()))
|
|
}
|
|
}
|
|
|
|
fn next_fire(schedule: &Schedule, tz: Option<Tz>) -> Option<DateTime<Utc>> {
|
|
next_fire_and_single(schedule, tz).map(|(dt, _)| dt)
|
|
}
|
|
|
|
impl TaskManager {
|
|
pub fn new(pool: Arc<SqlitePool>, tz: Option<Tz>, system_bus: Arc<SystemEventBus>) -> Arc<Self> {
|
|
Arc::new(Self {
|
|
pool,
|
|
tz,
|
|
session: std::sync::OnceLock::new(),
|
|
hub: std::sync::OnceLock::new(),
|
|
self_arc: std::sync::OnceLock::new(),
|
|
system_bus,
|
|
})
|
|
}
|
|
|
|
/// Called once after ChatSessionManager is built, breaking the circular dep.
|
|
pub fn set_session(&self, session: Arc<ChatSessionManager>) {
|
|
let _ = self.session.set(session);
|
|
}
|
|
|
|
/// Called once after ChatHub is built. Used for completion notifications.
|
|
pub fn set_hub(&self, hub: Arc<ChatHub>) {
|
|
let _ = self.hub.set(hub);
|
|
}
|
|
|
|
/// Called once after Arc<Self> is available (in skald.rs after new()).
|
|
pub fn set_self_arc(&self, arc: Arc<Self>) {
|
|
let _ = self.self_arc.set(arc);
|
|
}
|
|
|
|
fn session(&self) -> Result<&Arc<ChatSessionManager>> {
|
|
self.session.get().ok_or_else(|| anyhow::anyhow!("cron: session manager not initialized"))
|
|
}
|
|
|
|
fn self_arc(&self) -> Result<Arc<Self>> {
|
|
self.self_arc.get().cloned()
|
|
.ok_or_else(|| anyhow::anyhow!("cron: self_arc not initialized"))
|
|
}
|
|
|
|
/// Start the background loops. Must be called after set_session().
|
|
/// Returns join handles so the caller can await them during shutdown.
|
|
pub fn start(self: Arc<Self>, shutdown: tokio_util::sync::CancellationToken) -> Vec<tokio::task::JoinHandle<()>> {
|
|
// Main scheduler loop.
|
|
let me = Arc::clone(&self);
|
|
let sd1 = shutdown.clone();
|
|
let h1 = tokio::spawn(async move {
|
|
if let Err(e) = me.recover_interrupted().await {
|
|
error!("cron: startup recovery failed: {e}");
|
|
}
|
|
let mut interval = tokio::time::interval(Duration::from_secs(30));
|
|
loop {
|
|
tokio::select! {
|
|
_ = sd1.cancelled() => { info!("cron: scheduler loop stopping"); break; }
|
|
_ = interval.tick() => {
|
|
if let Err(e) = me.tick().await {
|
|
error!("cron tick error: {e}");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
// Cleanup loop: removes single_run jobs completed more than 7 days ago.
|
|
let pool = Arc::clone(&self.pool);
|
|
let sd2 = shutdown.clone();
|
|
let h2 = tokio::spawn(async move {
|
|
tokio::time::sleep(Duration::from_secs(15)).await;
|
|
let mut interval = tokio::time::interval(Duration::from_secs(3600));
|
|
loop {
|
|
tokio::select! {
|
|
_ = sd2.cancelled() => { info!("cron: cleanup loop stopping"); break; }
|
|
_ = interval.tick() => {
|
|
if let Err(e) = cleanup_expired_single_runs(&pool).await {
|
|
error!("cron: cleanup error: {e}");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
vec![h1, h2]
|
|
}
|
|
|
|
async fn recover_interrupted(&self) -> Result<()> {
|
|
let session = self.session()?;
|
|
let self_arc = self.self_arc()?;
|
|
let jobs = scheduled_jobs::list_interrupted(&self.pool).await?;
|
|
if jobs.is_empty() { return Ok(()); }
|
|
info!("cron: recovering {} interrupted job(s)", jobs.len());
|
|
for job in jobs {
|
|
let pool = Arc::clone(&self.pool);
|
|
let session = Arc::clone(session);
|
|
let hub = self.hub.get().cloned();
|
|
let task_mgr = Arc::clone(&self_arc);
|
|
let tz = self.tz;
|
|
tokio::spawn(async move {
|
|
if let Err(e) = run_job(&pool, &session, &task_mgr, hub.as_ref(), &job, tz).await {
|
|
error!("cron: recovery of job {} ('{}') failed: {e}", job.id, job.title);
|
|
}
|
|
});
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
async fn tick(&self) -> Result<()> {
|
|
let session = self.session()?;
|
|
let self_arc = self.self_arc()?;
|
|
let now = Utc::now().to_rfc3339();
|
|
let jobs = scheduled_jobs::list_due(&self.pool, &now).await?;
|
|
for job in jobs {
|
|
let pool = Arc::clone(&self.pool);
|
|
let session = Arc::clone(session);
|
|
let hub = self.hub.get().cloned();
|
|
let task_mgr = Arc::clone(&self_arc);
|
|
let job = job.clone();
|
|
let tz = self.tz;
|
|
tokio::spawn(async move {
|
|
if let Err(e) = run_job(&pool, &session, &task_mgr, hub.as_ref(), &job, tz).await {
|
|
error!("cron job {} ('{}') failed: {e}", job.id, job.title);
|
|
}
|
|
});
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
// ── Sync wrappers (called from LLM tools via block_in_place) ─────────────
|
|
|
|
pub fn list_jobs(&self) -> Result<Vec<ScheduledJob>> {
|
|
tokio::task::block_in_place(|| {
|
|
tokio::runtime::Handle::current()
|
|
.block_on(scheduled_jobs::list(&self.pool))
|
|
})
|
|
}
|
|
|
|
/// Validate that `agent_id` names a runnable task agent (non-empty, exists,
|
|
/// `type == Task`). Single gate shared by every job-creation entry point so
|
|
/// cron / sync / async / project-ticket paths all agree — no silent default.
|
|
fn require_task_agent(agent_id: &str) -> Result<()> {
|
|
if agent_id.trim().is_empty() {
|
|
anyhow::bail!("agent_id is required — specify which task agent runs this task (no default)");
|
|
}
|
|
crate::agents::load_task_meta(agent_id)?;
|
|
Ok(())
|
|
}
|
|
|
|
pub fn add_job(
|
|
&self,
|
|
title: &str,
|
|
description: &str,
|
|
cron: &str,
|
|
prompt: &str,
|
|
agent_id: &str,
|
|
single_run: bool,
|
|
kind: &str,
|
|
parent_session_id: Option<i64>,
|
|
run_context: Option<&str>,
|
|
) -> Result<ScheduledJob> {
|
|
Self::require_task_agent(agent_id)?;
|
|
let (first_fire, _is_single, single_run) = if kind == "sync" || kind == "immediate" {
|
|
(None, true, true)
|
|
} else {
|
|
let schedule = Schedule::from_str(cron).map_err(|_| {
|
|
anyhow::anyhow!(
|
|
"Invalid cron expression: '{cron}'. Use 7-field format: \
|
|
sec min hour dom month dow year (e.g. '0 0 9 * * * *' = every day at 9:00)"
|
|
)
|
|
})?;
|
|
let (first, single) = next_fire_and_single(&schedule, self.tz)
|
|
.ok_or_else(|| anyhow::anyhow!("Cron expression '{cron}' has no upcoming fire times"))?;
|
|
let single_run = single_run || single;
|
|
(Some(first.to_rfc3339()), single, single_run)
|
|
};
|
|
let next_run_at: Option<&str> = first_fire.as_deref();
|
|
let job = tokio::task::block_in_place(|| {
|
|
tokio::runtime::Handle::current().block_on(scheduled_jobs::create(
|
|
&self.pool, title, description, cron, prompt, agent_id,
|
|
single_run, next_run_at, kind, parent_session_id, run_context, None,
|
|
))
|
|
})?;
|
|
Ok(job)
|
|
}
|
|
|
|
/// Execute a task synchronously: creates the DB record, runs it inline,
|
|
/// and returns the agent's final response. Blocks until completion.
|
|
pub fn add_job_sync(
|
|
&self,
|
|
title: &str,
|
|
description: &str,
|
|
prompt: &str,
|
|
agent_id: &str,
|
|
run_context: Option<&str>,
|
|
) -> Result<String> {
|
|
Self::require_task_agent(agent_id)?;
|
|
let job = tokio::task::block_in_place(|| {
|
|
tokio::runtime::Handle::current().block_on(scheduled_jobs::create(
|
|
&self.pool, title, description, "", prompt, agent_id,
|
|
true, None, "sync", None, run_context, None,
|
|
))
|
|
})?;
|
|
let session = self.session()?;
|
|
let self_arc = self.self_arc()?;
|
|
let result = tokio::task::block_in_place(|| {
|
|
tokio::runtime::Handle::current().block_on(
|
|
run_job(&self.pool, session, &self_arc, self.hub.get(), &job, self.tz)
|
|
)
|
|
})?;
|
|
Ok(result.unwrap_or_else(|| "(no output)".to_string()))
|
|
}
|
|
|
|
/// Start a task asynchronously: creates the DB record, spawns the run,
|
|
/// returns immediately. Result is injected into parent_session_id when done.
|
|
pub fn add_job_async(
|
|
&self,
|
|
title: &str,
|
|
description: &str,
|
|
prompt: &str,
|
|
agent_id: &str,
|
|
parent_session_id: i64,
|
|
run_context: Option<&str>,
|
|
) -> Result<ScheduledJob> {
|
|
Self::require_task_agent(agent_id)?;
|
|
let job = tokio::task::block_in_place(|| {
|
|
tokio::runtime::Handle::current().block_on(scheduled_jobs::create(
|
|
&self.pool, title, description, "", prompt, agent_id,
|
|
true, None, "async", Some(parent_session_id), run_context, None,
|
|
))
|
|
})?;
|
|
let pool = Arc::clone(&self.pool);
|
|
let session = self.session()?.clone();
|
|
let hub = self.hub.get().cloned();
|
|
let task_mgr = self.self_arc()?;
|
|
let tz = self.tz;
|
|
let job_c = job.clone();
|
|
tokio::spawn(async move {
|
|
if let Err(e) = run_job(&pool, &session, &task_mgr, hub.as_ref(), &job_c, tz).await {
|
|
error!("async task {} ('{}') failed: {e}", job_c.id, job_c.title);
|
|
}
|
|
});
|
|
Ok(job)
|
|
}
|
|
|
|
/// Create and immediately spawn an async job with an opaque `origin_ref`.
|
|
/// Returns the created `ScheduledJob` (caller uses its `id` for tracking).
|
|
/// Unlike `add_job_async`, no `parent_session_id` is set — completion is
|
|
/// delivered via `SystemEvent::JobCompleted` on the system bus.
|
|
pub fn spawn_async_job(
|
|
&self,
|
|
title: &str,
|
|
description: &str,
|
|
prompt: &str,
|
|
agent_id: &str,
|
|
run_context: Option<&str>,
|
|
origin_ref: &str,
|
|
) -> Result<scheduled_jobs::ScheduledJob> {
|
|
Self::require_task_agent(agent_id)?;
|
|
let job = tokio::task::block_in_place(|| {
|
|
tokio::runtime::Handle::current().block_on(scheduled_jobs::create(
|
|
&self.pool, title, description, "", prompt, agent_id,
|
|
true, None, "async", None, run_context, Some(origin_ref),
|
|
))
|
|
})?;
|
|
let pool = Arc::clone(&self.pool);
|
|
let session = self.session()?.clone();
|
|
let hub = self.hub.get().cloned();
|
|
let task_mgr = self.self_arc()?;
|
|
let tz = self.tz;
|
|
let job_c = job.clone();
|
|
tokio::spawn(async move {
|
|
if let Err(e) = run_job(&pool, &session, &task_mgr, hub.as_ref(), &job_c, tz).await {
|
|
error!("project-ticket job {} failed: {e}", job_c.id);
|
|
}
|
|
});
|
|
Ok(job)
|
|
}
|
|
|
|
pub fn delete_job(&self, id: i64) -> Result<bool> {
|
|
tokio::task::block_in_place(|| {
|
|
tokio::runtime::Handle::current()
|
|
.block_on(scheduled_jobs::delete(&self.pool, id))
|
|
})
|
|
}
|
|
|
|
pub fn toggle_job(&self, id: i64, enabled: bool) -> Result<bool> {
|
|
tokio::task::block_in_place(|| {
|
|
tokio::runtime::Handle::current().block_on(async {
|
|
let found = scheduled_jobs::set_enabled(&self.pool, id, enabled).await?;
|
|
if found && enabled {
|
|
// Recalculate next_run_at when re-enabling so a stale timestamp
|
|
// doesn't cause an immediate spurious fire.
|
|
let jobs = scheduled_jobs::list(&self.pool).await?;
|
|
if let Some(job) = jobs.iter().find(|j| j.id == id) {
|
|
let tz = self.tz;
|
|
if let Some(next) = Schedule::from_str(&job.cron)
|
|
.ok()
|
|
.and_then(|s| next_fire(&s, tz))
|
|
.map(|t| t.to_rfc3339())
|
|
{
|
|
scheduled_jobs::set_next_run_at(&self.pool, id, &next).await?;
|
|
}
|
|
}
|
|
}
|
|
Ok(found)
|
|
})
|
|
})
|
|
}
|
|
}
|
|
|
|
// ── Job execution ─────────────────────────────────────────────────────────────
|
|
|
|
async fn run_job(
|
|
pool: &SqlitePool,
|
|
session: &ChatSessionManager,
|
|
task_mgr: &Arc<TaskManager>,
|
|
hub: Option<&Arc<ChatHub>>,
|
|
job: &ScheduledJob,
|
|
tz: Option<Tz>,
|
|
) -> Result<Option<String>> {
|
|
info!("running {} task {} ('{}')", job.kind, job.id, job.title);
|
|
|
|
let started_at = Utc::now();
|
|
|
|
let (session_id, _) = session.create_session(&job.agent_id, "cron", false, true, None).await?;
|
|
scheduled_jobs::set_running(pool, job.id, session_id).await?;
|
|
|
|
if let Some(rc) = &job.run_context {
|
|
chat_sessions::set_run_context(pool, session_id, Some(rc.as_str())).await.ok();
|
|
}
|
|
|
|
let handler = session.get_or_create_handler(session_id).await?;
|
|
handler.set_context_label(format!("CronJob: {}", job.title));
|
|
if job.kind == "async" {
|
|
if let Some(parent_id) = job.parent_session_id {
|
|
handler.set_scratchpad_session_id(parent_id);
|
|
}
|
|
}
|
|
|
|
let job_context = format!(
|
|
"[Job context]\nJob ID: {} — {}\nTime: {} UTC",
|
|
job.id, job.title,
|
|
started_at.format("%Y-%m-%d %H:%M"),
|
|
);
|
|
|
|
// Build interface_tools: execute_subtask for background sessions (sync only, no async/cron).
|
|
let task_mgr_clone = Arc::clone(task_mgr);
|
|
let execute_subtask_tool = build_execute_subtask_tool(task_mgr_clone, job.run_context.clone());
|
|
|
|
// Use a large buffer and drain rx concurrently with handle_message to avoid
|
|
// deadlock: handle_message may emit many events (ToolStart/ToolDone/Thinking
|
|
// per tool call), and the channel blocks when full if nobody is reading.
|
|
let (tx, mut rx) = mpsc::channel(512);
|
|
|
|
let handler_arc = Arc::clone(&handler);
|
|
let prompt = job.prompt.clone();
|
|
let ctx = job_context.clone();
|
|
let jh = tokio::spawn(async move {
|
|
handler_arc.handle_message(
|
|
&prompt,
|
|
None,
|
|
None,
|
|
Some(ctx),
|
|
None,
|
|
vec![execute_subtask_tool],
|
|
std::collections::HashMap::new(),
|
|
tx,
|
|
false,
|
|
None,
|
|
None, // non-interactive: no live user-message injection
|
|
).await
|
|
});
|
|
|
|
// Drain events concurrently. rx closes when the last tx clone is dropped,
|
|
// which happens only after resume_turn() completes the full sub-agent chain.
|
|
while let Some(_) = rx.recv().await {}
|
|
|
|
let handle_result = jh.await
|
|
.unwrap_or_else(|e| Err(anyhow::anyhow!("run_job task panicked: {e}")));
|
|
|
|
let completed_at = Utc::now();
|
|
let duration_ms = (completed_at - started_at).num_milliseconds();
|
|
let final_response = last_assistant_message(pool, session_id).await.ok().flatten();
|
|
|
|
let next_run_at: Option<String> = if job.single_run || job.kind != "cron" {
|
|
None
|
|
} else {
|
|
Schedule::from_str(&job.cron).ok()
|
|
.and_then(|s| next_fire(&s, tz))
|
|
.map(|t| t.to_rfc3339())
|
|
};
|
|
|
|
match handle_result {
|
|
Ok(_) => {
|
|
record_job_run(pool, job.id, session_id, &started_at.to_rfc3339(),
|
|
&completed_at.to_rfc3339(), duration_ms,
|
|
"completed", final_response.as_deref(), None).await?;
|
|
scheduled_jobs::finish_run(pool, job.id, next_run_at.as_deref()).await?;
|
|
|
|
task_mgr.system_bus.send(SystemEvent::JobCompleted {
|
|
job_id: job.id,
|
|
origin_ref: job.origin_ref.clone(),
|
|
result: final_response.clone(),
|
|
error: None,
|
|
});
|
|
|
|
match job.kind.as_str() {
|
|
"cron" => {
|
|
if let Some(hub) = hub {
|
|
let outcome = final_response.as_deref().unwrap_or("(no output)");
|
|
hub.notify(crate::notification::Notification {
|
|
source: "cron".into(),
|
|
event_type: "cron_result".into(),
|
|
summary: format!(
|
|
"Cron job \"{}\" (ID {}) completed: {}",
|
|
job.title, job.id, outcome,
|
|
),
|
|
event_time: Utc::now().to_rfc3339(),
|
|
refs: serde_json::json!({ "job_id": job.id, "title": job.title }),
|
|
}).await.ok();
|
|
}
|
|
}
|
|
"async" => {
|
|
if let Some(parent_id) = job.parent_session_id {
|
|
if let Some(hub) = hub {
|
|
inject_async_result(
|
|
pool,
|
|
hub,
|
|
parent_id,
|
|
job.id,
|
|
&job.title,
|
|
final_response.as_deref().unwrap_or("(no output)"),
|
|
).await;
|
|
}
|
|
}
|
|
}
|
|
_ => {} // sync: result was already returned inline via add_job_sync
|
|
}
|
|
|
|
info!("{} task {} done", job.kind, job.id);
|
|
Ok(final_response)
|
|
}
|
|
Err(e) => {
|
|
let err_str = e.to_string();
|
|
record_job_run(pool, job.id, session_id, &started_at.to_rfc3339(),
|
|
&completed_at.to_rfc3339(), duration_ms,
|
|
"failed", None, Some(&err_str)).await?;
|
|
scheduled_jobs::finish_run(pool, job.id, next_run_at.as_deref()).await?;
|
|
|
|
task_mgr.system_bus.send(SystemEvent::JobCompleted {
|
|
job_id: job.id,
|
|
origin_ref: job.origin_ref.clone(),
|
|
result: None,
|
|
error: Some(err_str.clone()),
|
|
});
|
|
|
|
if let Some(hub) = hub {
|
|
hub.notify(crate::notification::Notification {
|
|
source: "cron".into(),
|
|
event_type: "cron_error".into(),
|
|
summary: format!(
|
|
"Cron job \"{}\" (ID {}) failed: {} (check the logs)",
|
|
job.title, job.id, err_str,
|
|
),
|
|
event_time: Utc::now().to_rfc3339(),
|
|
refs: serde_json::json!({ "job_id": job.id, "title": job.title }),
|
|
}).await.ok();
|
|
}
|
|
Err(e)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Injects an async task result into the parent session using the same pattern as
|
|
/// the notification system: writes a synthetic assistant message + completed
|
|
/// `task_completed` tool call directly to the DB, then calls `hub.resume()` so
|
|
/// the parent LLM wakes up and events are properly bridged to the WebSocket.
|
|
async fn inject_async_result(
|
|
pool: &SqlitePool,
|
|
hub: &Arc<ChatHub>,
|
|
parent_session_id: i64,
|
|
task_id: i64,
|
|
task_title: &str,
|
|
result: &str,
|
|
) {
|
|
// Resolve source_id from the parent session row.
|
|
let source_id = match crate::db::chat_sessions::find_by_id(pool, parent_session_id).await {
|
|
Ok(Some(s)) => s.source,
|
|
Ok(None) => { error!("inject_async_result: session {parent_session_id} not found"); return; }
|
|
Err(e) => { error!("inject_async_result: DB error: {e}"); return; }
|
|
};
|
|
|
|
// Get the active stack for the parent session.
|
|
let stack = match crate::db::chat_sessions_stack::active_for_session(pool, parent_session_id).await {
|
|
Ok(Some(s)) => s,
|
|
Ok(None) => { error!("inject_async_result: no active stack for session {parent_session_id}"); return; }
|
|
Err(e) => { error!("inject_async_result: stack lookup failed: {e}"); return; }
|
|
};
|
|
|
|
// Write a synthetic assistant message (reasoning trace).
|
|
let reasoning = format!(
|
|
"The system is notifying me that async task #{task_id} ('{}') has completed. \
|
|
Let me process the result via task_completed.",
|
|
task_title,
|
|
);
|
|
let assistant_id = match crate::db::chat_history::append(
|
|
pool, stack.id, &crate::db::chat_history::Role::Assistant,
|
|
"", true, Some(&reasoning),
|
|
).await {
|
|
Ok(id) => id,
|
|
Err(e) => { error!("inject_async_result: append assistant failed: {e}"); return; }
|
|
};
|
|
|
|
// Write the completed task_completed tool call with the result payload.
|
|
let result_json = serde_json::to_string(&serde_json::json!({
|
|
"task_id": task_id,
|
|
"title": task_title,
|
|
"result": result,
|
|
})).unwrap_or_else(|_| "{}".to_string());
|
|
|
|
let tool_call_id = match crate::db::chat_llm_tools::append(
|
|
pool, assistant_id, "task_completed",
|
|
&serde_json::json!({"task_id": task_id}).to_string(),
|
|
).await {
|
|
Ok(id) => id,
|
|
Err(e) => { error!("inject_async_result: append tool call failed: {e}"); return; }
|
|
};
|
|
|
|
if let Err(e) = crate::db::chat_llm_tools::complete(pool, tool_call_id, &result_json, "string").await {
|
|
error!("inject_async_result: complete tool call failed: {e}"); return;
|
|
}
|
|
|
|
info!(parent_session_id, task_id, task_title, "inject_async_result: resuming parent session");
|
|
|
|
if let Err(e) = hub.resume(&source_id).await {
|
|
error!("inject_async_result: hub.resume failed: {e}");
|
|
}
|
|
}
|
|
|
|
/// Builds the `execute_subtask` InterfaceTool injected into background sessions.
|
|
/// Background tasks can only run synchronous sub-tasks — no cron or async.
|
|
fn build_execute_subtask_tool(task_mgr: Arc<TaskManager>, run_context: Option<String>) -> crate::session::handler::InterfaceTool {
|
|
use crate::session::handler::{InterfaceTool, ToolFuture};
|
|
use serde_json::json;
|
|
|
|
InterfaceTool {
|
|
definition: json!({
|
|
"type": "function",
|
|
"function": {
|
|
"name": crate::tools::tool_names::EXECUTE_SUBTASK,
|
|
"description": "Run a synchronous sub-task and return its result. Blocks until the sub-task completes.",
|
|
"parameters": {
|
|
"type": "object",
|
|
"required": ["title", "prompt", "agent_id"],
|
|
"properties": {
|
|
"title": { "type": "string", "description": "Short name for this sub-task" },
|
|
"description": { "type": "string", "description": "What this sub-task does" },
|
|
"prompt": { "type": "string", "description": "Prompt sent to the agent" },
|
|
"agent_id": { "type": "string", "description": "Task agent to run (required; e.g. software-engineer, researcher, generalist)" }
|
|
}
|
|
}
|
|
}
|
|
}),
|
|
handler: Arc::new(move |args: serde_json::Value| -> ToolFuture {
|
|
let tm = Arc::clone(&task_mgr);
|
|
let title = args["title"].as_str().unwrap_or("").to_string();
|
|
let desc = args["description"].as_str().unwrap_or("").to_string();
|
|
let prompt = args["prompt"].as_str().unwrap_or("").to_string();
|
|
let agent_id = args["agent_id"].as_str().unwrap_or("").to_string();
|
|
let run_context = run_context.clone();
|
|
Box::pin(async move {
|
|
tokio::task::spawn_blocking(move || {
|
|
tm.add_job_sync(&title, &desc, &prompt, &agent_id, run_context.as_deref())
|
|
})
|
|
.await
|
|
.map_err(|e| anyhow::anyhow!("execute_subtask task panicked: {e}"))?
|
|
})
|
|
}),
|
|
}
|
|
}
|
|
|
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
|
|
async fn record_job_run(
|
|
pool: &SqlitePool,
|
|
job_id: i64,
|
|
session_id: i64,
|
|
started_at: &str,
|
|
completed_at: &str,
|
|
duration_ms: i64,
|
|
status: &str,
|
|
response: Option<&str>,
|
|
error: Option<&str>,
|
|
) -> Result<()> {
|
|
crate::db::job_runs::insert(
|
|
pool, job_id, Some(session_id),
|
|
started_at, completed_at, duration_ms,
|
|
status, response, error,
|
|
).await.map(|_| ())
|
|
}
|
|
|
|
/// Returns the most recent successful assistant message in the given session.
|
|
async fn last_assistant_message(pool: &SqlitePool, session_id: i64) -> Result<Option<String>> {
|
|
let row: Option<(String,)> = sqlx::query_as(
|
|
"SELECT ch.content
|
|
FROM chat_history ch
|
|
JOIN chat_sessions_stack css ON ch.session_stack_id = css.id
|
|
WHERE css.session_id = ? AND ch.role = 'assistant' AND ch.status = 'ok'
|
|
ORDER BY ch.id DESC
|
|
LIMIT 1",
|
|
)
|
|
.bind(session_id)
|
|
.fetch_optional(pool)
|
|
.await?;
|
|
Ok(row.map(|(c,)| c))
|
|
}
|
|
|
|
async fn cleanup_expired_single_runs(pool: &SqlitePool) -> Result<()> {
|
|
// The set of jobs about to be deleted, reused by each cascade step below.
|
|
const EXPIRED: &str = "SELECT id FROM scheduled_jobs
|
|
WHERE single_run = 1
|
|
AND enabled = 0
|
|
AND last_run_at < datetime('now', '-7 days')";
|
|
|
|
sqlx::query(sqlx::AssertSqlSafe(format!(
|
|
"DELETE FROM job_runs WHERE job_id IN ({EXPIRED})"
|
|
)))
|
|
.execute(pool)
|
|
.await?;
|
|
|
|
let n = sqlx::query(
|
|
"DELETE FROM scheduled_jobs
|
|
WHERE single_run = 1
|
|
AND enabled = 0
|
|
AND last_run_at < datetime('now', '-7 days')",
|
|
)
|
|
.execute(pool)
|
|
.await?
|
|
.rows_affected();
|
|
if n > 0 {
|
|
info!("cron: removed {n} expired single-run job(s)");
|
|
}
|
|
Ok(())
|
|
}
|