feat(background): livraison auto au propriétaire + projection des tâches de fond dans le work-state (T1+T3)

T1 — Wake automatique du propriétaire à la complétion.
La complétion d'une tâche de fond est désormais livrée à l'agent
propriétaire dès que la session accepte l'envoi (wake.rs :
mark_completion_delivered au send accepté), via un drain de flux dédié
(structured.rs : drain_reply_stream_with_readiness). L'inbox médiée
enfile l'item sans démarrer de tour ni marquer l'agent busy
(input/mod.rs : enqueue FIFO silencieux). Régression couverte
(tests/agent_wake.rs, tests input/mod.rs).

T3 — Tâches de fond projetées dans le read-model du panneau Work.
AgentWorkState porte désormais background_tasks
(VO AgentBackgroundTaskState), alimenté par un builder best-effort
with_background_tasks(store) : union list_open_for_agent + dispatch des
completions non livrées par owner_agent_id, erreur store => Vec vide
(aucune régression live/busy/tickets). DTO Tauri backgroundTasks et
wiring du BackgroundTaskStore côté state.rs. Le frontend, déjà câblé,
affiche Cancel/Retry (mapping queued/waiting -> pending, tri sur
updatedAtMs). Borne V1 : une tâche terminale déjà livrée n'est plus
énumérable (Retry limité à la fenêtre non livrée).

Tests : cargo build --workspace OK ; cargo test -p application /
-p app-tauri / -p infrastructure verts ; frontend build + vitest verts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-04 00:21:33 +02:00
parent f08dae62eb
commit eb9cc16181
21 changed files with 946 additions and 148 deletions

View File

@ -89,6 +89,8 @@ function normalizeBackgroundStatus(
if (raw === "cancelled" || raw === "canceled" || raw === "expired") return "cancelled";
if (raw === "running" || raw === "started") return "running";
if (raw === "delivered") return "delivered";
// Backend `queued`/`waiting` collapse to the cancellable "pending" bucket.
if (raw === "queued" || raw === "waiting") return "pending";
return "pending";
}
@ -107,7 +109,7 @@ function normalizeBackgroundTask(
exitCode: nullableNumber(task.exitCode),
stdoutTail: nullableString(task.stdoutTail),
stderrTail: nullableString(task.stderrTail),
finishedAtMs: nullableNumber(task.finishedAtMs),
updatedAtMs: numberValue(task.updatedAtMs),
};
}

View File

@ -252,7 +252,8 @@ export interface BackgroundCompletion {
exitCode: number | null;
stdoutTail: string | null;
stderrTail: string | null;
finishedAtMs: number | null;
/** Last-update timestamp (epoch ms); chronological ordering key. */
updatedAtMs: number;
}
/** One item currently pending in an agent's inbox. */

View File

@ -451,11 +451,9 @@ function AgentRow({
const busy = busyState.state === "busy";
const tickets = [...(agent.tickets ?? [])].sort((a, b) => a.position - b.position);
const inbox = [...(agent.inbox ?? [])].sort((a, b) => a.createdAtMs - b.createdAtMs);
const backgroundTasks = [...(agent.backgroundTasks ?? [])].sort((a, b) => {
const aDone = a.finishedAtMs ?? 0;
const bDone = b.finishedAtMs ?? 0;
return bDone - aDone || a.taskId.localeCompare(b.taskId);
});
const backgroundTasks = [...(agent.backgroundTasks ?? [])].sort(
(a, b) => b.updatedAtMs - a.updatedAtMs || a.taskId.localeCompare(b.taskId),
);
const inboxDepth = Math.max(agent.inboxDepth ?? 0, inbox.length, tickets.length);
const visibleNodeIds = new Set(visibleLeaves.map((leaf) => leaf.id));
const liveVisible = live && visibleNodeIds.has(agent.live!.nodeId);

View File

@ -176,6 +176,89 @@ describe("ProjectWorkStatePanel", () => {
);
});
it("normalizes the real backend background-task payload shape", async () => {
// Mirrors AgentBackgroundTaskStateDto (crates/app-tauri/src/dto.rs): camelCase,
// `state` (not `status`), optional exitCode/summary/stdoutTail/stderrTail omitted
// when absent, createdAtMs/updatedAtMs present, NO ownerAgentId/projectId/finishedAtMs.
const workState = new MockWorkStateGateway();
workState._setProjectWorkState(PROJECT_ID, {
agents: [
{
agentId: "agent-bg",
name: "Runner",
profileId: "codex",
busy: { state: "idle" },
backgroundTasks: [
{
taskId: "task-waiting-1",
kind: "command",
state: "waiting",
createdAtMs: 10,
updatedAtMs: 11,
},
{
taskId: "task-queued-1",
kind: "headlessRendezvous",
state: "queued",
createdAtMs: 12,
updatedAtMs: 13,
},
{
taskId: "task-failed-1",
kind: "command",
state: "failed",
exitCode: 1,
stderrTail: "boom",
createdAtMs: 14,
updatedAtMs: 15,
},
{
taskId: "task-expired-1",
kind: "sessionResume",
state: "expired",
createdAtMs: 16,
updatedAtMs: 17,
},
],
},
],
});
// Adapter-level assertion: locks the mapping against the real payload.
const state = await workState.getProjectWorkState(PROJECT_ID);
const tasks = state.agents[0]?.backgroundTasks ?? [];
expect(tasks.map((t) => [t.taskId, t.status])).toEqual([
["task-waiting-1", "pending"],
["task-queued-1", "pending"],
["task-failed-1", "failed"],
["task-expired-1", "cancelled"],
]);
// Optional fields omitted by the backend normalize to null (not undefined/NaN).
expect(tasks[0]?.exitCode).toBeNull();
expect(tasks[0]?.stdoutTail).toBeNull();
expect(tasks[2]?.stderrTail).toBe("boom");
// updatedAtMs (backend-emitted) is carried through for chronological ordering.
expect(tasks.map((t) => t.updatedAtMs)).toEqual([11, 13, 15, 17]);
renderPanel(workState);
const list = await screen.findByLabelText("Runner background tasks");
const cancelButtons = within(list).getAllByRole("button", { name: "Cancel" });
const retryButtons = within(list).getAllByRole("button", { name: "Retry" });
// waiting + queued are cancellable; failed + expired are retryable.
expect(cancelButtons.filter((b) => !(b as HTMLButtonElement).disabled)).toHaveLength(2);
expect(retryButtons.filter((b) => !(b as HTMLButtonElement).disabled)).toHaveLength(2);
// Rows are ordered most-recently-updated first (updatedAtMs desc), taskId as
// the deterministic tiebreak — locks the fix for the dead finishedAtMs sort.
const text = list.textContent ?? "";
const order = ["task-exp", "task-fai", "task-que", "task-wai"].map((id) =>
text.indexOf(id),
);
expect(order).toEqual([...order].sort((a, b) => a - b));
expect(order.every((i) => i >= 0)).toBe(true);
});
it("renders a legacy agent without tickets", async () => {
const workState = new MockWorkStateGateway();
workState._setProjectWorkState(PROJECT_ID, {