Skip to content

feat: nightly OpenMythos eval scheduler#61

Open
djimit wants to merge 1 commit into
mainfrom
feat/openmythos-nightly
Open

feat: nightly OpenMythos eval scheduler#61
djimit wants to merge 1 commit into
mainfrom
feat/openmythos-nightly

Conversation

@djimit

@djimit djimit commented Jul 15, 2026

Copy link
Copy Markdown
Owner

Summary

Completes the P1 governance-scorecard arc (#60 ships the read path; this ships the data supply): a scheduler that runs OpenMythos governance evals on a nightly cadence so the leaderboard fills with real production data.

  • Default-off. Armed via OPENMYTHOS_NIGHTLY_ENABLED=true + OPENMYTHOS_NIGHTLY_MODELS=<comma list>; hour configurable via OPENMYTHOS_NIGHTLY_HOUR (default 3, server-local).
  • One run per model per UTC day under agent id nightly:<model>, deduped against openmythos_eval_runs so restarts don't double-run; boot does a catch-up tick.
  • With OPENMYTHOS_ORACLE_ANCHORS_PATH set, only the oracle-anchored subset runs — deterministic scoring, ~2–5 min per model. Models run sequentially to avoid saturating the Ollama host; one model's failure doesn't stop the rest.
  • Unref'd hourly timer — never blocks shutdown.

Validation

  • 5 new unit tests (arming gate, due-hour logic, same-day dedupe, failure isolation, anchor-subset plumbing); full server suite 1,218 pass; type-check + lint clean.
  • Live end-to-end: booted the server with the scheduler armed against workstation Ollama. It ran 78 oracle-anchored cases unattended and scored 2.692 for llama3.1:8b — byte-identical to both manual APEX validation runs of the same subset earlier today (third independent replication of the deterministic baseline), and the score/leaderboard endpoints served the result.

Pairs with #60; no conflicts (touches only new files + a 5-line startup hook in index.ts).

🤖 Generated with Claude Code

Scheduled governance evals so the leaderboard fills with production data
instead of staying empty. Default-off; armed via OPENMYTHOS_NIGHTLY_ENABLED
with a comma-separated subject-model list. One run per model per UTC day
under agent id nightly:<model>, deduped against openmythos_eval_runs so
restarts don't double-run. When OPENMYTHOS_ORACLE_ANCHORS_PATH is set only
the oracle-anchored subset runs (deterministic, minutes per model); models
run sequentially to avoid saturating the Ollama host.

Verified live: booted with the scheduler armed against workstation Ollama;
it ran 78 oracle cases unattended and scored 2.692 — identical to both
manual APEX validation runs of the same subset.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@kilo-code-bot

kilo-code-bot Bot commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

Kilo Code Review could not run — your account is out of credits.

Add credits or switch to a free model to enable reviews on this change.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Code Review

This pull request introduces OpenMythosNightlyService to run scheduled governance evaluations and populate the leaderboard, along with its corresponding unit tests and integration into the server startup. The review feedback highlights two critical issues: first, shouldRun uses SQLite's date('now') instead of the passed now parameter, which makes tests non-deterministic and mixes timezones; second, tick lacks a concurrency guard to prevent overlapping runs if a tick takes longer than an hour, and fails to catch errors during setup, which could lead to unhandled promise rejections.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines +74 to +82
shouldRun(model: string, now: Date = new Date()): boolean {
if (now.getHours() < this.targetHour()) return false;
const row = this.db.prepare(`
SELECT COUNT(*) AS n FROM openmythos_eval_runs
WHERE agent_id = ? AND status IN ('running', 'completed')
AND substr(started_at, 1, 10) = date('now')
`).get(`nightly:${model}`) as { n: number };
return row.n === 0;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

The shouldRun method currently uses SQLite's date('now') to check if a run has already been recorded today. This introduces two issues:

  1. It ignores the now parameter passed to the function, making the query non-deterministic and hard to test (the test will always query against the actual current system date).
  2. It mixes the server's local timezone (used in now.getHours()) with UTC (used in SQLite's date('now')), which can lead to unexpected behavior around timezone boundaries.

To make this deterministic and fully respect the now parameter, we should format now as a UTC date string (YYYY-MM-DD) and pass it as a parameter to the SQL query.

  shouldRun(model: string, now: Date = new Date()): boolean {
    if (now.getHours() < this.targetHour()) return false;
    const dateStr = now.toISOString().slice(0, 10);
    const row = this.db.prepare(
      "SELECT COUNT(*) AS n FROM openmythos_eval_runs WHERE agent_id = ? AND status IN ('running', 'completed') AND substr(started_at, 1, 10) = ?"
    ).get("nightly:" + model, dateStr) as { n: number };
    return row.n === 0;
  }

Comment on lines +85 to +99
async tick(now: Date = new Date()): Promise<string[]> {
const ran: string[] = [];
const caseIds = this.anchorCaseIds();
for (const model of this.models()) {
if (!this.shouldRun(model, now)) continue;
try {
const result = await this.evalService.runEval(`nightly:${model}`, undefined, model, caseIds);
console.log(`OpenMythos nightly: ${model} → ${result.overallScore.toFixed(3)} (${result.completedCases}/${result.totalCases} cases)`);
ran.push(model);
} catch (error) {
console.error(`OpenMythos nightly: ${model} failed —`, error instanceof Error ? error.message : error);
}
}
return ran;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

If a scheduler tick takes longer than an hour (e.g., if multiple models are run sequentially or Ollama is slow/hanging), the next hourly interval will fire and start a concurrent tick. Although shouldRun checks for 'running' status, models that haven't started yet in the first tick could be picked up by the second tick, leading to concurrent model execution and saturating the Ollama host.

Additionally, if anchorCaseIds() throws an error (e.g., due to a missing or malformed anchors file), the promise returned by tick() will reject. Since the interval timer does not catch this rejection, it will result in an unhandled promise rejection, which can crash the server.

We should introduce a concurrency guard (private isRunning = false; at the class level) and wrap the entire tick() execution in a try-catch-finally block to handle setup errors and ensure the guard is always reset.

Please add private isRunning = false; to the class properties, and update tick() as follows:

  async tick(now: Date = new Date()): Promise<string[]> {
    if (this.isRunning) {
      console.warn("OpenMythos nightly: previous tick is still running, skipping this tick");
      return [];
    }
    this.isRunning = true;
    const ran: string[] = [];
    try {
      const caseIds = this.anchorCaseIds();
      for (const model of this.models()) {
        if (!this.shouldRun(model, now)) continue;
        try {
          const result = await this.evalService.runEval("nightly:" + model, undefined, model, caseIds);
          console.log("OpenMythos nightly: " + model + " → " + result.overallScore.toFixed(3) + " (" + result.completedCases + "/" + result.totalCases + " cases)");
          ran.push(model);
        } catch (error) {
          console.error("OpenMythos nightly: " + model + " failed —", error instanceof Error ? error.message : error);
        }
      }
    } catch (error) {
      console.error("OpenMythos nightly scheduler tick failed:", error instanceof Error ? error.message : error);
    } finally {
      this.isRunning = false;
    }
    return ran;
  }

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 6e8fbb6937

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

console.warn('OpenMythos nightly: enabled but OPENMYTHOS_NIGHTLY_MODELS is empty — not arming');
return false;
}
this.timer = setInterval(() => void this.tick(), HOUR_MS);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Prevent overlapping nightly ticks

When a nightly pass runs longer than an hour, this interval can enter tick() again while the prior pass is still awaiting runEval. The per-model SQL check only marks the model currently being evaluated as running, so the second pass can start a later due model concurrently (for example, model-a still running while model-b starts), defeating the sequential model execution guarantee and potentially doubling the Ollama worker-pool load. Please add a service-level in-flight guard or schedule the next pass only after the previous one settles.

Useful? React with 👍 / 👎.

const row = this.db.prepare(`
SELECT COUNT(*) AS n FROM openmythos_eval_runs
WHERE agent_id = ? AND status IN ('running', 'completed')
AND substr(started_at, 1, 10) = date('now')

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Use one timezone for the nightly day boundary

When the server timezone is not UTC, this mixes the local-hour gate (now.getHours()) with SQLite's UTC date('now'). After UTC midnight but before the next local target hour, the UTC date has advanced with no row while the local hour may still be past yesterday's target, so the hourly tick starts the next day's run immediately instead of at OPENMYTHOS_NIGHTLY_HOUR (for example, afternoon/evening on US timezones). Use the same clock/timezone for both the due-hour check and the dedupe day key.

Useful? React with 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant