feat: show a conversation its own background tasks, and give it back every outcome
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:
2026-08-04 19:13:30 +01:00
parent e356741435
commit daaceff6ba
21 changed files with 949 additions and 76 deletions
+2
View File
@@ -334,6 +334,8 @@ The event **translator** (`loop_adapters/translate.rs`) is the ONE bus subscribe
- A sub-agent is a **tool**, not an interception: `DelegateTool` (registered under the legacy names `execute_task` / `execute_subtask`, D11, each keeping its exact legacy schema) opens a child frame and runs a normal loop in it. The parent simply awaits a slow tool call. Max depth `MAX_AGENT_DEPTH = 5`.
- **Parallel batches are the kernel's generic fan-out**: a round whose calls are all `concurrency_safe` (a sync delegate is) runs concurrently, bounded by `max_parallel_calls`. The ordering invariant is unchanged — ids allocated in call order (phase 1) → concurrent execution (phase 2) → recording in call order (phase 3) — so the model reconstructs results by id. Any mixed batch stays sequential. Siblings share the session scratchpad; concurrent writes to the same key are last-writer-wins by design.
- `mode: "async"` submits a durable `scheduled_jobs` row through `loop_adapters/async_task.rs::CronExecutor` and returns a receipt immediately; when the job finishes, `DurableSink` writes the result into the parent conversation (synthetic assistant + a completed `task_completed` call) and resumes it. `mode: "cron"` is scheduling, not delegation, and stays on the cron interface tool.
- **An async task ends in the conversation that started it, whatever happened to it** — and `cron::run_job` is shaped so it cannot do otherwise: one `JobOutcome` classification, then *one* `match job.kind` delivery site for every ending. It used to branch on `Ok`/`Err` first and route by kind only inside `Ok`, so a failure or a kill went out as a "Cron job … failed" notification to the **home** source (`/sethome`) while the parent sat waiting for a `task_completed` that never came — the wrong chat *and* a wedged conversation. The sink has a single channel by design: to the model, "it broke" is a result like any other and must not be overlookable, so the failure is delivered as prose (with whatever partial output the run produced). A cron job has no parent conversation and keeps the home notification — the future plan is to let its creator name a destination. Cancellation is a third outcome, not a flavour of failure: `job_runs.status` always had `'cancelled'` in its CHECK and nothing wrote it, and the classifier keys on the **typed** `session::handler::TurnCancelled` error, never on the message text.
- **The chat shows what it started.** `ServerEvent::TaskUpdate` announces an async task's state to the source of its parent conversation only (a cron job belongs to nobody's chat), and `GET /api/{source}/tasks` (`db::scheduled_jobs::list_for_parent_session`) answers the same question at load time — running tasks plus failures from the last 30 minutes, because the event is a broadcast with no replay and a browser reload would otherwise empty a chat that still has work under it. Successes are absent from that query on purpose: a finished task's result is already a message in the conversation. The strip itself is `web/components/shared/agent-tasks.js` (`renderTaskStrip`), rendered above the composer on desktop and mobile from state owned by `ChatSession`; the drill-in is `#session/{id}`, gated on `_canOpenTaskSession` because the mobile shell routes a fixed set of sections and would silently swallow that hash.
- A child's model is **never inherited** from the parent: passing a concrete name would bypass AUTO selection, so sub-agents auto-select unless explicitly overridden (`args.client``meta.json client` → AUTO by strength).
- `list_agents` returns **task** agents only (never `chat`/`system` ones like the entry agent).
Generated
+1 -1
View File
@@ -4178,7 +4178,7 @@ checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649"
[[package]]
name = "skald"
version = "0.1.2"
version = "0.2.0"
dependencies = [
"anyhow",
"async-trait",
+1 -1
View File
@@ -24,7 +24,7 @@ resolver = "2"
[package]
name = "skald"
version = "0.1.2"
version = "0.2.0"
edition = "2024"
[features]
+33
View File
@@ -285,6 +285,38 @@ pub enum ServerEvent {
SecurityGroupSelected {
group: String,
},
/// A background task (`execute_task` with `mode: "async"`) started by this
/// conversation changed state.
///
/// Emitted only for async tasks, and only to the source of the conversation
/// that started one: a cron job belongs to nobody's chat. It drives a live
/// view and nothing else — a client that misses it is merely out of date,
/// never out of sync, because the task's real ending is delivered into the
/// conversation's own history.
TaskUpdate {
job_id: i64,
title: String,
agent_id: String,
/// The task's own session — `#session/{id}` shows what it is doing.
session_id: Option<i64>,
state: TaskState,
/// Why it ended badly. Set for `Failed` and `Cancelled`.
#[serde(skip_serializing_if = "Option::is_none")]
error: Option<String>,
},
}
/// The lifecycle state of a background task in a [`ServerEvent::TaskUpdate`].
/// Mirrors `job_runs.status`, plus the `Running` state that table only records
/// by omission.
#[derive(Clone, Copy, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum TaskState {
Running,
Completed,
Failed,
/// Stopped by a human before it finished.
Cancelled,
}
impl ServerEvent {
@@ -324,6 +356,7 @@ impl ServerEvent {
Self::TurnRunning { .. } => "turn_running",
Self::ClientSelected { .. } => "client_selected",
Self::SecurityGroupSelected { .. } => "security_group_selected",
Self::TaskUpdate { .. } => "task_update",
}
}
}
+199 -69
View File
@@ -10,11 +10,13 @@ use tokio::sync::mpsc;
use tokio::time::Duration;
use tracing::{error, info};
use core_api::events::{ServerEvent, TaskState};
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::handler::TurnCancelled;
use crate::session::manager::ChatSessionManager;
pub struct TaskManager {
@@ -389,6 +391,11 @@ async fn run_job(
}
}
// The conversation that asked for this task learns it started, so the chat's
// background-task strip can show it without polling. Cron jobs are excluded
// on purpose: they belong to nobody's conversation.
emit_task_update(pool, hub, job, Some(session_id), TaskState::Running, None).await;
let job_context = format!(
"[Job context]\nJob ID: {}{}\nTime: {} UTC",
job.id, job.title,
@@ -442,94 +449,217 @@ async fn run_job(
.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?;
// ── Outcome ──────────────────────────────────────────────────────────────
//
// One classification, one delivery site, for **every** ending. The previous
// shape branched on `Ok`/`Err` first and only routed by `kind` inside the
// `Ok` arm, so a failed or killed async task never reached the conversation
// that started it: it went out as a "Cron job … failed" notification to the
// home source, while the parent sat waiting for a `task_completed` that
// would never come. An async task ends in its parent conversation whatever
// happened to it — that is the rule this shape makes structural.
let outcome = JobOutcome::classify(handle_result);
let error_text = outcome.error();
task_mgr.system_bus.send(SystemEvent::JobCompleted {
job_id: job.id,
origin_ref: job.origin_ref.clone(),
result: final_response.clone(),
error: None,
});
record_job_run(pool, job.id, session_id, &started_at.to_rfc3339(),
&completed_at.to_rfc3339(), duration_ms,
outcome.run_status(),
outcome.is_ok().then_some(final_response.as_deref()).flatten(),
error_text.as_deref()).await?;
scheduled_jobs::finish_run(pool, job.id, next_run_at.as_deref()).await?;
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(
&task_mgr.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
}
task_mgr.system_bus.send(SystemEvent::JobCompleted {
job_id: job.id,
origin_ref: job.origin_ref.clone(),
result: outcome.is_ok().then(|| final_response.clone()).flatten(),
error: error_text.clone(),
});
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()),
});
emit_task_update(
pool, hub, job, Some(session_id),
outcome.task_state(), error_text.as_deref(),
).await;
match job.kind.as_str() {
"cron" => {
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_type: outcome.notification_event_type().into(),
summary: outcome.cron_summary(job, final_response.as_deref()),
event_time: Utc::now().to_rfc3339(),
refs: serde_json::json!({ "job_id": job.id, "title": job.title }),
}).await.ok();
}
Err(e)
}
"async" => {
if let (Some(parent_id), Some(hub)) = (job.parent_session_id, hub) {
inject_async_result(
&task_mgr.pool,
hub,
parent_id,
job.id,
&job.title,
&outcome.delivery_text(final_response.as_deref()),
).await;
}
}
_ => {} // sync: the result was already returned inline via add_job_sync
}
match outcome {
JobOutcome::Completed => {
info!("{} task {} done", job.kind, job.id);
Ok(final_response)
}
JobOutcome::Failed(e) | JobOutcome::Cancelled(e) => Err(e),
}
}
/// How a job run ended. Cancellation is a third state, not a flavour of
/// failure: `job_runs.status` has always had `'cancelled'` in its CHECK and
/// nothing ever wrote it, so a task the user killed was indistinguishable in
/// the history from one that broke.
enum JobOutcome {
Completed,
Failed(anyhow::Error),
/// Stopped by a human (`/kill`, `/stop`).
Cancelled(anyhow::Error),
}
impl JobOutcome {
fn classify(result: Result<()>) -> Self {
match result {
Ok(()) => Self::Completed,
Err(e) if e.downcast_ref::<TurnCancelled>().is_some() => Self::Cancelled(e),
Err(e) => Self::Failed(e),
}
}
fn is_ok(&self) -> bool {
matches!(self, Self::Completed)
}
fn run_status(&self) -> &'static str {
match self {
Self::Completed => "completed",
Self::Failed(_) => "failed",
Self::Cancelled(_) => "cancelled",
}
}
fn task_state(&self) -> TaskState {
match self {
Self::Completed => TaskState::Completed,
Self::Failed(_) => TaskState::Failed,
Self::Cancelled(_) => TaskState::Cancelled,
}
}
/// The error text, for the run log and the WS event. `None` when the run
/// completed — a cancellation *has* one, since "stopped by the user" is
/// what the history should say.
fn error(&self) -> Option<String> {
match self {
Self::Completed => None,
Self::Failed(e) => Some(e.to_string()),
Self::Cancelled(_) => Some("Stopped by the user before it finished.".to_string()),
}
}
fn notification_event_type(&self) -> &'static str {
match self {
Self::Completed => "cron_result",
_ => "cron_error",
}
}
fn cron_summary(&self, job: &ScheduledJob, final_response: Option<&str>) -> String {
match self {
Self::Completed => format!(
"Cron job \"{}\" (ID {}) completed: {}",
job.title, job.id, final_response.unwrap_or("(no output)"),
),
Self::Failed(e) => format!(
"Cron job \"{}\" (ID {}) failed: {e} (check the logs)",
job.title, job.id,
),
Self::Cancelled(_) => format!(
"Cron job \"{}\" (ID {}) was stopped before it finished.",
job.title, job.id,
),
}
}
/// What the parent conversation is told. The model reads this as the result
/// of the `task_completed` call, so a failure has to *say* it failed —
/// prose, not a status code — and carry whatever the task did produce
/// before dying, which is usually the only clue about why.
fn delivery_text(&self, final_response: Option<&str>) -> String {
let partial = |body: String| match final_response {
Some(r) if !r.trim().is_empty() =>
format!("{body}\n\nLast thing the task said before stopping:\n{r}"),
_ => body,
};
match self {
Self::Completed => final_response.unwrap_or("(no output)").to_string(),
Self::Failed(e) => partial(format!(
"This task FAILED — it never produced a final answer.\n\nError: {e}"
)),
Self::Cancelled(_) => partial(
"This task was STOPPED by the user before it finished. \
Its work is incomplete; do not present it as done."
.to_string(),
),
}
}
}
/// Delivers an async task's result to the parent session through the loop's
/// Announces an async task's state to the conversation that started it, over
/// that source's WebSocket. Best-effort and silent on failure: it drives a
/// live view, never a state transition — the truth is `scheduled_jobs` plus the
/// result delivered into the parent's history.
///
/// A cron job has no parent conversation, so it emits nothing.
async fn emit_task_update(
pool: &SqlitePool,
hub: Option<&Arc<ChatHub>>,
job: &ScheduledJob,
session_id: Option<i64>,
state: TaskState,
error: Option<&str>,
) {
if job.kind != "async" { return; }
let (Some(hub), Some(parent_id)) = (hub, job.parent_session_id) else { return };
let Ok(Some(parent)) = chat_sessions::find_by_id(pool, parent_id).await else { return };
hub.emit(core_api::events::GlobalEvent {
source: Some(parent.source),
session_id: Some(parent_id),
event: ServerEvent::TaskUpdate {
job_id: job.id,
title: job.title.clone(),
agent_id: job.agent_id.clone(),
session_id,
state,
error: error.map(str::to_string),
},
});
}
/// Delivers an async task's **outcome** to the parent session through the loop's
/// [`AsyncResultSink`] seam (blueprint §7.2): the library writes the synthetic
/// assistant message + completed `task_completed` call, and Skald's
/// [`DurableSink`] resumes the parent so the model reads it right away.
///
/// Failures are logged, never propagated: the job itself succeeded, and losing
/// the delivery must not mark it failed.
/// `result` is whatever the conversation should be told — an answer, or the
/// prose that says the task failed or was stopped. The sink has one channel and
/// that is deliberate: to the model reading it, "it broke" is a result like any
/// other, and one it must not be able to overlook.
///
/// A delivery failure is logged, never propagated: it cannot change how the run
/// itself is recorded.
async fn inject_async_result(
pool: &Arc<SqlitePool>,
hub: &Arc<ChatHub>,
+169
View File
@@ -81,6 +81,90 @@ pub async fn list_interrupted(pool: &SqlitePool) -> Result<Vec<ScheduledJob>> {
Ok(rows)
}
/// One background (`async`) task as the conversation that started it sees it.
/// A flattened join of the job with its latest run — the chat cares about a
/// task's *current* state, not its scheduling row.
#[derive(Debug, Clone, sqlx::FromRow)]
pub struct SessionTask {
pub job_id: i64,
pub title: String,
pub agent_id: String,
/// The task's own session (`#session/{id}`).
pub session_id: Option<i64>,
/// `running` or `failed` — the only two states this query returns.
pub state: String,
pub error: Option<String>,
/// When it started, normalised to RFC 3339 (see [`normalise_ts`]).
pub started_at: Option<String>,
}
/// The background tasks one conversation should still be showing: everything
/// running right now, plus failures from the last `failed_within_minutes`.
///
/// Those are the two states a person can still act on — and the reason this
/// query exists at all is the browser reload: the strip is driven by
/// `ServerEvent::TaskUpdate`, which is a live broadcast with no replay, so
/// without a load-time read a refresh would empty a chat that still has work
/// running under it. Successes are deliberately absent: a completed task's
/// result is already a message in the conversation, which is a better place to
/// read it than a status chip.
pub async fn list_for_parent_session(
pool: &SqlitePool,
parent_session_id: i64,
failed_within_minutes: i64,
) -> Result<Vec<SessionTask>> {
let rows = sqlx::query_as::<_, SessionTask>(
"SELECT sj.id AS job_id,
sj.title AS title,
sj.agent_id AS agent_id,
COALESCE(sj.running_session_id, jr.session_id) AS session_id,
CASE WHEN sj.running_session_id IS NOT NULL
THEN 'running' ELSE jr.status END AS state,
jr.error AS error,
COALESCE(sj.running_since, jr.started_at) AS started_at
FROM scheduled_jobs sj
LEFT JOIN job_runs jr
ON jr.id = (SELECT id FROM job_runs
WHERE job_id = sj.id ORDER BY id DESC LIMIT 1)
WHERE sj.kind = 'async'
AND sj.parent_session_id = ?
AND (sj.running_session_id IS NOT NULL
-- `datetime()` on both sides, never a raw string compare:
-- `completed_at` is RFC 3339 (`T+00:00`) and the cutoff is
-- SQLite-shaped, and `'T' > ' '` makes every same-day row
-- compare as newer than the cutoff a window that lets
-- through everything it was meant to exclude.
OR (jr.status = 'failed'
AND datetime(jr.completed_at) >= datetime('now', ?)))
ORDER BY sj.id",
)
.bind(parent_session_id)
.bind(format!("-{failed_within_minutes} minutes"))
.fetch_all(pool)
.await?;
Ok(rows.into_iter()
.map(|mut t| { t.started_at = t.started_at.as_deref().and_then(normalise_ts); t })
.collect())
}
/// The two timestamp shapes this table mixes, as one RFC 3339 string:
/// `running_since` is written by SQLite's `datetime('now')` (`Y-m-d H:M:S`,
/// UTC, no offset) while `job_runs.started_at` is already RFC 3339. A client
/// that guesses wrong is off by its own timezone, so the guess is made here.
fn normalise_ts(raw: &str) -> Option<String> {
use chrono::{DateTime, NaiveDateTime, Utc};
DateTime::parse_from_rfc3339(raw)
.map(|d| d.with_timezone(&Utc))
.ok()
.or_else(|| {
NaiveDateTime::parse_from_str(raw, "%Y-%m-%d %H:%M:%S")
.ok()
.map(|n| n.and_utc())
})
.map(|d| d.to_rfc3339())
}
pub async fn create(
pool: &SqlitePool,
title: &str,
@@ -202,3 +286,88 @@ pub async fn finish_run(
.await?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
/// One conversation (session 1) with a background task in each state, plus
/// the rows the query must not pick up: another conversation's task, and a
/// cron job (which belongs to nobody's chat).
async fn seeded() -> SqlitePool {
let pool = SqlitePool::connect("sqlite::memory:").await.unwrap();
crate::db::create_owner_tables(&pool).await.unwrap();
let q = |sql: &'static str| sqlx::query(sql).execute(&pool);
q("INSERT INTO chat_sessions (id, title, source) VALUES (1, 'chat', 'web')").await.unwrap();
q("INSERT INTO chat_sessions (id, title, source) VALUES (2, 'other', 'mobile')").await.unwrap();
let job = |id: i64, title: &'static str, kind: &'static str,
parent: Option<i64>, running: Option<i64>| {
sqlx::query(
"INSERT INTO scheduled_jobs
(id, title, cron, prompt, agent_id, kind, parent_session_id,
running_session_id, running_since, single_run)
VALUES (?, ?, '', 'p', 'researcher', ?, ?, ?, '2026-08-04 10:00:00', 1)",
)
.bind(id).bind(title).bind(kind).bind(parent).bind(running)
.execute(&pool)
};
job(1, "still going", "async", Some(1), Some(11)).await.unwrap();
job(2, "just broke", "async", Some(1), None).await.unwrap();
job(3, "finished ok", "async", Some(1), None).await.unwrap();
job(4, "broke a while ago", "async", Some(1), None).await.unwrap();
job(5, "someone else's", "async", Some(2), Some(55)).await.unwrap();
job(6, "nightly digest", "cron", None, Some(66)).await.unwrap();
let run = |job_id: i64, session: i64, status: &'static str, completed: String| {
sqlx::query(
"INSERT INTO job_runs (job_id, session_id, started_at, completed_at,
duration_ms, status, error)
VALUES (?, ?, '2026-08-04T10:00:00+00:00', ?, 10, ?, 'boom')",
)
.bind(job_id).bind(session).bind(completed).bind(status)
.execute(&pool)
};
// RFC 3339, exactly as `run_job` writes it — the shape the window has to
// cope with. A test that seeded SQLite-shaped strings here would pass
// against a plain string comparison that production data defeats.
let now = chrono::Utc::now();
let at = |m: i64| (now - chrono::Duration::minutes(m)).to_rfc3339();
run(2, 22, "failed", at(1)).await.unwrap();
run(3, 33, "completed", at(1)).await.unwrap();
run(4, 44, "failed", at(120)).await.unwrap();
pool
}
/// The strip shows what is running plus what has just broken — and nothing
/// that belongs to another conversation, to the schedule, or to yesterday.
#[tokio::test]
async fn a_conversation_sees_its_running_and_recently_failed_tasks() {
let pool = seeded().await;
let tasks = list_for_parent_session(&pool, 1, 30).await.unwrap();
let seen: Vec<_> = tasks.iter().map(|t| (t.job_id, t.state.as_str())).collect();
assert_eq!(seen, vec![(1, "running"), (2, "failed")]);
// The drill-in target: the running job's live session, the failed one's run.
assert_eq!(tasks[0].session_id, Some(11));
assert_eq!(tasks[1].session_id, Some(22));
assert_eq!(tasks[1].error.as_deref(), Some("boom"));
}
/// `running_since` is SQLite-shaped and `job_runs.started_at` is RFC 3339;
/// both leave here as RFC 3339, or a browser reads one of them in the wrong
/// timezone and shows an elapsed counter hours off.
#[tokio::test]
async fn started_at_is_normalised_to_rfc3339() {
let pool = seeded().await;
let tasks = list_for_parent_session(&pool, 1, 30).await.unwrap();
for task in &tasks {
let raw = task.started_at.as_deref().expect("a started task has a start time");
chrono::DateTime::parse_from_rfc3339(raw)
.unwrap_or_else(|e| panic!("job {} start time {raw:?}: {e}", task.job_id));
}
}
}
+19 -1
View File
@@ -81,6 +81,24 @@ pub(super) enum TurnOutcome {
Exhausted,
}
/// A turn stopped by a human — `/stop` in the chat, or an admin killing a
/// running job — as opposed to one that failed.
///
/// It is a **typed** error carried by the `anyhow::Error` `handle_message`
/// returns, so a caller that cares about the difference (the cron runner, which
/// records `cancelled` rather than `failed` and words the delivery accordingly)
/// classifies it with `downcast_ref` and never by matching the message text.
#[derive(Debug)]
pub struct TurnCancelled;
impl std::fmt::Display for TurnCancelled {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("Turn cancelled by user")
}
}
impl std::error::Error for TurnCancelled {}
/// Truncate `s` to at most `max_chars` characters, appending `…` when it was
/// longer. Char-boundary safe: a raw `&s[..n]` byte slice panics when byte `n`
/// lands inside a multi-byte UTF-8 character (e.g. an em-dash or emoji straddling
@@ -641,7 +659,7 @@ impl ChatSessionHandler {
info!(session_id = self.session_id, "handle_message cancelled by user");
// The "Cancelled by user." error event was already emitted by
// the translator (root LoopEvent::Cancelled).
Err(anyhow::anyhow!("Turn cancelled by user"))
Err(anyhow::Error::new(TurnCancelled))
}
TurnOutcome::Exhausted => {
error!(session_id = self.session_id, max_rounds = self.max_tool_rounds, "tool-call loop exhausted without final answer");
+2 -1
View File
@@ -4,7 +4,7 @@ This folder is written for **you, the assistant**, not for the human directly. I
Keep answers grounded in what's actually enabled and configured for this instance — check with the relevant tool (e.g. list installed/enabled plugins) rather than assuming everything described here is turned on. A feature documented here may not be enabled on this particular instance.
This index will grow over time. Right now it covers memory, projects, system agents, access grants, voice input and plugins; more sections (agents, connectors, security groups, shared folders…) will be added later.
This index will grow over time. Right now it covers memory, projects, background tasks, system agents, access grants, voice input and plugins; more sections (agents, connectors, security groups, shared folders…) will be added later.
## Features
@@ -13,6 +13,7 @@ This index will grow over time. Right now it covers memory, projects, system age
| [memory.md](memory.md) | Private and shared memory: what goes where, the indexes and history log, why some shared facts can't be changed on request |
| [projects.md](projects.md) | Projects: shared folders with their own assistant chat, a live file explorer, and member sharing |
| [system-agents.md](system-agents.md) | Background agents that run on a schedule (event triage, the two memory lints, the nightly conversation review of a supervised account): what they watch, why they only ever report, why a run can be skipped, and their settings |
| [tasks.md](tasks.md) | Background tasks: the strip above the message box, following one live, stopping one, and how every outcome comes back to the conversation |
| [settings.md](settings.md) | The admin's Config page: interface language, the compaction model picker, debug mode |
| [access.md](access.md) | Who can use which plugin or connector: the open default, removing access per person, and the role switch that keeps children out of it |
| [voice.md](voice.md) | Voice input: configuring a transcription model, and why the microphone button does nothing unless the page is served over HTTPS or localhost |
+37
View File
@@ -0,0 +1,37 @@
# Background tasks (work that keeps running while you talk)
Some requests take minutes rather than seconds — reading a long document, searching the web thoroughly, crunching a folder of files. For those, the assistant can hand the work to a **background task**: a second agent that goes off and does it while the conversation carries on. The user does not have to sit and wait, and can keep asking about other things.
This is different from the two neighbouring things it is easy to confuse it with:
- A **sub-task** (the ordinary kind) runs *inside* the current answer. The conversation waits for it, and its progress is visible in the transcript as it happens.
- A **scheduled job** (a cron job) runs at a time of day, on repeat, and belongs to nobody's conversation. Those live on the **Tasks** page and report to the home chat.
- A **background task** belongs to the conversation that started it, and comes back to it.
## Seeing them: the strip above the message box
While a background task is running, a small strip appears **just above the composer**, in the desktop chat and the mobile one alike. One line per task: its title, which agent is doing it, and how long it has been going.
- **Clicking a task opens its own page**, where its work is shown live — the same view used for any background agent. This is the answer to "what is it actually doing?", which the chat itself cannot show: the task is a separate conversation.
- **The ■ button stops a task.** It stops there and then; whatever it had done so far is not thrown away, but it is incomplete, and the assistant is told so.
- The strip survives a page reload. It shows what is running *now*, so a task that finished while the browser was closed will not be there — but its result will be in the conversation, which is the better place to read it.
## How a task comes back
**Every** background task ends up back in the conversation that started it. There is no case where the user has to go looking for the outcome:
- **It succeeded** — its answer arrives as a message, and the assistant carries on from there, usually with a summary.
- **It failed** — the conversation is told it failed and why, together with whatever the task managed to say before it broke. The assistant should treat this as a real result and say so plainly, not quietly ignore it.
- **It was stopped** by the user — the conversation is told the work is incomplete. It must not be presented as if it had finished.
A finished task's line disappears from the strip after a few seconds. A **failed** one stays, so the reason can be read, until it is dismissed with the ✕.
## When a user asks about them
Common questions and the honest answers:
- *"Is it still running?"* — the strip is the answer; if the strip is empty, nothing of theirs is running.
- *"What is it doing?"* — click the task's line.
- *"It has been going for ages."* — a task has no time limit; stopping it with ■ is always available, and stopping is not the same as failing.
- *"Where did the result go?"* — into this conversation, always. If it is not there yet, the task has not finished.
- *"Show me everything that ever ran."* — the **Tasks** page (sidebar → Tasks) has the full history, including scheduled jobs; the strip only covers the current conversation.
+50 -1
View File
@@ -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>,
+2
View File
@@ -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()))
+6
View File
@@ -2,6 +2,7 @@ import { html, nothing } from 'lit';
import { ChatSession } from '../lib/chat-session.js';
import { t, I18nMixin } from '../lib/i18n.js';
import { renderMsg, renderAttachmentChips } from './copilot-render.js';
import { renderTaskStrip } from './shared/agent-tasks.js';
// Built-in (server-handled) slash commands shown at the top of the composer
// autocomplete. Custom commands (from `commands/<name>/`) are fetched from
@@ -59,6 +60,10 @@ export class AppCopilot extends I18nMixin(ChatSession) {
this._onPageChange = this._onPageChange.bind(this);
}
// The desktop shell routes `#session/{id}`, so a background task's row links
// through to what it is doing.
get _canOpenTaskSession() { return true; }
connectedCallback() {
super.connectedCallback?.();
this._restoreState();
@@ -398,6 +403,7 @@ export class AppCopilot extends I18nMixin(ChatSession) {
</div>
<div class="copilot-input-area">
${renderTaskStrip(this)}
${this._renderNoModelsBanner()}
<div class="copilot-composer"
@dragover=${(e) => e.preventDefault()}
+98
View File
@@ -0,0 +1,98 @@
import { html, nothing } from 'lit';
import { t } from '../../lib/i18n.js';
/**
* The background-task strip: what the agent handed off to run on its own.
*
* Rendered just above the composer, and only when there is something to say
* an empty strip would be a permanent slice of chrome for an occasional event.
* It sits outside the message flow on purpose: a task started twenty messages
* ago scrolls away exactly when it matters most, and the transcript is a record
* of what was said, not a dashboard.
*
* State lives on the chat component (`ChatSession._tasks`), which owns the
* WebSocket the updates arrive on; this file only knows how it looks. Shared by
* the desktop copilot and the mobile chat page.
*/
const STATE_ICON = {
running: 'bi-arrow-repeat agent-task-spin',
completed: 'bi-check-circle-fill',
failed: 'bi-exclamation-triangle-fill',
cancelled: 'bi-slash-circle',
};
/** Elapsed time since an ISO timestamp, coarse on purpose (`12s`, `4m 03s`). */
function elapsed(startedAt) {
if (!startedAt) return '';
const ms = Date.now() - new Date(startedAt).getTime();
if (!Number.isFinite(ms) || ms < 0) return '';
const s = Math.floor(ms / 1000);
if (s < 60) return `${s}s`;
const m = Math.floor(s / 60);
if (m < 60) return `${m}m ${String(s % 60).padStart(2, '0')}s`;
return `${Math.floor(m / 60)}h ${String(m % 60).padStart(2, '0')}m`;
}
function renderTask(host, task) {
const running = task.state === 'running';
// The task's own session page is the drill-in: it already shows, live, what a
// background agent is doing. Without it "a task is running" is a fact you can
// do nothing with.
const openable = task.session_id != null && host._canOpenTaskSession;
return html`
<div class="agent-task agent-task--${task.state}">
<i class="bi ${STATE_ICON[task.state] ?? 'bi-hourglass'} agent-task-icon"></i>
<button
class="agent-task-body"
?disabled=${!openable}
title=${openable ? t('chat.tasks.open') : ''}
@click=${() => { if (openable) window.location.hash = `session/${task.session_id}`; }}
>
<span class="agent-task-title">${task.title}</span>
<span class="agent-task-meta">
${task.agent_id}
${running ? html`· ${elapsed(task.started_at)}` : nothing}
${task.state === 'failed' ? html`· ${t('chat.tasks.failed')}` : nothing}
${task.state === 'completed' ? html`· ${t('chat.tasks.completed')}` : nothing}
${task.state === 'cancelled' ? html`· ${t('chat.tasks.cancelled')}` : nothing}
</span>
${task.error && task.state === 'failed'
? html`<span class="agent-task-error" title=${task.error}>${task.error}</span>`
: nothing}
</button>
${running
? html`<button class="agent-task-action" title=${t('chat.tasks.stop')}
@click=${() => host._stopTask(task.job_id)}>
<i class="bi bi-stop-fill"></i>
</button>`
: html`<button class="agent-task-action" title=${t('chat.tasks.dismiss')}
@click=${() => host._dismissTask(task.job_id)}>
<i class="bi bi-x"></i>
</button>`}
</div>
`;
}
export function renderTaskStrip(host) {
const tasks = host._tasks ?? [];
if (tasks.length === 0) return nothing;
const running = tasks.filter(x => x.state === 'running').length;
return html`
<div class="agent-tasks">
<div class="agent-tasks-head">
<i class="bi bi-cpu"></i>
<span>${running > 0
? t('chat.tasks.running_n', { n: running })
: t('chat.tasks.title')}</span>
<a class="agent-tasks-all" href="#tasks">${t('chat.tasks.see_all')}</a>
</div>
${tasks.map(task => renderTask(host, task))}
</div>
`;
}
+2
View File
@@ -2,6 +2,7 @@ import { html, nothing } from 'lit';
import { ChatSession } from '../../lib/chat-session.js';
import { t } from '../../lib/i18n.js';
import { renderMsg, renderAttachmentChips } from '../copilot-render.js';
import { renderTaskStrip } from './agent-tasks.js';
export class ChatPage extends ChatSession {
static properties = {
@@ -193,6 +194,7 @@ export class ChatPage extends ChatSession {
</div>
<div class="chat-page-input-area">
${renderTaskStrip(this)}
${this._renderNoModelsBanner()}
<div class="chat-page-composer"
@dragover=${(e) => e.preventDefault()}
+151
View File
@@ -0,0 +1,151 @@
/* Background-task strip
The list of `execute_task mode=async` tasks the current conversation started,
rendered above the composer by `renderTaskStrip` (shared by the desktop
copilot and the mobile chat page). Quiet by default: it sits next to the
composer, so it must read as a status line, never as a second UI. */
.agent-tasks {
display: flex;
flex-direction: column;
gap: 0.25rem;
margin-bottom: 0.5rem;
padding: 0.4rem 0.45rem 0.45rem;
border: 1px solid var(--toolbar-border);
border-radius: var(--radius-sm);
background: var(--msg-assistant-bg);
}
.agent-tasks-head {
display: flex;
align-items: center;
gap: 0.4rem;
padding: 0 0.25rem 0.1rem;
font-size: 0.72rem;
font-weight: 600;
letter-spacing: 0.02em;
text-transform: uppercase;
color: var(--placeholder-color);
}
.agent-tasks-all {
margin-left: auto;
font-size: 0.7rem;
font-weight: 500;
text-transform: none;
letter-spacing: 0;
color: var(--placeholder-color);
text-decoration: none;
}
.agent-tasks-all:hover { color: var(--accent); text-decoration: underline; }
/* ── One task ── */
.agent-task {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.3rem 0.4rem;
border-radius: var(--radius-sm);
background: var(--card-bg);
border: 1px solid transparent;
}
.agent-task--failed {
border-color: rgba(var(--bs-danger-rgb, 220, 53, 69), 0.35);
}
.agent-task--completed,
.agent-task--cancelled { opacity: 0.7; }
.agent-task-icon {
flex-shrink: 0;
font-size: 0.85rem;
color: var(--accent);
}
.agent-task--completed .agent-task-icon { color: var(--tool-subagent); }
.agent-task--failed .agent-task-icon { color: var(--bs-danger, #dc3545); }
.agent-task--cancelled .agent-task-icon { color: var(--placeholder-color); }
/* The body is a button: clicking opens the task's own session page. */
.agent-task-body {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.05rem;
padding: 0;
border: none;
background: none;
text-align: left;
color: inherit;
cursor: pointer;
}
.agent-task-body:disabled { cursor: default; }
.agent-task-title {
max-width: 100%;
font-size: 0.8rem;
font-weight: 500;
line-height: 1.3;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.agent-task-body:not(:disabled):hover .agent-task-title {
color: var(--accent);
text-decoration: underline;
}
.agent-task-meta,
.agent-task-error {
max-width: 100%;
font-size: 0.7rem;
line-height: 1.3;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--placeholder-color);
}
.agent-task-error { color: var(--bs-danger, #dc3545); }
.agent-task-action {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
width: 1.5rem;
height: 1.5rem;
padding: 0;
border: none;
border-radius: var(--radius-sm);
background: none;
color: var(--placeholder-color);
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.agent-task-action:hover {
background: var(--accent-soft);
color: var(--accent);
}
/* ── The running spinner ── */
.agent-task-spin {
display: inline-block;
animation: agent-task-spin 1.4s linear infinite;
}
@keyframes agent-task-spin {
to { transform: rotate(360deg); }
}
@media (prefers-reduced-motion: reduce) {
.agent-task-spin { animation: none; }
}
+9
View File
@@ -84,6 +84,15 @@ export default {
'chat.mic.unsupported': 'This browser does not support voice recording.',
'chat.mic.denied': 'Microphone access was denied. Allow it for this site in your browser settings, then try again.',
'chat.mic.failed': 'Could not start recording: {error}',
'chat.tasks.title': 'Background tasks',
'chat.tasks.running_n': 'Background tasks · {n} running',
'chat.tasks.open': 'See what this task is doing',
'chat.tasks.stop': 'Stop this task',
'chat.tasks.dismiss': 'Remove from the list',
'chat.tasks.completed': 'done',
'chat.tasks.failed': 'failed',
'chat.tasks.cancelled': 'stopped',
'chat.tasks.see_all': 'All tasks',
// ── Copilot render ─────────────────────────────────────────────────────────
'copilot.open_in_viewer': 'Open in viewer',
+9
View File
@@ -84,6 +84,15 @@ export default {
'chat.mic.unsupported': 'Ce navigateur ne prend pas en charge l\'enregistrement vocal.',
'chat.mic.denied': 'Accès au microphone refusé. Autorisez-le pour ce site dans les réglages du navigateur, puis réessayez.',
'chat.mic.failed': 'Impossible de démarrer l\'enregistrement : {error}',
'chat.tasks.title': 'Tâches en arrière-plan',
'chat.tasks.running_n': 'Tâches en arrière-plan · {n} en cours',
'chat.tasks.open': 'Voir ce que fait cette tâche',
'chat.tasks.stop': 'Arrêter cette tâche',
'chat.tasks.dismiss': 'Retirer de la liste',
'chat.tasks.completed': 'terminée',
'chat.tasks.failed': 'échouée',
'chat.tasks.cancelled': 'arrêtée',
'chat.tasks.see_all': 'Toutes les tâches',
// ── Copilot render ─────────────────────────────────────────────────────────
'copilot.open_in_viewer': 'Ouvrir dans le visualiseur',
+9
View File
@@ -84,6 +84,15 @@ export default {
'chat.mic.unsupported': 'Questo browser non supporta la registrazione vocale.',
'chat.mic.denied': 'Accesso al microfono negato. Consentilo per questo sito nelle impostazioni del browser e riprova.',
'chat.mic.failed': 'Impossibile avviare la registrazione: {error}',
'chat.tasks.title': 'Attività in background',
'chat.tasks.running_n': 'Attività in background · {n} in corso',
'chat.tasks.open': 'Guarda cosa sta facendo questa attività',
'chat.tasks.stop': 'Ferma questa attività',
'chat.tasks.dismiss': 'Togli dalla lista',
'chat.tasks.completed': 'completata',
'chat.tasks.failed': 'fallita',
'chat.tasks.cancelled': 'fermata',
'chat.tasks.see_all': 'Tutte le attività',
// ── Copilot render ─────────────────────────────────────────────────────────
'copilot.open_in_viewer': 'Apri nel visualizzatore',
+1
View File
@@ -45,6 +45,7 @@
<link rel="stylesheet" href="css/copilot.css" />
<link rel="stylesheet" href="css/copilot-messages.css" />
<link rel="stylesheet" href="css/copilot-input.css" />
<link rel="stylesheet" href="css/agent-tasks.css" />
<link rel="stylesheet" href="css/dialogs.css" />
<link rel="stylesheet" href="css/page-shell.css" />
<link rel="stylesheet" href="css/page-header.css" />
+148 -2
View File
@@ -59,6 +59,11 @@ export class ChatSession extends LightElement {
// Pending attachments for the message being composed (shown as chips above
// the textarea; uploaded to disk on selection, sent with the next message).
_attachments: { state: true },
// Background tasks (`execute_task mode=async`) this conversation started.
// Each entry: { job_id, title, agent_id, session_id, state, error, started_at }.
_tasks: { state: true },
// Bumped once a second while a task is running, so the elapsed counters move.
_taskTick: { state: true },
};
// Live events whose arrival implies a turn is in flight (used to restore the
@@ -110,6 +115,11 @@ export class ChatSession extends LightElement {
// Each entry: { name, path, mimetype, filesize, uploading? }. While an upload
// is in flight the entry has `uploading: true` and no `path` yet.
this._attachments = [];
this._tasks = [];
this._taskTick = 0;
this._taskTimer = null;
// Timers that drop a finished task from the strip after a grace period.
this._taskDropTimers = new Map();
this._onAuthRestored = this._onAuthRestored.bind(this);
}
@@ -119,13 +129,16 @@ export class ChatSession extends LightElement {
// Fire-and-forget: availability of a transcription provider determines
// whether the mic button is rendered at all.
this._checkTranscribe();
await Promise.all([this._loadProviders(), this._loadHistory()]);
await Promise.all([this._loadProviders(), this._loadHistory(), this._loadTasks()]);
this._connectWS();
}
disconnectedCallback() {
super.disconnectedCallback?.();
window.removeEventListener('auth-restored', this._onAuthRestored);
this._stopTaskClock();
for (const timer of this._taskDropTimers.values()) clearTimeout(timer);
this._taskDropTimers.clear();
}
// ── Source identity — override in subclass ────────────────────────────────────
@@ -146,7 +159,8 @@ export class ChatSession extends LightElement {
this._activeSource = source;
this._messages = [];
this._waiting = false;
await this._loadHistory();
this._tasks = [];
await Promise.all([this._loadHistory(), this._loadTasks()]);
this._connectWS();
}
@@ -209,6 +223,119 @@ export class ChatSession extends LightElement {
}
}
// ── Background tasks ──────────────────────────────────────────────────────────
//
// The agent can hand work to a background task (`execute_task mode="async"`)
// and keep talking. Until now the only trace of one was the receipt in the
// transcript and a row on the Tasks page, so "is it still going?" had no
// answer in the chat itself. `_tasks` is that answer: a small live list of
// the tasks *this* conversation started.
//
// Live updates arrive as `task_update` over the WebSocket — a broadcast with
// no replay, which is why `_loadTasks` runs on every load and reconnect: it
// is what makes the strip survive a browser refresh.
// A finished task lingers this long before disappearing on its own. Its result
// is already in the conversation by then; the chip is just the hand-off.
static TASK_DROP_MS = 20000;
/**
* Whether this surface can open a task's own session page (`#session/{id}`).
* The mobile shell routes a fixed set of sections and silently falls back to
* the chat for anything else, so there the row is shown without a link rather
* than with one that quietly navigates somewhere wrong.
*/
get _canOpenTaskSession() { return false; }
// Failures the user has dismissed with the ✕. Kept across reloads (the server
// keeps reporting a recent failure, and dismissing it should mean dismissed).
static _DISMISSED_KEY = 'skald.dismissedTasks';
_dismissedTasks() {
try {
return new Set(JSON.parse(localStorage.getItem(ChatSession._DISMISSED_KEY) ?? '[]'));
} catch { return new Set(); }
}
_rememberDismissed(jobId) {
const ids = [...this._dismissedTasks(), jobId].slice(-50);
try { localStorage.setItem(ChatSession._DISMISSED_KEY, JSON.stringify(ids)); } catch { /* private mode */ }
}
async _loadTasks() {
try {
const res = await fetch(`/api/${this._source}/tasks`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const dismissed = this._dismissedTasks();
this._tasks = (await res.json()).filter(t => !dismissed.has(t.job_id));
this._syncTaskClock();
} catch (e) {
console.warn('Could not load background tasks:', e.message);
}
}
/** Insert or advance one task from a `task_update` event. */
_upsertTask(task) {
if (this._dismissedTasks().has(task.job_id)) return;
const idx = this._tasks.findIndex(t => t.job_id === task.job_id);
const prev = idx >= 0 ? this._tasks[idx] : null;
// Keep the fields the event does not carry (a terminal update has no
// `started_at`, and the elapsed counter should not reset at the finish line).
const next = { ...prev, ...task, started_at: task.started_at ?? prev?.started_at ?? null };
this._tasks = idx >= 0
? this._tasks.map((t, i) => (i === idx ? next : t))
: [...this._tasks, next];
// A failure stays until dismissed — it is the only place the reason is
// readable at a glance. Everything else clears itself.
if (next.state === 'completed' || next.state === 'cancelled') {
this._scheduleTaskDrop(next.job_id);
}
this._syncTaskClock();
}
_scheduleTaskDrop(jobId) {
clearTimeout(this._taskDropTimers.get(jobId));
this._taskDropTimers.set(jobId, setTimeout(() => {
this._taskDropTimers.delete(jobId);
this._tasks = this._tasks.filter(t => t.job_id !== jobId);
this._syncTaskClock();
}, ChatSession.TASK_DROP_MS));
}
_dismissTask(jobId) {
this._rememberDismissed(jobId);
clearTimeout(this._taskDropTimers.get(jobId));
this._taskDropTimers.delete(jobId);
this._tasks = this._tasks.filter(t => t.job_id !== jobId);
this._syncTaskClock();
}
/** Stop a running task. The kill lands as a `task_update` like any other end. */
async _stopTask(jobId) {
try {
const res = await fetch(`/api/cron/jobs/${jobId}/kill`, { method: 'POST' });
if (!res.ok) throw new Error(await res.text() || `HTTP ${res.status}`);
} catch (e) {
console.warn('Could not stop task:', e.message);
}
}
/** The 1 s clock runs only while something is actually running. */
_syncTaskClock() {
const running = this._tasks.some(t => t.state === 'running');
if (running && !this._taskTimer) {
this._taskTimer = setInterval(() => { this._taskTick++; }, 1000);
} else if (!running) {
this._stopTaskClock();
}
}
_stopTaskClock() {
clearInterval(this._taskTimer);
this._taskTimer = null;
}
// ── WebSocket ─────────────────────────────────────────────────────────────────
_connectWS() {
@@ -224,6 +351,9 @@ export class ChatSession extends LightElement {
if (this._reconnecting) {
this._reconnecting = false;
this._resyncOnReconnect();
// A task that ended while the socket was down emitted its `task_update`
// into the void: re-read the authoritative list.
this._loadTasks();
}
if (this._hasPendingTools) {
ws.send(JSON.stringify({ type: 'resume' }));
@@ -315,6 +445,9 @@ export class ChatSession extends LightElement {
this._cancelStreamFlush();
this._messages = [];
this._waiting = false;
// Tasks belong to the conversation that started them; this is a new one.
this._tasks = [];
this._stopTaskClock();
try {
const res = await fetch(`/api/sessions?source=${this._source}`, { method: 'POST' });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
@@ -605,6 +738,19 @@ export class ChatSession extends LightElement {
this._selectedGroup = msg.group;
break;
case 'task_update':
// A background task this conversation started changed state.
this._upsertTask({
job_id: msg.job_id,
title: msg.title,
agent_id: msg.agent_id,
session_id: msg.session_id ?? null,
state: msg.state,
error: msg.error ?? null,
started_at: msg.state === 'running' ? new Date().toISOString() : null,
});
break;
case 'llm_failed':
this._waiting = false;
this._dropStreaming();
+1
View File
@@ -43,6 +43,7 @@
<link rel="stylesheet" href="css/variables.css" />
<link rel="stylesheet" href="css/setup-page.css" />
<link rel="stylesheet" href="css/copilot-messages.css" />
<link rel="stylesheet" href="css/agent-tasks.css" />
<link rel="stylesheet" href="css/inbox-cards.css" />
<link rel="stylesheet" href="css/file-viewer.css" />
<link rel="stylesheet" href="css/mobile.css" />