feat: show a conversation its own background tasks, and give it back every outcome
Nightly Build / build (push) Successful in 7m34s
Nightly Build / build (push) Successful in 7m34s
An `execute_task mode="async"` was invisible from the chat that started it.
The only trace was the receipt in the transcript and a row on the Tasks page
— which does not say *which* of those rows the assistant just spawned — so
"is it still going?" had no answer where the question is asked.
Worse, a task that did not simply succeed never came back at all. `run_job`
branched on `Ok`/`Err` first and routed by `job.kind` only inside the `Ok`
arm, so a failure or a kill left through the `Err` arm's unconditional
`hub.notify` — the home source (`/sethome`), worded "Cron job … failed" —
while the parent conversation sat waiting for a `task_completed` that would
never arrive. The wrong chat, and a wedged one.
The fix is a shape, not a branch: one `JobOutcome` classification, then one
`match job.kind` delivery site for every ending. An async task now ends in
its parent conversation whatever happened to it. The sink has a single
channel deliberately — to the model reading it, "it broke" is a result like
any other and must not be overlookable — so a failure is delivered as prose,
carrying whatever partial output the run produced, which is usually the only
clue about why. A cron job keeps the home notification: it belongs to nobody's
conversation. Cancellation becomes a third outcome rather than a flavour of
failure (`job_runs.status` has always had `'cancelled'` in its CHECK and
nothing ever wrote it), classified off the new typed `TurnCancelled` error so
nothing keys on a message string.
The strip above the composer is the visible half. `ServerEvent::TaskUpdate`
announces state to the source of the parent conversation only; the list is
`renderTaskStrip` (shared by the desktop copilot and the mobile chat), fed by
state on `ChatSession`. Each row links to `#session/{id}` — the page that
already shows, live, what a background agent is doing, and without which
"a task is running" is a fact you can do nothing with. Stopping is the
existing kill endpoint. A finished row clears itself after 20 s (its result
is in the conversation by then); a failed one stays until dismissed, and the
dismissal is remembered across reloads.
`GET /api/{source}/tasks` is what makes the strip survive a browser refresh:
the event is a broadcast with no replay, so without a load-time read a reload
would empty a chat that still has work running under it. It answers with the
running tasks plus failures from the last 30 minutes — the two states a person
can still act on. Successes are absent on purpose. Its window compares through
`datetime()` on both sides: `completed_at` is RFC 3339 and the cutoff is
SQLite-shaped, and `'T' > ' '` would let every same-day row through a window
meant to exclude it.
Not addressed, and worth doing next: a cron job's result should go where its
creator says, not always to the home chat.
This commit is contained in:
@@ -5,7 +5,7 @@ use axum::{
|
||||
};
|
||||
use serde::Deserialize;
|
||||
|
||||
use skald_core::db::{scheduled_jobs, job_runs};
|
||||
use skald_core::db::{scheduled_jobs, job_runs, sources};
|
||||
use std::sync::Arc;
|
||||
use skald_core::skald::Skald;
|
||||
use super::{ApiError, guard::AuthUser, require_context};
|
||||
@@ -130,6 +130,55 @@ pub async fn kill_job(
|
||||
Ok(StatusCode::ACCEPTED)
|
||||
}
|
||||
|
||||
// ── GET /api/{source}/tasks ───────────────────────────────────────────────────
|
||||
|
||||
/// Failures stay in a conversation's task strip this long. Long enough that a
|
||||
/// reload right after one still shows it, short enough that yesterday's does not.
|
||||
const FAILED_TASK_WINDOW_MINUTES: i64 = 30;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct SourcePath { pub source: String }
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
pub struct SessionTaskResponse {
|
||||
pub job_id: i64,
|
||||
pub title: String,
|
||||
pub agent_id: String,
|
||||
pub session_id: Option<i64>,
|
||||
pub state: String,
|
||||
pub error: Option<String>,
|
||||
pub started_at: Option<String>,
|
||||
}
|
||||
|
||||
/// The background tasks the chat for `source` should be showing right now.
|
||||
///
|
||||
/// The strip's live updates ride `ServerEvent::TaskUpdate`, a broadcast with no
|
||||
/// replay — so this is what a page reload reads to get its state back. An
|
||||
/// unknown source (no session yet) is an empty list, not an error: a chat that
|
||||
/// has never run is a legitimate caller.
|
||||
pub async fn session_tasks(
|
||||
State(skald): State<Arc<Skald>>,
|
||||
Extension(auth): Extension<AuthUser>,
|
||||
Path(p): Path<SourcePath>,
|
||||
) -> Result<Json<Vec<SessionTaskResponse>>, ApiError> {
|
||||
let ctx = require_context(&skald, &auth.user_id).await?;
|
||||
let Some(session_id) = sources::active_session_id(&ctx.pool, &p.source).await? else {
|
||||
return Ok(Json(vec![]));
|
||||
};
|
||||
let tasks = scheduled_jobs::list_for_parent_session(
|
||||
&ctx.pool, session_id, FAILED_TASK_WINDOW_MINUTES,
|
||||
).await?;
|
||||
Ok(Json(tasks.into_iter().map(|t| SessionTaskResponse {
|
||||
job_id: t.job_id,
|
||||
title: t.title,
|
||||
agent_id: t.agent_id,
|
||||
session_id: t.session_id,
|
||||
state: t.state,
|
||||
error: t.error,
|
||||
started_at: t.started_at,
|
||||
}).collect()))
|
||||
}
|
||||
|
||||
pub async fn list_runs(
|
||||
State(skald): State<Arc<Skald>>,
|
||||
Extension(auth): Extension<AuthUser>,
|
||||
|
||||
@@ -72,6 +72,8 @@ pub fn router() -> Router<Arc<Skald>> {
|
||||
.route("/sessions/{id}", get(sessions::get_session_detail))
|
||||
.route("/web/messages", get(sessions::web_messages))
|
||||
.route("/{source}/messages", get(sessions::source_messages))
|
||||
// Background (`execute_task mode=async`) tasks of this source's session.
|
||||
.route("/{source}/tasks", get(cron::session_tasks))
|
||||
// File attachments: streamed to disk, so the default body-size limit is
|
||||
// disabled on this route only.
|
||||
.route("/{source}/uploads", post(uploads::upload).layer(DefaultBodyLimit::disable()))
|
||||
|
||||
Reference in New Issue
Block a user