Skip to content
Closed
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
1 change: 1 addition & 0 deletions changelog.d/fixes/8604-task-routing-config-restore.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- **fix(sse):** restore Task-Aware Routing config from `settings.taskRouting` at boot and keep it on `globalThis` so multi-graph imports share one store — stops the feature silently reverting to disabled on every restart (#8601) ([#8604](https://github.com/diegosouzapw/OmniRoute/pull/8604)) — thanks @MumuTW
100 changes: 76 additions & 24 deletions open-sse/services/taskAwareRouter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,7 @@
// ── Types ───────────────────────────────────────────────────────────────────

export type TaskType =
| "coding"
| "creative"
| "analysis"
| "vision"
| "summarization"
| "background"
| "chat";
"coding" | "creative" | "analysis" | "vision" | "summarization" | "background" | "chat";

interface TaskPattern {
patterns: string[];
Expand Down Expand Up @@ -168,33 +162,90 @@ const DEFAULT_TASK_MODEL_MAP: Record<TaskType, string> = {

// ── State ────────────────────────────────────────────────────────────────────

let _config: TaskRoutingConfig = {
enabled: false, // User must explicitly enable
taskModelMap: { ...DEFAULT_TASK_MODEL_MAP },
detectionEnabled: true,
stats: { detected: 0, routed: 0 },
};
// #8601: the config MUST live on globalThis, NOT in a module-level `let`. A plain
// module-level binding is DUPLICATED per module graph, so the boot hydration in
// src/instrumentation-node.ts would land on the instrumentation graph's copy and
// never reach the copy src/sse/handlers/chat.ts reads — exactly the #5312 fix-A
// break proven on the VPS. Mirrors thinkingBudget.ts (#5312) and systemPrompt.ts (#2470).
const GLOBAL_KEY = "__omniroute_taskRouting_config__";
const _store = globalThis as unknown as Record<string, TaskRoutingConfig | undefined>;

function freshConfig(): TaskRoutingConfig {
return {
enabled: false, // User must explicitly enable
taskModelMap: { ...DEFAULT_TASK_MODEL_MAP },
detectionEnabled: true,
stats: { detected: 0, routed: 0 },
};
}

function getConfig(): TaskRoutingConfig {
if (!_store[GLOBAL_KEY]) {
_store[GLOBAL_KEY] = freshConfig();
}
return _store[GLOBAL_KEY]!;
}

// ── Config Management ────────────────────────────────────────────────────────

export function setTaskRoutingConfig(config: Partial<TaskRoutingConfig>): void {
_config = {
..._config,
const current = getConfig();
_store[GLOBAL_KEY] = {
...current,
...config,
stats: _config.stats, // preserve stats across config changes
stats: current.stats, // preserve stats across config changes
};
}

export function getTaskRoutingConfig(): TaskRoutingConfig {
const current = getConfig();
return {
..._config,
taskModelMap: { ..._config.taskModelMap },
stats: { ..._config.stats },
...current,
taskModelMap: { ...current.taskModelMap },
stats: { ...current.stats },
};
}

export function resetTaskRoutingStats(): void {
_config.stats = { detected: 0, routed: 0 };
getConfig().stats = { detected: 0, routed: 0 };
}

/**
* Restore the persisted Task-Aware Routing config at boot (#8601).
*
* `PUT /api/settings/task-routing` writes the config to `settings.taskRouting` as a
* JSON string, but nothing ever read it back — so the feature silently reverted to
* `enabled: false` + the default model map on every restart. `applyRuntimeSettings`
* does not cover this key, so it needs an explicit hydration step, same as the
* Global System Prompt (#2470) and the Thinking-Budget config (#5312).
*
* Accepts either the JSON string the route persists or an already-parsed object.
* Returns true when a config was applied, false for missing/malformed values
* (fail-open: the in-memory defaults stay in place).
*/
export function hydrateTaskRoutingConfig(settings: unknown): boolean {
const raw =
settings && typeof settings === "object" && !Array.isArray(settings)
? (settings as Record<string, unknown>).taskRouting
: undefined;
if (raw === undefined || raw === null) return false;

let parsed: unknown = raw;
if (typeof raw === "string") {
if (raw.trim().length === 0) return false;
try {
parsed = JSON.parse(raw);
} catch {
return false;
}
}
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return false;

// `stats` is runtime telemetry, never restored from the persisted blob — the route
// already strips it on write, but a hand-edited settings row must not resurrect it.
const { stats: _ignoredStats, ...persisted } = parsed as Partial<TaskRoutingConfig>;
setTaskRoutingConfig(persisted);
return true;
}

export function getDefaultTaskModelMap(): Record<TaskType, string> {
Expand Down Expand Up @@ -299,14 +350,15 @@ export function applyTaskAwareRouting(
originalModel: string,
body: any
): { model: string; taskType: TaskType; wasRouted: boolean } {
if (!_config.enabled || !_config.detectionEnabled) {
const config = getConfig();
if (!config.enabled || !config.detectionEnabled) {
return { model: originalModel, taskType: "chat", wasRouted: false };
}

const taskType = detectTaskType(body);
_config.stats.detected++;
config.stats.detected++;

const preferred = _config.taskModelMap[taskType];
const preferred = config.taskModelMap[taskType];

// No override configured for this task type
if (!preferred || preferred === "") {
Expand All @@ -321,6 +373,6 @@ export function applyTaskAwareRouting(
// This is a conservative heuristic — full override can be enabled via settting
}

_config.stats.routed++;
config.stats.routed++;
return { model: preferred, taskType, wasRouted: true };
}
9 changes: 0 additions & 9 deletions skills/cli-backup-sync/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,15 +75,6 @@ omniroute backup disable

### `backup status`

**Flags:**

- `--name <name>`
- `--cloud`
- `--encrypt`
- `--key-file <path>`
- `--exclude <pattern>`
- `--retention <n>`

**Example:**

```bash
Expand Down
11 changes: 11 additions & 0 deletions src/instrumentation-node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -402,6 +402,17 @@ export async function registerNodejs(): Promise<void> {
console.log("[STARTUP] Thinking-Budget config restored from settings");
}

// Restore the Task-Aware Smart Routing config (#8601). It lives in
// `settings.taskRouting` (written as a JSON string by PUT /api/settings/task-routing)
// and is NOT covered by applyRuntimeSettings, so without this the feature silently
// reverts to disabled + the default model map on every restart. Same shape as the
// Thinking-Budget restore above; must live here, not in the unused server-init.ts.
const { hydrateTaskRoutingConfig } =
await import("@omniroute/open-sse/services/taskAwareRouter.ts");
if (hydrateTaskRoutingConfig(settings)) {
console.log("[STARTUP] Task-Aware Routing config restored from settings");
}

const seededModelAliases = await seedDefaultModelAliases();
console.log(
`[STARTUP] Model alias seed: applied=${seededModelAliases.applied.length}, skipped=${seededModelAliases.skipped.length}, removed=${seededModelAliases.removed.length}, failed=${seededModelAliases.failed.length}`
Expand Down
132 changes: 132 additions & 0 deletions tests/unit/task-routing-config-restore-8601.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
/**
* TDD regression for #8601: the Task-Aware Smart Routing (T05) config is persisted
* to `settings.taskRouting` by PUT /api/settings/task-routing but never read back,
* so it silently reverts to `enabled: false` + the hardcoded default model map on
* every restart.
*
* Two independent root causes, one test each:
*
* A. No hydration entry point existed at all. `hydrateTaskRoutingConfig(settings)`
* must parse the persisted value (a JSON *string*, as the route writes it) and
* apply it, mirroring `hydrateThinkingBudgetConfig` (#5312) and
* `setSystemPromptConfig` (#2470).
*
* B. The config lived in a plain module-level `let`, which is DUPLICATED per module
* graph — so a boot hydration on the instrumentation graph would never reach the
* copy that `src/sse/handlers/chat.ts` reads. This is the exact break #5312 fix-A
* hit on the VPS. The store must live on `globalThis`, like `thinkingBudget.ts`
* and `systemPrompt.ts`. Importing the module under two distinct specifiers gives
* two real module instances, which reproduces the duplication directly.
*/
import { test } from "node:test";
import assert from "node:assert/strict";
import {
hydrateTaskRoutingConfig,
setTaskRoutingConfig,
getTaskRoutingConfig,
getDefaultTaskModelMap,
} from "../../open-sse/services/taskAwareRouter.ts";

const MODULE_PATH = "../../open-sse/services/taskAwareRouter.ts";

function resetConfig(): void {
setTaskRoutingConfig({
enabled: false,
taskModelMap: getDefaultTaskModelMap(),
detectionEnabled: true,
});
}

// ── A. hydration from persisted settings ─────────────────────────────────────

test("#8601 hydrateTaskRoutingConfig restores a JSON-string persisted config", () => {
resetConfig();

// Shape written by src/app/api/settings/task-routing/route.ts:66
const persisted = JSON.stringify({
enabled: true,
detectionEnabled: true,
taskModelMap: { ...getDefaultTaskModelMap(), coding: "auto/coding" },
});

assert.equal(hydrateTaskRoutingConfig({ taskRouting: persisted }), true);

const config = getTaskRoutingConfig();
assert.equal(config.enabled, true);
assert.equal(config.taskModelMap.coding, "auto/coding");

resetConfig();
});

test("#8601 hydrateTaskRoutingConfig accepts an already-parsed object", () => {
resetConfig();

assert.equal(
hydrateTaskRoutingConfig({ taskRouting: { enabled: true, detectionEnabled: false } }),
true
);
assert.equal(getTaskRoutingConfig().enabled, true);
assert.equal(getTaskRoutingConfig().detectionEnabled, false);

resetConfig();
});

test("#8601 hydrateTaskRoutingConfig is a no-op for missing/invalid settings", () => {
resetConfig();

for (const settings of [
undefined,
null,
{},
{ taskRouting: "" },
{ taskRouting: "not json" },
{ taskRouting: "[]" },
{ taskRouting: 42 },
]) {
assert.equal(
hydrateTaskRoutingConfig(settings),
false,
`should ignore ${JSON.stringify(settings)}`
);
assert.equal(getTaskRoutingConfig().enabled, false);
}

resetConfig();
});

test("#8601 hydration never resurrects stats from the persisted blob", () => {
resetConfig();

hydrateTaskRoutingConfig({
taskRouting: JSON.stringify({ enabled: true, stats: { detected: 999, routed: 999 } }),
});

const { stats } = getTaskRoutingConfig();
assert.equal(stats.detected, 0);
assert.equal(stats.routed, 0);

resetConfig();
});

// ── B. cross-module-graph sharing (the #5312 fix-A trap) ─────────────────────

test("#8601 config store is shared across duplicated module instances", async () => {
resetConfig();

// Distinct specifiers → two genuinely separate module records, the same way the
// Next.js instrumentation graph and the app-route/open-sse graph each get their own.
const graphA = await import(`${MODULE_PATH}?graph=a`);
const graphB = await import(`${MODULE_PATH}?graph=b`);

graphA.setTaskRoutingConfig({ enabled: true, detectionEnabled: true });

assert.equal(
graphB.getTaskRoutingConfig().enabled,
true,
"a config set on one module instance must be visible from another — otherwise boot " +
"hydration lands on the instrumentation copy and the chat handler never sees it"
);

graphA.setTaskRoutingConfig({ enabled: false });
resetConfig();
});
Loading