From 44632ea771ff9d3da7ab51e7ea2520aa9814b10c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Jul 2026 18:54:31 +0000 Subject: [PATCH] Extend the model picker to schedules schedules.model_id (migration 0013) pins every fired run of a recurring spawn to a registered model backend, exactly like a hand-spawned agent: the worker copies it into each firing's spawn payload, the launched agent records the pin, and resumes stay on the same backend. The Schedules form gets the same Model dropdown as the spawn form (Claude subscription by default), with a badge in the schedules table. Create/update routes fail fast on a missing or disabled backend so a stale selection bounces immediately instead of every firing failing asynchronously in Activity; a backend deleted later still fails each firing visibly rather than silently falling back to the subscription. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01DzDofD7gP63WpeLG8vEdZu --- README.md | 4 +- docs/local-models.md | 3 +- .../components/sections/SchedulesSection.tsx | 30 ++++++++++- frontend/components/store.tsx | 8 ++- frontend/lib/api.ts | 2 + src/handler/api/routes/schedules.py | 18 +++++++ src/handler/api/schemas.py | 12 ++++- src/handler/api/static/404.html | 2 +- src/handler/api/static/404/index.html | 2 +- .../_buildManifest.js | 0 .../_ssgManifest.js | 0 .../static/chunks/171-19de960b70cee25e.js | 1 + .../static/chunks/171-7eacb8f2c45d0186.js | 1 - .../app/schedules/page-4398c9c829e1b9fd.js | 1 + .../app/schedules/page-db675df274be2677.js | 1 - src/handler/api/static/activity/index.html | 2 +- src/handler/api/static/activity/index.txt | 6 +-- src/handler/api/static/agents/index.html | 2 +- src/handler/api/static/agents/index.txt | 6 +-- src/handler/api/static/approvals/index.html | 2 +- src/handler/api/static/approvals/index.txt | 6 +-- src/handler/api/static/claude/index.html | 2 +- src/handler/api/static/claude/index.txt | 6 +-- src/handler/api/static/index.html | 2 +- src/handler/api/static/index.txt | 6 +-- src/handler/api/static/login/index.html | 2 +- src/handler/api/static/login/index.txt | 4 +- .../api/static/repositories/index.html | 2 +- src/handler/api/static/repositories/index.txt | 6 +-- src/handler/api/static/schedules/index.html | 2 +- src/handler/api/static/schedules/index.txt | 6 +-- src/handler/api/static/servers/index.html | 2 +- src/handler/api/static/servers/index.txt | 6 +-- src/handler/api/static/shared/index.html | 2 +- src/handler/api/static/shared/index.txt | 6 +-- src/handler/control/worker.py | 2 +- src/handler/db/repository.py | 3 ++ src/handler/db/tables.py | 4 ++ .../versions/0013_schedule_model.py | 34 ++++++++++++ tests/test_claude_models.py | 53 +++++++++++++++++++ 40 files changed, 208 insertions(+), 51 deletions(-) rename src/handler/api/static/_next/static/{KmcUQ9fxOry3HfFR_U9HV => 4cEQAcmEL0RnY9yw8HjTQ}/_buildManifest.js (100%) rename src/handler/api/static/_next/static/{KmcUQ9fxOry3HfFR_U9HV => 4cEQAcmEL0RnY9yw8HjTQ}/_ssgManifest.js (100%) create mode 100644 src/handler/api/static/_next/static/chunks/171-19de960b70cee25e.js delete mode 100644 src/handler/api/static/_next/static/chunks/171-7eacb8f2c45d0186.js create mode 100644 src/handler/api/static/_next/static/chunks/app/schedules/page-4398c9c829e1b9fd.js delete mode 100644 src/handler/api/static/_next/static/chunks/app/schedules/page-db675df274be2677.js create mode 100644 src/handler/migrations/versions/0013_schedule_model.py diff --git a/README.md b/README.md index 1eaf364..ace378a 100644 --- a/README.md +++ b/README.md @@ -236,7 +236,9 @@ What the dashboard can now do (all state-changing actions require `ADMIN_TOKEN`) enqueues a `sync` command so the worker clones it. Manual mode (existing `root_dir`) still works; every project with a remote gets a **Pull now** button, and spawn always pulls first. -- **Schedules** — recurring agent spawns: a name prefix, a prompt, and an interval. The +- **Schedules** — recurring agent spawns: a name prefix, a prompt, an interval, and + optionally a **model backend** (the same dropdown the spawn form has — every fired run + spawns on it). The worker fires each due schedule as a normal queued `spawn` with a timestamped agent name (`nightly-20260710-090000`), so runs are fresh, stateless agents and show up in Activity. The canonical prompt keeps its state in the repo: *"Read @notes.md, continue diff --git a/docs/local-models.md b/docs/local-models.md index 3ef07fe..7afc7db 100644 --- a/docs/local-models.md +++ b/docs/local-models.md @@ -14,7 +14,8 @@ thing a **model backend** changes is the environment of that one agent's process | `CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC` | `1` (skip sidecar calls a local endpoint won't serve; override via the row's env map) | Register backends on the dashboard's **Claude → Models** tab (or `POST /claude/models`), -then pick one from the **Model** dropdown when spawning an agent. No selection = the +then pick one from the **Model** dropdown when spawning an agent — or on a **Schedule**, +so every fired run spawns on that backend. No selection = the worker's logged-in Claude subscription, exactly as before. The agent is *pinned* to its backend: resumes come back up on the same one, and deleting a backend makes resumes of its agents fail loudly rather than silently falling back to the subscription. diff --git a/frontend/components/sections/SchedulesSection.tsx b/frontend/components/sections/SchedulesSection.tsx index 18724ed..4f06cad 100644 --- a/frontend/components/sections/SchedulesSection.tsx +++ b/frontend/components/sections/SchedulesSection.tsx @@ -36,7 +36,7 @@ function intervalLabel(seconds: number): string { return `every ${seconds}s`; } -const emptyForm = { name_prefix: "", task: "", interval: "3600", role: "" }; +const emptyForm = { name_prefix: "", task: "", interval: "3600", role: "", model_id: "" }; export function SchedulesSection() { const s = useDashboard(); @@ -46,6 +46,21 @@ export function SchedulesSection() { () => s.projects.map((p) => ({ value: p.id, label: p.id })), [s.projects], ); + /* Same choice the spawn form offers: Claude subscription by default, plus every + * enabled backend from the Claude page's Models tab. Every fired run spawns on it. */ + const modelOpts = useMemo( + () => [ + { value: "", label: "Claude (subscription)" }, + ...s.claudeModels + .filter((m) => m.enabled) + .map((m) => ({ value: String(m.id), label: `${m.name} (${m.model})` })), + ], + [s.claudeModels], + ); + const modelName = useMemo(() => { + const byId = new Map(s.claudeModels.map((m) => [m.id, m.name])); + return (id: number | null | undefined) => (id == null ? null : byId.get(id) ?? `#${id}`); + }, [s.claudeModels]); const create = async () => { const ok = await s.createSchedule(s.selectedProjectId, { @@ -53,6 +68,7 @@ export function SchedulesSection() { task: form.task, interval_seconds: Number(form.interval), role: form.role, + model_id: form.model_id, }); if (ok) setForm(emptyForm); }; @@ -107,6 +123,12 @@ export function SchedulesSection() { onChange={(v) => setForm({ ...form, role: v })} options={ROLE_OPTS} /> +