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:
@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -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. */
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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, {
|
||||
|
||||
Reference in New Issue
Block a user