feat(orchestrator): add subagent session tree endpoint
All checks were successful
ci/woodpecker/push/ci Pipeline was successful

This commit is contained in:
2026-03-07 11:53:39 -06:00
parent f3726de54e
commit 9642cd41d4
10 changed files with 109 additions and 6 deletions

View File

@@ -25,14 +25,14 @@ export class AgentIngestionService {
where: { sessionId: agentId }, where: { sessionId: agentId },
create: { create: {
sessionId: agentId, sessionId: agentId,
parentSessionId: parentAgentId, parentSessionId: parentAgentId ?? null,
missionId, missionId,
taskId, taskId,
agentType, agentType,
status: "spawning", status: "spawning",
}, },
update: { update: {
parentSessionId: parentAgentId, parentSessionId: parentAgentId ?? null,
missionId, missionId,
taskId, taskId,
agentType, agentType,

View File

@@ -0,0 +1,30 @@
import { Injectable } from "@nestjs/common";
import { PrismaService } from "../../prisma/prisma.service";
import { AgentTreeResponseDto } from "./dto/agent-tree-response.dto";
@Injectable()
export class AgentTreeService {
constructor(private readonly prisma: PrismaService) {}
async getTree(): Promise<AgentTreeResponseDto[]> {
const entries = await this.prisma.agentSessionTree.findMany({
orderBy: { spawnedAt: "desc" },
take: 200,
});
const response: AgentTreeResponseDto[] = [];
for (const entry of entries) {
response.push({
sessionId: entry.sessionId,
parentSessionId: entry.parentSessionId ?? null,
status: entry.status,
agentType: entry.agentType ?? null,
taskSource: entry.taskSource ?? null,
spawnedAt: entry.spawnedAt.toISOString(),
completedAt: entry.completedAt?.toISOString() ?? null,
});
}
return response;
}
}

View File

@@ -7,6 +7,7 @@ import { KillswitchService } from "../../killswitch/killswitch.service";
import { AgentEventsService } from "./agent-events.service"; import { AgentEventsService } from "./agent-events.service";
import { AgentMessagesService } from "./agent-messages.service"; import { AgentMessagesService } from "./agent-messages.service";
import { AgentControlService } from "./agent-control.service"; import { AgentControlService } from "./agent-control.service";
import { AgentTreeService } from "./agent-tree.service";
import type { KillAllResult } from "../../killswitch/killswitch.service"; import type { KillAllResult } from "../../killswitch/killswitch.service";
describe("AgentsController - Killswitch Endpoints", () => { describe("AgentsController - Killswitch Endpoints", () => {
@@ -41,6 +42,9 @@ describe("AgentsController - Killswitch Endpoints", () => {
pauseAgent: ReturnType<typeof vi.fn>; pauseAgent: ReturnType<typeof vi.fn>;
resumeAgent: ReturnType<typeof vi.fn>; resumeAgent: ReturnType<typeof vi.fn>;
}; };
let mockTreeService: {
getTree: ReturnType<typeof vi.fn>;
};
beforeEach(() => { beforeEach(() => {
mockKillswitchService = { mockKillswitchService = {
@@ -89,6 +93,10 @@ describe("AgentsController - Killswitch Endpoints", () => {
resumeAgent: vi.fn().mockResolvedValue(undefined), resumeAgent: vi.fn().mockResolvedValue(undefined),
}; };
mockTreeService = {
getTree: vi.fn().mockResolvedValue([]),
};
controller = new AgentsController( controller = new AgentsController(
mockQueueService as unknown as QueueService, mockQueueService as unknown as QueueService,
mockSpawnerService as unknown as AgentSpawnerService, mockSpawnerService as unknown as AgentSpawnerService,
@@ -96,7 +104,8 @@ describe("AgentsController - Killswitch Endpoints", () => {
mockKillswitchService as unknown as KillswitchService, mockKillswitchService as unknown as KillswitchService,
mockEventsService as unknown as AgentEventsService, mockEventsService as unknown as AgentEventsService,
mockMessagesService as unknown as AgentMessagesService, mockMessagesService as unknown as AgentMessagesService,
mockControlService as unknown as AgentControlService mockControlService as unknown as AgentControlService,
mockTreeService as unknown as AgentTreeService
); );
}); });

View File

@@ -6,6 +6,7 @@ import { KillswitchService } from "../../killswitch/killswitch.service";
import { AgentEventsService } from "./agent-events.service"; import { AgentEventsService } from "./agent-events.service";
import { AgentMessagesService } from "./agent-messages.service"; import { AgentMessagesService } from "./agent-messages.service";
import { AgentControlService } from "./agent-control.service"; import { AgentControlService } from "./agent-control.service";
import { AgentTreeService } from "./agent-tree.service";
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
describe("AgentsController", () => { describe("AgentsController", () => {
@@ -42,6 +43,9 @@ describe("AgentsController", () => {
pauseAgent: ReturnType<typeof vi.fn>; pauseAgent: ReturnType<typeof vi.fn>;
resumeAgent: ReturnType<typeof vi.fn>; resumeAgent: ReturnType<typeof vi.fn>;
}; };
let treeService: {
getTree: ReturnType<typeof vi.fn>;
};
beforeEach(() => { beforeEach(() => {
// Create mock services // Create mock services
@@ -93,6 +97,10 @@ describe("AgentsController", () => {
resumeAgent: vi.fn().mockResolvedValue(undefined), resumeAgent: vi.fn().mockResolvedValue(undefined),
}; };
treeService = {
getTree: vi.fn().mockResolvedValue([]),
};
// Create controller with mocked services // Create controller with mocked services
controller = new AgentsController( controller = new AgentsController(
queueService as unknown as QueueService, queueService as unknown as QueueService,
@@ -101,7 +109,8 @@ describe("AgentsController", () => {
killswitchService as unknown as KillswitchService, killswitchService as unknown as KillswitchService,
eventsService as unknown as AgentEventsService, eventsService as unknown as AgentEventsService,
messagesService as unknown as AgentMessagesService, messagesService as unknown as AgentMessagesService,
controlService as unknown as AgentControlService controlService as unknown as AgentControlService,
treeService as unknown as AgentTreeService
); );
}); });
@@ -113,6 +122,27 @@ describe("AgentsController", () => {
expect(controller).toBeDefined(); expect(controller).toBeDefined();
}); });
describe("getAgentTree", () => {
it("should return tree entries", async () => {
const entries = [
{
sessionId: "agent-1",
parentSessionId: null,
status: "running",
agentType: "worker",
taskSource: "internal",
spawnedAt: "2026-03-07T00:00:00.000Z",
completedAt: null,
},
];
treeService.getTree.mockResolvedValue(entries);
await expect(controller.getAgentTree()).resolves.toEqual(entries);
expect(treeService.getTree).toHaveBeenCalledTimes(1);
});
});
describe("listAgents", () => { describe("listAgents", () => {
it("should return empty array when no agents exist", () => { it("should return empty array when no agents exist", () => {
// Arrange // Arrange

View File

@@ -30,6 +30,8 @@ import { AgentEventsService } from "./agent-events.service";
import { GetMessagesQueryDto } from "./dto/get-messages-query.dto"; import { GetMessagesQueryDto } from "./dto/get-messages-query.dto";
import { AgentMessagesService } from "./agent-messages.service"; import { AgentMessagesService } from "./agent-messages.service";
import { AgentControlService } from "./agent-control.service"; import { AgentControlService } from "./agent-control.service";
import { AgentTreeService } from "./agent-tree.service";
import { AgentTreeResponseDto } from "./dto/agent-tree-response.dto";
import { InjectAgentDto } from "./dto/inject-agent.dto"; import { InjectAgentDto } from "./dto/inject-agent.dto";
import { PauseAgentDto, ResumeAgentDto } from "./dto/control-agent.dto"; import { PauseAgentDto, ResumeAgentDto } from "./dto/control-agent.dto";
@@ -56,7 +58,8 @@ export class AgentsController {
private readonly killswitchService: KillswitchService, private readonly killswitchService: KillswitchService,
private readonly eventsService: AgentEventsService, private readonly eventsService: AgentEventsService,
private readonly messagesService: AgentMessagesService, private readonly messagesService: AgentMessagesService,
private readonly agentControlService: AgentControlService private readonly agentControlService: AgentControlService,
private readonly agentTreeService: AgentTreeService
) {} ) {}
/** /**
@@ -78,6 +81,7 @@ export class AgentsController {
// Spawn agent using spawner service // Spawn agent using spawner service
const spawnResponse = this.spawnerService.spawnAgent({ const spawnResponse = this.spawnerService.spawnAgent({
taskId: dto.taskId, taskId: dto.taskId,
...(dto.parentAgentId !== undefined ? { parentAgentId: dto.parentAgentId } : {}),
agentType: dto.agentType, agentType: dto.agentType,
context: dto.context, context: dto.context,
}); });
@@ -152,6 +156,13 @@ export class AgentsController {
}; };
} }
@Get("tree")
@UseGuards(OrchestratorApiKeyGuard)
@Throttle({ default: { limit: 200, ttl: 60000 } })
async getAgentTree(): Promise<AgentTreeResponseDto[]> {
return this.agentTreeService.getTree();
}
/** /**
* List all agents * List all agents
* @returns Array of all agent sessions with their status * @returns Array of all agent sessions with their status

View File

@@ -9,6 +9,7 @@ import { AgentEventsService } from "./agent-events.service";
import { PrismaModule } from "../../prisma/prisma.module"; import { PrismaModule } from "../../prisma/prisma.module";
import { AgentMessagesService } from "./agent-messages.service"; import { AgentMessagesService } from "./agent-messages.service";
import { AgentControlService } from "./agent-control.service"; import { AgentControlService } from "./agent-control.service";
import { AgentTreeService } from "./agent-tree.service";
@Module({ @Module({
imports: [QueueModule, SpawnerModule, KillswitchModule, ValkeyModule, PrismaModule], imports: [QueueModule, SpawnerModule, KillswitchModule, ValkeyModule, PrismaModule],
@@ -18,6 +19,7 @@ import { AgentControlService } from "./agent-control.service";
AgentEventsService, AgentEventsService,
AgentMessagesService, AgentMessagesService,
AgentControlService, AgentControlService,
AgentTreeService,
], ],
}) })
export class AgentsModule {} export class AgentsModule {}

View File

@@ -0,0 +1,9 @@
export class AgentTreeResponseDto {
sessionId!: string;
parentSessionId!: string | null;
status!: string;
agentType!: string | null;
taskSource!: string | null;
spawnedAt!: string;
completedAt!: string | null;
}

View File

@@ -116,6 +116,10 @@ export class SpawnAgentDto {
@IsOptional() @IsOptional()
@IsIn(["strict", "standard", "minimal", "custom"]) @IsIn(["strict", "standard", "minimal", "custom"])
gateProfile?: GateProfileType; gateProfile?: GateProfileType;
@IsOptional()
@IsString()
parentAgentId?: string;
} }
/** /**

View File

@@ -115,7 +115,13 @@ export class AgentSpawnerService implements OnModuleDestroy {
} }
void this.agentIngestionService void this.agentIngestionService
.recordAgentSpawned(agentId, undefined, undefined, request.taskId, request.agentType) .recordAgentSpawned(
agentId,
request.parentAgentId,
undefined,
request.taskId,
request.agentType
)
.catch((error: unknown) => { .catch((error: unknown) => {
const errorMessage = error instanceof Error ? error.message : String(error); const errorMessage = error instanceof Error ? error.message : String(error);
this.logger.error(`Failed to record spawned ingestion for ${agentId}: ${errorMessage}`); this.logger.error(`Failed to record spawned ingestion for ${agentId}: ${errorMessage}`);

View File

@@ -40,6 +40,8 @@ export interface SpawnAgentOptions {
export interface SpawnAgentRequest { export interface SpawnAgentRequest {
/** Unique task identifier */ /** Unique task identifier */
taskId: string; taskId: string;
/** Optional parent session identifier for subagent lineage */
parentAgentId?: string;
/** Type of agent to spawn */ /** Type of agent to spawn */
agentType: AgentType; agentType: AgentType;
/** Context for task execution */ /** Context for task execution */