From 00c2c144fc83928288749548c6bacfa52111968a Mon Sep 17 00:00:00 2001 From: Cedric Vidal Date: Tue, 9 Jun 2026 02:05:40 -0700 Subject: [PATCH 01/19] feat: DAG-based post-processing + taxonomy handler MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refactor post-processing from single-handler to DAG orchestration: Phase 1 - Handler Dispatcher: - Add HandlerDispatcher with DAG topology from services collection - Scheduler REST endpoints: POST /notify/run-terminal, /handler-complete - Workers notify scheduler instead of direct queue enqueue (fallback kept) - Remove triggerReportGeneration (reports now DAG node via pp-report) - DB migrations: services → handler entries, run.handlerStatus field Phase 2 - Taxonomy Handler: - Zod schema for 3-layer taxonomy (scorecard, behavior, actions) - taxonomy-handler worker with Copilot SDK + multi-turn validation loop - Docker-compose service with SCHEDULER_URL wiring DAG structure: pp-atif → pp-taxonomy → pp-report Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- AGENTS.md | 2 + ENV_VARIABLES.md | 36 ++ apps/scheduler/src/handler-dispatcher.ts | 295 +++++++++++++++ apps/scheduler/src/index.ts | 34 +- apps/scheduler/src/notify-routes.test.ts | 149 ++++++++ apps/scheduler/src/notify-routes.ts | 150 ++++++++ .../post-processor/src/post-processor.ts | 72 ++-- apps/workers/taxonomy/Dockerfile | 19 + apps/workers/taxonomy/package.json | 23 ++ apps/workers/taxonomy/src/index.ts | 30 ++ apps/workers/taxonomy/src/prompt.ts | 88 +++++ .../taxonomy/src/taxonomy-queue-processor.ts | 336 ++++++++++++++++++ apps/workers/taxonomy/src/tools.ts | 156 ++++++++ apps/workers/taxonomy/tsconfig.json | 8 + docker-compose.yml | 40 ++- docs/architecture/db-migrations.md | 3 + docs/architecture/overview.md | 2 + docs/architecture/post-processing.md | 45 ++- .../src/check-migrations.test.ts | 85 +---- .../018-migrate-services-to-handler-dag.ts | 96 +++++ .../019-add-handler-status-index.ts | 72 ++++ .../migrations/020-backfill-handler-status.ts | 76 ++++ .../db-migrations/src/required-migrations.ts | 3 + packages/shared/src/index.ts | 1 + .../shared/src/queue/queue-processor.test.ts | 15 +- packages/shared/src/queue/queue-processor.ts | 76 ++-- packages/shared/src/schemas/index.ts | 1 + packages/shared/src/schemas/taxonomy.test.ts | 180 ++++++++++ packages/shared/src/schemas/taxonomy.ts | 104 ++++++ packages/shared/src/types/types.ts | 28 +- pnpm-lock.yaml | 25 ++ 31 files changed, 2066 insertions(+), 184 deletions(-) create mode 100644 apps/scheduler/src/handler-dispatcher.ts create mode 100644 apps/scheduler/src/notify-routes.test.ts create mode 100644 apps/scheduler/src/notify-routes.ts create mode 100644 apps/workers/taxonomy/Dockerfile create mode 100644 apps/workers/taxonomy/package.json create mode 100644 apps/workers/taxonomy/src/index.ts create mode 100644 apps/workers/taxonomy/src/prompt.ts create mode 100644 apps/workers/taxonomy/src/taxonomy-queue-processor.ts create mode 100644 apps/workers/taxonomy/src/tools.ts create mode 100644 apps/workers/taxonomy/tsconfig.json create mode 100644 packages/db-migrations/src/migrations/018-migrate-services-to-handler-dag.ts create mode 100644 packages/db-migrations/src/migrations/019-add-handler-status-index.ts create mode 100644 packages/db-migrations/src/migrations/020-backfill-handler-status.ts create mode 100644 packages/shared/src/schemas/taxonomy.test.ts create mode 100644 packages/shared/src/schemas/taxonomy.ts diff --git a/AGENTS.md b/AGENTS.md index de77cbeba..419fb8e6a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,6 +19,7 @@ apps/ coder-acp-copilot/ # GitHub Copilot worker (ACP SDK) coder-acp-claude-code/ # Claude Code worker (ACP SDK) report-generator/ # Post-run report generation + taxonomy/ # Post-run taxonomy generation model-scanners/ # Feature detection for Copilot and Anthropic models version-checkers/ # Poll for new agent/tool releases key-updaters/ # GitHub auth cookie management for VS Code Web @@ -53,6 +54,7 @@ Each worker implements the same queue-processor interface but adapts a different | `coder-acp-copilot` | GitHub Copilot | ACP SDK v0.14.1 | | `coder-acp-claude-code` | Claude Code | ACP SDK v0.13.1 | | `report-generator` | — | Copilot SDK | +| `taxonomy` | — | Copilot SDK | Workers consume tasks from Azure Storage Queues (named `queue-`) and write results to MongoDB and Blob Storage. Each has its own Dockerfile and docker-compose profile. diff --git a/ENV_VARIABLES.md b/ENV_VARIABLES.md index 9259bf048..5044d6c53 100644 --- a/ENV_VARIABLES.md +++ b/ENV_VARIABLES.md @@ -277,6 +277,32 @@ Timeout for the Copilot SDK session used by the report-generator worker. If the Git commit hash embedded in reporter metadata. Automatically set during CI/CD builds. Used to track which version of the report-generator produced a given report. +## Taxonomy Handler Configuration + +### AZURE_STORAGE_QUEUE_TAXONOMY +**Default:** `pp-taxonomy-queue` +**Type:** string + +Azure Storage Queue name for taxonomy-generation jobs. The scheduler enqueues `{ type: "taxonomy", requestId, runId }` messages here after the `pp-atif` dependency completes, and the taxonomy worker polls this queue. + +### TAXONOMY_MODEL +**Default:** `gpt-4.1` +**Type:** string + +The LLM model used by the taxonomy worker (via the Copilot SDK) to generate structured taxonomy JSON from run metadata and ATIF trajectories. + +### SCOPE_MT_API_URL +**Default:** `http://localhost:3001` (local), `http://api:80` (Docker) +**Type:** URL string + +Base URL of the Scope API. The taxonomy worker calls this to fetch the request document and active run state through its Copilot tools. + +### SESSION_TIMEOUT_MS +**Default:** `300000` (5 minutes) +**Type:** integer (milliseconds) + +Timeout for the Copilot SDK session used by the taxonomy worker. If the model does not finish generating a valid taxonomy JSON document within this window, the worker marks `run.handlerStatus.pp-taxonomy` as failed. + ## Scheduler Configuration ### SCHEDULER_POLL_INTERVAL_MS @@ -297,6 +323,16 @@ How often the post-processor dispatcher polls for completed runs needing post-pr Azure Storage Queue name used by both the scheduler (to enqueue post-processing work) and the post-processor worker (to dequeue). Must match between the two services. +### SCHEDULER_URL +**Default:** (not set) +**Type:** URL string + +Optional base URL for the scheduler's HTTP notification endpoints. When set, coder workers call `POST /notify/run-terminal` after a run reaches a terminal state, and post-processing handlers call `POST /notify/handler-complete` after a handler finishes. If unset, workers fall back to the legacy direct post-processor queue enqueue path and the scheduler's polling loop catches up. + +- **Docker Compose:** `http://scheduler:8080` +- **Kubernetes:** `http://scheduler-service.scoped.svc.cluster.local:8080` +- **Local dev:** Leave unset to rely on legacy queue fallback unless you are also running the scheduler locally + ## Worker Configuration ### SCOPE_RUN_HEARTBEAT_STALE_MS diff --git a/apps/scheduler/src/handler-dispatcher.ts b/apps/scheduler/src/handler-dispatcher.ts new file mode 100644 index 000000000..2c4f1b99f --- /dev/null +++ b/apps/scheduler/src/handler-dispatcher.ts @@ -0,0 +1,295 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { Collection, Db } from "mongodb"; +import { QueueClient } from "@azure/storage-queue"; +import type { RequestDocument, HandlerServiceDocument, HandlerRunStatus } from "shared"; +import { DependencyGraph } from "shared"; +import type { NotifyHandler } from "./notify-routes.js"; + +/** + * HandlerDispatcher — DAG-aware post-processing orchestrator. + * + * Reads handler topology from the `services` collection (type: "post-process-handler"), + * builds a DependencyGraph, and dispatches handlers whose dependencies are satisfied. + * + * Two trigger paths: + * 1. `onRunTerminal` — a coder worker signals run completion. Dispatches root handlers. + * 2. `onHandlerComplete` — a handler signals completion. Dispatches downstream handlers. + * + * Also runs a poll safety net to catch missed notifications. + */ +export class HandlerDispatcher implements NotifyHandler { + private interval: ReturnType | null = null; + private dispatching = false; + private queueClients = new Map(); + + constructor( + private readonly collection: Collection, + private readonly db: Db, + private readonly createQueueClient: (queueName: string) => QueueClient, + private readonly pollIntervalMs: number = 30_000, + private readonly batchSize: number = 30, + ) {} + + start(): void { + if (this.interval) return; + this.interval = setInterval(() => this.pollDispatch(), this.pollIntervalMs); + void this.pollDispatch(); + } + + async stop(): Promise { + if (this.interval) { + clearInterval(this.interval); + this.interval = null; + } + const deadline = Date.now() + 5_000; + while (this.dispatching && Date.now() < deadline) { + await new Promise((r) => setTimeout(r, 100)); + } + } + + // ── NotifyHandler implementation ──────────────────────────────────── + + async onRunTerminal(requestId: string, runId: string): Promise { + console.log(`[HandlerDispatcher] Notify: run-terminal ${requestId} (run=${runId})`); + const handlers = await this.loadHandlers(); + if (handlers.length === 0) return; + + const graph = this.buildGraph(handlers); + const roots = handlers.filter((h) => !h.dependsOn || h.dependsOn.length === 0); + + for (const handler of roots) { + await this.dispatchIfEligible(requestId, runId, handler, graph, handlers); + } + } + + async onHandlerComplete( + requestId: string, + runId: string, + handlerId: string, + status: "done" | "failed", + ): Promise { + console.log( + `[HandlerDispatcher] Notify: handler-complete ${handlerId} for ${requestId} (status=${status})`, + ); + + // Update the handler status on the run document + await this.collection.updateOne( + { _id: requestId } as any, + { + $set: { + [`run.handlerStatus.${handlerId}.status`]: status, + [`run.handlerStatus.${handlerId}.updatedAt`]: new Date(), + }, + } as any, + ); + + if (status === "failed") { + // Don't dispatch downstream on failure + return; + } + + // Find and dispatch eligible downstream handlers + const handlers = await this.loadHandlers(); + if (handlers.length === 0) return; + + const graph = this.buildGraph(handlers); + const descendants = graph.getDescendants(handlerId); + + // Only check immediate children (not all descendants) + const children = handlers.filter( + (h) => h.dependsOn?.includes(handlerId) && descendants.has(h._id), + ); + + for (const child of children) { + await this.dispatchIfEligible(requestId, runId, child, graph, handlers); + } + } + + // ── Poll safety net ───────────────────────────────────────────────── + + private async pollDispatch(): Promise { + if (this.dispatching) return; + this.dispatching = true; + try { + const handlers = await this.loadHandlers(); + if (handlers.length === 0) return; + + const graph = this.buildGraph(handlers); + + for (const handler of handlers) { + // Find runs that need this handler dispatched + for (let i = 0; i < this.batchSize; i++) { + const filter = this.buildDispatchFilter(handler, handlers, graph); + if (!filter) break; + + const claimed = await this.collection.findOneAndUpdate( + filter as any, + { + $set: { + [`run.handlerStatus.${handler._id}.status`]: "queued", + [`run.handlerStatus.${handler._id}.updatedAt`]: new Date(), + }, + } as any, + { sort: { updatedAt: -1 }, returnDocument: "after" }, + ); + + if (!claimed) break; + + await this.enqueueToHandler(handler, claimed._id, (claimed as any).run?._id); + console.log(`[HandlerDispatcher] Poll-dispatched ${handler._id} for ${claimed._id}`); + } + } + } catch (err) { + console.error("[HandlerDispatcher] Error during poll dispatch:", err); + } finally { + this.dispatching = false; + } + } + + // ── Dispatch logic ────────────────────────────────────────────────── + + private async dispatchIfEligible( + requestId: string, + _runId: string, + handler: HandlerServiceDocument, + graph: DependencyGraph, + allHandlers: HandlerServiceDocument[], + ): Promise { + // Check if all dependencies are satisfied for this request + const doc = await this.collection.findOne({ _id: requestId } as any); + if (!doc?.run || doc.run.status !== "done") return; + + const handlerStatus = (doc.run as any).handlerStatus as + | Record + | undefined; + + // Check dependencies are all "done" + if (handler.dependsOn && handler.dependsOn.length > 0) { + for (const depId of handler.dependsOn) { + const depStatus = handlerStatus?.[depId]?.status; + if (depStatus !== "done") return; // Dependency not satisfied + } + } + + // Check this handler hasn't already been dispatched + const currentStatus = handlerStatus?.[handler._id]?.status; + if (currentStatus === "queued" || currentStatus === "processing" || currentStatus === "done") { + return; + } + + // Check version — skip if already at current version + const currentVersion = handlerStatus?.[handler._id]?.version; + if (currentVersion !== undefined && currentVersion >= handler.version && !handler.autoBackfill) { + return; + } + + // Atomic claim + const claimed = await this.collection.findOneAndUpdate( + { + _id: requestId, + "run.status": "done", + [`run.handlerStatus.${handler._id}.status`]: { $nin: ["queued", "processing"] }, + } as any, + { + $set: { + [`run.handlerStatus.${handler._id}.status`]: "queued", + [`run.handlerStatus.${handler._id}.updatedAt`]: new Date(), + }, + } as any, + { returnDocument: "after" }, + ); + + if (!claimed) return; + + await this.enqueueToHandler(handler, requestId, (claimed as any).run?._id); + console.log(`[HandlerDispatcher] Dispatched ${handler._id} for ${requestId}`); + } + + // ── Helpers ───────────────────────────────────────────────────────── + + private buildDispatchFilter( + handler: HandlerServiceDocument, + allHandlers: HandlerServiceDocument[], + _graph: DependencyGraph, + ): Record | null { + const filter: Record = { + "run.status": "done", + deletedAt: { $exists: false }, + [`run.handlerStatus.${handler._id}.status`]: { $nin: ["queued", "processing", "done"] }, + }; + + // All dependencies must be "done" + if (handler.dependsOn && handler.dependsOn.length > 0) { + for (const depId of handler.dependsOn) { + filter[`run.handlerStatus.${depId}.status`] = "done"; + } + } + + // Backfill: version check + if (handler.autoBackfill) { + filter.$or = [ + { [`run.handlerStatus.${handler._id}.version`]: { $exists: false } }, + { [`run.handlerStatus.${handler._id}.version`]: { $lt: handler.version } }, + ]; + } else { + // Only dispatch if never processed + filter[`run.handlerStatus.${handler._id}.version`] = { $exists: false }; + } + + return filter; + } + + private async enqueueToHandler( + handler: HandlerServiceDocument, + requestId: string, + runId: string, + ): Promise { + const queueClient = await this.getOrCreateQueueClient(handler.queue); + const message = Buffer.from( + JSON.stringify({ + type: handler.selector, + requestId, + runId, + }), + ).toString("base64"); + + try { + await queueClient.sendMessage(message); + } catch (err) { + // Roll back status on queue failure + await this.collection.updateOne( + { _id: requestId } as any, + { $unset: { [`run.handlerStatus.${handler._id}.status`]: "" } } as any, + ); + console.error( + `[HandlerDispatcher] Queue send failed for ${handler._id}/${requestId}, rolled back:`, + err, + ); + } + } + + private async getOrCreateQueueClient(queueName: string): Promise { + let client = this.queueClients.get(queueName); + if (!client) { + client = this.createQueueClient(queueName); + await client.createIfNotExists(); + this.queueClients.set(queueName, client); + } + return client; + } + + private async loadHandlers(): Promise { + const docs = await this.db + .collection("services") + .find({ type: "post-process-handler" }) + .toArray(); + return docs as unknown as HandlerServiceDocument[]; + } + + private buildGraph(handlers: HandlerServiceDocument[]): DependencyGraph { + const nodes = handlers.map((h) => ({ id: h._id, dependsOn: h.dependsOn })); + return new DependencyGraph(nodes); + } +} diff --git a/apps/scheduler/src/index.ts b/apps/scheduler/src/index.ts index c3ed6f991..dce1509fe 100644 --- a/apps/scheduler/src/index.ts +++ b/apps/scheduler/src/index.ts @@ -2,12 +2,13 @@ // Licensed under the MIT License. import "dotenv/config"; -import http from "node:http"; import { MongoClient } from "mongodb"; import { QueueClient } from "@azure/storage-queue"; import { DefaultAzureCredential } from "@azure/identity"; import { RequestScheduler, WorkerTypeConfig } from "./request-scheduler.js"; import { PostProcessorDispatcher } from "./post-processor-dispatcher.js"; +import { HandlerDispatcher } from "./handler-dispatcher.js"; +import { createHttpServer, type NotifyHandler } from "./notify-routes.js"; import type { RequestDocument } from "shared"; // ── Configuration ──────────────────────────────────────────────────── @@ -77,16 +78,6 @@ function createQueueClient(queueName: string): QueueClient { return new QueueClient(queueUrl, credential); } -// ── Health Check Server ────────────────────────────────────────────── - -function createHealthServer(): http.Server { - const server = http.createServer((_req, res) => { - res.writeHead(200, { "Content-Type": "application/json" }); - res.end(JSON.stringify({ status: "ok", service: "scheduler" })); - }); - return server; -} - // ── Main ───────────────────────────────────────────────────────────── async function main(): Promise { @@ -145,10 +136,20 @@ async function main(): Promise { postProcessorDispatcher.start(); console.log("[Scheduler] Post-processor dispatch loop started"); - // Start health check server - const healthServer = createHealthServer(); - healthServer.listen(HEALTH_PORT, () => { - console.log(`[Scheduler] Health check listening on :${HEALTH_PORT}`); + // Start the DAG-aware handler dispatcher (replaces legacy PostProcessorDispatcher) + const handlerDispatcher = new HandlerDispatcher( + collection, + db, + createQueueClient, + ppPollIntervalMs, + ); + handlerDispatcher.start(); + console.log("[Scheduler] Handler dispatcher (DAG) started"); + + // Wire notify handler to the HandlerDispatcher + const httpServer = createHttpServer(handlerDispatcher); + httpServer.listen(HEALTH_PORT, () => { + console.log(`[Scheduler] HTTP server listening on :${HEALTH_PORT}`); }); // Graceful shutdown @@ -156,7 +157,8 @@ async function main(): Promise { console.log("[Scheduler] Shutting down..."); await scheduler.stop(); await postProcessorDispatcher.stop(); - healthServer.close(); + await handlerDispatcher.stop(); + httpServer.close(); await mongoClient.close(); console.log("[Scheduler] Shutdown complete"); process.exit(0); diff --git a/apps/scheduler/src/notify-routes.test.ts b/apps/scheduler/src/notify-routes.test.ts new file mode 100644 index 000000000..552f03243 --- /dev/null +++ b/apps/scheduler/src/notify-routes.test.ts @@ -0,0 +1,149 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import type { AddressInfo } from "node:net"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createHttpServer, type NotifyHandler } from "./notify-routes.js"; + +async function startServer(handler: NotifyHandler): Promise<{ server: ReturnType; baseUrl: string }> { + const server = createHttpServer(handler); + + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", () => resolve()); + }); + + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("Server did not bind to an address"); + } + + return { + server, + baseUrl: `http://127.0.0.1:${(address as AddressInfo).port}`, + }; +} + +async function stopServer(server: ReturnType): Promise { + await new Promise((resolve, reject) => { + server.close((error) => { + if (error) { + reject(error); + return; + } + resolve(); + }); + }); +} + +describe("createHttpServer", () => { + const servers: Array> = []; + + afterEach(async () => { + while (servers.length > 0) { + const server = servers.pop(); + if (server) { + await stopServer(server); + } + } + }); + + it("returns 200 for the health endpoint", async () => { + const handler: NotifyHandler = { + onRunTerminal: vi.fn().mockResolvedValue(undefined), + onHandlerComplete: vi.fn().mockResolvedValue(undefined), + }; + const { server, baseUrl } = await startServer(handler); + servers.push(server); + + const response = await fetch(`${baseUrl}/health`); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ status: "ok", service: "scheduler" }); + }); + + it("calls onRunTerminal for valid run-terminal notifications", async () => { + const handler: NotifyHandler = { + onRunTerminal: vi.fn().mockResolvedValue(undefined), + onHandlerComplete: vi.fn().mockResolvedValue(undefined), + }; + const { server, baseUrl } = await startServer(handler); + servers.push(server); + + const response = await fetch(`${baseUrl}/notify/run-terminal`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ requestId: "req-1", runId: "run-1" }), + }); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ ok: true }); + expect(handler.onRunTerminal).toHaveBeenCalledWith("req-1", "run-1"); + expect(handler.onHandlerComplete).not.toHaveBeenCalled(); + }); + + it("calls onHandlerComplete for valid handler-complete notifications", async () => { + const handler: NotifyHandler = { + onRunTerminal: vi.fn().mockResolvedValue(undefined), + onHandlerComplete: vi.fn().mockResolvedValue(undefined), + }; + const { server, baseUrl } = await startServer(handler); + servers.push(server); + + const response = await fetch(`${baseUrl}/notify/handler-complete`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + requestId: "req-1", + runId: "run-1", + handlerId: "atif", + status: "done", + }), + }); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ ok: true }); + expect(handler.onHandlerComplete).toHaveBeenCalledWith("req-1", "run-1", "atif", "done"); + expect(handler.onRunTerminal).not.toHaveBeenCalled(); + }); + + it("returns 400 when required fields are missing", async () => { + const handler: NotifyHandler = { + onRunTerminal: vi.fn().mockResolvedValue(undefined), + onHandlerComplete: vi.fn().mockResolvedValue(undefined), + }; + const { server, baseUrl } = await startServer(handler); + servers.push(server); + + const response = await fetch(`${baseUrl}/notify/handler-complete`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ requestId: "req-1", runId: "run-1", status: "done" }), + }); + + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ + error: "requestId, runId, handlerId, and status (done|failed) are required", + }); + expect(handler.onRunTerminal).not.toHaveBeenCalled(); + expect(handler.onHandlerComplete).not.toHaveBeenCalled(); + }); + + it("returns 404 for unknown routes", async () => { + const handler: NotifyHandler = { + onRunTerminal: vi.fn().mockResolvedValue(undefined), + onHandlerComplete: vi.fn().mockResolvedValue(undefined), + }; + const { server, baseUrl } = await startServer(handler); + servers.push(server); + + const response = await fetch(`${baseUrl}/notify/unknown`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({}), + }); + + expect(response.status).toBe(404); + expect(await response.json()).toEqual({ error: "Not found" }); + }); +}); diff --git a/apps/scheduler/src/notify-routes.ts b/apps/scheduler/src/notify-routes.ts new file mode 100644 index 000000000..445945d2d --- /dev/null +++ b/apps/scheduler/src/notify-routes.ts @@ -0,0 +1,150 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import http from "node:http"; + +export interface NotifyHandler { + onRunTerminal(requestId: string, runId: string): Promise; + onHandlerComplete( + requestId: string, + runId: string, + handlerId: string, + status: "done" | "failed", + ): Promise; +} + +interface RunTerminalRequest { + requestId: string; + runId: string; +} + +interface HandlerCompleteRequest { + requestId: string; + runId: string; + handlerId: string; + status: "done" | "failed"; +} + +function sendJson(res: http.ServerResponse, statusCode: number, body: object): void { + res.writeHead(statusCode, { "Content-Type": "application/json" }); + res.end(JSON.stringify(body)); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function isNonEmptyString(value: unknown): value is string { + return typeof value === "string" && value.trim().length > 0; +} + +function parseRunTerminalRequest(body: unknown): RunTerminalRequest | null { + if (!isRecord(body)) { + return null; + } + + if (!isNonEmptyString(body.requestId) || !isNonEmptyString(body.runId)) { + return null; + } + + return { + requestId: body.requestId, + runId: body.runId, + }; +} + +function parseHandlerCompleteRequest(body: unknown): HandlerCompleteRequest | null { + if (!isRecord(body)) { + return null; + } + + if ( + !isNonEmptyString(body.requestId) || + !isNonEmptyString(body.runId) || + !isNonEmptyString(body.handlerId) || + (body.status !== "done" && body.status !== "failed") + ) { + return null; + } + + return { + requestId: body.requestId, + runId: body.runId, + handlerId: body.handlerId, + status: body.status, + }; +} + +async function readJsonBody(req: http.IncomingMessage): Promise { + const chunks: Buffer[] = []; + + for await (const chunk of req) { + chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk); + } + + const rawBody = Buffer.concat(chunks).toString("utf-8").trim(); + if (rawBody.length === 0) { + throw new Error("Request body is required"); + } + + return JSON.parse(rawBody) as unknown; +} + +export function createHttpServer(handler: NotifyHandler): http.Server { + return http.createServer(async (req, res) => { + const method = req.method ?? "GET"; + const url = new URL(req.url ?? "/", "http://localhost"); + + try { + if (method === "GET" && url.pathname === "/health") { + sendJson(res, 200, { status: "ok", service: "scheduler" }); + return; + } + + if (method === "POST" && url.pathname === "/notify/run-terminal") { + const parsedBody = parseRunTerminalRequest(await readJsonBody(req)); + if (!parsedBody) { + sendJson(res, 400, { error: "requestId and runId are required" }); + return; + } + + await handler.onRunTerminal(parsedBody.requestId, parsedBody.runId); + sendJson(res, 200, { ok: true }); + return; + } + + if (method === "POST" && url.pathname === "/notify/handler-complete") { + const parsedBody = parseHandlerCompleteRequest(await readJsonBody(req)); + if (!parsedBody) { + sendJson(res, 400, { + error: "requestId, runId, handlerId, and status (done|failed) are required", + }); + return; + } + + await handler.onHandlerComplete( + parsedBody.requestId, + parsedBody.runId, + parsedBody.handlerId, + parsedBody.status, + ); + sendJson(res, 200, { ok: true }); + return; + } + } catch (error) { + const message = error instanceof Error ? error.message : "Internal server error"; + const statusCode = + message === "Request body is required" || error instanceof SyntaxError ? 400 : 500; + + sendJson(res, statusCode, { + error: + statusCode === 400 + ? "Invalid JSON request body" + : "Internal server error", + }); + return; + } + + sendJson(res, 404, { error: "Not found" }); + }); +} diff --git a/apps/workers/post-processor/src/post-processor.ts b/apps/workers/post-processor/src/post-processor.ts index 2ffeab0b6..4b1b6e847 100644 --- a/apps/workers/post-processor/src/post-processor.ts +++ b/apps/workers/post-processor/src/post-processor.ts @@ -2,7 +2,7 @@ // Licensed under the MIT License. import type { DequeuedMessageItem } from "@azure/storage-queue"; -import { BaseQueueProcessor, BlobStorage, Retry, type BaseQueueProcessorConfig, type LogEvent, type VisibilityHeartbeat } from "shared"; +import { BaseQueueProcessor, BlobStorage, type BaseQueueProcessorConfig, type LogEvent, type VisibilityHeartbeat } from "shared"; import { POST_PROCESSOR_VERSION } from "./version.js"; import type { PostProcessHandler, PostProcessorMessage, HandlerContext } from "./types.js"; @@ -86,35 +86,45 @@ export class PostProcessor extends BaseQueueProcessor { await handler.process(msg, ctx); - // Stamp version and status on success + // Stamp version and status on success (legacy fields + new handlerStatus) + const handlerId = `pp-${msg.type}`; // e.g., "pp-atif" await this.collection.updateOne( { _id: doc._id } as any, { $set: { "run.postProcessorVersion": POST_PROCESSOR_VERSION, "run.postProcessorStatus": "done", + [`run.handlerStatus.${handlerId}.status`]: "done", + [`run.handlerStatus.${handlerId}.version`]: POST_PROCESSOR_VERSION, + [`run.handlerStatus.${handlerId}.updatedAt`]: new Date(), }, } as any, ); await log("info", `Post-processing complete (v${POST_PROCESSOR_VERSION})`); - // Trigger report generation now that enrichment is done (best-effort) - try { - await this.triggerReportGeneration(doc._id, log); - } catch (error) { - await log("warn", `Failed to trigger report generation after retries: ${error}`); - } + // Notify the scheduler that this handler is done (best-effort) + await this.notifyHandlerComplete(doc._id, doc.run?._id ?? "", handlerId, "done"); } catch (err) { const errMsg = err instanceof Error ? err.message : String(err); await log("error", `Handler '${msg.type}' failed: ${errMsg}`); // Mark as failed so scheduler doesn't immediately re-dispatch + const handlerId = `pp-${msg.type}`; await this.collection.updateOne( { _id: doc._id } as any, - { $set: { "run.postProcessorStatus": "failed" } } as any, + { + $set: { + "run.postProcessorStatus": "failed", + [`run.handlerStatus.${handlerId}.status`]: "failed", + [`run.handlerStatus.${handlerId}.updatedAt`]: new Date(), + }, + } as any, ); + // Notify scheduler of failure (best-effort — don't dispatch downstream) + await this.notifyHandlerComplete(doc._id, doc.run?._id ?? "", handlerId, "failed"); + throw err; } @@ -124,27 +134,31 @@ export class PostProcessor extends BaseQueueProcessor { } /** - * Trigger report generation via REST API. - * Called after post-processing succeeds so reports can use enriched data. + * Notify the scheduler that a handler completed (or failed). + * Best-effort — the scheduler's poll safety net will catch up if this fails. */ - @Retry({ maxRetries: 3, baseDelayMs: 1000, isRetryable: () => true }) - private async triggerReportGeneration(requestId: string, log: (level: LogEvent["level"], message: string) => Promise): Promise { - if (!this.apiBaseUrl) return; - - const response = await fetch(`${this.apiBaseUrl}/api/v1/reports/trigger`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ requestId }), - }); - if (response.ok) { - const result = await response.json() as { triggered: number }; - await log("info", `Triggered report generation: ${result.triggered} report(s) created`); - return; - } - if (response.status >= 400 && response.status < 500) { - await log("warn", `Failed to trigger report generation (${response.status}), not retrying`); - return; + private async notifyHandlerComplete( + requestId: string, + runId: string, + handlerId: string, + status: "done" | "failed", + ): Promise { + const schedulerUrl = process.env.SCHEDULER_URL; + if (!schedulerUrl) return; + + try { + const response = await fetch(`${schedulerUrl}/notify/handler-complete`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ requestId, runId, handlerId, status }), + }); + if (response.ok) { + console.log(`[post-processor] Notified scheduler: ${handlerId} ${status} for ${requestId}`); + } else { + console.warn(`[post-processor] Scheduler notify failed: ${response.status}`); + } + } catch (err) { + console.warn(`[post-processor] Scheduler notify error for ${handlerId}/${requestId}:`, err); } - throw new Error(`Report trigger returned ${response.status} ${response.statusText}`); } } diff --git a/apps/workers/taxonomy/Dockerfile b/apps/workers/taxonomy/Dockerfile new file mode 100644 index 000000000..9e0792411 --- /dev/null +++ b/apps/workers/taxonomy/Dockerfile @@ -0,0 +1,19 @@ +FROM node:22-alpine AS builder +WORKDIR /app +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ +COPY packages/shared/package.json packages/shared/ +COPY apps/workers/taxonomy/package.json apps/workers/taxonomy/ +RUN corepack enable && pnpm install --frozen-lockfile +COPY packages/shared packages/shared +COPY apps/workers/taxonomy apps/workers/taxonomy +RUN pnpm --filter shared build && pnpm --filter taxonomy-handler build + +FROM node:22-alpine +WORKDIR /app +COPY --from=builder /app/node_modules ./node_modules +COPY --from=builder /app/packages/shared/dist ./packages/shared/dist +COPY --from=builder /app/packages/shared/package.json ./packages/shared/ +COPY --from=builder /app/apps/workers/taxonomy/dist ./apps/workers/taxonomy/dist +COPY --from=builder /app/apps/workers/taxonomy/package.json ./apps/workers/taxonomy/ +WORKDIR /app/apps/workers/taxonomy +CMD ["node", "dist/index.js"] diff --git a/apps/workers/taxonomy/package.json b/apps/workers/taxonomy/package.json new file mode 100644 index 000000000..aacbfc4f1 --- /dev/null +++ b/apps/workers/taxonomy/package.json @@ -0,0 +1,23 @@ +{ + "name": "taxonomy-handler", + "version": "1.0.0", + "type": "module", + "main": "dist/index.js", + "scripts": { + "build": "tsc", + "dev": "tsx watch src/index.ts", + "start": "node dist/index.js", + "lint": "eslint src/" + }, + "dependencies": { + "shared": "workspace:*", + "@azure/storage-queue": "^12.25.0", + "@github/copilot-sdk": "^0.1.17", + "dotenv": "^16.4.5" + }, + "devDependencies": { + "@types/node": "^22.7.0", + "tsx": "^4.19.0", + "typescript": "^5.6.2" + } +} diff --git a/apps/workers/taxonomy/src/index.ts b/apps/workers/taxonomy/src/index.ts new file mode 100644 index 000000000..2ee1a8118 --- /dev/null +++ b/apps/workers/taxonomy/src/index.ts @@ -0,0 +1,30 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import "dotenv/config"; +import { TaxonomyQueueProcessor, type TaxonomyQueueProcessorConfig } from "./taxonomy-queue-processor.js"; + +const config: TaxonomyQueueProcessorConfig = { + mongoUri: process.env.MONGO_CONNECTION_STRING || process.env.AZURE_COSMOS_CONNECTION_STRING || "mongodb://localhost:27017", + mongoDatabase: process.env.MONGO_DATABASE || "requests-db", + mongoCollection: "requests", + storageAccountName: process.env.AZURE_STORAGE_ACCOUNT_NAME || "devstoreaccount1", + storageConnectionString: process.env.STORAGE_CONNECTION_STRING || process.env.AZURE_STORAGE_CONNECTION_STRING || undefined, + queueName: process.env.AZURE_STORAGE_QUEUE_TAXONOMY || "pp-taxonomy-queue", + batchSize: Number(process.env.BATCH_SIZE) || 1, + pollIntervalMs: Number(process.env.POLL_INTERVAL_MS) || 5000, + redisHost: process.env.REDIS_HOST || "localhost", + redisPort: Number(process.env.REDIS_PORT) || 6379, + redisPassword: process.env.REDIS_PASSWORD || "", + taxonomyModel: process.env.TAXONOMY_MODEL || "gpt-4.1", + apiBaseUrl: process.env.SCOPE_MT_API_URL || "http://localhost:3001", + sessionTimeoutMs: Number(process.env.SESSION_TIMEOUT_MS) || 5 * 60 * 1000, + schedulerUrl: process.env.SCHEDULER_URL, + tokenManagerUrl: process.env.TOKEN_MANAGER_URL, +}; + +const processor = new TaxonomyQueueProcessor(config); +processor.start().catch((err) => { + console.error("[taxonomy-handler] Fatal error:", err); + process.exit(1); +}); diff --git a/apps/workers/taxonomy/src/prompt.ts b/apps/workers/taxonomy/src/prompt.ts new file mode 100644 index 000000000..47ea28e4d --- /dev/null +++ b/apps/workers/taxonomy/src/prompt.ts @@ -0,0 +1,88 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +export const TAXONOMY_SYSTEM_PROMPT = `You generate benchmark taxonomy documents for completed Scope runs. + +Your job is to inspect the run metadata and ATIF trajectories, then return a taxonomy JSON document that conforms exactly to the taxonomy Zod schema enforced by the worker. + +Required JSON shape: +{ + "meta": { + "generatedAt": "ISO-8601 datetime with offset", + "schemaVersion": "string", + "runId": "string", + "requestId": "string" + }, + "scorecard": { + "summary": "string", + "profileResults": [ + { + "profileId": "string", + "select": "best|median|worst", + "taskOutcome": "success|partial|failure", + "overallScore": 0-100, + "dimensions": [ + { + "name": "string", + "score": 0-100, + "rationale": "string", + "evidence": ["string"] + } + ] + } + ], + "crossProfileInsights": ["string"] + }, + "behaviorAnalysis": { + "patterns": [ + { + "id": "string", + "category": "strength|weakness|inconsistency|regression", + "title": "string", + "description": "string", + "frequency": 0-1, + "impact": "critical|high|medium|low", + "evidence": [ + { + "runId": "string", + "iteration": 0-or-greater integer, + "excerpt": "string" + } + ] + } + ], + "agentPersonality": { + "communicationStyle": "string", + "problemSolvingApproach": "string", + "errorRecoveryBehavior": "string" + } + }, + "actionList": { + "fix": [actionItem], + "improve": [actionItem], + "investigate": [actionItem] + } +} + +Each actionItem must include: +- id +- title +- priority: critical|high|medium|low +- description +- ownership: agent-team|platform-team|prompt-engineer|scenario-author +- relatedPatterns: string[] +- suggestedApproach + +Output rules: +- Return raw JSON only. +- Do not wrap the JSON in markdown fences. +- Do not include commentary before or after the JSON. +- Every field must be valid for the schema. +- Preserve the 3-layer structure: scorecard, behaviorAnalysis, actionList. +- If a value is unknown, use the schema-compatible empty/null/default value rather than inventing facts. + +Use the available tools to gather evidence before answering: +- get_run_data for the request, scenario, run, turns, and criteria outcomes. +- get_atif_trajectory for ATIF evidence from individual iterations. + +Ground every classification in the actual run evidence. Prefer precise, stable labels over verbose prose. If validation feedback is provided later in the conversation, correct the JSON and return a full replacement document.`; diff --git a/apps/workers/taxonomy/src/taxonomy-queue-processor.ts b/apps/workers/taxonomy/src/taxonomy-queue-processor.ts new file mode 100644 index 000000000..db2c7f7e3 --- /dev/null +++ b/apps/workers/taxonomy/src/taxonomy-queue-processor.ts @@ -0,0 +1,336 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { DequeuedMessageItem } from "@azure/storage-queue"; +import { CopilotClient, SessionEvent } from "@github/copilot-sdk"; +import { + BaseQueueProcessor, + BlobStorage, + type BaseQueueProcessorConfig, + type LogEvent, + type RequestDocument, + taxonomySchema, + TokenManagerClient, + type TaxonomyDocument, + type VisibilityHeartbeat, + withRetry, +} from "shared"; +import { TAXONOMY_SYSTEM_PROMPT } from "./prompt.js"; +import { createTaxonomyTools } from "./tools.js"; + +const HANDLER_ID = "pp-taxonomy"; +const HANDLER_VERSION = 1; +const DEFAULT_SESSION_TIMEOUT_MS = 5 * 60 * 1000; +const DELTA_LOG_INTERVAL = 2_000; + +interface TaxonomyQueueMessage { + type: "taxonomy"; + requestId: string; + runId?: string; +} + +export interface TaxonomyQueueProcessorConfig extends BaseQueueProcessorConfig { + taxonomyModel: string; + apiBaseUrl: string; + sessionTimeoutMs?: number; + schedulerUrl?: string; + tokenManagerUrl?: string; +} + +export class TaxonomyQueueProcessor extends BaseQueueProcessor { + private taxonomyConfig: TaxonomyQueueProcessorConfig; + private tokenClient: TokenManagerClient; + private blobStorage: BlobStorage; + + constructor(config: TaxonomyQueueProcessorConfig) { + super(config, "taxonomy-handler"); + this.taxonomyConfig = config; + this.tokenClient = new TokenManagerClient(config.tokenManagerUrl); + this.blobStorage = new BlobStorage({ + storageAccountName: config.storageAccountName, + storageConnectionString: config.storageConnectionString, + }); + } + + protected override async handleRequest( + doc: RequestDocument, + message: DequeuedMessageItem, + heartbeat: VisibilityHeartbeat, + log: (level: LogEvent["level"], msg: string, data?: Record) => Promise, + payload?: Record, + ): Promise { + const queueMessage = payload as TaxonomyQueueMessage | undefined; + const requestId = doc._id; + const activeRunId = doc.run?._id; + const runId = queueMessage?.runId ?? activeRunId ?? requestId; + + if (!queueMessage || queueMessage.type !== "taxonomy") { + await log("warn", "Discarding message with unsupported taxonomy payload", { payload }); + await this.safeDeleteMessage(message.messageId, heartbeat.stop()); + return; + } + + if (queueMessage.runId && activeRunId && queueMessage.runId !== activeRunId) { + await log("warn", "Discarding stale taxonomy message for superseded run", { + requestId, + queuedRunId: queueMessage.runId, + activeRunId, + }); + await this.safeDeleteMessage(message.messageId, heartbeat.stop()); + return; + } + + if (!doc.run) { + throw new Error(`Request ${requestId} has no active run to classify`); + } + + await withRetry(() => this.collection.updateOne( + { _id: requestId } as never, + { + $set: { + [`run.handlerStatus.${HANDLER_ID}.status`]: "processing", + [`run.handlerStatus.${HANDLER_ID}.version`]: HANDLER_VERSION, + [`run.handlerStatus.${HANDLER_ID}.updatedAt`]: new Date(), + [`run.handlerStatus.${HANDLER_ID}.error`]: null, + }, + } as never, + )); + + await log("info", "Starting taxonomy generation", { requestId, runId, model: this.taxonomyConfig.taxonomyModel }); + + try { + const taxonomy = await this.generateTaxonomy(requestId, runId, log); + const blobName = `${requestId}/runs/${runId}/taxonomy.json`; + const taxonomyUrl = await this.blobStorage.uploadJson(blobName, taxonomy); + + await withRetry(() => this.collection.updateOne( + { _id: requestId } as never, + { + $set: { + [`run.handlerStatus.${HANDLER_ID}.status`]: "done", + [`run.handlerStatus.${HANDLER_ID}.version`]: HANDLER_VERSION, + [`run.handlerStatus.${HANDLER_ID}.updatedAt`]: new Date(), + }, + $unset: { + [`run.handlerStatus.${HANDLER_ID}.error`]: "", + }, + } as never, + )); + + await log("info", "Taxonomy generation completed", { + requestId, + runId, + blobName, + taxonomyUrl, + }); + + await this.notifyHandlerComplete(requestId, runId, "done"); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + + await withRetry(() => this.collection.updateOne( + { _id: requestId } as never, + { + $set: { + [`run.handlerStatus.${HANDLER_ID}.status`]: "failed", + [`run.handlerStatus.${HANDLER_ID}.version`]: HANDLER_VERSION, + [`run.handlerStatus.${HANDLER_ID}.updatedAt`]: new Date(), + [`run.handlerStatus.${HANDLER_ID}.error`]: errorMessage, + }, + } as never, + )); + + await log("error", `Taxonomy generation failed: ${errorMessage}`, { requestId, runId }); + await this.notifyHandlerComplete(requestId, runId, "failed"); + throw error; + } + + const finalPopReceipt = heartbeat.stop(); + await this.safeDeleteMessage(message.messageId, finalPopReceipt); + } + + private async generateTaxonomy( + requestId: string, + runId: string, + log: (level: LogEvent["level"], msg: string, data?: Record) => Promise, + ): Promise { + const githubToken = await this.tokenClient.acquireToken("copilot-sdk"); + const client = new CopilotClient({ githubToken }); + const tools = createTaxonomyTools(this.taxonomyConfig.apiBaseUrl, requestId, this.blobStorage); + const timeoutMs = this.taxonomyConfig.sessionTimeoutMs ?? DEFAULT_SESSION_TIMEOUT_MS; + + let streamedResponse = ""; + let latestMessage = ""; + let lastLoggedCharCount = 0; + const toolNames = new Map(); + + try { + await log("info", "Acquired Copilot SDK token"); + + const session = await client.createSession({ + model: this.taxonomyConfig.taxonomyModel, + streaming: true, + tools, + systemMessage: { mode: "replace", content: TAXONOMY_SYSTEM_PROMPT }, + }); + + session.on((event: SessionEvent) => { + switch (event.type) { + case "session.start": + void log("info", "Copilot session started", { + sessionId: event.data.sessionId, + model: event.data.selectedModel, + }); + break; + case "session.error": + void log("error", `Session error: ${event.data.message}`, { errorType: event.data.errorType }); + break; + case "tool.execution_start": + toolNames.set(event.data.toolCallId, event.data.toolName); + void log("info", `Tool call: ${event.data.toolName}`, { + toolCallId: event.data.toolCallId, + arguments: event.data.arguments as Record | undefined, + }); + break; + case "tool.execution_complete": { + const toolName = toolNames.get(event.data.toolCallId) ?? "unknown"; + toolNames.delete(event.data.toolCallId); + void log("info", `Tool result: ${toolName} (${event.data.success ? "success" : "failed"})`, { + toolCallId: event.data.toolCallId, + error: event.data.error?.message, + }); + break; + } + case "assistant.message_delta": + streamedResponse += event.data.deltaContent; + if (streamedResponse.length - lastLoggedCharCount >= DELTA_LOG_INTERVAL) { + lastLoggedCharCount = streamedResponse.length; + void log("info", `Generating taxonomy… (${streamedResponse.length} chars so far)`); + } + break; + case "assistant.message": + latestMessage = event.data.content; + break; + } + }); + + let attempts = 0; + const maxAttempts = 3; + let prompt = [ + `Generate taxonomy JSON for request ${requestId} and run ${runId}.`, + "Use get_run_data first, then inspect ATIF trajectories that materially affect the classification.", + "Return only raw JSON that satisfies the taxonomy schema.", + ].join(" "); + let lastJson: string | undefined; + + while (attempts < maxAttempts) { + attempts += 1; + streamedResponse = ""; + latestMessage = ""; + lastLoggedCharCount = 0; + + await log("info", `Sending taxonomy prompt to Copilot SDK (attempt ${attempts}/${maxAttempts})`, { + requestId, + runId, + timeoutMs, + }); + + const response = await session.sendAndWait({ prompt }, timeoutMs); + const responseText = response?.data.content?.trim() || latestMessage.trim() || streamedResponse.trim(); + + if (!responseText) { + throw new Error("Copilot SDK returned an empty taxonomy response"); + } + + const jsonText = extractJson(responseText); + lastJson = jsonText; + + let parsedJson: unknown; + try { + parsedJson = JSON.parse(jsonText) as unknown; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + if (attempts >= maxAttempts) { + throw new Error(`Taxonomy response was not valid JSON after ${maxAttempts} attempts: ${errorMessage}`); + } + + prompt = [ + "Your previous response was not valid JSON.", + `JSON parse error: ${errorMessage}.`, + "Return the full taxonomy document again as raw JSON only, with no markdown fences or commentary.", + "Replace the entire response with corrected JSON.", + ].join(" "); + continue; + } + + const validation = taxonomySchema.safeParse(parsedJson); + if (validation.success) { + return validation.data; + } + + const validationErrors = formatValidationErrors(validation.error.issues); + await log("warn", `Taxonomy validation failed on attempt ${attempts}`, { + issues: validationErrors, + }); + + if (attempts >= maxAttempts) { + throw new Error( + `Taxonomy schema validation failed after ${maxAttempts} attempts: ${validationErrors.join(" | ")}`, + ); + } + + prompt = [ + "Your previous taxonomy JSON did not satisfy the schema.", + "Correct the document and return the entire replacement JSON object.", + "Do not include markdown fences or commentary.", + `Validation errors: ${validationErrors.join("; ")}`, + lastJson ? `Previous JSON: ${lastJson}` : "", + ].filter(Boolean).join(" "); + } + + throw new Error("Taxonomy generation ended without a valid document"); + } finally { + await client.stop().catch(() => undefined); + } + } + + private async notifyHandlerComplete( + requestId: string, + runId: string, + status: "done" | "failed", + ): Promise { + const schedulerUrl = this.taxonomyConfig.schedulerUrl; + if (!schedulerUrl) return; + + try { + const response = await fetch(`${schedulerUrl}/notify/handler-complete`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ requestId, runId, handlerId: HANDLER_ID, status }), + }); + + if (!response.ok) { + console.warn(`[taxonomy-handler] Scheduler notify failed: ${response.status}`); + } + } catch (error) { + console.warn(`[taxonomy-handler] Scheduler notify error for ${requestId}/${runId}:`, error); + } + } +} + +function extractJson(responseText: string): string { + const trimmed = responseText.trim(); + if (!trimmed.startsWith("```")) { + return trimmed; + } + + const fenceMatch = trimmed.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/i); + return fenceMatch?.[1]?.trim() ?? trimmed; +} + +function formatValidationErrors(issues: Array<{ path: PropertyKey[]; message: string }>): string[] { + return issues.map((issue) => { + const path = issue.path.length > 0 ? issue.path.map((segment) => String(segment)).join(".") : ""; + return `${path}: ${issue.message}`; + }); +} diff --git a/apps/workers/taxonomy/src/tools.ts b/apps/workers/taxonomy/src/tools.ts new file mode 100644 index 000000000..9c92b5b18 --- /dev/null +++ b/apps/workers/taxonomy/src/tools.ts @@ -0,0 +1,156 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { defineTool } from "@github/copilot-sdk"; +import { BlobStorage, type ConversationTurn, type RequestDocument } from "shared"; + +interface GetAtifTrajectoryArgs { + iteration?: number; +} + +function resolveSnapshotsBlobName(blobUrl: string): string { + const url = new URL(blobUrl); + const containerPrefix = "/snapshots/"; + const containerIndex = url.pathname.indexOf(containerPrefix); + if (containerIndex === -1) { + throw new Error(`Blob URL does not contain the snapshots container: ${blobUrl}`); + } + return decodeURIComponent(url.pathname.substring(containerIndex + containerPrefix.length)); +} + +function selectTurnWithAtif(turns: ConversationTurn[] | undefined, requestedIteration?: number): ConversationTurn | undefined { + if (!turns?.length) return undefined; + + if (requestedIteration !== undefined) { + return turns.find((turn) => turn.iteration === requestedIteration && typeof turn.atifUrl === "string"); + } + + return [...turns] + .filter((turn) => typeof turn.atifUrl === "string") + .sort((a, b) => b.iteration - a.iteration)[0]; +} + +export function createTaxonomyTools( + apiBaseUrl: string, + requestId: string, + blobStorage: BlobStorage, +) { + const getRunData = defineTool("get_run_data", { + description: + "Fetch the full request document plus the active run state, including scenario, persona, turns, criteria results, and handler status.", + parameters: { + type: "object", + properties: {}, + }, + handler: async () => { + try { + const response = await fetch(`${apiBaseUrl}/api/v1/requests/${requestId}`); + if (!response.ok) { + return { error: `Failed to fetch request: ${response.status} ${response.statusText}` }; + } + + const request: RequestDocument = await response.json(); + const run = request.run; + + return { + request: { + id: request._id, + workerType: request.workerType, + model: request.model, + reasoningEffort: request.reasoningEffort, + maxIterations: request.maxIterations, + createdAt: request.createdAt, + updatedAt: request.updatedAt, + persona: request.persona, + personaInstructions: request.personaInstructions, + mcpServers: request.mcpServers, + skillRevisions: request.skillRevisions, + extensions: request.extensions, + priority: request.priority, + scenario: request.scenario, + }, + run: run ? { + id: run._id, + attemptNumber: run.attemptNumber, + status: run.status, + outcome: run.outcome, + result: run.result, + error: run.error, + startedAt: run.startedAt, + finishedAt: run.finishedAt, + updatedAt: run.updatedAt, + aiCallCount: run.aiCallCount, + tokenUsage: run.tokenUsage, + handlerStatus: run.handlerStatus, + turns: run.turns?.map((turn) => ({ + iteration: turn.iteration, + passed: turn.passed, + timestamp: turn.timestamp, + startedAt: turn.startedAt, + durationMs: turn.durationMs, + judgeFeedback: turn.judgeFeedback, + codingAgentResponse: turn.codingAgentResponse, + aiCallCount: turn.aiCallCount, + tokenUsage: turn.tokenUsage, + snapshotUrl: turn.snapshotUrl, + rawChatUrl: turn.rawChatUrl, + rawChatFormat: turn.rawChatFormat, + toolCallsUrl: turn.toolCallsUrl, + toolCallCount: turn.toolCallCount, + atifUrl: turn.atifUrl, + criteriaResults: turn.criteriaResults, + })) ?? [], + } : null, + }; + } catch (err) { + return { error: `Failed to fetch run data: ${err}` }; + } + }, + }); + + const getAtifTrajectory = defineTool("get_atif_trajectory", { + description: + "Fetch an ATIF trajectory JSON document for a specific iteration, or the latest available iteration when omitted.", + parameters: { + type: "object", + properties: { + iteration: { + type: "number", + description: "Optional 1-based iteration number. If omitted, the latest available ATIF trajectory is returned.", + }, + }, + }, + handler: async (args: GetAtifTrajectoryArgs) => { + try { + const response = await fetch(`${apiBaseUrl}/api/v1/requests/${requestId}`); + if (!response.ok) { + return { error: `Failed to fetch request: ${response.status} ${response.statusText}` }; + } + + const request: RequestDocument = await response.json(); + const selectedTurn = selectTurnWithAtif(request.run?.turns, args.iteration); + + if (!selectedTurn?.atifUrl) { + return { + error: args.iteration !== undefined + ? `No ATIF trajectory available for iteration ${args.iteration}` + : "No ATIF trajectory is available for this run", + }; + } + + const blobName = resolveSnapshotsBlobName(selectedTurn.atifUrl); + const buffer = await blobStorage.downloadBlobToBuffer(blobName); + const trajectory: unknown = JSON.parse(buffer.toString("utf-8")); + + return { + iteration: selectedTurn.iteration, + trajectory, + }; + } catch (err) { + return { error: `Failed to fetch ATIF trajectory: ${err}` }; + } + }, + }); + + return [getRunData, getAtifTrajectory]; +} diff --git a/apps/workers/taxonomy/tsconfig.json b/apps/workers/taxonomy/tsconfig.json new file mode 100644 index 000000000..bf012ecbb --- /dev/null +++ b/apps/workers/taxonomy/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*"] +} diff --git a/docker-compose.yml b/docker-compose.yml index d1ca5dcb0..a4503d104 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,6 +9,7 @@ # docker compose --profile vscode-web up # + VS Code Web worker # docker compose --profile claude-code --profile copilot up # multiple workers # docker compose --profile report up # + Report Generator worker +# docker compose --profile taxonomy up # + Taxonomy handler worker # docker compose up mongodb redis azurite # infra only (run app services natively) # # Host-native debugging: @@ -35,6 +36,7 @@ x-worker-env: &worker-env <<: *common-env JUDGE_SERVICE_URL: http://judge:80 SCOPE_MT_API_URL: http://api:80 + SCHEDULER_URL: http://scheduler:8080 TOKEN_MANAGER_URL: http://token-manager:80 x-worker-deps: &worker-deps @@ -138,7 +140,7 @@ services: timeout: 5s retries: 5 - # Creates the 'har' blob container in Azurite for the gateway HAR plugin. + # Creates the 'har' blob container and handler queues in Azurite. azurite-init: image: mcr.microsoft.com/azure-cli:2.68.0 restart: "no" @@ -146,9 +148,12 @@ services: azurite: condition: service_healthy environment: - AZURE_STORAGE_CONNECTION_STRING: "DefaultEndpointsProtocol=http;AccountName=devstoreaccount1;AccountKey=Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==;BlobEndpoint=http://azurite:10000/devstoreaccount1;" + AZURE_STORAGE_CONNECTION_STRING: "DefaultEndpointsProtocol=http;AccountName=devstoreaccount1;AccountKey=Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==;QueueEndpoint=http://azurite:10001/devstoreaccount1;BlobEndpoint=http://azurite:10000/devstoreaccount1;" command: > - sh -c "az storage container create --name har --connection-string \"$$AZURE_STORAGE_CONNECTION_STRING\" || true" + sh -c "az storage container create --name har --connection-string \"$$AZURE_STORAGE_CONNECTION_STRING\" || true && + az storage queue create --name report-queue --connection-string \"$$AZURE_STORAGE_CONNECTION_STRING\" || true && + az storage queue create --name post-processor-queue --connection-string \"$$AZURE_STORAGE_CONNECTION_STRING\" || true && + az storage queue create --name pp-taxonomy-queue --connection-string \"$$AZURE_STORAGE_CONNECTION_STRING\" || true" # =========================================================================== # Application services @@ -638,6 +643,34 @@ services: api: condition: service_started + taxonomy-handler: + build: + context: . + dockerfile: apps/workers/taxonomy/Dockerfile + profiles: [taxonomy, all] + environment: + <<: *common-env + AZURE_STORAGE_QUEUE_TAXONOMY: pp-taxonomy-queue + TAXONOMY_MODEL: ${TAXONOMY_MODEL:-gpt-4.1} + SCOPE_MT_API_URL: http://api:80 + SCHEDULER_URL: http://scheduler:8080 + GITHUB_TOKEN: ${GITHUB_TOKEN:-} + TOKEN_MANAGER_URL: http://token-manager:80 + GIT_COMMIT: ${GIT_COMMIT:-development} + SESSION_TIMEOUT_MS: ${SESSION_TIMEOUT_MS:-300000} + depends_on: + mongodb: + condition: service_healthy + required: false + redis: + condition: service_healthy + azurite: + condition: service_healthy + api: + condition: service_started + scheduler: + condition: service_started + register-version-post-processor: build: context: . @@ -661,6 +694,7 @@ services: <<: *common-env AZURE_STORAGE_QUEUE_POSTPROCESSOR: post-processor-queue SCOPE_MT_API_URL: http://api:80 + SCHEDULER_URL: http://scheduler:8080 depends_on: mongodb: condition: service_healthy diff --git a/docs/architecture/db-migrations.md b/docs/architecture/db-migrations.md index 9cfd9540e..fbe5b1cd4 100644 --- a/docs/architecture/db-migrations.md +++ b/docs/architecture/db-migrations.md @@ -102,6 +102,9 @@ When you run a command: | `007-rename-exhausted-to-finished` | Renames outcome `exhausted` → `finished` | | `008-backfill-ai-call-count` | Downloads HARs from blob storage to count AI completion calls per turn | | `009-add-requests-filter-indexes` | Adds indexes on `taskPromptId`, `status`, `outcome`, `workerType`, `deletedAt` for server-side filtering/grouping | +| `018-migrate-services-to-handler-dag` | Replaces the legacy single `post-processor` service row with per-handler service registrations and dependencies | +| `019-add-handler-status-index` | Adds handler-specific dispatch indexes for `pp-atif`, `pp-taxonomy`, and `pp-report` polling | +| `020-backfill-handler-status` | Backfills `run.handlerStatus.pp-atif` from deprecated `run.postProcessorStatus` / `run.postProcessorVersion` fields | ## CI/CD diff --git a/docs/architecture/overview.md b/docs/architecture/overview.md index 8c919498b..7550c5703 100644 --- a/docs/architecture/overview.md +++ b/docs/architecture/overview.md @@ -79,6 +79,8 @@ flowchart TB | `shared` | Shared types and utilities | | `workers/coder-acp-claude-code` | Claude Code agent via Agent Client Protocol (ACP) | | `workers/coder-acp-copilot` | GitHub Copilot agent via Agent Client Protocol (ACP) | +| `workers/report-generator` | Copilot SDK worker that generates post-run markdown reports | +| `workers/taxonomy` | Copilot SDK worker that generates validated post-run taxonomy JSON | | `gateway` | AI Gateway — shared Rust TLS-intercepting proxy with plugin architecture (HAR capture, future: token refresh, rate limiting) | ## Data Flow diff --git a/docs/architecture/post-processing.md b/docs/architecture/post-processing.md index c351b830c..7bdf7e2e8 100644 --- a/docs/architecture/post-processing.md +++ b/docs/architecture/post-processing.md @@ -14,8 +14,9 @@ flowchart LR Two paths trigger post-processing: -1. **Event-driven (primary):** Coder workers enqueue a post-processing message immediately when a run completes, setting `run.postProcessorStatus: "queued"` atomically in the same DB write. -2. **Scheduler backfill:** The `PostProcessorDispatcher` polls every 30 seconds for runs that have `status: "done"` but no `postProcessorStatus` set (or a `postProcessorVersion` below the current target). This catches runs that completed before the event-driven dispatch was deployed, or that need re-processing after a version bump. +1. **Event-driven (primary):** Coder workers enqueue a post-processing message immediately when a run completes, currently setting the deprecated `run.postProcessorStatus: "queued"` compatibility field atomically in the same DB write while the system migrates to per-handler status. +2. **Scheduler backfill:** The `PostProcessorDispatcher` polls every 30 seconds for runs that have `status: "done"` but no post-processing marker set (or a stale handler version). During the migration window this may consult the deprecated flat fields; the long-term source of truth is `run.handlerStatus[handlerId]`. This catches runs that completed before the event-driven dispatch was deployed, or that need re-processing after a version bump. +3. **Scheduler notify server:** The scheduler also exposes `GET /health`, `POST /notify/run-terminal`, and `POST /notify/handler-complete` on a lightweight `node:http` server. Workers and post-process handlers use these endpoints to notify the scheduler when terminal states are reached so it can re-evaluate which root or downstream handlers are ready to dispatch. ## ATIF Generation @@ -32,8 +33,24 @@ Two paths trigger post-processing: - Uploads `atif.trajectory.json` to blob storage at `{requestId}/runs/{runId}/iteration-{N}/atif.trajectory.json` - Updates `run.turns.$.atifUrl` in the request document -4. On success, stamps `run.postProcessorVersion` and sets `run.postProcessorStatus: "done"` -5. Triggers report generation via the API (best-effort, with retry) +4. On success, stamps `run.handlerStatus["pp-atif"] = { status: "done", version: N }` (while legacy `run.postProcessorVersion` / `run.postProcessorStatus` may still be mirrored during the transition) +5. Triggers downstream taxonomy/report handlers once the scheduler sees the dependency graph is satisfied + +## Taxonomy Generation + +The taxonomy handler (`apps/workers/taxonomy/`) consumes messages from the dedicated `pp-taxonomy-queue` and produces a structured JSON artifact for a completed run. It uses the GitHub Copilot SDK with a tool-backed session to fetch run metadata from the API and ATIF trajectories from blob storage, then validates the generated JSON against the shared taxonomy Zod schema. + +### Flow + +1. Queue message arrives: `{ type: "taxonomy", requestId, runId }` +2. Worker fetches the request document from MongoDB and marks `run.handlerStatus["pp-taxonomy"].status = "processing"` +3. Copilot SDK session calls: + - `get_run_data` to fetch the request + active run from the API + - `get_atif_trajectory` to fetch per-iteration ATIF JSON from blob storage +4. The worker validates the assistant response with `taxonomySchema.safeParse()` +5. If validation fails, the worker feeds the schema errors back into the same session and retries up to 3 total attempts +6. On success, the worker uploads `taxonomy.json` to `{requestId}/runs/{runId}/taxonomy.json` +7. The worker stamps `run.handlerStatus["pp-taxonomy"] = { status: "done", version: 1 }` and calls `POST /notify/handler-complete` ### Blob Storage Layout @@ -98,10 +115,10 @@ processor.start(); The post-processor uses a version-based re-processing scheme: -- The worker declares its version in `src/version.ts` (e.g., `export const POST_PROCESSOR_VERSION = 1`) -- On each deploy, a registration Job writes `{ _id: "post-processor", version: N }` to the `services` collection -- The scheduler's `PostProcessorDispatcher` compares each request's `run.postProcessorVersion` against the registered version -- Requests with a lower version are re-dispatched for processing with the new handler logic +- Each handler is registered independently in the `services` collection using a `post-process-handler` document keyed by handler ID (for example `pp-atif`, `pp-taxonomy`, `pp-report`) +- Each registration stores the handler `version`, queue name, selector, `autoBackfill` behavior, and `dependsOn` DAG edges +- The scheduler compares each request's per-handler status/version state (for example `run.handlerStatus["pp-atif"]`) against the registered handler entry for that selector +- Requests with missing or outdated handler state are re-dispatched for that specific handler once its dependencies are satisfied This allows deploying handler improvements and having them automatically applied to historical data. @@ -127,16 +144,20 @@ stateDiagram-v2 | Environment Variable | Default | Description | | --- | --- | --- | -| `AZURE_STORAGE_QUEUE_POSTPROCESSOR` | `post-processor-queue` | Queue name for post-processor messages | +| `AZURE_STORAGE_QUEUE_POSTPROCESSOR` | `post-processor-queue` | Queue name for ATIF post-processor messages | +| `AZURE_STORAGE_QUEUE_TAXONOMY` | `pp-taxonomy-queue` | Queue name for taxonomy-generation jobs | | `SCHEDULER_PP_POLL_INTERVAL_MS` | `30000` | Scheduler backfill polling interval | | `BATCH_SIZE` | `1` | Messages to process per poll (worker-side) | | `POLL_INTERVAL_MS` | `5000` | Worker queue polling interval | -| `SCOPE_MT_API_URL` / `API_BASE_URL` | `http://localhost:3001` | API URL for report generation trigger | +| `SCOPE_MT_API_URL` / `API_BASE_URL` | `http://localhost:3001` | API URL used by report/taxonomy Copilot tools | +| `TAXONOMY_MODEL` | `gpt-4.1` | Copilot SDK model used by the taxonomy handler | +| `SESSION_TIMEOUT_MS` | `300000` | Timeout for report/taxonomy Copilot SDK sessions | +| `SCHEDULER_URL` | _(unset)_ | Optional scheduler notify base URL for downstream handler dispatch | ## Infrastructure -- **Queue:** Azure Storage Queue (`post-processor-queue`) +- **Queues:** Azure Storage Queues (`post-processor-queue` for ATIF, `pp-taxonomy-queue` for taxonomy, `report-queue` for reports) - **Blob Storage:** `snapshots` container (shared with HAR, tool-calls, and other artifacts) -- **Database:** MongoDB `requests` collection (`run.postProcessorStatus`, `run.postProcessorVersion` fields) +- **Database:** MongoDB `requests` collection (`run.handlerStatus` map keyed by handler ID, with deprecated `run.postProcessorStatus` / `run.postProcessorVersion` compatibility fields during migration) - **KEDA:** ScaledObject scales the worker to zero when queue is empty - **Version registration:** Kubernetes Job runs on each deploy; Docker Compose uses an init service \ No newline at end of file diff --git a/packages/db-migrations/src/check-migrations.test.ts b/packages/db-migrations/src/check-migrations.test.ts index 6c631b83e..7a7a07b63 100644 --- a/packages/db-migrations/src/check-migrations.test.ts +++ b/packages/db-migrations/src/check-migrations.test.ts @@ -7,6 +7,7 @@ import { resetMigrationCheckCache, type MigrationCheckResult, } from "./check-migrations.js"; +import { REQUIRED_MIGRATIONS } from "./required-migrations.js"; // Mock Db function makeMockDb(appliedFiles: string[]) { @@ -30,55 +31,19 @@ describe("checkMigrations", () => { }); it("returns ready when all required migrations are applied", async () => { - const db = makeMockDb([ - "001-backfill-task-prompts.ts", - "002-create-indexes.ts", - "003-create-skill-indexes.ts", - "004-add-submission-id-index.ts", - "005-backfill-iteration-durations.ts", - "006-split-status-outcome.ts", - "007-rename-exhausted-to-finished.ts", - "008-backfill-ai-call-count.ts", - "009-add-requests-filter-indexes.ts", - "010-add-requests-pagination-index.ts", - "011-add-profile-indexes.ts", - "012-add-profile-name-index.ts", - "013-remove-logs-from-docs.ts", - "014-introduce-runs-and-run.ts", - "015-add-priority-and-scheduler-index.ts", - "016-fix-scheduler-sort-index.ts", - "017-add-post-processor-dispatch-index.ts", - ]); + const db = makeMockDb([...REQUIRED_MIGRATIONS]); const result = await checkMigrations(db); expect(result.ready).toBe(true); expect(result.pending).toEqual([]); - expect(result.totalApplied).toBe(17); - expect(result.applied).toEqual([ - "001-backfill-task-prompts.ts", - "002-create-indexes.ts", - "003-create-skill-indexes.ts", - "004-add-submission-id-index.ts", - "005-backfill-iteration-durations.ts", - "006-split-status-outcome.ts", - "007-rename-exhausted-to-finished.ts", - "008-backfill-ai-call-count.ts", - "009-add-requests-filter-indexes.ts", - "010-add-requests-pagination-index.ts", - "011-add-profile-indexes.ts", - "012-add-profile-name-index.ts", - "013-remove-logs-from-docs.ts", - "014-introduce-runs-and-run.ts", - "015-add-priority-and-scheduler-index.ts", - "016-fix-scheduler-sort-index.ts", - "017-add-post-processor-dispatch-index.ts", - ]); + expect(result.totalApplied).toBe(REQUIRED_MIGRATIONS.length); + expect(result.applied).toEqual(REQUIRED_MIGRATIONS); }); it("returns not ready when migrations are missing", async () => { const db = makeMockDb(["001-backfill-task-prompts.ts"]); const result = await checkMigrations(db); expect(result.ready).toBe(false); - expect(result.pending).toEqual(["002-create-indexes.ts", "003-create-skill-indexes.ts", "004-add-submission-id-index.ts", "005-backfill-iteration-durations.ts", "006-split-status-outcome.ts", "007-rename-exhausted-to-finished.ts", "008-backfill-ai-call-count.ts", "009-add-requests-filter-indexes.ts", "010-add-requests-pagination-index.ts", "011-add-profile-indexes.ts", "012-add-profile-name-index.ts", "013-remove-logs-from-docs.ts", "014-introduce-runs-and-run.ts", "015-add-priority-and-scheduler-index.ts", "016-fix-scheduler-sort-index.ts", "017-add-post-processor-dispatch-index.ts"]); + expect(result.pending).toEqual(REQUIRED_MIGRATIONS.slice(1)); expect(result.applied).toEqual(["001-backfill-task-prompts.ts"]); expect(result.totalApplied).toBe(1); }); @@ -87,54 +52,20 @@ describe("checkMigrations", () => { const db = makeMockDb([]); const result = await checkMigrations(db); expect(result.ready).toBe(false); - expect(result.pending).toEqual([ - "001-backfill-task-prompts.ts", - "002-create-indexes.ts", - "003-create-skill-indexes.ts", - "004-add-submission-id-index.ts", - "005-backfill-iteration-durations.ts", - "006-split-status-outcome.ts", - "007-rename-exhausted-to-finished.ts", - "008-backfill-ai-call-count.ts", - "009-add-requests-filter-indexes.ts", - "010-add-requests-pagination-index.ts", - "011-add-profile-indexes.ts", - "012-add-profile-name-index.ts", - "013-remove-logs-from-docs.ts", - "014-introduce-runs-and-run.ts", - "015-add-priority-and-scheduler-index.ts", - "016-fix-scheduler-sort-index.ts", - "017-add-post-processor-dispatch-index.ts", - ]); + expect(result.pending).toEqual(REQUIRED_MIGRATIONS); expect(result.applied).toEqual([]); expect(result.totalApplied).toBe(0); }); it("ignores extra applied migrations not in the required list", async () => { const db = makeMockDb([ - "001-backfill-task-prompts.ts", - "002-create-indexes.ts", - "003-create-skill-indexes.ts", - "004-add-submission-id-index.ts", - "005-backfill-iteration-durations.ts", - "006-split-status-outcome.ts", - "007-rename-exhausted-to-finished.ts", - "008-backfill-ai-call-count.ts", - "009-add-requests-filter-indexes.ts", - "010-add-requests-pagination-index.ts", - "011-add-profile-indexes.ts", - "012-add-profile-name-index.ts", - "013-remove-logs-from-docs.ts", - "014-introduce-runs-and-run.ts", - "015-add-priority-and-scheduler-index.ts", - "016-fix-scheduler-sort-index.ts", - "017-add-post-processor-dispatch-index.ts", + ...REQUIRED_MIGRATIONS, "999-future-migration.ts", ]); const result = await checkMigrations(db); expect(result.ready).toBe(true); expect(result.pending).toEqual([]); - expect(result.totalApplied).toBe(18); + expect(result.totalApplied).toBe(REQUIRED_MIGRATIONS.length + 1); }); it("caches results within TTL", async () => { diff --git a/packages/db-migrations/src/migrations/018-migrate-services-to-handler-dag.ts b/packages/db-migrations/src/migrations/018-migrate-services-to-handler-dag.ts new file mode 100644 index 000000000..9ea8644fe --- /dev/null +++ b/packages/db-migrations/src/migrations/018-migrate-services-to-handler-dag.ts @@ -0,0 +1,96 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import type { Db } from "mongodb"; +import type { MigrationInterface } from "mongo-migrate-ts"; + +interface ServiceDocument { + _id: string; + version: number; + type?: "post-process-handler"; + queue?: string; + selector?: string; + autoBackfill?: boolean; + dependsOn?: string[]; +} + +type HandlerServiceSeed = Required & { + type: "post-process-handler"; +}; + +const HANDLER_SERVICES: readonly HandlerServiceSeed[] = [ + { + _id: "pp-atif", + type: "post-process-handler", + version: 2, + queue: "post-processor-queue", + selector: "atif", + autoBackfill: true, + dependsOn: [], + }, + { + _id: "pp-taxonomy", + type: "post-process-handler", + version: 1, + queue: "pp-taxonomy-queue", + selector: "taxonomy", + autoBackfill: false, + dependsOn: ["pp-atif"], + }, + { + _id: "pp-report", + type: "post-process-handler", + version: 1, + queue: "report-queue", + selector: "report", + autoBackfill: false, + dependsOn: ["pp-atif", "pp-taxonomy"], + }, +]; + +export class MigrateServicesToHandlerDag implements MigrationInterface { + async up(db: Db): Promise { + const services = db.collection("services"); + + await services.deleteOne({ _id: "post-processor" }); + console.log("[018] Deleted legacy post-processor service entry if it existed"); + + for (const service of HANDLER_SERVICES) { + const { _id, ...fields } = service; + await services.updateOne( + { _id }, + { + $set: { + ...fields, + dependsOn: [...fields.dependsOn], + }, + }, + { upsert: true }, + ); + console.log(`[018] Upserted handler service ${_id}`); + } + } + + async down(db: Db): Promise { + const services = db.collection("services"); + + await services.deleteMany({ _id: { $in: HANDLER_SERVICES.map((service) => service._id) } }); + console.log("[018-down] Deleted handler service entries"); + + await services.updateOne( + { _id: "post-processor" }, + { + $set: { version: 2 }, + $unset: { + type: "", + queue: "", + selector: "", + autoBackfill: "", + dependsOn: "", + }, + }, + { upsert: true }, + ); + console.log("[018-down] Restored legacy post-processor service entry"); + } +} diff --git a/packages/db-migrations/src/migrations/019-add-handler-status-index.ts b/packages/db-migrations/src/migrations/019-add-handler-status-index.ts new file mode 100644 index 000000000..ecdb0352a --- /dev/null +++ b/packages/db-migrations/src/migrations/019-add-handler-status-index.ts @@ -0,0 +1,72 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import type { Db } from "mongodb"; +import type { MigrationInterface } from "mongo-migrate-ts"; + +const HANDLER_STATUS_INDEXES = [ + { + name: "idx_handler_dispatch_atif", + key: { + "run.handlerStatus.pp-atif.status": 1 as const, + "run.status": 1 as const, + updatedAt: -1 as const, + }, + }, + { + name: "idx_handler_dispatch_taxonomy", + key: { + "run.handlerStatus.pp-taxonomy.status": 1 as const, + "run.status": 1 as const, + updatedAt: -1 as const, + }, + }, + { + name: "idx_handler_dispatch_report", + key: { + "run.handlerStatus.pp-report.status": 1 as const, + "run.status": 1 as const, + updatedAt: -1 as const, + }, + }, +] as const; + +export class AddHandlerStatusIndex implements MigrationInterface { + async up(db: Db): Promise { + const requests = db.collection("requests"); + + for (const index of HANDLER_STATUS_INDEXES) { + try { + const createdName = await requests.createIndex(index.key, { name: index.name }); + const exists = await requests.indexExists(createdName); + console.log( + exists + ? `[019] Created ${index.name} index on requests` + : `[019] ${index.name} was requested but is not visible in getIndexes()`, + ); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + console.log(`[019] ${index.name} already exists or couldn't be created: ${message}`); + } + } + } + + async down(db: Db): Promise { + const requests = db.collection("requests"); + + for (const index of HANDLER_STATUS_INDEXES) { + try { + await requests.dropIndex(index.name); + console.log(`[019-down] Dropped ${index.name} index`); + } catch (err: unknown) { + const codeName = typeof err === "object" && err !== null && "codeName" in err + ? String((err as { codeName?: unknown }).codeName) + : undefined; + if (codeName !== "IndexNotFound") { + const message = err instanceof Error ? err.message : String(err); + console.log(`[019-down] ${index.name} not found: ${message}`); + } + } + } + } +} diff --git a/packages/db-migrations/src/migrations/020-backfill-handler-status.ts b/packages/db-migrations/src/migrations/020-backfill-handler-status.ts new file mode 100644 index 000000000..ac5326afd --- /dev/null +++ b/packages/db-migrations/src/migrations/020-backfill-handler-status.ts @@ -0,0 +1,76 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import type { AnyBulkWriteOperation, Db } from "mongodb"; +import type { MigrationInterface } from "mongo-migrate-ts"; + +const BATCH_SIZE = 100; + +type RequestWithPostProcessorState = { + _id: string; + run?: { + postProcessorStatus?: "queued" | "processing" | "done" | "failed"; + postProcessorVersion?: number; + }; +}; + +export class BackfillHandlerStatus implements MigrationInterface { + async up(db: Db): Promise { + const requests = db.collection("requests"); + const cursor = requests.find( + { "run.postProcessorVersion": { $exists: true } }, + { + projection: { + _id: 1, + "run.postProcessorStatus": 1, + "run.postProcessorVersion": 1, + }, + batchSize: BATCH_SIZE, + }, + ); + + let processed = 0; + let modified = 0; + let batch: AnyBulkWriteOperation[] = []; + + for await (const doc of cursor) { + const version = doc.run?.postProcessorVersion; + if (version === undefined) { + continue; + } + + batch.push({ + updateOne: { + filter: { _id: doc._id, "run.postProcessorVersion": { $exists: true } }, + update: { + $set: { + "run.handlerStatus.pp-atif": { + status: doc.run?.postProcessorStatus ?? "done", + version, + }, + }, + }, + }, + }); + + if (batch.length >= BATCH_SIZE) { + const result = await requests.bulkWrite(batch, { ordered: false }); + processed += batch.length; + modified += result.modifiedCount; + batch = []; + } + } + + if (batch.length > 0) { + const result = await requests.bulkWrite(batch, { ordered: false }); + processed += batch.length; + modified += result.modifiedCount; + } + + console.log(` Backfilled handlerStatus for ${modified} runs (processed ${processed})`); + } + + async down(_db: Db): Promise { + // No-op: deprecated flat fields remain intact for rollback compatibility. + } +} diff --git a/packages/db-migrations/src/required-migrations.ts b/packages/db-migrations/src/required-migrations.ts index 120a54d0d..c1f1079d3 100644 --- a/packages/db-migrations/src/required-migrations.ts +++ b/packages/db-migrations/src/required-migrations.ts @@ -30,4 +30,7 @@ export const REQUIRED_MIGRATIONS: readonly string[] = [ "015-add-priority-and-scheduler-index.ts", "016-fix-scheduler-sort-index.ts", "017-add-post-processor-dispatch-index.ts", + "018-migrate-services-to-handler-dag.ts", + "019-add-handler-status-index.ts", + "020-backfill-handler-status.ts", ]; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 392a9cde2..e2af74303 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -21,4 +21,5 @@ export * from "./devproxy/index.js"; export * from "./har/index.js"; export * from "./utils/index.js"; export * from "./schemas/index.js"; +export * from "./schemas/taxonomy.js"; export * from "./cursor.js"; diff --git a/packages/shared/src/queue/queue-processor.test.ts b/packages/shared/src/queue/queue-processor.test.ts index fb2a1dc95..2323e0057 100644 --- a/packages/shared/src/queue/queue-processor.test.ts +++ b/packages/shared/src/queue/queue-processor.test.ts @@ -269,8 +269,9 @@ describe("CodingAgentQueueProcessor.handleRequest redelivery handling", () => { }); // ─── Post-processor event-driven dispatch ──────────────────────────────────── -describe("CodingAgentQueueProcessor.enqueuePostProcessing", () => { - it("sends a post-processor queue message when postProcessorQueueName is configured", async () => { +describe("CodingAgentQueueProcessor.notifyRunTerminal", () => { + it("sends a post-processor queue message via legacy fallback when SCHEDULER_URL is not set", async () => { + delete process.env.SCHEDULER_URL; const configWithPP: QueueProcessorConfig = { ...testConfig, postProcessorQueueName: "post-processor-queue", @@ -280,7 +281,7 @@ describe("CodingAgentQueueProcessor.enqueuePostProcessing", () => { const sendMessage = vi.fn().mockResolvedValue({}); (qp as any).postProcessorQueueClient = { sendMessage }; - await (qp as any).enqueuePostProcessing("req-123", "run-456"); + await (qp as any).notifyRunTerminal("req-123", "run-456"); expect(sendMessage).toHaveBeenCalledTimes(1); const decoded = JSON.parse( @@ -289,13 +290,15 @@ describe("CodingAgentQueueProcessor.enqueuePostProcessing", () => { expect(decoded).toEqual({ type: "atif", requestId: "req-123", runId: "run-456" }); }); - it("does nothing when postProcessorQueueClient is null", async () => { + it("does nothing when postProcessorQueueClient is null and SCHEDULER_URL not set", async () => { + delete process.env.SCHEDULER_URL; const qp = new CodingAgentQueueProcessor(testConfig, stubProcessor); expect((qp as any).postProcessorQueueClient).toBeNull(); - await (qp as any).enqueuePostProcessing("req-123", "run-456"); + await (qp as any).notifyRunTerminal("req-123", "run-456"); }); it("logs a warning but does not throw on queue send failure", async () => { + delete process.env.SCHEDULER_URL; const configWithPP: QueueProcessorConfig = { ...testConfig, postProcessorQueueName: "post-processor-queue", @@ -306,7 +309,7 @@ describe("CodingAgentQueueProcessor.enqueuePostProcessing", () => { (qp as any).postProcessorQueueClient = { sendMessage }; const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); - await (qp as any).enqueuePostProcessing("req-123", "run-456"); + await (qp as any).notifyRunTerminal("req-123", "run-456"); expect(warnSpy).toHaveBeenCalledWith( expect.stringContaining("Failed to enqueue post-processing"), expect.any(Error), diff --git a/packages/shared/src/queue/queue-processor.ts b/packages/shared/src/queue/queue-processor.ts index f663284f7..0324499b4 100644 --- a/packages/shared/src/queue/queue-processor.ts +++ b/packages/shared/src/queue/queue-processor.ts @@ -44,7 +44,7 @@ export class CodingAgentQueueProcessor extends BaseQueueProcessor { + private async notifyRunTerminal(requestId: string, runId: string): Promise { + const schedulerUrl = process.env.SCHEDULER_URL; + if (schedulerUrl) { + try { + const response = await fetch(`${schedulerUrl}/notify/run-terminal`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ requestId, runId }), + }); + if (response.ok) { + console.log(`[${this.workerName}] Notified scheduler: run-terminal for ${requestId}`); + } else { + console.warn(`[${this.workerName}] Scheduler notify failed: ${response.status} — poll will catch up`); + } + return; + } catch (err) { + console.warn(`[${this.workerName}] Scheduler notify error for ${requestId} — poll will catch up:`, err); + return; + } + } + + // Legacy fallback: direct queue enqueue + await this.enqueuePostProcessingLegacy(requestId, runId); + } + + /** + * @deprecated Legacy direct queue enqueue. Use notifyRunTerminal instead. + * Kept as fallback when SCHEDULER_URL is not configured. + */ + private async enqueuePostProcessingLegacy(requestId: string, runId: string): Promise { if (!this.postProcessorQueueClient) return; try { const message = Buffer.from( @@ -79,9 +109,9 @@ export class CodingAgentQueueProcessor extends BaseQueueProcessor { - await this.enqueuePostProcessing(requestId, runId); + await this.notifyRunTerminal(requestId, runId); } /** Build workerVersion and OS fields for stamping on request documents. @@ -243,8 +273,8 @@ export class CodingAgentQueueProcessor extends BaseQueueProcessor { - const apiBaseUrl = (this.config as QueueProcessorConfig).apiBaseUrl; - if (!apiBaseUrl) return; - - try { - const response = await fetch(`${apiBaseUrl}/api/v1/reports/trigger`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ requestId }), - }); - if (response.ok) { - const result = await response.json() as { triggered: number }; - console.log(`[${this.workerName}] Triggered report generation for request ${requestId}: ${result.triggered} report(s) created`); - } else { - console.warn(`[${this.workerName}] Failed to trigger report generation: ${response.status} ${response.statusText}`); - } - } catch (error) { - console.warn(`[${this.workerName}] Failed to trigger report generation: ${error}`); - } - } - /** * Extract skill archives to the workspace filesystem so agents can discover them. * Must be called after setup() so that processor.workspacePath points to the @@ -597,8 +601,8 @@ export class CodingAgentQueueProcessor extends BaseQueueProcessor { + const validFixture = { + meta: { + generatedAt: "2025-02-01T12:00:00Z", + schemaVersion: "1.0.0", + runId: "run-123", + requestId: "req-456", + }, + scorecard: { + summary: "The agent usually succeeds but struggles with recovery quality.", + profileResults: [ + { + profileId: "profile-best", + select: "best", + taskOutcome: "success", + overallScore: 92, + dimensions: [ + { + name: "Correctness", + score: 95, + rationale: "Implemented the requested behavior fully.", + evidence: ["All acceptance criteria passed", "No regressions observed"], + }, + ], + }, + ], + crossProfileInsights: [ + "The agent communicates clearly across profiles.", + "Stress conditions reduce recovery quality.", + ], + }, + behaviorAnalysis: { + patterns: [ + { + id: "pattern-1", + category: "strength", + title: "Consistent planning", + description: "The agent outlines a clear plan before making changes.", + frequency: 0.75, + impact: "high", + evidence: [ + { + runId: "run-123", + iteration: 1, + excerpt: "Plan: inspect the failing endpoint, then patch the handler.", + }, + ], + }, + ], + agentPersonality: { + communicationStyle: "Concise and structured", + problemSolvingApproach: "Hypothesis-driven debugging", + errorRecoveryBehavior: "Retries with targeted fixes after inspecting evidence", + }, + }, + actionList: { + fix: [ + { + id: "action-1", + title: "Stabilize recovery prompts", + priority: "critical", + description: "Reduce failures when the first attempt is rejected.", + ownership: "prompt-engineer", + relatedPatterns: ["pattern-1"], + suggestedApproach: "Add explicit fallback guidance for failed first passes.", + }, + ], + improve: [ + { + id: "action-2", + title: "Expand planning examples", + priority: "medium", + description: "Reinforce successful planning behavior.", + ownership: "agent-team", + relatedPatterns: ["pattern-1"], + suggestedApproach: "Fine-tune on traces with strong upfront decomposition.", + }, + ], + investigate: [ + { + id: "action-3", + title: "Review profile variance", + priority: "low", + description: "Understand why profile-specific regressions appear sporadically.", + ownership: "scenario-author", + relatedPatterns: ["pattern-1"], + suggestedApproach: "Compare scenario wording against affected profile runs.", + }, + ], + }, + }; + + it("accepts a valid taxonomy fixture", () => { + const result = taxonomySchema.safeParse(validFixture); + expect(result.success).toBe(true); + }); + + it("rejects fixtures missing required fields with precise paths", () => { + const invalidFixture = { + ...validFixture, + scorecard: { + ...validFixture.scorecard, + profileResults: [ + { + ...validFixture.scorecard.profileResults[0], + dimensions: [ + { + name: "Correctness", + score: 95, + evidence: ["All acceptance criteria passed"], + }, + ], + }, + ], + }, + behaviorAnalysis: { + ...validFixture.behaviorAnalysis, + agentPersonality: { + communicationStyle: "Concise and structured", + errorRecoveryBehavior: "Retries with targeted fixes after inspecting evidence", + }, + }, + }; + + const result = taxonomySchema.safeParse(invalidFixture); + expect(result.success).toBe(false); + if (result.success) { + throw new Error("Expected invalid taxonomy fixture to fail parsing"); + } + + const issuePaths = result.error.issues.map((issue) => issue.path); + expect(issuePaths).toContainEqual([ + "scorecard", + "profileResults", + 0, + "dimensions", + 0, + "rationale", + ]); + expect(issuePaths).toContainEqual([ + "behaviorAnalysis", + "agentPersonality", + "problemSolvingApproach", + ]); + }); + + it("enforces enum values", () => { + const invalidFixture = { + ...validFixture, + actionList: { + ...validFixture.actionList, + fix: [ + { + ...validFixture.actionList.fix[0], + priority: "urgent", + }, + ], + }, + }; + + const result = taxonomySchema.safeParse(invalidFixture); + expect(result.success).toBe(false); + if (result.success) { + throw new Error("Expected invalid enum fixture to fail parsing"); + } + + expect(result.error.issues.map((issue) => issue.path)).toContainEqual([ + "actionList", + "fix", + 0, + "priority", + ]); + }); +}); diff --git a/packages/shared/src/schemas/taxonomy.ts b/packages/shared/src/schemas/taxonomy.ts new file mode 100644 index 000000000..a837d4f91 --- /dev/null +++ b/packages/shared/src/schemas/taxonomy.ts @@ -0,0 +1,104 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { z } from "zod"; + +const scoreSchema = z.number().min(0).max(100); +const frequencySchema = z.number().min(0).max(1); + +/** Taxonomy document metadata. */ +export const taxonomyMetaSchema = z.object({ + generatedAt: z.string().datetime({ offset: true }), + schemaVersion: z.string().min(1), + runId: z.string().min(1), + requestId: z.string().min(1), +}); + +/** Scorecard dimension result for a single profile. */ +export const scorecardDimensionSchema = z.object({ + name: z.string().min(1), + score: scoreSchema, + rationale: z.string().min(1), + evidence: z.array(z.string().min(1)), +}); + +/** Scorecard result for a specific profile slice. */ +export const profileResultSchema = z.object({ + profileId: z.string().min(1), + select: z.enum(["best", "median", "worst"]), + taskOutcome: z.enum(["success", "partial", "failure"]), + overallScore: scoreSchema, + dimensions: z.array(scorecardDimensionSchema), +}); + +/** Top-level scorecard section summarizing profile results. */ +export const scorecardSchema = z.object({ + summary: z.string().min(1), + profileResults: z.array(profileResultSchema), + crossProfileInsights: z.array(z.string().min(1)), +}); + +/** Evidence excerpt supporting an observed behavior pattern. */ +export const behaviorEvidenceSchema = z.object({ + runId: z.string().min(1), + iteration: z.number().int().min(0), + excerpt: z.string().min(1), +}); + +/** Cross-run behavioral pattern identified by the taxonomy analysis. */ +export const behaviorPatternSchema = z.object({ + id: z.string().min(1), + category: z.enum(["strength", "weakness", "inconsistency", "regression"]), + title: z.string().min(1), + description: z.string().min(1), + frequency: frequencySchema, + impact: z.enum(["critical", "high", "medium", "low"]), + evidence: z.array(behaviorEvidenceSchema), +}); + +/** Qualitative description of the agent's personality traits. */ +export const agentPersonalitySchema = z.object({ + communicationStyle: z.string().min(1), + problemSolvingApproach: z.string().min(1), + errorRecoveryBehavior: z.string().min(1), +}); + +/** Behavior analysis section describing patterns and personality. */ +export const behaviorAnalysisSchema = z.object({ + patterns: z.array(behaviorPatternSchema), + agentPersonality: agentPersonalitySchema, +}); + +/** Action item emitted by the taxonomy analysis. */ +export const actionItemSchema = z.object({ + id: z.string().min(1), + title: z.string().min(1), + priority: z.enum(["critical", "high", "medium", "low"]), + description: z.string().min(1), + ownership: z.enum([ + "agent-team", + "platform-team", + "prompt-engineer", + "scenario-author", + ]), + relatedPatterns: z.array(z.string().min(1)), + suggestedApproach: z.string().min(1), +}); + +/** Prioritized action lists grouped by remediation intent. */ +export const actionListSchema = z.object({ + fix: z.array(actionItemSchema), + improve: z.array(actionItemSchema), + investigate: z.array(actionItemSchema), +}); + +/** Complete taxonomy document produced by the taxonomy post-processor. */ +export const taxonomySchema = z.object({ + meta: taxonomyMetaSchema, + scorecard: scorecardSchema, + behaviorAnalysis: behaviorAnalysisSchema, + actionList: actionListSchema, +}); + +/** Inferred TypeScript type for the taxonomy document. */ +export type TaxonomyDocument = z.infer; diff --git a/packages/shared/src/types/types.ts b/packages/shared/src/types/types.ts index c23444417..77fcb57b4 100644 --- a/packages/shared/src/types/types.ts +++ b/packages/shared/src/types/types.ts @@ -206,6 +206,14 @@ export interface QueueMessagePayload { runId?: string; } +/** Status of a single post-process handler for a specific run. */ +export interface HandlerRunStatus { + status: "queued" | "processing" | "done" | "failed"; + version?: number; + updatedAt?: Date; + error?: string; +} + /** * RunState — represents one execution attempt of a request. * @@ -270,12 +278,11 @@ export interface RunState { aiCallCount?: number; rawChatUrl?: string; rawChatFormat?: string; - /** Version of the post-processor that last successfully processed this run. - * Used by the scheduler to detect runs needing (re-)processing when the - * post-processor version advances. */ + /** Per-handler post-processing status. Keys are handler IDs (e.g. "pp-atif", "pp-taxonomy", "pp-report"). */ + handlerStatus?: Record; + /** @deprecated Use handlerStatus["pp-atif"].version instead. Kept for migration compatibility. */ postProcessorVersion?: number; - /** Lifecycle status of post-processing for this run. Prevents duplicate - * enqueuing and tracks processing progress. */ + /** @deprecated Use handlerStatus["pp-atif"].status instead. Kept for migration compatibility. */ postProcessorStatus?: "queued" | "processing" | "done" | "failed"; } @@ -287,6 +294,17 @@ export interface RunHistoryDocument extends RunState { requestId: string; // FK → RequestDocument._id } +/** A post-process handler registered in the `services` collection. */ +export interface HandlerServiceDocument { + _id: string; + type: "post-process-handler"; + version: number; + queue: string; + selector: string; + autoBackfill: boolean; + dependsOn: string[]; +} + /** * Field names whose values move from `RequestDocument` (top-level, legacy * shape) into `RequestDocument.run: RunState` (new shape introduced for diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 339c191ba..a7903fc00 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -705,6 +705,31 @@ importers: specifier: ^5.6.2 version: 5.9.3 + apps/workers/taxonomy: + dependencies: + '@azure/storage-queue': + specifier: ^12.25.0 + version: 12.29.0 + '@github/copilot-sdk': + specifier: ^0.1.17 + version: 0.1.23 + dotenv: + specifier: ^16.4.5 + version: 16.6.1 + shared: + specifier: workspace:* + version: link:../../../packages/shared + devDependencies: + '@types/node': + specifier: ^22.7.0 + version: 22.19.10 + tsx: + specifier: ^4.19.0 + version: 4.21.0 + typescript: + specifier: ^5.6.2 + version: 5.9.3 + packages/copilot-driver-ext: devDependencies: '@types/node': From e9bf6ad106cb0cd8135164ca0220a2d69908d3fd Mon Sep 17 00:00:00 2001 From: Cedric Vidal Date: Tue, 9 Jun 2026 02:15:31 -0700 Subject: [PATCH 02/19] feat: add taxonomy API route and archive bundling (Phase 3) - Add GET /api/v1/requests/:id/taxonomy endpoint - Bundle taxonomy.json in run download tar.gz archive - Add taxonomyUrl to RunState type and request schema - Update taxonomy worker to persist run.taxonomyUrl - Update OpenAPI snapshot and post-processing docs Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../openapi-snapshot.test.ts.snap | 63 +++++++++++ apps/api/src/archive-har.test.ts | 41 +++++++ apps/api/src/archive-har.ts | 29 ++++- apps/api/src/index.ts | 2 + apps/api/src/routes/requests/taxonomy.test.ts | 107 ++++++++++++++++++ apps/api/src/routes/requests/taxonomy.ts | 30 +++++ .../taxonomy/src/taxonomy-queue-processor.ts | 1 + docs/architecture/post-processing.md | 7 +- packages/shared/src/schemas/request.ts | 1 + packages/shared/src/types/types.ts | 2 + 10 files changed, 281 insertions(+), 2 deletions(-) create mode 100644 apps/api/src/routes/requests/taxonomy.test.ts create mode 100644 apps/api/src/routes/requests/taxonomy.ts diff --git a/apps/api/src/__snapshots__/openapi-snapshot.test.ts.snap b/apps/api/src/__snapshots__/openapi-snapshot.test.ts.snap index 7912955e3..16ee7fc6d 100644 --- a/apps/api/src/__snapshots__/openapi-snapshot.test.ts.snap +++ b/apps/api/src/__snapshots__/openapi-snapshot.test.ts.snap @@ -2313,6 +2313,9 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` ], "type": "string", }, + "taxonomyUrl": { + "type": "string", + }, "tokenUsage": { "$ref": "#/components/schemas/TokenUsage", }, @@ -7981,6 +7984,40 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` ], }, }, + "/api/v1/requests/{id}/runs/{runId}/taxonomy": { + "get": { + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string", + }, + }, + { + "in": "path", + "name": "runId", + "required": true, + "schema": { + "type": "string", + }, + }, + ], + "responses": { + "200": { + "description": "Taxonomy v1 JSON document (scorecard, behavior analysis, action list)", + }, + "404": { + "description": "Not found — taxonomy not yet generated", + }, + }, + "summary": "Download taxonomy JSON for a specific attempt", + "tags": [ + "Requests", + ], + }, + }, "/api/v1/requests/{id}/runs/{runId}/tool-calls": { "get": { "parameters": [ @@ -8083,6 +8120,32 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` ], }, }, + "/api/v1/requests/{id}/taxonomy": { + "get": { + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string", + }, + }, + ], + "responses": { + "200": { + "description": "Taxonomy v1 JSON document (scorecard, behavior analysis, action list)", + }, + "404": { + "description": "Not found — taxonomy not yet generated", + }, + }, + "summary": "Download taxonomy JSON for the run", + "tags": [ + "Requests", + ], + }, + }, "/api/v1/requests/{id}/tool-calls": { "get": { "parameters": [ diff --git a/apps/api/src/archive-har.test.ts b/apps/api/src/archive-har.test.ts index f83d846ac..51d5e36f2 100644 --- a/apps/api/src/archive-har.test.ts +++ b/apps/api/src/archive-har.test.ts @@ -99,6 +99,17 @@ describe("rewriteHarUrlsForArchive", () => { expect(result.run!.turns![1].toolCallsUrl).toBe("iteration-2.tool-calls.jsonl"); expect(result.run!.turns![2].toolCallsUrl).toBeUndefined(); }); + + it("rewrites run.taxonomyUrl to taxonomy.json", () => { + const resource = { + run: { + taxonomyUrl: "https://storage.blob.core.windows.net/snapshots/abc123/runs/attempt-1/taxonomy.json", + turns: [], + }, + }; + const result = rewriteHarUrlsForArchive(resource); + expect(result.run!.taxonomyUrl).toBe("taxonomy.json"); + }); }); // --- blobNameFromSnapshotsUrl --- @@ -844,4 +855,34 @@ describe("packRunIntoTar", () => { expect(yaml).toContain("iteration-1.chat-result.json"); expect(yaml).not.toContain("blob.core.windows.net"); }); + + it("bundles run-level taxonomy.json and rewrites taxonomyUrl", async () => { + const { pack } = await import("tar-stream"); + const p = pack(); + const taxonomyData = Buffer.from('{"topics":["agent"]}'); + const container = makeMockBlobContainer({ + "run-010/runs/attempt-1/taxonomy.json": { body: taxonomyData, length: taxonomyData.length }, + }); + + const run: ArchivableRun = { + _id: "run-010", + run: { + taxonomyUrl: "https://storage.blob.core.windows.net/snapshots/run-010/runs/attempt-1/taxonomy.json", + turns: [], + }, + }; + + const entriesPromise = collectPackEntries(p); + await packRunIntoTar(p, run, container, "run-010", () => true); + p.finalize(); + + const entries = await entriesPromise; + const names = entries.map(e => e.name); + expect(names).toContain("run-010/taxonomy.json"); + expect(entries.find(e => e.name === "run-010/taxonomy.json")!.data).toEqual(taxonomyData); + + const yaml = entries.find(e => e.name === "run-010/run.yaml")!.data.toString(); + expect(yaml).toContain("taxonomy.json"); + expect(yaml).not.toContain("blob.core.windows.net"); + }); }); diff --git a/apps/api/src/archive-har.ts b/apps/api/src/archive-har.ts index 21f922179..86cfeb1ee 100644 --- a/apps/api/src/archive-har.ts +++ b/apps/api/src/archive-har.ts @@ -41,11 +41,12 @@ export function blobNameFromLogsUrl(url: string): string | null { /** * Deep-clone a run resource and rewrite `harUrl`, `rawChatUrl`, - * `chatResultUrl`, and `toolCallsUrl` fields to relative archive paths. + * `taxonomyUrl`, `chatResultUrl`, and `toolCallsUrl` fields to relative archive paths. * * - `run.harUrl` → `"run.har"` * - Per-turn `harUrl` → `"iteration-{N}.har"` * - `run.rawChatUrl` → `"run.chat-export.json"` + * - `run.taxonomyUrl` → `"taxonomy.json"` * - Per-turn `rawChatUrl` → `"iteration-{N}.chat-export.json"` * - Per-turn `chatResultUrl` → `"iteration-{N}.chat-result.json"` * - Per-turn `toolCallsUrl` → `"iteration-{N}.tool-calls.jsonl"` @@ -54,6 +55,7 @@ export function rewriteHarUrlsForArchive; [key: string]: unknown; }; @@ -66,6 +68,9 @@ export function rewriteHarUrlsForArchive ({ + checkMigrations: vi.fn().mockResolvedValue({ ready: true, applied: ["001", "002"], pending: [] }), +})); + +vi.hoisted(() => { + process.env.STORAGE_CONNECTION_STRING = "DefaultEndpointsProtocol=https;AccountName=mockaccount;AccountKey=bW9jaw==;EndpointSuffix=core.windows.net"; +}); + +vi.mock("../../llm.js", () => ({ isLlmAvailable: vi.fn().mockReturnValue(false) })); +vi.mock("../../prompt-feature-llm.js", () => ({ isLlmAvailable: vi.fn().mockReturnValue(false) })); +vi.mock("../../task-prompt-llm.js", () => ({ isTaskPromptLlmAvailable: vi.fn().mockReturnValue(false) })); + +const { mockDownload, mockGetProperties, mockGetBlockBlobClient, mockGetBlobClient, mockGetContainerClient } = vi.hoisted(() => { + const mockDownload = vi.fn(); + const mockGetProperties = vi.fn(); + const mockGetBlockBlobClient = vi.fn().mockReturnValue({ download: mockDownload, getProperties: mockGetProperties }); + const mockGetBlobClient = vi.fn().mockReturnValue({ download: mockDownload, getProperties: mockGetProperties }); + const mockGetContainerClient = vi.fn().mockReturnValue({ getBlockBlobClient: mockGetBlockBlobClient, getBlobClient: mockGetBlobClient }); + return { mockDownload, mockGetProperties, mockGetBlockBlobClient, mockGetBlobClient, mockGetContainerClient }; +}); + +vi.mock("@azure/storage-blob", async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + BlobServiceClient: { fromConnectionString: vi.fn().mockReturnValue({ getContainerClient: mockGetContainerClient }) }, + }; +}); + +describe("taxonomy endpoints", () => { + let mocks: ReturnType; + + beforeAll(() => { + mocks = createAllMockDependencies(); + _injectTestDependencies(mocks); + }); + + beforeEach(() => { + vi.clearAllMocks(); + mocks = createAllMockDependencies(); + _injectTestDependencies(mocks); + rewireBlobMocks({ mockDownload, mockGetProperties, mockGetBlockBlobClient, mockGetBlobClient, mockGetContainerClient }); + mockDownload.mockResolvedValue({ readableStreamBody: readableFrom('{"status":"ok"}'), contentLength: 15 }); + mockGetProperties.mockResolvedValue({ contentLength: 15 }); + }); + + describe.each(VARIANTS)("$label", (variant) => { + const taxonomyUrl = (id: string) => variant.url(id) + "taxonomy"; + + it("returns 404 when request not found", async () => { + (mocks.collection.findOne as ReturnType).mockResolvedValue(null); + const res = await supertest(app).get(taxonomyUrl("missing")); + expect(res.status).toBe(404); + expect(res.body).toMatchObject({ error: "Request not found" }); + }); + + it("returns 404 when taxonomyUrl is not set", async () => { + variant.mockRequest(mocks, "req-1", { _id: "run-1", attemptNumber: 1, status: "done" }); + const res = await supertest(app).get(taxonomyUrl("req-1")); + expect(res.status).toBe(404); + expect(res.body).toMatchObject({ error: "Taxonomy not yet generated for this run" }); + }); + + it("returns taxonomy JSON when taxonomyUrl is set", async () => { + variant.mockRequest(mocks, "req-1", { + _id: "run-1", + attemptNumber: 1, + status: "done", + taxonomyUrl: blobUrl("req-1/taxonomy.json"), + }); + + const res = await supertest(app).get(taxonomyUrl("req-1")).buffer(true).parse(rawParser); + expect(res.status).toBe(200); + expect(res.headers["content-type"]).toMatch("application/json"); + expect(res.headers["content-disposition"]).toContain("req-1-taxonomy.json"); + expect(mockGetBlockBlobClient).toHaveBeenCalledWith("req-1/taxonomy.json"); + }); + }); + + it("works with per-run variant", async () => { + RUN_LEVEL.mockRequest(mocks, "req-1", { + _id: "run-1", + attemptNumber: 1, + status: "done", + taxonomyUrl: blobUrl("req-1/runs/run-1/taxonomy.json"), + }); + + const res = await supertest(app).get(RUN_LEVEL.url("req-1") + "taxonomy").buffer(true).parse(rawParser); + expect(res.status).toBe(200); + expect(res.headers["content-disposition"]).toContain("req-1-taxonomy.json"); + expect(mockGetBlockBlobClient).toHaveBeenCalledWith("req-1/runs/run-1/taxonomy.json"); + }); +}); diff --git a/apps/api/src/routes/requests/taxonomy.ts b/apps/api/src/routes/requests/taxonomy.ts new file mode 100644 index 000000000..e74d303f6 --- /dev/null +++ b/apps/api/src/routes/requests/taxonomy.ts @@ -0,0 +1,30 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import type { RouteContext } from "../../route-context.js"; +import { downloadBlobToResponse } from "./blob-helpers.js"; +import { registerArtifactRoutes } from "./artifact-route.js"; + +export function registerRequestsTaxonomyRoutes(ctx: RouteContext): void { + registerArtifactRoutes(ctx, { + path: "taxonomy", + summary: "Download taxonomy JSON for the run", + perRunSummary: "Download taxonomy JSON for a specific attempt", + responseDescription: "Taxonomy v1 JSON document (scorecard, behavior analysis, action list)", + errorResponses: { 404: { description: "Not found — taxonomy not yet generated" } }, + blobNotFoundMessage: "Taxonomy file not found — the blob may have been deleted or is no longer available", + handler: async (ctx, _req, res, targetRun, id) => { + const taxonomyUrl = targetRun.taxonomyUrl; + if (!taxonomyUrl) { + res.status(404).json({ error: "Taxonomy not yet generated for this run" }); + return; + } + + await downloadBlobToResponse(ctx, res, taxonomyUrl, { + contentType: "application/json", + filename: `${id}-taxonomy.json`, + label: "Taxonomy", + }); + }, + }); +} diff --git a/apps/workers/taxonomy/src/taxonomy-queue-processor.ts b/apps/workers/taxonomy/src/taxonomy-queue-processor.ts index db2c7f7e3..29fb3b61a 100644 --- a/apps/workers/taxonomy/src/taxonomy-queue-processor.ts +++ b/apps/workers/taxonomy/src/taxonomy-queue-processor.ts @@ -107,6 +107,7 @@ export class TaxonomyQueueProcessor extends BaseQueueProcessor { _id: requestId } as never, { $set: { + [`run.taxonomyUrl`]: taxonomyUrl, [`run.handlerStatus.${HANDLER_ID}.status`]: "done", [`run.handlerStatus.${HANDLER_ID}.version`]: HANDLER_VERSION, [`run.handlerStatus.${HANDLER_ID}.updatedAt`]: new Date(), diff --git a/docs/architecture/post-processing.md b/docs/architecture/post-processing.md index 7bdf7e2e8..df326d5e4 100644 --- a/docs/architecture/post-processing.md +++ b/docs/architecture/post-processing.md @@ -61,16 +61,21 @@ The taxonomy handler (`apps/workers/taxonomy/`) consumes messages from the dedic atif.trajectory.json iteration-2/ atif.trajectory.json + taxonomy.json ``` +The completed run stores the taxonomy blob URL on `run.taxonomyUrl`. + ### API Endpoints | Endpoint | Description | | --- | --- | | `GET /api/v1/requests/:id/atif?iteration=N` | Download ATIF for the latest run | | `GET /api/v1/requests/:id/runs/:runId/atif?iteration=N` | Download ATIF for a specific run | +| `GET /api/v1/requests/:id/taxonomy` | Download taxonomy JSON for the latest run | +| `GET /api/v1/requests/:id/runs/:runId/taxonomy` | Download taxonomy JSON for a specific run | -The `iteration` query parameter is mandatory. ATIF files are also included in archive exports as `iteration-{N}.atif.trajectory.json`. +The `iteration` query parameter is mandatory only for the ATIF endpoints. ATIF files are also included in archive exports as `iteration-{N}.atif.trajectory.json`. ## Handler Interface diff --git a/packages/shared/src/schemas/request.ts b/packages/shared/src/schemas/request.ts index bdcf6cc88..ea6f3d4da 100644 --- a/packages/shared/src/schemas/request.ts +++ b/packages/shared/src/schemas/request.ts @@ -173,6 +173,7 @@ export const RunStateSchema = z podName: z.string().optional(), }).optional(), harUrl: z.string().optional(), + taxonomyUrl: z.string().optional(), videoUrls: z.array(z.string()).optional(), setupVideoUrls: z.array(z.string()).optional(), tokenUsage: TokenUsageSchema.optional(), diff --git a/packages/shared/src/types/types.ts b/packages/shared/src/types/types.ts index 77fcb57b4..ccd7d7805 100644 --- a/packages/shared/src/types/types.ts +++ b/packages/shared/src/types/types.ts @@ -272,6 +272,7 @@ export interface RunState { podName?: string; }; harUrl?: string; + taxonomyUrl?: string; videoUrls?: string[]; setupVideoUrls?: string[]; tokenUsage?: TokenUsage; @@ -323,6 +324,7 @@ export const RUN_STATE_FIELD_NAMES = [ "lastHeartbeatAt", "worker", "harUrl", + "taxonomyUrl", "videoUrls", "setupVideoUrls", "tokenUsage", From 227328624e3f91c61abaef1331af6584cef0207b Mon Sep 17 00:00:00 2001 From: Cedric Vidal Date: Tue, 9 Jun 2026 02:38:12 -0700 Subject: [PATCH 03/19] fix: taxonomy handler Dockerfile and docker-compose dev setup - Use multi-stage Dockerfile matching report-generator pattern - Add tsconfig.base.json, proper COPY layers, --prod install in prod stage - Add dev stage for hot-reload with tsx watch - Add taxonomy-handler to docker-compose.dev.yml watch config - Add --profile taxonomy to docker:dev:copilot and docker:dev:all scripts Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- apps/workers/taxonomy/Dockerfile | 55 ++++++++++++++++++++++++-------- docker-compose.dev.yml | 16 ++++++++++ package.json | 4 +-- 3 files changed, 60 insertions(+), 15 deletions(-) diff --git a/apps/workers/taxonomy/Dockerfile b/apps/workers/taxonomy/Dockerfile index 9e0792411..6114e03a8 100644 --- a/apps/workers/taxonomy/Dockerfile +++ b/apps/workers/taxonomy/Dockerfile @@ -1,19 +1,48 @@ -FROM node:22-alpine AS builder +FROM node:22.22.0-slim AS builder + +RUN corepack enable && corepack prepare pnpm@10.29.1 --activate + WORKDIR /app -COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ -COPY packages/shared/package.json packages/shared/ -COPY apps/workers/taxonomy/package.json apps/workers/taxonomy/ -RUN corepack enable && pnpm install --frozen-lockfile -COPY packages/shared packages/shared -COPY apps/workers/taxonomy apps/workers/taxonomy + +COPY package.json pnpm-workspace.yaml pnpm-lock.yaml ./ +COPY tsconfig.base.json ./ +COPY packages/shared/package.json ./packages/shared/ +COPY apps/workers/taxonomy/package.json ./apps/workers/taxonomy/ + +RUN pnpm install --frozen-lockfile + +COPY packages/shared/tsconfig.json ./packages/shared/ +COPY packages/shared/src ./packages/shared/src/ +COPY apps/workers/taxonomy/tsconfig.json ./apps/workers/taxonomy/ +COPY apps/workers/taxonomy/src ./apps/workers/taxonomy/src/ + RUN pnpm --filter shared build && pnpm --filter taxonomy-handler build -FROM node:22-alpine +# --- Dev stage: hot reload with tsx watch --- +FROM builder AS dev +ENV NODE_ENV=development +ENV SERVICE_DIR=workers/taxonomy +COPY scripts/dev-entrypoint.sh /app/scripts/dev-entrypoint.sh +EXPOSE 80 +ENV PORT=80 +CMD ["/app/scripts/dev-entrypoint.sh"] + +# --- Production stage --- +FROM node:22.22.0-slim + +RUN corepack enable && corepack prepare pnpm@10.29.1 --activate + WORKDIR /app -COPY --from=builder /app/node_modules ./node_modules -COPY --from=builder /app/packages/shared/dist ./packages/shared/dist -COPY --from=builder /app/packages/shared/package.json ./packages/shared/ -COPY --from=builder /app/apps/workers/taxonomy/dist ./apps/workers/taxonomy/dist -COPY --from=builder /app/apps/workers/taxonomy/package.json ./apps/workers/taxonomy/ + +COPY package.json pnpm-workspace.yaml pnpm-lock.yaml ./ +COPY packages/shared/package.json ./packages/shared/ +COPY apps/workers/taxonomy/package.json ./apps/workers/taxonomy/ + +RUN pnpm install --frozen-lockfile --prod + +COPY --from=builder /app/packages/shared/dist ./packages/shared/dist/ +COPY --from=builder /app/apps/workers/taxonomy/dist ./apps/workers/taxonomy/dist/ + WORKDIR /app/apps/workers/taxonomy + CMD ["node", "dist/index.js"] diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index aac741607..c571a727d 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -180,6 +180,22 @@ services: - action: rebuild path: ./pnpm-lock.yaml + taxonomy-handler: + build: + target: dev + develop: + watch: + - action: sync + path: ./apps/workers/taxonomy/src + target: /app/apps/workers/taxonomy/src + - action: sync + path: ./packages/shared/src + target: /app/packages/shared/src + - action: rebuild + path: ./apps/workers/taxonomy/package.json + - action: rebuild + path: ./pnpm-lock.yaml + register-version-post-processor: build: target: dev diff --git a/package.json b/package.json index 46b946a51..62ce2760d 100644 --- a/package.json +++ b/package.json @@ -25,9 +25,9 @@ "docker:up:portal": "scripts/worktree-env.sh && scripts/dev-compose.sh --profile portal up --build", "docker:dev": "scripts/worktree-env.sh && COMPOSE_EXPERIMENTAL_WATCH_POLL=1 scripts/dev-compose.sh -f docker-compose.yml -f docker-compose.dev.yml up --build --watch", "docker:dev:claude-code": "scripts/worktree-env.sh && GIT_BRANCH=$(git rev-parse --abbrev-ref HEAD) COMPOSE_EXPERIMENTAL_WATCH_POLL=1 scripts/dev-compose.sh --env-file .env --env-file apps/workers/coder-acp-claude-code/versions.env -f docker-compose.yml -f docker-compose.dev.yml --profile claude-code --profile report --profile post-processor --profile portal up --build --watch", - "docker:dev:copilot": "scripts/worktree-env.sh && GIT_BRANCH=$(git rev-parse --abbrev-ref HEAD) COMPOSE_EXPERIMENTAL_WATCH_POLL=1 scripts/dev-compose.sh --env-file .env --env-file apps/workers/coder-acp-copilot/versions.env -f docker-compose.yml -f docker-compose.dev.yml --profile copilot --profile report --profile post-processor --profile portal up --build --watch", + "docker:dev:copilot": "scripts/worktree-env.sh && GIT_BRANCH=$(git rev-parse --abbrev-ref HEAD) COMPOSE_EXPERIMENTAL_WATCH_POLL=1 scripts/dev-compose.sh --env-file .env --env-file apps/workers/coder-acp-copilot/versions.env -f docker-compose.yml -f docker-compose.dev.yml --profile copilot --profile report --profile post-processor --profile taxonomy --profile portal up --build --watch", "docker:dev:portal": "scripts/worktree-env.sh && GIT_BRANCH=$(git rev-parse --abbrev-ref HEAD) COMPOSE_EXPERIMENTAL_WATCH_POLL=1 scripts/dev-compose.sh -f docker-compose.yml -f docker-compose.dev.yml --profile portal up --build --watch", - "docker:dev:all": "scripts/worktree-env.sh && GIT_BRANCH=$(git rev-parse --abbrev-ref HEAD) COMPOSE_EXPERIMENTAL_WATCH_POLL=1 scripts/dev-compose.sh --env-file .env --env-file apps/workers/coder-acp-copilot/versions.env --env-file apps/workers/coder-acp-claude-code/versions.env -f docker-compose.yml -f docker-compose.dev.yml --profile claude-code --profile copilot --profile report --profile post-processor --profile portal up --build --watch", + "docker:dev:all": "scripts/worktree-env.sh && GIT_BRANCH=$(git rev-parse --abbrev-ref HEAD) COMPOSE_EXPERIMENTAL_WATCH_POLL=1 scripts/dev-compose.sh --env-file .env --env-file apps/workers/coder-acp-copilot/versions.env --env-file apps/workers/coder-acp-claude-code/versions.env -f docker-compose.yml -f docker-compose.dev.yml --profile claude-code --profile copilot --profile report --profile post-processor --profile taxonomy --profile portal up --build --watch", "docker:dev:build": "scripts/worktree-env.sh && GIT_BRANCH=$(git rev-parse --abbrev-ref HEAD) scripts/dev-compose.sh --env-file .env --env-file apps/workers/coder-acp-copilot/versions.env --env-file apps/workers/coder-acp-claude-code/versions.env -f docker-compose.yml -f docker-compose.dev.yml --profile claude-code --profile copilot --profile report --profile portal build", "docker:down": "scripts/worktree-env.sh && scripts/dev-compose.sh --env-file .env --env-file apps/workers/coder-acp-copilot/versions.env --env-file apps/workers/coder-acp-claude-code/versions.env --profile claude-code --profile copilot --profile report --profile portal down --volumes", "docker:build": "scripts/worktree-env.sh && scripts/dev-compose.sh --env-file .env --env-file apps/workers/coder-acp-copilot/versions.env --env-file apps/workers/coder-acp-claude-code/versions.env --profile claude-code --profile copilot --profile report build", From 4507fb81d05d70ec4d11199238e0f05440338c76 Mon Sep 17 00:00:00 2001 From: Cedric Vidal Date: Tue, 9 Jun 2026 10:03:52 -0700 Subject: [PATCH 04/19] feat: add taxonomy tab to portal run detail view Adds a Taxonomy tab to the run detail page that shows: - Scorecard with visual score bars per dimension - Behavior analysis patterns with impact badges - Action items with priority indicators - Collapsible raw JSON view The tab only appears when taxonomy data is available (run.taxonomyUrl set). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- apps/portal/src/components/TaxonomyTab.tsx | 248 +++++++++++++++++++++ apps/portal/src/lib/api.ts | 10 + apps/portal/src/pages/RunDetail.tsx | 10 + apps/portal/src/types.ts | 1 + 4 files changed, 269 insertions(+) create mode 100644 apps/portal/src/components/TaxonomyTab.tsx diff --git a/apps/portal/src/components/TaxonomyTab.tsx b/apps/portal/src/components/TaxonomyTab.tsx new file mode 100644 index 000000000..e97c8c8c4 --- /dev/null +++ b/apps/portal/src/components/TaxonomyTab.tsx @@ -0,0 +1,248 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { useQuery } from "@tanstack/react-query"; +import { api } from "@/lib/api"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Loader2, Download, ChevronDown, ChevronRight } from "lucide-react"; +import { useState } from "react"; + +interface TaxonomyDimension { + name: string; + score: number; + maxScore: number; + justification: string; +} + +interface BehaviorPattern { + id: string; + category: string; + observation: string; + evidence: string[]; + impact: "positive" | "negative" | "neutral"; +} + +interface ActionItem { + id: string; + priority: "high" | "medium" | "low"; + title: string; + description: string; + category: string; +} + +interface TaxonomyData { + meta?: { + schemaVersion?: string; + generatedAt?: string; + requestId?: string; + runId?: string; + model?: string; + }; + scorecard?: { + overall?: number; + dimensions?: TaxonomyDimension[]; + }; + behaviorAnalysis?: { + patterns?: BehaviorPattern[]; + }; + actionList?: { + items?: ActionItem[]; + }; +} + +function ScoreBar({ score, maxScore }: { score: number; maxScore: number }) { + const pct = Math.round((score / maxScore) * 100); + const color = pct >= 80 ? "bg-green-500" : pct >= 50 ? "bg-yellow-500" : "bg-red-500"; + return ( +
+
+
+
+ {score}/{maxScore} +
+ ); +} + +function ImpactBadge({ impact }: { impact: string }) { + const variant = impact === "positive" ? "default" : impact === "negative" ? "destructive" : "secondary"; + return {impact}; +} + +function PriorityBadge({ priority }: { priority: string }) { + const colors: Record = { + high: "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300", + medium: "bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300", + low: "bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300", + }; + return {priority}; +} + +function CollapsibleSection({ title, defaultOpen = true, children }: { title: string; defaultOpen?: boolean; children: React.ReactNode }) { + const [open, setOpen] = useState(defaultOpen); + return ( +
+ + {open &&
{children}
} +
+ ); +} + +export function TaxonomyTab({ runId, attemptRunId }: { runId: string; attemptRunId?: string }) { + const url = attemptRunId ? api.runTaxonomyUrl(runId, attemptRunId) : api.taxonomyUrl(runId); + + const { data: taxonomy, isLoading, error } = useQuery({ + queryKey: ["run-taxonomy", runId, attemptRunId], + queryFn: async () => { + const res = await fetch(url); + if (!res.ok) { + if (res.status === 404) return null; + throw new Error(`Failed to fetch taxonomy: ${res.statusText}`); + } + return res.json(); + }, + }); + + if (isLoading) { + return ( +
+ + Loading taxonomy… +
+ ); + } + + if (error) { + return ( +
+

Failed to load taxonomy: {(error as Error).message}

+
+ ); + } + + if (!taxonomy) { + return ( +
+

Taxonomy not yet generated for this run.

+

It will appear here once post-processing completes.

+
+ ); + } + + const { meta, scorecard, behaviorAnalysis, actionList } = taxonomy; + + return ( +
+ {/* Header with download */} +
+
+

Run Taxonomy

+ {meta?.generatedAt && ( +

+ Generated {new Date(meta.generatedAt).toLocaleString()} + {meta.model && <> · Model: {meta.model}} +

+ )} +
+ +
+ + {/* Scorecard */} + {scorecard && ( + + + + Scorecard + {scorecard.overall !== undefined && ( + {scorecard.overall}% + )} + + + + {scorecard.dimensions?.map((dim) => ( +
+
+ {dim.name} +
+ +

{dim.justification}

+
+ ))} +
+
+ )} + + {/* Behavior Analysis */} + {behaviorAnalysis?.patterns && behaviorAnalysis.patterns.length > 0 && ( + + + + Behavior Analysis ({behaviorAnalysis.patterns.length}) + + + + +
+ {behaviorAnalysis.patterns.map((pattern) => ( +
+
+ {pattern.category} + +
+

{pattern.observation}

+ {pattern.evidence.length > 0 && ( +
    + {pattern.evidence.map((e, i) =>
  • {e}
  • )} +
+ )} +
+ ))} +
+
+
+
+ )} + + {/* Action List */} + {actionList?.items && actionList.items.length > 0 && ( + + + + Action Items ({actionList.items.length}) + + + + +
+ {actionList.items.map((item) => ( +
+ +
+

{item.title}

+

{item.description}

+ {item.category} +
+
+ ))} +
+
+
+
+ )} + + {/* Raw JSON fallback — collapsed by default */} + +
+          {JSON.stringify(taxonomy, null, 2)}
+        
+
+
+ ); +} diff --git a/apps/portal/src/lib/api.ts b/apps/portal/src/lib/api.ts index ea16aeabb..144b41c73 100644 --- a/apps/portal/src/lib/api.ts +++ b/apps/portal/src/lib/api.ts @@ -217,6 +217,16 @@ export const api = { return `${BASE}/requests/${id}/atif${qs}`; }, + /** Get taxonomy JSON URL for a request (latest run) */ + taxonomyUrl: (id: string): string => { + return `${BASE}/requests/${id}/taxonomy`; + }, + + /** Get taxonomy JSON URL for a specific attempt */ + runTaxonomyUrl: (requestId: string, runId: string): string => { + return `${BASE}/requests/${requestId}/runs/${runId}/taxonomy`; + }, + /** Download a batch archive of multiple runs as a single .tar.gz */ batchArchive: async (ids: string[]): Promise => { const resp = await fetch(`${BASE}/requests/archive`, { diff --git a/apps/portal/src/pages/RunDetail.tsx b/apps/portal/src/pages/RunDetail.tsx index ee42b2467..789e74743 100644 --- a/apps/portal/src/pages/RunDetail.tsx +++ b/apps/portal/src/pages/RunDetail.tsx @@ -24,6 +24,7 @@ import { CriteriaGraphView } from "@/components/CriteriaGraphView"; import { HarNetworkViewer } from "@/components/HarNetworkViewer"; import { ConversationView } from "@/components/ConversationView"; import { VideoPlayer } from "@/components/VideoPlayer"; +import { TaxonomyTab } from "@/components/TaxonomyTab"; import { useLogStream } from "@/hooks/use-log-stream"; import { useAllTurnsToolCalls } from "@/hooks/useHarExtraction"; import { MarkdownRenderer } from "@/components/MarkdownRenderer"; @@ -325,6 +326,7 @@ export function RunDetail() { } const hasHarData = !!(activeRun?.harUrl || activeRun?.turns?.some(t => t.harUrl)); + const hasTaxonomy = !!activeRun?.taxonomyUrl; const hasVideoData = !!(activeRun?.videoUrls?.length || activeRun?.setupVideoUrls?.length || activeRun?.turns?.some(t => t.videoUrls?.length)); const videoCount = (activeRun?.setupVideoUrls?.length ?? 0) + (activeRun?.videoUrls?.length ?? 0) @@ -652,6 +654,7 @@ export function RunDetail() { )} {hasHarData && Network} {hasHarData && Tool Calls} + {hasTaxonomy && Taxonomy} {hasVideoData && } Logs @@ -684,6 +687,13 @@ export function RunDetail() { )} + {/* Taxonomy tab — structured scorecard + behavior analysis + action items */} + {hasTaxonomy && ( + + + + )} + {/* Video tab — session recording player */} {hasVideoData && ( diff --git a/apps/portal/src/types.ts b/apps/portal/src/types.ts index 7b7e4c0f2..019f77a9f 100644 --- a/apps/portal/src/types.ts +++ b/apps/portal/src/types.ts @@ -112,6 +112,7 @@ export interface RunState { resumedAt?: string; postProcessorVersion?: number; postProcessorStatus?: "queued" | "processing" | "done" | "failed"; + taxonomyUrl?: string; } export interface Run { From 3756ccc2f7ac6646a1e11167aa8f3cbf233caccf Mon Sep 17 00:00:00 2001 From: Cedric Vidal Date: Tue, 9 Jun 2026 10:13:14 -0700 Subject: [PATCH 05/19] refactor: move handler registration from migration to worker registration jobs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each post-processing handler now owns its own service entry registration via a register-version.ts script (run as K8s Job on deploy): - pp-atif: apps/workers/post-processor/src/register-version.ts - pp-taxonomy: apps/workers/taxonomy/src/register-version.ts - pp-report: apps/workers/report-generator/src/register-version.ts Migration 018 is reduced to only cleaning up the legacy 'post-processor' entry. Each handler defines its own dependencies (dependsOn), queue, version, and selector — decoupling the DAG topology from a single migration. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../post-processor/src/register-version.ts | 21 +++-- apps/workers/report-generator/package.json | 3 +- .../report-generator/src/register-version.ts | 51 ++++++++++++ apps/workers/taxonomy/package.json | 3 +- apps/workers/taxonomy/src/register-version.ts | 51 ++++++++++++ docker-compose.dev.yml | 8 ++ docker-compose.yml | 41 ++++++++-- .../018-migrate-services-to-handler-dag.ts | 81 +++---------------- pnpm-lock.yaml | 6 ++ 9 files changed, 183 insertions(+), 82 deletions(-) create mode 100644 apps/workers/report-generator/src/register-version.ts create mode 100644 apps/workers/taxonomy/src/register-version.ts diff --git a/apps/workers/post-processor/src/register-version.ts b/apps/workers/post-processor/src/register-version.ts index 6eb0ca889..1d3e062e3 100644 --- a/apps/workers/post-processor/src/register-version.ts +++ b/apps/workers/post-processor/src/register-version.ts @@ -3,8 +3,9 @@ /** * Standalone script run by the K8s Job on each deployment. - * Writes the current post-processor version to MongoDB so the scheduler - * can detect runs needing (re-)processing. + * Registers the post-processor (pp-atif handler) in the services collection + * with its version, queue, and dependency information so the scheduler + * can orchestrate the DAG-based post-processing pipeline. */ import "dotenv/config"; import { MongoClient } from "mongodb"; @@ -24,13 +25,23 @@ async function main(): Promise { .db(MONGO_DATABASE) .collection("services") .updateOne( - { _id: "post-processor" } as any, - { $set: { version: POST_PROCESSOR_VERSION, updatedAt: new Date() } }, + { _id: "pp-atif" } as any, + { + $set: { + type: "post-process-handler", + version: POST_PROCESSOR_VERSION, + queue: "post-processor-queue", + selector: "atif", + autoBackfill: true, + dependsOn: [], + updatedAt: new Date(), + }, + }, { upsert: true }, ); await mongo.close(); - console.log(`[register-version] Registered post-processor version: ${POST_PROCESSOR_VERSION}`); + console.log(`[register-version] Registered pp-atif handler version: ${POST_PROCESSOR_VERSION}`); } main().catch((err) => { diff --git a/apps/workers/report-generator/package.json b/apps/workers/report-generator/package.json index d296fc738..367bdb307 100644 --- a/apps/workers/report-generator/package.json +++ b/apps/workers/report-generator/package.json @@ -13,7 +13,8 @@ "shared": "workspace:*", "@azure/storage-queue": "^12.25.0", "@github/copilot-sdk": "^0.1.17", - "dotenv": "^16.4.5" + "dotenv": "^16.4.5", + "mongodb": "^6.10.0" }, "devDependencies": { "@types/node": "^22.7.0", diff --git a/apps/workers/report-generator/src/register-version.ts b/apps/workers/report-generator/src/register-version.ts new file mode 100644 index 000000000..ed423546b --- /dev/null +++ b/apps/workers/report-generator/src/register-version.ts @@ -0,0 +1,51 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * Standalone script run by the K8s Job on each deployment. + * Registers the report-generator handler in the services collection + * with its version, queue, and dependency information so the scheduler + * can orchestrate the DAG-based post-processing pipeline. + */ +import "dotenv/config"; +import { MongoClient } from "mongodb"; + +const REPORT_HANDLER_VERSION = 1; + +const MONGO_URI = + process.env.MONGO_CONNECTION_STRING || + process.env.AZURE_COSMOS_CONNECTION_STRING || + "mongodb://localhost:27017"; +const MONGO_DATABASE = process.env.MONGO_DATABASE || "requests-db"; + +async function main(): Promise { + const mongo = new MongoClient(MONGO_URI); + await mongo.connect(); + + await mongo + .db(MONGO_DATABASE) + .collection("services") + .updateOne( + { _id: "pp-report" } as any, + { + $set: { + type: "post-process-handler", + version: REPORT_HANDLER_VERSION, + queue: "report-queue", + selector: "report", + autoBackfill: false, + dependsOn: ["pp-atif", "pp-taxonomy"], + updatedAt: new Date(), + }, + }, + { upsert: true }, + ); + + await mongo.close(); + console.log(`[register-version] Registered pp-report handler version: ${REPORT_HANDLER_VERSION}`); +} + +main().catch((err) => { + console.error("[register-version] Fatal error:", err); + process.exit(1); +}); diff --git a/apps/workers/taxonomy/package.json b/apps/workers/taxonomy/package.json index aacbfc4f1..1f496a50c 100644 --- a/apps/workers/taxonomy/package.json +++ b/apps/workers/taxonomy/package.json @@ -13,7 +13,8 @@ "shared": "workspace:*", "@azure/storage-queue": "^12.25.0", "@github/copilot-sdk": "^0.1.17", - "dotenv": "^16.4.5" + "dotenv": "^16.4.5", + "mongodb": "^6.10.0" }, "devDependencies": { "@types/node": "^22.7.0", diff --git a/apps/workers/taxonomy/src/register-version.ts b/apps/workers/taxonomy/src/register-version.ts new file mode 100644 index 000000000..e30425c35 --- /dev/null +++ b/apps/workers/taxonomy/src/register-version.ts @@ -0,0 +1,51 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * Standalone script run by the K8s Job on each deployment. + * Registers the taxonomy handler in the services collection + * with its version, queue, and dependency information so the scheduler + * can orchestrate the DAG-based post-processing pipeline. + */ +import "dotenv/config"; +import { MongoClient } from "mongodb"; + +const TAXONOMY_HANDLER_VERSION = 1; + +const MONGO_URI = + process.env.MONGO_CONNECTION_STRING || + process.env.AZURE_COSMOS_CONNECTION_STRING || + "mongodb://localhost:27017"; +const MONGO_DATABASE = process.env.MONGO_DATABASE || "requests-db"; + +async function main(): Promise { + const mongo = new MongoClient(MONGO_URI); + await mongo.connect(); + + await mongo + .db(MONGO_DATABASE) + .collection("services") + .updateOne( + { _id: "pp-taxonomy" } as any, + { + $set: { + type: "post-process-handler", + version: TAXONOMY_HANDLER_VERSION, + queue: "pp-taxonomy-queue", + selector: "taxonomy", + autoBackfill: false, + dependsOn: ["pp-atif"], + updatedAt: new Date(), + }, + }, + { upsert: true }, + ); + + await mongo.close(); + console.log(`[register-version] Registered pp-taxonomy handler version: ${TAXONOMY_HANDLER_VERSION}`); +} + +main().catch((err) => { + console.error("[register-version] Fatal error:", err); + process.exit(1); +}); diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index c571a727d..bdfd84780 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -200,6 +200,14 @@ services: build: target: dev + register-version-taxonomy: + build: + target: dev + + register-version-report-generator: + build: + target: dev + post-processor: build: target: dev diff --git a/docker-compose.yml b/docker-compose.yml index a4503d104..9b56ad1b0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -140,7 +140,7 @@ services: timeout: 5s retries: 5 - # Creates the 'har' blob container and handler queues in Azurite. + # Creates the 'har' blob container in Azurite. azurite-init: image: mcr.microsoft.com/azure-cli:2.68.0 restart: "no" @@ -148,12 +148,9 @@ services: azurite: condition: service_healthy environment: - AZURE_STORAGE_CONNECTION_STRING: "DefaultEndpointsProtocol=http;AccountName=devstoreaccount1;AccountKey=Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==;QueueEndpoint=http://azurite:10001/devstoreaccount1;BlobEndpoint=http://azurite:10000/devstoreaccount1;" + AZURE_STORAGE_CONNECTION_STRING: "DefaultEndpointsProtocol=http;AccountName=devstoreaccount1;AccountKey=Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==;BlobEndpoint=http://azurite:10000/devstoreaccount1;" command: > - sh -c "az storage container create --name har --connection-string \"$$AZURE_STORAGE_CONNECTION_STRING\" || true && - az storage queue create --name report-queue --connection-string \"$$AZURE_STORAGE_CONNECTION_STRING\" || true && - az storage queue create --name post-processor-queue --connection-string \"$$AZURE_STORAGE_CONNECTION_STRING\" || true && - az storage queue create --name pp-taxonomy-queue --connection-string \"$$AZURE_STORAGE_CONNECTION_STRING\" || true" + sh -c "az storage container create --name har --connection-string \"$$AZURE_STORAGE_CONNECTION_STRING\" || true" # =========================================================================== # Application services @@ -642,6 +639,8 @@ services: condition: service_healthy api: condition: service_started + register-version-report-generator: + condition: service_completed_successfully taxonomy-handler: build: @@ -670,6 +669,36 @@ services: condition: service_started scheduler: condition: service_started + register-version-taxonomy: + condition: service_completed_successfully + + register-version-taxonomy: + build: + context: . + dockerfile: apps/workers/taxonomy/Dockerfile + profiles: [taxonomy, all] + environment: + <<: *common-env + depends_on: + mongodb: + condition: service_healthy + required: false + restart: "no" + entrypoint: ["npx", "tsx", "apps/workers/taxonomy/src/register-version.ts"] + + register-version-report-generator: + build: + context: . + dockerfile: apps/workers/report-generator/Dockerfile + profiles: [report] + environment: + <<: *common-env + depends_on: + mongodb: + condition: service_healthy + required: false + restart: "no" + entrypoint: ["npx", "tsx", "apps/workers/report-generator/src/register-version.ts"] register-version-post-processor: build: diff --git a/packages/db-migrations/src/migrations/018-migrate-services-to-handler-dag.ts b/packages/db-migrations/src/migrations/018-migrate-services-to-handler-dag.ts index 9ea8644fe..b82b4335c 100644 --- a/packages/db-migrations/src/migrations/018-migrate-services-to-handler-dag.ts +++ b/packages/db-migrations/src/migrations/018-migrate-services-to-handler-dag.ts @@ -4,81 +4,24 @@ import type { Db } from "mongodb"; import type { MigrationInterface } from "mongo-migrate-ts"; -interface ServiceDocument { - _id: string; - version: number; - type?: "post-process-handler"; - queue?: string; - selector?: string; - autoBackfill?: boolean; - dependsOn?: string[]; -} - -type HandlerServiceSeed = Required & { - type: "post-process-handler"; -}; - -const HANDLER_SERVICES: readonly HandlerServiceSeed[] = [ - { - _id: "pp-atif", - type: "post-process-handler", - version: 2, - queue: "post-processor-queue", - selector: "atif", - autoBackfill: true, - dependsOn: [], - }, - { - _id: "pp-taxonomy", - type: "post-process-handler", - version: 1, - queue: "pp-taxonomy-queue", - selector: "taxonomy", - autoBackfill: false, - dependsOn: ["pp-atif"], - }, - { - _id: "pp-report", - type: "post-process-handler", - version: 1, - queue: "report-queue", - selector: "report", - autoBackfill: false, - dependsOn: ["pp-atif", "pp-taxonomy"], - }, -]; - +/** + * Migration 018: Remove the legacy "post-processor" service entry. + * + * Handler service registration (pp-atif, pp-taxonomy, pp-report) is now owned + * by each worker's register-version.ts script, run as a K8s Job on deploy. + * This migration only cleans up the old single-entry format. + */ export class MigrateServicesToHandlerDag implements MigrationInterface { async up(db: Db): Promise { - const services = db.collection("services"); - - await services.deleteOne({ _id: "post-processor" }); - console.log("[018] Deleted legacy post-processor service entry if it existed"); - - for (const service of HANDLER_SERVICES) { - const { _id, ...fields } = service; - await services.updateOne( - { _id }, - { - $set: { - ...fields, - dependsOn: [...fields.dependsOn], - }, - }, - { upsert: true }, - ); - console.log(`[018] Upserted handler service ${_id}`); - } + const services = db.collection("services"); + await services.deleteOne({ _id: "post-processor" } as any); + console.log("[018] Deleted legacy post-processor service entry (handler registration now owned by workers)"); } async down(db: Db): Promise { - const services = db.collection("services"); - - await services.deleteMany({ _id: { $in: HANDLER_SERVICES.map((service) => service._id) } }); - console.log("[018-down] Deleted handler service entries"); - + const services = db.collection("services"); await services.updateOne( - { _id: "post-processor" }, + { _id: "post-processor" } as any, { $set: { version: 2 }, $unset: { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a7903fc00..6f1c765b7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -691,6 +691,9 @@ importers: dotenv: specifier: ^16.4.5 version: 16.6.1 + mongodb: + specifier: ^6.10.0 + version: 6.21.0 shared: specifier: workspace:* version: link:../../../packages/shared @@ -716,6 +719,9 @@ importers: dotenv: specifier: ^16.4.5 version: 16.6.1 + mongodb: + specifier: ^6.10.0 + version: 6.21.0 shared: specifier: workspace:* version: link:../../../packages/shared From d02ec6863a4d9d67a9c9f9410ec66487adb7c734 Mon Sep 17 00:00:00 2001 From: Cedric Vidal Date: Tue, 9 Jun 2026 11:57:43 -0700 Subject: [PATCH 06/19] refactor: YAML-driven handler registration via scheduler endpoint Replace the three inlined register-version.ts scripts (which wrote handler metadata directly to MongoDB) with a declarative, YAML-driven approach that mirrors the coding-agent registration pattern. - Add per-worker handler.yaml (pp-atif, pp-taxonomy, pp-report) as the single source of truth for handler metadata. - Add generic packages/shared/src/scripts/register-handler.ts that reads HANDLER_YAML_PATH, waits for the scheduler /health, and POSTs to POST /handlers/register with retry. - Add scheduler POST /handlers/register endpoint (notify-routes.ts) + HandlerDispatcher.registerHandler (cycle-checked upsert). Scheduler is now the sole writer of the services collection. - Remove the 3 register-version.ts scripts and the mongodb dep from the taxonomy + report-generator workers. - Wire compose (register-handler-* services) and K8s (scheduler Service, register-handler-post-processor Job, SCHEDULER_URL, /health probes). - Update docs/architecture/post-processing.md. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- apps/scheduler/src/handler-dispatcher.ts | 27 +++ apps/scheduler/src/notify-routes.test.ts | 55 ++++++ apps/scheduler/src/notify-routes.ts | 57 ++++++ apps/workers/post-processor/Dockerfile | 2 + apps/workers/post-processor/handler.yaml | 10 ++ .../post-processor/src/register-version.ts | 50 ------ apps/workers/report-generator/Dockerfile | 2 + apps/workers/report-generator/handler.yaml | 12 ++ apps/workers/report-generator/package.json | 3 +- .../report-generator/src/register-version.ts | 51 ------ apps/workers/taxonomy/Dockerfile | 2 + apps/workers/taxonomy/handler.yaml | 11 ++ apps/workers/taxonomy/package.json | 3 +- apps/workers/taxonomy/src/register-version.ts | 51 ------ docker-compose.dev.yml | 6 +- docker-compose.yml | 39 ++-- docs/architecture/post-processing.md | 39 +++- .../src/scripts/register-handler.test.ts | 54 ++++++ .../shared/src/scripts/register-handler.ts | 170 ++++++++++++++++++ pnpm-lock.yaml | 6 - 20 files changed, 464 insertions(+), 186 deletions(-) create mode 100644 apps/workers/post-processor/handler.yaml delete mode 100644 apps/workers/post-processor/src/register-version.ts create mode 100644 apps/workers/report-generator/handler.yaml delete mode 100644 apps/workers/report-generator/src/register-version.ts create mode 100644 apps/workers/taxonomy/handler.yaml delete mode 100644 apps/workers/taxonomy/src/register-version.ts create mode 100644 packages/shared/src/scripts/register-handler.test.ts create mode 100644 packages/shared/src/scripts/register-handler.ts diff --git a/apps/scheduler/src/handler-dispatcher.ts b/apps/scheduler/src/handler-dispatcher.ts index 2c4f1b99f..09b461def 100644 --- a/apps/scheduler/src/handler-dispatcher.ts +++ b/apps/scheduler/src/handler-dispatcher.ts @@ -107,6 +107,33 @@ export class HandlerDispatcher implements NotifyHandler { } } + async registerHandler(doc: HandlerServiceDocument): Promise { + // Validate the resulting topology has no cycles before persisting. + const existing = await this.loadHandlers(); + const merged = [ + ...existing.filter((h) => h._id !== doc._id), + doc, + ]; + // Throws on cycle — rejects invalid registrations. + this.buildGraph(merged); + + const { _id, ...fields } = doc; + await this.db.collection("services").updateOne( + { _id } as any, + { + $set: { + ...fields, + dependsOn: [...fields.dependsOn], + updatedAt: new Date(), + }, + } as any, + { upsert: true }, + ); + console.log( + `[HandlerDispatcher] Registered handler ${_id} (version=${doc.version}, queue=${doc.queue}, dependsOn=[${doc.dependsOn.join(", ")}])`, + ); + } + // ── Poll safety net ───────────────────────────────────────────────── private async pollDispatch(): Promise { diff --git a/apps/scheduler/src/notify-routes.test.ts b/apps/scheduler/src/notify-routes.test.ts index 552f03243..00968ba29 100644 --- a/apps/scheduler/src/notify-routes.test.ts +++ b/apps/scheduler/src/notify-routes.test.ts @@ -52,6 +52,7 @@ describe("createHttpServer", () => { const handler: NotifyHandler = { onRunTerminal: vi.fn().mockResolvedValue(undefined), onHandlerComplete: vi.fn().mockResolvedValue(undefined), + registerHandler: vi.fn().mockResolvedValue(undefined), }; const { server, baseUrl } = await startServer(handler); servers.push(server); @@ -66,6 +67,7 @@ describe("createHttpServer", () => { const handler: NotifyHandler = { onRunTerminal: vi.fn().mockResolvedValue(undefined), onHandlerComplete: vi.fn().mockResolvedValue(undefined), + registerHandler: vi.fn().mockResolvedValue(undefined), }; const { server, baseUrl } = await startServer(handler); servers.push(server); @@ -86,6 +88,7 @@ describe("createHttpServer", () => { const handler: NotifyHandler = { onRunTerminal: vi.fn().mockResolvedValue(undefined), onHandlerComplete: vi.fn().mockResolvedValue(undefined), + registerHandler: vi.fn().mockResolvedValue(undefined), }; const { server, baseUrl } = await startServer(handler); servers.push(server); @@ -111,6 +114,7 @@ describe("createHttpServer", () => { const handler: NotifyHandler = { onRunTerminal: vi.fn().mockResolvedValue(undefined), onHandlerComplete: vi.fn().mockResolvedValue(undefined), + registerHandler: vi.fn().mockResolvedValue(undefined), }; const { server, baseUrl } = await startServer(handler); servers.push(server); @@ -133,6 +137,7 @@ describe("createHttpServer", () => { const handler: NotifyHandler = { onRunTerminal: vi.fn().mockResolvedValue(undefined), onHandlerComplete: vi.fn().mockResolvedValue(undefined), + registerHandler: vi.fn().mockResolvedValue(undefined), }; const { server, baseUrl } = await startServer(handler); servers.push(server); @@ -146,4 +151,54 @@ describe("createHttpServer", () => { expect(response.status).toBe(404); expect(await response.json()).toEqual({ error: "Not found" }); }); + + it("calls registerHandler for valid handler registrations", async () => { + const handler: NotifyHandler = { + onRunTerminal: vi.fn().mockResolvedValue(undefined), + onHandlerComplete: vi.fn().mockResolvedValue(undefined), + registerHandler: vi.fn().mockResolvedValue(undefined), + }; + const { server, baseUrl } = await startServer(handler); + servers.push(server); + + const doc = { + _id: "pp-taxonomy", + type: "post-process-handler", + version: 1, + queue: "pp-taxonomy-queue", + selector: "taxonomy", + autoBackfill: false, + dependsOn: ["pp-atif"], + }; + + const response = await fetch(`${baseUrl}/handlers/register`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(doc), + }); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ ok: true, handlerId: "pp-taxonomy" }); + expect(handler.registerHandler).toHaveBeenCalledWith(doc); + }); + + it("returns 400 for invalid handler registrations", async () => { + const handler: NotifyHandler = { + onRunTerminal: vi.fn().mockResolvedValue(undefined), + onHandlerComplete: vi.fn().mockResolvedValue(undefined), + registerHandler: vi.fn().mockResolvedValue(undefined), + }; + const { server, baseUrl } = await startServer(handler); + servers.push(server); + + const response = await fetch(`${baseUrl}/handlers/register`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + // Missing queue/selector, wrong type, dependsOn not array + body: JSON.stringify({ _id: "bad", type: "wrong", version: "x" }), + }); + + expect(response.status).toBe(400); + expect(handler.registerHandler).not.toHaveBeenCalled(); + }); }); diff --git a/apps/scheduler/src/notify-routes.ts b/apps/scheduler/src/notify-routes.ts index 445945d2d..577478b64 100644 --- a/apps/scheduler/src/notify-routes.ts +++ b/apps/scheduler/src/notify-routes.ts @@ -2,6 +2,7 @@ // Licensed under the MIT License. import http from "node:http"; +import type { HandlerServiceDocument } from "shared"; export interface NotifyHandler { onRunTerminal(requestId: string, runId: string): Promise; @@ -11,6 +12,7 @@ export interface NotifyHandler { handlerId: string, status: "done" | "failed", ): Promise; + registerHandler(doc: HandlerServiceDocument): Promise; } interface RunTerminalRequest { @@ -75,6 +77,46 @@ function parseHandlerCompleteRequest(body: unknown): HandlerCompleteRequest | nu }; } +function parseHandlerRegisterRequest(body: unknown): HandlerServiceDocument | null { + if (!isRecord(body)) { + return null; + } + + const { + _id, + type, + version, + queue, + selector, + autoBackfill, + dependsOn, + } = body; + + if ( + !isNonEmptyString(_id) || + type !== "post-process-handler" || + typeof version !== "number" || + !Number.isFinite(version) || + !isNonEmptyString(queue) || + !isNonEmptyString(selector) || + typeof autoBackfill !== "boolean" || + !Array.isArray(dependsOn) || + !dependsOn.every((d) => isNonEmptyString(d)) + ) { + return null; + } + + return { + _id, + type, + version, + queue, + selector, + autoBackfill, + dependsOn: dependsOn as string[], + }; +} + async function readJsonBody(req: http.IncomingMessage): Promise { const chunks: Buffer[] = []; @@ -131,6 +173,21 @@ export function createHttpServer(handler: NotifyHandler): http.Server { sendJson(res, 200, { ok: true }); return; } + + if (method === "POST" && url.pathname === "/handlers/register") { + const parsedBody = parseHandlerRegisterRequest(await readJsonBody(req)); + if (!parsedBody) { + sendJson(res, 400, { + error: + "Invalid handler registration: _id, type='post-process-handler', version (number), queue, selector, autoBackfill (boolean), and dependsOn (string[]) are required", + }); + return; + } + + await handler.registerHandler(parsedBody); + sendJson(res, 200, { ok: true, handlerId: parsedBody._id }); + return; + } } catch (error) { const message = error instanceof Error ? error.message : "Internal server error"; const statusCode = diff --git a/apps/workers/post-processor/Dockerfile b/apps/workers/post-processor/Dockerfile index d69ca9cb1..6d6bf8e04 100644 --- a/apps/workers/post-processor/Dockerfile +++ b/apps/workers/post-processor/Dockerfile @@ -15,6 +15,7 @@ COPY packages/shared/tsconfig.json ./packages/shared/ COPY packages/shared/src ./packages/shared/src/ COPY apps/workers/post-processor/tsconfig.json ./apps/workers/post-processor/ COPY apps/workers/post-processor/src ./apps/workers/post-processor/src/ +COPY apps/workers/post-processor/handler.yaml ./apps/workers/post-processor/ RUN pnpm --filter shared build && pnpm --filter post-processor build @@ -43,6 +44,7 @@ RUN pnpm install --frozen-lockfile --prod COPY --from=builder /app/packages/shared/dist ./packages/shared/dist/ COPY --from=builder /app/apps/workers/post-processor/dist ./apps/workers/post-processor/dist/ +COPY --from=builder /app/apps/workers/post-processor/handler.yaml ./apps/workers/post-processor/handler.yaml WORKDIR /app/apps/workers/post-processor diff --git a/apps/workers/post-processor/handler.yaml b/apps/workers/post-processor/handler.yaml new file mode 100644 index 000000000..dc3c1f348 --- /dev/null +++ b/apps/workers/post-processor/handler.yaml @@ -0,0 +1,10 @@ +# Post-process handler registration metadata. +# Read by the generic register-handler script and POSTed to the +# scheduler's POST /handlers/register endpoint on deploy. +_id: pp-atif +type: post-process-handler +version: 1 +queue: post-processor-queue +selector: atif +autoBackfill: true +dependsOn: [] diff --git a/apps/workers/post-processor/src/register-version.ts b/apps/workers/post-processor/src/register-version.ts deleted file mode 100644 index 1d3e062e3..000000000 --- a/apps/workers/post-processor/src/register-version.ts +++ /dev/null @@ -1,50 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -/** - * Standalone script run by the K8s Job on each deployment. - * Registers the post-processor (pp-atif handler) in the services collection - * with its version, queue, and dependency information so the scheduler - * can orchestrate the DAG-based post-processing pipeline. - */ -import "dotenv/config"; -import { MongoClient } from "mongodb"; -import { POST_PROCESSOR_VERSION } from "./version.js"; - -const MONGO_URI = - process.env.MONGO_CONNECTION_STRING || - process.env.AZURE_COSMOS_CONNECTION_STRING || - "mongodb://localhost:27017"; -const MONGO_DATABASE = process.env.MONGO_DATABASE || "requests-db"; - -async function main(): Promise { - const mongo = new MongoClient(MONGO_URI); - await mongo.connect(); - - await mongo - .db(MONGO_DATABASE) - .collection("services") - .updateOne( - { _id: "pp-atif" } as any, - { - $set: { - type: "post-process-handler", - version: POST_PROCESSOR_VERSION, - queue: "post-processor-queue", - selector: "atif", - autoBackfill: true, - dependsOn: [], - updatedAt: new Date(), - }, - }, - { upsert: true }, - ); - - await mongo.close(); - console.log(`[register-version] Registered pp-atif handler version: ${POST_PROCESSOR_VERSION}`); -} - -main().catch((err) => { - console.error("[register-version] Fatal error:", err); - process.exit(1); -}); diff --git a/apps/workers/report-generator/Dockerfile b/apps/workers/report-generator/Dockerfile index 3cfd18150..62a5f2646 100644 --- a/apps/workers/report-generator/Dockerfile +++ b/apps/workers/report-generator/Dockerfile @@ -15,6 +15,7 @@ COPY packages/shared/tsconfig.json ./packages/shared/ COPY packages/shared/src ./packages/shared/src/ COPY apps/workers/report-generator/tsconfig.json ./apps/workers/report-generator/ COPY apps/workers/report-generator/src ./apps/workers/report-generator/src/ +COPY apps/workers/report-generator/handler.yaml ./apps/workers/report-generator/ RUN pnpm --filter shared build && pnpm --filter report-generator build @@ -43,6 +44,7 @@ RUN pnpm install --frozen-lockfile --prod COPY --from=builder /app/packages/shared/dist ./packages/shared/dist/ COPY --from=builder /app/apps/workers/report-generator/dist ./apps/workers/report-generator/dist/ +COPY --from=builder /app/apps/workers/report-generator/handler.yaml ./apps/workers/report-generator/handler.yaml WORKDIR /app/apps/workers/report-generator diff --git a/apps/workers/report-generator/handler.yaml b/apps/workers/report-generator/handler.yaml new file mode 100644 index 000000000..f7e14d83e --- /dev/null +++ b/apps/workers/report-generator/handler.yaml @@ -0,0 +1,12 @@ +# Post-process handler registration metadata. +# Read by the generic register-handler script and POSTed to the +# scheduler's POST /handlers/register endpoint on deploy. +_id: pp-report +type: post-process-handler +version: 1 +queue: report-queue +selector: report +autoBackfill: false +dependsOn: + - pp-atif + - pp-taxonomy diff --git a/apps/workers/report-generator/package.json b/apps/workers/report-generator/package.json index 367bdb307..d296fc738 100644 --- a/apps/workers/report-generator/package.json +++ b/apps/workers/report-generator/package.json @@ -13,8 +13,7 @@ "shared": "workspace:*", "@azure/storage-queue": "^12.25.0", "@github/copilot-sdk": "^0.1.17", - "dotenv": "^16.4.5", - "mongodb": "^6.10.0" + "dotenv": "^16.4.5" }, "devDependencies": { "@types/node": "^22.7.0", diff --git a/apps/workers/report-generator/src/register-version.ts b/apps/workers/report-generator/src/register-version.ts deleted file mode 100644 index ed423546b..000000000 --- a/apps/workers/report-generator/src/register-version.ts +++ /dev/null @@ -1,51 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -/** - * Standalone script run by the K8s Job on each deployment. - * Registers the report-generator handler in the services collection - * with its version, queue, and dependency information so the scheduler - * can orchestrate the DAG-based post-processing pipeline. - */ -import "dotenv/config"; -import { MongoClient } from "mongodb"; - -const REPORT_HANDLER_VERSION = 1; - -const MONGO_URI = - process.env.MONGO_CONNECTION_STRING || - process.env.AZURE_COSMOS_CONNECTION_STRING || - "mongodb://localhost:27017"; -const MONGO_DATABASE = process.env.MONGO_DATABASE || "requests-db"; - -async function main(): Promise { - const mongo = new MongoClient(MONGO_URI); - await mongo.connect(); - - await mongo - .db(MONGO_DATABASE) - .collection("services") - .updateOne( - { _id: "pp-report" } as any, - { - $set: { - type: "post-process-handler", - version: REPORT_HANDLER_VERSION, - queue: "report-queue", - selector: "report", - autoBackfill: false, - dependsOn: ["pp-atif", "pp-taxonomy"], - updatedAt: new Date(), - }, - }, - { upsert: true }, - ); - - await mongo.close(); - console.log(`[register-version] Registered pp-report handler version: ${REPORT_HANDLER_VERSION}`); -} - -main().catch((err) => { - console.error("[register-version] Fatal error:", err); - process.exit(1); -}); diff --git a/apps/workers/taxonomy/Dockerfile b/apps/workers/taxonomy/Dockerfile index 6114e03a8..b6be8f2ad 100644 --- a/apps/workers/taxonomy/Dockerfile +++ b/apps/workers/taxonomy/Dockerfile @@ -15,6 +15,7 @@ COPY packages/shared/tsconfig.json ./packages/shared/ COPY packages/shared/src ./packages/shared/src/ COPY apps/workers/taxonomy/tsconfig.json ./apps/workers/taxonomy/ COPY apps/workers/taxonomy/src ./apps/workers/taxonomy/src/ +COPY apps/workers/taxonomy/handler.yaml ./apps/workers/taxonomy/ RUN pnpm --filter shared build && pnpm --filter taxonomy-handler build @@ -42,6 +43,7 @@ RUN pnpm install --frozen-lockfile --prod COPY --from=builder /app/packages/shared/dist ./packages/shared/dist/ COPY --from=builder /app/apps/workers/taxonomy/dist ./apps/workers/taxonomy/dist/ +COPY --from=builder /app/apps/workers/taxonomy/handler.yaml ./apps/workers/taxonomy/handler.yaml WORKDIR /app/apps/workers/taxonomy diff --git a/apps/workers/taxonomy/handler.yaml b/apps/workers/taxonomy/handler.yaml new file mode 100644 index 000000000..b479f905e --- /dev/null +++ b/apps/workers/taxonomy/handler.yaml @@ -0,0 +1,11 @@ +# Post-process handler registration metadata. +# Read by the generic register-handler script and POSTed to the +# scheduler's POST /handlers/register endpoint on deploy. +_id: pp-taxonomy +type: post-process-handler +version: 1 +queue: pp-taxonomy-queue +selector: taxonomy +autoBackfill: false +dependsOn: + - pp-atif diff --git a/apps/workers/taxonomy/package.json b/apps/workers/taxonomy/package.json index 1f496a50c..aacbfc4f1 100644 --- a/apps/workers/taxonomy/package.json +++ b/apps/workers/taxonomy/package.json @@ -13,8 +13,7 @@ "shared": "workspace:*", "@azure/storage-queue": "^12.25.0", "@github/copilot-sdk": "^0.1.17", - "dotenv": "^16.4.5", - "mongodb": "^6.10.0" + "dotenv": "^16.4.5" }, "devDependencies": { "@types/node": "^22.7.0", diff --git a/apps/workers/taxonomy/src/register-version.ts b/apps/workers/taxonomy/src/register-version.ts deleted file mode 100644 index e30425c35..000000000 --- a/apps/workers/taxonomy/src/register-version.ts +++ /dev/null @@ -1,51 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -/** - * Standalone script run by the K8s Job on each deployment. - * Registers the taxonomy handler in the services collection - * with its version, queue, and dependency information so the scheduler - * can orchestrate the DAG-based post-processing pipeline. - */ -import "dotenv/config"; -import { MongoClient } from "mongodb"; - -const TAXONOMY_HANDLER_VERSION = 1; - -const MONGO_URI = - process.env.MONGO_CONNECTION_STRING || - process.env.AZURE_COSMOS_CONNECTION_STRING || - "mongodb://localhost:27017"; -const MONGO_DATABASE = process.env.MONGO_DATABASE || "requests-db"; - -async function main(): Promise { - const mongo = new MongoClient(MONGO_URI); - await mongo.connect(); - - await mongo - .db(MONGO_DATABASE) - .collection("services") - .updateOne( - { _id: "pp-taxonomy" } as any, - { - $set: { - type: "post-process-handler", - version: TAXONOMY_HANDLER_VERSION, - queue: "pp-taxonomy-queue", - selector: "taxonomy", - autoBackfill: false, - dependsOn: ["pp-atif"], - updatedAt: new Date(), - }, - }, - { upsert: true }, - ); - - await mongo.close(); - console.log(`[register-version] Registered pp-taxonomy handler version: ${TAXONOMY_HANDLER_VERSION}`); -} - -main().catch((err) => { - console.error("[register-version] Fatal error:", err); - process.exit(1); -}); diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index bdfd84780..a2fbe32b5 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -196,15 +196,15 @@ services: - action: rebuild path: ./pnpm-lock.yaml - register-version-post-processor: + register-handler-post-processor: build: target: dev - register-version-taxonomy: + register-handler-taxonomy: build: target: dev - register-version-report-generator: + register-handler-report-generator: build: target: dev diff --git a/docker-compose.yml b/docker-compose.yml index 9b56ad1b0..53cdc0da1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -639,7 +639,7 @@ services: condition: service_healthy api: condition: service_started - register-version-report-generator: + register-handler-report-generator: condition: service_completed_successfully taxonomy-handler: @@ -669,50 +669,53 @@ services: condition: service_started scheduler: condition: service_started - register-version-taxonomy: + register-handler-taxonomy: condition: service_completed_successfully - register-version-taxonomy: + register-handler-taxonomy: build: context: . dockerfile: apps/workers/taxonomy/Dockerfile profiles: [taxonomy, all] environment: <<: *common-env + HANDLER_YAML_PATH: apps/workers/taxonomy/handler.yaml + SCHEDULER_URL: http://scheduler:8080 depends_on: - mongodb: - condition: service_healthy - required: false + scheduler: + condition: service_started restart: "no" - entrypoint: ["npx", "tsx", "apps/workers/taxonomy/src/register-version.ts"] + entrypoint: ["npx", "tsx", "packages/shared/src/scripts/register-handler.ts"] - register-version-report-generator: + register-handler-report-generator: build: context: . dockerfile: apps/workers/report-generator/Dockerfile profiles: [report] environment: <<: *common-env + HANDLER_YAML_PATH: apps/workers/report-generator/handler.yaml + SCHEDULER_URL: http://scheduler:8080 depends_on: - mongodb: - condition: service_healthy - required: false + scheduler: + condition: service_started restart: "no" - entrypoint: ["npx", "tsx", "apps/workers/report-generator/src/register-version.ts"] + entrypoint: ["npx", "tsx", "packages/shared/src/scripts/register-handler.ts"] - register-version-post-processor: + register-handler-post-processor: build: context: . dockerfile: apps/workers/post-processor/Dockerfile profiles: [post-processor] environment: <<: *common-env + HANDLER_YAML_PATH: apps/workers/post-processor/handler.yaml + SCHEDULER_URL: http://scheduler:8080 depends_on: - mongodb: - condition: service_healthy - required: false + scheduler: + condition: service_started restart: "no" - entrypoint: ["npx", "tsx", "apps/workers/post-processor/src/register-version.ts"] + entrypoint: ["npx", "tsx", "packages/shared/src/scripts/register-handler.ts"] post-processor: build: @@ -734,7 +737,7 @@ services: condition: service_healthy api: condition: service_started - register-version-post-processor: + register-handler-post-processor: condition: service_completed_successfully volumes: diff --git a/docs/architecture/post-processing.md b/docs/architecture/post-processing.md index df326d5e4..d16d6294f 100644 --- a/docs/architecture/post-processing.md +++ b/docs/architecture/post-processing.md @@ -16,7 +16,7 @@ Two paths trigger post-processing: 1. **Event-driven (primary):** Coder workers enqueue a post-processing message immediately when a run completes, currently setting the deprecated `run.postProcessorStatus: "queued"` compatibility field atomically in the same DB write while the system migrates to per-handler status. 2. **Scheduler backfill:** The `PostProcessorDispatcher` polls every 30 seconds for runs that have `status: "done"` but no post-processing marker set (or a stale handler version). During the migration window this may consult the deprecated flat fields; the long-term source of truth is `run.handlerStatus[handlerId]`. This catches runs that completed before the event-driven dispatch was deployed, or that need re-processing after a version bump. -3. **Scheduler notify server:** The scheduler also exposes `GET /health`, `POST /notify/run-terminal`, and `POST /notify/handler-complete` on a lightweight `node:http` server. Workers and post-process handlers use these endpoints to notify the scheduler when terminal states are reached so it can re-evaluate which root or downstream handlers are ready to dispatch. +3. **Scheduler notify & registration server:** The scheduler also exposes `GET /health`, `POST /notify/run-terminal`, `POST /notify/handler-complete`, and `POST /handlers/register` on a lightweight `node:http` server. Workers and post-process handlers use the notify endpoints to signal terminal states so the scheduler can re-evaluate which root or downstream handlers are ready to dispatch. The `/handlers/register` endpoint is the single writer to the `services` collection — see [Handler Registration](#handler-registration). ## ATIF Generation @@ -127,6 +127,38 @@ The post-processor uses a version-based re-processing scheme: This allows deploying handler improvements and having them automatically applied to historical data. +## Handler Registration + +Each post-process handler declares its DAG metadata in a `handler.yaml` file colocated with the worker (e.g. `apps/workers/taxonomy/handler.yaml`). This mirrors the coding-agent `agent.yaml` registration pattern. + +```yaml +# apps/workers/taxonomy/handler.yaml +_id: pp-taxonomy +type: post-process-handler +version: 1 +queue: pp-taxonomy-queue +selector: taxonomy +autoBackfill: false +dependsOn: + - pp-atif +``` + +On deploy, a generic registration script — `packages/shared/src/scripts/register-handler.ts` — reads the `handler.yaml` pointed to by `HANDLER_YAML_PATH`, waits for the scheduler's `/health`, and POSTs the parsed document to `${SCHEDULER_URL}/handlers/register` (with retry). The scheduler validates the document, runs a cycle check against the existing topology, and upserts it into the `services` collection via `HandlerDispatcher.registerHandler`. **Workers never write to the `services` collection directly** — the scheduler is the sole owner. + +```mermaid +flowchart LR + YAML[handler.yaml] --> SCRIPT[register-handler.ts] + SCRIPT -->|"POST /handlers/register"| SCH[Scheduler] + SCH -->|"validate + cycle check + upsert"| SVC[(services collection)] +``` + +**Where it runs:** + +- **Docker Compose:** one `register-handler-` init service per handler (`pp-atif`, `pp-taxonomy`, `pp-report`) runs the generic script via `tsx`, gated on the scheduler being up. The worker waits for its registration service to complete. +- **Kubernetes:** a `register-handler-post-processor` Job runs the compiled script (`node packages/shared/dist/scripts/register-handler.js`) on each deploy. Only `pp-atif` is registered in K8s today because it is the only post-process handler deployed to the cluster; `pp-taxonomy` and `pp-report` are registered in Compose only until the taxonomy worker is deployed to K8s and report generation moves onto the DAG. The scheduler exposes a `Service` (`scheduler.scoped.svc.cluster.local:8080`) so Jobs and workers can reach `/handlers/register` and the notify endpoints. + +To **add or change a handler**, edit its `handler.yaml` (bump `version`, adjust `dependsOn`, etc.) — no code or migration changes are needed for registration. + ## Status Lifecycle ```mermaid @@ -157,7 +189,8 @@ stateDiagram-v2 | `SCOPE_MT_API_URL` / `API_BASE_URL` | `http://localhost:3001` | API URL used by report/taxonomy Copilot tools | | `TAXONOMY_MODEL` | `gpt-4.1` | Copilot SDK model used by the taxonomy handler | | `SESSION_TIMEOUT_MS` | `300000` | Timeout for report/taxonomy Copilot SDK sessions | -| `SCHEDULER_URL` | _(unset)_ | Optional scheduler notify base URL for downstream handler dispatch | +| `SCHEDULER_URL` | _(unset)_ | Scheduler base URL — used by handlers to notify completion and by the register-handler script to reach `/handlers/register` | +| `HANDLER_YAML_PATH` | _(unset)_ | Path to a handler's `handler.yaml`, read by the generic `register-handler` script | ## Infrastructure @@ -165,4 +198,4 @@ stateDiagram-v2 - **Blob Storage:** `snapshots` container (shared with HAR, tool-calls, and other artifacts) - **Database:** MongoDB `requests` collection (`run.handlerStatus` map keyed by handler ID, with deprecated `run.postProcessorStatus` / `run.postProcessorVersion` compatibility fields during migration) - **KEDA:** ScaledObject scales the worker to zero when queue is empty -- **Version registration:** Kubernetes Job runs on each deploy; Docker Compose uses an init service \ No newline at end of file +- **Handler registration:** declarative `handler.yaml` per worker → generic `register-handler` script → scheduler `POST /handlers/register` (sole writer to the `services` collection). A Kubernetes Job runs the script on each deploy; Docker Compose uses per-handler init services. See [Handler Registration](#handler-registration). \ No newline at end of file diff --git a/packages/shared/src/scripts/register-handler.test.ts b/packages/shared/src/scripts/register-handler.test.ts new file mode 100644 index 000000000..0c3858b58 --- /dev/null +++ b/packages/shared/src/scripts/register-handler.test.ts @@ -0,0 +1,54 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { describe, expect, it } from "vitest"; +import { parseHandlerDocument } from "./register-handler.js"; + +describe("parseHandlerDocument", () => { + const valid = { + _id: "pp-taxonomy", + type: "post-process-handler", + version: 1, + queue: "pp-taxonomy-queue", + selector: "taxonomy", + autoBackfill: false, + dependsOn: ["pp-atif"], + }; + + it("accepts a valid handler document", () => { + expect(parseHandlerDocument(valid)).toEqual(valid); + }); + + it("defaults dependsOn to an empty array when omitted", () => { + const { dependsOn, ...withoutDeps } = valid; + expect(parseHandlerDocument(withoutDeps).dependsOn).toEqual([]); + }); + + it("rejects a missing _id", () => { + expect(() => parseHandlerDocument({ ...valid, _id: "" })).toThrow(/_id/); + }); + + it("rejects a wrong type", () => { + expect(() => parseHandlerDocument({ ...valid, type: "agent" })).toThrow(/type/); + }); + + it("rejects a non-numeric version", () => { + expect(() => parseHandlerDocument({ ...valid, version: "1" })).toThrow(/version/); + }); + + it("rejects a non-boolean autoBackfill", () => { + expect(() => parseHandlerDocument({ ...valid, autoBackfill: "yes" })).toThrow( + /autoBackfill/, + ); + }); + + it("rejects a dependsOn that is not an array of strings", () => { + expect(() => parseHandlerDocument({ ...valid, dependsOn: [1, 2] })).toThrow( + /dependsOn/, + ); + }); + + it("rejects a non-object input", () => { + expect(() => parseHandlerDocument("nope")).toThrow(/mapping/); + }); +}); diff --git a/packages/shared/src/scripts/register-handler.ts b/packages/shared/src/scripts/register-handler.ts new file mode 100644 index 000000000..c585e9eb1 --- /dev/null +++ b/packages/shared/src/scripts/register-handler.ts @@ -0,0 +1,170 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * Generic post-process handler registration script. + * + * Replaces the per-worker, inlined `register-version.ts` scripts that wrote + * directly to MongoDB. Reads a declarative `handler.yaml` (path from + * HANDLER_YAML_PATH) and POSTs it to the scheduler's + * `POST /handlers/register` endpoint (the scheduler owns the `services` + * collection). Used identically by docker-compose init services and the K8s + * registration Jobs. + * + * Mirrors the coding-agent registration pattern (`agent.yaml` → + * `POST /api/v1/agents`). + * + * Environment: + * HANDLER_YAML_PATH Path to the handler.yaml file (required) + * SCHEDULER_URL Base URL of the scheduler HTTP server, e.g. + * http://scheduler:8080 (required) + */ +import { readFile } from "node:fs/promises"; +import { parse as parseYaml } from "yaml"; +import type { HandlerServiceDocument } from "../types/types.js"; + +const HEALTH_RETRIES = 30; +const HEALTH_DELAY_MS = 3_000; +const POST_RETRIES = 10; +const POST_DELAY_MS = 3_000; + +function isNonEmptyString(value: unknown): value is string { + return typeof value === "string" && value.trim().length > 0; +} + +/** Validate the parsed YAML conforms to the HandlerServiceDocument shape. */ +export function parseHandlerDocument(raw: unknown): HandlerServiceDocument { + if (typeof raw !== "object" || raw === null) { + throw new Error("handler.yaml must contain a mapping"); + } + const doc = raw as Record; + + if (!isNonEmptyString(doc._id)) { + throw new Error("handler.yaml: '_id' is required"); + } + if (doc.type !== "post-process-handler") { + throw new Error("handler.yaml: 'type' must be 'post-process-handler'"); + } + if (typeof doc.version !== "number" || !Number.isFinite(doc.version)) { + throw new Error("handler.yaml: 'version' must be a number"); + } + if (!isNonEmptyString(doc.queue)) { + throw new Error("handler.yaml: 'queue' is required"); + } + if (!isNonEmptyString(doc.selector)) { + throw new Error("handler.yaml: 'selector' is required"); + } + if (typeof doc.autoBackfill !== "boolean") { + throw new Error("handler.yaml: 'autoBackfill' must be a boolean"); + } + const dependsOn = doc.dependsOn ?? []; + if (!Array.isArray(dependsOn) || !dependsOn.every((d) => isNonEmptyString(d))) { + throw new Error("handler.yaml: 'dependsOn' must be an array of strings"); + } + + return { + _id: doc._id, + type: "post-process-handler", + version: doc.version, + queue: doc.queue, + selector: doc.selector, + autoBackfill: doc.autoBackfill, + dependsOn: dependsOn as string[], + }; +} + +async function delay(ms: number): Promise { + await new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function waitForScheduler(schedulerUrl: string): Promise { + for (let attempt = 1; attempt <= HEALTH_RETRIES; attempt++) { + try { + const res = await fetch(`${schedulerUrl}/health`); + if (res.ok) { + return; + } + } catch { + // not ready yet + } + console.log( + `[register-handler] Scheduler not ready (attempt ${attempt}/${HEALTH_RETRIES}), retrying in ${HEALTH_DELAY_MS}ms...`, + ); + await delay(HEALTH_DELAY_MS); + } + throw new Error(`Scheduler did not become healthy at ${schedulerUrl}`); +} + +async function postRegistration( + schedulerUrl: string, + doc: HandlerServiceDocument, +): Promise { + for (let attempt = 1; attempt <= POST_RETRIES; attempt++) { + try { + const res = await fetch(`${schedulerUrl}/handlers/register`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(doc), + }); + if (res.ok) { + return; + } + const body = await res.text(); + console.log( + `[register-handler] Register failed (attempt ${attempt}/${POST_RETRIES}): HTTP ${res.status} ${body}`, + ); + } catch (err) { + console.log( + `[register-handler] Register error (attempt ${attempt}/${POST_RETRIES}): ${ + err instanceof Error ? err.message : String(err) + }`, + ); + } + await delay(POST_DELAY_MS); + } + throw new Error(`Failed to register handler ${doc._id} after ${POST_RETRIES} attempts`); +} + +export async function registerHandlerFromYaml( + yamlPath: string, + schedulerUrl: string, +): Promise { + const content = await readFile(yamlPath, "utf-8"); + const doc = parseHandlerDocument(parseYaml(content)); + + console.log( + `[register-handler] Registering ${doc._id} (version=${doc.version}, queue=${doc.queue}) at ${schedulerUrl}`, + ); + + await waitForScheduler(schedulerUrl); + await postRegistration(schedulerUrl, doc); + + console.log(`[register-handler] Registered ${doc._id} successfully`); + return doc; +} + +async function main(): Promise { + const yamlPath = process.env.HANDLER_YAML_PATH; + const schedulerUrl = process.env.SCHEDULER_URL; + + if (!yamlPath) { + throw new Error("HANDLER_YAML_PATH is required"); + } + if (!schedulerUrl) { + throw new Error("SCHEDULER_URL is required"); + } + + await registerHandlerFromYaml(yamlPath, schedulerUrl.replace(/\/+$/, "")); +} + +// Only run when executed directly (not when imported by tests). +const isDirectRun = + process.argv[1] !== undefined && + import.meta.url === new URL(`file://${process.argv[1]}`).href; + +if (isDirectRun) { + main().catch((err) => { + console.error("[register-handler] Fatal error:", err); + process.exit(1); + }); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6f1c765b7..a7903fc00 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -691,9 +691,6 @@ importers: dotenv: specifier: ^16.4.5 version: 16.6.1 - mongodb: - specifier: ^6.10.0 - version: 6.21.0 shared: specifier: workspace:* version: link:../../../packages/shared @@ -719,9 +716,6 @@ importers: dotenv: specifier: ^16.4.5 version: 16.6.1 - mongodb: - specifier: ^6.10.0 - version: 6.21.0 shared: specifier: workspace:* version: link:../../../packages/shared From 02900bda44f5f43aa8368d72b960b825d5456cdc Mon Sep 17 00:00:00 2001 From: Cedric Vidal Date: Tue, 9 Jun 2026 13:34:08 -0700 Subject: [PATCH 07/19] feat: scheduler triggers report generation when handler DAG drains MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reports were modeled as a DAG handler node (pp-report, dependsOn [pp-atif, pp-taxonomy]), but the report-generator worker is keyed by reportId and requires a templateId, while the DAG dispatches a generic {type, requestId, runId} message — so pp-report could never process a DAG message. Reports are also parameterized, N-per-run, and trigger-gated, so they don't fit the idempotent per-run DAG-node model. Take reports off the DAG. The scheduler now detects when a run's handler DAG has drained (every registered handler terminal: done | failed | blocked-by-failed-dependency) and calls the existing POST /api/v1/reports/trigger endpoint, which evaluates each template's trigger and creates a properly-templated ReportDocument + enqueues {reportId} per match. - HandlerDispatcher: isHandlerTerminal/isDagDrained/maybeTriggerReports/ triggerReports + pollTriggerReports safety net. Exactly-once via an atomic run.reportsTriggeredAt guard, scoped by run._id so a stale notify or concurrent retry can never trigger reports for — or roll back the guard of — a different attempt. Fires on run-terminal (zero-handler / already-drained), both branches of handler-complete (a failure can drain the DAG), and the 30s poll loop. Rolls back the guard on POST failure. - Inject API_URL into the dispatcher; add API_URL to scheduler env in docker-compose.yml and deploy/base/scheduler.yaml. - Remove pp-report as a DAG node: delete report-generator/handler.yaml, its Dockerfile COPY lines, the register-handler-report-generator compose service, the dead idx_handler_dispatch_report index in migration 019, and stale comments. No migration to delete pp-report is needed — nothing is deployed, so a fresh environment never registers it. - Add reportsTriggeredAt to RunState. - Tests: 13 dispatcher tests (drain detection, exactly-once guard, attempt-scoped claim/rollback, stale-runId rejection, POST payload). - Docs: post-processing.md "Report Triggering on DAG Drain" section + ENV_VARIABLES API_URL. Verified E2E: run -> pp-atif(done) -> pp-taxonomy(failed) -> DAG drains -> scheduler POST /reports/trigger -> API matches template -> ReportDocument (templateId) enqueued -> report-generator -> report done. Confirms reports fire even when a handler fails. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ENV_VARIABLES.md | 9 + apps/scheduler/src/handler-dispatcher.test.ts | 251 ++++++++++++++++++ apps/scheduler/src/handler-dispatcher.ts | 221 ++++++++++++--- apps/scheduler/src/index.ts | 3 + apps/workers/report-generator/Dockerfile | 2 - apps/workers/report-generator/handler.yaml | 12 - docker-compose.dev.yml | 4 - docker-compose.yml | 18 +- docs/architecture/post-processing.md | 59 +++- .../018-migrate-services-to-handler-dag.ts | 7 +- .../019-add-handler-status-index.ts | 8 - packages/shared/src/types/types.ts | 7 +- 12 files changed, 520 insertions(+), 81 deletions(-) create mode 100644 apps/scheduler/src/handler-dispatcher.test.ts delete mode 100644 apps/workers/report-generator/handler.yaml diff --git a/ENV_VARIABLES.md b/ENV_VARIABLES.md index 5044d6c53..5d62a193a 100644 --- a/ENV_VARIABLES.md +++ b/ENV_VARIABLES.md @@ -333,6 +333,15 @@ Optional base URL for the scheduler's HTTP notification endpoints. When set, cod - **Kubernetes:** `http://scheduler-service.scoped.svc.cluster.local:8080` - **Local dev:** Leave unset to rely on legacy queue fallback unless you are also running the scheduler locally +### API_URL +**Default:** `http://api:80` +**Type:** URL string + +Base URL the **scheduler** uses to reach the API's `POST /api/v1/reports/trigger` endpoint. When a run's post-processing handler DAG drains (every registered handler reaches a terminal state), the scheduler calls this endpoint so the API can evaluate each report template's trigger and create the templated report job(s). Reports are deliberately not DAG handlers — see [docs/architecture/post-processing.md](docs/architecture/post-processing.md#report-triggering-on-dag-drain). + +- **Docker Compose:** `http://api:80` +- **Kubernetes:** `http://api.scoped.svc.cluster.local:80` + ## Worker Configuration ### SCOPE_RUN_HEARTBEAT_STALE_MS diff --git a/apps/scheduler/src/handler-dispatcher.test.ts b/apps/scheduler/src/handler-dispatcher.test.ts new file mode 100644 index 000000000..080b2b4a7 --- /dev/null +++ b/apps/scheduler/src/handler-dispatcher.test.ts @@ -0,0 +1,251 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import type { HandlerServiceDocument } from "shared"; +import { HandlerDispatcher } from "./handler-dispatcher.js"; + +const HANDLERS: HandlerServiceDocument[] = [ + { + _id: "pp-atif", + type: "post-process-handler", + version: 1, + queue: "post-processor-queue", + selector: "atif", + autoBackfill: true, + dependsOn: [], + }, + { + _id: "pp-taxonomy", + type: "post-process-handler", + version: 1, + queue: "pp-taxonomy-queue", + selector: "taxonomy", + autoBackfill: false, + dependsOn: ["pp-atif"], + }, +]; + +function createMockCollection() { + return { + findOne: vi.fn(), + findOneAndUpdate: vi.fn(), + updateOne: vi.fn().mockResolvedValue(undefined), + find: vi.fn(), + } as any; +} + +function createMockDb(handlers: HandlerServiceDocument[]) { + return { + collection: vi.fn().mockReturnValue({ + find: vi.fn().mockReturnValue({ toArray: vi.fn().mockResolvedValue(handlers) }), + updateOne: vi.fn().mockResolvedValue(undefined), + }), + } as any; +} + +function makeDispatcher( + collection: any, + handlers: HandlerServiceDocument[] = HANDLERS, + apiUrl = "http://api.test", +) { + const db = createMockDb(handlers); + const createQueueClient = vi.fn(); + return new HandlerDispatcher(collection, db, createQueueClient, 60_000, 30, apiUrl); +} + +describe("HandlerDispatcher — drain detection", () => { + let collection: ReturnType; + + beforeEach(() => { + collection = createMockCollection(); + }); + + function isDrained( + handlers: HandlerServiceDocument[], + handlerStatus: Record | undefined, + ): boolean { + const d = makeDispatcher(collection, handlers) as any; + const graph = d.buildGraph(handlers); + return d.isDagDrained(handlers, graph, handlerStatus); + } + + it("is drained when all handlers are done", () => { + expect( + isDrained(HANDLERS, { "pp-atif": { status: "done" }, "pp-taxonomy": { status: "done" } }), + ).toBe(true); + }); + + it("is NOT drained when the leaf is still pending", () => { + expect(isDrained(HANDLERS, { "pp-atif": { status: "done" } })).toBe(false); + }); + + it("is NOT drained when a handler is processing", () => { + expect( + isDrained(HANDLERS, { + "pp-atif": { status: "done" }, + "pp-taxonomy": { status: "processing" }, + }), + ).toBe(false); + }); + + it("is drained when a failed dependency blocks its descendant", () => { + // pp-atif failed → pp-taxonomy can never run (blocked) → terminal. + expect(isDrained(HANDLERS, { "pp-atif": { status: "failed" } })).toBe(true); + }); + + it("is drained when the leaf itself failed", () => { + expect( + isDrained(HANDLERS, { + "pp-atif": { status: "done" }, + "pp-taxonomy": { status: "failed" }, + }), + ).toBe(true); + }); + + it("is vacuously drained when there are no handlers", () => { + expect(isDrained([], undefined)).toBe(true); + }); +}); + +describe("HandlerDispatcher — maybeTriggerReports", () => { + let collection: ReturnType; + const fetchMock = vi.fn(); + + beforeEach(() => { + collection = createMockCollection(); + fetchMock.mockReset(); + vi.stubGlobal("fetch", fetchMock); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("POSTs to /api/v1/reports/trigger once the DAG drains", async () => { + collection.findOne.mockResolvedValue({ + _id: "req-1", + run: { + _id: "run-1", + status: "done", + handlerStatus: { "pp-atif": { status: "done" }, "pp-taxonomy": { status: "done" } }, + }, + }); + collection.findOneAndUpdate.mockResolvedValue({ _id: "req-1" }); // claim succeeds + fetchMock.mockResolvedValue({ ok: true, status: 201, text: async () => "" }); + + const d = makeDispatcher(collection) as any; + await d.maybeTriggerReports("req-1", "run-1"); + + expect(collection.findOneAndUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + _id: "req-1", + "run._id": "run-1", + "run.status": "done", + "run.reportsTriggeredAt": { $exists: false }, + }), + expect.objectContaining({ $set: expect.objectContaining({ "run.reportsTriggeredAt": expect.any(Date) }) }), + expect.objectContaining({ returnDocument: "after" }), + ); + expect(fetchMock).toHaveBeenCalledTimes(1); + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe("http://api.test/api/v1/reports/trigger"); + expect(init.method).toBe("POST"); + expect(JSON.parse(init.body)).toEqual({ requestId: "req-1" }); + }); + + it("does not trigger when the DAG has not drained", async () => { + collection.findOne.mockResolvedValue({ + _id: "req-1", + run: { _id: "run-1", status: "done", handlerStatus: { "pp-atif": { status: "done" } } }, + }); + + const d = makeDispatcher(collection) as any; + await d.maybeTriggerReports("req-1", "run-1"); + + expect(collection.findOneAndUpdate).not.toHaveBeenCalled(); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("does not trigger when reports were already triggered", async () => { + collection.findOne.mockResolvedValue({ + _id: "req-1", + run: { _id: "run-1", status: "done", reportsTriggeredAt: new Date(), handlerStatus: {} }, + }); + + const d = makeDispatcher(collection) as any; + await d.maybeTriggerReports("req-1", "run-1"); + + expect(collection.findOneAndUpdate).not.toHaveBeenCalled(); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("does not trigger when the current attempt differs from the notified runId", async () => { + // A stale notify for run-1 arrives, but the request has since been retried + // into run-2 — the guard must not fire for the wrong attempt. + collection.findOne.mockResolvedValue({ + _id: "req-1", + run: { + _id: "run-2", + status: "done", + handlerStatus: { "pp-atif": { status: "done" }, "pp-taxonomy": { status: "done" } }, + }, + }); + + const d = makeDispatcher(collection) as any; + await d.maybeTriggerReports("req-1", "run-1"); + + expect(collection.findOneAndUpdate).not.toHaveBeenCalled(); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("does not POST twice when the guard claim is lost to a concurrent path", async () => { + collection.findOne.mockResolvedValue({ + _id: "req-1", + run: { + _id: "run-1", + status: "done", + handlerStatus: { "pp-atif": { status: "done" }, "pp-taxonomy": { status: "done" } }, + }, + }); + collection.findOneAndUpdate.mockResolvedValue(null); // another path claimed it first + + const d = makeDispatcher(collection) as any; + await d.maybeTriggerReports("req-1", "run-1"); + + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("rolls back the guard when the trigger POST fails", async () => { + collection.findOne.mockResolvedValue({ + _id: "req-1", + run: { + _id: "run-1", + status: "done", + handlerStatus: { "pp-atif": { status: "done" }, "pp-taxonomy": { status: "done" } }, + }, + }); + collection.findOneAndUpdate.mockResolvedValue({ _id: "req-1" }); + fetchMock.mockResolvedValue({ ok: false, status: 500, text: async () => "boom" }); + + const d = makeDispatcher(collection) as any; + d.reportRetryOptions = { maxRetries: 1, baseDelayMs: 1, isRetryable: () => true }; + await d.maybeTriggerReports("req-1", "run-1"); + + // Guard rolled back via $unset (scoped to the attempt) so the poll net retries. + expect(collection.updateOne).toHaveBeenCalledWith( + { _id: "req-1", "run._id": "run-1" }, + { $unset: { "run.reportsTriggeredAt": "" } }, + ); + }); + + it("ignores runs that are not done", async () => { + collection.findOne.mockResolvedValue({ _id: "req-1", run: { _id: "run-1", status: "processing" } }); + + const d = makeDispatcher(collection) as any; + await d.maybeTriggerReports("req-1", "run-1"); + + expect(collection.findOneAndUpdate).not.toHaveBeenCalled(); + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/scheduler/src/handler-dispatcher.ts b/apps/scheduler/src/handler-dispatcher.ts index 09b461def..615a45e3d 100644 --- a/apps/scheduler/src/handler-dispatcher.ts +++ b/apps/scheduler/src/handler-dispatcher.ts @@ -4,7 +4,8 @@ import { Collection, Db } from "mongodb"; import { QueueClient } from "@azure/storage-queue"; import type { RequestDocument, HandlerServiceDocument, HandlerRunStatus } from "shared"; -import { DependencyGraph } from "shared"; +import { DependencyGraph, withRetry } from "shared"; +import type { RetryOptions } from "shared"; import type { NotifyHandler } from "./notify-routes.js"; /** @@ -24,12 +25,22 @@ export class HandlerDispatcher implements NotifyHandler { private dispatching = false; private queueClients = new Map(); + /** Retry policy for the report-trigger POST. The 30s poll net is the durable + * backstop, so in-call retries are modest. Overridable in tests. */ + private reportRetryOptions: RetryOptions = { + maxRetries: 4, + baseDelayMs: 250, + maxDelayMs: 2_000, + isRetryable: () => true, + }; + constructor( private readonly collection: Collection, private readonly db: Db, private readonly createQueueClient: (queueName: string) => QueueClient, private readonly pollIntervalMs: number = 30_000, private readonly batchSize: number = 30, + private readonly apiUrl: string = process.env.API_URL || "http://api:80", ) {} start(): void { @@ -54,14 +65,18 @@ export class HandlerDispatcher implements NotifyHandler { async onRunTerminal(requestId: string, runId: string): Promise { console.log(`[HandlerDispatcher] Notify: run-terminal ${requestId} (run=${runId})`); const handlers = await this.loadHandlers(); - if (handlers.length === 0) return; - const graph = this.buildGraph(handlers); - const roots = handlers.filter((h) => !h.dependsOn || h.dependsOn.length === 0); + if (handlers.length > 0) { + const graph = this.buildGraph(handlers); + const roots = handlers.filter((h) => !h.dependsOn || h.dependsOn.length === 0); - for (const handler of roots) { - await this.dispatchIfEligible(requestId, runId, handler, graph, handlers); + for (const handler of roots) { + await this.dispatchIfEligible(requestId, runId, handler, graph, handlers); + } } + + // A run with no handlers (or an already-drained DAG) triggers reports now. + await this.maybeTriggerReports(requestId, runId); } async onHandlerComplete( @@ -86,7 +101,9 @@ export class HandlerDispatcher implements NotifyHandler { ); if (status === "failed") { - // Don't dispatch downstream on failure + // Don't dispatch downstream on failure, but the failure may have drained + // the DAG (descendants are now permanently blocked) — check for reports. + await this.maybeTriggerReports(requestId, runId); return; } @@ -105,6 +122,9 @@ export class HandlerDispatcher implements NotifyHandler { for (const child of children) { await this.dispatchIfEligible(requestId, runId, child, graph, handlers); } + + // Completing this handler may have drained the DAG (e.g. it was the leaf). + await this.maybeTriggerReports(requestId, runId); } async registerHandler(doc: HandlerServiceDocument): Promise { @@ -134,6 +154,119 @@ export class HandlerDispatcher implements NotifyHandler { ); } + // ── Report trigger on DAG drain ───────────────────────────────────── + + /** + * A handler is "terminal" for a run when it can produce no further work: + * it has succeeded (`done`), failed (`failed`), or is permanently blocked + * because one of its transitive dependencies failed (so it can never run). + */ + private isHandlerTerminal( + handlerId: string, + graph: DependencyGraph, + handlerStatus: Record | undefined, + ): boolean { + const status = handlerStatus?.[handlerId]?.status; + if (status === "done" || status === "failed") return true; + + // Not yet dispatched/processed — terminal only if blocked by a failed + // ancestor (any transitive dependency that failed). + if (status === undefined) { + for (const ancestorId of graph.getAncestors(handlerId)) { + if (handlerStatus?.[ancestorId]?.status === "failed") return true; + } + } + + // "queued" or "processing" — still active. + return false; + } + + /** + * The DAG is drained for a run when every registered handler is terminal. + * Vacuously true when there are no handlers. + */ + private isDagDrained( + handlers: HandlerServiceDocument[], + graph: DependencyGraph, + handlerStatus: Record | undefined, + ): boolean { + return handlers.every((h) => + this.isHandlerTerminal(h._id, graph, handlerStatus), + ); + } + + /** + * If the run's handler DAG has drained and reports have not yet been + * triggered, atomically claim the exactly-once guard and POST to the API's + * report-trigger endpoint. Rolls the guard back on failure so the poll + * safety net retries. + * + * All reads/writes are scoped to the specific attempt (`run._id === runId`) + * so a stale notify or a concurrent retry can never trigger reports for, or + * roll back the guard of, a different attempt. + */ + private async maybeTriggerReports(requestId: string, runId: string): Promise { + const doc = await this.collection.findOne({ _id: requestId } as any); + const run = doc?.run; + if (!run || run._id !== runId || run.status !== "done") return; + if ((run as any).reportsTriggeredAt) return; + + const handlers = await this.loadHandlers(); + const graph = this.buildGraph(handlers); + const handlerStatus = (run as any).handlerStatus as + | Record + | undefined; + + if (!this.isDagDrained(handlers, graph, handlerStatus)) return; + + // Atomically claim the trigger so concurrent notify/poll paths fire once. + const claimed = await this.collection.findOneAndUpdate( + { + _id: requestId, + "run._id": runId, + "run.status": "done", + "run.reportsTriggeredAt": { $exists: false }, + } as any, + { $set: { "run.reportsTriggeredAt": new Date() } } as any, + { returnDocument: "after" }, + ); + if (!claimed) return; // Another path already claimed it. + + try { + await this.triggerReports(requestId); + console.log( + `[HandlerDispatcher] Triggered report generation for ${requestId} (DAG drained)`, + ); + } catch (err) { + // Roll back the guard so a later poll retries — scoped to this attempt so + // a concurrent retry's valid guard is never cleared. + await this.collection.updateOne( + { _id: requestId, "run._id": runId } as any, + { $unset: { "run.reportsTriggeredAt": "" } } as any, + ); + console.error( + `[HandlerDispatcher] Report trigger failed for ${requestId}, rolled back guard:`, + err, + ); + } + } + + /** POSTs to the API's report-trigger endpoint with retry on transient errors. */ + private async triggerReports(requestId: string): Promise { + const url = `${this.apiUrl}/api/v1/reports/trigger`; + await withRetry(async () => { + const res = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ requestId }), + }); + if (!res.ok) { + const text = await res.text().catch(() => ""); + throw new Error(`POST ${url} -> ${res.status} ${text}`.trim()); + } + }, this.reportRetryOptions); + } + // ── Poll safety net ───────────────────────────────────────────────── private async pollDispatch(): Promise { @@ -141,33 +274,38 @@ export class HandlerDispatcher implements NotifyHandler { this.dispatching = true; try { const handlers = await this.loadHandlers(); - if (handlers.length === 0) return; - - const graph = this.buildGraph(handlers); - for (const handler of handlers) { - // Find runs that need this handler dispatched - for (let i = 0; i < this.batchSize; i++) { - const filter = this.buildDispatchFilter(handler, handlers, graph); - if (!filter) break; - - const claimed = await this.collection.findOneAndUpdate( - filter as any, - { - $set: { - [`run.handlerStatus.${handler._id}.status`]: "queued", - [`run.handlerStatus.${handler._id}.updatedAt`]: new Date(), - }, - } as any, - { sort: { updatedAt: -1 }, returnDocument: "after" }, - ); - - if (!claimed) break; - - await this.enqueueToHandler(handler, claimed._id, (claimed as any).run?._id); - console.log(`[HandlerDispatcher] Poll-dispatched ${handler._id} for ${claimed._id}`); + if (handlers.length > 0) { + const graph = this.buildGraph(handlers); + + for (const handler of handlers) { + // Find runs that need this handler dispatched + for (let i = 0; i < this.batchSize; i++) { + const filter = this.buildDispatchFilter(handler, handlers, graph); + if (!filter) break; + + const claimed = await this.collection.findOneAndUpdate( + filter as any, + { + $set: { + [`run.handlerStatus.${handler._id}.status`]: "queued", + [`run.handlerStatus.${handler._id}.updatedAt`]: new Date(), + }, + } as any, + { sort: { updatedAt: -1 }, returnDocument: "after" }, + ); + + if (!claimed) break; + + await this.enqueueToHandler(handler, claimed._id, (claimed as any).run?._id); + console.log(`[HandlerDispatcher] Poll-dispatched ${handler._id} for ${claimed._id}`); + } } } + + // Safety net: trigger reports for any done run whose DAG has drained but + // whose report trigger was missed (e.g. a dropped notify). + await this.pollTriggerReports(); } catch (err) { console.error("[HandlerDispatcher] Error during poll dispatch:", err); } finally { @@ -175,6 +313,27 @@ export class HandlerDispatcher implements NotifyHandler { } } + /** + * Scans for `done` runs whose reports have not yet been triggered and runs + * the drain check on each. Bounded by `batchSize` per poll. + */ + private async pollTriggerReports(): Promise { + const candidates = await this.collection + .find({ + "run.status": "done", + "run.reportsTriggeredAt": { $exists: false }, + deletedAt: { $exists: false }, + } as any) + .limit(this.batchSize) + .toArray(); + + for (const doc of candidates) { + const runId = (doc as any).run?._id as string | undefined; + if (!runId) continue; + await this.maybeTriggerReports(doc._id, runId); + } + } + // ── Dispatch logic ────────────────────────────────────────────────── private async dispatchIfEligible( diff --git a/apps/scheduler/src/index.ts b/apps/scheduler/src/index.ts index dce1509fe..71ba9382a 100644 --- a/apps/scheduler/src/index.ts +++ b/apps/scheduler/src/index.ts @@ -30,6 +30,7 @@ const POLL_INTERVAL_MS = parseInt( 10, ); const HEALTH_PORT = parseInt(process.env.PORT || "8080", 10); +const API_URL = process.env.API_URL || "http://api:80"; /** * Parse per-worker-type queue depth config from environment. @@ -142,6 +143,8 @@ async function main(): Promise { db, createQueueClient, ppPollIntervalMs, + 30, + API_URL, ); handlerDispatcher.start(); console.log("[Scheduler] Handler dispatcher (DAG) started"); diff --git a/apps/workers/report-generator/Dockerfile b/apps/workers/report-generator/Dockerfile index 62a5f2646..3cfd18150 100644 --- a/apps/workers/report-generator/Dockerfile +++ b/apps/workers/report-generator/Dockerfile @@ -15,7 +15,6 @@ COPY packages/shared/tsconfig.json ./packages/shared/ COPY packages/shared/src ./packages/shared/src/ COPY apps/workers/report-generator/tsconfig.json ./apps/workers/report-generator/ COPY apps/workers/report-generator/src ./apps/workers/report-generator/src/ -COPY apps/workers/report-generator/handler.yaml ./apps/workers/report-generator/ RUN pnpm --filter shared build && pnpm --filter report-generator build @@ -44,7 +43,6 @@ RUN pnpm install --frozen-lockfile --prod COPY --from=builder /app/packages/shared/dist ./packages/shared/dist/ COPY --from=builder /app/apps/workers/report-generator/dist ./apps/workers/report-generator/dist/ -COPY --from=builder /app/apps/workers/report-generator/handler.yaml ./apps/workers/report-generator/handler.yaml WORKDIR /app/apps/workers/report-generator diff --git a/apps/workers/report-generator/handler.yaml b/apps/workers/report-generator/handler.yaml deleted file mode 100644 index f7e14d83e..000000000 --- a/apps/workers/report-generator/handler.yaml +++ /dev/null @@ -1,12 +0,0 @@ -# Post-process handler registration metadata. -# Read by the generic register-handler script and POSTed to the -# scheduler's POST /handlers/register endpoint on deploy. -_id: pp-report -type: post-process-handler -version: 1 -queue: report-queue -selector: report -autoBackfill: false -dependsOn: - - pp-atif - - pp-taxonomy diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index a2fbe32b5..0f9a0753e 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -204,10 +204,6 @@ services: build: target: dev - register-handler-report-generator: - build: - target: dev - post-processor: build: target: dev diff --git a/docker-compose.yml b/docker-compose.yml index 53cdc0da1..164946b0e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -366,6 +366,7 @@ services: SCHEDULER_QUEUE_DEPTH_CODER_ACP_COPILOT: "2" SCHEDULER_POLL_INTERVAL_MS: "2000" PORT: "8080" + API_URL: http://api:80 depends_on: mongodb: condition: service_healthy @@ -639,8 +640,6 @@ services: condition: service_healthy api: condition: service_started - register-handler-report-generator: - condition: service_completed_successfully taxonomy-handler: build: @@ -687,21 +686,6 @@ services: restart: "no" entrypoint: ["npx", "tsx", "packages/shared/src/scripts/register-handler.ts"] - register-handler-report-generator: - build: - context: . - dockerfile: apps/workers/report-generator/Dockerfile - profiles: [report] - environment: - <<: *common-env - HANDLER_YAML_PATH: apps/workers/report-generator/handler.yaml - SCHEDULER_URL: http://scheduler:8080 - depends_on: - scheduler: - condition: service_started - restart: "no" - entrypoint: ["npx", "tsx", "packages/shared/src/scripts/register-handler.ts"] - register-handler-post-processor: build: context: . diff --git a/docs/architecture/post-processing.md b/docs/architecture/post-processing.md index d16d6294f..a9fc61d42 100644 --- a/docs/architecture/post-processing.md +++ b/docs/architecture/post-processing.md @@ -34,7 +34,7 @@ Two paths trigger post-processing: - Updates `run.turns.$.atifUrl` in the request document 4. On success, stamps `run.handlerStatus["pp-atif"] = { status: "done", version: N }` (while legacy `run.postProcessorVersion` / `run.postProcessorStatus` may still be mirrored during the transition) -5. Triggers downstream taxonomy/report handlers once the scheduler sees the dependency graph is satisfied +5. Triggers the downstream taxonomy handler once the scheduler sees the dependency graph is satisfied. (Reports are **not** a DAG handler — see [Report Triggering](#report-triggering-on-dag-drain).) ## Taxonomy Generation @@ -120,7 +120,7 @@ processor.start(); The post-processor uses a version-based re-processing scheme: -- Each handler is registered independently in the `services` collection using a `post-process-handler` document keyed by handler ID (for example `pp-atif`, `pp-taxonomy`, `pp-report`) +- Each handler is registered independently in the `services` collection using a `post-process-handler` document keyed by handler ID (for example `pp-atif`, `pp-taxonomy`) - Each registration stores the handler `version`, queue name, selector, `autoBackfill` behavior, and `dependsOn` DAG edges - The scheduler compares each request's per-handler status/version state (for example `run.handlerStatus["pp-atif"]`) against the registered handler entry for that selector - Requests with missing or outdated handler state are re-dispatched for that specific handler once its dependencies are satisfied @@ -154,11 +154,61 @@ flowchart LR **Where it runs:** -- **Docker Compose:** one `register-handler-` init service per handler (`pp-atif`, `pp-taxonomy`, `pp-report`) runs the generic script via `tsx`, gated on the scheduler being up. The worker waits for its registration service to complete. -- **Kubernetes:** a `register-handler-post-processor` Job runs the compiled script (`node packages/shared/dist/scripts/register-handler.js`) on each deploy. Only `pp-atif` is registered in K8s today because it is the only post-process handler deployed to the cluster; `pp-taxonomy` and `pp-report` are registered in Compose only until the taxonomy worker is deployed to K8s and report generation moves onto the DAG. The scheduler exposes a `Service` (`scheduler.scoped.svc.cluster.local:8080`) so Jobs and workers can reach `/handlers/register` and the notify endpoints. +- **Docker Compose:** one `register-handler-` init service per handler (`pp-atif`, `pp-taxonomy`) runs the generic script via `tsx`, gated on the scheduler being up. The worker waits for its registration service to complete. +- **Kubernetes:** a `register-handler-post-processor` Job runs the compiled script (`node packages/shared/dist/scripts/register-handler.js`) on each deploy. Only `pp-atif` is registered in K8s today because it is the only post-process handler deployed to the cluster; `pp-taxonomy` is registered in Compose only until the taxonomy worker is deployed to K8s. The scheduler exposes a `Service` (`scheduler.scoped.svc.cluster.local:8080`) so Jobs and workers can reach `/handlers/register` and the notify endpoints. To **add or change a handler**, edit its `handler.yaml` (bump `version`, adjust `dependsOn`, etc.) — no code or migration changes are needed for registration. +## Report Triggering on DAG Drain + +Reports are intentionally **not** modeled as a DAG handler. The report-generator +worker is keyed by `reportId` and requires a `templateId`, whereas the DAG +dispatches a generic `{ type, requestId, runId }` message — so a report node +could never process a DAG message. Reports are also fundamentally different from +post-process handlers: they are parameterized (per `templateId`), N-per-run (one +per matching template), and trigger-gated (each template has its own +`ReportTrigger`). + +Instead, the scheduler watches for a run's handler DAG to **drain** and then +calls the existing `POST /api/v1/reports/trigger` endpoint, which evaluates every +active template's trigger and creates the properly-templated `ReportDocument`(s). + +```mermaid +flowchart LR + RT["run done"] --> DAG["scheduler dispatches DAG
(pp-atif → pp-taxonomy)"] + DAG -->|"all handlers terminal"| DR{DAG drained?} + DR -->|yes| TRIG["POST /api/v1/reports/trigger
{ requestId }"] + TRIG --> API["API evaluates each template trigger
→ ReportDocument + report-queue {reportId}"] + API --> RG["report-generator (unchanged)"] +``` + +**Drain definition.** A handler is _terminal_ when its status is `done` or +`failed`, **or** when its status is absent but a transitive dependency `failed` +(so it can never run — effectively blocked). The DAG is drained when every +registered handler is terminal. A run with **zero** registered handlers is +vacuously drained, so reports trigger immediately on run completion. + +**Trigger regardless of handler success.** A flaky taxonomy must not block report +generation (reports analyze the run/ATIF), so the scheduler triggers reports +whenever the DAG drains — not only when every handler succeeded. + +**Exactly-once, attempt-scoped.** Before POSTing, the scheduler atomically claims +a per-attempt guard (`run.reportsTriggeredAt`) via `findOneAndUpdate` filtered by +`{ _id: requestId, "run._id": runId, "run.status": "done", "run.reportsTriggeredAt": { $exists: false } }`. +Scoping by `run._id` ensures a stale notify or a concurrent retry can never fire +reports for — or roll back the guard of — a different attempt. If the POST fails, +the guard is rolled back (also `run._id`-scoped) so the 30s poll safety net +retries. + +**Where it fires.** `maybeTriggerReports(requestId, runId)` runs at the end of +`onRunTerminal` (handles zero-handler / already-drained runs), both branches of +`onHandlerComplete` (success **and** the failure early-return, since a failure can +drain the DAG by blocking descendants), and a `pollTriggerReports()` scan in the +scheduler's 30s poll loop (durable backstop for dropped notifications). + +The scheduler reaches the API via the `API_URL` env var (`http://api:80` in +Compose, `http://api.scoped.svc.cluster.local:80` in K8s). + ## Status Lifecycle ```mermaid @@ -190,6 +240,7 @@ stateDiagram-v2 | `TAXONOMY_MODEL` | `gpt-4.1` | Copilot SDK model used by the taxonomy handler | | `SESSION_TIMEOUT_MS` | `300000` | Timeout for report/taxonomy Copilot SDK sessions | | `SCHEDULER_URL` | _(unset)_ | Scheduler base URL — used by handlers to notify completion and by the register-handler script to reach `/handlers/register` | +| `API_URL` | `http://api:80` | API base URL the **scheduler** uses to call `POST /api/v1/reports/trigger` when a run's handler DAG drains | | `HANDLER_YAML_PATH` | _(unset)_ | Path to a handler's `handler.yaml`, read by the generic `register-handler` script | ## Infrastructure diff --git a/packages/db-migrations/src/migrations/018-migrate-services-to-handler-dag.ts b/packages/db-migrations/src/migrations/018-migrate-services-to-handler-dag.ts index b82b4335c..bcb3b3ade 100644 --- a/packages/db-migrations/src/migrations/018-migrate-services-to-handler-dag.ts +++ b/packages/db-migrations/src/migrations/018-migrate-services-to-handler-dag.ts @@ -7,8 +7,11 @@ import type { MigrationInterface } from "mongo-migrate-ts"; /** * Migration 018: Remove the legacy "post-processor" service entry. * - * Handler service registration (pp-atif, pp-taxonomy, pp-report) is now owned - * by each worker's register-version.ts script, run as a K8s Job on deploy. + * Handler service registration (pp-atif, pp-taxonomy) is now owned + * by each worker's handler.yaml + the generic register-handler script, + * which POSTs to the scheduler's POST /handlers/register endpoint on deploy. + * Reports are not a DAG handler — the scheduler triggers report generation + * (POST /api/v1/reports/trigger) once the DAG drains. * This migration only cleans up the old single-entry format. */ export class MigrateServicesToHandlerDag implements MigrationInterface { diff --git a/packages/db-migrations/src/migrations/019-add-handler-status-index.ts b/packages/db-migrations/src/migrations/019-add-handler-status-index.ts index ecdb0352a..577dc8901 100644 --- a/packages/db-migrations/src/migrations/019-add-handler-status-index.ts +++ b/packages/db-migrations/src/migrations/019-add-handler-status-index.ts @@ -21,14 +21,6 @@ const HANDLER_STATUS_INDEXES = [ updatedAt: -1 as const, }, }, - { - name: "idx_handler_dispatch_report", - key: { - "run.handlerStatus.pp-report.status": 1 as const, - "run.status": 1 as const, - updatedAt: -1 as const, - }, - }, ] as const; export class AddHandlerStatusIndex implements MigrationInterface { diff --git a/packages/shared/src/types/types.ts b/packages/shared/src/types/types.ts index ccd7d7805..1a176e9c1 100644 --- a/packages/shared/src/types/types.ts +++ b/packages/shared/src/types/types.ts @@ -279,8 +279,13 @@ export interface RunState { aiCallCount?: number; rawChatUrl?: string; rawChatFormat?: string; - /** Per-handler post-processing status. Keys are handler IDs (e.g. "pp-atif", "pp-taxonomy", "pp-report"). */ + /** Per-handler post-processing status. Keys are handler IDs (e.g. "pp-atif", "pp-taxonomy"). */ handlerStatus?: Record; + /** Set once the scheduler has triggered report generation for this run after + * the handler DAG drained (every handler reached a terminal state). Acts as + * an exactly-once guard so the scheduler POSTs /api/v1/reports/trigger at + * most once per run, even under notify/poll races. */ + reportsTriggeredAt?: Date; /** @deprecated Use handlerStatus["pp-atif"].version instead. Kept for migration compatibility. */ postProcessorVersion?: number; /** @deprecated Use handlerStatus["pp-atif"].status instead. Kept for migration compatibility. */ From c6cd9b25c44db1b132ff5e07ef521a64713549e8 Mon Sep 17 00:00:00 2001 From: Cedric Vidal Date: Tue, 9 Jun 2026 17:21:44 -0700 Subject: [PATCH 08/19] feat(taxonomy): add dev CLI, API-based ATIF fetch, and default to gpt-5.4 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract the Copilot SDK taxonomy-generation loop into a shared, side-effect-free generateTaxonomy() used by both the worker and a new developer CLI (src/cli.ts, `pnpm taxonomy`). The CLI runs generation against any local or integration run, streams session events, and writes the validated taxonomy to a file — making it easy to troubleshoot and A/B models without the full queue/scheduler pipeline. Switch get_atif_trajectory to fetch ATIF through the run-scoped API endpoint instead of reading blob storage directly, so the CLI needs only an API URL + token (no storage connection string). Change the default TAXONOMY_MODEL from gpt-4.1 to gpt-5.4. A/B testing nine models on a multi-iteration run containing a real defect (agent lost context in iteration 2) showed verdicts ranging 72-100: weaker models (incl. gpt-4.1) missed the critical failure entirely, while gpt-5.4 caught it at the lowest token cost of the models tested. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ENV_VARIABLES.md | 4 +- apps/workers/taxonomy/package.json | 1 + apps/workers/taxonomy/src/cli.ts | 206 ++++++++++++++++++ apps/workers/taxonomy/src/index.ts | 2 +- .../taxonomy/src/taxonomy-generator.ts | 195 +++++++++++++++++ .../taxonomy/src/taxonomy-queue-processor.ts | 170 +-------------- apps/workers/taxonomy/src/tools.ts | 30 ++- docker-compose.yml | 2 +- docs/architecture/post-processing.md | 37 +++- package.json | 1 + 10 files changed, 468 insertions(+), 180 deletions(-) create mode 100644 apps/workers/taxonomy/src/cli.ts create mode 100644 apps/workers/taxonomy/src/taxonomy-generator.ts diff --git a/ENV_VARIABLES.md b/ENV_VARIABLES.md index 5d62a193a..cd43d79bc 100644 --- a/ENV_VARIABLES.md +++ b/ENV_VARIABLES.md @@ -286,11 +286,13 @@ Git commit hash embedded in reporter metadata. Automatically set during CI/CD bu Azure Storage Queue name for taxonomy-generation jobs. The scheduler enqueues `{ type: "taxonomy", requestId, runId }` messages here after the `pp-atif` dependency completes, and the taxonomy worker polls this queue. ### TAXONOMY_MODEL -**Default:** `gpt-4.1` +**Default:** `gpt-5.4` **Type:** string The LLM model used by the taxonomy worker (via the Copilot SDK) to generate structured taxonomy JSON from run metadata and ATIF trajectories. +> **Model choice matters for taxonomy quality.** A/B testing across nine models on a multi-iteration run with a real defect (agent lost context in iteration 2) showed the verdict ranged from 72 to 100 depending on the model. `gpt-4.1` (the previous default) missed action items and, on harder runs, produced the thinnest output; weaker models rubber-stamped a buggy run with a perfect score. `gpt-5.4` was the best value: it correctly caught the critical defect while using the fewest tokens and lowest cost of the models tested. Stronger alternatives (e.g. `claude-opus-4-7`) yield richer evidence at higher token cost; avoid `gpt-5.3-codex` as a default (≈1M tokens / ~7 min per run). + ### SCOPE_MT_API_URL **Default:** `http://localhost:3001` (local), `http://api:80` (Docker) **Type:** URL string diff --git a/apps/workers/taxonomy/package.json b/apps/workers/taxonomy/package.json index aacbfc4f1..52cdd6b2f 100644 --- a/apps/workers/taxonomy/package.json +++ b/apps/workers/taxonomy/package.json @@ -7,6 +7,7 @@ "build": "tsc", "dev": "tsx watch src/index.ts", "start": "node dist/index.js", + "taxonomy": "tsx src/cli.ts", "lint": "eslint src/" }, "dependencies": { diff --git a/apps/workers/taxonomy/src/cli.ts b/apps/workers/taxonomy/src/cli.ts new file mode 100644 index 000000000..a5f51f600 --- /dev/null +++ b/apps/workers/taxonomy/src/cli.ts @@ -0,0 +1,206 @@ +#!/usr/bin/env npx tsx +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * Taxonomy post-processor developer CLI. + * + * Invokes the taxonomy generation loop directly against an existing requestId, + * streams the Copilot SDK session events live, and saves the validated taxonomy + * to a local JSON file. This is a developer/troubleshooting tool only — it never + * writes to Mongo (handlerStatus) or blob storage (taxonomy.json) and does not + * notify the scheduler. + * + * Usage: + * npx tsx src/cli.ts [options] + * pnpm taxonomy [options] + * + * Options: + * --run, -r Run id for the prompt/logging (default: active run from API). + * --model Override TAXONOMY_MODEL (e.g. a reasoning model). + * --timeout Override SESSION_TIMEOUT_MS (per-attempt session timeout). + * --output, -o Output file path (default: taxonomy-.json). + * --stdout Also print the taxonomy JSON to stdout. + * --quiet Suppress live session-event streaming on stderr. + * --help, -h Show this help. + */ +import "dotenv/config"; +import { writeFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { SessionEvent } from "@github/copilot-sdk"; +import { TokenManagerClient, type LogEvent, type RequestDocument } from "shared"; +import { generateTaxonomy } from "./taxonomy-generator.js"; + +interface CliArgs { + requestId: string; + runId?: string; + model?: string; + timeoutMs?: number; + output?: string; + stdout: boolean; + quiet: boolean; +} + +function printHelp(): void { + process.stderr.write( + [ + "Taxonomy post-processor developer CLI", + "", + "Usage: npx tsx src/cli.ts [options]", + "", + "Options:", + " --run, -r Run id for the prompt/logging (default: active run from API)", + " --model Override TAXONOMY_MODEL", + " --timeout Override SESSION_TIMEOUT_MS (per-attempt session timeout)", + " --output, -o Output file path (default: taxonomy-.json)", + " --stdout Also print the taxonomy JSON to stdout", + " --quiet Suppress live session-event streaming on stderr", + " --help, -h Show this help", + "", + ].join("\n"), + ); +} + +function parseArgs(argv: string[]): CliArgs | undefined { + const args: Partial & { stdout: boolean; quiet: boolean } = { stdout: false, quiet: false }; + const positionals: string[] = []; + + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + switch (arg) { + case "--help": + case "-h": + printHelp(); + return undefined; + case "--run": + case "-r": + args.runId = argv[++i]; + break; + case "--model": + args.model = argv[++i]; + break; + case "--timeout": + args.timeoutMs = Number(argv[++i]); + break; + case "--output": + case "-o": + args.output = argv[++i]; + break; + case "--stdout": + args.stdout = true; + break; + case "--quiet": + args.quiet = true; + break; + default: + if (arg.startsWith("-")) { + throw new Error(`Unknown option: ${arg}`); + } + positionals.push(arg); + } + } + + const requestId = positionals[0]; + if (!requestId) { + throw new Error("Missing required argument"); + } + if (args.timeoutMs !== undefined && Number.isNaN(args.timeoutMs)) { + throw new Error("--timeout must be a number (milliseconds)"); + } + + return { ...args, requestId }; +} + +async function resolveRunId(apiBaseUrl: string, requestId: string, explicit?: string): Promise { + if (explicit) return explicit; + try { + const response = await fetch(`${apiBaseUrl}/api/v1/requests/${requestId}`); + if (response.ok) { + const request: RequestDocument = await response.json(); + if (request.run?._id) return request.run._id; + } + } catch { + // fall through to requestId fallback + } + return requestId; +} + +function summarizeEvent(event: SessionEvent): string | undefined { + switch (event.type) { + case "session.start": + return `session.start model=${event.data.selectedModel}`; + case "session.error": + return `session.error ${event.data.errorType}: ${event.data.message}`; + case "tool.execution_start": + return `tool.start ${event.data.toolName} ${JSON.stringify(event.data.arguments ?? {})}`; + case "tool.execution_complete": + return `tool.done ${event.data.success ? "ok" : "FAILED"}${event.data.error ? ` ${event.data.error.message}` : ""}`; + case "assistant.message": + return `assistant.message (${event.data.content.length} chars)`; + default: + return undefined; + } +} + +async function main(): Promise { + let parsed: CliArgs | undefined; + try { + parsed = parseArgs(process.argv.slice(2)); + } catch (error) { + process.stderr.write(`Error: ${error instanceof Error ? error.message : String(error)}\n\n`); + printHelp(); + process.exit(2); + } + if (!parsed) return; // help shown + + const apiBaseUrl = process.env.SCOPE_MT_API_URL || "http://localhost:3001"; + const taxonomyModel = parsed.model || process.env.TAXONOMY_MODEL || "gpt-5.4"; + const sessionTimeoutMs = parsed.timeoutMs ?? (Number(process.env.SESSION_TIMEOUT_MS) || 5 * 60 * 1000); + const outputPath = resolve(parsed.output ?? `taxonomy-${parsed.requestId}.json`); + + const githubToken = process.env.GITHUB_TOKEN + ? process.env.GITHUB_TOKEN + : await new TokenManagerClient(process.env.TOKEN_MANAGER_URL).acquireToken("copilot-sdk"); + + const runId = await resolveRunId(apiBaseUrl, parsed.requestId, parsed.runId); + + const log = (level: LogEvent["level"], msg: string, data?: Record): void => { + if (parsed.quiet) return; + const suffix = data && Object.keys(data).length > 0 ? ` ${JSON.stringify(data)}` : ""; + process.stderr.write(`[${level}] ${msg}${suffix}\n`); + }; + + process.stderr.write( + `Generating taxonomy: requestId=${parsed.requestId} runId=${runId} model=${taxonomyModel} timeout=${sessionTimeoutMs}ms\n`, + ); + + const onEvent = parsed.quiet + ? undefined + : (event: SessionEvent): void => { + const summary = summarizeEvent(event); + if (summary) process.stderr.write(` · ${summary}\n`); + }; + + const taxonomy = await generateTaxonomy({ + requestId: parsed.requestId, + runId, + apiBaseUrl, + taxonomyModel, + githubToken, + sessionTimeoutMs, + log, + onEvent, + }); + + const json = JSON.stringify(taxonomy, null, 2); + await writeFile(outputPath, `${json}\n`, "utf-8"); + process.stderr.write(`\nTaxonomy written to ${outputPath}\n`); + if (parsed.stdout) { + process.stdout.write(`${json}\n`); + } +} + +main().catch((error) => { + process.stderr.write(`\nTaxonomy generation failed: ${error instanceof Error ? error.message : String(error)}\n`); + process.exit(1); +}); diff --git a/apps/workers/taxonomy/src/index.ts b/apps/workers/taxonomy/src/index.ts index 2ee1a8118..b1a4f322b 100644 --- a/apps/workers/taxonomy/src/index.ts +++ b/apps/workers/taxonomy/src/index.ts @@ -16,7 +16,7 @@ const config: TaxonomyQueueProcessorConfig = { redisHost: process.env.REDIS_HOST || "localhost", redisPort: Number(process.env.REDIS_PORT) || 6379, redisPassword: process.env.REDIS_PASSWORD || "", - taxonomyModel: process.env.TAXONOMY_MODEL || "gpt-4.1", + taxonomyModel: process.env.TAXONOMY_MODEL || "gpt-5.4", apiBaseUrl: process.env.SCOPE_MT_API_URL || "http://localhost:3001", sessionTimeoutMs: Number(process.env.SESSION_TIMEOUT_MS) || 5 * 60 * 1000, schedulerUrl: process.env.SCHEDULER_URL, diff --git a/apps/workers/taxonomy/src/taxonomy-generator.ts b/apps/workers/taxonomy/src/taxonomy-generator.ts new file mode 100644 index 000000000..0df69d2c5 --- /dev/null +++ b/apps/workers/taxonomy/src/taxonomy-generator.ts @@ -0,0 +1,195 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { CopilotClient, SessionEvent } from "@github/copilot-sdk"; +import { + type LogEvent, + taxonomySchema, + type TaxonomyDocument, +} from "shared"; +import { TAXONOMY_SYSTEM_PROMPT } from "./prompt.js"; +import { createTaxonomyTools } from "./tools.js"; + +export const DEFAULT_SESSION_TIMEOUT_MS = 5 * 60 * 1000; +const DELTA_LOG_INTERVAL = 2_000; + +type TaxonomyLogFn = ( + level: LogEvent["level"], + msg: string, + data?: Record, +) => Promise | void; + +export interface GenerateTaxonomyParams { + requestId: string; + runId: string; + apiBaseUrl: string; + taxonomyModel: string; + githubToken: string; + sessionTimeoutMs?: number; + log: TaxonomyLogFn; + /** Optional raw session-event hook (used by the dev CLI to render live progress). */ + onEvent?: (event: SessionEvent) => void; +} + +/** + * Runs the Copilot SDK taxonomy generation loop and returns a schema-validated + * taxonomy document. This is side-effect-free with respect to Mongo and blob + * storage (it only reads run data and ATIF trajectories through the API); + * persistence is the caller's responsibility. + */ +export async function generateTaxonomy(params: GenerateTaxonomyParams): Promise { + const { requestId, runId, apiBaseUrl, taxonomyModel, githubToken, log, onEvent } = params; + const client = new CopilotClient({ githubToken }); + const tools = createTaxonomyTools(apiBaseUrl, requestId); + const timeoutMs = params.sessionTimeoutMs ?? DEFAULT_SESSION_TIMEOUT_MS; + + let streamedResponse = ""; + let latestMessage = ""; + let lastLoggedCharCount = 0; + const toolNames = new Map(); + + try { + const session = await client.createSession({ + model: taxonomyModel, + streaming: true, + tools, + systemMessage: { mode: "replace", content: TAXONOMY_SYSTEM_PROMPT }, + }); + + session.on((event: SessionEvent) => { + onEvent?.(event); + switch (event.type) { + case "session.start": + void log("info", "Copilot session started", { + sessionId: event.data.sessionId, + model: event.data.selectedModel, + }); + break; + case "session.error": + void log("error", `Session error: ${event.data.message}`, { errorType: event.data.errorType }); + break; + case "tool.execution_start": + toolNames.set(event.data.toolCallId, event.data.toolName); + void log("info", `Tool call: ${event.data.toolName}`, { + toolCallId: event.data.toolCallId, + arguments: event.data.arguments as Record | undefined, + }); + break; + case "tool.execution_complete": { + const toolName = toolNames.get(event.data.toolCallId) ?? "unknown"; + toolNames.delete(event.data.toolCallId); + void log("info", `Tool result: ${toolName} (${event.data.success ? "success" : "failed"})`, { + toolCallId: event.data.toolCallId, + error: event.data.error?.message, + }); + break; + } + case "assistant.message_delta": + streamedResponse += event.data.deltaContent; + if (streamedResponse.length - lastLoggedCharCount >= DELTA_LOG_INTERVAL) { + lastLoggedCharCount = streamedResponse.length; + void log("info", `Generating taxonomy… (${streamedResponse.length} chars so far)`); + } + break; + case "assistant.message": + latestMessage = event.data.content; + break; + } + }); + + let attempts = 0; + const maxAttempts = 3; + let prompt = [ + `Generate taxonomy JSON for request ${requestId} and run ${runId}.`, + "Use get_run_data first, then inspect ATIF trajectories that materially affect the classification.", + "Return only raw JSON that satisfies the taxonomy schema.", + ].join(" "); + let lastJson: string | undefined; + + while (attempts < maxAttempts) { + attempts += 1; + streamedResponse = ""; + latestMessage = ""; + lastLoggedCharCount = 0; + + await log("info", `Sending taxonomy prompt to Copilot SDK (attempt ${attempts}/${maxAttempts})`, { + requestId, + runId, + timeoutMs, + }); + + const response = await session.sendAndWait({ prompt }, timeoutMs); + const responseText = response?.data.content?.trim() || latestMessage.trim() || streamedResponse.trim(); + + if (!responseText) { + throw new Error("Copilot SDK returned an empty taxonomy response"); + } + + const jsonText = extractJson(responseText); + lastJson = jsonText; + + let parsedJson: unknown; + try { + parsedJson = JSON.parse(jsonText) as unknown; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + if (attempts >= maxAttempts) { + throw new Error(`Taxonomy response was not valid JSON after ${maxAttempts} attempts: ${errorMessage}`); + } + + prompt = [ + "Your previous response was not valid JSON.", + `JSON parse error: ${errorMessage}.`, + "Return the full taxonomy document again as raw JSON only, with no markdown fences or commentary.", + "Replace the entire response with corrected JSON.", + ].join(" "); + continue; + } + + const validation = taxonomySchema.safeParse(parsedJson); + if (validation.success) { + return validation.data; + } + + const validationErrors = formatValidationErrors(validation.error.issues); + await log("warn", `Taxonomy validation failed on attempt ${attempts}`, { + issues: validationErrors, + }); + + if (attempts >= maxAttempts) { + throw new Error( + `Taxonomy schema validation failed after ${maxAttempts} attempts: ${validationErrors.join(" | ")}`, + ); + } + + prompt = [ + "Your previous taxonomy JSON did not satisfy the schema.", + "Correct the document and return the entire replacement JSON object.", + "Do not include markdown fences or commentary.", + `Validation errors: ${validationErrors.join("; ")}`, + lastJson ? `Previous JSON: ${lastJson}` : "", + ].filter(Boolean).join(" "); + } + + throw new Error("Taxonomy generation ended without a valid document"); + } finally { + await client.stop().catch(() => undefined); + } +} + +function extractJson(responseText: string): string { + const trimmed = responseText.trim(); + if (!trimmed.startsWith("```")) { + return trimmed; + } + + const fenceMatch = trimmed.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/i); + return fenceMatch?.[1]?.trim() ?? trimmed; +} + +function formatValidationErrors(issues: Array<{ path: PropertyKey[]; message: string }>): string[] { + return issues.map((issue) => { + const path = issue.path.length > 0 ? issue.path.map((segment) => String(segment)).join(".") : ""; + return `${path}: ${issue.message}`; + }); +} diff --git a/apps/workers/taxonomy/src/taxonomy-queue-processor.ts b/apps/workers/taxonomy/src/taxonomy-queue-processor.ts index 29fb3b61a..d3c7495d3 100644 --- a/apps/workers/taxonomy/src/taxonomy-queue-processor.ts +++ b/apps/workers/taxonomy/src/taxonomy-queue-processor.ts @@ -2,26 +2,22 @@ // Licensed under the MIT License. import { DequeuedMessageItem } from "@azure/storage-queue"; -import { CopilotClient, SessionEvent } from "@github/copilot-sdk"; import { BaseQueueProcessor, BlobStorage, type BaseQueueProcessorConfig, type LogEvent, type RequestDocument, - taxonomySchema, TokenManagerClient, type TaxonomyDocument, type VisibilityHeartbeat, withRetry, } from "shared"; -import { TAXONOMY_SYSTEM_PROMPT } from "./prompt.js"; -import { createTaxonomyTools } from "./tools.js"; +import { generateTaxonomy } from "./taxonomy-generator.js"; const HANDLER_ID = "pp-taxonomy"; const HANDLER_VERSION = 1; const DEFAULT_SESSION_TIMEOUT_MS = 5 * 60 * 1000; -const DELTA_LOG_INTERVAL = 2_000; interface TaxonomyQueueMessage { type: "taxonomy"; @@ -156,143 +152,17 @@ export class TaxonomyQueueProcessor extends BaseQueueProcessor log: (level: LogEvent["level"], msg: string, data?: Record) => Promise, ): Promise { const githubToken = await this.tokenClient.acquireToken("copilot-sdk"); - const client = new CopilotClient({ githubToken }); - const tools = createTaxonomyTools(this.taxonomyConfig.apiBaseUrl, requestId, this.blobStorage); - const timeoutMs = this.taxonomyConfig.sessionTimeoutMs ?? DEFAULT_SESSION_TIMEOUT_MS; - - let streamedResponse = ""; - let latestMessage = ""; - let lastLoggedCharCount = 0; - const toolNames = new Map(); - - try { - await log("info", "Acquired Copilot SDK token"); - - const session = await client.createSession({ - model: this.taxonomyConfig.taxonomyModel, - streaming: true, - tools, - systemMessage: { mode: "replace", content: TAXONOMY_SYSTEM_PROMPT }, - }); - - session.on((event: SessionEvent) => { - switch (event.type) { - case "session.start": - void log("info", "Copilot session started", { - sessionId: event.data.sessionId, - model: event.data.selectedModel, - }); - break; - case "session.error": - void log("error", `Session error: ${event.data.message}`, { errorType: event.data.errorType }); - break; - case "tool.execution_start": - toolNames.set(event.data.toolCallId, event.data.toolName); - void log("info", `Tool call: ${event.data.toolName}`, { - toolCallId: event.data.toolCallId, - arguments: event.data.arguments as Record | undefined, - }); - break; - case "tool.execution_complete": { - const toolName = toolNames.get(event.data.toolCallId) ?? "unknown"; - toolNames.delete(event.data.toolCallId); - void log("info", `Tool result: ${toolName} (${event.data.success ? "success" : "failed"})`, { - toolCallId: event.data.toolCallId, - error: event.data.error?.message, - }); - break; - } - case "assistant.message_delta": - streamedResponse += event.data.deltaContent; - if (streamedResponse.length - lastLoggedCharCount >= DELTA_LOG_INTERVAL) { - lastLoggedCharCount = streamedResponse.length; - void log("info", `Generating taxonomy… (${streamedResponse.length} chars so far)`); - } - break; - case "assistant.message": - latestMessage = event.data.content; - break; - } - }); - - let attempts = 0; - const maxAttempts = 3; - let prompt = [ - `Generate taxonomy JSON for request ${requestId} and run ${runId}.`, - "Use get_run_data first, then inspect ATIF trajectories that materially affect the classification.", - "Return only raw JSON that satisfies the taxonomy schema.", - ].join(" "); - let lastJson: string | undefined; - - while (attempts < maxAttempts) { - attempts += 1; - streamedResponse = ""; - latestMessage = ""; - lastLoggedCharCount = 0; - - await log("info", `Sending taxonomy prompt to Copilot SDK (attempt ${attempts}/${maxAttempts})`, { - requestId, - runId, - timeoutMs, - }); - - const response = await session.sendAndWait({ prompt }, timeoutMs); - const responseText = response?.data.content?.trim() || latestMessage.trim() || streamedResponse.trim(); - - if (!responseText) { - throw new Error("Copilot SDK returned an empty taxonomy response"); - } - - const jsonText = extractJson(responseText); - lastJson = jsonText; - - let parsedJson: unknown; - try { - parsedJson = JSON.parse(jsonText) as unknown; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - if (attempts >= maxAttempts) { - throw new Error(`Taxonomy response was not valid JSON after ${maxAttempts} attempts: ${errorMessage}`); - } - - prompt = [ - "Your previous response was not valid JSON.", - `JSON parse error: ${errorMessage}.`, - "Return the full taxonomy document again as raw JSON only, with no markdown fences or commentary.", - "Replace the entire response with corrected JSON.", - ].join(" "); - continue; - } - - const validation = taxonomySchema.safeParse(parsedJson); - if (validation.success) { - return validation.data; - } - - const validationErrors = formatValidationErrors(validation.error.issues); - await log("warn", `Taxonomy validation failed on attempt ${attempts}`, { - issues: validationErrors, - }); - - if (attempts >= maxAttempts) { - throw new Error( - `Taxonomy schema validation failed after ${maxAttempts} attempts: ${validationErrors.join(" | ")}`, - ); - } - - prompt = [ - "Your previous taxonomy JSON did not satisfy the schema.", - "Correct the document and return the entire replacement JSON object.", - "Do not include markdown fences or commentary.", - `Validation errors: ${validationErrors.join("; ")}`, - lastJson ? `Previous JSON: ${lastJson}` : "", - ].filter(Boolean).join(" "); - } - - throw new Error("Taxonomy generation ended without a valid document"); - } finally { - await client.stop().catch(() => undefined); - } + await log("info", "Acquired Copilot SDK token"); + + return generateTaxonomy({ + requestId, + runId, + apiBaseUrl: this.taxonomyConfig.apiBaseUrl, + taxonomyModel: this.taxonomyConfig.taxonomyModel, + githubToken, + sessionTimeoutMs: this.taxonomyConfig.sessionTimeoutMs ?? DEFAULT_SESSION_TIMEOUT_MS, + log, + }); } private async notifyHandlerComplete( @@ -319,19 +189,3 @@ export class TaxonomyQueueProcessor extends BaseQueueProcessor } } -function extractJson(responseText: string): string { - const trimmed = responseText.trim(); - if (!trimmed.startsWith("```")) { - return trimmed; - } - - const fenceMatch = trimmed.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/i); - return fenceMatch?.[1]?.trim() ?? trimmed; -} - -function formatValidationErrors(issues: Array<{ path: PropertyKey[]; message: string }>): string[] { - return issues.map((issue) => { - const path = issue.path.length > 0 ? issue.path.map((segment) => String(segment)).join(".") : ""; - return `${path}: ${issue.message}`; - }); -} diff --git a/apps/workers/taxonomy/src/tools.ts b/apps/workers/taxonomy/src/tools.ts index 9c92b5b18..ea4eced32 100644 --- a/apps/workers/taxonomy/src/tools.ts +++ b/apps/workers/taxonomy/src/tools.ts @@ -2,22 +2,12 @@ // Licensed under the MIT License. import { defineTool } from "@github/copilot-sdk"; -import { BlobStorage, type ConversationTurn, type RequestDocument } from "shared"; +import { type ConversationTurn, type RequestDocument } from "shared"; interface GetAtifTrajectoryArgs { iteration?: number; } -function resolveSnapshotsBlobName(blobUrl: string): string { - const url = new URL(blobUrl); - const containerPrefix = "/snapshots/"; - const containerIndex = url.pathname.indexOf(containerPrefix); - if (containerIndex === -1) { - throw new Error(`Blob URL does not contain the snapshots container: ${blobUrl}`); - } - return decodeURIComponent(url.pathname.substring(containerIndex + containerPrefix.length)); -} - function selectTurnWithAtif(turns: ConversationTurn[] | undefined, requestedIteration?: number): ConversationTurn | undefined { if (!turns?.length) return undefined; @@ -33,7 +23,6 @@ function selectTurnWithAtif(turns: ConversationTurn[] | undefined, requestedIter export function createTaxonomyTools( apiBaseUrl: string, requestId: string, - blobStorage: BlobStorage, ) { const getRunData = defineTool("get_run_data", { description: @@ -138,12 +127,21 @@ export function createTaxonomyTools( }; } - const blobName = resolveSnapshotsBlobName(selectedTurn.atifUrl); - const buffer = await blobStorage.downloadBlobToBuffer(blobName); - const trajectory: unknown = JSON.parse(buffer.toString("utf-8")); + const runId = request.run?._id; + const iteration = selectedTurn.iteration; + const atifUrl = runId + ? `${apiBaseUrl}/api/v1/requests/${requestId}/runs/${runId}/atif?iteration=${iteration}` + : `${apiBaseUrl}/api/v1/requests/${requestId}/atif?iteration=${iteration}`; + + const atifResponse = await fetch(atifUrl); + if (!atifResponse.ok) { + return { error: `Failed to fetch ATIF trajectory: ${atifResponse.status} ${atifResponse.statusText}` }; + } + + const trajectory: unknown = await atifResponse.json(); return { - iteration: selectedTurn.iteration, + iteration, trajectory, }; } catch (err) { diff --git a/docker-compose.yml b/docker-compose.yml index 164946b0e..0124b6f71 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -649,7 +649,7 @@ services: environment: <<: *common-env AZURE_STORAGE_QUEUE_TAXONOMY: pp-taxonomy-queue - TAXONOMY_MODEL: ${TAXONOMY_MODEL:-gpt-4.1} + TAXONOMY_MODEL: ${TAXONOMY_MODEL:-gpt-5.4} SCOPE_MT_API_URL: http://api:80 SCHEDULER_URL: http://scheduler:8080 GITHUB_TOKEN: ${GITHUB_TOKEN:-} diff --git a/docs/architecture/post-processing.md b/docs/architecture/post-processing.md index a9fc61d42..dadda0544 100644 --- a/docs/architecture/post-processing.md +++ b/docs/architecture/post-processing.md @@ -38,7 +38,7 @@ Two paths trigger post-processing: ## Taxonomy Generation -The taxonomy handler (`apps/workers/taxonomy/`) consumes messages from the dedicated `pp-taxonomy-queue` and produces a structured JSON artifact for a completed run. It uses the GitHub Copilot SDK with a tool-backed session to fetch run metadata from the API and ATIF trajectories from blob storage, then validates the generated JSON against the shared taxonomy Zod schema. +The taxonomy handler (`apps/workers/taxonomy/`) consumes messages from the dedicated `pp-taxonomy-queue` and produces a structured JSON artifact for a completed run. It uses the GitHub Copilot SDK with a tool-backed session to fetch run metadata and ATIF trajectories from the API, then validates the generated JSON against the shared taxonomy Zod schema. ### Flow @@ -46,7 +46,7 @@ The taxonomy handler (`apps/workers/taxonomy/`) consumes messages from the dedic 2. Worker fetches the request document from MongoDB and marks `run.handlerStatus["pp-taxonomy"].status = "processing"` 3. Copilot SDK session calls: - `get_run_data` to fetch the request + active run from the API - - `get_atif_trajectory` to fetch per-iteration ATIF JSON from blob storage + - `get_atif_trajectory` to fetch per-iteration ATIF JSON from the API (`GET /api/v1/requests/:id/runs/:runId/atif?iteration=N`) 4. The worker validates the assistant response with `taxonomySchema.safeParse()` 5. If validation fails, the worker feeds the schema errors back into the same session and retries up to 3 total attempts 6. On success, the worker uploads `taxonomy.json` to `{requestId}/runs/{runId}/taxonomy.json` @@ -77,6 +77,37 @@ The completed run stores the taxonomy blob URL on `run.taxonomyUrl`. The `iteration` query parameter is mandatory only for the ATIF endpoints. ATIF files are also included in archive exports as `iteration-{N}.atif.trajectory.json`. +### Local troubleshooting: taxonomy CLI + +The Copilot SDK generation loop lives in `apps/workers/taxonomy/src/taxonomy-generator.ts` (`generateTaxonomy()`), shared by both the queue processor and a standalone developer CLI (`src/cli.ts`). The CLI invokes generation directly against an existing `requestId`, streams the session events live, and saves the validated taxonomy to a local file — **it never writes to MongoDB, blob storage, or the scheduler** (read-only). It is a developer tool and is intentionally not part of the Scope CLI. + +Because both taxonomy tools (`get_run_data` and `get_atif_trajectory`) read through the API, the CLI needs only an API URL and a Copilot token — no storage connection string. This lets it run against any environment (local or a remote deployment such as integration) given a reachable API. + +```bash +# Local stack (use your worktree's host-mapped API port): +SCOPE_MT_API_URL=http://localhost:3001 \ +GITHUB_TOKEN=$(gh auth token) \ +pnpm taxonomy [--run ] [--model ] [--timeout ] [-o out.json] [--stdout] [--quiet] + +# Integration (read-only against the deployed API): +SCOPE_MT_API_URL=https://msscope-int.azurewebsites.net \ +GITHUB_TOKEN=$(gh auth token) \ +pnpm taxonomy -o taxonomy-int.json +``` + +The root `pnpm taxonomy` script proxies to `pnpm --filter taxonomy-handler taxonomy` (i.e. `tsx src/cli.ts`). + +| Option | Description | +| --- | --- | +| `--run, -r ` | Run id used in the prompt/logging (default: active run resolved from the API) | +| `--model ` | Override `TAXONOMY_MODEL` (e.g. A/B a reasoning model vs the `gpt-5.4` default) | +| `--timeout ` | Override `SESSION_TIMEOUT_MS` (per-attempt session timeout) | +| `--output, -o ` | Output file path (default: `taxonomy-.json` in the cwd) | +| `--stdout` | Also print the taxonomy JSON to stdout | +| `--quiet` | Suppress live session-event streaming on stderr | + +Live progress (tool calls, errors, deltas) is written to stderr so the saved JSON file stays clean. The CLI exits non-zero on failure (empty response, JSON/schema validation failure after 3 attempts, timeout, or model rate limit) and surfaces the underlying error message — making it well suited to diagnosing the oversized-`get_atif_trajectory` paging loop and Copilot model rate limits. + ## Handler Interface The post-processor uses a registry pattern for extensibility: @@ -237,7 +268,7 @@ stateDiagram-v2 | `BATCH_SIZE` | `1` | Messages to process per poll (worker-side) | | `POLL_INTERVAL_MS` | `5000` | Worker queue polling interval | | `SCOPE_MT_API_URL` / `API_BASE_URL` | `http://localhost:3001` | API URL used by report/taxonomy Copilot tools | -| `TAXONOMY_MODEL` | `gpt-4.1` | Copilot SDK model used by the taxonomy handler | +| `TAXONOMY_MODEL` | `gpt-5.4` | Copilot SDK model used by the taxonomy handler | | `SESSION_TIMEOUT_MS` | `300000` | Timeout for report/taxonomy Copilot SDK sessions | | `SCHEDULER_URL` | _(unset)_ | Scheduler base URL — used by handlers to notify completion and by the register-handler script to reach `/handlers/register` | | `API_URL` | `http://api:80` | API base URL the **scheduler** uses to call `POST /api/v1/reports/trigger` when a run's handler DAG drains | diff --git a/package.json b/package.json index 62ce2760d..2905d5efc 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "dev:coder-acp-claude-code": "pnpm --filter coder-acp-claude-code dev", "dev:coder-acp-copilot": "pnpm --filter coder-acp-copilot dev", "dev:report-generator": "pnpm --filter report-generator dev", + "taxonomy": "pnpm --filter taxonomy-handler taxonomy", "docker:up": "scripts/worktree-env.sh && scripts/dev-compose.sh up --build", "docker:up:claude-code": "scripts/worktree-env.sh && scripts/dev-compose.sh --env-file .env --env-file apps/workers/coder-acp-claude-code/versions.env --profile claude-code --profile report up --build", "docker:up:copilot": "scripts/worktree-env.sh && scripts/dev-compose.sh --env-file .env --env-file apps/workers/coder-acp-copilot/versions.env --profile copilot --profile report up --build", From 07127ae483af9d6c1d48d6f34f69cef4d02fcb33 Mon Sep 17 00:00:00 2001 From: Cedric Vidal Date: Wed, 24 Jun 2026 14:34:24 -0700 Subject: [PATCH 09/19] chore: remove taxonomy changes (split to #1053) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- AGENTS.md | 2 - ENV_VARIABLES.md | 28 -- .../openapi-snapshot.test.ts.snap | 63 ----- apps/api/src/archive-har.test.ts | 41 --- apps/api/src/archive-har.ts | 29 +- apps/api/src/index.ts | 2 - apps/api/src/routes/requests/taxonomy.test.ts | 107 -------- apps/api/src/routes/requests/taxonomy.ts | 30 --- apps/portal/src/components/TaxonomyTab.tsx | 248 ------------------ apps/portal/src/lib/api.ts | 10 - apps/portal/src/pages/RunDetail.tsx | 10 - apps/portal/src/types.ts | 1 - apps/scheduler/src/handler-dispatcher.test.ts | 22 +- apps/scheduler/src/notify-routes.test.ts | 8 +- apps/workers/taxonomy/Dockerfile | 50 ---- apps/workers/taxonomy/handler.yaml | 11 - apps/workers/taxonomy/package.json | 24 -- apps/workers/taxonomy/src/cli.ts | 206 --------------- apps/workers/taxonomy/src/index.ts | 30 --- apps/workers/taxonomy/src/prompt.ts | 88 ------- .../taxonomy/src/taxonomy-generator.ts | 195 -------------- .../taxonomy/src/taxonomy-queue-processor.ts | 191 -------------- apps/workers/taxonomy/src/tools.ts | 154 ----------- apps/workers/taxonomy/tsconfig.json | 8 - docker-compose.dev.yml | 20 -- docker-compose.yml | 46 ---- docs/architecture/db-migrations.md | 2 +- docs/architecture/overview.md | 1 - docs/architecture/post-processing.md | 89 ++----- package.json | 5 +- .../018-migrate-services-to-handler-dag.ts | 2 +- .../019-add-handler-status-index.ts | 8 - packages/shared/src/index.ts | 1 - packages/shared/src/schemas/index.ts | 1 - packages/shared/src/schemas/request.ts | 1 - packages/shared/src/schemas/taxonomy.test.ts | 180 ------------- packages/shared/src/schemas/taxonomy.ts | 104 -------- .../src/scripts/register-handler.test.ts | 6 +- packages/shared/src/types/types.ts | 4 +- pnpm-lock.yaml | 25 -- 40 files changed, 41 insertions(+), 2012 deletions(-) delete mode 100644 apps/api/src/routes/requests/taxonomy.test.ts delete mode 100644 apps/api/src/routes/requests/taxonomy.ts delete mode 100644 apps/portal/src/components/TaxonomyTab.tsx delete mode 100644 apps/workers/taxonomy/Dockerfile delete mode 100644 apps/workers/taxonomy/handler.yaml delete mode 100644 apps/workers/taxonomy/package.json delete mode 100644 apps/workers/taxonomy/src/cli.ts delete mode 100644 apps/workers/taxonomy/src/index.ts delete mode 100644 apps/workers/taxonomy/src/prompt.ts delete mode 100644 apps/workers/taxonomy/src/taxonomy-generator.ts delete mode 100644 apps/workers/taxonomy/src/taxonomy-queue-processor.ts delete mode 100644 apps/workers/taxonomy/src/tools.ts delete mode 100644 apps/workers/taxonomy/tsconfig.json delete mode 100644 packages/shared/src/schemas/taxonomy.test.ts delete mode 100644 packages/shared/src/schemas/taxonomy.ts diff --git a/AGENTS.md b/AGENTS.md index 419fb8e6a..de77cbeba 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,7 +19,6 @@ apps/ coder-acp-copilot/ # GitHub Copilot worker (ACP SDK) coder-acp-claude-code/ # Claude Code worker (ACP SDK) report-generator/ # Post-run report generation - taxonomy/ # Post-run taxonomy generation model-scanners/ # Feature detection for Copilot and Anthropic models version-checkers/ # Poll for new agent/tool releases key-updaters/ # GitHub auth cookie management for VS Code Web @@ -54,7 +53,6 @@ Each worker implements the same queue-processor interface but adapts a different | `coder-acp-copilot` | GitHub Copilot | ACP SDK v0.14.1 | | `coder-acp-claude-code` | Claude Code | ACP SDK v0.13.1 | | `report-generator` | — | Copilot SDK | -| `taxonomy` | — | Copilot SDK | Workers consume tasks from Azure Storage Queues (named `queue-`) and write results to MongoDB and Blob Storage. Each has its own Dockerfile and docker-compose profile. diff --git a/ENV_VARIABLES.md b/ENV_VARIABLES.md index cd43d79bc..e56ea082e 100644 --- a/ENV_VARIABLES.md +++ b/ENV_VARIABLES.md @@ -277,34 +277,6 @@ Timeout for the Copilot SDK session used by the report-generator worker. If the Git commit hash embedded in reporter metadata. Automatically set during CI/CD builds. Used to track which version of the report-generator produced a given report. -## Taxonomy Handler Configuration - -### AZURE_STORAGE_QUEUE_TAXONOMY -**Default:** `pp-taxonomy-queue` -**Type:** string - -Azure Storage Queue name for taxonomy-generation jobs. The scheduler enqueues `{ type: "taxonomy", requestId, runId }` messages here after the `pp-atif` dependency completes, and the taxonomy worker polls this queue. - -### TAXONOMY_MODEL -**Default:** `gpt-5.4` -**Type:** string - -The LLM model used by the taxonomy worker (via the Copilot SDK) to generate structured taxonomy JSON from run metadata and ATIF trajectories. - -> **Model choice matters for taxonomy quality.** A/B testing across nine models on a multi-iteration run with a real defect (agent lost context in iteration 2) showed the verdict ranged from 72 to 100 depending on the model. `gpt-4.1` (the previous default) missed action items and, on harder runs, produced the thinnest output; weaker models rubber-stamped a buggy run with a perfect score. `gpt-5.4` was the best value: it correctly caught the critical defect while using the fewest tokens and lowest cost of the models tested. Stronger alternatives (e.g. `claude-opus-4-7`) yield richer evidence at higher token cost; avoid `gpt-5.3-codex` as a default (≈1M tokens / ~7 min per run). - -### SCOPE_MT_API_URL -**Default:** `http://localhost:3001` (local), `http://api:80` (Docker) -**Type:** URL string - -Base URL of the Scope API. The taxonomy worker calls this to fetch the request document and active run state through its Copilot tools. - -### SESSION_TIMEOUT_MS -**Default:** `300000` (5 minutes) -**Type:** integer (milliseconds) - -Timeout for the Copilot SDK session used by the taxonomy worker. If the model does not finish generating a valid taxonomy JSON document within this window, the worker marks `run.handlerStatus.pp-taxonomy` as failed. - ## Scheduler Configuration ### SCHEDULER_POLL_INTERVAL_MS diff --git a/apps/api/src/__snapshots__/openapi-snapshot.test.ts.snap b/apps/api/src/__snapshots__/openapi-snapshot.test.ts.snap index 16ee7fc6d..7912955e3 100644 --- a/apps/api/src/__snapshots__/openapi-snapshot.test.ts.snap +++ b/apps/api/src/__snapshots__/openapi-snapshot.test.ts.snap @@ -2313,9 +2313,6 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` ], "type": "string", }, - "taxonomyUrl": { - "type": "string", - }, "tokenUsage": { "$ref": "#/components/schemas/TokenUsage", }, @@ -7984,40 +7981,6 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` ], }, }, - "/api/v1/requests/{id}/runs/{runId}/taxonomy": { - "get": { - "parameters": [ - { - "in": "path", - "name": "id", - "required": true, - "schema": { - "type": "string", - }, - }, - { - "in": "path", - "name": "runId", - "required": true, - "schema": { - "type": "string", - }, - }, - ], - "responses": { - "200": { - "description": "Taxonomy v1 JSON document (scorecard, behavior analysis, action list)", - }, - "404": { - "description": "Not found — taxonomy not yet generated", - }, - }, - "summary": "Download taxonomy JSON for a specific attempt", - "tags": [ - "Requests", - ], - }, - }, "/api/v1/requests/{id}/runs/{runId}/tool-calls": { "get": { "parameters": [ @@ -8120,32 +8083,6 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` ], }, }, - "/api/v1/requests/{id}/taxonomy": { - "get": { - "parameters": [ - { - "in": "path", - "name": "id", - "required": true, - "schema": { - "type": "string", - }, - }, - ], - "responses": { - "200": { - "description": "Taxonomy v1 JSON document (scorecard, behavior analysis, action list)", - }, - "404": { - "description": "Not found — taxonomy not yet generated", - }, - }, - "summary": "Download taxonomy JSON for the run", - "tags": [ - "Requests", - ], - }, - }, "/api/v1/requests/{id}/tool-calls": { "get": { "parameters": [ diff --git a/apps/api/src/archive-har.test.ts b/apps/api/src/archive-har.test.ts index 51d5e36f2..f83d846ac 100644 --- a/apps/api/src/archive-har.test.ts +++ b/apps/api/src/archive-har.test.ts @@ -99,17 +99,6 @@ describe("rewriteHarUrlsForArchive", () => { expect(result.run!.turns![1].toolCallsUrl).toBe("iteration-2.tool-calls.jsonl"); expect(result.run!.turns![2].toolCallsUrl).toBeUndefined(); }); - - it("rewrites run.taxonomyUrl to taxonomy.json", () => { - const resource = { - run: { - taxonomyUrl: "https://storage.blob.core.windows.net/snapshots/abc123/runs/attempt-1/taxonomy.json", - turns: [], - }, - }; - const result = rewriteHarUrlsForArchive(resource); - expect(result.run!.taxonomyUrl).toBe("taxonomy.json"); - }); }); // --- blobNameFromSnapshotsUrl --- @@ -855,34 +844,4 @@ describe("packRunIntoTar", () => { expect(yaml).toContain("iteration-1.chat-result.json"); expect(yaml).not.toContain("blob.core.windows.net"); }); - - it("bundles run-level taxonomy.json and rewrites taxonomyUrl", async () => { - const { pack } = await import("tar-stream"); - const p = pack(); - const taxonomyData = Buffer.from('{"topics":["agent"]}'); - const container = makeMockBlobContainer({ - "run-010/runs/attempt-1/taxonomy.json": { body: taxonomyData, length: taxonomyData.length }, - }); - - const run: ArchivableRun = { - _id: "run-010", - run: { - taxonomyUrl: "https://storage.blob.core.windows.net/snapshots/run-010/runs/attempt-1/taxonomy.json", - turns: [], - }, - }; - - const entriesPromise = collectPackEntries(p); - await packRunIntoTar(p, run, container, "run-010", () => true); - p.finalize(); - - const entries = await entriesPromise; - const names = entries.map(e => e.name); - expect(names).toContain("run-010/taxonomy.json"); - expect(entries.find(e => e.name === "run-010/taxonomy.json")!.data).toEqual(taxonomyData); - - const yaml = entries.find(e => e.name === "run-010/run.yaml")!.data.toString(); - expect(yaml).toContain("taxonomy.json"); - expect(yaml).not.toContain("blob.core.windows.net"); - }); }); diff --git a/apps/api/src/archive-har.ts b/apps/api/src/archive-har.ts index 86cfeb1ee..21f922179 100644 --- a/apps/api/src/archive-har.ts +++ b/apps/api/src/archive-har.ts @@ -41,12 +41,11 @@ export function blobNameFromLogsUrl(url: string): string | null { /** * Deep-clone a run resource and rewrite `harUrl`, `rawChatUrl`, - * `taxonomyUrl`, `chatResultUrl`, and `toolCallsUrl` fields to relative archive paths. + * `chatResultUrl`, and `toolCallsUrl` fields to relative archive paths. * * - `run.harUrl` → `"run.har"` * - Per-turn `harUrl` → `"iteration-{N}.har"` * - `run.rawChatUrl` → `"run.chat-export.json"` - * - `run.taxonomyUrl` → `"taxonomy.json"` * - Per-turn `rawChatUrl` → `"iteration-{N}.chat-export.json"` * - Per-turn `chatResultUrl` → `"iteration-{N}.chat-result.json"` * - Per-turn `toolCallsUrl` → `"iteration-{N}.tool-calls.jsonl"` @@ -55,7 +54,6 @@ export function rewriteHarUrlsForArchive; [key: string]: unknown; }; @@ -68,9 +66,6 @@ export function rewriteHarUrlsForArchive ({ - checkMigrations: vi.fn().mockResolvedValue({ ready: true, applied: ["001", "002"], pending: [] }), -})); - -vi.hoisted(() => { - process.env.STORAGE_CONNECTION_STRING = "DefaultEndpointsProtocol=https;AccountName=mockaccount;AccountKey=bW9jaw==;EndpointSuffix=core.windows.net"; -}); - -vi.mock("../../llm.js", () => ({ isLlmAvailable: vi.fn().mockReturnValue(false) })); -vi.mock("../../prompt-feature-llm.js", () => ({ isLlmAvailable: vi.fn().mockReturnValue(false) })); -vi.mock("../../task-prompt-llm.js", () => ({ isTaskPromptLlmAvailable: vi.fn().mockReturnValue(false) })); - -const { mockDownload, mockGetProperties, mockGetBlockBlobClient, mockGetBlobClient, mockGetContainerClient } = vi.hoisted(() => { - const mockDownload = vi.fn(); - const mockGetProperties = vi.fn(); - const mockGetBlockBlobClient = vi.fn().mockReturnValue({ download: mockDownload, getProperties: mockGetProperties }); - const mockGetBlobClient = vi.fn().mockReturnValue({ download: mockDownload, getProperties: mockGetProperties }); - const mockGetContainerClient = vi.fn().mockReturnValue({ getBlockBlobClient: mockGetBlockBlobClient, getBlobClient: mockGetBlobClient }); - return { mockDownload, mockGetProperties, mockGetBlockBlobClient, mockGetBlobClient, mockGetContainerClient }; -}); - -vi.mock("@azure/storage-blob", async (importOriginal) => { - const original = await importOriginal(); - return { - ...original, - BlobServiceClient: { fromConnectionString: vi.fn().mockReturnValue({ getContainerClient: mockGetContainerClient }) }, - }; -}); - -describe("taxonomy endpoints", () => { - let mocks: ReturnType; - - beforeAll(() => { - mocks = createAllMockDependencies(); - _injectTestDependencies(mocks); - }); - - beforeEach(() => { - vi.clearAllMocks(); - mocks = createAllMockDependencies(); - _injectTestDependencies(mocks); - rewireBlobMocks({ mockDownload, mockGetProperties, mockGetBlockBlobClient, mockGetBlobClient, mockGetContainerClient }); - mockDownload.mockResolvedValue({ readableStreamBody: readableFrom('{"status":"ok"}'), contentLength: 15 }); - mockGetProperties.mockResolvedValue({ contentLength: 15 }); - }); - - describe.each(VARIANTS)("$label", (variant) => { - const taxonomyUrl = (id: string) => variant.url(id) + "taxonomy"; - - it("returns 404 when request not found", async () => { - (mocks.collection.findOne as ReturnType).mockResolvedValue(null); - const res = await supertest(app).get(taxonomyUrl("missing")); - expect(res.status).toBe(404); - expect(res.body).toMatchObject({ error: "Request not found" }); - }); - - it("returns 404 when taxonomyUrl is not set", async () => { - variant.mockRequest(mocks, "req-1", { _id: "run-1", attemptNumber: 1, status: "done" }); - const res = await supertest(app).get(taxonomyUrl("req-1")); - expect(res.status).toBe(404); - expect(res.body).toMatchObject({ error: "Taxonomy not yet generated for this run" }); - }); - - it("returns taxonomy JSON when taxonomyUrl is set", async () => { - variant.mockRequest(mocks, "req-1", { - _id: "run-1", - attemptNumber: 1, - status: "done", - taxonomyUrl: blobUrl("req-1/taxonomy.json"), - }); - - const res = await supertest(app).get(taxonomyUrl("req-1")).buffer(true).parse(rawParser); - expect(res.status).toBe(200); - expect(res.headers["content-type"]).toMatch("application/json"); - expect(res.headers["content-disposition"]).toContain("req-1-taxonomy.json"); - expect(mockGetBlockBlobClient).toHaveBeenCalledWith("req-1/taxonomy.json"); - }); - }); - - it("works with per-run variant", async () => { - RUN_LEVEL.mockRequest(mocks, "req-1", { - _id: "run-1", - attemptNumber: 1, - status: "done", - taxonomyUrl: blobUrl("req-1/runs/run-1/taxonomy.json"), - }); - - const res = await supertest(app).get(RUN_LEVEL.url("req-1") + "taxonomy").buffer(true).parse(rawParser); - expect(res.status).toBe(200); - expect(res.headers["content-disposition"]).toContain("req-1-taxonomy.json"); - expect(mockGetBlockBlobClient).toHaveBeenCalledWith("req-1/runs/run-1/taxonomy.json"); - }); -}); diff --git a/apps/api/src/routes/requests/taxonomy.ts b/apps/api/src/routes/requests/taxonomy.ts deleted file mode 100644 index e74d303f6..000000000 --- a/apps/api/src/routes/requests/taxonomy.ts +++ /dev/null @@ -1,30 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -import type { RouteContext } from "../../route-context.js"; -import { downloadBlobToResponse } from "./blob-helpers.js"; -import { registerArtifactRoutes } from "./artifact-route.js"; - -export function registerRequestsTaxonomyRoutes(ctx: RouteContext): void { - registerArtifactRoutes(ctx, { - path: "taxonomy", - summary: "Download taxonomy JSON for the run", - perRunSummary: "Download taxonomy JSON for a specific attempt", - responseDescription: "Taxonomy v1 JSON document (scorecard, behavior analysis, action list)", - errorResponses: { 404: { description: "Not found — taxonomy not yet generated" } }, - blobNotFoundMessage: "Taxonomy file not found — the blob may have been deleted or is no longer available", - handler: async (ctx, _req, res, targetRun, id) => { - const taxonomyUrl = targetRun.taxonomyUrl; - if (!taxonomyUrl) { - res.status(404).json({ error: "Taxonomy not yet generated for this run" }); - return; - } - - await downloadBlobToResponse(ctx, res, taxonomyUrl, { - contentType: "application/json", - filename: `${id}-taxonomy.json`, - label: "Taxonomy", - }); - }, - }); -} diff --git a/apps/portal/src/components/TaxonomyTab.tsx b/apps/portal/src/components/TaxonomyTab.tsx deleted file mode 100644 index e97c8c8c4..000000000 --- a/apps/portal/src/components/TaxonomyTab.tsx +++ /dev/null @@ -1,248 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -import { useQuery } from "@tanstack/react-query"; -import { api } from "@/lib/api"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; -import { Loader2, Download, ChevronDown, ChevronRight } from "lucide-react"; -import { useState } from "react"; - -interface TaxonomyDimension { - name: string; - score: number; - maxScore: number; - justification: string; -} - -interface BehaviorPattern { - id: string; - category: string; - observation: string; - evidence: string[]; - impact: "positive" | "negative" | "neutral"; -} - -interface ActionItem { - id: string; - priority: "high" | "medium" | "low"; - title: string; - description: string; - category: string; -} - -interface TaxonomyData { - meta?: { - schemaVersion?: string; - generatedAt?: string; - requestId?: string; - runId?: string; - model?: string; - }; - scorecard?: { - overall?: number; - dimensions?: TaxonomyDimension[]; - }; - behaviorAnalysis?: { - patterns?: BehaviorPattern[]; - }; - actionList?: { - items?: ActionItem[]; - }; -} - -function ScoreBar({ score, maxScore }: { score: number; maxScore: number }) { - const pct = Math.round((score / maxScore) * 100); - const color = pct >= 80 ? "bg-green-500" : pct >= 50 ? "bg-yellow-500" : "bg-red-500"; - return ( -
-
-
-
- {score}/{maxScore} -
- ); -} - -function ImpactBadge({ impact }: { impact: string }) { - const variant = impact === "positive" ? "default" : impact === "negative" ? "destructive" : "secondary"; - return {impact}; -} - -function PriorityBadge({ priority }: { priority: string }) { - const colors: Record = { - high: "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300", - medium: "bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300", - low: "bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300", - }; - return {priority}; -} - -function CollapsibleSection({ title, defaultOpen = true, children }: { title: string; defaultOpen?: boolean; children: React.ReactNode }) { - const [open, setOpen] = useState(defaultOpen); - return ( -
- - {open &&
{children}
} -
- ); -} - -export function TaxonomyTab({ runId, attemptRunId }: { runId: string; attemptRunId?: string }) { - const url = attemptRunId ? api.runTaxonomyUrl(runId, attemptRunId) : api.taxonomyUrl(runId); - - const { data: taxonomy, isLoading, error } = useQuery({ - queryKey: ["run-taxonomy", runId, attemptRunId], - queryFn: async () => { - const res = await fetch(url); - if (!res.ok) { - if (res.status === 404) return null; - throw new Error(`Failed to fetch taxonomy: ${res.statusText}`); - } - return res.json(); - }, - }); - - if (isLoading) { - return ( -
- - Loading taxonomy… -
- ); - } - - if (error) { - return ( -
-

Failed to load taxonomy: {(error as Error).message}

-
- ); - } - - if (!taxonomy) { - return ( -
-

Taxonomy not yet generated for this run.

-

It will appear here once post-processing completes.

-
- ); - } - - const { meta, scorecard, behaviorAnalysis, actionList } = taxonomy; - - return ( -
- {/* Header with download */} -
-
-

Run Taxonomy

- {meta?.generatedAt && ( -

- Generated {new Date(meta.generatedAt).toLocaleString()} - {meta.model && <> · Model: {meta.model}} -

- )} -
- -
- - {/* Scorecard */} - {scorecard && ( - - - - Scorecard - {scorecard.overall !== undefined && ( - {scorecard.overall}% - )} - - - - {scorecard.dimensions?.map((dim) => ( -
-
- {dim.name} -
- -

{dim.justification}

-
- ))} -
-
- )} - - {/* Behavior Analysis */} - {behaviorAnalysis?.patterns && behaviorAnalysis.patterns.length > 0 && ( - - - - Behavior Analysis ({behaviorAnalysis.patterns.length}) - - - - -
- {behaviorAnalysis.patterns.map((pattern) => ( -
-
- {pattern.category} - -
-

{pattern.observation}

- {pattern.evidence.length > 0 && ( -
    - {pattern.evidence.map((e, i) =>
  • {e}
  • )} -
- )} -
- ))} -
-
-
-
- )} - - {/* Action List */} - {actionList?.items && actionList.items.length > 0 && ( - - - - Action Items ({actionList.items.length}) - - - - -
- {actionList.items.map((item) => ( -
- -
-

{item.title}

-

{item.description}

- {item.category} -
-
- ))} -
-
-
-
- )} - - {/* Raw JSON fallback — collapsed by default */} - -
-          {JSON.stringify(taxonomy, null, 2)}
-        
-
-
- ); -} diff --git a/apps/portal/src/lib/api.ts b/apps/portal/src/lib/api.ts index 144b41c73..ea16aeabb 100644 --- a/apps/portal/src/lib/api.ts +++ b/apps/portal/src/lib/api.ts @@ -217,16 +217,6 @@ export const api = { return `${BASE}/requests/${id}/atif${qs}`; }, - /** Get taxonomy JSON URL for a request (latest run) */ - taxonomyUrl: (id: string): string => { - return `${BASE}/requests/${id}/taxonomy`; - }, - - /** Get taxonomy JSON URL for a specific attempt */ - runTaxonomyUrl: (requestId: string, runId: string): string => { - return `${BASE}/requests/${requestId}/runs/${runId}/taxonomy`; - }, - /** Download a batch archive of multiple runs as a single .tar.gz */ batchArchive: async (ids: string[]): Promise => { const resp = await fetch(`${BASE}/requests/archive`, { diff --git a/apps/portal/src/pages/RunDetail.tsx b/apps/portal/src/pages/RunDetail.tsx index 789e74743..ee42b2467 100644 --- a/apps/portal/src/pages/RunDetail.tsx +++ b/apps/portal/src/pages/RunDetail.tsx @@ -24,7 +24,6 @@ import { CriteriaGraphView } from "@/components/CriteriaGraphView"; import { HarNetworkViewer } from "@/components/HarNetworkViewer"; import { ConversationView } from "@/components/ConversationView"; import { VideoPlayer } from "@/components/VideoPlayer"; -import { TaxonomyTab } from "@/components/TaxonomyTab"; import { useLogStream } from "@/hooks/use-log-stream"; import { useAllTurnsToolCalls } from "@/hooks/useHarExtraction"; import { MarkdownRenderer } from "@/components/MarkdownRenderer"; @@ -326,7 +325,6 @@ export function RunDetail() { } const hasHarData = !!(activeRun?.harUrl || activeRun?.turns?.some(t => t.harUrl)); - const hasTaxonomy = !!activeRun?.taxonomyUrl; const hasVideoData = !!(activeRun?.videoUrls?.length || activeRun?.setupVideoUrls?.length || activeRun?.turns?.some(t => t.videoUrls?.length)); const videoCount = (activeRun?.setupVideoUrls?.length ?? 0) + (activeRun?.videoUrls?.length ?? 0) @@ -654,7 +652,6 @@ export function RunDetail() { )} {hasHarData && Network} {hasHarData && Tool Calls} - {hasTaxonomy && Taxonomy} {hasVideoData && } Logs @@ -687,13 +684,6 @@ export function RunDetail() { )} - {/* Taxonomy tab — structured scorecard + behavior analysis + action items */} - {hasTaxonomy && ( - - - - )} - {/* Video tab — session recording player */} {hasVideoData && ( diff --git a/apps/portal/src/types.ts b/apps/portal/src/types.ts index 019f77a9f..7b7e4c0f2 100644 --- a/apps/portal/src/types.ts +++ b/apps/portal/src/types.ts @@ -112,7 +112,6 @@ export interface RunState { resumedAt?: string; postProcessorVersion?: number; postProcessorStatus?: "queued" | "processing" | "done" | "failed"; - taxonomyUrl?: string; } export interface Run { diff --git a/apps/scheduler/src/handler-dispatcher.test.ts b/apps/scheduler/src/handler-dispatcher.test.ts index 080b2b4a7..67264d02c 100644 --- a/apps/scheduler/src/handler-dispatcher.test.ts +++ b/apps/scheduler/src/handler-dispatcher.test.ts @@ -16,11 +16,11 @@ const HANDLERS: HandlerServiceDocument[] = [ dependsOn: [], }, { - _id: "pp-taxonomy", + _id: "pp-example", type: "post-process-handler", version: 1, - queue: "pp-taxonomy-queue", - selector: "taxonomy", + queue: "pp-example-queue", + selector: "example", autoBackfill: false, dependsOn: ["pp-atif"], }, @@ -72,7 +72,7 @@ describe("HandlerDispatcher — drain detection", () => { it("is drained when all handlers are done", () => { expect( - isDrained(HANDLERS, { "pp-atif": { status: "done" }, "pp-taxonomy": { status: "done" } }), + isDrained(HANDLERS, { "pp-atif": { status: "done" }, "pp-example": { status: "done" } }), ).toBe(true); }); @@ -84,13 +84,13 @@ describe("HandlerDispatcher — drain detection", () => { expect( isDrained(HANDLERS, { "pp-atif": { status: "done" }, - "pp-taxonomy": { status: "processing" }, + "pp-example": { status: "processing" }, }), ).toBe(false); }); it("is drained when a failed dependency blocks its descendant", () => { - // pp-atif failed → pp-taxonomy can never run (blocked) → terminal. + // pp-atif failed → pp-example can never run (blocked) → terminal. expect(isDrained(HANDLERS, { "pp-atif": { status: "failed" } })).toBe(true); }); @@ -98,7 +98,7 @@ describe("HandlerDispatcher — drain detection", () => { expect( isDrained(HANDLERS, { "pp-atif": { status: "done" }, - "pp-taxonomy": { status: "failed" }, + "pp-example": { status: "failed" }, }), ).toBe(true); }); @@ -128,7 +128,7 @@ describe("HandlerDispatcher — maybeTriggerReports", () => { run: { _id: "run-1", status: "done", - handlerStatus: { "pp-atif": { status: "done" }, "pp-taxonomy": { status: "done" } }, + handlerStatus: { "pp-atif": { status: "done" }, "pp-example": { status: "done" } }, }, }); collection.findOneAndUpdate.mockResolvedValue({ _id: "req-1" }); // claim succeeds @@ -188,7 +188,7 @@ describe("HandlerDispatcher — maybeTriggerReports", () => { run: { _id: "run-2", status: "done", - handlerStatus: { "pp-atif": { status: "done" }, "pp-taxonomy": { status: "done" } }, + handlerStatus: { "pp-atif": { status: "done" }, "pp-example": { status: "done" } }, }, }); @@ -205,7 +205,7 @@ describe("HandlerDispatcher — maybeTriggerReports", () => { run: { _id: "run-1", status: "done", - handlerStatus: { "pp-atif": { status: "done" }, "pp-taxonomy": { status: "done" } }, + handlerStatus: { "pp-atif": { status: "done" }, "pp-example": { status: "done" } }, }, }); collection.findOneAndUpdate.mockResolvedValue(null); // another path claimed it first @@ -222,7 +222,7 @@ describe("HandlerDispatcher — maybeTriggerReports", () => { run: { _id: "run-1", status: "done", - handlerStatus: { "pp-atif": { status: "done" }, "pp-taxonomy": { status: "done" } }, + handlerStatus: { "pp-atif": { status: "done" }, "pp-example": { status: "done" } }, }, }); collection.findOneAndUpdate.mockResolvedValue({ _id: "req-1" }); diff --git a/apps/scheduler/src/notify-routes.test.ts b/apps/scheduler/src/notify-routes.test.ts index 00968ba29..797256018 100644 --- a/apps/scheduler/src/notify-routes.test.ts +++ b/apps/scheduler/src/notify-routes.test.ts @@ -162,11 +162,11 @@ describe("createHttpServer", () => { servers.push(server); const doc = { - _id: "pp-taxonomy", + _id: "pp-example", type: "post-process-handler", version: 1, - queue: "pp-taxonomy-queue", - selector: "taxonomy", + queue: "pp-example-queue", + selector: "example", autoBackfill: false, dependsOn: ["pp-atif"], }; @@ -178,7 +178,7 @@ describe("createHttpServer", () => { }); expect(response.status).toBe(200); - expect(await response.json()).toEqual({ ok: true, handlerId: "pp-taxonomy" }); + expect(await response.json()).toEqual({ ok: true, handlerId: "pp-example" }); expect(handler.registerHandler).toHaveBeenCalledWith(doc); }); diff --git a/apps/workers/taxonomy/Dockerfile b/apps/workers/taxonomy/Dockerfile deleted file mode 100644 index b6be8f2ad..000000000 --- a/apps/workers/taxonomy/Dockerfile +++ /dev/null @@ -1,50 +0,0 @@ -FROM node:22.22.0-slim AS builder - -RUN corepack enable && corepack prepare pnpm@10.29.1 --activate - -WORKDIR /app - -COPY package.json pnpm-workspace.yaml pnpm-lock.yaml ./ -COPY tsconfig.base.json ./ -COPY packages/shared/package.json ./packages/shared/ -COPY apps/workers/taxonomy/package.json ./apps/workers/taxonomy/ - -RUN pnpm install --frozen-lockfile - -COPY packages/shared/tsconfig.json ./packages/shared/ -COPY packages/shared/src ./packages/shared/src/ -COPY apps/workers/taxonomy/tsconfig.json ./apps/workers/taxonomy/ -COPY apps/workers/taxonomy/src ./apps/workers/taxonomy/src/ -COPY apps/workers/taxonomy/handler.yaml ./apps/workers/taxonomy/ - -RUN pnpm --filter shared build && pnpm --filter taxonomy-handler build - -# --- Dev stage: hot reload with tsx watch --- -FROM builder AS dev -ENV NODE_ENV=development -ENV SERVICE_DIR=workers/taxonomy -COPY scripts/dev-entrypoint.sh /app/scripts/dev-entrypoint.sh -EXPOSE 80 -ENV PORT=80 -CMD ["/app/scripts/dev-entrypoint.sh"] - -# --- Production stage --- -FROM node:22.22.0-slim - -RUN corepack enable && corepack prepare pnpm@10.29.1 --activate - -WORKDIR /app - -COPY package.json pnpm-workspace.yaml pnpm-lock.yaml ./ -COPY packages/shared/package.json ./packages/shared/ -COPY apps/workers/taxonomy/package.json ./apps/workers/taxonomy/ - -RUN pnpm install --frozen-lockfile --prod - -COPY --from=builder /app/packages/shared/dist ./packages/shared/dist/ -COPY --from=builder /app/apps/workers/taxonomy/dist ./apps/workers/taxonomy/dist/ -COPY --from=builder /app/apps/workers/taxonomy/handler.yaml ./apps/workers/taxonomy/handler.yaml - -WORKDIR /app/apps/workers/taxonomy - -CMD ["node", "dist/index.js"] diff --git a/apps/workers/taxonomy/handler.yaml b/apps/workers/taxonomy/handler.yaml deleted file mode 100644 index b479f905e..000000000 --- a/apps/workers/taxonomy/handler.yaml +++ /dev/null @@ -1,11 +0,0 @@ -# Post-process handler registration metadata. -# Read by the generic register-handler script and POSTed to the -# scheduler's POST /handlers/register endpoint on deploy. -_id: pp-taxonomy -type: post-process-handler -version: 1 -queue: pp-taxonomy-queue -selector: taxonomy -autoBackfill: false -dependsOn: - - pp-atif diff --git a/apps/workers/taxonomy/package.json b/apps/workers/taxonomy/package.json deleted file mode 100644 index 52cdd6b2f..000000000 --- a/apps/workers/taxonomy/package.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "name": "taxonomy-handler", - "version": "1.0.0", - "type": "module", - "main": "dist/index.js", - "scripts": { - "build": "tsc", - "dev": "tsx watch src/index.ts", - "start": "node dist/index.js", - "taxonomy": "tsx src/cli.ts", - "lint": "eslint src/" - }, - "dependencies": { - "shared": "workspace:*", - "@azure/storage-queue": "^12.25.0", - "@github/copilot-sdk": "^0.1.17", - "dotenv": "^16.4.5" - }, - "devDependencies": { - "@types/node": "^22.7.0", - "tsx": "^4.19.0", - "typescript": "^5.6.2" - } -} diff --git a/apps/workers/taxonomy/src/cli.ts b/apps/workers/taxonomy/src/cli.ts deleted file mode 100644 index a5f51f600..000000000 --- a/apps/workers/taxonomy/src/cli.ts +++ /dev/null @@ -1,206 +0,0 @@ -#!/usr/bin/env npx tsx -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -/** - * Taxonomy post-processor developer CLI. - * - * Invokes the taxonomy generation loop directly against an existing requestId, - * streams the Copilot SDK session events live, and saves the validated taxonomy - * to a local JSON file. This is a developer/troubleshooting tool only — it never - * writes to Mongo (handlerStatus) or blob storage (taxonomy.json) and does not - * notify the scheduler. - * - * Usage: - * npx tsx src/cli.ts [options] - * pnpm taxonomy [options] - * - * Options: - * --run, -r Run id for the prompt/logging (default: active run from API). - * --model Override TAXONOMY_MODEL (e.g. a reasoning model). - * --timeout Override SESSION_TIMEOUT_MS (per-attempt session timeout). - * --output, -o Output file path (default: taxonomy-.json). - * --stdout Also print the taxonomy JSON to stdout. - * --quiet Suppress live session-event streaming on stderr. - * --help, -h Show this help. - */ -import "dotenv/config"; -import { writeFile } from "node:fs/promises"; -import { resolve } from "node:path"; -import { SessionEvent } from "@github/copilot-sdk"; -import { TokenManagerClient, type LogEvent, type RequestDocument } from "shared"; -import { generateTaxonomy } from "./taxonomy-generator.js"; - -interface CliArgs { - requestId: string; - runId?: string; - model?: string; - timeoutMs?: number; - output?: string; - stdout: boolean; - quiet: boolean; -} - -function printHelp(): void { - process.stderr.write( - [ - "Taxonomy post-processor developer CLI", - "", - "Usage: npx tsx src/cli.ts [options]", - "", - "Options:", - " --run, -r Run id for the prompt/logging (default: active run from API)", - " --model Override TAXONOMY_MODEL", - " --timeout Override SESSION_TIMEOUT_MS (per-attempt session timeout)", - " --output, -o Output file path (default: taxonomy-.json)", - " --stdout Also print the taxonomy JSON to stdout", - " --quiet Suppress live session-event streaming on stderr", - " --help, -h Show this help", - "", - ].join("\n"), - ); -} - -function parseArgs(argv: string[]): CliArgs | undefined { - const args: Partial & { stdout: boolean; quiet: boolean } = { stdout: false, quiet: false }; - const positionals: string[] = []; - - for (let i = 0; i < argv.length; i += 1) { - const arg = argv[i]; - switch (arg) { - case "--help": - case "-h": - printHelp(); - return undefined; - case "--run": - case "-r": - args.runId = argv[++i]; - break; - case "--model": - args.model = argv[++i]; - break; - case "--timeout": - args.timeoutMs = Number(argv[++i]); - break; - case "--output": - case "-o": - args.output = argv[++i]; - break; - case "--stdout": - args.stdout = true; - break; - case "--quiet": - args.quiet = true; - break; - default: - if (arg.startsWith("-")) { - throw new Error(`Unknown option: ${arg}`); - } - positionals.push(arg); - } - } - - const requestId = positionals[0]; - if (!requestId) { - throw new Error("Missing required argument"); - } - if (args.timeoutMs !== undefined && Number.isNaN(args.timeoutMs)) { - throw new Error("--timeout must be a number (milliseconds)"); - } - - return { ...args, requestId }; -} - -async function resolveRunId(apiBaseUrl: string, requestId: string, explicit?: string): Promise { - if (explicit) return explicit; - try { - const response = await fetch(`${apiBaseUrl}/api/v1/requests/${requestId}`); - if (response.ok) { - const request: RequestDocument = await response.json(); - if (request.run?._id) return request.run._id; - } - } catch { - // fall through to requestId fallback - } - return requestId; -} - -function summarizeEvent(event: SessionEvent): string | undefined { - switch (event.type) { - case "session.start": - return `session.start model=${event.data.selectedModel}`; - case "session.error": - return `session.error ${event.data.errorType}: ${event.data.message}`; - case "tool.execution_start": - return `tool.start ${event.data.toolName} ${JSON.stringify(event.data.arguments ?? {})}`; - case "tool.execution_complete": - return `tool.done ${event.data.success ? "ok" : "FAILED"}${event.data.error ? ` ${event.data.error.message}` : ""}`; - case "assistant.message": - return `assistant.message (${event.data.content.length} chars)`; - default: - return undefined; - } -} - -async function main(): Promise { - let parsed: CliArgs | undefined; - try { - parsed = parseArgs(process.argv.slice(2)); - } catch (error) { - process.stderr.write(`Error: ${error instanceof Error ? error.message : String(error)}\n\n`); - printHelp(); - process.exit(2); - } - if (!parsed) return; // help shown - - const apiBaseUrl = process.env.SCOPE_MT_API_URL || "http://localhost:3001"; - const taxonomyModel = parsed.model || process.env.TAXONOMY_MODEL || "gpt-5.4"; - const sessionTimeoutMs = parsed.timeoutMs ?? (Number(process.env.SESSION_TIMEOUT_MS) || 5 * 60 * 1000); - const outputPath = resolve(parsed.output ?? `taxonomy-${parsed.requestId}.json`); - - const githubToken = process.env.GITHUB_TOKEN - ? process.env.GITHUB_TOKEN - : await new TokenManagerClient(process.env.TOKEN_MANAGER_URL).acquireToken("copilot-sdk"); - - const runId = await resolveRunId(apiBaseUrl, parsed.requestId, parsed.runId); - - const log = (level: LogEvent["level"], msg: string, data?: Record): void => { - if (parsed.quiet) return; - const suffix = data && Object.keys(data).length > 0 ? ` ${JSON.stringify(data)}` : ""; - process.stderr.write(`[${level}] ${msg}${suffix}\n`); - }; - - process.stderr.write( - `Generating taxonomy: requestId=${parsed.requestId} runId=${runId} model=${taxonomyModel} timeout=${sessionTimeoutMs}ms\n`, - ); - - const onEvent = parsed.quiet - ? undefined - : (event: SessionEvent): void => { - const summary = summarizeEvent(event); - if (summary) process.stderr.write(` · ${summary}\n`); - }; - - const taxonomy = await generateTaxonomy({ - requestId: parsed.requestId, - runId, - apiBaseUrl, - taxonomyModel, - githubToken, - sessionTimeoutMs, - log, - onEvent, - }); - - const json = JSON.stringify(taxonomy, null, 2); - await writeFile(outputPath, `${json}\n`, "utf-8"); - process.stderr.write(`\nTaxonomy written to ${outputPath}\n`); - if (parsed.stdout) { - process.stdout.write(`${json}\n`); - } -} - -main().catch((error) => { - process.stderr.write(`\nTaxonomy generation failed: ${error instanceof Error ? error.message : String(error)}\n`); - process.exit(1); -}); diff --git a/apps/workers/taxonomy/src/index.ts b/apps/workers/taxonomy/src/index.ts deleted file mode 100644 index b1a4f322b..000000000 --- a/apps/workers/taxonomy/src/index.ts +++ /dev/null @@ -1,30 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -import "dotenv/config"; -import { TaxonomyQueueProcessor, type TaxonomyQueueProcessorConfig } from "./taxonomy-queue-processor.js"; - -const config: TaxonomyQueueProcessorConfig = { - mongoUri: process.env.MONGO_CONNECTION_STRING || process.env.AZURE_COSMOS_CONNECTION_STRING || "mongodb://localhost:27017", - mongoDatabase: process.env.MONGO_DATABASE || "requests-db", - mongoCollection: "requests", - storageAccountName: process.env.AZURE_STORAGE_ACCOUNT_NAME || "devstoreaccount1", - storageConnectionString: process.env.STORAGE_CONNECTION_STRING || process.env.AZURE_STORAGE_CONNECTION_STRING || undefined, - queueName: process.env.AZURE_STORAGE_QUEUE_TAXONOMY || "pp-taxonomy-queue", - batchSize: Number(process.env.BATCH_SIZE) || 1, - pollIntervalMs: Number(process.env.POLL_INTERVAL_MS) || 5000, - redisHost: process.env.REDIS_HOST || "localhost", - redisPort: Number(process.env.REDIS_PORT) || 6379, - redisPassword: process.env.REDIS_PASSWORD || "", - taxonomyModel: process.env.TAXONOMY_MODEL || "gpt-5.4", - apiBaseUrl: process.env.SCOPE_MT_API_URL || "http://localhost:3001", - sessionTimeoutMs: Number(process.env.SESSION_TIMEOUT_MS) || 5 * 60 * 1000, - schedulerUrl: process.env.SCHEDULER_URL, - tokenManagerUrl: process.env.TOKEN_MANAGER_URL, -}; - -const processor = new TaxonomyQueueProcessor(config); -processor.start().catch((err) => { - console.error("[taxonomy-handler] Fatal error:", err); - process.exit(1); -}); diff --git a/apps/workers/taxonomy/src/prompt.ts b/apps/workers/taxonomy/src/prompt.ts deleted file mode 100644 index 47ea28e4d..000000000 --- a/apps/workers/taxonomy/src/prompt.ts +++ /dev/null @@ -1,88 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -export const TAXONOMY_SYSTEM_PROMPT = `You generate benchmark taxonomy documents for completed Scope runs. - -Your job is to inspect the run metadata and ATIF trajectories, then return a taxonomy JSON document that conforms exactly to the taxonomy Zod schema enforced by the worker. - -Required JSON shape: -{ - "meta": { - "generatedAt": "ISO-8601 datetime with offset", - "schemaVersion": "string", - "runId": "string", - "requestId": "string" - }, - "scorecard": { - "summary": "string", - "profileResults": [ - { - "profileId": "string", - "select": "best|median|worst", - "taskOutcome": "success|partial|failure", - "overallScore": 0-100, - "dimensions": [ - { - "name": "string", - "score": 0-100, - "rationale": "string", - "evidence": ["string"] - } - ] - } - ], - "crossProfileInsights": ["string"] - }, - "behaviorAnalysis": { - "patterns": [ - { - "id": "string", - "category": "strength|weakness|inconsistency|regression", - "title": "string", - "description": "string", - "frequency": 0-1, - "impact": "critical|high|medium|low", - "evidence": [ - { - "runId": "string", - "iteration": 0-or-greater integer, - "excerpt": "string" - } - ] - } - ], - "agentPersonality": { - "communicationStyle": "string", - "problemSolvingApproach": "string", - "errorRecoveryBehavior": "string" - } - }, - "actionList": { - "fix": [actionItem], - "improve": [actionItem], - "investigate": [actionItem] - } -} - -Each actionItem must include: -- id -- title -- priority: critical|high|medium|low -- description -- ownership: agent-team|platform-team|prompt-engineer|scenario-author -- relatedPatterns: string[] -- suggestedApproach - -Output rules: -- Return raw JSON only. -- Do not wrap the JSON in markdown fences. -- Do not include commentary before or after the JSON. -- Every field must be valid for the schema. -- Preserve the 3-layer structure: scorecard, behaviorAnalysis, actionList. -- If a value is unknown, use the schema-compatible empty/null/default value rather than inventing facts. - -Use the available tools to gather evidence before answering: -- get_run_data for the request, scenario, run, turns, and criteria outcomes. -- get_atif_trajectory for ATIF evidence from individual iterations. - -Ground every classification in the actual run evidence. Prefer precise, stable labels over verbose prose. If validation feedback is provided later in the conversation, correct the JSON and return a full replacement document.`; diff --git a/apps/workers/taxonomy/src/taxonomy-generator.ts b/apps/workers/taxonomy/src/taxonomy-generator.ts deleted file mode 100644 index 0df69d2c5..000000000 --- a/apps/workers/taxonomy/src/taxonomy-generator.ts +++ /dev/null @@ -1,195 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -import { CopilotClient, SessionEvent } from "@github/copilot-sdk"; -import { - type LogEvent, - taxonomySchema, - type TaxonomyDocument, -} from "shared"; -import { TAXONOMY_SYSTEM_PROMPT } from "./prompt.js"; -import { createTaxonomyTools } from "./tools.js"; - -export const DEFAULT_SESSION_TIMEOUT_MS = 5 * 60 * 1000; -const DELTA_LOG_INTERVAL = 2_000; - -type TaxonomyLogFn = ( - level: LogEvent["level"], - msg: string, - data?: Record, -) => Promise | void; - -export interface GenerateTaxonomyParams { - requestId: string; - runId: string; - apiBaseUrl: string; - taxonomyModel: string; - githubToken: string; - sessionTimeoutMs?: number; - log: TaxonomyLogFn; - /** Optional raw session-event hook (used by the dev CLI to render live progress). */ - onEvent?: (event: SessionEvent) => void; -} - -/** - * Runs the Copilot SDK taxonomy generation loop and returns a schema-validated - * taxonomy document. This is side-effect-free with respect to Mongo and blob - * storage (it only reads run data and ATIF trajectories through the API); - * persistence is the caller's responsibility. - */ -export async function generateTaxonomy(params: GenerateTaxonomyParams): Promise { - const { requestId, runId, apiBaseUrl, taxonomyModel, githubToken, log, onEvent } = params; - const client = new CopilotClient({ githubToken }); - const tools = createTaxonomyTools(apiBaseUrl, requestId); - const timeoutMs = params.sessionTimeoutMs ?? DEFAULT_SESSION_TIMEOUT_MS; - - let streamedResponse = ""; - let latestMessage = ""; - let lastLoggedCharCount = 0; - const toolNames = new Map(); - - try { - const session = await client.createSession({ - model: taxonomyModel, - streaming: true, - tools, - systemMessage: { mode: "replace", content: TAXONOMY_SYSTEM_PROMPT }, - }); - - session.on((event: SessionEvent) => { - onEvent?.(event); - switch (event.type) { - case "session.start": - void log("info", "Copilot session started", { - sessionId: event.data.sessionId, - model: event.data.selectedModel, - }); - break; - case "session.error": - void log("error", `Session error: ${event.data.message}`, { errorType: event.data.errorType }); - break; - case "tool.execution_start": - toolNames.set(event.data.toolCallId, event.data.toolName); - void log("info", `Tool call: ${event.data.toolName}`, { - toolCallId: event.data.toolCallId, - arguments: event.data.arguments as Record | undefined, - }); - break; - case "tool.execution_complete": { - const toolName = toolNames.get(event.data.toolCallId) ?? "unknown"; - toolNames.delete(event.data.toolCallId); - void log("info", `Tool result: ${toolName} (${event.data.success ? "success" : "failed"})`, { - toolCallId: event.data.toolCallId, - error: event.data.error?.message, - }); - break; - } - case "assistant.message_delta": - streamedResponse += event.data.deltaContent; - if (streamedResponse.length - lastLoggedCharCount >= DELTA_LOG_INTERVAL) { - lastLoggedCharCount = streamedResponse.length; - void log("info", `Generating taxonomy… (${streamedResponse.length} chars so far)`); - } - break; - case "assistant.message": - latestMessage = event.data.content; - break; - } - }); - - let attempts = 0; - const maxAttempts = 3; - let prompt = [ - `Generate taxonomy JSON for request ${requestId} and run ${runId}.`, - "Use get_run_data first, then inspect ATIF trajectories that materially affect the classification.", - "Return only raw JSON that satisfies the taxonomy schema.", - ].join(" "); - let lastJson: string | undefined; - - while (attempts < maxAttempts) { - attempts += 1; - streamedResponse = ""; - latestMessage = ""; - lastLoggedCharCount = 0; - - await log("info", `Sending taxonomy prompt to Copilot SDK (attempt ${attempts}/${maxAttempts})`, { - requestId, - runId, - timeoutMs, - }); - - const response = await session.sendAndWait({ prompt }, timeoutMs); - const responseText = response?.data.content?.trim() || latestMessage.trim() || streamedResponse.trim(); - - if (!responseText) { - throw new Error("Copilot SDK returned an empty taxonomy response"); - } - - const jsonText = extractJson(responseText); - lastJson = jsonText; - - let parsedJson: unknown; - try { - parsedJson = JSON.parse(jsonText) as unknown; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - if (attempts >= maxAttempts) { - throw new Error(`Taxonomy response was not valid JSON after ${maxAttempts} attempts: ${errorMessage}`); - } - - prompt = [ - "Your previous response was not valid JSON.", - `JSON parse error: ${errorMessage}.`, - "Return the full taxonomy document again as raw JSON only, with no markdown fences or commentary.", - "Replace the entire response with corrected JSON.", - ].join(" "); - continue; - } - - const validation = taxonomySchema.safeParse(parsedJson); - if (validation.success) { - return validation.data; - } - - const validationErrors = formatValidationErrors(validation.error.issues); - await log("warn", `Taxonomy validation failed on attempt ${attempts}`, { - issues: validationErrors, - }); - - if (attempts >= maxAttempts) { - throw new Error( - `Taxonomy schema validation failed after ${maxAttempts} attempts: ${validationErrors.join(" | ")}`, - ); - } - - prompt = [ - "Your previous taxonomy JSON did not satisfy the schema.", - "Correct the document and return the entire replacement JSON object.", - "Do not include markdown fences or commentary.", - `Validation errors: ${validationErrors.join("; ")}`, - lastJson ? `Previous JSON: ${lastJson}` : "", - ].filter(Boolean).join(" "); - } - - throw new Error("Taxonomy generation ended without a valid document"); - } finally { - await client.stop().catch(() => undefined); - } -} - -function extractJson(responseText: string): string { - const trimmed = responseText.trim(); - if (!trimmed.startsWith("```")) { - return trimmed; - } - - const fenceMatch = trimmed.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/i); - return fenceMatch?.[1]?.trim() ?? trimmed; -} - -function formatValidationErrors(issues: Array<{ path: PropertyKey[]; message: string }>): string[] { - return issues.map((issue) => { - const path = issue.path.length > 0 ? issue.path.map((segment) => String(segment)).join(".") : ""; - return `${path}: ${issue.message}`; - }); -} diff --git a/apps/workers/taxonomy/src/taxonomy-queue-processor.ts b/apps/workers/taxonomy/src/taxonomy-queue-processor.ts deleted file mode 100644 index d3c7495d3..000000000 --- a/apps/workers/taxonomy/src/taxonomy-queue-processor.ts +++ /dev/null @@ -1,191 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -import { DequeuedMessageItem } from "@azure/storage-queue"; -import { - BaseQueueProcessor, - BlobStorage, - type BaseQueueProcessorConfig, - type LogEvent, - type RequestDocument, - TokenManagerClient, - type TaxonomyDocument, - type VisibilityHeartbeat, - withRetry, -} from "shared"; -import { generateTaxonomy } from "./taxonomy-generator.js"; - -const HANDLER_ID = "pp-taxonomy"; -const HANDLER_VERSION = 1; -const DEFAULT_SESSION_TIMEOUT_MS = 5 * 60 * 1000; - -interface TaxonomyQueueMessage { - type: "taxonomy"; - requestId: string; - runId?: string; -} - -export interface TaxonomyQueueProcessorConfig extends BaseQueueProcessorConfig { - taxonomyModel: string; - apiBaseUrl: string; - sessionTimeoutMs?: number; - schedulerUrl?: string; - tokenManagerUrl?: string; -} - -export class TaxonomyQueueProcessor extends BaseQueueProcessor { - private taxonomyConfig: TaxonomyQueueProcessorConfig; - private tokenClient: TokenManagerClient; - private blobStorage: BlobStorage; - - constructor(config: TaxonomyQueueProcessorConfig) { - super(config, "taxonomy-handler"); - this.taxonomyConfig = config; - this.tokenClient = new TokenManagerClient(config.tokenManagerUrl); - this.blobStorage = new BlobStorage({ - storageAccountName: config.storageAccountName, - storageConnectionString: config.storageConnectionString, - }); - } - - protected override async handleRequest( - doc: RequestDocument, - message: DequeuedMessageItem, - heartbeat: VisibilityHeartbeat, - log: (level: LogEvent["level"], msg: string, data?: Record) => Promise, - payload?: Record, - ): Promise { - const queueMessage = payload as TaxonomyQueueMessage | undefined; - const requestId = doc._id; - const activeRunId = doc.run?._id; - const runId = queueMessage?.runId ?? activeRunId ?? requestId; - - if (!queueMessage || queueMessage.type !== "taxonomy") { - await log("warn", "Discarding message with unsupported taxonomy payload", { payload }); - await this.safeDeleteMessage(message.messageId, heartbeat.stop()); - return; - } - - if (queueMessage.runId && activeRunId && queueMessage.runId !== activeRunId) { - await log("warn", "Discarding stale taxonomy message for superseded run", { - requestId, - queuedRunId: queueMessage.runId, - activeRunId, - }); - await this.safeDeleteMessage(message.messageId, heartbeat.stop()); - return; - } - - if (!doc.run) { - throw new Error(`Request ${requestId} has no active run to classify`); - } - - await withRetry(() => this.collection.updateOne( - { _id: requestId } as never, - { - $set: { - [`run.handlerStatus.${HANDLER_ID}.status`]: "processing", - [`run.handlerStatus.${HANDLER_ID}.version`]: HANDLER_VERSION, - [`run.handlerStatus.${HANDLER_ID}.updatedAt`]: new Date(), - [`run.handlerStatus.${HANDLER_ID}.error`]: null, - }, - } as never, - )); - - await log("info", "Starting taxonomy generation", { requestId, runId, model: this.taxonomyConfig.taxonomyModel }); - - try { - const taxonomy = await this.generateTaxonomy(requestId, runId, log); - const blobName = `${requestId}/runs/${runId}/taxonomy.json`; - const taxonomyUrl = await this.blobStorage.uploadJson(blobName, taxonomy); - - await withRetry(() => this.collection.updateOne( - { _id: requestId } as never, - { - $set: { - [`run.taxonomyUrl`]: taxonomyUrl, - [`run.handlerStatus.${HANDLER_ID}.status`]: "done", - [`run.handlerStatus.${HANDLER_ID}.version`]: HANDLER_VERSION, - [`run.handlerStatus.${HANDLER_ID}.updatedAt`]: new Date(), - }, - $unset: { - [`run.handlerStatus.${HANDLER_ID}.error`]: "", - }, - } as never, - )); - - await log("info", "Taxonomy generation completed", { - requestId, - runId, - blobName, - taxonomyUrl, - }); - - await this.notifyHandlerComplete(requestId, runId, "done"); - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - - await withRetry(() => this.collection.updateOne( - { _id: requestId } as never, - { - $set: { - [`run.handlerStatus.${HANDLER_ID}.status`]: "failed", - [`run.handlerStatus.${HANDLER_ID}.version`]: HANDLER_VERSION, - [`run.handlerStatus.${HANDLER_ID}.updatedAt`]: new Date(), - [`run.handlerStatus.${HANDLER_ID}.error`]: errorMessage, - }, - } as never, - )); - - await log("error", `Taxonomy generation failed: ${errorMessage}`, { requestId, runId }); - await this.notifyHandlerComplete(requestId, runId, "failed"); - throw error; - } - - const finalPopReceipt = heartbeat.stop(); - await this.safeDeleteMessage(message.messageId, finalPopReceipt); - } - - private async generateTaxonomy( - requestId: string, - runId: string, - log: (level: LogEvent["level"], msg: string, data?: Record) => Promise, - ): Promise { - const githubToken = await this.tokenClient.acquireToken("copilot-sdk"); - await log("info", "Acquired Copilot SDK token"); - - return generateTaxonomy({ - requestId, - runId, - apiBaseUrl: this.taxonomyConfig.apiBaseUrl, - taxonomyModel: this.taxonomyConfig.taxonomyModel, - githubToken, - sessionTimeoutMs: this.taxonomyConfig.sessionTimeoutMs ?? DEFAULT_SESSION_TIMEOUT_MS, - log, - }); - } - - private async notifyHandlerComplete( - requestId: string, - runId: string, - status: "done" | "failed", - ): Promise { - const schedulerUrl = this.taxonomyConfig.schedulerUrl; - if (!schedulerUrl) return; - - try { - const response = await fetch(`${schedulerUrl}/notify/handler-complete`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ requestId, runId, handlerId: HANDLER_ID, status }), - }); - - if (!response.ok) { - console.warn(`[taxonomy-handler] Scheduler notify failed: ${response.status}`); - } - } catch (error) { - console.warn(`[taxonomy-handler] Scheduler notify error for ${requestId}/${runId}:`, error); - } - } -} - diff --git a/apps/workers/taxonomy/src/tools.ts b/apps/workers/taxonomy/src/tools.ts deleted file mode 100644 index ea4eced32..000000000 --- a/apps/workers/taxonomy/src/tools.ts +++ /dev/null @@ -1,154 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -import { defineTool } from "@github/copilot-sdk"; -import { type ConversationTurn, type RequestDocument } from "shared"; - -interface GetAtifTrajectoryArgs { - iteration?: number; -} - -function selectTurnWithAtif(turns: ConversationTurn[] | undefined, requestedIteration?: number): ConversationTurn | undefined { - if (!turns?.length) return undefined; - - if (requestedIteration !== undefined) { - return turns.find((turn) => turn.iteration === requestedIteration && typeof turn.atifUrl === "string"); - } - - return [...turns] - .filter((turn) => typeof turn.atifUrl === "string") - .sort((a, b) => b.iteration - a.iteration)[0]; -} - -export function createTaxonomyTools( - apiBaseUrl: string, - requestId: string, -) { - const getRunData = defineTool("get_run_data", { - description: - "Fetch the full request document plus the active run state, including scenario, persona, turns, criteria results, and handler status.", - parameters: { - type: "object", - properties: {}, - }, - handler: async () => { - try { - const response = await fetch(`${apiBaseUrl}/api/v1/requests/${requestId}`); - if (!response.ok) { - return { error: `Failed to fetch request: ${response.status} ${response.statusText}` }; - } - - const request: RequestDocument = await response.json(); - const run = request.run; - - return { - request: { - id: request._id, - workerType: request.workerType, - model: request.model, - reasoningEffort: request.reasoningEffort, - maxIterations: request.maxIterations, - createdAt: request.createdAt, - updatedAt: request.updatedAt, - persona: request.persona, - personaInstructions: request.personaInstructions, - mcpServers: request.mcpServers, - skillRevisions: request.skillRevisions, - extensions: request.extensions, - priority: request.priority, - scenario: request.scenario, - }, - run: run ? { - id: run._id, - attemptNumber: run.attemptNumber, - status: run.status, - outcome: run.outcome, - result: run.result, - error: run.error, - startedAt: run.startedAt, - finishedAt: run.finishedAt, - updatedAt: run.updatedAt, - aiCallCount: run.aiCallCount, - tokenUsage: run.tokenUsage, - handlerStatus: run.handlerStatus, - turns: run.turns?.map((turn) => ({ - iteration: turn.iteration, - passed: turn.passed, - timestamp: turn.timestamp, - startedAt: turn.startedAt, - durationMs: turn.durationMs, - judgeFeedback: turn.judgeFeedback, - codingAgentResponse: turn.codingAgentResponse, - aiCallCount: turn.aiCallCount, - tokenUsage: turn.tokenUsage, - snapshotUrl: turn.snapshotUrl, - rawChatUrl: turn.rawChatUrl, - rawChatFormat: turn.rawChatFormat, - toolCallsUrl: turn.toolCallsUrl, - toolCallCount: turn.toolCallCount, - atifUrl: turn.atifUrl, - criteriaResults: turn.criteriaResults, - })) ?? [], - } : null, - }; - } catch (err) { - return { error: `Failed to fetch run data: ${err}` }; - } - }, - }); - - const getAtifTrajectory = defineTool("get_atif_trajectory", { - description: - "Fetch an ATIF trajectory JSON document for a specific iteration, or the latest available iteration when omitted.", - parameters: { - type: "object", - properties: { - iteration: { - type: "number", - description: "Optional 1-based iteration number. If omitted, the latest available ATIF trajectory is returned.", - }, - }, - }, - handler: async (args: GetAtifTrajectoryArgs) => { - try { - const response = await fetch(`${apiBaseUrl}/api/v1/requests/${requestId}`); - if (!response.ok) { - return { error: `Failed to fetch request: ${response.status} ${response.statusText}` }; - } - - const request: RequestDocument = await response.json(); - const selectedTurn = selectTurnWithAtif(request.run?.turns, args.iteration); - - if (!selectedTurn?.atifUrl) { - return { - error: args.iteration !== undefined - ? `No ATIF trajectory available for iteration ${args.iteration}` - : "No ATIF trajectory is available for this run", - }; - } - - const runId = request.run?._id; - const iteration = selectedTurn.iteration; - const atifUrl = runId - ? `${apiBaseUrl}/api/v1/requests/${requestId}/runs/${runId}/atif?iteration=${iteration}` - : `${apiBaseUrl}/api/v1/requests/${requestId}/atif?iteration=${iteration}`; - - const atifResponse = await fetch(atifUrl); - if (!atifResponse.ok) { - return { error: `Failed to fetch ATIF trajectory: ${atifResponse.status} ${atifResponse.statusText}` }; - } - - const trajectory: unknown = await atifResponse.json(); - - return { - iteration, - trajectory, - }; - } catch (err) { - return { error: `Failed to fetch ATIF trajectory: ${err}` }; - } - }, - }); - - return [getRunData, getAtifTrajectory]; -} diff --git a/apps/workers/taxonomy/tsconfig.json b/apps/workers/taxonomy/tsconfig.json deleted file mode 100644 index bf012ecbb..000000000 --- a/apps/workers/taxonomy/tsconfig.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "extends": "../../../tsconfig.base.json", - "compilerOptions": { - "outDir": "./dist", - "rootDir": "./src" - }, - "include": ["src/**/*"] -} diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 0f9a0753e..daa4d9c9f 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -180,30 +180,10 @@ services: - action: rebuild path: ./pnpm-lock.yaml - taxonomy-handler: - build: - target: dev - develop: - watch: - - action: sync - path: ./apps/workers/taxonomy/src - target: /app/apps/workers/taxonomy/src - - action: sync - path: ./packages/shared/src - target: /app/packages/shared/src - - action: rebuild - path: ./apps/workers/taxonomy/package.json - - action: rebuild - path: ./pnpm-lock.yaml - register-handler-post-processor: build: target: dev - register-handler-taxonomy: - build: - target: dev - post-processor: build: target: dev diff --git a/docker-compose.yml b/docker-compose.yml index 0124b6f71..3ab31dced 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,7 +9,6 @@ # docker compose --profile vscode-web up # + VS Code Web worker # docker compose --profile claude-code --profile copilot up # multiple workers # docker compose --profile report up # + Report Generator worker -# docker compose --profile taxonomy up # + Taxonomy handler worker # docker compose up mongodb redis azurite # infra only (run app services natively) # # Host-native debugging: @@ -641,51 +640,6 @@ services: api: condition: service_started - taxonomy-handler: - build: - context: . - dockerfile: apps/workers/taxonomy/Dockerfile - profiles: [taxonomy, all] - environment: - <<: *common-env - AZURE_STORAGE_QUEUE_TAXONOMY: pp-taxonomy-queue - TAXONOMY_MODEL: ${TAXONOMY_MODEL:-gpt-5.4} - SCOPE_MT_API_URL: http://api:80 - SCHEDULER_URL: http://scheduler:8080 - GITHUB_TOKEN: ${GITHUB_TOKEN:-} - TOKEN_MANAGER_URL: http://token-manager:80 - GIT_COMMIT: ${GIT_COMMIT:-development} - SESSION_TIMEOUT_MS: ${SESSION_TIMEOUT_MS:-300000} - depends_on: - mongodb: - condition: service_healthy - required: false - redis: - condition: service_healthy - azurite: - condition: service_healthy - api: - condition: service_started - scheduler: - condition: service_started - register-handler-taxonomy: - condition: service_completed_successfully - - register-handler-taxonomy: - build: - context: . - dockerfile: apps/workers/taxonomy/Dockerfile - profiles: [taxonomy, all] - environment: - <<: *common-env - HANDLER_YAML_PATH: apps/workers/taxonomy/handler.yaml - SCHEDULER_URL: http://scheduler:8080 - depends_on: - scheduler: - condition: service_started - restart: "no" - entrypoint: ["npx", "tsx", "packages/shared/src/scripts/register-handler.ts"] - register-handler-post-processor: build: context: . diff --git a/docs/architecture/db-migrations.md b/docs/architecture/db-migrations.md index fbe5b1cd4..f69880a10 100644 --- a/docs/architecture/db-migrations.md +++ b/docs/architecture/db-migrations.md @@ -103,7 +103,7 @@ When you run a command: | `008-backfill-ai-call-count` | Downloads HARs from blob storage to count AI completion calls per turn | | `009-add-requests-filter-indexes` | Adds indexes on `taskPromptId`, `status`, `outcome`, `workerType`, `deletedAt` for server-side filtering/grouping | | `018-migrate-services-to-handler-dag` | Replaces the legacy single `post-processor` service row with per-handler service registrations and dependencies | -| `019-add-handler-status-index` | Adds handler-specific dispatch indexes for `pp-atif`, `pp-taxonomy`, and `pp-report` polling | +| `019-add-handler-status-index` | Adds a handler-specific dispatch index for `pp-atif` polling | | `020-backfill-handler-status` | Backfills `run.handlerStatus.pp-atif` from deprecated `run.postProcessorStatus` / `run.postProcessorVersion` fields | ## CI/CD diff --git a/docs/architecture/overview.md b/docs/architecture/overview.md index 7550c5703..cb0aa299c 100644 --- a/docs/architecture/overview.md +++ b/docs/architecture/overview.md @@ -80,7 +80,6 @@ flowchart TB | `workers/coder-acp-claude-code` | Claude Code agent via Agent Client Protocol (ACP) | | `workers/coder-acp-copilot` | GitHub Copilot agent via Agent Client Protocol (ACP) | | `workers/report-generator` | Copilot SDK worker that generates post-run markdown reports | -| `workers/taxonomy` | Copilot SDK worker that generates validated post-run taxonomy JSON | | `gateway` | AI Gateway — shared Rust TLS-intercepting proxy with plugin architecture (HAR capture, future: token refresh, rate limiting) | ## Data Flow diff --git a/docs/architecture/post-processing.md b/docs/architecture/post-processing.md index dadda0544..097dc4b28 100644 --- a/docs/architecture/post-processing.md +++ b/docs/architecture/post-processing.md @@ -34,23 +34,7 @@ Two paths trigger post-processing: - Updates `run.turns.$.atifUrl` in the request document 4. On success, stamps `run.handlerStatus["pp-atif"] = { status: "done", version: N }` (while legacy `run.postProcessorVersion` / `run.postProcessorStatus` may still be mirrored during the transition) -5. Triggers the downstream taxonomy handler once the scheduler sees the dependency graph is satisfied. (Reports are **not** a DAG handler — see [Report Triggering](#report-triggering-on-dag-drain).) - -## Taxonomy Generation - -The taxonomy handler (`apps/workers/taxonomy/`) consumes messages from the dedicated `pp-taxonomy-queue` and produces a structured JSON artifact for a completed run. It uses the GitHub Copilot SDK with a tool-backed session to fetch run metadata and ATIF trajectories from the API, then validates the generated JSON against the shared taxonomy Zod schema. - -### Flow - -1. Queue message arrives: `{ type: "taxonomy", requestId, runId }` -2. Worker fetches the request document from MongoDB and marks `run.handlerStatus["pp-taxonomy"].status = "processing"` -3. Copilot SDK session calls: - - `get_run_data` to fetch the request + active run from the API - - `get_atif_trajectory` to fetch per-iteration ATIF JSON from the API (`GET /api/v1/requests/:id/runs/:runId/atif?iteration=N`) -4. The worker validates the assistant response with `taxonomySchema.safeParse()` -5. If validation fails, the worker feeds the schema errors back into the same session and retries up to 3 total attempts -6. On success, the worker uploads `taxonomy.json` to `{requestId}/runs/{runId}/taxonomy.json` -7. The worker stamps `run.handlerStatus["pp-taxonomy"] = { status: "done", version: 1 }` and calls `POST /notify/handler-complete` +5. Triggers any downstream handlers once the scheduler sees the dependency graph is satisfied. (Reports are **not** a DAG handler — see [Report Triggering](#report-triggering-on-dag-drain).) ### Blob Storage Layout @@ -61,52 +45,16 @@ The taxonomy handler (`apps/workers/taxonomy/`) consumes messages from the dedic atif.trajectory.json iteration-2/ atif.trajectory.json - taxonomy.json ``` -The completed run stores the taxonomy blob URL on `run.taxonomyUrl`. - ### API Endpoints | Endpoint | Description | | --- | --- | | `GET /api/v1/requests/:id/atif?iteration=N` | Download ATIF for the latest run | | `GET /api/v1/requests/:id/runs/:runId/atif?iteration=N` | Download ATIF for a specific run | -| `GET /api/v1/requests/:id/taxonomy` | Download taxonomy JSON for the latest run | -| `GET /api/v1/requests/:id/runs/:runId/taxonomy` | Download taxonomy JSON for a specific run | - -The `iteration` query parameter is mandatory only for the ATIF endpoints. ATIF files are also included in archive exports as `iteration-{N}.atif.trajectory.json`. - -### Local troubleshooting: taxonomy CLI - -The Copilot SDK generation loop lives in `apps/workers/taxonomy/src/taxonomy-generator.ts` (`generateTaxonomy()`), shared by both the queue processor and a standalone developer CLI (`src/cli.ts`). The CLI invokes generation directly against an existing `requestId`, streams the session events live, and saves the validated taxonomy to a local file — **it never writes to MongoDB, blob storage, or the scheduler** (read-only). It is a developer tool and is intentionally not part of the Scope CLI. - -Because both taxonomy tools (`get_run_data` and `get_atif_trajectory`) read through the API, the CLI needs only an API URL and a Copilot token — no storage connection string. This lets it run against any environment (local or a remote deployment such as integration) given a reachable API. - -```bash -# Local stack (use your worktree's host-mapped API port): -SCOPE_MT_API_URL=http://localhost:3001 \ -GITHUB_TOKEN=$(gh auth token) \ -pnpm taxonomy [--run ] [--model ] [--timeout ] [-o out.json] [--stdout] [--quiet] - -# Integration (read-only against the deployed API): -SCOPE_MT_API_URL=https://msscope-int.azurewebsites.net \ -GITHUB_TOKEN=$(gh auth token) \ -pnpm taxonomy -o taxonomy-int.json -``` - -The root `pnpm taxonomy` script proxies to `pnpm --filter taxonomy-handler taxonomy` (i.e. `tsx src/cli.ts`). - -| Option | Description | -| --- | --- | -| `--run, -r ` | Run id used in the prompt/logging (default: active run resolved from the API) | -| `--model ` | Override `TAXONOMY_MODEL` (e.g. A/B a reasoning model vs the `gpt-5.4` default) | -| `--timeout ` | Override `SESSION_TIMEOUT_MS` (per-attempt session timeout) | -| `--output, -o ` | Output file path (default: `taxonomy-.json` in the cwd) | -| `--stdout` | Also print the taxonomy JSON to stdout | -| `--quiet` | Suppress live session-event streaming on stderr | -Live progress (tool calls, errors, deltas) is written to stderr so the saved JSON file stays clean. The CLI exits non-zero on failure (empty response, JSON/schema validation failure after 3 attempts, timeout, or model rate limit) and surfaces the underlying error message — making it well suited to diagnosing the oversized-`get_atif_trajectory` paging loop and Copilot model rate limits. +The `iteration` query parameter is mandatory for the ATIF endpoints. ATIF files are also included in archive exports as `iteration-{N}.atif.trajectory.json`. ## Handler Interface @@ -151,7 +99,7 @@ processor.start(); The post-processor uses a version-based re-processing scheme: -- Each handler is registered independently in the `services` collection using a `post-process-handler` document keyed by handler ID (for example `pp-atif`, `pp-taxonomy`) +- Each handler is registered independently in the `services` collection using a `post-process-handler` document keyed by handler ID (for example `pp-atif`) - Each registration stores the handler `version`, queue name, selector, `autoBackfill` behavior, and `dependsOn` DAG edges - The scheduler compares each request's per-handler status/version state (for example `run.handlerStatus["pp-atif"]`) against the registered handler entry for that selector - Requests with missing or outdated handler state are re-dispatched for that specific handler once its dependencies are satisfied @@ -160,18 +108,17 @@ This allows deploying handler improvements and having them automatically applied ## Handler Registration -Each post-process handler declares its DAG metadata in a `handler.yaml` file colocated with the worker (e.g. `apps/workers/taxonomy/handler.yaml`). This mirrors the coding-agent `agent.yaml` registration pattern. +Each post-process handler declares its DAG metadata in a `handler.yaml` file colocated with the worker (e.g. `apps/workers/post-processor/handler.yaml`). This mirrors the coding-agent `agent.yaml` registration pattern. ```yaml -# apps/workers/taxonomy/handler.yaml -_id: pp-taxonomy +# apps/workers/post-processor/handler.yaml +_id: pp-atif type: post-process-handler version: 1 -queue: pp-taxonomy-queue -selector: taxonomy -autoBackfill: false -dependsOn: - - pp-atif +queue: post-processor-queue +selector: atif +autoBackfill: true +dependsOn: [] ``` On deploy, a generic registration script — `packages/shared/src/scripts/register-handler.ts` — reads the `handler.yaml` pointed to by `HANDLER_YAML_PATH`, waits for the scheduler's `/health`, and POSTs the parsed document to `${SCHEDULER_URL}/handlers/register` (with retry). The scheduler validates the document, runs a cycle check against the existing topology, and upserts it into the `services` collection via `HandlerDispatcher.registerHandler`. **Workers never write to the `services` collection directly** — the scheduler is the sole owner. @@ -185,8 +132,8 @@ flowchart LR **Where it runs:** -- **Docker Compose:** one `register-handler-` init service per handler (`pp-atif`, `pp-taxonomy`) runs the generic script via `tsx`, gated on the scheduler being up. The worker waits for its registration service to complete. -- **Kubernetes:** a `register-handler-post-processor` Job runs the compiled script (`node packages/shared/dist/scripts/register-handler.js`) on each deploy. Only `pp-atif` is registered in K8s today because it is the only post-process handler deployed to the cluster; `pp-taxonomy` is registered in Compose only until the taxonomy worker is deployed to K8s. The scheduler exposes a `Service` (`scheduler.scoped.svc.cluster.local:8080`) so Jobs and workers can reach `/handlers/register` and the notify endpoints. +- **Docker Compose:** one `register-handler-` init service per handler (e.g. `pp-atif`) runs the generic script via `tsx`, gated on the scheduler being up. The worker waits for its registration service to complete. +- **Kubernetes:** a `register-handler-post-processor` Job runs the compiled script (`node packages/shared/dist/scripts/register-handler.js`) on each deploy. Only `pp-atif` is registered in K8s today because it is the only post-process handler deployed to the cluster. The scheduler exposes a `Service` (`scheduler.scoped.svc.cluster.local:8080`) so Jobs and workers can reach `/handlers/register` and the notify endpoints. To **add or change a handler**, edit its `handler.yaml` (bump `version`, adjust `dependsOn`, etc.) — no code or migration changes are needed for registration. @@ -206,7 +153,7 @@ active template's trigger and creates the properly-templated `ReportDocument`(s) ```mermaid flowchart LR - RT["run done"] --> DAG["scheduler dispatches DAG
(pp-atif → pp-taxonomy)"] + RT["run done"] --> DAG["scheduler dispatches DAG
(pp-atif → …)"] DAG -->|"all handlers terminal"| DR{DAG drained?} DR -->|yes| TRIG["POST /api/v1/reports/trigger
{ requestId }"] TRIG --> API["API evaluates each template trigger
→ ReportDocument + report-queue {reportId}"] @@ -219,7 +166,7 @@ flowchart LR registered handler is terminal. A run with **zero** registered handlers is vacuously drained, so reports trigger immediately on run completion. -**Trigger regardless of handler success.** A flaky taxonomy must not block report +**Trigger regardless of handler success.** A flaky handler must not block report generation (reports analyze the run/ATIF), so the scheduler triggers reports whenever the DAG drains — not only when every handler succeeded. @@ -263,20 +210,18 @@ stateDiagram-v2 | Environment Variable | Default | Description | | --- | --- | --- | | `AZURE_STORAGE_QUEUE_POSTPROCESSOR` | `post-processor-queue` | Queue name for ATIF post-processor messages | -| `AZURE_STORAGE_QUEUE_TAXONOMY` | `pp-taxonomy-queue` | Queue name for taxonomy-generation jobs | | `SCHEDULER_PP_POLL_INTERVAL_MS` | `30000` | Scheduler backfill polling interval | | `BATCH_SIZE` | `1` | Messages to process per poll (worker-side) | | `POLL_INTERVAL_MS` | `5000` | Worker queue polling interval | -| `SCOPE_MT_API_URL` / `API_BASE_URL` | `http://localhost:3001` | API URL used by report/taxonomy Copilot tools | -| `TAXONOMY_MODEL` | `gpt-5.4` | Copilot SDK model used by the taxonomy handler | -| `SESSION_TIMEOUT_MS` | `300000` | Timeout for report/taxonomy Copilot SDK sessions | +| `SCOPE_MT_API_URL` / `API_BASE_URL` | `http://localhost:3001` | API URL used by report Copilot tools | +| `SESSION_TIMEOUT_MS` | `300000` | Timeout for report Copilot SDK sessions | | `SCHEDULER_URL` | _(unset)_ | Scheduler base URL — used by handlers to notify completion and by the register-handler script to reach `/handlers/register` | | `API_URL` | `http://api:80` | API base URL the **scheduler** uses to call `POST /api/v1/reports/trigger` when a run's handler DAG drains | | `HANDLER_YAML_PATH` | _(unset)_ | Path to a handler's `handler.yaml`, read by the generic `register-handler` script | ## Infrastructure -- **Queues:** Azure Storage Queues (`post-processor-queue` for ATIF, `pp-taxonomy-queue` for taxonomy, `report-queue` for reports) +- **Queues:** Azure Storage Queues (`post-processor-queue` for ATIF, `report-queue` for reports) - **Blob Storage:** `snapshots` container (shared with HAR, tool-calls, and other artifacts) - **Database:** MongoDB `requests` collection (`run.handlerStatus` map keyed by handler ID, with deprecated `run.postProcessorStatus` / `run.postProcessorVersion` compatibility fields during migration) - **KEDA:** ScaledObject scales the worker to zero when queue is empty diff --git a/package.json b/package.json index 2905d5efc..46b946a51 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,6 @@ "dev:coder-acp-claude-code": "pnpm --filter coder-acp-claude-code dev", "dev:coder-acp-copilot": "pnpm --filter coder-acp-copilot dev", "dev:report-generator": "pnpm --filter report-generator dev", - "taxonomy": "pnpm --filter taxonomy-handler taxonomy", "docker:up": "scripts/worktree-env.sh && scripts/dev-compose.sh up --build", "docker:up:claude-code": "scripts/worktree-env.sh && scripts/dev-compose.sh --env-file .env --env-file apps/workers/coder-acp-claude-code/versions.env --profile claude-code --profile report up --build", "docker:up:copilot": "scripts/worktree-env.sh && scripts/dev-compose.sh --env-file .env --env-file apps/workers/coder-acp-copilot/versions.env --profile copilot --profile report up --build", @@ -26,9 +25,9 @@ "docker:up:portal": "scripts/worktree-env.sh && scripts/dev-compose.sh --profile portal up --build", "docker:dev": "scripts/worktree-env.sh && COMPOSE_EXPERIMENTAL_WATCH_POLL=1 scripts/dev-compose.sh -f docker-compose.yml -f docker-compose.dev.yml up --build --watch", "docker:dev:claude-code": "scripts/worktree-env.sh && GIT_BRANCH=$(git rev-parse --abbrev-ref HEAD) COMPOSE_EXPERIMENTAL_WATCH_POLL=1 scripts/dev-compose.sh --env-file .env --env-file apps/workers/coder-acp-claude-code/versions.env -f docker-compose.yml -f docker-compose.dev.yml --profile claude-code --profile report --profile post-processor --profile portal up --build --watch", - "docker:dev:copilot": "scripts/worktree-env.sh && GIT_BRANCH=$(git rev-parse --abbrev-ref HEAD) COMPOSE_EXPERIMENTAL_WATCH_POLL=1 scripts/dev-compose.sh --env-file .env --env-file apps/workers/coder-acp-copilot/versions.env -f docker-compose.yml -f docker-compose.dev.yml --profile copilot --profile report --profile post-processor --profile taxonomy --profile portal up --build --watch", + "docker:dev:copilot": "scripts/worktree-env.sh && GIT_BRANCH=$(git rev-parse --abbrev-ref HEAD) COMPOSE_EXPERIMENTAL_WATCH_POLL=1 scripts/dev-compose.sh --env-file .env --env-file apps/workers/coder-acp-copilot/versions.env -f docker-compose.yml -f docker-compose.dev.yml --profile copilot --profile report --profile post-processor --profile portal up --build --watch", "docker:dev:portal": "scripts/worktree-env.sh && GIT_BRANCH=$(git rev-parse --abbrev-ref HEAD) COMPOSE_EXPERIMENTAL_WATCH_POLL=1 scripts/dev-compose.sh -f docker-compose.yml -f docker-compose.dev.yml --profile portal up --build --watch", - "docker:dev:all": "scripts/worktree-env.sh && GIT_BRANCH=$(git rev-parse --abbrev-ref HEAD) COMPOSE_EXPERIMENTAL_WATCH_POLL=1 scripts/dev-compose.sh --env-file .env --env-file apps/workers/coder-acp-copilot/versions.env --env-file apps/workers/coder-acp-claude-code/versions.env -f docker-compose.yml -f docker-compose.dev.yml --profile claude-code --profile copilot --profile report --profile post-processor --profile taxonomy --profile portal up --build --watch", + "docker:dev:all": "scripts/worktree-env.sh && GIT_BRANCH=$(git rev-parse --abbrev-ref HEAD) COMPOSE_EXPERIMENTAL_WATCH_POLL=1 scripts/dev-compose.sh --env-file .env --env-file apps/workers/coder-acp-copilot/versions.env --env-file apps/workers/coder-acp-claude-code/versions.env -f docker-compose.yml -f docker-compose.dev.yml --profile claude-code --profile copilot --profile report --profile post-processor --profile portal up --build --watch", "docker:dev:build": "scripts/worktree-env.sh && GIT_BRANCH=$(git rev-parse --abbrev-ref HEAD) scripts/dev-compose.sh --env-file .env --env-file apps/workers/coder-acp-copilot/versions.env --env-file apps/workers/coder-acp-claude-code/versions.env -f docker-compose.yml -f docker-compose.dev.yml --profile claude-code --profile copilot --profile report --profile portal build", "docker:down": "scripts/worktree-env.sh && scripts/dev-compose.sh --env-file .env --env-file apps/workers/coder-acp-copilot/versions.env --env-file apps/workers/coder-acp-claude-code/versions.env --profile claude-code --profile copilot --profile report --profile portal down --volumes", "docker:build": "scripts/worktree-env.sh && scripts/dev-compose.sh --env-file .env --env-file apps/workers/coder-acp-copilot/versions.env --env-file apps/workers/coder-acp-claude-code/versions.env --profile claude-code --profile copilot --profile report build", diff --git a/packages/db-migrations/src/migrations/018-migrate-services-to-handler-dag.ts b/packages/db-migrations/src/migrations/018-migrate-services-to-handler-dag.ts index bcb3b3ade..6d2aa2c94 100644 --- a/packages/db-migrations/src/migrations/018-migrate-services-to-handler-dag.ts +++ b/packages/db-migrations/src/migrations/018-migrate-services-to-handler-dag.ts @@ -7,7 +7,7 @@ import type { MigrationInterface } from "mongo-migrate-ts"; /** * Migration 018: Remove the legacy "post-processor" service entry. * - * Handler service registration (pp-atif, pp-taxonomy) is now owned + * Handler service registration (e.g. pp-atif) is now owned * by each worker's handler.yaml + the generic register-handler script, * which POSTs to the scheduler's POST /handlers/register endpoint on deploy. * Reports are not a DAG handler — the scheduler triggers report generation diff --git a/packages/db-migrations/src/migrations/019-add-handler-status-index.ts b/packages/db-migrations/src/migrations/019-add-handler-status-index.ts index 577dc8901..ee6e35f95 100644 --- a/packages/db-migrations/src/migrations/019-add-handler-status-index.ts +++ b/packages/db-migrations/src/migrations/019-add-handler-status-index.ts @@ -13,14 +13,6 @@ const HANDLER_STATUS_INDEXES = [ updatedAt: -1 as const, }, }, - { - name: "idx_handler_dispatch_taxonomy", - key: { - "run.handlerStatus.pp-taxonomy.status": 1 as const, - "run.status": 1 as const, - updatedAt: -1 as const, - }, - }, ] as const; export class AddHandlerStatusIndex implements MigrationInterface { diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index e2af74303..392a9cde2 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -21,5 +21,4 @@ export * from "./devproxy/index.js"; export * from "./har/index.js"; export * from "./utils/index.js"; export * from "./schemas/index.js"; -export * from "./schemas/taxonomy.js"; export * from "./cursor.js"; diff --git a/packages/shared/src/schemas/index.ts b/packages/shared/src/schemas/index.ts index 06332d90d..b64fcee87 100644 --- a/packages/shared/src/schemas/index.ts +++ b/packages/shared/src/schemas/index.ts @@ -19,4 +19,3 @@ export * from "./token.js"; export * from "./account.js"; export * from "./feature-flag.js"; export * from "./profile.js"; -export * from "./taxonomy.js"; diff --git a/packages/shared/src/schemas/request.ts b/packages/shared/src/schemas/request.ts index ea6f3d4da..bdcf6cc88 100644 --- a/packages/shared/src/schemas/request.ts +++ b/packages/shared/src/schemas/request.ts @@ -173,7 +173,6 @@ export const RunStateSchema = z podName: z.string().optional(), }).optional(), harUrl: z.string().optional(), - taxonomyUrl: z.string().optional(), videoUrls: z.array(z.string()).optional(), setupVideoUrls: z.array(z.string()).optional(), tokenUsage: TokenUsageSchema.optional(), diff --git a/packages/shared/src/schemas/taxonomy.test.ts b/packages/shared/src/schemas/taxonomy.test.ts deleted file mode 100644 index 320f89ceb..000000000 --- a/packages/shared/src/schemas/taxonomy.test.ts +++ /dev/null @@ -1,180 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -import { describe, expect, it } from "vitest"; -import { taxonomySchema } from "./taxonomy.js"; - -describe("taxonomySchema", () => { - const validFixture = { - meta: { - generatedAt: "2025-02-01T12:00:00Z", - schemaVersion: "1.0.0", - runId: "run-123", - requestId: "req-456", - }, - scorecard: { - summary: "The agent usually succeeds but struggles with recovery quality.", - profileResults: [ - { - profileId: "profile-best", - select: "best", - taskOutcome: "success", - overallScore: 92, - dimensions: [ - { - name: "Correctness", - score: 95, - rationale: "Implemented the requested behavior fully.", - evidence: ["All acceptance criteria passed", "No regressions observed"], - }, - ], - }, - ], - crossProfileInsights: [ - "The agent communicates clearly across profiles.", - "Stress conditions reduce recovery quality.", - ], - }, - behaviorAnalysis: { - patterns: [ - { - id: "pattern-1", - category: "strength", - title: "Consistent planning", - description: "The agent outlines a clear plan before making changes.", - frequency: 0.75, - impact: "high", - evidence: [ - { - runId: "run-123", - iteration: 1, - excerpt: "Plan: inspect the failing endpoint, then patch the handler.", - }, - ], - }, - ], - agentPersonality: { - communicationStyle: "Concise and structured", - problemSolvingApproach: "Hypothesis-driven debugging", - errorRecoveryBehavior: "Retries with targeted fixes after inspecting evidence", - }, - }, - actionList: { - fix: [ - { - id: "action-1", - title: "Stabilize recovery prompts", - priority: "critical", - description: "Reduce failures when the first attempt is rejected.", - ownership: "prompt-engineer", - relatedPatterns: ["pattern-1"], - suggestedApproach: "Add explicit fallback guidance for failed first passes.", - }, - ], - improve: [ - { - id: "action-2", - title: "Expand planning examples", - priority: "medium", - description: "Reinforce successful planning behavior.", - ownership: "agent-team", - relatedPatterns: ["pattern-1"], - suggestedApproach: "Fine-tune on traces with strong upfront decomposition.", - }, - ], - investigate: [ - { - id: "action-3", - title: "Review profile variance", - priority: "low", - description: "Understand why profile-specific regressions appear sporadically.", - ownership: "scenario-author", - relatedPatterns: ["pattern-1"], - suggestedApproach: "Compare scenario wording against affected profile runs.", - }, - ], - }, - }; - - it("accepts a valid taxonomy fixture", () => { - const result = taxonomySchema.safeParse(validFixture); - expect(result.success).toBe(true); - }); - - it("rejects fixtures missing required fields with precise paths", () => { - const invalidFixture = { - ...validFixture, - scorecard: { - ...validFixture.scorecard, - profileResults: [ - { - ...validFixture.scorecard.profileResults[0], - dimensions: [ - { - name: "Correctness", - score: 95, - evidence: ["All acceptance criteria passed"], - }, - ], - }, - ], - }, - behaviorAnalysis: { - ...validFixture.behaviorAnalysis, - agentPersonality: { - communicationStyle: "Concise and structured", - errorRecoveryBehavior: "Retries with targeted fixes after inspecting evidence", - }, - }, - }; - - const result = taxonomySchema.safeParse(invalidFixture); - expect(result.success).toBe(false); - if (result.success) { - throw new Error("Expected invalid taxonomy fixture to fail parsing"); - } - - const issuePaths = result.error.issues.map((issue) => issue.path); - expect(issuePaths).toContainEqual([ - "scorecard", - "profileResults", - 0, - "dimensions", - 0, - "rationale", - ]); - expect(issuePaths).toContainEqual([ - "behaviorAnalysis", - "agentPersonality", - "problemSolvingApproach", - ]); - }); - - it("enforces enum values", () => { - const invalidFixture = { - ...validFixture, - actionList: { - ...validFixture.actionList, - fix: [ - { - ...validFixture.actionList.fix[0], - priority: "urgent", - }, - ], - }, - }; - - const result = taxonomySchema.safeParse(invalidFixture); - expect(result.success).toBe(false); - if (result.success) { - throw new Error("Expected invalid enum fixture to fail parsing"); - } - - expect(result.error.issues.map((issue) => issue.path)).toContainEqual([ - "actionList", - "fix", - 0, - "priority", - ]); - }); -}); diff --git a/packages/shared/src/schemas/taxonomy.ts b/packages/shared/src/schemas/taxonomy.ts deleted file mode 100644 index a837d4f91..000000000 --- a/packages/shared/src/schemas/taxonomy.ts +++ /dev/null @@ -1,104 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -import { z } from "zod"; - -const scoreSchema = z.number().min(0).max(100); -const frequencySchema = z.number().min(0).max(1); - -/** Taxonomy document metadata. */ -export const taxonomyMetaSchema = z.object({ - generatedAt: z.string().datetime({ offset: true }), - schemaVersion: z.string().min(1), - runId: z.string().min(1), - requestId: z.string().min(1), -}); - -/** Scorecard dimension result for a single profile. */ -export const scorecardDimensionSchema = z.object({ - name: z.string().min(1), - score: scoreSchema, - rationale: z.string().min(1), - evidence: z.array(z.string().min(1)), -}); - -/** Scorecard result for a specific profile slice. */ -export const profileResultSchema = z.object({ - profileId: z.string().min(1), - select: z.enum(["best", "median", "worst"]), - taskOutcome: z.enum(["success", "partial", "failure"]), - overallScore: scoreSchema, - dimensions: z.array(scorecardDimensionSchema), -}); - -/** Top-level scorecard section summarizing profile results. */ -export const scorecardSchema = z.object({ - summary: z.string().min(1), - profileResults: z.array(profileResultSchema), - crossProfileInsights: z.array(z.string().min(1)), -}); - -/** Evidence excerpt supporting an observed behavior pattern. */ -export const behaviorEvidenceSchema = z.object({ - runId: z.string().min(1), - iteration: z.number().int().min(0), - excerpt: z.string().min(1), -}); - -/** Cross-run behavioral pattern identified by the taxonomy analysis. */ -export const behaviorPatternSchema = z.object({ - id: z.string().min(1), - category: z.enum(["strength", "weakness", "inconsistency", "regression"]), - title: z.string().min(1), - description: z.string().min(1), - frequency: frequencySchema, - impact: z.enum(["critical", "high", "medium", "low"]), - evidence: z.array(behaviorEvidenceSchema), -}); - -/** Qualitative description of the agent's personality traits. */ -export const agentPersonalitySchema = z.object({ - communicationStyle: z.string().min(1), - problemSolvingApproach: z.string().min(1), - errorRecoveryBehavior: z.string().min(1), -}); - -/** Behavior analysis section describing patterns and personality. */ -export const behaviorAnalysisSchema = z.object({ - patterns: z.array(behaviorPatternSchema), - agentPersonality: agentPersonalitySchema, -}); - -/** Action item emitted by the taxonomy analysis. */ -export const actionItemSchema = z.object({ - id: z.string().min(1), - title: z.string().min(1), - priority: z.enum(["critical", "high", "medium", "low"]), - description: z.string().min(1), - ownership: z.enum([ - "agent-team", - "platform-team", - "prompt-engineer", - "scenario-author", - ]), - relatedPatterns: z.array(z.string().min(1)), - suggestedApproach: z.string().min(1), -}); - -/** Prioritized action lists grouped by remediation intent. */ -export const actionListSchema = z.object({ - fix: z.array(actionItemSchema), - improve: z.array(actionItemSchema), - investigate: z.array(actionItemSchema), -}); - -/** Complete taxonomy document produced by the taxonomy post-processor. */ -export const taxonomySchema = z.object({ - meta: taxonomyMetaSchema, - scorecard: scorecardSchema, - behaviorAnalysis: behaviorAnalysisSchema, - actionList: actionListSchema, -}); - -/** Inferred TypeScript type for the taxonomy document. */ -export type TaxonomyDocument = z.infer; diff --git a/packages/shared/src/scripts/register-handler.test.ts b/packages/shared/src/scripts/register-handler.test.ts index 0c3858b58..162916404 100644 --- a/packages/shared/src/scripts/register-handler.test.ts +++ b/packages/shared/src/scripts/register-handler.test.ts @@ -6,11 +6,11 @@ import { parseHandlerDocument } from "./register-handler.js"; describe("parseHandlerDocument", () => { const valid = { - _id: "pp-taxonomy", + _id: "pp-example", type: "post-process-handler", version: 1, - queue: "pp-taxonomy-queue", - selector: "taxonomy", + queue: "pp-example-queue", + selector: "example", autoBackfill: false, dependsOn: ["pp-atif"], }; diff --git a/packages/shared/src/types/types.ts b/packages/shared/src/types/types.ts index 1a176e9c1..374d5b2ca 100644 --- a/packages/shared/src/types/types.ts +++ b/packages/shared/src/types/types.ts @@ -272,14 +272,13 @@ export interface RunState { podName?: string; }; harUrl?: string; - taxonomyUrl?: string; videoUrls?: string[]; setupVideoUrls?: string[]; tokenUsage?: TokenUsage; aiCallCount?: number; rawChatUrl?: string; rawChatFormat?: string; - /** Per-handler post-processing status. Keys are handler IDs (e.g. "pp-atif", "pp-taxonomy"). */ + /** Per-handler post-processing status. Keys are handler IDs (e.g. "pp-atif"). */ handlerStatus?: Record; /** Set once the scheduler has triggered report generation for this run after * the handler DAG drained (every handler reached a terminal state). Acts as @@ -329,7 +328,6 @@ export const RUN_STATE_FIELD_NAMES = [ "lastHeartbeatAt", "worker", "harUrl", - "taxonomyUrl", "videoUrls", "setupVideoUrls", "tokenUsage", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a7903fc00..339c191ba 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -705,31 +705,6 @@ importers: specifier: ^5.6.2 version: 5.9.3 - apps/workers/taxonomy: - dependencies: - '@azure/storage-queue': - specifier: ^12.25.0 - version: 12.29.0 - '@github/copilot-sdk': - specifier: ^0.1.17 - version: 0.1.23 - dotenv: - specifier: ^16.4.5 - version: 16.6.1 - shared: - specifier: workspace:* - version: link:../../../packages/shared - devDependencies: - '@types/node': - specifier: ^22.7.0 - version: 22.19.10 - tsx: - specifier: ^4.19.0 - version: 4.21.0 - typescript: - specifier: ^5.6.2 - version: 5.9.3 - packages/copilot-driver-ext: devDependencies: '@types/node': From 688fbb52ca9f1473c8ac29bb133a199c840a5770 Mon Sep 17 00:00:00 2001 From: Cedric Vidal Date: Wed, 1 Jul 2026 11:46:05 -0700 Subject: [PATCH 10/19] fix(scheduler): allow autoBackfill to re-dispatch runs with outdated versions The autoBackfill feature re-processes historical runs when a handler version bumps, enabling backfill of new/updated post-processing features across the whole dataset. However, buildDispatchFilter() was excluding 'done' status universally, preventing the version-check branch ($lt version) from matching any historical runs. This change makes status exclusions conditional: - autoBackfill handlers exclude only ['queued', 'processing'], allowing done runs to qualify for version-based re-dispatch - Non-backfill handlers still exclude 'done' (only dispatch on missing handler status) Fixes regression identified in #1147 code review. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- apps/scheduler/src/handler-dispatcher.test.ts | 34 +++++++++++++++++++ apps/scheduler/src/handler-dispatcher.ts | 6 +++- 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/apps/scheduler/src/handler-dispatcher.test.ts b/apps/scheduler/src/handler-dispatcher.test.ts index 67264d02c..53d652061 100644 --- a/apps/scheduler/src/handler-dispatcher.test.ts +++ b/apps/scheduler/src/handler-dispatcher.test.ts @@ -249,3 +249,37 @@ describe("HandlerDispatcher — maybeTriggerReports", () => { expect(fetchMock).not.toHaveBeenCalled(); }); }); + +describe("HandlerDispatcher — buildDispatchFilter", () => { + let collection: ReturnType; + + beforeEach(() => { + collection = createMockCollection(); + }); + + it("allows done runs to be considered for autoBackfill version bumps", () => { + const d = makeDispatcher(collection) as any; + const autoBackfillHandler = HANDLERS[0]; + + const filter = d.buildDispatchFilter(autoBackfillHandler, HANDLERS, d.buildGraph(HANDLERS)); + + expect(filter[`run.handlerStatus.${autoBackfillHandler._id}.status`]).toEqual({ + $nin: ["queued", "processing"], + }); + expect(filter.$or).toEqual([ + { [`run.handlerStatus.${autoBackfillHandler._id}.version`]: { $exists: false } }, + { [`run.handlerStatus.${autoBackfillHandler._id}.version`]: { $lt: autoBackfillHandler.version } }, + ]); + }); + + it("keeps done runs excluded for non-backfill handlers", () => { + const d = makeDispatcher(collection) as any; + const noBackfillHandler = HANDLERS[1]; + + const filter = d.buildDispatchFilter(noBackfillHandler, HANDLERS, d.buildGraph(HANDLERS)); + + expect(filter[`run.handlerStatus.${noBackfillHandler._id}.status`]).toEqual({ + $nin: ["queued", "processing", "done"], + }); + }); +}); diff --git a/apps/scheduler/src/handler-dispatcher.ts b/apps/scheduler/src/handler-dispatcher.ts index 615a45e3d..1dcbe878b 100644 --- a/apps/scheduler/src/handler-dispatcher.ts +++ b/apps/scheduler/src/handler-dispatcher.ts @@ -400,10 +400,14 @@ export class HandlerDispatcher implements NotifyHandler { allHandlers: HandlerServiceDocument[], _graph: DependencyGraph, ): Record | null { + const statusExclusions = handler.autoBackfill + ? ["queued", "processing"] + : ["queued", "processing", "done"]; + const filter: Record = { "run.status": "done", deletedAt: { $exists: false }, - [`run.handlerStatus.${handler._id}.status`]: { $nin: ["queued", "processing", "done"] }, + [`run.handlerStatus.${handler._id}.status`]: { $nin: statusExclusions }, }; // All dependencies must be "done" From 7288b65dd928a4dcad0a114cfaed2ca7d87622ab Mon Sep 17 00:00:00 2001 From: Cedric Vidal Date: Wed, 1 Jul 2026 13:32:05 -0700 Subject: [PATCH 11/19] fix(scheduler): ignore stale handler completion notifications Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- apps/scheduler/src/handler-dispatcher.test.ts | 44 ++++++++++++++++++- apps/scheduler/src/handler-dispatcher.ts | 15 +++++-- 2 files changed, 55 insertions(+), 4 deletions(-) diff --git a/apps/scheduler/src/handler-dispatcher.test.ts b/apps/scheduler/src/handler-dispatcher.test.ts index 53d652061..3adf8fb76 100644 --- a/apps/scheduler/src/handler-dispatcher.test.ts +++ b/apps/scheduler/src/handler-dispatcher.test.ts @@ -30,7 +30,7 @@ function createMockCollection() { return { findOne: vi.fn(), findOneAndUpdate: vi.fn(), - updateOne: vi.fn().mockResolvedValue(undefined), + updateOne: vi.fn().mockResolvedValue({ matchedCount: 1 }), find: vi.fn(), } as any; } @@ -250,6 +250,48 @@ describe("HandlerDispatcher — maybeTriggerReports", () => { }); }); +describe("HandlerDispatcher — onHandlerComplete", () => { + let collection: ReturnType; + + beforeEach(() => { + collection = createMockCollection(); + }); + + it("only accepts completion when current handler status is processing", async () => { + const d = makeDispatcher(collection) as any; + const maybeTriggerReportsSpy = vi + .spyOn(d, "maybeTriggerReports") + .mockResolvedValue(undefined); + + await d.onHandlerComplete("req-1", "run-1", "pp-atif", "failed"); + + expect(collection.updateOne).toHaveBeenCalledWith( + { _id: "req-1", "run.handlerStatus.pp-atif.status": "processing" }, + { + $set: { + "run.handlerStatus.pp-atif.status": "failed", + "run.handlerStatus.pp-atif.updatedAt": expect.any(Date), + }, + }, + ); + expect(maybeTriggerReportsSpy).toHaveBeenCalledWith("req-1", "run-1"); + }); + + it("ignores stale or duplicate completion notifications", async () => { + collection.updateOne.mockResolvedValue({ matchedCount: 0 }); + const d = makeDispatcher(collection) as any; + const maybeTriggerReportsSpy = vi + .spyOn(d, "maybeTriggerReports") + .mockResolvedValue(undefined); + const loadHandlersSpy = vi.spyOn(d, "loadHandlers"); + + await d.onHandlerComplete("req-1", "run-1", "pp-atif", "done"); + + expect(maybeTriggerReportsSpy).not.toHaveBeenCalled(); + expect(loadHandlersSpy).not.toHaveBeenCalled(); + }); +}); + describe("HandlerDispatcher — buildDispatchFilter", () => { let collection: ReturnType; diff --git a/apps/scheduler/src/handler-dispatcher.ts b/apps/scheduler/src/handler-dispatcher.ts index 1dcbe878b..c7b99b148 100644 --- a/apps/scheduler/src/handler-dispatcher.ts +++ b/apps/scheduler/src/handler-dispatcher.ts @@ -89,9 +89,11 @@ export class HandlerDispatcher implements NotifyHandler { `[HandlerDispatcher] Notify: handler-complete ${handlerId} for ${requestId} (status=${status})`, ); - // Update the handler status on the run document - await this.collection.updateOne( - { _id: requestId } as any, + // Only apply terminal status from an active in-flight execution. + // This avoids stale/duplicate notifies regressing a newly re-dispatched + // handler back to "done" or "failed". + const updateResult = await this.collection.updateOne( + { _id: requestId, [`run.handlerStatus.${handlerId}.status`]: "processing" } as any, { $set: { [`run.handlerStatus.${handlerId}.status`]: status, @@ -100,6 +102,13 @@ export class HandlerDispatcher implements NotifyHandler { } as any, ); + if (updateResult.matchedCount === 0) { + console.log( + `[HandlerDispatcher] Ignored stale/duplicate completion for ${handlerId} on ${requestId}`, + ); + return; + } + if (status === "failed") { // Don't dispatch downstream on failure, but the failure may have drained // the DAG (descendants are now permanently blocked) — check for reports. From 5c64dea4d76a13519a6dfb00dfefae63a1debbd0 Mon Sep 17 00:00:00 2001 From: Cedric Vidal Date: Wed, 1 Jul 2026 14:19:12 -0700 Subject: [PATCH 12/19] fix(post-processing): enforce worker-entrypoint idempotency Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- apps/scheduler/src/handler-dispatcher.test.ts | 87 +++++++++ apps/scheduler/src/handler-dispatcher.ts | 86 ++++---- .../src/handlers/atif-handler.ts | 3 + .../post-processor/src/post-processor.test.ts | 184 ++++++++++++++++++ .../post-processor/src/post-processor.ts | 139 ++++++++++--- apps/workers/post-processor/src/types.ts | 2 + docs/architecture/post-processing.md | 21 +- 7 files changed, 449 insertions(+), 73 deletions(-) create mode 100644 apps/workers/post-processor/src/post-processor.test.ts diff --git a/apps/scheduler/src/handler-dispatcher.test.ts b/apps/scheduler/src/handler-dispatcher.test.ts index 3adf8fb76..9431be37b 100644 --- a/apps/scheduler/src/handler-dispatcher.test.ts +++ b/apps/scheduler/src/handler-dispatcher.test.ts @@ -54,6 +54,21 @@ function makeDispatcher( return new HandlerDispatcher(collection, db, createQueueClient, 60_000, 30, apiUrl); } +function makeDispatcherWithQueue( + collection: any, + handlers: HandlerServiceDocument[] = HANDLERS, + apiUrl = "http://api.test", +) { + const db = createMockDb(handlers); + const queueClient = { + createIfNotExists: vi.fn().mockResolvedValue(undefined), + sendMessage: vi.fn().mockResolvedValue(undefined), + }; + const createQueueClient = vi.fn().mockReturnValue(queueClient); + const dispatcher = new HandlerDispatcher(collection, db, createQueueClient, 60_000, 30, apiUrl); + return { dispatcher, queueClient, createQueueClient }; +} + describe("HandlerDispatcher — drain detection", () => { let collection: ReturnType; @@ -324,4 +339,76 @@ describe("HandlerDispatcher — buildDispatchFilter", () => { $nin: ["queued", "processing", "done"], }); }); + + describe("HandlerDispatcher — dispatchIfEligible", () => { + let collection: ReturnType; + + beforeEach(() => { + collection = createMockCollection(); + }); + + it("allows autoBackfill re-dispatch for outdated done versions", async () => { + collection.findOne.mockResolvedValue({ + _id: "req-1", + run: { + _id: "run-1", + status: "done", + handlerStatus: { + "pp-atif": { status: "done", version: 0 }, + }, + }, + }); + collection.updateOne.mockResolvedValue({ matchedCount: 1 }); + + const { dispatcher, queueClient } = makeDispatcherWithQueue(collection); + await (dispatcher as any).dispatchIfEligible( + "req-1", + "run-1", + HANDLERS[0], + (dispatcher as any).buildGraph(HANDLERS), + HANDLERS, + ); + + expect(queueClient.sendMessage).toHaveBeenCalledTimes(1); + expect(collection.updateOne).toHaveBeenCalledWith( + expect.objectContaining({ + _id: "req-1", + "run._id": "run-1", + "run.status": "done", + "run.handlerStatus.pp-atif.status": { $nin: ["queued", "processing"] }, + $or: [ + { "run.handlerStatus.pp-atif.version": { $exists: false } }, + { "run.handlerStatus.pp-atif.version": { $lt: 1 } }, + ], + }), + { + $set: { + "run.handlerStatus.pp-atif.status": "queued", + "run.handlerStatus.pp-atif.updatedAt": expect.any(Date), + }, + }, + ); + }); + + it("enqueues before marking the handler queued", async () => { + collection.findOne.mockResolvedValue({ + _id: "req-1", + run: { _id: "run-1", status: "done", handlerStatus: {} }, + }); + collection.updateOne.mockResolvedValue({ matchedCount: 1 }); + + const { dispatcher, queueClient } = makeDispatcherWithQueue(collection); + await (dispatcher as any).dispatchIfEligible( + "req-1", + "run-1", + HANDLERS[0], + (dispatcher as any).buildGraph(HANDLERS), + HANDLERS, + ); + + expect(queueClient.sendMessage.mock.invocationCallOrder[0]).toBeLessThan( + collection.updateOne.mock.invocationCallOrder[0], + ); + }); + }); }); diff --git a/apps/scheduler/src/handler-dispatcher.ts b/apps/scheduler/src/handler-dispatcher.ts index c7b99b148..92a9752bc 100644 --- a/apps/scheduler/src/handler-dispatcher.ts +++ b/apps/scheduler/src/handler-dispatcher.ts @@ -288,26 +288,21 @@ export class HandlerDispatcher implements NotifyHandler { const graph = this.buildGraph(handlers); for (const handler of handlers) { - // Find runs that need this handler dispatched - for (let i = 0; i < this.batchSize; i++) { - const filter = this.buildDispatchFilter(handler, handlers, graph); - if (!filter) break; - - const claimed = await this.collection.findOneAndUpdate( - filter as any, - { - $set: { - [`run.handlerStatus.${handler._id}.status`]: "queued", - [`run.handlerStatus.${handler._id}.updatedAt`]: new Date(), - }, - } as any, - { sort: { updatedAt: -1 }, returnDocument: "after" }, - ); - - if (!claimed) break; - - await this.enqueueToHandler(handler, claimed._id, (claimed as any).run?._id); - console.log(`[HandlerDispatcher] Poll-dispatched ${handler._id} for ${claimed._id}`); + const filter = this.buildDispatchFilter(handler, handlers, graph); + if (!filter) continue; + + const candidates = await this.collection + .find(filter as any, { + projection: { _id: 1, "run._id": 1 }, + sort: { updatedAt: -1 }, + limit: this.batchSize, + }) + .toArray(); + + for (const candidate of candidates) { + const runId = (candidate as any).run?._id as string | undefined; + if (!runId) continue; + await this.dispatchIfEligible(candidate._id, runId, handler, graph, handlers); } } } @@ -347,7 +342,7 @@ export class HandlerDispatcher implements NotifyHandler { private async dispatchIfEligible( requestId: string, - _runId: string, + runId: string, handler: HandlerServiceDocument, graph: DependencyGraph, allHandlers: HandlerServiceDocument[], @@ -355,6 +350,7 @@ export class HandlerDispatcher implements NotifyHandler { // Check if all dependencies are satisfied for this request const doc = await this.collection.findOne({ _id: requestId } as any); if (!doc?.run || doc.run.status !== "done") return; + if (doc.run._id !== runId) return; const handlerStatus = (doc.run as any).handlerStatus as | Record @@ -368,24 +364,34 @@ export class HandlerDispatcher implements NotifyHandler { } } - // Check this handler hasn't already been dispatched + // Check whether this handler still needs work. const currentStatus = handlerStatus?.[handler._id]?.status; - if (currentStatus === "queued" || currentStatus === "processing" || currentStatus === "done") { + const currentVersion = handlerStatus?.[handler._id]?.version; + const statusExclusions = handler.autoBackfill + ? ["queued", "processing"] + : ["queued", "processing", "done"]; + + if (currentStatus && statusExclusions.includes(currentStatus)) { return; } - // Check version — skip if already at current version - const currentVersion = handlerStatus?.[handler._id]?.version; - if (currentVersion !== undefined && currentVersion >= handler.version && !handler.autoBackfill) { + if (handler.autoBackfill) { + if (currentVersion !== undefined && currentVersion >= handler.version) { + return; + } + } else if (currentVersion !== undefined) { return; } - // Atomic claim - const claimed = await this.collection.findOneAndUpdate( + const enqueued = await this.enqueueToHandler(handler, requestId, runId); + if (!enqueued) return; + + const queueMarkFilter = this.buildDispatchFilter(handler, allHandlers, graph); + const queueMarkResult = await this.collection.updateOne( { _id: requestId, - "run.status": "done", - [`run.handlerStatus.${handler._id}.status`]: { $nin: ["queued", "processing"] }, + "run._id": runId, + ...(queueMarkFilter ?? {}), } as any, { $set: { @@ -393,12 +399,15 @@ export class HandlerDispatcher implements NotifyHandler { [`run.handlerStatus.${handler._id}.updatedAt`]: new Date(), }, } as any, - { returnDocument: "after" }, ); - if (!claimed) return; + if ((queueMarkResult.matchedCount ?? 0) === 0) { + console.log( + `[HandlerDispatcher] ${handler._id} for ${requestId} advanced concurrently after enqueue; leaving queue message to worker idempotency guard`, + ); + return; + } - await this.enqueueToHandler(handler, requestId, (claimed as any).run?._id); console.log(`[HandlerDispatcher] Dispatched ${handler._id} for ${requestId}`); } @@ -444,7 +453,7 @@ export class HandlerDispatcher implements NotifyHandler { handler: HandlerServiceDocument, requestId: string, runId: string, - ): Promise { + ): Promise { const queueClient = await this.getOrCreateQueueClient(handler.queue); const message = Buffer.from( JSON.stringify({ @@ -456,16 +465,13 @@ export class HandlerDispatcher implements NotifyHandler { try { await queueClient.sendMessage(message); + return true; } catch (err) { - // Roll back status on queue failure - await this.collection.updateOne( - { _id: requestId } as any, - { $unset: { [`run.handlerStatus.${handler._id}.status`]: "" } } as any, - ); console.error( - `[HandlerDispatcher] Queue send failed for ${handler._id}/${requestId}, rolled back:`, + `[HandlerDispatcher] Queue send failed for ${handler._id}/${requestId}:`, err, ); + return false; } } diff --git a/apps/workers/post-processor/src/handlers/atif-handler.ts b/apps/workers/post-processor/src/handlers/atif-handler.ts index 5b51e208c..9a28cb604 100644 --- a/apps/workers/post-processor/src/handlers/atif-handler.ts +++ b/apps/workers/post-processor/src/handlers/atif-handler.ts @@ -5,6 +5,7 @@ import { writeFile, mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import type { PostProcessHandler, PostProcessorMessage, HandlerContext } from "../types.js"; +import { POST_PROCESSOR_VERSION } from "../version.js"; /** * Extracts the blob name (path within the container) from a full blob storage URL. @@ -30,6 +31,8 @@ function extractBlobName(url: string, container = "snapshots"): string | null { */ export class AtifHandler implements PostProcessHandler { readonly type = "atif"; + readonly version = POST_PROCESSOR_VERSION; + readonly autoBackfill = true; async process(message: PostProcessorMessage, ctx: HandlerContext): Promise { const { requestId, runId, iteration } = message; diff --git a/apps/workers/post-processor/src/post-processor.test.ts b/apps/workers/post-processor/src/post-processor.test.ts new file mode 100644 index 000000000..e33cccbd3 --- /dev/null +++ b/apps/workers/post-processor/src/post-processor.test.ts @@ -0,0 +1,184 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { RequestDocument } from "shared"; +import { PostProcessor } from "./post-processor.js"; +import type { PostProcessHandler, PostProcessorMessage } from "./types.js"; + +const CONFIG = { + mongoUri: "mongodb://localhost:27017", + mongoDatabase: "requests-db", + mongoCollection: "requests", + storageAccountName: "devstoreaccount1", + storageConnectionString: "UseDevelopmentStorage=true", + queueName: "post-processor-queue", + batchSize: 1, + pollIntervalMs: 5000, + redisHost: "localhost", + redisPort: 6379, + redisPassword: "", +}; + +function makeDoc(overrides: Partial = {}): RequestDocument { + return { + _id: "req-1", + scenario: { task: "task", criteria: [] }, + workerType: "post-processor", + createdAt: new Date(), + priority: 0, + run: { + _id: "run-1", + attemptNumber: 1, + status: "done", + handlerStatus: {}, + ...overrides, + }, + }; +} + +function makeProcessor() { + const processor = new PostProcessor(CONFIG); + const collection = { + findOneAndUpdate: vi.fn(), + updateOne: vi.fn(), + }; + const blobStorage = {}; + + (processor as any).collection = collection; + (processor as any).blobStorage = blobStorage; + + const safeDeleteMessage = vi + .spyOn(processor as any, "safeDeleteMessage") + .mockResolvedValue(undefined); + const notifyHandlerComplete = vi + .spyOn(processor as any, "notifyHandlerComplete") + .mockResolvedValue(undefined); + + return { processor, collection, blobStorage, safeDeleteMessage, notifyHandlerComplete }; +} + +function makeMessage(messageId = "message-1") { + return { + messageId, + popReceipt: "pop-0", + } as any; +} + +function makeHeartbeat() { + return { + popReceipt: "pop-0", + stop: vi.fn().mockReturnValue("pop-1"), + } as any; +} + +describe("PostProcessor", () => { + let handler: PostProcessHandler; + let log: ReturnType; + + beforeEach(() => { + handler = { + type: "atif", + version: 1, + autoBackfill: true, + process: vi.fn().mockResolvedValue(undefined), + }; + log = vi.fn().mockResolvedValue(undefined); + }); + + it("discards stale messages for an older run attempt", async () => { + const { processor, collection, safeDeleteMessage } = makeProcessor(); + processor.registerHandler(handler); + + const doc = makeDoc({ _id: "run-2" }); + const heartbeat = makeHeartbeat(); + const payload: PostProcessorMessage = { type: "atif", requestId: "req-1", runId: "run-1" }; + + await (processor as any).handleRequest(doc, makeMessage(), heartbeat, log, payload); + + expect(collection.findOneAndUpdate).not.toHaveBeenCalled(); + expect(handler.process).not.toHaveBeenCalled(); + expect(safeDeleteMessage).toHaveBeenCalledWith("message-1", "pop-1"); + }); + + it("no-ops duplicate messages when the handler is already current", async () => { + const { processor, collection, safeDeleteMessage, notifyHandlerComplete } = makeProcessor(); + processor.registerHandler(handler); + + const doc = makeDoc({ + handlerStatus: { + "pp-atif": { status: "done", version: 1, updatedAt: new Date() }, + }, + }); + const heartbeat = makeHeartbeat(); + const payload: PostProcessorMessage = { type: "atif", requestId: "req-1", runId: "run-1" }; + + collection.findOneAndUpdate.mockResolvedValue(null); + + await (processor as any).handleRequest(doc, makeMessage(), heartbeat, log, payload); + + expect(collection.findOneAndUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + _id: "req-1", + "run._id": "run-1", + "run.status": "done", + "run.handlerStatus.pp-atif.status": { $nin: ["queued", "processing"] }, + $or: [ + { "run.handlerStatus.pp-atif.version": { $exists: false } }, + { "run.handlerStatus.pp-atif.version": { $lt: 1 } }, + ], + }), + expect.any(Object), + expect.objectContaining({ returnDocument: "after" }), + ); + expect(handler.process).not.toHaveBeenCalled(); + expect(collection.updateOne).not.toHaveBeenCalled(); + expect(notifyHandlerComplete).not.toHaveBeenCalled(); + expect(safeDeleteMessage).toHaveBeenCalledWith("message-1", "pop-1"); + }); + + it("re-processes stale versions and stamps success for the same run attempt", async () => { + const { processor, collection, safeDeleteMessage, notifyHandlerComplete } = makeProcessor(); + processor.registerHandler(handler); + + const doc = makeDoc({ + handlerStatus: { + "pp-atif": { status: "done", version: 0, updatedAt: new Date() }, + }, + }); + const heartbeat = makeHeartbeat(); + const payload: PostProcessorMessage = { type: "atif", requestId: "req-1", runId: "run-1" }; + + collection.findOneAndUpdate.mockResolvedValue({ _id: "req-1" }); + collection.updateOne.mockResolvedValue({ matchedCount: 1 }); + + await (processor as any).handleRequest(doc, makeMessage(), heartbeat, log, payload); + + expect(handler.process).toHaveBeenCalledWith( + payload, + expect.objectContaining({ + blobStorage: expect.any(Object), + collection, + log, + }), + ); + expect(collection.updateOne).toHaveBeenCalledWith( + { + _id: "req-1", + "run._id": "run-1", + "run.handlerStatus.pp-atif.status": "processing", + }, + { + $set: expect.objectContaining({ + "run.postProcessorVersion": 1, + "run.postProcessorStatus": "done", + "run.handlerStatus.pp-atif.status": "done", + "run.handlerStatus.pp-atif.version": 1, + "run.handlerStatus.pp-atif.updatedAt": expect.any(Date), + }), + }, + ); + expect(notifyHandlerComplete).toHaveBeenCalledWith("req-1", "run-1", "pp-atif", "done"); + expect(safeDeleteMessage).toHaveBeenCalledWith("message-1", "pop-1"); + }); +}); diff --git a/apps/workers/post-processor/src/post-processor.ts b/apps/workers/post-processor/src/post-processor.ts index 4b1b6e847..32bec7202 100644 --- a/apps/workers/post-processor/src/post-processor.ts +++ b/apps/workers/post-processor/src/post-processor.ts @@ -3,24 +3,13 @@ import type { DequeuedMessageItem } from "@azure/storage-queue"; import { BaseQueueProcessor, BlobStorage, type BaseQueueProcessorConfig, type LogEvent, type VisibilityHeartbeat } from "shared"; -import { POST_PROCESSOR_VERSION } from "./version.js"; +import type { RequestDocument, HandlerRunStatus } from "shared"; import type { PostProcessHandler, PostProcessorMessage, HandlerContext } from "./types.js"; export interface PostProcessorConfig extends BaseQueueProcessorConfig { apiBaseUrl?: string; } -interface RequestDocument { - _id: string; - run?: { - _id: string; - status: string; - turns?: Array<{ iteration: number; harUrl?: string; atifUrl?: string }>; - postProcessorVersion?: number; - postProcessorStatus?: string; - }; -} - /** * Post-processor worker: extensible queue processor with handler registry. * Dispatched by the PostProcessorDispatcher in the scheduler when runs @@ -45,6 +34,43 @@ export class PostProcessor extends BaseQueueProcessor { console.log(`[post-processor] Registered handler: ${handler.type}`); } + private isPostProcessorMessage(payload: unknown): payload is PostProcessorMessage { + return typeof payload === "object" && + payload !== null && + typeof (payload as Record).type === "string" && + typeof (payload as Record).requestId === "string" && + typeof (payload as Record).runId === "string"; + } + + private buildHandlerClaimFilter( + requestId: string, + runId: string, + handlerId: string, + handler: PostProcessHandler, + ): Record { + const statusExclusions = handler.autoBackfill + ? ["queued", "processing"] + : ["queued", "processing", "done"]; + + const filter: Record = { + _id: requestId, + "run._id": runId, + "run.status": "done", + [`run.handlerStatus.${handlerId}.status`]: { $nin: statusExclusions }, + }; + + if (handler.autoBackfill) { + filter.$or = [ + { [`run.handlerStatus.${handlerId}.version`]: { $exists: false } }, + { [`run.handlerStatus.${handlerId}.version`]: { $lt: handler.version } }, + ]; + } else { + filter[`run.handlerStatus.${handlerId}.version`] = { $exists: false }; + } + + return filter; + } + protected async handleRequest( doc: RequestDocument, message: DequeuedMessageItem, @@ -52,14 +78,14 @@ export class PostProcessor extends BaseQueueProcessor { log: (level: LogEvent["level"], msg: string, data?: Record) => Promise, payload?: Record, ): Promise { - const msg = payload as unknown as PostProcessorMessage; - - if (!msg?.type) { + if (!this.isPostProcessorMessage(payload)) { await log("warn", "Message missing 'type' field, discarding"); await this.safeDeleteMessage(message.messageId, heartbeat.stop()); return; } + const msg: PostProcessorMessage = payload; + const handler = this.handlers.get(msg.type); if (!handler) { await log("warn", `No handler registered for type '${msg.type}', discarding`); @@ -67,14 +93,45 @@ export class PostProcessor extends BaseQueueProcessor { return; } - // Set status to "processing". - // Concurrency safety: the Azure Storage Queue guarantees at-most-once delivery - // via visibility timeout, so only one worker processes a given message at a time. - await this.collection.updateOne( - { _id: doc._id } as any, - { $set: { "run.postProcessorStatus": "processing" } } as any, + if (doc.run?._id !== msg.runId) { + await log("info", "Stale post-processing message discarded (runId mismatch)", { + messageRunId: msg.runId, + currentRunId: doc.run?._id, + }); + await this.safeDeleteMessage(message.messageId, heartbeat.stop()); + return; + } + + const handlerId = `pp-${msg.type}`; + const currentStatus = doc.run?.handlerStatus?.[handlerId] as HandlerRunStatus | undefined; + + const claim = await this.collection.findOneAndUpdate( + this.buildHandlerClaimFilter(doc._id, msg.runId, handlerId, handler) as any, + { + $set: { + "run.postProcessorStatus": "processing", + [`run.handlerStatus.${handlerId}.status`]: "processing", + [`run.handlerStatus.${handlerId}.updatedAt`]: new Date(), + }, + $unset: { + [`run.handlerStatus.${handlerId}.error`]: "", + }, + } as any, + { returnDocument: "after" }, ); + if (!claim) { + await log("info", "Post-processing message no longer needed, discarding", { + handlerId, + runId: msg.runId, + currentStatus: currentStatus?.status, + currentVersion: currentStatus?.version, + targetVersion: handler.version, + }); + await this.safeDeleteMessage(message.messageId, heartbeat.stop()); + return; + } + try { await log("info", `Running handler: ${msg.type}`); @@ -87,21 +144,33 @@ export class PostProcessor extends BaseQueueProcessor { await handler.process(msg, ctx); // Stamp version and status on success (legacy fields + new handlerStatus) - const handlerId = `pp-${msg.type}`; // e.g., "pp-atif" - await this.collection.updateOne( - { _id: doc._id } as any, + const successResult = await this.collection.updateOne( + { + _id: doc._id, + "run._id": msg.runId, + [`run.handlerStatus.${handlerId}.status`]: "processing", + } as any, { $set: { - "run.postProcessorVersion": POST_PROCESSOR_VERSION, + "run.postProcessorVersion": handler.version, "run.postProcessorStatus": "done", [`run.handlerStatus.${handlerId}.status`]: "done", - [`run.handlerStatus.${handlerId}.version`]: POST_PROCESSOR_VERSION, + [`run.handlerStatus.${handlerId}.version`]: handler.version, [`run.handlerStatus.${handlerId}.updatedAt`]: new Date(), }, } as any, ); - await log("info", `Post-processing complete (v${POST_PROCESSOR_VERSION})`); + if ((successResult.matchedCount ?? 0) === 0) { + await log("warn", `Handler '${msg.type}' completed after state changed; skipping terminal write`, { + handlerId, + runId: msg.runId, + }); + await this.safeDeleteMessage(message.messageId, heartbeat.stop()); + return; + } + + await log("info", `Post-processing complete (v${handler.version})`); // Notify the scheduler that this handler is done (best-effort) await this.notifyHandlerComplete(doc._id, doc.run?._id ?? "", handlerId, "done"); @@ -110,20 +179,26 @@ export class PostProcessor extends BaseQueueProcessor { await log("error", `Handler '${msg.type}' failed: ${errMsg}`); // Mark as failed so scheduler doesn't immediately re-dispatch - const handlerId = `pp-${msg.type}`; - await this.collection.updateOne( - { _id: doc._id } as any, + const failureResult = await this.collection.updateOne( + { + _id: doc._id, + "run._id": msg.runId, + [`run.handlerStatus.${handlerId}.status`]: "processing", + } as any, { $set: { "run.postProcessorStatus": "failed", [`run.handlerStatus.${handlerId}.status`]: "failed", [`run.handlerStatus.${handlerId}.updatedAt`]: new Date(), + [`run.handlerStatus.${handlerId}.error`]: errMsg, }, } as any, ); - // Notify scheduler of failure (best-effort — don't dispatch downstream) - await this.notifyHandlerComplete(doc._id, doc.run?._id ?? "", handlerId, "failed"); + if ((failureResult.matchedCount ?? 0) > 0) { + // Notify scheduler of failure (best-effort — don't dispatch downstream) + await this.notifyHandlerComplete(doc._id, doc.run?._id ?? "", handlerId, "failed"); + } throw err; } diff --git a/apps/workers/post-processor/src/types.ts b/apps/workers/post-processor/src/types.ts index 8765dd926..e1ee29669 100644 --- a/apps/workers/post-processor/src/types.ts +++ b/apps/workers/post-processor/src/types.ts @@ -22,5 +22,7 @@ export interface HandlerContext { /** Interface for post-processing handlers (extensibility point). */ export interface PostProcessHandler { readonly type: string; + readonly version: number; + readonly autoBackfill: boolean; process(message: PostProcessorMessage, ctx: HandlerContext): Promise; } diff --git a/docs/architecture/post-processing.md b/docs/architecture/post-processing.md index 910812ac0..4407afad0 100644 --- a/docs/architecture/post-processing.md +++ b/docs/architecture/post-processing.md @@ -63,6 +63,8 @@ The post-processor uses a registry pattern for extensibility: ```typescript interface PostProcessHandler { readonly type: string; + readonly version: number; + readonly autoBackfill: boolean; process(message: PostProcessorMessage, ctx: HandlerContext): Promise; } @@ -88,6 +90,23 @@ processor.registerHandler(new AtifHandler()); processor.start(); ``` +## Idempotency and Dispatch Semantics + +Post-processing delivery is treated as **at-least-once**. Queue messages may be duplicated or redelivered, and the scheduler may enqueue a handler message before the corresponding `queued` marker is durably written back to MongoDB. + +To keep this safe, idempotency is enforced at the **post-processor worker entrypoint**, not inside each individual handler: + +1. The scheduler decides whether work is needed from `run.handlerStatus[handlerId]` + handler metadata (`version`, `autoBackfill`). +2. The worker re-checks that same eligibility when a message is dequeued, scoped to the specific `runId`. +3. The worker atomically claims the handler by transitioning its status to `processing`. +4. If the message is stale, duplicated, or the handler is already current, the worker deletes the queue message and no-ops. + +This means: + +- A crash after queue send but before the scheduler writes `queued` no longer wedges the run forever. +- Duplicate queue messages are harmless. +- Handler implementations can stay simple; correctness does not depend on each handler re-implementing duplicate guards. + ### Adding a New Handler 1. Create a new file in `apps/workers/post-processor/src/handlers/` @@ -207,7 +226,7 @@ stateDiagram-v2 | Status | Meaning | | --- | --- | | *(absent)* | Run hasn't been dispatched for post-processing yet | -| `queued` | Message sent to queue, awaiting pickup | +| `queued` | Message sent to queue, awaiting pickup (best-effort marker; the worker entrypoint is authoritative) | | `processing` | Worker is actively processing | | `done` | Post-processing completed successfully | | `failed` | Handler threw an error (may be retried by scheduler on next poll) | From fe5b9eed30ba2bde3d23f115c95b946d6eaaffd1 Mon Sep 17 00:00:00 2001 From: Cedric Vidal Date: Wed, 1 Jul 2026 14:21:51 -0700 Subject: [PATCH 13/19] docs(post-processing): clarify enqueue and claim boundaries Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- apps/scheduler/src/handler-dispatcher.ts | 9 +++++++++ apps/workers/post-processor/src/post-processor.ts | 7 +++++++ 2 files changed, 16 insertions(+) diff --git a/apps/scheduler/src/handler-dispatcher.ts b/apps/scheduler/src/handler-dispatcher.ts index 92a9752bc..b93afca52 100644 --- a/apps/scheduler/src/handler-dispatcher.ts +++ b/apps/scheduler/src/handler-dispatcher.ts @@ -383,9 +383,18 @@ export class HandlerDispatcher implements NotifyHandler { return; } + // Queue FIRST, then write the best-effort "queued" marker back to Mongo. + // The queue message is the durable work signal; the DB status is only an + // optimization / observability hint. If we crash after sendMessage() but + // before this update, the worker entrypoint claim still makes the message + // safe and prevents a permanent "queued" wedge. const enqueued = await this.enqueueToHandler(handler, requestId, runId); if (!enqueued) return; + // This is NOT the authoritative processing claim. It only records that a + // message was successfully placed on the queue and is expected to be picked + // up soon. The worker performs the real claim when it atomically flips the + // handler from eligible -> processing for this specific run attempt. const queueMarkFilter = this.buildDispatchFilter(handler, allHandlers, graph); const queueMarkResult = await this.collection.updateOne( { diff --git a/apps/workers/post-processor/src/post-processor.ts b/apps/workers/post-processor/src/post-processor.ts index 32bec7202..db2a46c9f 100644 --- a/apps/workers/post-processor/src/post-processor.ts +++ b/apps/workers/post-processor/src/post-processor.ts @@ -105,6 +105,11 @@ export class PostProcessor extends BaseQueueProcessor { const handlerId = `pp-${msg.type}`; const currentStatus = doc.run?.handlerStatus?.[handlerId] as HandlerRunStatus | undefined; + // This is the authoritative claim. A queued message alone does not mean the + // handler still needs work: the scheduler may have enqueued optimistically, + // another worker may already be processing it, or a fresher backfill may + // have completed it. Only the worker can safely decide that by atomically + // transitioning the specific run attempt into "processing". const claim = await this.collection.findOneAndUpdate( this.buildHandlerClaimFilter(doc._id, msg.runId, handlerId, handler) as any, { @@ -121,6 +126,8 @@ export class PostProcessor extends BaseQueueProcessor { ); if (!claim) { + // No claim means the message is stale, duplicate, or already satisfied. + // Ack and drop it — at-least-once delivery is expected here. await log("info", "Post-processing message no longer needed, discarding", { handlerId, runId: msg.runId, From 87df1b0573339259ed3a848623495698a47ca050 Mon Sep 17 00:00:00 2001 From: Cedric Vidal Date: Wed, 1 Jul 2026 14:45:58 -0700 Subject: [PATCH 14/19] perf(scheduler): cache handler topology with short TTL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit loadHandlers() sits on the notify/poll hot path (2-3 reads per notify, one per poll cycle) but the services collection only changes on deploys. Cache the topology in-memory with a 60s TTL (configurable) to eliminate nearly all of those reads — meaningful on CosmosDB where each query costs RUs. registerHandler invalidates the cache after upserting so new topology is picked up immediately; cycle-check validation reads fresh. Addresses jduffney review feedback #4 on #1147. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- apps/scheduler/src/handler-dispatcher.test.ts | 58 +++++++++++++++++++ apps/scheduler/src/handler-dispatcher.ts | 35 +++++++++-- docs/architecture/post-processing.md | 2 + 3 files changed, 89 insertions(+), 6 deletions(-) diff --git a/apps/scheduler/src/handler-dispatcher.test.ts b/apps/scheduler/src/handler-dispatcher.test.ts index 9431be37b..32d954243 100644 --- a/apps/scheduler/src/handler-dispatcher.test.ts +++ b/apps/scheduler/src/handler-dispatcher.test.ts @@ -307,6 +307,64 @@ describe("HandlerDispatcher — onHandlerComplete", () => { }); }); +describe("HandlerDispatcher — handler topology caching", () => { + function makeWithSpies(ttlMs = 60_000, handlers: HandlerServiceDocument[] = HANDLERS) { + const collection = createMockCollection(); + const toArray = vi.fn().mockResolvedValue(handlers); + const find = vi.fn().mockReturnValue({ toArray }); + const servicesUpdateOne = vi.fn().mockResolvedValue(undefined); + const db = { + collection: vi.fn().mockReturnValue({ find, updateOne: servicesUpdateOne }), + } as any; + const dispatcher = new HandlerDispatcher( + collection, + db, + vi.fn(), + 60_000, + 30, + "http://api.test", + ttlMs, + ) as any; + return { dispatcher, find, toArray, servicesUpdateOne }; + } + + it("reuses cached topology within the TTL window", async () => { + const { dispatcher, toArray } = makeWithSpies(60_000); + + await dispatcher.loadHandlers(); + await dispatcher.loadHandlers(); + + expect(toArray).toHaveBeenCalledTimes(1); + }); + + it("re-reads topology once the TTL has elapsed", async () => { + const { dispatcher, toArray } = makeWithSpies(0); // TTL 0 → every read is stale + + await dispatcher.loadHandlers(); + await dispatcher.loadHandlers(); + + expect(toArray).toHaveBeenCalledTimes(2); + }); + + it("invalidates the cache when a handler is registered", async () => { + const { dispatcher, toArray } = makeWithSpies(60_000); + + await dispatcher.loadHandlers(); // read #1 — populates cache + await dispatcher.registerHandler({ + _id: "pp-new", + type: "post-process-handler", + version: 1, + queue: "pp-new-queue", + selector: "new", + autoBackfill: false, + dependsOn: [], + }); // read #2 (forceFresh validation) + invalidate + await dispatcher.loadHandlers(); // read #3 — cache was invalidated + + expect(toArray).toHaveBeenCalledTimes(3); + }); +}); + describe("HandlerDispatcher — buildDispatchFilter", () => { let collection: ReturnType; diff --git a/apps/scheduler/src/handler-dispatcher.ts b/apps/scheduler/src/handler-dispatcher.ts index b93afca52..760bd87d8 100644 --- a/apps/scheduler/src/handler-dispatcher.ts +++ b/apps/scheduler/src/handler-dispatcher.ts @@ -25,6 +25,14 @@ export class HandlerDispatcher implements NotifyHandler { private dispatching = false; private queueClients = new Map(); + /** Cached handler topology. The `services` collection changes only on deploys + * (via registerHandler), yet loadHandlers() sits on the notify/poll hot path + * (2–3 reads per notify, plus every poll cycle). A short TTL cache eliminates + * nearly all of those reads — significant on CosmosDB where each query costs + * RUs — while registerHandler invalidates it so new topology is picked up at + * once rather than after the TTL elapses. */ + private handlerCache: { docs: HandlerServiceDocument[]; expiresAt: number } | null = null; + /** Retry policy for the report-trigger POST. The 30s poll net is the durable * backstop, so in-call retries are modest. Overridable in tests. */ private reportRetryOptions: RetryOptions = { @@ -41,6 +49,7 @@ export class HandlerDispatcher implements NotifyHandler { private readonly pollIntervalMs: number = 30_000, private readonly batchSize: number = 30, private readonly apiUrl: string = process.env.API_URL || "http://api:80", + private readonly handlerCacheTtlMs: number = 60_000, ) {} start(): void { @@ -137,8 +146,9 @@ export class HandlerDispatcher implements NotifyHandler { } async registerHandler(doc: HandlerServiceDocument): Promise { - // Validate the resulting topology has no cycles before persisting. - const existing = await this.loadHandlers(); + // Validate the resulting topology has no cycles before persisting. Read + // fresh (bypass the cache) so validation reflects any concurrent registrations. + const existing = await this.loadHandlers(true); const merged = [ ...existing.filter((h) => h._id !== doc._id), doc, @@ -158,6 +168,8 @@ export class HandlerDispatcher implements NotifyHandler { } as any, { upsert: true }, ); + // Topology changed — drop the cache so the next read picks up the new handler. + this.invalidateHandlerCache(); console.log( `[HandlerDispatcher] Registered handler ${_id} (version=${doc.version}, queue=${doc.queue}, dependsOn=[${doc.dependsOn.join(", ")}])`, ); @@ -494,12 +506,23 @@ export class HandlerDispatcher implements NotifyHandler { return client; } - private async loadHandlers(): Promise { - const docs = await this.db + private async loadHandlers(forceFresh = false): Promise { + const now = Date.now(); + if (!forceFresh && this.handlerCache && this.handlerCache.expiresAt > now) { + return this.handlerCache.docs; + } + const docs = (await this.db .collection("services") .find({ type: "post-process-handler" }) - .toArray(); - return docs as unknown as HandlerServiceDocument[]; + .toArray()) as unknown as HandlerServiceDocument[]; + this.handlerCache = { docs, expiresAt: now + this.handlerCacheTtlMs }; + return docs; + } + + /** Drops the cached topology so the next loadHandlers() re-reads from Mongo. + * Called after registerHandler persists a change. */ + private invalidateHandlerCache(): void { + this.handlerCache = null; } private buildGraph(handlers: HandlerServiceDocument[]): DependencyGraph { diff --git a/docs/architecture/post-processing.md b/docs/architecture/post-processing.md index 4407afad0..b695ca92e 100644 --- a/docs/architecture/post-processing.md +++ b/docs/architecture/post-processing.md @@ -149,6 +149,8 @@ dependsOn: [] On deploy, a generic registration script — `packages/shared/src/scripts/register-handler.ts` — reads the `handler.yaml` pointed to by `HANDLER_YAML_PATH`, waits for the scheduler's `/health`, and POSTs the parsed document to `${SCHEDULER_URL}/handlers/register` (with retry). The scheduler validates the document, runs a cycle check against the existing topology, and upserts it into the `services` collection via `HandlerDispatcher.registerHandler`. **Workers never write to the `services` collection directly** — the scheduler is the sole owner. +Because handler topology is read on the dispatch hot path (every notify reads it 2–3 times, and every poll cycle once) but only changes on deploys, the scheduler caches it in-memory with a short TTL (60s, configurable via the `HandlerDispatcher` constructor). `registerHandler` invalidates the cache immediately after upserting, so a freshly registered handler is picked up on the next dispatch rather than after the TTL elapses. Cycle-check validation always reads fresh (bypassing the cache). + ```mermaid flowchart LR YAML[handler.yaml] --> SCRIPT[register-handler.ts] From a56c0beb8700566ebcfebf0162be4a769bf749d2 Mon Sep 17 00:00:00 2001 From: Cedric Vidal Date: Wed, 1 Jul 2026 15:06:17 -0700 Subject: [PATCH 15/19] perf(scheduler): raise handler topology cache TTL to 5min, make it env-configurable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The scheduler is a singleton (replicas: 1, Recreate) and the sole writer of the services collection, and registerHandler invalidates the cache on every topology change — so cache coherence rests on invalidation, not expiry. A 60s TTL sat barely above the 30s poll, forcing a needless fresh read on roughly every other poll cycle. Raise the default to 5min (well above the poll interval) and expose SCHEDULER_HANDLER_CACHE_TTL_MS so the TTL is only a coarse backstop for out-of-band writes or a future scale-out beyond one replica. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- ENV_VARIABLES.md | 6 ++++++ apps/scheduler/src/handler-dispatcher.ts | 16 +++++++++++----- apps/scheduler/src/index.ts | 13 ++++++++++++- docs/architecture/post-processing.md | 2 +- 4 files changed, 30 insertions(+), 7 deletions(-) diff --git a/ENV_VARIABLES.md b/ENV_VARIABLES.md index 73262c238..48016b472 100644 --- a/ENV_VARIABLES.md +++ b/ENV_VARIABLES.md @@ -360,6 +360,12 @@ How often the request scheduler polls MongoDB for pending requests to dispatch t How often the post-processor dispatcher polls for completed runs needing post-processing. This is a **backfill/catch-up** mechanism — the primary dispatch path is event-driven (coder workers enqueue directly on run completion). The 30s default keeps idle RU consumption low while still catching missed events or version-upgrade backfills within a reasonable window. Reduce temporarily for large backfills. +### SCHEDULER_HANDLER_CACHE_TTL_MS +**Default:** `300000` +**Type:** integer (milliseconds) + +TTL for the DAG dispatcher's in-memory cache of handler topology (the `services` collection, `type: "post-process-handler"`). `loadHandlers()` runs on the notify/poll hot path (2–3 reads per notify plus every poll cycle) but topology only changes on deploys, so caching it eliminates nearly all of those reads (meaningful on CosmosDB where each query costs RUs). Cache coherence comes primarily from `HandlerDispatcher.registerHandler` invalidating the cache the moment topology changes — the scheduler is a singleton (`replicas: 1`, `Recreate`) and the sole writer of the collection — so this TTL is only a coarse backstop for out-of-band writes or a future scale-out to more than one replica. Kept well above `SCHEDULER_PP_POLL_INTERVAL_MS` so the poll loop doesn't force a fresh read every cycle. + ### QUEUE_NAME_POST_PROCESSOR **Default:** `post-processor-queue` **Type:** string diff --git a/apps/scheduler/src/handler-dispatcher.ts b/apps/scheduler/src/handler-dispatcher.ts index 760bd87d8..4ff1f8275 100644 --- a/apps/scheduler/src/handler-dispatcher.ts +++ b/apps/scheduler/src/handler-dispatcher.ts @@ -27,10 +27,16 @@ export class HandlerDispatcher implements NotifyHandler { /** Cached handler topology. The `services` collection changes only on deploys * (via registerHandler), yet loadHandlers() sits on the notify/poll hot path - * (2–3 reads per notify, plus every poll cycle). A short TTL cache eliminates - * nearly all of those reads — significant on CosmosDB where each query costs - * RUs — while registerHandler invalidates it so new topology is picked up at - * once rather than after the TTL elapses. */ + * (2–3 reads per notify, plus every poll cycle). A TTL cache eliminates nearly + * all of those reads — significant on CosmosDB where each query costs RUs. + * + * Coherence comes primarily from `registerHandler` invalidating the cache the + * moment topology changes, not from expiry: the scheduler is a singleton + * (`replicas: 1`, `Recreate`) and the sole writer of the `services` collection, + * so a same-process registration is reflected immediately regardless of TTL. + * The TTL is therefore a coarse defensive backstop (out-of-band writes, or a + * future scale-out to >1 replica) and is intentionally set well above the poll + * interval so the poll loop doesn't force a fresh read every cycle. */ private handlerCache: { docs: HandlerServiceDocument[]; expiresAt: number } | null = null; /** Retry policy for the report-trigger POST. The 30s poll net is the durable @@ -49,7 +55,7 @@ export class HandlerDispatcher implements NotifyHandler { private readonly pollIntervalMs: number = 30_000, private readonly batchSize: number = 30, private readonly apiUrl: string = process.env.API_URL || "http://api:80", - private readonly handlerCacheTtlMs: number = 60_000, + private readonly handlerCacheTtlMs: number = 300_000, ) {} start(): void { diff --git a/apps/scheduler/src/index.ts b/apps/scheduler/src/index.ts index c59522167..4ce9f053f 100644 --- a/apps/scheduler/src/index.ts +++ b/apps/scheduler/src/index.ts @@ -184,6 +184,14 @@ async function main(): Promise { 10, ); + // Handler topology is deploy-static and invalidated on registration, so this + // TTL only backstops out-of-band/multi-replica changes — keep it well above + // the poll interval so the poll loop doesn't force a fresh read every cycle. + const handlerCacheTtlMs = parseInt( + process.env.SCHEDULER_HANDLER_CACHE_TTL_MS || "300000", + 10, + ); + const postProcessorDispatcher = new PostProcessorDispatcher( collection, db, @@ -201,9 +209,12 @@ async function main(): Promise { ppPollIntervalMs, 30, API_URL, + handlerCacheTtlMs, ); handlerDispatcher.start(); - console.log("[Scheduler] Handler dispatcher (DAG) started"); + console.log( + `[Scheduler] Handler dispatcher (DAG) started (poll=${ppPollIntervalMs}ms, topologyCacheTtl=${handlerCacheTtlMs}ms)`, + ); // Start the stuck-run reaper (authoritative backstop). Redis is non-fatal: // on misconfig or connection failure the reaper self-disables (its sweeps diff --git a/docs/architecture/post-processing.md b/docs/architecture/post-processing.md index b695ca92e..298a0058d 100644 --- a/docs/architecture/post-processing.md +++ b/docs/architecture/post-processing.md @@ -149,7 +149,7 @@ dependsOn: [] On deploy, a generic registration script — `packages/shared/src/scripts/register-handler.ts` — reads the `handler.yaml` pointed to by `HANDLER_YAML_PATH`, waits for the scheduler's `/health`, and POSTs the parsed document to `${SCHEDULER_URL}/handlers/register` (with retry). The scheduler validates the document, runs a cycle check against the existing topology, and upserts it into the `services` collection via `HandlerDispatcher.registerHandler`. **Workers never write to the `services` collection directly** — the scheduler is the sole owner. -Because handler topology is read on the dispatch hot path (every notify reads it 2–3 times, and every poll cycle once) but only changes on deploys, the scheduler caches it in-memory with a short TTL (60s, configurable via the `HandlerDispatcher` constructor). `registerHandler` invalidates the cache immediately after upserting, so a freshly registered handler is picked up on the next dispatch rather than after the TTL elapses. Cycle-check validation always reads fresh (bypassing the cache). +Because handler topology is read on the dispatch hot path (every notify reads it 2–3 times, and every poll cycle once) but only changes on deploys, the scheduler caches it in-memory with a TTL (default 5 min, `SCHEDULER_HANDLER_CACHE_TTL_MS`). Coherence comes primarily from invalidation, not expiry: `registerHandler` invalidates the cache immediately after upserting, and since the scheduler is a singleton (`replicas: 1`, `Recreate`) and the sole writer of the `services` collection, a change is reflected at once regardless of TTL. The TTL is therefore only a defensive backstop for out-of-band writes or a future scale-out to more than one replica, and is deliberately kept well above the poll interval so the poll loop doesn't force a fresh read every cycle. Cycle-check validation always reads fresh (bypassing the cache). ```mermaid flowchart LR From b55299c9a06f3b27d6676e048717dbe1c3be1400 Mon Sep 17 00:00:00 2001 From: Cedric Vidal Date: Wed, 1 Jul 2026 15:14:21 -0700 Subject: [PATCH 16/19] docs: call out single-replica limitation of the handler topology cache Make explicit that process-local cache invalidation is only correct while the scheduler runs as a single replica, and document the cross-replica mitigations (Redis pub/sub or change-stream watcher, or a shorter TTL) required before any scale-out. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- docs/architecture/post-processing.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/architecture/post-processing.md b/docs/architecture/post-processing.md index 298a0058d..7fd352e55 100644 --- a/docs/architecture/post-processing.md +++ b/docs/architecture/post-processing.md @@ -151,6 +151,8 @@ On deploy, a generic registration script — `packages/shared/src/scripts/regist Because handler topology is read on the dispatch hot path (every notify reads it 2–3 times, and every poll cycle once) but only changes on deploys, the scheduler caches it in-memory with a TTL (default 5 min, `SCHEDULER_HANDLER_CACHE_TTL_MS`). Coherence comes primarily from invalidation, not expiry: `registerHandler` invalidates the cache immediately after upserting, and since the scheduler is a singleton (`replicas: 1`, `Recreate`) and the sole writer of the `services` collection, a change is reflected at once regardless of TTL. The TTL is therefore only a defensive backstop for out-of-band writes or a future scale-out to more than one replica, and is deliberately kept well above the poll interval so the poll loop doesn't force a fresh read every cycle. Cycle-check validation always reads fresh (bypassing the cache). +> **Limitation — the topology cache assumes a single scheduler replica.** In-memory cache invalidation is process-local, so it is only correct while the scheduler runs as one replica. This holds today (`deploy/base/scheduler.yaml`: `replicas: 1`, `strategy: Recreate`, so pods never overlap). **If the scheduler is ever scaled beyond one replica, this cache becomes stale-prone:** a `registerHandler` on one replica invalidates only that replica's cache, so the others keep serving old topology for up to the TTL. The blast radius is bounded — with idempotent dispatch a newly added handler is still dispatched by whichever replica has fresh topology, and a removed/re-wired handler self-heals within the TTL — but it is real. Before scaling out, replace process-local invalidation with a cross-replica signal (e.g. Redis pub/sub on `registerHandler`, or a change-stream watcher on the `services` collection), or lower `SCHEDULER_HANDLER_CACHE_TTL_MS` to shrink the staleness window. + ```mermaid flowchart LR YAML[handler.yaml] --> SCRIPT[register-handler.ts] From ab696a2b94129e4f1af71665240da4071fecd5a5 Mon Sep 17 00:00:00 2001 From: Cedric Vidal Date: Wed, 1 Jul 2026 15:24:04 -0700 Subject: [PATCH 17/19] feat(scheduler): cap notify/register request body size (64 KB) Guards the singleton scheduler HTTP server against unbounded-memory requests. Rejects oversized payloads with 413 Payload Too Large + Connection: close, using a Content-Length fast-reject plus a streaming byte-count cap for chunked/lying clients. maxBodyBytes is threaded through createHttpServer so tests can use a small deterministic cap. Addresses jduffney review feedback #5 on #1147. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- apps/scheduler/src/notify-routes.test.ts | 88 +++++++++++++++- apps/scheduler/src/notify-routes.ts | 122 ++++++++++++++++++++--- docs/architecture/post-processing.md | 2 +- 3 files changed, 192 insertions(+), 20 deletions(-) diff --git a/apps/scheduler/src/notify-routes.test.ts b/apps/scheduler/src/notify-routes.test.ts index 797256018..2d7ef741b 100644 --- a/apps/scheduler/src/notify-routes.test.ts +++ b/apps/scheduler/src/notify-routes.test.ts @@ -2,11 +2,22 @@ // Licensed under the MIT License. import type { AddressInfo } from "node:net"; +import type { IncomingMessage } from "node:http"; +import { PassThrough } from "node:stream"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { createHttpServer, type NotifyHandler } from "./notify-routes.js"; +import { + createHttpServer, + readJsonBody, + MAX_BODY_BYTES, + type HttpServerOptions, + type NotifyHandler, +} from "./notify-routes.js"; -async function startServer(handler: NotifyHandler): Promise<{ server: ReturnType; baseUrl: string }> { - const server = createHttpServer(handler); +async function startServer( + handler: NotifyHandler, + options?: HttpServerOptions, +): Promise<{ server: ReturnType; baseUrl: string }> { + const server = createHttpServer(handler, options); await new Promise((resolve, reject) => { server.once("error", reject); @@ -201,4 +212,75 @@ describe("createHttpServer", () => { expect(response.status).toBe(400); expect(handler.registerHandler).not.toHaveBeenCalled(); }); + + it("returns 413 and does not invoke the handler when the body exceeds the cap", async () => { + const handler: NotifyHandler = { + onRunTerminal: vi.fn().mockResolvedValue(undefined), + onHandlerComplete: vi.fn().mockResolvedValue(undefined), + registerHandler: vi.fn().mockResolvedValue(undefined), + }; + // Tiny cap so a small, deterministic payload trips it (no socket-buffer race). + const { server, baseUrl } = await startServer(handler, { maxBodyBytes: 32 }); + servers.push(server); + + const response = await fetch(`${baseUrl}/notify/run-terminal`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ requestId: "r".repeat(100), runId: "run-1" }), + }); + + expect(response.status).toBe(413); + expect(await response.json()).toEqual({ error: "Request body too large" }); + expect(handler.onRunTerminal).not.toHaveBeenCalled(); + }); +}); + +describe("readJsonBody", () => { + /** Build a mock IncomingMessage backed by a PassThrough with the given headers. */ + function mockReq(headers: Record = {}): IncomingMessage & PassThrough { + const req = new PassThrough() as PassThrough & { headers: Record }; + req.headers = headers; + return req as unknown as IncomingMessage & PassThrough; + } + + it("parses a valid JSON body under the cap", async () => { + const req = mockReq(); + const promise = readJsonBody(req, 1024); + req.end(JSON.stringify({ hello: "world" })); + + await expect(promise).resolves.toEqual({ hello: "world" }); + }); + + it("rejects a declared Content-Length over the cap without reading the body", async () => { + const req = mockReq({ "content-length": "5000" }); + const onData = vi.fn(); + req.on("data", onData); + + await expect(readJsonBody(req, 1024)).rejects.toThrow(/limit/); + expect(onData).not.toHaveBeenCalled(); // fast-path rejects before consuming + }); + + it("rejects a chunked body that streams past the cap (no Content-Length)", async () => { + const req = mockReq(); // no content-length → exercises the streaming guard + const promise = readJsonBody(req, 16); + req.write("x".repeat(10)); + req.write("x".repeat(10)); // total 20 > 16 + req.end(); + + await expect(promise).rejects.toThrow(/limit/); + }); + + it("rejects an empty body", async () => { + const req = mockReq(); + const promise = readJsonBody(req, 1024); + req.end(""); + + await expect(promise).rejects.toThrow("Request body is required"); + }); + + it("defaults the cap to MAX_BODY_BYTES (64 KB)", async () => { + const req = mockReq({ "content-length": String(MAX_BODY_BYTES + 1) }); + + await expect(readJsonBody(req)).rejects.toThrow(/limit/); + }); }); diff --git a/apps/scheduler/src/notify-routes.ts b/apps/scheduler/src/notify-routes.ts index 577478b64..b63d13dd9 100644 --- a/apps/scheduler/src/notify-routes.ts +++ b/apps/scheduler/src/notify-routes.ts @@ -4,6 +4,22 @@ import http from "node:http"; import type { HandlerServiceDocument } from "shared"; +/** + * Maximum accepted request body size for the scheduler's HTTP endpoints. + * Notify/register payloads are a few hundred bytes; 64 KB is a generous cap + * that protects this singleton control-plane process from unbounded buffering + * (and OOM) if a buggy caller streams a large body. + */ +export const MAX_BODY_BYTES = 64 * 1024; + +/** Thrown by readJsonBody when the request body exceeds MAX_BODY_BYTES. */ +class PayloadTooLargeError extends Error { + constructor(maxBytes: number) { + super(`Request body exceeds ${maxBytes}-byte limit`); + this.name = "PayloadTooLargeError"; + } +} + export interface NotifyHandler { onRunTerminal(requestId: string, runId: string): Promise; onHandlerComplete( @@ -27,8 +43,13 @@ interface HandlerCompleteRequest { status: "done" | "failed"; } -function sendJson(res: http.ServerResponse, statusCode: number, body: object): void { - res.writeHead(statusCode, { "Content-Type": "application/json" }); +function sendJson( + res: http.ServerResponse, + statusCode: number, + body: object, + extraHeaders?: http.OutgoingHttpHeaders, +): void { + res.writeHead(statusCode, { "Content-Type": "application/json", ...extraHeaders }); res.end(JSON.stringify(body)); } @@ -117,22 +138,84 @@ function parseHandlerRegisterRequest(body: unknown): HandlerServiceDocument | nu }; } -async function readJsonBody(req: http.IncomingMessage): Promise { - const chunks: Buffer[] = []; +export function readJsonBody( + req: http.IncomingMessage, + maxBytes: number = MAX_BODY_BYTES, +): Promise { + return new Promise((resolve, reject) => { + // Fast path: reject an oversized declared Content-Length before reading any + // body. Well-behaved clients (fetch/axios/http.request with a body) always + // send it, so this catches the common case without buffering a single byte. + const declaredLength = Number(req.headers["content-length"]); + if (Number.isFinite(declaredLength) && declaredLength > maxBytes) { + reject(new PayloadTooLargeError(maxBytes)); + return; + } - for await (const chunk of req) { - chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk); - } + const chunks: Buffer[] = []; + let total = 0; + let settled = false; - const rawBody = Buffer.concat(chunks).toString("utf-8").trim(); - if (rawBody.length === 0) { - throw new Error("Request body is required"); - } + const cleanup = (): void => { + req.removeListener("data", onData); + req.removeListener("end", onEnd); + req.removeListener("error", onError); + }; + + // Streaming guard: Content-Length may be absent (chunked) or lie, so also + // enforce the cap as bytes arrive and bail the moment it is exceeded. + const onData = (chunk: Buffer | string): void => { + if (settled) return; + const buf = typeof chunk === "string" ? Buffer.from(chunk) : chunk; + total += buf.length; + if (total > maxBytes) { + settled = true; + cleanup(); + reject(new PayloadTooLargeError(maxBytes)); + return; + } + chunks.push(buf); + }; - return JSON.parse(rawBody) as unknown; + const onEnd = (): void => { + if (settled) return; + settled = true; + cleanup(); + const rawBody = Buffer.concat(chunks).toString("utf-8").trim(); + if (rawBody.length === 0) { + reject(new Error("Request body is required")); + return; + } + try { + resolve(JSON.parse(rawBody) as unknown); + } catch (err) { + reject(err); + } + }; + + const onError = (err: Error): void => { + if (settled) return; + settled = true; + cleanup(); + reject(err); + }; + + req.on("data", onData); + req.on("end", onEnd); + req.on("error", onError); + }); } -export function createHttpServer(handler: NotifyHandler): http.Server { +export interface HttpServerOptions { + /** Max request body size in bytes. Defaults to MAX_BODY_BYTES (64 KB). */ + maxBodyBytes?: number; +} + +export function createHttpServer( + handler: NotifyHandler, + options: HttpServerOptions = {}, +): http.Server { + const maxBodyBytes = options.maxBodyBytes ?? MAX_BODY_BYTES; return http.createServer(async (req, res) => { const method = req.method ?? "GET"; const url = new URL(req.url ?? "/", "http://localhost"); @@ -144,7 +227,7 @@ export function createHttpServer(handler: NotifyHandler): http.Server { } if (method === "POST" && url.pathname === "/notify/run-terminal") { - const parsedBody = parseRunTerminalRequest(await readJsonBody(req)); + const parsedBody = parseRunTerminalRequest(await readJsonBody(req, maxBodyBytes)); if (!parsedBody) { sendJson(res, 400, { error: "requestId and runId are required" }); return; @@ -156,7 +239,7 @@ export function createHttpServer(handler: NotifyHandler): http.Server { } if (method === "POST" && url.pathname === "/notify/handler-complete") { - const parsedBody = parseHandlerCompleteRequest(await readJsonBody(req)); + const parsedBody = parseHandlerCompleteRequest(await readJsonBody(req, maxBodyBytes)); if (!parsedBody) { sendJson(res, 400, { error: "requestId, runId, handlerId, and status (done|failed) are required", @@ -175,7 +258,7 @@ export function createHttpServer(handler: NotifyHandler): http.Server { } if (method === "POST" && url.pathname === "/handlers/register") { - const parsedBody = parseHandlerRegisterRequest(await readJsonBody(req)); + const parsedBody = parseHandlerRegisterRequest(await readJsonBody(req, maxBodyBytes)); if (!parsedBody) { sendJson(res, 400, { error: @@ -189,6 +272,13 @@ export function createHttpServer(handler: NotifyHandler): http.Server { return; } } catch (error) { + // Body was not fully consumed — close the connection so leftover bytes + // can't corrupt the next request on a keep-alive socket. + if (error instanceof PayloadTooLargeError) { + sendJson(res, 413, { error: "Request body too large" }, { Connection: "close" }); + return; + } + const message = error instanceof Error ? error.message : "Internal server error"; const statusCode = message === "Request body is required" || error instanceof SyntaxError ? 400 : 500; diff --git a/docs/architecture/post-processing.md b/docs/architecture/post-processing.md index 7fd352e55..5bc1efbd5 100644 --- a/docs/architecture/post-processing.md +++ b/docs/architecture/post-processing.md @@ -16,7 +16,7 @@ Two paths trigger post-processing: 1. **Event-driven (primary):** Coder workers enqueue a post-processing message immediately when a run completes, currently setting the deprecated `run.postProcessorStatus: "queued"` compatibility field atomically in the same DB write while the system migrates to per-handler status. 2. **Scheduler backfill:** The `PostProcessorDispatcher` polls every 30 seconds for runs that have `status: "done"` but no post-processing marker set (or a stale handler version). During the migration window this may consult the deprecated flat fields; the long-term source of truth is `run.handlerStatus[handlerId]`. This catches runs that completed before the event-driven dispatch was deployed, or that need re-processing after a version bump. -3. **Scheduler notify & registration server:** The scheduler also exposes `GET /health`, `POST /notify/run-terminal`, `POST /notify/handler-complete`, and `POST /handlers/register` on a lightweight `node:http` server. Workers and post-process handlers use the notify endpoints to signal terminal states so the scheduler can re-evaluate which root or downstream handlers are ready to dispatch. The `/handlers/register` endpoint is the single writer to the `services` collection — see [Handler Registration](#handler-registration). +3. **Scheduler notify & registration server:** The scheduler also exposes `GET /health`, `POST /notify/run-terminal`, `POST /notify/handler-complete`, and `POST /handlers/register` on a lightweight `node:http` server. Workers and post-process handlers use the notify endpoints to signal terminal states so the scheduler can re-evaluate which root or downstream handlers are ready to dispatch. The `/handlers/register` endpoint is the single writer to the `services` collection — see [Handler Registration](#handler-registration). All POST endpoints cap the request body at 64 KB (`MAX_BODY_BYTES` in `notify-routes.ts`); oversized payloads are rejected with `413 Payload Too Large` and `Connection: close` before the body is fully buffered, guarding the singleton scheduler against unbounded-memory requests. ## ATIF Generation From 726e0cd9b95f5148365edb265b9d7ab0897ae105 Mon Sep 17 00:00:00 2001 From: Cedric Vidal Date: Wed, 1 Jul 2026 16:02:48 -0700 Subject: [PATCH 18/19] fix(post-processing): let worker claim scheduler-queued runs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The enqueue-before-claim refactor made the scheduler write a best-effort handlerStatus..status="queued" marker in-process immediately after enqueueing. The worker's claim filter excluded "queued", so once that marker won the (near-certain) race against queue delivery, the worker dequeued its own message, matched nothing on the atomic claim, logged "no longer needed, discarding", and deleted the message — stranding the run at "queued" forever (the 30s backfill poll also excludes "queued", so nothing recovered it). This reintroduced the stuck-dispatch failure mode via a new path. Fix: remove "queued" from the worker claim exclusions so queued -> processing is a permitted transition. Idempotency is preserved: still exclude "processing" (another worker in-flight) and, for non-backfill handlers, "done"; autoBackfill "done" stays gated by the version check. Add a regression test asserting a run in the scheduler's "queued" state is claimable and driven to done, update the existing filter-shape assertion, and document that queued -> processing must be permitted. Verified end-to-end against the docker-compose stack: a synthetic status=done run dispatched via /notify/run-terminal now reaches handlerStatus.pp-atif.status=done + reportsTriggeredAt, with duplicate delivery still no-oping and the report trigger firing exactly once. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../post-processor/src/post-processor.test.ts | 56 ++++++++++++++++++- .../post-processor/src/post-processor.ts | 14 ++++- docs/architecture/post-processing.md | 2 +- 3 files changed, 68 insertions(+), 4 deletions(-) diff --git a/apps/workers/post-processor/src/post-processor.test.ts b/apps/workers/post-processor/src/post-processor.test.ts index e33cccbd3..44f102fa9 100644 --- a/apps/workers/post-processor/src/post-processor.test.ts +++ b/apps/workers/post-processor/src/post-processor.test.ts @@ -122,7 +122,7 @@ describe("PostProcessor", () => { _id: "req-1", "run._id": "run-1", "run.status": "done", - "run.handlerStatus.pp-atif.status": { $nin: ["queued", "processing"] }, + "run.handlerStatus.pp-atif.status": { $nin: ["processing"] }, $or: [ { "run.handlerStatus.pp-atif.version": { $exists: false } }, { "run.handlerStatus.pp-atif.version": { $lt: 1 } }, @@ -181,4 +181,58 @@ describe("PostProcessor", () => { expect(notifyHandlerComplete).toHaveBeenCalledWith("req-1", "run-1", "pp-atif", "done"); expect(safeDeleteMessage).toHaveBeenCalledWith("message-1", "pop-1"); }); + + it("claims a run left in the scheduler's 'queued' state and drives it to done", async () => { + // Regression: the scheduler writes a best-effort "queued" marker right after + // enqueue-before-claim, so a dequeued message almost always sees status + // "queued". The worker MUST be able to claim it (queued -> processing). + // Previously "queued" was in the claim exclusion list, so the worker + // discarded its own message and stranded the run at "queued" forever. + const { processor, collection, safeDeleteMessage, notifyHandlerComplete } = makeProcessor(); + processor.registerHandler(handler); + + const doc = makeDoc({ + handlerStatus: { + "pp-atif": { status: "queued", updatedAt: new Date() }, + }, + }); + const heartbeat = makeHeartbeat(); + const payload: PostProcessorMessage = { type: "atif", requestId: "req-1", runId: "run-1" }; + + collection.findOneAndUpdate.mockResolvedValue({ _id: "req-1" }); + collection.updateOne.mockResolvedValue({ matchedCount: 1 }); + + await (processor as any).handleRequest(doc, makeMessage(), heartbeat, log, payload); + + // The claim filter must NOT exclude "queued" — only "processing". + expect(collection.findOneAndUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + _id: "req-1", + "run._id": "run-1", + "run.status": "done", + "run.handlerStatus.pp-atif.status": { $nin: ["processing"] }, + }), + expect.any(Object), + expect.objectContaining({ returnDocument: "after" }), + ); + expect(handler.process).toHaveBeenCalledWith( + payload, + expect.objectContaining({ collection, log }), + ); + expect(collection.updateOne).toHaveBeenCalledWith( + { + _id: "req-1", + "run._id": "run-1", + "run.handlerStatus.pp-atif.status": "processing", + }, + { + $set: expect.objectContaining({ + "run.handlerStatus.pp-atif.status": "done", + "run.handlerStatus.pp-atif.version": 1, + }), + }, + ); + expect(notifyHandlerComplete).toHaveBeenCalledWith("req-1", "run-1", "pp-atif", "done"); + expect(safeDeleteMessage).toHaveBeenCalledWith("message-1", "pop-1"); + }); }); diff --git a/apps/workers/post-processor/src/post-processor.ts b/apps/workers/post-processor/src/post-processor.ts index db2a46c9f..d8dcb2a66 100644 --- a/apps/workers/post-processor/src/post-processor.ts +++ b/apps/workers/post-processor/src/post-processor.ts @@ -48,9 +48,19 @@ export class PostProcessor extends BaseQueueProcessor { handlerId: string, handler: PostProcessHandler, ): Record { + // "queued" is deliberately NOT excluded: the scheduler writes a best-effort + // "queued" marker immediately after enqueueing (enqueue-before-claim), so by + // the time this message is dequeued the status is almost always "queued". + // The worker is exactly what transitions queued -> processing, so it must be + // able to claim a "queued" attempt. Excluding it would let the worker discard + // its own message and strand the run at "queued" (the scheduler poll also + // excludes "queued", so nothing would ever retry it). We only exclude + // "processing" (another worker is mid-flight) and — for non-backfill handlers + // — "done" (already complete). For autoBackfill, "done" is gated by the + // version check below instead, so a version bump can re-process it. const statusExclusions = handler.autoBackfill - ? ["queued", "processing"] - : ["queued", "processing", "done"]; + ? ["processing"] + : ["processing", "done"]; const filter: Record = { _id: requestId, diff --git a/docs/architecture/post-processing.md b/docs/architecture/post-processing.md index 5bc1efbd5..1370e5b7c 100644 --- a/docs/architecture/post-processing.md +++ b/docs/architecture/post-processing.md @@ -98,7 +98,7 @@ To keep this safe, idempotency is enforced at the **post-processor worker entryp 1. The scheduler decides whether work is needed from `run.handlerStatus[handlerId]` + handler metadata (`version`, `autoBackfill`). 2. The worker re-checks that same eligibility when a message is dequeued, scoped to the specific `runId`. -3. The worker atomically claims the handler by transitioning its status to `processing`. +3. The worker atomically claims the handler by transitioning its status to `processing`. The claim filter accepts both the *absent* state and the `queued` marker as valid source states — it only excludes `processing` (another worker is mid-flight) and, for non-backfill handlers, `done` (already complete; for `autoBackfill` handlers a stale `done` is re-claimable via the version gate). Because the scheduler writes the best-effort `queued` marker in-process immediately after enqueueing (enqueue-before-claim), a dequeued message almost always sees `queued`; excluding `queued` from the claim filter would make the worker discard its own message and strand the run at `queued` forever (the 30s backfill poll also skips `queued`), so `queued → processing` **must** be a permitted worker transition. 4. If the message is stale, duplicated, or the handler is already current, the worker deletes the queue message and no-ops. This means: From 0531a0ad6440f41058c514b2f1fac7af6ea4bad5 Mon Sep 17 00:00:00 2001 From: Cedric Vidal Date: Thu, 9 Jul 2026 18:47:31 -0700 Subject: [PATCH 19/19] chore(db-migrations): renumber handler-DAG migrations to 025-027 `main` independently claimed 018/019/020 (backfill-criteria-gates, backfill-task-prompt-type, create-codebase-indexes) after this branch had assigned those numbers to the handler-DAG migrations, producing duplicate prefixes once main was merged in. Renumber the three handler migrations to sit after main's highest (024) so prefixes are unique and monotonic: 018-migrate-services-to-handler-dag -> 025-migrate-services-to-handler-dag 019-add-handler-status-index -> 026-add-handler-status-index 020-backfill-handler-status -> 027-backfill-handler-status Class names are unchanged (mongo-migrate-ts tracks applied migrations by className), and these migrations have not been applied to any shared environment (int/prod migrations come from main only), so the renumber is safe. Also updates required-migrations.ts, the internal [NNN] log prefixes, and the db-migrations doc table. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- docs/architecture/db-migrations.md | 6 +++--- ...r-dag.ts => 025-migrate-services-to-handler-dag.ts} | 6 +++--- ...status-index.ts => 026-add-handler-status-index.ts} | 10 +++++----- ...andler-status.ts => 027-backfill-handler-status.ts} | 0 packages/db-migrations/src/required-migrations.ts | 6 +++--- 5 files changed, 14 insertions(+), 14 deletions(-) rename packages/db-migrations/src/migrations/{018-migrate-services-to-handler-dag.ts => 025-migrate-services-to-handler-dag.ts} (86%) rename packages/db-migrations/src/migrations/{019-add-handler-status-index.ts => 026-add-handler-status-index.ts} (82%) rename packages/db-migrations/src/migrations/{020-backfill-handler-status.ts => 027-backfill-handler-status.ts} (100%) diff --git a/docs/architecture/db-migrations.md b/docs/architecture/db-migrations.md index f69880a10..2e5b28da0 100644 --- a/docs/architecture/db-migrations.md +++ b/docs/architecture/db-migrations.md @@ -102,9 +102,9 @@ When you run a command: | `007-rename-exhausted-to-finished` | Renames outcome `exhausted` → `finished` | | `008-backfill-ai-call-count` | Downloads HARs from blob storage to count AI completion calls per turn | | `009-add-requests-filter-indexes` | Adds indexes on `taskPromptId`, `status`, `outcome`, `workerType`, `deletedAt` for server-side filtering/grouping | -| `018-migrate-services-to-handler-dag` | Replaces the legacy single `post-processor` service row with per-handler service registrations and dependencies | -| `019-add-handler-status-index` | Adds a handler-specific dispatch index for `pp-atif` polling | -| `020-backfill-handler-status` | Backfills `run.handlerStatus.pp-atif` from deprecated `run.postProcessorStatus` / `run.postProcessorVersion` fields | +| `025-migrate-services-to-handler-dag` | Replaces the legacy single `post-processor` service row with per-handler service registrations and dependencies | +| `026-add-handler-status-index` | Adds a handler-specific dispatch index for `pp-atif` polling | +| `027-backfill-handler-status` | Backfills `run.handlerStatus.pp-atif` from deprecated `run.postProcessorStatus` / `run.postProcessorVersion` fields | ## CI/CD diff --git a/packages/db-migrations/src/migrations/018-migrate-services-to-handler-dag.ts b/packages/db-migrations/src/migrations/025-migrate-services-to-handler-dag.ts similarity index 86% rename from packages/db-migrations/src/migrations/018-migrate-services-to-handler-dag.ts rename to packages/db-migrations/src/migrations/025-migrate-services-to-handler-dag.ts index 6d2aa2c94..e2c683b56 100644 --- a/packages/db-migrations/src/migrations/018-migrate-services-to-handler-dag.ts +++ b/packages/db-migrations/src/migrations/025-migrate-services-to-handler-dag.ts @@ -5,7 +5,7 @@ import type { Db } from "mongodb"; import type { MigrationInterface } from "mongo-migrate-ts"; /** - * Migration 018: Remove the legacy "post-processor" service entry. + * Migration 025: Remove the legacy "post-processor" service entry. * * Handler service registration (e.g. pp-atif) is now owned * by each worker's handler.yaml + the generic register-handler script, @@ -18,7 +18,7 @@ export class MigrateServicesToHandlerDag implements MigrationInterface { async up(db: Db): Promise { const services = db.collection("services"); await services.deleteOne({ _id: "post-processor" } as any); - console.log("[018] Deleted legacy post-processor service entry (handler registration now owned by workers)"); + console.log("[025] Deleted legacy post-processor service entry (handler registration now owned by workers)"); } async down(db: Db): Promise { @@ -37,6 +37,6 @@ export class MigrateServicesToHandlerDag implements MigrationInterface { }, { upsert: true }, ); - console.log("[018-down] Restored legacy post-processor service entry"); + console.log("[025-down] Restored legacy post-processor service entry"); } } diff --git a/packages/db-migrations/src/migrations/019-add-handler-status-index.ts b/packages/db-migrations/src/migrations/026-add-handler-status-index.ts similarity index 82% rename from packages/db-migrations/src/migrations/019-add-handler-status-index.ts rename to packages/db-migrations/src/migrations/026-add-handler-status-index.ts index ee6e35f95..cf514668e 100644 --- a/packages/db-migrations/src/migrations/019-add-handler-status-index.ts +++ b/packages/db-migrations/src/migrations/026-add-handler-status-index.ts @@ -25,12 +25,12 @@ export class AddHandlerStatusIndex implements MigrationInterface { const exists = await requests.indexExists(createdName); console.log( exists - ? `[019] Created ${index.name} index on requests` - : `[019] ${index.name} was requested but is not visible in getIndexes()`, + ? `[026] Created ${index.name} index on requests` + : `[026] ${index.name} was requested but is not visible in getIndexes()`, ); } catch (err: unknown) { const message = err instanceof Error ? err.message : String(err); - console.log(`[019] ${index.name} already exists or couldn't be created: ${message}`); + console.log(`[026] ${index.name} already exists or couldn't be created: ${message}`); } } } @@ -41,14 +41,14 @@ export class AddHandlerStatusIndex implements MigrationInterface { for (const index of HANDLER_STATUS_INDEXES) { try { await requests.dropIndex(index.name); - console.log(`[019-down] Dropped ${index.name} index`); + console.log(`[026-down] Dropped ${index.name} index`); } catch (err: unknown) { const codeName = typeof err === "object" && err !== null && "codeName" in err ? String((err as { codeName?: unknown }).codeName) : undefined; if (codeName !== "IndexNotFound") { const message = err instanceof Error ? err.message : String(err); - console.log(`[019-down] ${index.name} not found: ${message}`); + console.log(`[026-down] ${index.name} not found: ${message}`); } } } diff --git a/packages/db-migrations/src/migrations/020-backfill-handler-status.ts b/packages/db-migrations/src/migrations/027-backfill-handler-status.ts similarity index 100% rename from packages/db-migrations/src/migrations/020-backfill-handler-status.ts rename to packages/db-migrations/src/migrations/027-backfill-handler-status.ts diff --git a/packages/db-migrations/src/required-migrations.ts b/packages/db-migrations/src/required-migrations.ts index 41f0897ba..8d8484068 100644 --- a/packages/db-migrations/src/required-migrations.ts +++ b/packages/db-migrations/src/required-migrations.ts @@ -31,13 +31,13 @@ export const REQUIRED_MIGRATIONS: readonly string[] = [ "016-fix-scheduler-sort-index.ts", "017-add-post-processor-dispatch-index.ts", "018-backfill-criteria-gates.ts", - "018-migrate-services-to-handler-dag.ts", - "019-add-handler-status-index.ts", "019-backfill-task-prompt-type.ts", - "020-backfill-handler-status.ts", "020-create-codebase-indexes.ts", "021-add-runs-filter-indexes.ts", "022-add-runs-sort-indexes.ts", "023-add-runs-search-task-index.ts", "024-add-criteria-sort-index.ts", + "025-migrate-services-to-handler-dag.ts", + "026-add-handler-status-index.ts", + "027-backfill-handler-status.ts", ];