From b7604a94bf957ba84dc46a77f808a4865fde4dbb Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Fri, 14 Aug 2026 12:39:23 -0700 Subject: [PATCH] FN-9041: remove agent state-change activity logs Remove durable agent state-change events and hide retained state-change rows from activity displays. - Stop AgentStore update and reconciliation paths from writing state-change activity events. - Filter historical state-change events in activity stores and panels while retaining live roster notifications. - Document the retained activity contract, add coverage, and include a patch changeset. Files changed: .changeset/fn-9041-removal.md | 7 ++++ docs/agent-activity-contract.md | 4 ++ docs/architecture.md | 2 +- docs/dashboard-guide.md | 2 +- .../src/__tests__/agent-activity-writers.test.ts | 23 ++++++----- packages/core/src/agents/agent-store.ts | 28 +++++-------- .../app/components/agentsOrgChartActivity.ts | 12 ++++++ .../command-center/AgentActivityPanel.tsx | 25 +++++++---- .../__tests__/AgentActivityPanel.test.tsx | 48 ++++++++++++++++++++++ .../app/hooks/__tests__/agentActivityStore.test.ts | 39 +++++++++++++++++- packages/dashboard/app/hooks/agentActivityStore.ts | 3 +- 11 files changed, 153 insertions(+), 40 deletions(-) Fusion-Task-Id: FN-9041 Fusion-Task-Lineage: 532724b1-5945-4904-b5cd-2c27909805de Co-authored-by: Fusion (runfusion.ai) --- .changeset/fn-9041-removal.md | 7 +++ docs/agent-activity-contract.md | 4 ++ docs/architecture.md | 2 +- docs/dashboard-guide.md | 2 +- .../__tests__/agent-activity-writers.test.ts | 23 +++++---- packages/core/src/agents/agent-store.ts | 28 ++++------- .../app/components/agentsOrgChartActivity.ts | 12 +++++ .../command-center/AgentActivityPanel.tsx | 25 +++++++--- .../__tests__/AgentActivityPanel.test.tsx | 48 +++++++++++++++++++ .../__tests__/agentActivityStore.test.ts | 39 ++++++++++++++- .../dashboard/app/hooks/agentActivityStore.ts | 3 +- 11 files changed, 153 insertions(+), 40 deletions(-) create mode 100644 .changeset/fn-9041-removal.md diff --git a/.changeset/fn-9041-removal.md b/.changeset/fn-9041-removal.md new file mode 100644 index 0000000000..5039a8ba46 --- /dev/null +++ b/.changeset/fn-9041-removal.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Agent Activity no longer lists agent state-change events. +category: fix +dev: Removes AgentStore state-change outbox writers and hides historical rows with the dashboard predicate. diff --git a/docs/agent-activity-contract.md b/docs/agent-activity-contract.md index 88360f5177..137e09f0d3 100644 --- a/docs/agent-activity-contract.md +++ b/docs/agent-activity-contract.md @@ -27,6 +27,10 @@ Each event has these 12 fields, in the shape exported as `AgentActivityEvent`: | `occurredAt` | ISO timestamp string. | | `metadata` | Sanitized metadata object or `null`. | +## Event type compatibility + +`agent:state-changed` remains a valid event enum value and `type` filter value so historical durable rows remain readable through this unchanged wire shape. No current writer produces that type; agent roster state is instead observed through the roster and its separate live state channel. Consumers that present work activity hide historical state-change rows without treating the enum as invalid. + ## Bounds and ordering `before` is an **exclusive** upper bound and `since` is an **exclusive** lower bound. Supplying both selects the **open interval `(since, before)`**. It is not a half-open interval: neither endpoint is returned. diff --git a/docs/architecture.md b/docs/architecture.md index 253f16470f..ff64a25ee6 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -2406,7 +2406,7 @@ The resolver deliberately separates four outcomes: `selected` writes the owner o ### Agent activity outbox -FN-8864 records project-scoped agent activity in `project.agent_activity_events`, with a transactional per-project bigint counter providing a stable decimal-string `seq` cursor. The deterministic event id and `(project_id,event_id)` uniqueness make retries idempotent without allocating a cursor position for a deduplicated retry. The stream covers task starts, review handoffs and completions, agent state changes, workflow gates, and approval requests. +FN-8864 records project-scoped agent activity in `project.agent_activity_events`, with a transactional per-project bigint counter providing a stable decimal-string `seq` cursor. The deterministic event id and `(project_id,event_id)` uniqueness make retries idempotent without allocating a cursor position for a deduplicated retry. The stream covers task starts, review handoffs and completions, workflow gates, and approval requests. Historical `agent:state-changed` rows remain wire-compatible but are no longer produced; current agent state comes from the roster/live state channel. Writers use the `AsyncDataLayer` outbox seam because AgentStore and approval stores can run outside a TaskStore process. Callers provide attribution claims only; the append boundary probes the current project roster before emitting `agent` attribution, without exemptions. `lane` and `actor` are never org-map nodes. Metadata is deny-by-default: it contains only closed enums (with `unlisted`/`custom` fallbacks), generated Fusion identifiers, counts, booleans, and SHAs—never freeform text. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index a7cda3cd65..d5bd31b943 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -1350,7 +1350,7 @@ Navigation: Features: - Global date-range picker in the header scopes the analytics tabs; **Last 24h**, **Last 7 days**, **Last 30 days**, **All time**, and custom/open-ended ranges each request their selected analytics window. **Mission Control** remains live rather than historical. -- **Agent Activity** is an org-wide event surface, separate from the aggregate **Activity** analytics tab. **Live** seeds a running feed from `GET /api/agent-activity` and receives `agent:activity` SSE frames; **Timeline** provides filters for agent, task, and event type plus manual scroll-back paging. Its time picker filters the loaded timeline client-side because the route exposes no time-range parameter. Entries open their originating task or agent detail. The feed does not use `/api/activity-feed`; backward paging echoes the server `nextCursor` into exclusive `before` over unique `seq` values, so changing bound inclusivity or `seq` uniqueness would break lossless timeline paging. +- **Agent Activity** is an org-wide event surface, separate from the aggregate **Activity** analytics tab. **Live** seeds a running feed from `GET /api/agent-activity` and receives `agent:activity` SSE frames; **Timeline** provides filters for agent, task, and event type plus manual scroll-back paging. Agent state-change events are excluded from both lists and the type filter; use the roster and Live Agent cards for current agent state. Its time picker filters the loaded timeline client-side because the route exposes no time-range parameter. Entries open their originating task or agent detail. The feed does not use `/api/activity-feed`; backward paging echoes the server `nextCursor` into exclusive `before` over unique `seq` values, so changing bound inclusivity or `seq` uniqueness would break lossless timeline paging. diff --git a/packages/core/src/__tests__/agent-activity-writers.test.ts b/packages/core/src/__tests__/agent-activity-writers.test.ts index e1a0b76009..01fab02eac 100644 --- a/packages/core/src/__tests__/agent-activity-writers.test.ts +++ b/packages/core/src/__tests__/agent-activity-writers.test.ts @@ -37,19 +37,22 @@ pgTest("agent activity production writers", () => { await h.afterEach(); }); - it("persists one roster-proven state transition through updateAgentState", async () => { + it("does not persist direct or cross-process roster state transitions", async () => { const agent = await agentStore.createAgent({ name: "Activity executor", role: "executor" }); + const observer = new AgentStore({ rootDir: h.rootDir(), asyncLayer: h.layer() }); + await observer.init(); - await agentStore.updateAgentState(agent.id, "running"); + try { + await agentStore.updateAgentState(agent.id, "running"); + let activity = await queryAgentActivityEvents(h.layer(), { agentId: agent.id }); + expect(activity.events).toHaveLength(0); - const { events } = await queryAgentActivityEvents(h.layer(), { agentId: agent.id }); - expect(events).toHaveLength(1); - expect(events[0]).toMatchObject({ - type: "agent:state-changed", - agentId: agent.id, - agentAttribution: "agent", - metadata: { fromState: "active", toState: "running", source: "update" }, - }); + await observer.checkForChanges(); + activity = await queryAgentActivityEvents(h.layer(), { agentId: agent.id }); + expect(activity.events).toHaveLength(0); + } finally { + observer.close(); + } }); it("persists an attributable approval through its production AsyncDataLayer seam", async () => { diff --git a/packages/core/src/agents/agent-store.ts b/packages/core/src/agents/agent-store.ts index 4c0826ee78..57e735d258 100644 --- a/packages/core/src/agents/agent-store.ts +++ b/packages/core/src/agents/agent-store.ts @@ -1503,8 +1503,12 @@ export class AgentStore extends EventEmitter { await this.writeAgent(updated); this.emit("agent:stateChanged", agentId, currentState, newState); - /* FNXC:AgentActivityStream 2026-08-09-09:09: monitoring is fail-soft and still probes this in-memory agent against the live roster. */ - if (this.asyncLayer) try { await appendAgentActivityEvent(this.asyncLayer, { type: "agent:state-changed", attributionClaim: resolveAgentActivityAttribution([{ id: agentId, provenance: "roster" }], "executor"), taskId: updated.taskId, occurredAt: updated.updatedAt, discriminator: updated.updatedAt, metadata: { fromState: currentState, toState: newState, source: "update" } }); } catch { /* monitoring must not block a state transition */ } + /* + FNXC:AgentActivityStream 2026-08-14-19:18: + FN-9041 removes roster state churn from the durable work-activity feed. State remains + observable through the roster and this live agent:stateChanged event without consuming + outbox retention reserved for task and workflow activity. + */ this.emit("agent:updated", updated, currentState); return updated; @@ -3411,23 +3415,11 @@ export class AgentStore extends EventEmitter { if (stateChanged) { this.emit("agent:stateChanged", agent.id, previousState, agent.state); /* - FNXC:AgentActivityStream 2026-08-09-09:38: - A separate-process state transition may only become visible through this reconciliation observer. Reuse its durable updatedAt identity so an originating writer dedupes, while an older writer still gains one outbox row. Monitoring remains fail-soft. + FNXC:AgentActivityStream 2026-08-14-19:18: + FN-9041 keeps cross-process reconciliation on the roster/live state channel rather than + writing state churn to the durable work-activity outbox. Cache updates and both emitted + events remain intact so separate-process state observers still receive this transition. */ - if (this.asyncLayer) { - try { - await appendAgentActivityEvent(this.asyncLayer, { - type: "agent:state-changed", - attributionClaim: resolveAgentActivityAttribution([{ id: agent.id, provenance: "roster" }], "executor"), - taskId: agent.taskId, - occurredAt: agent.updatedAt, - discriminator: agent.updatedAt, - metadata: { fromState: previousState, toState: agent.state, source: "reconciliation" }, - }); - } catch { - // Monitoring must not interrupt roster reconciliation. - } - } this.emit("agent:updated", agent, previousState); } else { this.emit("agent:updated", agent); diff --git a/packages/dashboard/app/components/agentsOrgChartActivity.ts b/packages/dashboard/app/components/agentsOrgChartActivity.ts index ef1dbfd9cf..1937da139e 100644 --- a/packages/dashboard/app/components/agentsOrgChartActivity.ts +++ b/packages/dashboard/app/components/agentsOrgChartActivity.ts @@ -31,6 +31,18 @@ export const AGENT_ACTIVITY_EVENT_CLASSIFICATION: Readonly = new Set(["agent:state-changed"]); + +export function isHiddenAgentActivityType(type: string): boolean { + return HIDDEN_AGENT_ACTIVITY_TYPES.has(type as AgentActivityEventType); +} + /** Returns milliseconds for a valid ISO timestamp, otherwise null for store-side rejection. */ export function parseActivityOccurredAt(occurredAt: unknown): number | null { /* diff --git a/packages/dashboard/app/components/command-center/AgentActivityPanel.tsx b/packages/dashboard/app/components/command-center/AgentActivityPanel.tsx index 64b3a5a0a1..e616280b59 100644 --- a/packages/dashboard/app/components/command-center/AgentActivityPanel.tsx +++ b/packages/dashboard/app/components/command-center/AgentActivityPanel.tsx @@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import type { AgentActivityEventType } from "../../api"; import { ActivityFeedRowPresentation } from "../ActivityFeed"; +import { isHiddenAgentActivityType } from "../agentsOrgChartActivity"; import { AreaShell } from "./areas/AreaShell"; import { AGENT_ACTIVITY_TYPE_CONFIG, DEFAULT_AGENT_ACTIVITY_CONFIG } from "./agentActivityPresentation"; import type { DateRange } from "./DateRangePicker"; @@ -10,7 +11,8 @@ import "./AgentActivityPanel.css"; const LIVE_RENDER_LIMIT = 100; const TIMELINE_WINDOW_SIZE = 100; -const EVENT_TYPES = Object.keys(AGENT_ACTIVITY_TYPE_CONFIG) as AgentActivityEventType[]; +const EVENT_TYPES = (Object.keys(AGENT_ACTIVITY_TYPE_CONFIG) as AgentActivityEventType[]) + .filter((type) => !isHiddenAgentActivityType(type)); export interface AgentActivityPanelProps { projectId?: string; @@ -38,9 +40,16 @@ export function AgentActivityPanel({ projectId, range, onOpenAgent, onOpenTask } () => [...new Set(activity.events.map((event) => event.agentId).filter(Boolean))].sort(), [activity.events], ); + /* + FNXC:AgentActivityStream 2026-08-14-19:18: + FN-9041 hides historical state churn at this render boundary because the cursor hook must retain + every wire row for correct no-progress and exhaustion accounting. Filter before each display limit + so hidden rows cannot consume a visible slot, while paging can still load older real activity. + */ + const visibleEvents = activity.visibleEvents.filter((event) => !isHiddenAgentActivityType(event.type)); const rows = mode === "live" - ? activity.events.slice(0, LIVE_RENDER_LIMIT) - : activity.visibleEvents.slice(0, timelineLimit); + ? activity.events.filter((event) => !isHiddenAgentActivityType(event.type)).slice(0, LIVE_RENDER_LIMIT) + : visibleEvents.slice(0, timelineLimit); return (
@@ -63,10 +72,12 @@ export function AgentActivityPanel({ projectId, range, onOpenAgent, onOpenTask } ) : null} -
- {rows.map((row) => )} -
- {mode === "timeline" && timelineLimit < activity.visibleEvents.length ? ( + {!rows.length ?

{t("commandCenter.agentActivity.empty", "No agent activity yet.")}

: ( +
+ {rows.map((row) => )} +
+ )} + {mode === "timeline" && timelineLimit < visibleEvents.length ? ( ) : null} {mode === "timeline" && activity.hasMore ? ( diff --git a/packages/dashboard/app/components/command-center/__tests__/AgentActivityPanel.test.tsx b/packages/dashboard/app/components/command-center/__tests__/AgentActivityPanel.test.tsx index f09a4ef3d7..e70dc9f176 100644 --- a/packages/dashboard/app/components/command-center/__tests__/AgentActivityPanel.test.tsx +++ b/packages/dashboard/app/components/command-center/__tests__/AgentActivityPanel.test.tsx @@ -53,6 +53,54 @@ describe("AgentActivityPanel", () => { expect(screen.getAllByText(/Completed work/).map((row) => row.textContent)).toEqual(["Completed work 3", "Completed work 2"]); }); + it("hides state changes from live and timeline rows and from the type filter", async () => { + getAgentActivity.mockResolvedValueOnce({ + events: [ + event("2", { type: "agent:state-changed", summary: "Changed state" }), + event("1", { type: "task:started", summary: "Started work" }), + ], + nextCursor: null, + }); + render(); + + await screen.findByText("Started work"); + expect(screen.queryByText("Changed state")).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Timeline" })); + expect(screen.getByText("Started work")).toBeInTheDocument(); + expect(screen.queryByText("Changed state")).not.toBeInTheDocument(); + const typeFilter = screen.getByLabelText("Filter by event type") as HTMLSelectElement; + expect([...typeFilter.options].some((option) => option.text === "Agent state changed")).toBe(false); + expect(typeFilter.options).toHaveLength(7); + }); + + it("does not render a live state-change SSE frame", async () => { + let subscription: { events: Record void> } | undefined; + subscribeSse.mockImplementation((_url, options) => { + subscription = options; + return vi.fn(); + }); + getAgentActivity.mockResolvedValueOnce({ events: [], nextCursor: null }); + render(); + await screen.findByTestId("cc-area-agent-activity-empty"); + + act(() => subscription?.events["agent:activity"](new MessageEvent("agent:activity", { + data: JSON.stringify(event("2", { type: "agent:state-changed", summary: "Changed state" })), + }))); + expect(screen.queryByText("Changed state")).not.toBeInTheDocument(); + }); + + it("shows an empty state and keeps timeline paging available when every row is hidden", async () => { + getAgentActivity.mockResolvedValueOnce({ + events: [event("1", { type: "agent:state-changed", summary: "Changed state" })], + nextCursor: "1", + }); + render(); + await screen.findByTestId("cc-area-agent-activity-empty"); + fireEvent.click(screen.getByRole("button", { name: "Timeline" })); + expect(screen.getByTestId("cc-area-agent-activity-empty")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Load older" })).toBeInTheDocument(); + }); + it("opens a task from its timeline row and retains a separate agent target", async () => { const onOpenTask = vi.fn(); const onOpenAgent = vi.fn(); diff --git a/packages/dashboard/app/hooks/__tests__/agentActivityStore.test.ts b/packages/dashboard/app/hooks/__tests__/agentActivityStore.test.ts index 49b15a09f4..748a1cca19 100644 --- a/packages/dashboard/app/hooks/__tests__/agentActivityStore.test.ts +++ b/packages/dashboard/app/hooks/__tests__/agentActivityStore.test.ts @@ -31,9 +31,15 @@ import { ACTIVE_STATE_WINDOW_MS } from "../../components/agentsOrgChartActivity" const NOW = new Date("2026-08-09T10:00:00.000Z"); const page = (events: ReturnType[] = []) => ({ events, nextCursor: null }); -const event = (eventId: string, occurredAt: string, agentId = "agent-a", projectId = "project") => ({ +const event = ( + eventId: string, + occurredAt: string, + agentId = "agent-a", + projectId = "project", + type: "task:started" | "agent:state-changed" = "task:started", +) => ({ eventId, seq: eventId, projectId, agentId, agentAttribution: "agent" as const, - taskId: null, type: "task:started" as const, fromAgentId: null, toAgentId: null, + taskId: null, type, fromAgentId: null, toAgentId: null, summary: eventId, occurredAt, metadata: null, }); const deliver = (value: ReturnType) => { @@ -93,6 +99,35 @@ describe("agentActivityStore", () => { expect(agentActivityStore.getSnapshot().events.map((item) => item.eventId)).toEqual(["new", "old"]); }); + it("drops hidden seed and SSE state changes while retaining real activity for the same agent", async () => { + mocks.getAgentActivity.mockResolvedValue(page([ + event("seed-state", "2026-08-09T10:00:02.000Z", "agent-a", "project", "agent:state-changed"), + event("started", "2026-08-09T10:00:01.000Z"), + ])); + agentActivityStore.retain("one", "project"); + await vi.waitFor(() => expect(agentActivityStore.getSnapshot().events).toHaveLength(1)); + + deliver(event("live-state", "2026-08-09T10:00:03.000Z", "agent-a", "project", "agent:state-changed")); + const snapshot = agentActivityStore.getSnapshot(); + expect(snapshot.events.map((item) => item.eventId)).toEqual(["started"]); + expect(snapshot.activityByAgentId.get("agent-a")?.eventId).toBe("started"); + }); + + it("does not let hidden state changes consume retained-ring capacity", async () => { + mocks.getAgentActivity.mockResolvedValue(page()); + agentActivityStore.retain("one", "project"); + await vi.waitFor(() => expect(mocks.getActivityHandler()).toBeTypeOf("function")); + for (let index = 0; index < ACTIVITY_EVENT_CAP; index++) { + deliver(event(`event-${index}`, new Date(NOW.getTime() + index).toISOString(), `agent-${index}`)); + } + + deliver(event("hidden", new Date(NOW.getTime() + ACTIVITY_EVENT_CAP).toISOString(), "agent-hidden", "project", "agent:state-changed")); + const snapshot = agentActivityStore.getSnapshot(); + expect(snapshot.events).toHaveLength(ACTIVITY_EVENT_CAP); + expect(snapshot.events.some((item) => item.eventId === "event-0")).toBe(true); + expect(snapshot.events.some((item) => item.eventId === "hidden")).toBe(false); + }); + it("drops malformed timestamps and retains the newest events when the ring reaches its cap", async () => { mocks.getAgentActivity.mockResolvedValue(page()); agentActivityStore.retain("one", "project"); diff --git a/packages/dashboard/app/hooks/agentActivityStore.ts b/packages/dashboard/app/hooks/agentActivityStore.ts index 4d39c4a0dd..3078ff874a 100644 --- a/packages/dashboard/app/hooks/agentActivityStore.ts +++ b/packages/dashboard/app/hooks/agentActivityStore.ts @@ -5,6 +5,7 @@ import { compareActivityEvents, FLOW_EDGE_WINDOW_MS, getActivityEventAgeMs, + isHiddenAgentActivityType, parseActivityOccurredAt, withActivityWindowTimestamp, } from "../components/agentsOrgChartActivity"; @@ -152,7 +153,7 @@ class AgentActivityStore { // The event URL is project-scoped, but validate frames and seed rows as a defense-in-depth // boundary: a stale or misrouted project frame must never populate a retained snapshot. if (projectId !== undefined && event?.projectId !== projectId) return false; - if (!event || typeof event.eventId !== "string" || this.seenIds.has(event.eventId) || parseActivityOccurredAt(event.occurredAt) === null) return false; + if (!event || isHiddenAgentActivityType(event.type) || typeof event.eventId !== "string" || this.seenIds.has(event.eventId) || parseActivityOccurredAt(event.occurredAt) === null) return false; this.seenIds.add(event.eventId); this.events.push(withActivityWindowTimestamp(event, Date.now())); // Comparator order is newest-first; remove the tail so backfills cannot evict fresh activity.