Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- **fix(routing):** round-robin combos now show up in Combo Studio's Live dashboard — they were completing successfully but never publishing the attempt/success/failure events the dashboard listens for (#13089) — thanks @adityadwi21
1 change: 1 addition & 0 deletions config/quality/file-size-baseline.json
Original file line number Diff line number Diff line change
Expand Up @@ -500,6 +500,7 @@
"open-sse/services/autoCombo/virtualFactory.ts": 1230,
"open-sse/services/combo/roundRobinCombo.ts": 1213
},
"_rebaseline_2026_09_15_roundrobin_dashboard_events": "Fix #13089 (Combo Studio Live dashboard shows an empty backlog for round-robin combos): open-sse/services/combo/roundRobinCombo.ts 1205->1213. Round-robin is the only combo strategy that bypasses handleComboChat/executeTargetAttempt.ts, the path that publishes the combo.target.attempt/succeeded/failed EventBus events the Live dashboard listens for — so round-robin completions never showed up. The new call-site wiring (createRRDashboardEvents(...) instantiated once per target, one-line .attempt()/.succeeded()/.failed() calls at the 6 existing dispatch/outcome points) is the emitter logic actually extracted into a new module, open-sse/services/combo/rrDashboardEvents.ts — this is the minimum irreducible footprint for wiring 6 required call sites into 6 fixed control-flow points of the frozen file. Covered by tests/unit/issue-13089-roundrobin-live-ws-events.test.ts (2 tests: success + failure paths).",
"_rebaseline_base_2026_08_10_proxyfetch": "Base-red fix (green-prs sweep, issue #9985): open-sse/utils/proxyFetch.ts 1207 > cap 1000 — new proxied-TLS fetch helper introduced by the Fal reference-image work. Owner-authorized quick rebaseline to green; structural slim tracked for v3.9.0.",
"_rebaseline_2026_07_27_v3849_train2": "Merge-train 2 (7 PRs) — owner-approved 2026-07-27. Single entry: chatCore.ts 4955->5006 (#8595, Responses multi-turn image compaction before the context hard-reject). Genuine irreducible growth at the existing compaction chokepoint in handleChatCore — the PR adds a last-resort retry against the concrete budget plus the estimateFinalInputTokens helper, both wired at the pre-existing call site rather than a new branch. Covered by tests/unit/8560-responses-image-compaction.test.ts (4 tests).",
"_rebaseline_2026_07_27_v3849_train3": "Merge-train 3 (13 PRs) — owner-approved 2026-07-27. Both entries are genuine irreducible growth at existing chokepoints, not new branches: src/lib/db/apiKeys.ts 1518->1529 (#8805 cx/* ≡ codex/* API-key model permissions); open-sse/handlers/chatCore.ts 5006->5020 (#8806 real response payload into plugin onResponse hooks). Covered by tests/unit/db-apiKeys-crud.test.ts (4 new cases) and the two plugin-hook test files updated in #8806 respectively.",
Expand Down
8 changes: 8 additions & 0 deletions open-sse/services/combo/roundRobinCombo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ import {
import { applyComboTargetExhaustion } from "./targetExhaustion.ts";
import { isRetryAfterEligibleStatus } from "./unavailableRetryGate.ts";
import { isRecord } from "./comboData.ts";
import { createRRDashboardEvents } from "./rrDashboardEvents.ts";
import { attemptCompatRejectedFallback } from "./comboCompatFallback.ts";
import { applyRequestTagRouting } from "./autoStrategy.ts";
import {
Expand Down Expand Up @@ -485,6 +486,7 @@ export async function handleRoundRobinCombo({
const target = filteredTargets[modelIndex];
const modelStr = target.modelStr;
const provider = target.provider;
const rrEvents = createRRDashboardEvents(combo.name, modelIndex, provider, modelStr);
const profile = await getRuntimeProviderProfile(provider);
const semaphoreKey = `combo:${combo.name}:${target.executionKey}`;
const allowRateLimitedConnection =
Expand Down Expand Up @@ -646,6 +648,7 @@ export async function handleRoundRobinCombo({
fingerprint: resolveTargetFingerprint(target) ?? "",
});

rrEvents.attempt();
const result = await Promise.race([
handleSingleModel(attemptBody, modelStr, {
...targetForAttempt,
Expand Down Expand Up @@ -709,6 +712,7 @@ export async function handleRoundRobinCombo({
rrSelectedConnectionId || target.connectionId
);
}
rrEvents.failed(`Quality: ${quality.reason}`, Date.now() - startTime);
recordComboRequest(combo.name, modelStr, {
success: false,
latencyMs: Date.now() - startTime,
Expand All @@ -735,6 +739,7 @@ export async function handleRoundRobinCombo({
"COMBO-RR",
`${modelStr} succeeded (${latencyMs}ms, ${fallbackCount} fallbacks)`
);
rrEvents.succeeded(latencyMs);
recordComboRequest(combo.name, modelStr, {
success: true,
latencyMs,
Expand Down Expand Up @@ -844,6 +849,7 @@ export async function handleRoundRobinCombo({
"COMBO-RR",
`Client disconnected (499) during ${modelStr} — stopping combo loop`
);
rrEvents.failed("Client disconnected", Date.now() - startTime);
recordComboRequest(combo.name, modelStr, {
success: false,
latencyMs: Date.now() - startTime,
Expand Down Expand Up @@ -884,6 +890,7 @@ export async function handleRoundRobinCombo({
"COMBO-RR",
`Local rate-limit queue capacity reached for ${modelStr} — returning without upstream fallback`
);
rrEvents.failed(errorText || "Local queue full", Date.now() - startTime);
recordComboRequest(combo.name, modelStr, {
success: false,
latencyMs: Date.now() - startTime,
Expand Down Expand Up @@ -1023,6 +1030,7 @@ export async function handleRoundRobinCombo({
}

// Done with this model
rrEvents.failed(errorText || `HTTP ${result.status}`, Date.now() - startTime);
recordComboRequest(combo.name, modelStr, {
success: false,
latencyMs: Date.now() - startTime,
Expand Down
43 changes: 43 additions & 0 deletions open-sse/services/combo/rrDashboardEvents.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
/**
* Dashboard EventBus emitters for the round-robin combo loop (#13089).
*
* `roundRobinCombo.ts` is frozen at its file-size cap (#12884), so the
* `combo.target.attempt` / `combo.target.succeeded` / `combo.target.failed`
* publishing logic lives here — a factory bound to one target's identity so
* each call site in the frozen file is a single line.
*
* @internal — not part of the public combo.ts barrel.
*/
import { emit } from "../../../src/lib/events/eventBus";

export interface RRDashboardEvents {
attempt(): void;
succeeded(latencyMs: number): void;
failed(error: string, latencyMs: number): void;
}

export function createRRDashboardEvents(
comboName: string,
targetIndex: number,
provider: string,
model: string
): RRDashboardEvents {
return {
attempt() {
emit("combo.target.attempt", {
comboName,
targetIndex,
provider,
model,
timestamp: Date.now(),
strategy: "round-robin",
});
},
succeeded(latencyMs) {
emit("combo.target.succeeded", { comboName, targetIndex, provider, model, latencyMs });
},
failed(error, latencyMs) {
emit("combo.target.failed", { comboName, targetIndex, provider, model, error, latencyMs });
},
};
}
140 changes: 140 additions & 0 deletions tests/unit/issue-13089-roundrobin-live-ws-events.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
/**
* Repro for #13089 — Combo Studio Live dashboard shows empty backlog despite
* successful (or failed) chat completions routed through a round-robin combo.
*
* Round-robin bypasses `handleComboChat` -> `executeTargetAttempt` (the path that
* publishes `combo.target.attempt` / `combo.target.succeeded` / `combo.target.failed`
* on the dashboard EventBus) and dispatches through its own loop in
* `open-sse/services/combo/roundRobinCombo.ts`, which never emitted those events.
*/
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";

const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-13089-"));
const ORIGINAL_DATA_DIR = process.env.DATA_DIR;
process.env.DATA_DIR = TEST_DATA_DIR;

const { handleComboChat } = await import("../../open-sse/services/combo.ts");
const rrState = await import("../../open-sse/services/combo/rrState.ts");
const dbCore = await import("../../src/lib/db/core.ts");
const eventBus = await import("../../src/lib/events/eventBus.ts");

function makeLog() {
return { info() {}, warn() {}, debug() {}, error() {} };
}

function rrCombo(name: string, maxRetries = 0) {
return {
name,
strategy: "round-robin",
config: { maxRetries, disableSessionStickiness: true },
models: [
{
kind: "model",
provider: "codex",
providerId: "codex",
model: "m-a",
connectionId: "conn-A",
id: `${name}-0`,
},
],
};
}

test.beforeEach(() => {
rrState.rrCounters.clear();
rrState.rrStickyTargets.clear();
});

test.after(() => {
try {
dbCore.resetDbInstance?.();
} catch {
/* ignore */
}
if (ORIGINAL_DATA_DIR === undefined) delete process.env.DATA_DIR;
else process.env.DATA_DIR = ORIGINAL_DATA_DIR;
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
});

test("#13089: a successful round-robin combo completion publishes combo.target.succeeded on the dashboard EventBus", async () => {
const combo = rrCombo("rr13089-success");

const seen: Array<{ event: string; payload: unknown }> = [];
const unsubscribeAttempt = eventBus.on("combo.target.attempt", (payload) => {
seen.push({ event: "combo.target.attempt", payload });
});
const unsubscribeSucceeded = eventBus.on("combo.target.succeeded", (payload) => {
seen.push({ event: "combo.target.succeeded", payload });
});

try {
const response = await handleComboChat({
body: { model: combo.name, messages: [{ role: "user", content: "hi" }], stream: false },
combo,
allCombos: [combo],
isModelAvailable: async () => true,
relayOptions: undefined,
signal: undefined,
settings: {},
log: makeLog(),
handleSingleModel: async (_b, modelStr, _target) => {
return Response.json({
choices: [{ message: { role: "assistant", content: modelStr } }],
});
},
});

assert.equal(response.status, 200, "the combo-routed completion itself must succeed");
assert.ok(
seen.some((e) => e.event === "combo.target.attempt"),
`expected a "combo.target.attempt" EventBus event, but none was published (saw: ${JSON.stringify(seen)}).`
);
assert.ok(
seen.some((e) => e.event === "combo.target.succeeded"),
`expected a "combo.target.succeeded" EventBus event after a successful round-robin ` +
`combo completion, but none was published (saw: ${JSON.stringify(seen)}). This is why ` +
`Combo Studio -> Live never shows round-robin combo executions (#13089).`
);
} finally {
unsubscribeAttempt();
unsubscribeSucceeded();
}
});

test("#13089: a failing round-robin combo target publishes combo.target.failed on the dashboard EventBus", async () => {
const combo = rrCombo("rr13089-failure");

const seen: Array<{ event: string; payload: unknown }> = [];
const unsubscribeFailed = eventBus.on("combo.target.failed", (payload) => {
seen.push({ event: "combo.target.failed", payload });
});

try {
const response = await handleComboChat({
body: { model: combo.name, messages: [{ role: "user", content: "hi" }], stream: false },
combo,
allCombos: [combo],
isModelAvailable: async () => true,
relayOptions: undefined,
signal: undefined,
settings: {},
log: makeLog(),
handleSingleModel: async () => {
return Response.json({ error: { message: "rate limited" } }, { status: 429 });
},
});

assert.equal(response.status, 429, "the exhausted combo must surface the upstream failure");
assert.ok(
seen.some((e) => e.event === "combo.target.failed"),
`expected a "combo.target.failed" EventBus event after an exhausted round-robin ` +
`combo target, but none was published (saw: ${JSON.stringify(seen)}).`
);
} finally {
unsubscribeFailed();
}
});
Loading