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:
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user