Scheduled evals — run a scorecard on a cron schedule (regression monitoring)
Status: slices 1 + 2 + 3 SHIPPED. Driver decision locked with the user: Temporal Schedules (native). Slice 1 (
f57a55bbackend +5960009web) = Schedule SSOT + CRUD API/MCP/web. Slice 2 (a0ed30d) = firing —ScheduleServicedriver-sync seam +fire()+ internal routes +scheduledScorecardWorkflow(poll-to-terminal)
- schedule activities (HTTP bridge) +
TemporalScheduleDriver. Slice 3 = completion visibility —finalize()(the workflow calls it post-terminal) records the fire's finallastStatus; the creator's completion notification is emitted by the scorecard's ownonComplete, schedule-branded (schedule_{completed,failed}, title "Scheduled run …") whenorigin.source === "schedule"— so cron fires and manual "run now" both notify exactly once. (This superseded the original per-regressionnotifyRegressionalert; regression-over-time still lives in the schedule detail page'strendSeries/diffScorecardsanalytics.) Web cron preset picker + last-fire display. creator-left auto-disable also shipped (member leave/remove → disable that creator's schedules + Temporal pause).Live Temporal e2e — VERIFIED (2026-07-03). The full cron→fire→run→grade loop was run against a real Temporal dev server (
docker run temporalio/temporal server start-dev) with the codex+pinch harness (the bundle harness that runs PinchBench). Two live scripts:scripts/live/scheduled-pinch-temporal.mjs(in-memory / no-auth) andscripts/live/scheduled-pinch-acme-temporal.mjs(multi-tenant: real Postgres + Keycloak OIDC, schedule created as useralicein workspaceacme). Observed:POST /schedules→TemporalScheduleDriver.ensurecreatedeverdict-sched-<id>(temporal schedule describe: workflow=scheduledScorecardWorkflow, tq=everdict-eval, cron* * * * *, overlap=Skip, args carry{scheduleId, tenant}); Temporal fired exactly at the top-of-minute (lastFiredAt=…:00Z) → workflow → internalfire→ScorecardService.submit→ self-hosted runner rancodex exec→tests_passPASS → leaderboard row; schedule record stampedlastFiredAt/lastScorecardId, and the workflow's poll-to-terminalfinalizerecorded the terminallastStatus. Remaining: connection-revoked auto-disable (indirect: schedule→dataset→case.connectionId).Driver location (deviation from the table below):
TemporalScheduleDriverlives inapps/api(temporal-schedule-driver.ts), not@everdict/orchestrator— it needs only@temporalio/client, and importing the orchestrator index into the API would pull in@temporalio/worker's native bindings. The workflow + activities stay in@everdict/orchestrator(they run in the worker). Driver is env-gated:EVERDICT_TEMPORAL_ADDRESSset on the API ⇒ schedules sync to Temporal and fire; unset ⇒ CRUD-only (dev). The worker bridges back to the API viaEVERDICT_API_URL+EVERDICT_INTERNAL_TOKEN.Like self-hosted-runner and judge-placement-locality: strict generalization, additive. The unit of work —
ScorecardService.submit(RunScorecardInput)— is reused verbatim. A schedule is just a storedRunScorecardInput+ a cron spec + a policy. The existingPOST /scorecardspath is untouched; the absence of a schedule changes nothing.
Problem
Teams want "run dataset × harness every night and tell me if it regressed." Today every scorecard is a
manual POST /scorecards; there is no recurring trigger and no automatic baseline-vs-latest comparison over
time.
The payoff is nearly free: trendSeries (regression-over-time) and diffScorecards (baseline↔candidate) plus
onComplete (Mattermost) already exist. A cron trigger that re-submits the same run nightly turns those into
automated regression monitoring with near-zero new analytics code.
Current state — verified
- Trigger is one call —
ScorecardService.submit(RunScorecardInput)(packages/application-control/src/scorecard/scorecard-service.ts) →queuedrecord → async batch via the in-processdispatcher(Scheduler/Router). - Scorecards do NOT go through Temporal today.
scorecardServiceholds aDispatcherand never touches theOrchestrator. (Single runs viaRunServicecan use the Orchestrator; scorecards don't.) - Orchestration is optional —
DirectOrchestrator(in-process, the--orchestrator directdefault) vsTemporalOrchestrator(durable). The worker (everdict worker→runWorker) holds aScheduler+ activities (dispatchCase); it does not hold aScorecardService. - Regression analytics already shipped —
summarizeScorecard/diffScorecards/trendSeries(@everdict/domain);onCompletenotifies (Mattermost). Records are workspace-scoped inScorecardStore. - Auth —
Principal.via ∈ {oidc, api-key, runner};/internal/**routes are guarded byx-internal-token(constant-time, fail-closed) — e.g.POST /internal/tenant-keys. concurrency(just shipped) —RunScorecardInput.concurrencyflows torunSuite; a scheduled run carries it like any other.
Design
The schedule is data; the trigger is reuse
A Schedule = a stored RunScorecardInput + { cron, timezone, overlapPolicy, enabled } + provenance
(createdBy, lastFiredAt, lastStatus). SSOT = a new mutable ScheduleStore (@everdict/db; InMemory +
Pg + numbered migration), workspace-scoped. It is mutable (pause/resume/edit) → a Store, not the immutable
versioned registry. No new execution engine — firing = calling the existing submit.
Three fire modes: batch run · trace evaluation · view report
ScheduleRunTemplate is a discriminated definition (exactly-one-mode refine) with three mutually-exclusive modes:
- Batch (
dataset+harness) — the original: each fire runs dataset×harness →ScorecardService.submit. - Trace evaluation (
pull: { source, correlate?, scope?, windowHours }) — each fire pulls the recent traces of a registered observability source over a rolling window ending at the fire moment and judges them directly (no harness run) →ScorecardService.ingestPull. This is "every day, judge the last 24h of production traces" (cron="0 3 * * *",windowHours=24). - View report (
report: { view, instructions?, compare? }) — each fire runs ONE budgeted headless agent analysis turn over a saved View and pins the emitted markdown report artifact to it (AgentReportRunner→ the agent service's internal route; stampslastArtifactId+lastStatus: reported;notifyReportfans out feed/Mattermost/agent-event). "Every Monday morning, report this view's pass-rate movement". No scorecard is produced — the workflow ends without polling. Seedocs/architecture/analysis-studio.md(V4).
ScheduleService.fire() branches on runTemplate.pull: it computes until=now(), since=now()-windowHours,
enumerates the window via listTraceIds (= TraceSourceService.listTraces({scope, since, until}) → trace ids), and
calls ingestPull({ source:{name, correlate:"id"}, runs:[{caseId,runId}], judges }) — no dataset/harness (the record
carries the TRACE_EVAL_REF sentinel, docs/scorecards.md). An empty window yields an empty (succeeded)
scorecard, so a quiet day is recorded rather than erroring. The Temporal fire→poll→finalize loop is unchanged (it is
agnostic to how the scorecard was produced). Both ingestPull + listTraceIds are injected in composition/schedule.ts
(the latter only when the workspace has a trace-source pool; absent ⇒ a pull-mode fire cleanly 400s). Firer-configured
checks live OUTSIDE fire()'s try so a missing firer (deployment config) does not auto-disable the schedule.
Driver: Temporal Schedules (native) — chosen
Temporal Schedules give timezone, overlap policy, catchup window, pause/resume and backfill natively —
exactly the hard parts of cron. Our DB Schedule is the SSOT for the UI/API; the Temporal Schedule is the
execution mechanism. ScheduleService keeps the two in sync (create/update/pause/delete write the DB and
call the Temporal ScheduleClient); reads/list come from the DB (fast, workspace-scoped). Temporal unreachable
at mutate time ⇒ fail the request (Temporal-native ⇒ Temporal required).
ScheduleClient.create({
spec: { cronExpressions: [cron], timeZone },
policies:{ overlap: overlapPolicy },
action: startWorkflow scheduledScorecardWorkflow(scheduleId, tenant)
})
│ (Temporal fires per cron)
▼
scheduledScorecardWorkflow(scheduleId) [deterministic — NO I/O]
1. id = await submitScheduledScorecard(scheduleId) // activity → API internal route → ScorecardService.submit
2. await pollUntilTerminal(id) // activity getScorecardStatus + workflow.sleep loop
3. await finalizeScheduledScorecard(scheduleId, id) // activity: record final lastStatus (completion notif = scorecard onComplete, schedule-branded)
Why the workflow polls to completion (step 2): submit returns a queued record immediately. If the
workflow fired-and-returned, Temporal would never see the real (minutes-to-hours) run as "still running", so
Skip/BufferOne overlap would be a no-op. Polling to terminal makes the workflow's lifetime track the actual
scorecard, so overlap and timeouts behave as intended.
Bridging "scorecards bypass Temporal" (the key wrinkle)
The worker holds only a Scheduler, not a ScorecardService. So the schedule activity reaches submit via a
new internal route POST /internal/schedules/:id/fire (x-internal-token guard, like
/internal/tenant-keys) that loads the Schedule and calls
ScorecardService.submit({ ...schedule.runInput, tenant: schedule.tenant, submittedBy: schedule.createdBy });
GET /internal/schedules/:id/last-status backs the poll. The worker stays thin and ScorecardService stays the
single owner in the API — no fork, no stores duplicated into the worker. (Alternative — co-host a
ScorecardService in the worker — rejected: it would need every store/registry the API wires.)
The Everdict-specific decisions
- Identity — a fire has no live user token. The schedule stores
createdBy(subject); the run executes as that subject: budget →tenant(workspace), private-repo case tokens resolve against the workspace GitHub App installation (installationTokenFor(tenant, gitUrl)) — identical to a manual submit. If the creator leaves the workspace, fires carrying their identity break → policy: auto-disable the schedule and surface the reason (lastStatus/last-fire error on the record). A newvia:"schedule"is not needed — the internal route is token-guarded and passestenant+submittedByexplicitly. - Self-hosted runtime —
runtime=self:<id>requires the runner be online at fire time, else jobs park thenqueueTimeoutMs-reject. Warn in the UI; treat a no-runner fire as a failed run with a clear reason (don't retry forever). - Overlap — default Skip (don't pile up long evals); expose
BufferOne/AllowAll(TemporalScheduleOverlapPolicy). - Version —
harness.version=latest(default) ⇒ each fire re-resolves latest (the point of regression monitoring); pin for a fixed-version cadence. concurrency— carried inrunInput; scheduled runs are case-parallel like manual ones.
Surface (BFF↔MCP parity + roles)
- HTTP —
POST /schedules{name, cron, timezone?, dataset, harness, judges?, runtime?, concurrency?, overlapPolicy?, enabled?}→ record;GET /schedules,GET /schedules/:id,PATCH /schedules/:id(edit / pause / resume),DELETE /schedules/:id,GET /schedules/:id/runs(scorecards this schedule produced, tagged{scheduleId, firedAt}). - MCP —
create/list/get/update/delete_schedule(sameScheduleServicecore). - Next-fire (authoritative + fallback) —
list/getenrich each enabled schedule withnextFireTimes(ISO[]) via the driver's optionaldescribeMany(ids)(TemporalScheduleDriver→handle.describe().info.nextActionTimes, one connection for the whole list; best-effort — failure/absence just omits the field). Non-persisted, attached at read time; internal reads (update/remove/fire/finalize) use a privategetRecordthat skips the Temporal round-trip. When Temporal is not deployed (no driver) the web falls back to a dependency-free cron computation (apps/web/src/shared/lib/cron.ts, Intl-based, IANA-tz/DST safe) and marks those rows (estimated). - Internal —
POST /internal/schedules/:id/fire,GET /internal/schedules/:id/last-status(x-internal-token). - Roles — new
schedules:read(viewer+) /schedules:write(member+) in the authz matrix; gate mutating routes + workspace-scope; another workspace's schedule reads 404. Content edit (name/cron/timezone/ overlap/runTemplate) is further gated to creator OR workspace admin — enforced inScheduleService.update(route/MCP injectactor={subject,isAdmin}; a patch touching onlyenabled, i.e. pause/resume, stays member+). Web mirrors this (edit button + edit page gated to creator/admin; the control plane is authoritative). - Web — a Schedules page with a view switcher (list / by owner / calendar,
?view=deep-link) over shared owner·status·runtime filters: each row shows the owner (members-joined avatar), runtime chip, benchmark→harness, a human-readable cadence (describeCron), and the next fire (authoritative or (estimated)); an Upcoming runs timeline (next 7 days) merges upcoming fires across the visible schedules; the calendar marks each day's active schedules (firesOnDate, one chip per schedule/day so dense crons don't smear). Plus an enable/pause toggle, and a "Schedule with these settings" button on the scorecard run form (reuse the form values →POST /schedules). Cron picker (presets daily/weekly/hourly + raw expression).
Reuse vs new
| Piece | Status |
|---|---|
ScorecardService.submit / RunScorecardInput / trendSeries / diffScorecards / onComplete | reused verbatim |
Temporal client/worker, /internal token guard, ScorecardStore, registries | reused |
ScheduleStore (@everdict/db) + migration | new |
ScheduleService + ScheduleDriver (TemporalScheduleDriver) | new |
scheduledScorecardWorkflow + activities (submit / status / notify) | new (@everdict/orchestrator) |
/schedules routes + MCP tools + schedules:* authz | new (apps/api) |
| Schedules web page + "Schedule" button | new (apps/web) |
Slices (pnpm gates green at each)
- ✅ Schedule SSOT —
ScheduleStore(InMemory+Pg+ mig 0027), Zod schema + 5-field cron validation;ScheduleServiceCRUD;/schedulesroutes + MCP parity +schedules:*roles; web list/create. (Testable with no Temporal.) - ✅ Temporal driver —
ScheduleDriverseam +TemporalScheduleDriver(apps/api;ScheduleClientcreate-or-recreate + delete, env-gated),ScheduleServicesyncs DB↔Temporal on create/update/remove (+ DB rollback ifensurefails) +fire()(submit as creator, recordlast*);scheduledScorecardWorkflow(poll-to-terminal) +fireScheduledScorecard/scheduledScorecardStatusactivities (HTTP bridge) + worker wiring;POST /internal/schedules/:id/fire+GET /internal/schedules/scorecard-status/:id(x-internal-token). Fires a real scorecard on cron. (fire + driver-sync unit-tested with fakes; Temporal glue is live-verified.) - ✅ Completion visibility + UX —
finalize()(workflow calls it after poll-to-terminal, viaPOST /internal/schedules/:id/finalize) records the fire's finallastStatus; the creator's completion notification is schedule-branded by the scorecard's ownonComplete(schedule_{completed,failed}whenorigin.source === "schedule"— cron fires + manual "run now" both notify once), superseding the original per-regression alert; web cron preset picker (hourly/daily/weekday/weekly chips → cron string) + last-fire time on the list. (finalize lastStatus + schedule-branded notify unit-tested.) Creator-left auto-disable shipped:ScheduleService.disableByCreator(tenant, createdBy)(disable + Temporal pause + reason inlastStatus), wired viaMembershipService.onMemberRemoved(single core → HTTP + MCP leave/remove both covered). Follow-up: connection-revoked auto-disable (indirect dependency schedule→dataset→case.connectionId — needs dataset resolution; deferred).
Decisions / non-goals
- Temporal required for firing (chosen). The Direct/in-memory dev path won't fire schedules (CRUD still works; they just don't run). A dev-only ticker is explicitly out of scope (revisit only if dev demand appears).
- No new durable eval execution. Schedules trigger the existing in-process batch; the workflow only wraps
fire + poll for cron semantics. Making each case a durable workflow is a separate concern (
evalCaseWorkflow). - One run template per schedule. A matrix (N datasets × M harnesses) = N×M schedules, not a new combinatorial type.
- Backfill is available via Temporal but not surfaced in v1 (manual "run now" covers the common need).
See also
scorecards.md · orchestration.md · suites.md
(trend/diff) · workspace-scoped-integrations.md (private-repo token lifecycle) ·
self-hosted-runner.md (runtime online-ness) · rules orchestrator / api-layer / auth.
Auto-disable on a deterministic fire failure
A CONFIG-class submit failure at fire time (deleted dataset/harness, revoked credentials/authz, invalid
template, exhausted budget — classifyFailure class config) is deterministic: the same fire fails the same
way on every tick, so firing on is pure noise. The schedule is AUTO-DISABLED with a visible reason
(lastStatus: "Auto-disabled: <code> — <message>") and the Temporal schedule is paused (driver.ensure),
the same pattern as creator-left auto-disable. Transient (infra) failures rethrow — the firing workflow's
activity retry owns those, and the schedule stays enabled.