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
+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>
`;
}