Compare commits

..

4 Commits

Author SHA1 Message Date
4d70e4e779 chore(tasks): MS23-P0-005 done, P0-006 in-progress 2026-03-07 11:58:15 -06:00
03dd25f028 feat(orchestrator): MS23-P0-005 subagent tree endpoint (#714)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-03-07 17:57:55 +00:00
f3726de54e chore(tasks): MS23-P0-004 done, P0-005 in-progress (#713)
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-03-07 17:43:35 +00:00
d0c6622de5 feat(orchestrator): MS23-P0-004 operator inject/pause/resume endpoints (#712)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-03-07 17:43:11 +00:00
11 changed files with 112 additions and 9 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 */

View File

@@ -124,9 +124,9 @@ Target version: `v0.0.23`
| MS23-P0-001 | done | p0-foundation | Prisma schema: AgentConversationMessage, AgentSessionTree, AgentProviderConfig, OperatorAuditLog | #693 | api | feat/ms23-p0-schema | — | MS23-P0-002,MS23-P0-003,MS23-P0-004,MS23-P0-005,MS23-P1-001 | codex | 2026-03-06 | 2026-03-06 | 15K | — | taskSource field per mosaic-queue note in PRD | | MS23-P0-001 | done | p0-foundation | Prisma schema: AgentConversationMessage, AgentSessionTree, AgentProviderConfig, OperatorAuditLog | #693 | api | feat/ms23-p0-schema | — | MS23-P0-002,MS23-P0-003,MS23-P0-004,MS23-P0-005,MS23-P1-001 | codex | 2026-03-06 | 2026-03-06 | 15K | — | taskSource field per mosaic-queue note in PRD |
| MS23-P0-002 | done | p0-foundation | Agent message ingestion: wire spawner/lifecycle to write messages to DB | #693 | orchestrator | feat/ms23-p0-ingestion | MS23-P0-001 | MS23-P0-006 | codex | 2026-03-06 | 2026-03-07 | 20K | — | | | MS23-P0-002 | done | p0-foundation | Agent message ingestion: wire spawner/lifecycle to write messages to DB | #693 | orchestrator | feat/ms23-p0-ingestion | MS23-P0-001 | MS23-P0-006 | codex | 2026-03-06 | 2026-03-07 | 20K | — | |
| MS23-P0-003 | done | p0-foundation | Orchestrator API: GET /agents/:id/messages + SSE stream endpoint | #693 | orchestrator | feat/ms23-p0-stream | MS23-P0-001 | MS23-P0-006 | codex | 2026-03-06 | 2026-03-07 | 20K | — | | | MS23-P0-003 | done | p0-foundation | Orchestrator API: GET /agents/:id/messages + SSE stream endpoint | #693 | orchestrator | feat/ms23-p0-stream | MS23-P0-001 | MS23-P0-006 | codex | 2026-03-06 | 2026-03-07 | 20K | — | |
| MS23-P0-004 | in-progress | p0-foundation | Orchestrator API: POST /agents/:id/inject + pause/resume endpoints | #693 | orchestrator | feat/ms23-p0-controls | MS23-P0-001 | MS23-P0-006 | codex | 2026-03-07 | — | 15K | — | | | MS23-P0-004 | done | p0-foundation | Orchestrator API: POST /agents/:id/inject + pause/resume endpoints | #693 | orchestrator | feat/ms23-p0-controls | MS23-P0-001 | MS23-P0-006 | codex | 2026-03-07 | 2026-03-07 | 15K | — | |
| MS23-P0-005 | not-started | p0-foundation | Subagent tree: parentAgentId on spawn registration + GET /agents/tree | #693 | orchestrator | feat/ms23-p0-tree | MS23-P0-001 | MS23-P0-006 | — | — | — | 15K | — | | | MS23-P0-005 | done | p0-foundation | Subagent tree: parentAgentId on spawn registration + GET /agents/tree | #693 | orchestrator | feat/ms23-p0-tree | MS23-P0-001 | MS23-P0-006 | — | — | — | 15K | — | |
| MS23-P0-006 | not-started | p0-foundation | Unit + integration tests for all P0 orchestrator endpoints | #693 | orchestrator | test/ms23-p0 | MS23-P0-002,MS23-P0-003,MS23-P0-004,MS23-P0-005 | MS23-P1-001 | — | — | — | 20K | — | Phase 0 gate: SSE stream verified via curl | | MS23-P0-006 | in-progress | p0-foundation | Unit + integration tests for all P0 orchestrator endpoints | #693 | orchestrator | test/ms23-p0 | MS23-P0-002,MS23-P0-003,MS23-P0-004,MS23-P0-005 | MS23-P1-001 | codex | 2026-03-07 | — | 20K | — | Phase 0 gate: SSE stream verified via curl |
### Phase 1 — Provider Interface (Plugin Architecture) ### Phase 1 — Provider Interface (Plugin Architecture)