Skip to content
Open
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
56 changes: 56 additions & 0 deletions assets/oh-my-opencode.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -3702,6 +3702,62 @@
},
"additionalProperties": false
},
"model_scheduler": {
"type": "object",
"properties": {
"enabled": {
"type": "boolean"
},
"interval_minutes": {
"type": "integer",
"minimum": 1,
"maximum": 1440
},
"mode": {
"type": "string",
"enum": [
"observe",
"dry-run",
"active"
]
},
"preflight_on_session_created": {
"type": "boolean"
},
"failure_threshold": {
"type": "integer",
"minimum": 1,
"maximum": 10
},
"recovery_threshold": {
"type": "integer",
"minimum": 1,
"maximum": 10
},
"agent_cooldown_minutes": {
"type": "integer",
"minimum": 0,
"maximum": 1440
},
"protect_manual_routing": {
"type": "boolean"
},
"probe_enabled": {
"type": "boolean"
},
"probe_timeout_ms": {
"type": "integer",
"minimum": 1000,
"maximum": 300000
},
"probe_max_latency_ms": {
"type": "integer",
"minimum": 100,
"maximum": 300000
}
},
"additionalProperties": false
},
"babysitting": {
"type": "object",
"properties": {
Expand Down
44 changes: 11 additions & 33 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions src/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ export type {
McpName,
AgentName,
HookName,
ModelSchedulerConfig,
ModelSchedulerMode,
BuiltinCommandName,
SisyphusAgentConfig,
ExperimentalConfig,
Expand Down
1 change: 1 addition & 0 deletions src/config/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export * from "./schema/experimental"
export * from "./schema/fallback-models"
export * from "./schema/git-master"
export * from "./schema/hooks"
export * from "./schema/model-scheduler"
export * from "./schema/notification"
export * from "./schema/oh-my-opencode-config"
export * from "./schema/ralph-loop"
Expand Down
49 changes: 49 additions & 0 deletions src/config/schema/model-scheduler.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { describe, expect, test } from "bun:test"
import { ZodError } from "zod/v4"
import { ModelSchedulerConfigSchema } from "./model-scheduler"

describe("ModelSchedulerConfigSchema", () => {
test("parses valid scheduler config", () => {
const result = ModelSchedulerConfigSchema.parse({
enabled: true,
interval_minutes: 60,
mode: "active",
preflight_on_session_created: true,
failure_threshold: 2,
recovery_threshold: 2,
agent_cooldown_minutes: 180,
protect_manual_routing: true,
probe_enabled: true,
probe_timeout_ms: 15000,
probe_max_latency_ms: 8000,
})

expect(result.mode).toBe("active")
expect(result.interval_minutes).toBe(60)
expect(result.probe_enabled).toBe(true)
})

test("rejects invalid interval", () => {
let thrownError: unknown

try {
ModelSchedulerConfigSchema.parse({ interval_minutes: 0 })
} catch (error) {
thrownError = error
}

expect(thrownError).toBeInstanceOf(ZodError)
})

test("rejects invalid probe timeout", () => {
let thrownError: unknown

try {
ModelSchedulerConfigSchema.parse({ probe_timeout_ms: 999 })
} catch (error) {
thrownError = error
}

expect(thrownError).toBeInstanceOf(ZodError)
})
})
20 changes: 20 additions & 0 deletions src/config/schema/model-scheduler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { z } from "zod"

export const ModelSchedulerModeSchema = z.enum(["observe", "dry-run", "active"])

export const ModelSchedulerConfigSchema = z.object({
enabled: z.boolean().optional(),
interval_minutes: z.number().int().min(1).max(24 * 60).optional(),
mode: ModelSchedulerModeSchema.optional(),
preflight_on_session_created: z.boolean().optional(),
failure_threshold: z.number().int().min(1).max(10).optional(),
recovery_threshold: z.number().int().min(1).max(10).optional(),
agent_cooldown_minutes: z.number().int().min(0).max(24 * 60).optional(),
protect_manual_routing: z.boolean().optional(),
probe_enabled: z.boolean().optional(),
probe_timeout_ms: z.number().int().min(1000).max(300000).optional(),
probe_max_latency_ms: z.number().int().min(100).max(300000).optional(),
})

export type ModelSchedulerMode = z.infer<typeof ModelSchedulerModeSchema>
export type ModelSchedulerConfig = z.infer<typeof ModelSchedulerConfigSchema>
2 changes: 2 additions & 0 deletions src/config/schema/oh-my-opencode-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { BuiltinCommandNameSchema } from "./commands"
import { ExperimentalConfigSchema } from "./experimental"
import { GitMasterConfigSchema } from "./git-master"
import { NotificationConfigSchema } from "./notification"
import { ModelSchedulerConfigSchema } from "./model-scheduler"
import { RalphLoopConfigSchema } from "./ralph-loop"
import { RuntimeFallbackConfigSchema } from "./runtime-fallback"
import { SkillsConfigSchema } from "./skills"
Expand Down Expand Up @@ -55,6 +56,7 @@ export const OhMyOpenCodeConfigSchema = z.object({
runtime_fallback: z.union([z.boolean(), RuntimeFallbackConfigSchema]).optional(),
background_task: BackgroundTaskConfigSchema.optional(),
notification: NotificationConfigSchema.optional(),
model_scheduler: ModelSchedulerConfigSchema.optional(),
babysitting: BabysittingConfigSchema.optional(),
git_master: GitMasterConfigSchema.optional(),
browser_automation_engine: BrowserAutomationConfigSchema.optional(),
Expand Down
59 changes: 59 additions & 0 deletions src/features/model-scheduler/candidate-models.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import {
Copy link

@cubic-dev-ai cubic-dev-ai bot Mar 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Duplicate normalizeKey function introduced in two files

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/features/model-scheduler/candidate-models.ts, line 8:

<comment>Duplicate `normalizeKey` function introduced in two files</comment>

<file context>
@@ -0,0 +1,59 @@
+} from "../../shared"
+import type { RoutingEntry, RoutingTargetKind } from "./types"
+
+function normalizeKey(value: string): string {
+  return value.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "")
+}
</file context>
Fix with Cubic

AGENT_MODEL_REQUIREMENTS,
CATEGORY_MODEL_REQUIREMENTS,
fuzzyMatchModel,
} from "../../shared"
import type { RoutingEntry, RoutingTargetKind } from "./types"

function normalizeKey(value: string): string {
return value.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "")
}

function resolveCandidate(model: string | null | undefined, availableModels: Set<string>): string[] {
if (!model) return []

for (const availableModel of availableModels) {
if (availableModel.toLowerCase() === model.trim().toLowerCase()) {
return [availableModel]
}
}

return []
}

export function collectCandidateModels(args: {
kind: RoutingTargetKind
key: string
routingEntry?: RoutingEntry | null
currentModel: string | null
availableModels: Set<string>
}): string[] {
const resolvedCandidates = new Set<string>()
const pushResolved = (model: string | null | undefined) => {
for (const resolved of resolveCandidate(model, args.availableModels)) {
resolvedCandidates.add(resolved)
}
}

pushResolved(args.currentModel)
for (const fallback of args.routingEntry?.fallback ?? []) {
pushResolved(fallback)
}

const requirements = args.kind === "agent"
? AGENT_MODEL_REQUIREMENTS[normalizeKey(args.key)]
: CATEGORY_MODEL_REQUIREMENTS[normalizeKey(args.key)]

for (const fallbackEntry of requirements?.fallbackChain ?? []) {
const matchedModel = fuzzyMatchModel(
fallbackEntry.model,
args.availableModels,
fallbackEntry.providers,
)
if (matchedModel) {
resolvedCandidates.add(matchedModel)
}
}

return Array.from(resolvedCandidates)
}
17 changes: 17 additions & 0 deletions src/features/model-scheduler/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
export const MODEL_HEALTH_FILE = "model-health.json"
export const MODEL_SCHEDULER_AUDIT_FILE = "scheduler-audit.jsonl"
export const MODEL_ROUTING_FILE = "model-routing.json"

export const DEFAULT_MODEL_SCHEDULER_CONFIG = {
enabled: true,
interval_minutes: 60,
mode: "active",
preflight_on_session_created: true,
failure_threshold: 1,
recovery_threshold: 1,
agent_cooldown_minutes: 180,
protect_manual_routing: true,
probe_enabled: true,
probe_timeout_ms: 15000,
probe_max_latency_ms: 8000,
} as const
60 changes: 60 additions & 0 deletions src/features/model-scheduler/health-store.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs"
Copy link

@cubic-dev-ai cubic-dev-ai bot Mar 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Duplicate writeJsonAtomic function across two new files

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/features/model-scheduler/health-store.ts, line 14:

<comment>Duplicate `writeJsonAtomic` function across two new files</comment>

<file context>
@@ -0,0 +1,60 @@
+  }
+}
+
+function writeJsonAtomic(filePath: string, data: unknown): void {
+  ensureParentDir(filePath)
+  const tempPath = `${filePath}.tmp.${Date.now()}`
</file context>
Fix with Cubic

import { dirname, join } from "node:path"
import { getOmoOpenCodeCacheDir } from "../../shared"
import { MODEL_HEALTH_FILE, MODEL_SCHEDULER_AUDIT_FILE } from "./constants"
import type { ModelHealthSnapshot, ModelSchedulerAuditEntry } from "./types"

function ensureParentDir(filePath: string): void {
const parentDir = dirname(filePath)
if (!existsSync(parentDir)) {
mkdirSync(parentDir, { recursive: true })
}
}

function writeJsonAtomic(filePath: string, data: unknown): void {
ensureParentDir(filePath)
const tempPath = `${filePath}.tmp.${Date.now()}`

try {
writeFileSync(tempPath, JSON.stringify(data, null, 2), "utf-8")
renameSync(tempPath, filePath)
} catch (error) {
if (existsSync(tempPath)) {
unlinkSync(tempPath)
}
throw error
}
}

export function getModelHealthFilePath(): string {
return join(getOmoOpenCodeCacheDir(), MODEL_HEALTH_FILE)
}

export function getModelSchedulerAuditFilePath(): string {
return join(getOmoOpenCodeCacheDir(), MODEL_SCHEDULER_AUDIT_FILE)
}

export function readModelHealthSnapshot(): ModelHealthSnapshot | null {
const filePath = getModelHealthFilePath()
if (!existsSync(filePath)) return null

try {
const raw = readFileSync(filePath, "utf-8")
return JSON.parse(raw) as ModelHealthSnapshot
} catch {
return null
}
}

export function writeModelHealthSnapshot(snapshot: ModelHealthSnapshot): void {
writeJsonAtomic(getModelHealthFilePath(), snapshot)
}

export function appendModelSchedulerAuditEntry(entry: ModelSchedulerAuditEntry): void {
const filePath = getModelSchedulerAuditFilePath()
ensureParentDir(filePath)
writeFileSync(filePath, `${JSON.stringify(entry)}\n`, {
encoding: "utf-8",
flag: "a",
})
}
8 changes: 8 additions & 0 deletions src/features/model-scheduler/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export * from "./constants"
export * from "./candidate-models"
export * from "./health-store"
export * from "./model-probe"
export * from "./routing-store"
export * from "./scheduler"
export * from "./selector"
export * from "./types"
Loading
Loading