fix(domain,application,infrastructure,backend,web-server,app-tauri,frontend): statut/LED projet indexé par projectId, source LiveSessions/LiveStateStore/ReconcileLiveState — la fin d'un agent dans un projet non actif repasse désormais la LED du projet au repos — domain: DomainEvent::AgentBusyChanged porte project_id ; infrastructure input: busy émis au démarrage du tour et idle au mark_idle avec le projet possesseur ; backend: DTO agentBusyChanged sérialise project_id (busy_changed_keeps_owning_project_on_the_wire) ; application workstate: ReconcileLiveState exécuté aussi avant les lectures de projection (plus seulement à l'ouverture) et liveness évaluée par (projectId, agentId) — un même agent_id vivant dans un autre projet n'épargne plus un orphelin ; web-server: ws_send_chat libère le busy avant le Final visible et la route work-state projet reste au contrat tauri ; app-tauri: commandes work-state/busy alignées sur le projectId possesseur ; frontend: useProjectActivity agrège l'activité par projectId (busy_changed filtré par projet possesseur, plus le seul projet courant), wsLiveClient/wsLiveClientReconnect propagant project_id, AnnouncementsProvider/CustomAgentChatView/WebWorkspaceLive/LayoutGrid.cellControlsLayering alignés — #373 (QA verte: cargo check --tests -p domain -p application -p infrastructure -p backend -p app-tauri -p web-server, cargo test -p application workstate::reconcile::tests, cargo test -p infrastructure input::tests::busy_event_fires_on_turn_start_and_idle_on_mark_idle, cargo test -p backend events::tests::busy_changed_keeps_owning_project_on_the_wire, cargo test -p web-server websocket_chat_send_releases_busy_before_visible_final + authorized_get_project_work_state_returns_tauri_contract, npm test -- useProjectActivity wsLiveClient wsLiveClientReconnect workstate AnnouncementsProvider CustomAgentChatView WebWorkspaceLive LayoutGrid.cellControlsLayering, npm run typecheck, cargo fmt --all -- --check, git diff --check ; fichiers .ideai/* et scripts/ hors périmètre ; aucun push)
This commit is contained in:
@ -148,9 +148,9 @@ describe("WsLiveClient", () => {
|
||||
const handler = vi.fn();
|
||||
client.setDomainEventHandler(handler);
|
||||
|
||||
socket.receive({ kind: "event.domain", payload: { type: "agentBusyChanged", agentId: "a1", busy: true } });
|
||||
socket.receive({ kind: "event.domain", payload: { type: "agentBusyChanged", projectId: "p1", agentId: "a1", busy: true } });
|
||||
|
||||
expect(handler).toHaveBeenCalledWith({ type: "agentBusyChanged", agentId: "a1", busy: true });
|
||||
expect(handler).toHaveBeenCalledWith({ type: "agentBusyChanged", projectId: "p1", agentId: "a1", busy: true });
|
||||
});
|
||||
|
||||
it("rejects a pending command when an error frame arrives", async () => {
|
||||
|
||||
@ -177,7 +177,7 @@ describe("WsLiveClient connection state + reconnection", () => {
|
||||
// Events resume flowing to the persisted handler after reconnect.
|
||||
const events: unknown[] = [];
|
||||
h.ws.setDomainEventHandler((e) => events.push(e));
|
||||
h.sockets[1].receive({ kind: "event.domain", payload: { type: "agentBusyChanged", agentId: "a1", busy: true } });
|
||||
h.sockets[1].receive({ kind: "event.domain", payload: { type: "agentBusyChanged", projectId: "p1", agentId: "a1", busy: true } });
|
||||
expect(events).toHaveLength(1);
|
||||
});
|
||||
|
||||
|
||||
@ -333,7 +333,7 @@ export type DomainEvent =
|
||||
message: string;
|
||||
}
|
||||
| { type: "agentProfileChanged"; agentId: string; profileId: string }
|
||||
| { type: "agentBusyChanged"; agentId: string; busy: boolean }
|
||||
| { type: "agentBusyChanged"; projectId: string; agentId: string; busy: boolean }
|
||||
| {
|
||||
type: "agentRestartRequested";
|
||||
agentId: string;
|
||||
|
||||
@ -1866,7 +1866,7 @@ describe("CustomAgentChatView", () => {
|
||||
],
|
||||
conversations: [],
|
||||
});
|
||||
system.emit({ type: "agentBusyChanged", agentId: "agent-1", busy: false });
|
||||
system.emit({ type: "agentBusyChanged", projectId: "project-1", agentId: "agent-1", busy: false });
|
||||
onChunk({ kind: "final", content: "Next answer" });
|
||||
}),
|
||||
stopLiveAgent: vi.fn(),
|
||||
@ -1949,7 +1949,7 @@ describe("CustomAgentChatView", () => {
|
||||
],
|
||||
conversations: [],
|
||||
});
|
||||
system.emit({ type: "agentBusyChanged", agentId: "agent-1", busy: true });
|
||||
system.emit({ type: "agentBusyChanged", projectId: "project-1", agentId: "agent-1", busy: true });
|
||||
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Stop" }));
|
||||
|
||||
@ -2103,7 +2103,7 @@ describe("CustomAgentChatView", () => {
|
||||
expect(screen.getByRole("button", { name: "Stop" })).toBeTruthy();
|
||||
|
||||
workState._setProjectWorkState("project-1", stateFor(false));
|
||||
act(() => system.emit({ type: "agentBusyChanged", agentId: "agent-1", busy: false }));
|
||||
act(() => system.emit({ type: "agentBusyChanged", projectId: "project-1", agentId: "agent-1", busy: false }));
|
||||
|
||||
expect(await screen.findByText("Tour terminé sans réponse finale.")).toBeTruthy();
|
||||
expect(screen.queryByRole("button", { name: "Stop" })).toBeNull();
|
||||
@ -2254,7 +2254,7 @@ describe("CustomAgentChatView", () => {
|
||||
],
|
||||
conversations: [],
|
||||
});
|
||||
system.emit({ type: "agentBusyChanged", agentId: "agent-1", busy: false });
|
||||
system.emit({ type: "agentBusyChanged", projectId: "project-1", agentId: "agent-1", busy: false });
|
||||
|
||||
await waitFor(() => expect(composer).toHaveProperty("disabled", false));
|
||||
expect(screen.getByRole("button", { name: "Envoyer" })).toBeTruthy();
|
||||
@ -6465,7 +6465,7 @@ describe("CustomAgentChatView", () => {
|
||||
expect(screen.getByRole("button", { name: "En attente · 2" })).toBeTruthy();
|
||||
|
||||
workState._setProjectWorkState("project-1", stateFor(true));
|
||||
act(() => system.emit({ type: "agentBusyChanged", agentId: "agent-1", busy: true }));
|
||||
act(() => system.emit({ type: "agentBusyChanged", projectId: "project-1", agentId: "agent-1", busy: true }));
|
||||
await waitFor(() => expect(screen.getByRole("button", { name: "Mettre en attente" })).toBeTruthy());
|
||||
|
||||
act(() => harness.emit(0, { kind: "final", content: "first final" }));
|
||||
@ -6475,7 +6475,7 @@ describe("CustomAgentChatView", () => {
|
||||
expect(screen.getByRole("button", { name: "En attente · 2" })).toBeTruthy();
|
||||
|
||||
workState._setProjectWorkState("project-1", stateFor(false));
|
||||
act(() => system.emit({ type: "agentBusyChanged", agentId: "agent-1", busy: false }));
|
||||
act(() => system.emit({ type: "agentBusyChanged", projectId: "project-1", agentId: "agent-1", busy: false }));
|
||||
|
||||
await waitFor(() => expect(harness.sendAgentChat).toHaveBeenCalledTimes(2));
|
||||
expect(harness.sendAgentChat).toHaveBeenNthCalledWith(
|
||||
|
||||
@ -1587,6 +1587,9 @@ export function CustomAgentChatView({
|
||||
let cancelled = false;
|
||||
void system
|
||||
.onDomainEvent((event) => {
|
||||
if (event.type === "agentBusyChanged" && event.projectId !== projectId) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
event.type === "agentBusyChanged" ||
|
||||
event.type === "delegationReady" ||
|
||||
|
||||
@ -56,7 +56,7 @@ function announcement(over: Partial<AnnouncementEvent> = {}): AnnouncementEvent
|
||||
}
|
||||
|
||||
function busy(agentId: string, isBusy: boolean): DomainEvent {
|
||||
return { type: "agentBusyChanged", agentId, busy: isBusy };
|
||||
return { type: "agentBusyChanged", projectId: "p1", agentId, busy: isBusy };
|
||||
}
|
||||
|
||||
// The provider subscribes asynchronously (onDomainEvent returns a Promise) and
|
||||
|
||||
@ -8,7 +8,8 @@
|
||||
* and the requester preview F2).
|
||||
* 2. **Overlay lifecycle** — the per-agent **busy/idle** state, the single
|
||||
* authority for mounting/retracting the target overlay (Architect arbitrage
|
||||
* 2026-07-04). Fed live by `agentBusyChanged { agentId, busy }` and hydrated
|
||||
* 2026-07-04). Fed live by
|
||||
* `agentBusyChanged { projectId, agentId, busy }` and hydrated
|
||||
* from the reconciled read-model `ProjectWorkState.agents[].busy` at mount /
|
||||
* reboot (via {@link useHydrateAgentBusy}).
|
||||
* 3. **Self-restart presentation** — the live restart phase plus a small,
|
||||
@ -160,6 +161,7 @@ export function AnnouncementsProvider({
|
||||
}),
|
||||
);
|
||||
} else if (event.type === "agentBusyChanged") {
|
||||
if (event.projectId !== projectId) return;
|
||||
// Lifecycle authority. `true` mounts the target overlay; `false` retracts
|
||||
// it (idle) and clears the target's content so a next turn starts fresh —
|
||||
// even when no completion event was ever emitted (the sticking case).
|
||||
|
||||
@ -137,7 +137,7 @@ function renderGrid(
|
||||
}
|
||||
|
||||
function busy(agentId: string, isBusy: boolean): DomainEvent {
|
||||
return { type: "agentBusyChanged", agentId, busy: isBusy };
|
||||
return { type: "agentBusyChanged", projectId: "p1", agentId, busy: isBusy };
|
||||
}
|
||||
|
||||
async function flush() {
|
||||
|
||||
@ -49,6 +49,20 @@ export function hasProjectCliInteraction(projectId: string): boolean {
|
||||
return (activeByProject.get(projectId)?.size ?? 0) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drops optimistic interaction markers once the authoritative project snapshot
|
||||
* confirms that no work remains. This is intentionally project-scoped: a
|
||||
* terminal event received while another project is displayed must never leave
|
||||
* the originating project's LED busy, nor clear activity in another project.
|
||||
*/
|
||||
export function reconcileProjectCliInteractionActivity(
|
||||
projectId: string,
|
||||
hasActiveWork: boolean,
|
||||
): void {
|
||||
if (hasActiveWork || !activeByProject.delete(projectId)) return;
|
||||
emit();
|
||||
}
|
||||
|
||||
export function subscribeProjectCliInteractionActivity(
|
||||
listener: Listener,
|
||||
): () => void {
|
||||
|
||||
@ -1,11 +1,12 @@
|
||||
import { act, render, screen, waitFor } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { DIProvider } from "@/app/di";
|
||||
import { MockWorkStateGateway } from "@/adapters/mock";
|
||||
import { MockSystemGateway, MockWorkStateGateway } from "@/adapters/mock";
|
||||
import type { Gateways } from "@/ports";
|
||||
import {
|
||||
__resetProjectCliInteractionActivityForTests,
|
||||
hasProjectCliInteraction,
|
||||
markProjectCliInteractionStarted,
|
||||
markProjectCliInteractionStopped,
|
||||
} from "./projectCliInteractionActivity";
|
||||
@ -16,6 +17,19 @@ function ActivityProbe({ projectId }: { projectId: string }) {
|
||||
return <span data-testid="activity">{activity[projectId]}</span>;
|
||||
}
|
||||
|
||||
function MultiProjectActivityProbe({ projectIds }: { projectIds: string[] }) {
|
||||
const activity = useProjectActivity(projectIds);
|
||||
return (
|
||||
<>
|
||||
{projectIds.map((projectId) => (
|
||||
<span key={projectId} data-testid={`activity-${projectId}`}>
|
||||
{activity[projectId]}
|
||||
</span>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
describe("useProjectActivity CLI interaction projection (#198)", () => {
|
||||
beforeEach(() => {
|
||||
__resetProjectCliInteractionActivityForTests();
|
||||
@ -45,4 +59,100 @@ describe("useProjectActivity CLI interaction projection (#198)", () => {
|
||||
expect(screen.getByTestId("activity").textContent).toBe("none"),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not clear optimistic activity on non-terminal refresh events", async () => {
|
||||
const projectId = "project-1";
|
||||
const workState = new MockWorkStateGateway();
|
||||
const system = new MockSystemGateway();
|
||||
const getProjectWorkState = vi.spyOn(workState, "getProjectWorkState");
|
||||
workState._setProjectWorkState(projectId, { agents: [], conversations: [] });
|
||||
act(() => markProjectCliInteractionStarted(projectId, "agent-1:turn-1"));
|
||||
|
||||
render(
|
||||
<DIProvider gateways={{ workState, system } as unknown as Gateways}>
|
||||
<ActivityProbe projectId={projectId} />
|
||||
</DIProvider>,
|
||||
);
|
||||
|
||||
expect((await screen.findByTestId("activity")).textContent).toBe("busy");
|
||||
await waitFor(() => expect(getProjectWorkState).toHaveBeenCalledTimes(1));
|
||||
act(() => system.emit({
|
||||
type: "agentBusyChanged",
|
||||
projectId,
|
||||
agentId: "agent-1",
|
||||
busy: true,
|
||||
}));
|
||||
await waitFor(() => expect(getProjectWorkState).toHaveBeenCalledTimes(2));
|
||||
expect(screen.getByTestId("activity").textContent).toBe("busy");
|
||||
expect(hasProjectCliInteraction(projectId)).toBe(true);
|
||||
});
|
||||
|
||||
it("reconciles an inactive project's optimistic LED by project id (#373)", async () => {
|
||||
const workState = new MockWorkStateGateway();
|
||||
const system = new MockSystemGateway();
|
||||
const getProjectWorkState = vi.spyOn(workState, "getProjectWorkState");
|
||||
const busyAgent = (agentId: string) => ({
|
||||
agentId,
|
||||
name: agentId,
|
||||
profileId: "profile-1",
|
||||
live: { kind: "structured" as const, nodeId: `node-${agentId}`, sessionId: `session-${agentId}` },
|
||||
busy: { state: "busy" as const, ticket: `ticket-${agentId}`, sinceMs: 1 },
|
||||
tickets: [],
|
||||
});
|
||||
const idleAgent = (agentId: string) => ({
|
||||
...busyAgent(agentId),
|
||||
busy: { state: "idle" as const },
|
||||
});
|
||||
workState._setProjectWorkState("project-a", {
|
||||
agents: [busyAgent("agent-a1"), busyAgent("agent-a2")],
|
||||
conversations: [],
|
||||
});
|
||||
workState._setProjectWorkState("project-b", { agents: [], conversations: [] });
|
||||
act(() => {
|
||||
markProjectCliInteractionStarted("project-a", "agent-a1:turn-1");
|
||||
markProjectCliInteractionStarted("project-a", "agent-a2:turn-1");
|
||||
});
|
||||
|
||||
render(
|
||||
<DIProvider gateways={{ workState, system } as unknown as Gateways}>
|
||||
<MultiProjectActivityProbe projectIds={["project-a", "project-b"]} />
|
||||
</DIProvider>,
|
||||
);
|
||||
|
||||
expect((await screen.findByTestId("activity-project-a")).textContent).toBe("busy");
|
||||
expect(screen.getByTestId("activity-project-b").textContent).toBe("none");
|
||||
await waitFor(() => expect(getProjectWorkState).toHaveBeenCalledTimes(2));
|
||||
|
||||
workState._setProjectWorkState("project-a", {
|
||||
agents: [idleAgent("agent-a1"), busyAgent("agent-a2")],
|
||||
conversations: [],
|
||||
});
|
||||
act(() => system.emit({
|
||||
type: "agentBusyChanged",
|
||||
projectId: "project-a",
|
||||
agentId: "agent-a1",
|
||||
busy: false,
|
||||
}));
|
||||
await waitFor(() => expect(getProjectWorkState).toHaveBeenCalledTimes(3));
|
||||
expect(getProjectWorkState).toHaveBeenLastCalledWith("project-a");
|
||||
expect(screen.getByTestId("activity-project-a").textContent).toBe("busy");
|
||||
expect(hasProjectCliInteraction("project-a")).toBe(true);
|
||||
|
||||
workState._setProjectWorkState("project-a", {
|
||||
agents: [idleAgent("agent-a1"), idleAgent("agent-a2")],
|
||||
conversations: [],
|
||||
});
|
||||
act(() => system.emit({
|
||||
type: "agentBusyChanged",
|
||||
projectId: "project-a",
|
||||
agentId: "agent-a2",
|
||||
busy: false,
|
||||
}));
|
||||
await waitFor(() => expect(getProjectWorkState).toHaveBeenCalledTimes(4));
|
||||
expect(getProjectWorkState).toHaveBeenLastCalledWith("project-a");
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("activity-project-a").textContent).toBe("idle"),
|
||||
);
|
||||
expect(hasProjectCliInteraction("project-a")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import type { ProjectWorkState } from "@/domain";
|
||||
import { useGateways } from "@/app/di";
|
||||
@ -8,6 +8,7 @@ import {
|
||||
} from "./projectActivity";
|
||||
import {
|
||||
hasProjectCliInteraction,
|
||||
reconcileProjectCliInteractionActivity,
|
||||
useProjectCliInteractionActivityVersion,
|
||||
} from "./projectCliInteractionActivity";
|
||||
|
||||
@ -35,30 +36,56 @@ export function useProjectActivity(projectIds: string[]): ProjectActivityMap {
|
||||
const [states, setStates] = useState<Record<string, ProjectWorkState | null>>(
|
||||
{},
|
||||
);
|
||||
const refreshSequenceByProject = useRef<Record<string, number>>({});
|
||||
const cliInteractionVersion = useProjectCliInteractionActivityVersion();
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
if (stableProjectIds.length === 0) {
|
||||
const refresh = useCallback(async (
|
||||
requestedProjectIds: string[] = stableProjectIds,
|
||||
reconcileLocalActivity = false,
|
||||
) => {
|
||||
if (requestedProjectIds.length === 0) {
|
||||
setStates({});
|
||||
return;
|
||||
}
|
||||
const entries = await Promise.all(
|
||||
stableProjectIds.map(async (projectId) => {
|
||||
requestedProjectIds.map(async (projectId) => {
|
||||
const sequence = (refreshSequenceByProject.current[projectId] ?? 0) + 1;
|
||||
refreshSequenceByProject.current[projectId] = sequence;
|
||||
try {
|
||||
return [
|
||||
projectId,
|
||||
await workState.getProjectWorkState(projectId),
|
||||
sequence,
|
||||
] as const;
|
||||
} catch {
|
||||
return [projectId, null] as const;
|
||||
return [projectId, null, sequence] as const;
|
||||
}
|
||||
}),
|
||||
);
|
||||
setStates(Object.fromEntries(entries));
|
||||
const currentEntries = entries.filter(
|
||||
([projectId, , sequence]) =>
|
||||
refreshSequenceByProject.current[projectId] === sequence,
|
||||
);
|
||||
if (currentEntries.length === 0) return;
|
||||
if (reconcileLocalActivity) {
|
||||
for (const [projectId, state] of currentEntries) {
|
||||
if (!state) continue;
|
||||
reconcileProjectCliInteractionActivity(
|
||||
projectId,
|
||||
projectActivityStatus(state) === "busy",
|
||||
);
|
||||
}
|
||||
}
|
||||
setStates((previous) => ({
|
||||
...previous,
|
||||
...Object.fromEntries(
|
||||
currentEntries.map(([projectId, state]) => [projectId, state]),
|
||||
),
|
||||
}));
|
||||
}, [stableProjectIds, workState]);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
void refresh(stableProjectIds);
|
||||
}, [refresh]);
|
||||
|
||||
useEffect(() => {
|
||||
@ -67,7 +94,15 @@ export function useProjectActivity(projectIds: string[]): ProjectActivityMap {
|
||||
let cancelled = false;
|
||||
void system
|
||||
.onDomainEvent((event) => {
|
||||
if (REFRESH_EVENTS.has(event.type)) void refresh();
|
||||
if (!REFRESH_EVENTS.has(event.type)) return;
|
||||
if (event.type === "agentBusyChanged") {
|
||||
if (!stableProjectIds.includes(event.projectId)) return;
|
||||
void refresh([event.projectId], !event.busy);
|
||||
return;
|
||||
}
|
||||
const terminalAgentEvent =
|
||||
event.type === "agentExited";
|
||||
void refresh(stableProjectIds, terminalAgentEvent);
|
||||
})
|
||||
.then((un) => {
|
||||
if (cancelled) un();
|
||||
@ -77,7 +112,7 @@ export function useProjectActivity(projectIds: string[]): ProjectActivityMap {
|
||||
cancelled = true;
|
||||
unsubscribe?.();
|
||||
};
|
||||
}, [refresh, system]);
|
||||
}, [refresh, stableProjectIds, system]);
|
||||
|
||||
return useMemo(() => {
|
||||
const next: ProjectActivityMap = {};
|
||||
|
||||
@ -103,7 +103,7 @@ describe("WebWorkspace live surfaces (F5)", () => {
|
||||
);
|
||||
// …and an `agentBusyChanged` domain event drives a live refresh.
|
||||
act(() => {
|
||||
(gateways.system as MockSystemGateway).emit({ type: "agentBusyChanged", agentId: "a1", busy: true });
|
||||
(gateways.system as MockSystemGateway).emit({ type: "agentBusyChanged", projectId, agentId: "a1", busy: true });
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
|
||||
@ -60,6 +60,9 @@ export function useProjectWorkState(projectId: string): ProjectWorkStateViewMode
|
||||
let unsubscribe: (() => void) | undefined;
|
||||
let cancelled = false;
|
||||
void system.onDomainEvent((event) => {
|
||||
if (event.type === "agentBusyChanged" && event.projectId !== projectId) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
event.type === "agentLaunched" ||
|
||||
event.type === "agentExited" ||
|
||||
|
||||
@ -818,6 +818,7 @@ describe("ProjectWorkStatePanel", () => {
|
||||
|
||||
system.emit({
|
||||
type: "agentBusyChanged",
|
||||
projectId: PROJECT_ID,
|
||||
agentId: "agent-3",
|
||||
busy: true,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user