feat(orchestrator): convoy driver tick loop (Epic 6)
feat(orchestrator): convoy driver tick loop (Epic 6)
This commit is contained in:
commit
5bade93b07
22 changed files with 853 additions and 12 deletions
|
|
@ -31,6 +31,12 @@ MARKDOWN_BACKLOG_WORKSPACE_ID="00000000-0000-0000-0000-000000000000"
|
|||
# Local dev: redis://localhost:6379
|
||||
REDIS_URL="redis://:CHANGE_ME@192.168.68.102:6379/11"
|
||||
|
||||
# ----- Orchestrator (apps/orchestrator) -----
|
||||
# ORCHESTRATOR_DRY_RUN=1 logs dispatch/transition without Redis enqueue
|
||||
# ORCHESTRATOR_POLL_MS=5000
|
||||
# ORCHESTRATOR_STALL_MINUTES=120
|
||||
# ORCHESTRATOR_MAX_IMPLEMENTERS=2
|
||||
|
||||
# ----- Auth (NextAuth v5) -----
|
||||
# Local: http://localhost:3000 • Prod: https://echodo.stillwell.cloud
|
||||
NEXTAUTH_URL="http://localhost:3000"
|
||||
|
|
|
|||
28
apps/orchestrator/package.json
Normal file
28
apps/orchestrator/package.json
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
{
|
||||
"name": "@tasks/orchestrator",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"start": "node dist/index.mjs",
|
||||
"build": "tsup",
|
||||
"type-check": "tsc --noEmit",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tasks/database": "workspace:*",
|
||||
"@tasks/shared": "workspace:*",
|
||||
"drizzle-orm": "^0.38.0",
|
||||
"ioredis": "^5.4.2",
|
||||
"zod": "^3.24.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.10.0",
|
||||
"tsup": "^8.3.5",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^5.7.0",
|
||||
"vitest": "^2.1.9"
|
||||
}
|
||||
}
|
||||
162
apps/orchestrator/src/db/convoy.ts
Normal file
162
apps/orchestrator/src/db/convoy.ts
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
import { and, desc, eq, inArray, isNull, lt, sql } from "drizzle-orm";
|
||||
|
||||
import type { db as dbInstance } from "@tasks/database";
|
||||
import { agentRuns, convoyEvents, objects } from "@tasks/database/schema";
|
||||
import {
|
||||
briefObjectContentSchema,
|
||||
convoyObjectContentSchema,
|
||||
validateConvoyTransition,
|
||||
type ConvoyStatus,
|
||||
} from "@tasks/shared/convoy-lifecycle";
|
||||
import type { BriefSummary } from "@tasks/shared/orchestrator";
|
||||
|
||||
type Db = typeof dbInstance;
|
||||
|
||||
export type LoadedConvoy = {
|
||||
id: string;
|
||||
workspaceId: string;
|
||||
title: string;
|
||||
status: ConvoyStatus;
|
||||
skipFlags: string[];
|
||||
briefs: BriefSummary[];
|
||||
completedRoles: Set<string>;
|
||||
completedBriefIds: Set<string>;
|
||||
};
|
||||
|
||||
function parseStatus(raw: string | null): ConvoyStatus {
|
||||
const status = raw ?? "draft";
|
||||
return status as ConvoyStatus;
|
||||
}
|
||||
|
||||
export async function listActiveConvoys(db: Db): Promise<LoadedConvoy[]> {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(objects)
|
||||
.where(
|
||||
and(
|
||||
eq(objects.type, "project"),
|
||||
sql`${objects.content} ->> 'convoySlug' IS NOT NULL`,
|
||||
inArray(objects.status, ["draft", "ready", "in_progress"]),
|
||||
),
|
||||
);
|
||||
|
||||
const loaded: LoadedConvoy[] = [];
|
||||
for (const row of rows) {
|
||||
const content = convoyObjectContentSchema.safeParse(row.content);
|
||||
if (!content.success) continue;
|
||||
|
||||
const briefRows = await db
|
||||
.select()
|
||||
.from(objects)
|
||||
.where(and(eq(objects.parentId, row.id), eq(objects.workspaceId, row.workspaceId)));
|
||||
|
||||
const events = await db
|
||||
.select({ role: convoyEvents.role, eventType: convoyEvents.eventType, briefId: convoyEvents.briefId })
|
||||
.from(convoyEvents)
|
||||
.where(eq(convoyEvents.convoyId, row.id))
|
||||
.orderBy(desc(convoyEvents.occurredAt))
|
||||
.limit(200);
|
||||
|
||||
const completedRoles = new Set<string>();
|
||||
const completedBriefIds = new Set<string>();
|
||||
for (const event of events) {
|
||||
if (event.eventType === "role_complete") {
|
||||
completedRoles.add(event.role);
|
||||
if (event.briefId) completedBriefIds.add(event.briefId);
|
||||
}
|
||||
}
|
||||
|
||||
const briefs: BriefSummary[] = briefRows.map((brief) => {
|
||||
const parsed = briefObjectContentSchema.safeParse(brief.content);
|
||||
return {
|
||||
id: brief.id,
|
||||
briefNumber: parsed.success ? parsed.data.briefNumber : brief.sortOrder,
|
||||
status: brief.status,
|
||||
sliceDependencies: parsed.success ? parsed.data.sliceDependencies : [],
|
||||
};
|
||||
});
|
||||
|
||||
loaded.push({
|
||||
id: row.id,
|
||||
workspaceId: row.workspaceId,
|
||||
title: row.title,
|
||||
status: parseStatus(row.status),
|
||||
skipFlags: content.data.skipFlags,
|
||||
briefs,
|
||||
completedRoles,
|
||||
completedBriefIds,
|
||||
});
|
||||
}
|
||||
|
||||
return loaded;
|
||||
}
|
||||
|
||||
export async function transitionConvoyInDb(
|
||||
db: Db,
|
||||
input: {
|
||||
workspaceId: string;
|
||||
convoyId: string;
|
||||
toStatus: ConvoyStatus;
|
||||
actor: string;
|
||||
reason?: string;
|
||||
},
|
||||
) {
|
||||
const [convoy] = await db
|
||||
.select()
|
||||
.from(objects)
|
||||
.where(
|
||||
and(
|
||||
eq(objects.id, input.convoyId),
|
||||
eq(objects.workspaceId, input.workspaceId),
|
||||
eq(objects.type, "project"),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!convoy) {
|
||||
throw new Error(`Convoy ${input.convoyId} not found`);
|
||||
}
|
||||
|
||||
const fromStatus = parseStatus(convoy.status);
|
||||
validateConvoyTransition({
|
||||
from: fromStatus,
|
||||
to: input.toStatus,
|
||||
actor: input.actor,
|
||||
reason: input.reason,
|
||||
});
|
||||
|
||||
await db
|
||||
.update(objects)
|
||||
.set({ status: input.toStatus, updatedAt: new Date() })
|
||||
.where(eq(objects.id, convoy.id));
|
||||
|
||||
await db.insert(convoyEvents).values({
|
||||
workspaceId: input.workspaceId,
|
||||
convoyId: convoy.id,
|
||||
role: input.actor,
|
||||
actor: input.actor,
|
||||
eventType: "status_change",
|
||||
metadata: {
|
||||
from: fromStatus,
|
||||
to: input.toStatus,
|
||||
reason: input.reason ?? null,
|
||||
source: "orchestrator",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function detectStalledConvoys(db: Db, stallMinutes: number): Promise<string[]> {
|
||||
const cutoff = new Date(Date.now() - stallMinutes * 60_000);
|
||||
const openRuns = await db
|
||||
.select({
|
||||
backlogItemId: agentRuns.backlogItemId,
|
||||
startedAt: agentRuns.startedAt,
|
||||
})
|
||||
.from(agentRuns)
|
||||
.where(and(isNull(agentRuns.finishedAt), lt(agentRuns.startedAt, cutoff)))
|
||||
.limit(50);
|
||||
|
||||
if (openRuns.length === 0) return [];
|
||||
|
||||
return openRuns.map((run) => run.backlogItemId);
|
||||
}
|
||||
42
apps/orchestrator/src/env.ts
Normal file
42
apps/orchestrator/src/env.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import { z } from "zod";
|
||||
|
||||
const envSchema = z.object({
|
||||
DATABASE_URL: z.string().min(1),
|
||||
REDIS_URL: z.string().min(1).optional(),
|
||||
ORCHESTRATOR_DRY_RUN: z
|
||||
.enum(["0", "1", "true", "false", ""])
|
||||
.optional()
|
||||
.transform((value) => value === "1" || value === "true"),
|
||||
ORCHESTRATOR_POLL_MS: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((value) => {
|
||||
const parsed = Number(value ?? "5000");
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : 5_000;
|
||||
}),
|
||||
ORCHESTRATOR_STALL_MINUTES: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((value) => {
|
||||
const parsed = Number(value ?? "120");
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : 120;
|
||||
}),
|
||||
ORCHESTRATOR_MAX_IMPLEMENTERS: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((value) => {
|
||||
const parsed = Number(value ?? "2");
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : 2;
|
||||
}),
|
||||
});
|
||||
|
||||
export type OrchestratorEnv = z.infer<typeof envSchema>;
|
||||
|
||||
export function loadEnv(): OrchestratorEnv {
|
||||
const parsed = envSchema.safeParse(process.env);
|
||||
if (!parsed.success) {
|
||||
console.error("[orchestrator] Invalid environment:", parsed.error.flatten().fieldErrors);
|
||||
process.exit(1);
|
||||
}
|
||||
return parsed.data;
|
||||
}
|
||||
49
apps/orchestrator/src/index.ts
Normal file
49
apps/orchestrator/src/index.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import { db } from "@tasks/database";
|
||||
import IoRedis from "ioredis";
|
||||
|
||||
import { detectStalledConvoys } from "./db/convoy.js";
|
||||
import { loadEnv } from "./env.js";
|
||||
import { runOrchestratorTick } from "./tick.js";
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const env = loadEnv();
|
||||
const redis = env.REDIS_URL ? new IoRedis(env.REDIS_URL) : null;
|
||||
|
||||
if (env.ORCHESTRATOR_DRY_RUN) {
|
||||
console.info("[orchestrator] DRY-RUN mode — jobs will be logged, not enqueued");
|
||||
} else if (!redis) {
|
||||
console.warn("[orchestrator] REDIS_URL not set — running in DRY-RUN mode");
|
||||
}
|
||||
|
||||
console.info(`[orchestrator] polling every ${env.ORCHESTRATOR_POLL_MS}ms`);
|
||||
|
||||
const loop = async () => {
|
||||
try {
|
||||
const results = await runOrchestratorTick(db, redis, {
|
||||
...env,
|
||||
ORCHESTRATOR_DRY_RUN: env.ORCHESTRATOR_DRY_RUN || !redis,
|
||||
});
|
||||
|
||||
const stalled = await detectStalledConvoys(db, env.ORCHESTRATOR_STALL_MINUTES);
|
||||
if (stalled.length > 0) {
|
||||
console.warn(
|
||||
`[orchestrator] stall detector: ${stalled.length} open agent_runs older than ${env.ORCHESTRATOR_STALL_MINUTES}m`,
|
||||
);
|
||||
}
|
||||
|
||||
if (results.length === 0) {
|
||||
console.info("[orchestrator] no active convoys");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[orchestrator] tick failed:", error);
|
||||
}
|
||||
};
|
||||
|
||||
await loop();
|
||||
setInterval(loop, env.ORCHESTRATOR_POLL_MS);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error("[orchestrator] fatal:", error);
|
||||
process.exit(1);
|
||||
});
|
||||
31
apps/orchestrator/src/queue/redis-queue.ts
Normal file
31
apps/orchestrator/src/queue/redis-queue.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import { randomUUID } from "node:crypto";
|
||||
|
||||
import type IoRedis from "ioredis";
|
||||
|
||||
import {
|
||||
ORCHESTRATOR_QUEUE_KEY,
|
||||
orchestratorJobSchema,
|
||||
type OrchestratorJob,
|
||||
} from "@tasks/shared/orchestrator";
|
||||
|
||||
export async function enqueueJob(redis: IoRedis, job: Omit<OrchestratorJob, "id" | "enqueuedAt">) {
|
||||
const payload = orchestratorJobSchema.parse({
|
||||
...job,
|
||||
id: randomUUID(),
|
||||
enqueuedAt: new Date().toISOString(),
|
||||
});
|
||||
await redis.rpush(ORCHESTRATOR_QUEUE_KEY, JSON.stringify(payload));
|
||||
return payload;
|
||||
}
|
||||
|
||||
export async function dequeueJob(redis: IoRedis, timeoutSeconds = 5): Promise<OrchestratorJob | null> {
|
||||
const result = await redis.blpop(ORCHESTRATOR_QUEUE_KEY, timeoutSeconds);
|
||||
if (!result) return null;
|
||||
const [, raw] = result;
|
||||
try {
|
||||
return orchestratorJobSchema.parse(JSON.parse(raw));
|
||||
} catch {
|
||||
console.error("[orchestrator] Dropping invalid job payload:", raw);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
122
apps/orchestrator/src/tick.ts
Normal file
122
apps/orchestrator/src/tick.ts
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
import type IoRedis from "ioredis";
|
||||
|
||||
import type { db as dbInstance } from "@tasks/database";
|
||||
import {
|
||||
planOrchestratorAction,
|
||||
type OrchestratorAction,
|
||||
type OrchestratorJob,
|
||||
} from "@tasks/shared/orchestrator";
|
||||
|
||||
import { listActiveConvoys, transitionConvoyInDb, type LoadedConvoy } from "./db/convoy.js";
|
||||
import { enqueueJob } from "./queue/redis-queue.js";
|
||||
import type { OrchestratorEnv } from "./env.js";
|
||||
|
||||
type Db = typeof dbInstance;
|
||||
|
||||
export type TickResult = {
|
||||
convoyId: string;
|
||||
action: OrchestratorAction;
|
||||
job?: OrchestratorJob;
|
||||
};
|
||||
|
||||
async function applyAction(
|
||||
db: Db,
|
||||
redis: IoRedis | null,
|
||||
env: OrchestratorEnv,
|
||||
convoy: LoadedConvoy,
|
||||
action: OrchestratorAction,
|
||||
): Promise<TickResult> {
|
||||
const base = { convoyId: convoy.id, action };
|
||||
|
||||
if (action.type === "noop" || action.type === "pause") {
|
||||
console.info(
|
||||
`[orchestrator] convoy=${convoy.title} status=${convoy.status} → ${action.type}: ${
|
||||
action.type === "pause" ? action.reason : action.reason
|
||||
}`,
|
||||
);
|
||||
return base;
|
||||
}
|
||||
|
||||
if (action.type === "transition") {
|
||||
if (env.ORCHESTRATOR_DRY_RUN) {
|
||||
console.info(
|
||||
`[orchestrator] DRY-RUN transition convoy=${convoy.title} ${convoy.status} → ${action.toStatus} (actor=${action.actor})`,
|
||||
);
|
||||
return base;
|
||||
}
|
||||
|
||||
await transitionConvoyInDb(db, {
|
||||
workspaceId: convoy.workspaceId,
|
||||
convoyId: convoy.id,
|
||||
toStatus: action.toStatus,
|
||||
actor: action.actor,
|
||||
});
|
||||
console.info(
|
||||
`[orchestrator] transition convoy=${convoy.title} → ${action.toStatus} (actor=${action.actor})`,
|
||||
);
|
||||
return base;
|
||||
}
|
||||
|
||||
if (action.type === "dispatch") {
|
||||
if (
|
||||
action.roleId === "role-implementer" &&
|
||||
convoy.status === "ready" &&
|
||||
!env.ORCHESTRATOR_DRY_RUN
|
||||
) {
|
||||
await transitionConvoyInDb(db, {
|
||||
workspaceId: convoy.workspaceId,
|
||||
convoyId: convoy.id,
|
||||
toStatus: "in_progress",
|
||||
actor: "role-implementer",
|
||||
});
|
||||
convoy.status = "in_progress";
|
||||
}
|
||||
|
||||
if (env.ORCHESTRATOR_DRY_RUN || !redis) {
|
||||
console.info(
|
||||
`[orchestrator] DRY-RUN dispatch convoy=${convoy.title} role=${action.roleId}${
|
||||
action.briefId ? ` brief=${action.briefId}` : ""
|
||||
}`,
|
||||
);
|
||||
return base;
|
||||
}
|
||||
|
||||
const job = await enqueueJob(redis, {
|
||||
type: "role_dispatch",
|
||||
workspaceId: convoy.workspaceId,
|
||||
convoyId: convoy.id,
|
||||
roleId: action.roleId,
|
||||
briefId: action.briefId,
|
||||
});
|
||||
console.info(
|
||||
`[orchestrator] enqueued job=${job.id} convoy=${convoy.title} role=${action.roleId}`,
|
||||
);
|
||||
return { ...base, job };
|
||||
}
|
||||
|
||||
return base;
|
||||
}
|
||||
|
||||
export async function runOrchestratorTick(
|
||||
db: Db,
|
||||
redis: IoRedis | null,
|
||||
env: OrchestratorEnv,
|
||||
): Promise<TickResult[]> {
|
||||
const convoys = await listActiveConvoys(db);
|
||||
const results: TickResult[] = [];
|
||||
|
||||
for (const convoy of convoys) {
|
||||
const action = planOrchestratorAction({
|
||||
convoyId: convoy.id,
|
||||
status: convoy.status,
|
||||
skipFlags: convoy.skipFlags,
|
||||
briefs: convoy.briefs,
|
||||
completedRoles: convoy.completedRoles,
|
||||
completedBriefIds: convoy.completedBriefIds,
|
||||
});
|
||||
|
||||
results.push(await applyAction(db, redis, env, convoy, action));
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
9
apps/orchestrator/tsconfig.json
Normal file
9
apps/orchestrator/tsconfig.json
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
11
apps/orchestrator/tsup.config.ts
Normal file
11
apps/orchestrator/tsup.config.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { defineConfig } from "tsup";
|
||||
|
||||
export default defineConfig({
|
||||
entry: ["src/index.ts"],
|
||||
format: ["esm"],
|
||||
dts: true,
|
||||
noExternal: [/^@tasks\//],
|
||||
target: "node20",
|
||||
platform: "node",
|
||||
clean: true,
|
||||
});
|
||||
7
apps/orchestrator/vitest.config.ts
Normal file
7
apps/orchestrator/vitest.config.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: "node",
|
||||
},
|
||||
});
|
||||
30
docs/orchestrator.md
Normal file
30
docs/orchestrator.md
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
# Orchestrator (`apps/orchestrator`)
|
||||
|
||||
Long-running service that drives convoy lifecycle: plans next pipeline role, enqueues worker jobs on Redis, pauses at human gates, and detects stalled agent runs.
|
||||
|
||||
## Run locally
|
||||
|
||||
```bash
|
||||
# Dry-run (no Redis required)
|
||||
ORCHESTRATOR_DRY_RUN=1 pnpm --filter @tasks/orchestrator dev
|
||||
|
||||
# Live enqueue (requires REDIS_URL)
|
||||
pnpm --filter @tasks/orchestrator dev
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
- **Driver** — pure `planOrchestratorAction()` in `@tasks/shared/orchestrator`
|
||||
- **Tick loop** — loads active convoys (`draft`, `ready`, `in_progress`), applies next action
|
||||
- **Queue** — `echodo:orchestrator:jobs` Redis list (consumed by `apps/worker`, Epic 7)
|
||||
- **Gates** — `convoy_review`, `pr_review`, `merged` pause until mission control approves
|
||||
|
||||
## Env
|
||||
|
||||
| Variable | Default | Purpose |
|
||||
|----------|---------|---------|
|
||||
| `DATABASE_URL` | required | Convoy + event reads/writes |
|
||||
| `REDIS_URL` | optional | Job queue; missing ⇒ dry-run |
|
||||
| `ORCHESTRATOR_DRY_RUN` | `false` | Log actions without enqueue |
|
||||
| `ORCHESTRATOR_POLL_MS` | `5000` | Tick interval |
|
||||
| `ORCHESTRATOR_STALL_MINUTES` | `120` | Stall detector threshold |
|
||||
|
|
@ -9,6 +9,7 @@
|
|||
".": "./src/index.ts",
|
||||
"./connected-repos": "./src/connected-repos/index.ts",
|
||||
"./convoy-lifecycle": "./src/convoy-lifecycle/index.ts",
|
||||
"./orchestrator": "./src/orchestrator/index.ts",
|
||||
"./model-routing": "./src/model-routing/index.ts",
|
||||
"./types": "./src/types/index.ts",
|
||||
"./utils": "./src/utils/index.ts"
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
export * from "./connected-repos";
|
||||
export * from "./convoy-lifecycle";
|
||||
export * from "./model-routing";
|
||||
export * from "./orchestrator";
|
||||
export * from "./types";
|
||||
export * from "./utils";
|
||||
|
|
|
|||
82
packages/shared/src/orchestrator/convoy-driver.test.ts
Normal file
82
packages/shared/src/orchestrator/convoy-driver.test.ts
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { planOrchestratorAction } from "./convoy-driver";
|
||||
|
||||
describe("planOrchestratorAction", () => {
|
||||
const convoyId = "00000000-0000-4000-8000-000000000001";
|
||||
|
||||
it("pauses at convoy_review gate", () => {
|
||||
const action = planOrchestratorAction({
|
||||
convoyId,
|
||||
status: "convoy_review",
|
||||
skipFlags: [],
|
||||
briefs: [],
|
||||
completedRoles: new Set(),
|
||||
completedBriefIds: new Set(),
|
||||
});
|
||||
expect(action).toMatchObject({ type: "pause", gate: "1" });
|
||||
});
|
||||
|
||||
it("dispatches first planning role on draft", () => {
|
||||
const action = planOrchestratorAction({
|
||||
convoyId,
|
||||
status: "draft",
|
||||
skipFlags: [],
|
||||
briefs: [],
|
||||
completedRoles: new Set(),
|
||||
completedBriefIds: new Set(),
|
||||
});
|
||||
expect(action).toMatchObject({ type: "dispatch", roleId: "role-ia-architect" });
|
||||
});
|
||||
|
||||
it("skips ia when in skip flags", () => {
|
||||
const action = planOrchestratorAction({
|
||||
convoyId,
|
||||
status: "draft",
|
||||
skipFlags: ["ia"],
|
||||
briefs: [],
|
||||
completedRoles: new Set(),
|
||||
completedBriefIds: new Set(),
|
||||
});
|
||||
expect(action).toMatchObject({ type: "dispatch", roleId: "role-ux-reviewer" });
|
||||
});
|
||||
|
||||
it("transitions to convoy_review when planning roles complete", () => {
|
||||
const action = planOrchestratorAction({
|
||||
convoyId,
|
||||
status: "draft",
|
||||
skipFlags: ["ia", "ux"],
|
||||
briefs: [],
|
||||
completedRoles: new Set(["role-architect"]),
|
||||
completedBriefIds: new Set(),
|
||||
});
|
||||
expect(action).toMatchObject({
|
||||
type: "transition",
|
||||
toStatus: "convoy_review",
|
||||
actor: "role-architect",
|
||||
});
|
||||
});
|
||||
|
||||
it("dispatches implementer for ready brief", () => {
|
||||
const action = planOrchestratorAction({
|
||||
convoyId,
|
||||
status: "ready",
|
||||
skipFlags: [],
|
||||
briefs: [
|
||||
{
|
||||
id: "00000000-0000-4000-8000-000000000010",
|
||||
briefNumber: 1,
|
||||
status: "ready",
|
||||
sliceDependencies: [],
|
||||
},
|
||||
],
|
||||
completedRoles: new Set(),
|
||||
completedBriefIds: new Set(),
|
||||
});
|
||||
expect(action).toMatchObject({
|
||||
type: "dispatch",
|
||||
roleId: "role-implementer",
|
||||
briefId: "00000000-0000-4000-8000-000000000010",
|
||||
});
|
||||
});
|
||||
});
|
||||
147
packages/shared/src/orchestrator/convoy-driver.ts
Normal file
147
packages/shared/src/orchestrator/convoy-driver.ts
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
import type { ConvoyStatus } from "../convoy-lifecycle/schemas";
|
||||
|
||||
export type BriefSummary = {
|
||||
id: string;
|
||||
briefNumber: number;
|
||||
status: string | null;
|
||||
sliceDependencies: number[];
|
||||
};
|
||||
|
||||
export type OrchestratorPauseAction = {
|
||||
type: "pause";
|
||||
reason: string;
|
||||
gate: "1" | "2" | "3" | null;
|
||||
};
|
||||
|
||||
export type OrchestratorDispatchAction = {
|
||||
type: "dispatch";
|
||||
roleId: string;
|
||||
convoyId: string;
|
||||
briefId?: string;
|
||||
};
|
||||
|
||||
export type OrchestratorTransitionAction = {
|
||||
type: "transition";
|
||||
actor: string;
|
||||
toStatus: ConvoyStatus;
|
||||
};
|
||||
|
||||
export type OrchestratorNoopAction = {
|
||||
type: "noop";
|
||||
reason: string;
|
||||
};
|
||||
|
||||
export type OrchestratorAction =
|
||||
| OrchestratorPauseAction
|
||||
| OrchestratorDispatchAction
|
||||
| OrchestratorTransitionAction
|
||||
| OrchestratorNoopAction;
|
||||
|
||||
const PLANNING_PIPELINE: Array<{ skipFlag: string; roleId: string }> = [
|
||||
{ skipFlag: "ia", roleId: "role-ia-architect" },
|
||||
{ skipFlag: "ux", roleId: "role-ux-reviewer" },
|
||||
{ skipFlag: "arch", roleId: "role-architect" },
|
||||
];
|
||||
|
||||
const GATE_STATUSES: Record<ConvoyStatus, "1" | "2" | "3" | null> = {
|
||||
draft: null,
|
||||
convoy_review: "1",
|
||||
ready: null,
|
||||
in_progress: null,
|
||||
pr_review: "2",
|
||||
merged: "3",
|
||||
released: null,
|
||||
blocked: null,
|
||||
cancelled: null,
|
||||
};
|
||||
|
||||
function isBriefComplete(brief: BriefSummary): boolean {
|
||||
const status = brief.status ?? "";
|
||||
return status === "done" || status === "completed" || status === "merged";
|
||||
}
|
||||
|
||||
function briefsReadyToDispatch(briefs: BriefSummary[], completedBriefIds: Set<string>): BriefSummary[] {
|
||||
return briefs.filter((brief) => {
|
||||
if (completedBriefIds.has(brief.id)) return false;
|
||||
if (isBriefComplete(brief)) return false;
|
||||
const deps = brief.sliceDependencies;
|
||||
if (deps.length === 0) return true;
|
||||
return deps.every((depNumber) =>
|
||||
briefs.some((candidate) => candidate.briefNumber === depNumber && isBriefComplete(candidate)),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure convoy driver — given current status and completed roles/briefs, returns the next orchestrator action.
|
||||
*/
|
||||
export function planOrchestratorAction(input: {
|
||||
convoyId: string;
|
||||
status: ConvoyStatus;
|
||||
skipFlags: string[];
|
||||
briefs: BriefSummary[];
|
||||
completedRoles: Set<string>;
|
||||
completedBriefIds: Set<string>;
|
||||
}): OrchestratorAction {
|
||||
const gate = GATE_STATUSES[input.status];
|
||||
if (gate) {
|
||||
return {
|
||||
type: "pause",
|
||||
reason: `Awaiting human gate ${gate}`,
|
||||
gate,
|
||||
};
|
||||
}
|
||||
|
||||
if (input.status === "cancelled" || input.status === "released" || input.status === "blocked") {
|
||||
return { type: "noop", reason: `Terminal or blocked status: ${input.status}` };
|
||||
}
|
||||
|
||||
if (input.status === "draft") {
|
||||
for (const step of PLANNING_PIPELINE) {
|
||||
if (input.skipFlags.includes(step.skipFlag)) continue;
|
||||
if (!input.completedRoles.has(step.roleId)) {
|
||||
return {
|
||||
type: "dispatch",
|
||||
roleId: step.roleId,
|
||||
convoyId: input.convoyId,
|
||||
};
|
||||
}
|
||||
}
|
||||
return {
|
||||
type: "transition",
|
||||
actor: "role-architect",
|
||||
toStatus: "convoy_review",
|
||||
};
|
||||
}
|
||||
|
||||
if (input.status === "ready" || input.status === "in_progress") {
|
||||
const eligible = briefsReadyToDispatch(input.briefs, input.completedBriefIds);
|
||||
if (eligible.length > 0) {
|
||||
const nextBrief = eligible.sort((a, b) => a.briefNumber - b.briefNumber)[0]!;
|
||||
return {
|
||||
type: "dispatch",
|
||||
roleId: "role-implementer",
|
||||
convoyId: input.convoyId,
|
||||
briefId: nextBrief.id,
|
||||
};
|
||||
}
|
||||
|
||||
const allBriefsDone =
|
||||
input.briefs.length > 0 && input.briefs.every((brief) => isBriefComplete(brief));
|
||||
if (allBriefsDone && input.status === "in_progress") {
|
||||
return {
|
||||
type: "transition",
|
||||
actor: "role-implementer",
|
||||
toStatus: "pr_review",
|
||||
};
|
||||
}
|
||||
|
||||
if (input.briefs.length === 0) {
|
||||
return { type: "noop", reason: "No implementer briefs defined yet" };
|
||||
}
|
||||
|
||||
return { type: "noop", reason: "Waiting for implementer briefs to complete" };
|
||||
}
|
||||
|
||||
return { type: "noop", reason: `No driver rule for status ${input.status}` };
|
||||
}
|
||||
13
packages/shared/src/orchestrator/index.ts
Normal file
13
packages/shared/src/orchestrator/index.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
export {
|
||||
ORCHESTRATOR_QUEUE_KEY,
|
||||
orchestratorJobSchema,
|
||||
orchestratorJobTypeSchema,
|
||||
type OrchestratorJob,
|
||||
type OrchestratorJobType,
|
||||
} from "./job";
|
||||
|
||||
export {
|
||||
planOrchestratorAction,
|
||||
type BriefSummary,
|
||||
type OrchestratorAction,
|
||||
} from "./convoy-driver";
|
||||
19
packages/shared/src/orchestrator/job.ts
Normal file
19
packages/shared/src/orchestrator/job.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { z } from "zod";
|
||||
|
||||
export const orchestratorJobTypeSchema = z.enum(["role_dispatch", "stall_check"]);
|
||||
|
||||
export type OrchestratorJobType = z.infer<typeof orchestratorJobTypeSchema>;
|
||||
|
||||
export const orchestratorJobSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
type: orchestratorJobTypeSchema,
|
||||
workspaceId: z.string().uuid(),
|
||||
convoyId: z.string().uuid(),
|
||||
roleId: z.string().min(1).max(64),
|
||||
briefId: z.string().uuid().optional(),
|
||||
enqueuedAt: z.string().datetime(),
|
||||
});
|
||||
|
||||
export type OrchestratorJob = z.infer<typeof orchestratorJobSchema>;
|
||||
|
||||
export const ORCHESTRATOR_QUEUE_KEY = "echodo:orchestrator:jobs";
|
||||
|
|
@ -3,7 +3,7 @@ kind: epic
|
|||
slug: apps-orchestrator
|
||||
title: apps/orchestrator — role dispatch and gate pauses
|
||||
plan_slug: automation-foundation
|
||||
status: draft
|
||||
status: done
|
||||
priority: P1
|
||||
tenant_id: global
|
||||
cursor_epic_id: null
|
||||
|
|
@ -47,12 +47,13 @@ Each job calls `resolveModel(workspace, roleId, convoy, brief)` and passes `Reso
|
|||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] Dry-run: orchestrator logs next role for a convoy without worker.
|
||||
- [x] Dry-run: orchestrator logs next role for a convoy without worker.
|
||||
- [ ] Live: one implementer brief dispatched and paused correctly at gate.
|
||||
|
||||
## Tasks
|
||||
|
||||
_To be decomposed when epic graduates to `ready`._
|
||||
- [x] [Task-shared-orchestrator-driver](./Task-shared-orchestrator-driver.md)
|
||||
- [x] [Task-apps-orchestrator-service](./Task-apps-orchestrator-service.md)
|
||||
|
||||
## Note
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,23 @@
|
|||
---
|
||||
kind: task
|
||||
slug: apps-orchestrator-service
|
||||
title: apps/orchestrator Redis tick loop and dry-run mode
|
||||
plan_slug: automation-foundation
|
||||
epic_slug: apps-orchestrator
|
||||
status: done
|
||||
priority: P1
|
||||
tenant_id: global
|
||||
owner: unassigned
|
||||
cursor_todo_id: null
|
||||
updated_at: "2026-07-07"
|
||||
---
|
||||
|
||||
# Task summary
|
||||
|
||||
`@tasks/orchestrator` package: env validation, DB convoy loader, Redis enqueue, poll loop, stall detector stub.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] `ORCHESTRATOR_DRY_RUN=1` logs next role without worker
|
||||
- [x] Live mode enqueues `role_dispatch` jobs when `REDIS_URL` is set
|
||||
- [x] `docs/orchestrator.md` documents run commands
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
---
|
||||
kind: task
|
||||
slug: shared-orchestrator-driver
|
||||
title: Shared planOrchestratorAction driver and job schemas
|
||||
plan_slug: automation-foundation
|
||||
epic_slug: apps-orchestrator
|
||||
status: done
|
||||
priority: P1
|
||||
tenant_id: global
|
||||
owner: unassigned
|
||||
cursor_todo_id: null
|
||||
updated_at: "2026-07-07"
|
||||
---
|
||||
|
||||
# Task summary
|
||||
|
||||
Pure convoy driver in `@tasks/shared/orchestrator` with vitest coverage for planning, gates, and implementer dispatch.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] Pauses at gate statuses
|
||||
- [x] Serial planning roles respect `skip` flags
|
||||
- [x] `orchestratorJobSchema` + `ORCHESTRATOR_QUEUE_KEY`
|
||||
|
|
@ -167,7 +167,7 @@ flowchart LR
|
|||
| 3 | [Connected repos](./Epic-connected-repos/Epic-connected-repos.md) | done | `repo_links` table, “paste remote URL”, forge adapter interface | — |
|
||||
| 4 | [Convoy lifecycle MCP](./Epic-convoy-lifecycle-mcp/Epic-convoy-lifecycle-mcp.md) | done | `create_convoy`, `transition_convoy_status`, state machine §7, `convoy_events` | 2, 3 |
|
||||
| 5 | [Mission control gates UI](./Epic-mission-control-gates-ui/Epic-mission-control-gates-ui.md) | done | Convoy kanban, gate inbox, approve/deny/revise, gate co-pilot chat | 4 |
|
||||
| 6 | [Orchestrator](./Epic-apps-orchestrator/Epic-apps-orchestrator.md) | draft | `apps/orchestrator`, Redis queue, role dispatch, pause at gates | 1, 3, 4 |
|
||||
| 6 | [Orchestrator](./Epic-apps-orchestrator/Epic-apps-orchestrator.md) | done | `apps/orchestrator`, Redis queue, role dispatch, pause at gates | 1, 3, 4 |
|
||||
| 7 | [Worker v1](./Epic-apps-worker-v1/Epic-apps-worker-v1.md) | draft | `apps/worker` minimal tool loop, worktree-per-brief, MCP claim/complete | 1, 3, 6 |
|
||||
| 8 | [Forge webhooks](./Epic-forge-webhooks/Epic-forge-webhooks.md) | draft | PR opened/merged/CI webhooks → convoy transitions | 3, 4, 7 |
|
||||
| 9 | [Fleet dashboard](./Epic-fleet-dashboard/Epic-fleet-dashboard.md) | draft | Cross-repo home, `convoys-*` workspaces, “needs you” rollup | 4, 5 |
|
||||
|
|
|
|||
|
|
@ -95,6 +95,40 @@ importers:
|
|||
specifier: ^5.7.0
|
||||
version: 5.9.3
|
||||
|
||||
apps/orchestrator:
|
||||
dependencies:
|
||||
'@tasks/database':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/database
|
||||
'@tasks/shared':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/shared
|
||||
drizzle-orm:
|
||||
specifier: ^0.38.0
|
||||
version: 0.38.4(@opentelemetry/api@1.9.0)(@types/react@19.2.14)(postgres@3.4.8)(react@19.2.4)
|
||||
ioredis:
|
||||
specifier: ^5.4.2
|
||||
version: 5.10.1
|
||||
zod:
|
||||
specifier: ^3.24.0
|
||||
version: 3.25.76
|
||||
devDependencies:
|
||||
'@types/node':
|
||||
specifier: ^22.10.0
|
||||
version: 22.19.15
|
||||
tsup:
|
||||
specifier: ^8.3.5
|
||||
version: 8.5.1(jiti@1.21.7)(postcss@8.5.8)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3)
|
||||
tsx:
|
||||
specifier: ^4.19.2
|
||||
version: 4.21.0
|
||||
typescript:
|
||||
specifier: ^5.7.0
|
||||
version: 5.9.3
|
||||
vitest:
|
||||
specifier: ^2.1.9
|
||||
version: 2.1.9(@types/node@22.19.15)
|
||||
|
||||
apps/web:
|
||||
dependencies:
|
||||
'@ai-sdk/openai':
|
||||
|
|
@ -8315,8 +8349,8 @@ snapshots:
|
|||
'@typescript-eslint/parser': 8.57.2(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3)
|
||||
eslint: 9.39.4(jiti@1.21.7)
|
||||
eslint-import-resolver-node: 0.3.9
|
||||
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@1.21.7))
|
||||
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.57.2(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@1.21.7))
|
||||
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.57.2(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.4(jiti@1.21.7)))(eslint@9.39.4(jiti@1.21.7))
|
||||
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.57.2(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.57.2(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.4(jiti@1.21.7)))(eslint@9.39.4(jiti@1.21.7)))(eslint@9.39.4(jiti@1.21.7))
|
||||
eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.4(jiti@1.21.7))
|
||||
eslint-plugin-react: 7.37.5(eslint@9.39.4(jiti@1.21.7))
|
||||
eslint-plugin-react-hooks: 5.2.0(eslint@9.39.4(jiti@1.21.7))
|
||||
|
|
@ -8335,7 +8369,7 @@ snapshots:
|
|||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@1.21.7)):
|
||||
eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.57.2(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.4(jiti@1.21.7)))(eslint@9.39.4(jiti@1.21.7)):
|
||||
dependencies:
|
||||
'@nolyfill/is-core-module': 1.0.39
|
||||
debug: 4.4.3
|
||||
|
|
@ -8346,22 +8380,22 @@ snapshots:
|
|||
tinyglobby: 0.2.15
|
||||
unrs-resolver: 1.11.1
|
||||
optionalDependencies:
|
||||
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.57.2(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@1.21.7))
|
||||
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.57.2(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.57.2(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.4(jiti@1.21.7)))(eslint@9.39.4(jiti@1.21.7)))(eslint@9.39.4(jiti@1.21.7))
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
eslint-module-utils@2.12.1(@typescript-eslint/parser@8.57.2(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@1.21.7)):
|
||||
eslint-module-utils@2.12.1(@typescript-eslint/parser@8.57.2(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.57.2(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.4(jiti@1.21.7)))(eslint@9.39.4(jiti@1.21.7)))(eslint@9.39.4(jiti@1.21.7)):
|
||||
dependencies:
|
||||
debug: 3.2.7
|
||||
optionalDependencies:
|
||||
'@typescript-eslint/parser': 8.57.2(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3)
|
||||
eslint: 9.39.4(jiti@1.21.7)
|
||||
eslint-import-resolver-node: 0.3.9
|
||||
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@1.21.7))
|
||||
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.57.2(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.4(jiti@1.21.7)))(eslint@9.39.4(jiti@1.21.7))
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.57.2(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@1.21.7)):
|
||||
eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.57.2(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.57.2(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.4(jiti@1.21.7)))(eslint@9.39.4(jiti@1.21.7)))(eslint@9.39.4(jiti@1.21.7)):
|
||||
dependencies:
|
||||
'@rtsao/scc': 1.1.0
|
||||
array-includes: 3.1.9
|
||||
|
|
@ -8372,7 +8406,7 @@ snapshots:
|
|||
doctrine: 2.1.0
|
||||
eslint: 9.39.4(jiti@1.21.7)
|
||||
eslint-import-resolver-node: 0.3.9
|
||||
eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.57.2(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@1.21.7))
|
||||
eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.57.2(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.57.2(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.4(jiti@1.21.7)))(eslint@9.39.4(jiti@1.21.7)))(eslint@9.39.4(jiti@1.21.7))
|
||||
hasown: 2.0.2
|
||||
is-core-module: 2.16.1
|
||||
is-glob: 4.0.3
|
||||
|
|
|
|||
Loading…
Reference in a new issue