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) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-9041-removal.md
Normal file
7
.changeset/fn-9041-removal.md
Normal file
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
<!-- FNXC:CommandCenter 2026-06-25-19:47: FN-7019 restored the user-facing picker contract: preset and custom date-range selections must change every historical analytics tab, while Mission Control stays live and intentionally ignores historical range filters. -->
|
||||
<!-- FNXC:CommandCenter 2026-06-19-23:54: FN-6755 moved team-specific operations out of Overview: org hierarchy and heartbeat pause/resume live in Team, while Overview keeps global AI engine, concurrency, and theme controls. -->
|
||||
<!-- FNXC:GlobalConcurrencyControls 2026-06-26-00:00: The Command Center Concurrency card mirrors the footer concurrency popover by showing read-only running-agent counts and current-use markers for the shared global cap and current-project max-concurrent slider. -->
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -31,6 +31,18 @@ export const AGENT_ACTIVITY_EVENT_CLASSIFICATION: Readonly<Record<AgentActivityE
|
||||
"approval:requested": "in-flight",
|
||||
};
|
||||
|
||||
/*
|
||||
FNXC:AgentActivityStream 2026-08-14-19:18:
|
||||
FN-9041 removes agent state churn from work-activity displays. Roster state and the live
|
||||
agent:stateChanged channel remain authoritative, so historical state-change rows must never
|
||||
enter the retained activity stream or consume its bounded capacity.
|
||||
*/
|
||||
export const HIDDEN_AGENT_ACTIVITY_TYPES: ReadonlySet<AgentActivityEventType> = 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 {
|
||||
/*
|
||||
|
||||
@@ -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 (
|
||||
<section className="cc-agent-activity">
|
||||
@@ -63,10 +72,12 @@ export function AgentActivityPanel({ projectId, range, onOpenAgent, onOpenTask }
|
||||
</div>
|
||||
) : null}
|
||||
<AreaShell testId="agent-activity" isLoading={activity.isLoading} error={activity.error} isEmpty={!rows.length && !activity.hasMore} emptyMessage={t("commandCenter.agentActivity.empty", "No agent activity yet.")}>
|
||||
<div className="cc-agent-activity-list">
|
||||
{rows.map((row) => <AgentActivityRow key={row.eventId} event={row} onOpenAgent={onOpenAgent} onOpenTask={onOpenTask} />)}
|
||||
</div>
|
||||
{mode === "timeline" && timelineLimit < activity.visibleEvents.length ? (
|
||||
{!rows.length ? <div className="cc-area-empty" data-testid="cc-area-agent-activity-empty"><p>{t("commandCenter.agentActivity.empty", "No agent activity yet.")}</p></div> : (
|
||||
<div className="cc-agent-activity-list">
|
||||
{rows.map((row) => <AgentActivityRow key={row.eventId} event={row} onOpenAgent={onOpenAgent} onOpenTask={onOpenTask} />)}
|
||||
</div>
|
||||
)}
|
||||
{mode === "timeline" && timelineLimit < visibleEvents.length ? (
|
||||
<button type="button" className="btn" onClick={() => setTimelineLimit((value) => value + TIMELINE_WINDOW_SIZE)}>{t("commandCenter.agentActivity.showMore", "Show more loaded activity")}</button>
|
||||
) : null}
|
||||
{mode === "timeline" && activity.hasMore ? (
|
||||
|
||||
@@ -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(<AgentActivityPanel projectId="project" range={range} />);
|
||||
|
||||
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<string, (message: MessageEvent) => void> } | undefined;
|
||||
subscribeSse.mockImplementation((_url, options) => {
|
||||
subscription = options;
|
||||
return vi.fn();
|
||||
});
|
||||
getAgentActivity.mockResolvedValueOnce({ events: [], nextCursor: null });
|
||||
render(<AgentActivityPanel projectId="project" range={range} />);
|
||||
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(<AgentActivityPanel projectId="project" range={range} />);
|
||||
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();
|
||||
|
||||
@@ -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<typeof event>[] = []) => ({ 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<typeof event>) => {
|
||||
@@ -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");
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user