feat(FN-1868): merge fusion/fn-1868

This commit is contained in:
gsxdsm
2026-04-16 09:53:06 -07:00
parent c8ad04323d
commit ca3da5c5a0
11 changed files with 119 additions and 93 deletions

View File

@@ -999,7 +999,7 @@ describe("AgentStore", () => {
expect(agents[0].name).toBe("Valid");
});
it("filters out system agents when includeSystem is false", async () => {
it("filters out ephemeral agents by default", async () => {
// Create a normal agent
const normal = await store.createAgent({ name: "Normal Agent", role: "executor" });
@@ -1031,21 +1031,17 @@ describe("AgentStore", () => {
metadata: { managedBy: "task-executor" },
});
// Without includeSystem filter, all agents are returned
// Without includeEphemeral filter, ephemeral agents are filtered out by default
const allAgents = await store.listAgents();
expect(allAgents).toHaveLength(5);
expect(allAgents).toHaveLength(1);
expect(allAgents[0].id).toBe(normal.id);
// With includeSystem: false, system agents are filtered out
const nonSystemAgents = await store.listAgents({ includeSystem: false });
expect(nonSystemAgents).toHaveLength(1);
expect(nonSystemAgents[0].id).toBe(normal.id);
// With includeSystem: true, all agents are returned
const systemAgents = await store.listAgents({ includeSystem: true });
expect(systemAgents).toHaveLength(5);
// With includeEphemeral: true, all agents are returned
const allIncludingEphemeral = await store.listAgents({ includeEphemeral: true });
expect(allIncludingEphemeral).toHaveLength(5);
});
it("includeSystem filter works with state filter", async () => {
it("includeEphemeral filter works with state filter", async () => {
// Create a normal agent
const normal = await store.createAgent({ name: "Normal Agent", role: "executor" });
@@ -1058,12 +1054,12 @@ describe("AgentStore", () => {
await store.recordHeartbeat(taskWorker.id, "ok");
await store.updateAgentState(taskWorker.id, "active");
// Without includeSystem, but with state=active - only returns active non-system agents
const activeNonSystem = await store.listAgents({ state: "active", includeSystem: false });
expect(activeNonSystem).toHaveLength(0);
// Without includeEphemeral filter - only returns active non-ephemeral agents
const activeNonEphemeral = await store.listAgents({ state: "active" });
expect(activeNonEphemeral).toHaveLength(0);
// With includeSystem: true, returns all active agents
const activeAll = await store.listAgents({ state: "active", includeSystem: true });
// With includeEphemeral: true, returns all active agents
const activeAll = await store.listAgents({ state: "active", includeEphemeral: true });
expect(activeAll).toHaveLength(1);
expect(activeAll[0].id).toBe(taskWorker.id);
});

View File

@@ -43,7 +43,7 @@ import type {
AgentRatingInput,
Task,
} from "./types.js";
import { AGENT_VALID_TRANSITIONS, agentToConfigSnapshot, diffConfigSnapshots, CheckoutConflictError } from "./types.js";
import { AGENT_VALID_TRANSITIONS, agentToConfigSnapshot, diffConfigSnapshots, isEphemeralAgent, CheckoutConflictError } from "./types.js";
import type { RunMutationContext } from "./types.js";
import type { TaskStore } from "./store.js";
import { computeAccessState } from "./agent-permissions.js";
@@ -985,27 +985,12 @@ export class AgentStore extends EventEmitter {
return agent;
}
/**
* Check if an agent is a system-generated ephemeral agent (task-worker or spawned child).
* These agents are created at runtime by the engine and should typically be hidden
* from the default agents page view.
*/
private isSystemAgent(agent: Agent): boolean {
const metadata = agent.metadata ?? {};
return (
metadata.agentKind === "task-worker" ||
metadata.type === "spawned" ||
metadata.taskWorker === true ||
metadata.managedBy === "task-executor"
);
}
/**
* List all agents, optionally filtered by state.
* @param filter - Optional filter criteria
* @returns Array of agents
*/
async listAgents(filter?: { state?: AgentState; role?: AgentCapability; includeSystem?: boolean }): Promise<Agent[]> {
async listAgents(filter?: { state?: AgentState; role?: AgentCapability; includeEphemeral?: boolean }): Promise<Agent[]> {
const files = await readdir(this.agentsDir).catch(() => [] as string[]);
const agentFiles = files.filter((f) => f.endsWith(".json") && !f.includes("-heartbeats") && !f.includes("-sessions") && !f.includes("-runs") && !f.includes("-revisions"));
@@ -1019,8 +1004,8 @@ export class AgentStore extends EventEmitter {
if (filter?.state && agent.state !== filter.state) continue;
if (filter?.role && agent.role !== filter.role) continue;
// When includeSystem is explicitly false, filter out system agents
if (filter?.includeSystem === false && this.isSystemAgent(agent)) continue;
// When includeEphemeral is not true, filter out ephemeral agents
if (filter?.includeEphemeral !== true && isEphemeralAgent(agent)) continue;
agents.push(agent);
} catch {
@@ -1503,7 +1488,7 @@ export class AgentStore extends EventEmitter {
* @param filter - Optional filter for listing agents
* @returns Root nodes with nested children
*/
async getOrgTree(filter?: { includeSystem?: boolean }): Promise<OrgTreeNode[]> {
async getOrgTree(filter?: { includeEphemeral?: boolean }): Promise<OrgTreeNode[]> {
const agents = await this.listAgents(filter);
if (agents.length === 0) {
return [];

View File

@@ -1,4 +1,4 @@
export { COLUMNS, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS, isGlobalSettingsKey, isProjectSettingsKey, THINKING_LEVELS, THEME_MODES, COLOR_THEMES, WORKFLOW_STEP_TEMPLATES, AGENT_PERMISSIONS, agentToConfigSnapshot, diffConfigSnapshots, CheckoutConflictError } from "./types.js";
export { COLUMNS, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS, isGlobalSettingsKey, isProjectSettingsKey, THINKING_LEVELS, THEME_MODES, COLOR_THEMES, WORKFLOW_STEP_TEMPLATES, AGENT_PERMISSIONS, agentToConfigSnapshot, diffConfigSnapshots, isEphemeralAgent, CheckoutConflictError } from "./types.js";
export type { Column, IssueInfo, IssueState, PrInfo, PrStatus, Task, TaskAttachment, TaskComment, TaskCommentInput, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, TaskCreateInput, TaskDetail, InboxTask, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, MergeDetails, MergeResult, Settings, GlobalSettings, ProjectSettings, SettingsScope, DaemonTokenSettings, TaskStep, StepStatus, TaskLogEntry, RunMutationContext, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepPhase, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, OrgTreeNode, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentApiKey, AgentApiKeyCreateResult, AgentCapability, AgentPromptTemplate, AgentPromptsConfig, AgentPermission, TaskAssignSource, AgentAccessState, AgentHeartbeatConfig, AgentBudgetConfig, AgentBudgetStatus, InstructionsBundleConfig, MessageResponseMode, AgentHeartbeatEvent, AgentHeartbeatRun, BlockedStateSnapshot, HeartbeatInvocationSource, AgentTaskSession, AgentRating, AgentRatingSummary, AgentRatingInput, AgentConfigSnapshot, RevisionFieldDiff, AgentConfigRevision, AgentStats, ReflectionTrigger, ReflectionMetrics, AgentReflection, AgentPerformanceSummary, NtfyNotificationEvent, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, Mailbox, CheckoutLease, RunAuditDomain, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter } from "./types.js";
export { AGENT_VALID_TRANSITIONS } from "./types.js";
export {

View File

@@ -1947,6 +1947,46 @@ export const AGENT_VALID_TRANSITIONS: Record<AgentState, AgentState[]> = {
terminated: ["idle", "active", "running"], // Can be restarted or reset
};
/**
* Detect if an agent is a runtime-created ephemeral agent (task-worker or spawned child).
* These agents are created by the engine for task execution and should typically be
* hidden from the default agents page listing.
*
* Detection heuristics (returns true if ANY match):
* - `agent.metadata?.agentKind === "task-worker"` — task-worker agents from InProcessRuntime
* - `agent.metadata?.taskWorker === true` — legacy task-worker marker
* - `agent.metadata?.managedBy === "task-executor"` — executor-managed agents
* - `agent.metadata?.type === "spawned"` — spawned child agents from TaskExecutor
* - Legacy fallback: executor role with name starting with "executor-" and no reportsTo
*
* @param agent - Agent object (partial shape accepted)
* @returns true if the agent is an ephemeral/runtime-created agent
*/
export function isEphemeralAgent(
agent: { metadata?: Record<string, unknown> | null; name?: string; role?: string; reportsTo?: string | null },
): boolean {
const metadata = agent.metadata ?? {};
// Check explicit metadata markers first
if (metadata.agentKind === "task-worker") return true;
if (metadata.taskWorker === true) return true;
if (metadata.managedBy === "task-executor") return true;
if (metadata.type === "spawned") return true;
// Legacy fallback: executor agents with "executor-" prefix and no manager
// These are task workers that were created before metadata was standardized
if (
agent.role === "executor" &&
typeof agent.name === "string" &&
agent.name.startsWith("executor-") &&
agent.reportsTo == null
) {
return true;
}
return false;
}
/** Single heartbeat event recorded for an agent */
export interface AgentHeartbeatEvent {
/** ISO-8601 timestamp of when the heartbeat was recorded */