feat(orchestrator): add SSE events, queue controls, and mosaic rails sync

This commit is contained in:
Jason Woltje
2026-02-17 15:39:15 -06:00
parent 758b2a839b
commit 3258cd4f4d
35 changed files with 1016 additions and 89 deletions

View File

@@ -2,9 +2,10 @@ import { Module } from "@nestjs/common";
import { ConfigModule } from "@nestjs/config";
import { QueueService } from "./queue.service";
import { ValkeyModule } from "../valkey/valkey.module";
import { SpawnerModule } from "../spawner/spawner.module";
@Module({
imports: [ConfigModule, ValkeyModule],
imports: [ConfigModule, ValkeyModule, SpawnerModule],
providers: [QueueService],
exports: [QueueService],
})

View File

@@ -991,12 +991,17 @@ describe("QueueService", () => {
success: true,
metadata: { attempt: 1 },
});
expect(mockValkeyService.updateTaskStatus).toHaveBeenCalledWith("task-123", "executing");
expect(mockValkeyService.updateTaskStatus).toHaveBeenCalledWith(
"task-123",
"executing",
undefined
);
expect(mockValkeyService.publishEvent).toHaveBeenCalledWith({
type: "task.processing",
type: "task.executing",
timestamp: expect.any(String),
taskId: "task-123",
data: { attempt: 1 },
agentId: undefined,
data: { attempt: 1, dispatchedByQueue: true },
});
});

View File

@@ -1,7 +1,9 @@
import { Injectable, OnModuleDestroy, OnModuleInit } from "@nestjs/common";
import { Injectable, OnModuleDestroy, OnModuleInit, Optional, Logger } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { Queue, Worker, Job } from "bullmq";
import { ValkeyService } from "../valkey/valkey.service";
import { AgentSpawnerService } from "../spawner/agent-spawner.service";
import { AgentLifecycleService } from "../spawner/agent-lifecycle.service";
import type { TaskContext } from "../valkey/types";
import type {
QueuedTask,
@@ -16,6 +18,7 @@ import type {
*/
@Injectable()
export class QueueService implements OnModuleInit, OnModuleDestroy {
private readonly logger = new Logger(QueueService.name);
private queue!: Queue<QueuedTask>;
private worker!: Worker<QueuedTask, TaskProcessingResult>;
private readonly queueName: string;
@@ -23,7 +26,9 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
constructor(
private readonly valkeyService: ValkeyService,
private readonly configService: ConfigService
private readonly configService: ConfigService,
@Optional() private readonly spawnerService?: AgentSpawnerService,
@Optional() private readonly lifecycleService?: AgentLifecycleService
) {
this.queueName = this.configService.get<string>(
"orchestrator.queue.name",
@@ -132,6 +137,16 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
context,
};
// Ensure task state exists before queue lifecycle updates.
const getTaskState = (this.valkeyService as Partial<ValkeyService>).getTaskState;
const createTask = (this.valkeyService as Partial<ValkeyService>).createTask;
if (typeof getTaskState === "function" && typeof createTask === "function") {
const existingTask = await getTaskState.call(this.valkeyService, taskId);
if (!existingTask) {
await createTask.call(this.valkeyService, taskId, context);
}
}
// Add to BullMQ queue
await this.queue.add(taskId, queuedTask, {
priority: 10 - priority + 1, // BullMQ: lower number = higher priority, so invert
@@ -214,23 +229,35 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
const { taskId } = job.data;
try {
const session = this.spawnerService?.findAgentSessionByTaskId(taskId);
const agentId = session?.agentId;
if (agentId) {
if (this.lifecycleService) {
await this.lifecycleService.transitionToRunning(agentId);
}
this.spawnerService?.setSessionState(agentId, "running");
}
// Update task state to executing
await this.valkeyService.updateTaskStatus(taskId, "executing");
await this.valkeyService.updateTaskStatus(taskId, "executing", agentId);
// Publish event
await this.valkeyService.publishEvent({
type: "task.processing",
type: "task.executing",
timestamp: new Date().toISOString(),
taskId,
data: { attempt: job.attemptsMade + 1 },
agentId,
data: {
attempt: job.attemptsMade + 1,
dispatchedByQueue: true,
},
});
// Task processing will be handled by agent spawner
// For now, just mark as processing
return {
success: true,
metadata: {
attempt: job.attemptsMade + 1,
...(agentId && { agentId }),
},
};
} catch (error) {
@@ -270,6 +297,14 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
* Handle task failure
*/
private async handleTaskFailure(taskId: string, error: Error): Promise<void> {
const session = this.spawnerService?.findAgentSessionByTaskId(taskId);
if (session) {
this.spawnerService?.setSessionState(session.agentId, "failed", error.message, new Date());
if (this.lifecycleService) {
await this.lifecycleService.transitionToFailed(session.agentId, error.message);
}
}
await this.valkeyService.updateTaskStatus(taskId, "failed", undefined, error.message);
await this.valkeyService.publishEvent({
@@ -284,12 +319,25 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
* Handle task completion
*/
private async handleTaskCompletion(taskId: string): Promise<void> {
const session = this.spawnerService?.findAgentSessionByTaskId(taskId);
if (session) {
this.spawnerService?.setSessionState(session.agentId, "completed", undefined, new Date());
if (this.lifecycleService) {
await this.lifecycleService.transitionToCompleted(session.agentId);
}
} else {
this.logger.warn(
`Queue completed task ${taskId} but no session was found; using queue-only completion state`
);
}
await this.valkeyService.updateTaskStatus(taskId, "completed");
await this.valkeyService.publishEvent({
type: "task.completed",
timestamp: new Date().toISOString(),
taskId,
...(session && { agentId: session.agentId }),
});
}
}