From bff47f9e4d7dad905c6e0249c09bd3d27f4cd4dc Mon Sep 17 00:00:00 2001 From: ClaydeCode Date: Thu, 15 Oct 2026 13:51:21 +0000 Subject: [PATCH 1/4] scheduler: add optional model frontmatter field to scheduled tasks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scheduled tasks had no way to request a non-default model — every run used whatever the CLI defaults to. Parse an optional `model:` key the same way `tz`/`timeout` are already validated, rejecting unrecognized ids at parse time (so a typo surfaces in the skipped-file warning, not silently at run time). Co-Authored-By: Claude Sonnet 5 --- src/clayde/scheduler/tasks.py | 25 ++++++++++++++++++++++++- tests/scheduler/test_tasks.py | 20 ++++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/src/clayde/scheduler/tasks.py b/src/clayde/scheduler/tasks.py index cfb805b..db0f81c 100644 --- a/src/clayde/scheduler/tasks.py +++ b/src/clayde/scheduler/tasks.py @@ -15,6 +15,18 @@ MAX_TIMEOUT_S = 14400 # 4 hours +# Model ids a scheduled task may request via the `model:` frontmatter key. +# There is no live registry to validate against, so this list goes stale as +# new model ids ship — update it by hand when they do. +KNOWN_MODELS = { + "claude-fable-5-1", + "claude-opus-5-5", + "claude-sonnet-5-5", + "claude-haiku-5-5", + "claude-opus-4-6", + "claude-sonnet-4-6", +} + _TIMEOUT_RE = re.compile(r"^\s*(\d+)\s*([hms]?)\s*$") _TIMEOUT_UNIT_SECONDS = {"": 1, "s": 1, "m": 60, "h": 3600} @@ -29,6 +41,7 @@ class ScheduledTask: timeout_s: int tz: ZoneInfo enabled: bool + model: str | None def _split_frontmatter(text: str) -> tuple[dict, str]: @@ -67,6 +80,15 @@ def _parse_timeout(value, default_s: int) -> int: return max(1, requested) +def _parse_model(value) -> str | None: + if value is None: + return None + model = str(value) + if model not in KNOWN_MODELS: + raise ValueError(f"bad model {value!r}") + return model + + def parse_task_file(path: Path, default_tz: str, default_timeout_s: int) -> ScheduledTask: data, body = _split_frontmatter(path.read_text()) @@ -94,10 +116,11 @@ def parse_task_file(path: Path, default_tz: str, default_timeout_s: int) -> Sche enabled = bool(data.get("enabled", True)) title = data.get("title") timeout_s = _parse_timeout(data.get("timeout"), default_timeout_s) + model = _parse_model(data.get("model")) return ScheduledTask( path=path, prompt=body, cron=cron, at=at, tz=tz, enabled=enabled, title=str(title) if title is not None else None, - timeout_s=timeout_s, + timeout_s=timeout_s, model=model, ) diff --git a/tests/scheduler/test_tasks.py b/tests/scheduler/test_tasks.py index 1402ae4..f9deadb 100644 --- a/tests/scheduler/test_tasks.py +++ b/tests/scheduler/test_tasks.py @@ -95,3 +95,23 @@ def test_timeout_malformed_skipped_by_discover(tmp_path): _w(tmp_path / "bad.md", 'cron: "0 8 * * *"\ntimeout: soon') tasks = discover_tasks(tmp_path, "Europe/Berlin", DEFAULT_TIMEOUT_S) assert tasks == [] + +def test_model_absent_is_none(tmp_path): + f = tmp_path / "k.md"; _w(f, 'cron: "0 8 * * *"') + t = parse_task_file(f, "Europe/Berlin", DEFAULT_TIMEOUT_S) + assert t.model is None + +def test_model_known_id_accepted(tmp_path): + f = tmp_path / "k.md"; _w(f, 'cron: "0 8 * * *"\nmodel: claude-fable-5-1') + t = parse_task_file(f, "Europe/Berlin", DEFAULT_TIMEOUT_S) + assert t.model == "claude-fable-5-1" + +def test_model_unrecognized_rejected(tmp_path): + f = tmp_path / "k.md"; _w(f, 'cron: "0 8 * * *"\nmodel: claude-made-up-9000') + with pytest.raises(ValueError): + parse_task_file(f, "Europe/Berlin", DEFAULT_TIMEOUT_S) + +def test_model_malformed_skipped_by_discover(tmp_path): + _w(tmp_path / "bad.md", 'cron: "0 8 * * *"\nmodel: claude-made-up-9000') + tasks = discover_tasks(tmp_path, "Europe/Berlin", DEFAULT_TIMEOUT_S) + assert tasks == [] From 350154f4a0decc891614f0755959331548e7221f Mon Sep 17 00:00:00 2001 From: ClaydeCode Date: Thu, 15 Oct 2026 13:51:25 +0000 Subject: [PATCH 2/4] scheduler: carry model selection from task to Job Plumb ScheduledTask.model through to the Job the scheduler enqueues, so the worker has it available at invocation time. Jobs with no model set keep the existing default of None. Co-Authored-By: Claude Sonnet 5 --- src/clayde/scheduler/loop.py | 2 +- src/clayde/service/queue.py | 1 + tests/scheduler/test_loop.py | 16 ++++++++++++++++ 3 files changed, 18 insertions(+), 1 deletion(-) diff --git a/src/clayde/scheduler/loop.py b/src/clayde/scheduler/loop.py index b137396..b782df4 100644 --- a/src/clayde/scheduler/loop.py +++ b/src/clayde/scheduler/loop.py @@ -78,7 +78,7 @@ def run_tick(queue: JobQueue, *, tasks_dir: Path, state_path: Path, text = f"{note}\n{task.prompt}" if note else task.prompt job = Job(id=str(uuid.uuid4()), text=text, timestamp=int(now.timestamp()), origin="scheduler", - timeout_s=task.timeout_s) + timeout_s=task.timeout_s, model=task.model) try: queue.enqueue(job) except QueueFullError: diff --git a/src/clayde/service/queue.py b/src/clayde/service/queue.py index 43faf5b..a4b0216 100644 --- a/src/clayde/service/queue.py +++ b/src/clayde/service/queue.py @@ -17,6 +17,7 @@ class Job: timestamp: int origin: str = "pebble" timeout_s: int = 300 + model: str | None = None class JobQueue: diff --git a/tests/scheduler/test_loop.py b/tests/scheduler/test_loop.py index e2a549f..be5587f 100644 --- a/tests/scheduler/test_loop.py +++ b/tests/scheduler/test_loop.py @@ -73,6 +73,22 @@ async def test_job_gets_default_timeout_when_absent(tmp_path): jobs = await _drain(q) assert jobs[0].timeout_s == 300 +async def test_job_gets_task_model(tmp_path): + _w(tmp_path, "call.md", 'at: 2026-09-21T08:00\nmodel: claude-fable-5-1') + q = JobQueue(maxsize=10) + now = datetime(2026, 9, 21, 8, 1, tzinfo=ZoneInfo(TZ)) + run_tick(q, tasks_dir=tmp_path, state_path=tmp_path / "s.json", default_tz=TZ, now=now, default_timeout_s=300) + jobs = await _drain(q) + assert jobs[0].model == "claude-fable-5-1" + +async def test_job_gets_no_model_when_absent(tmp_path): + _w(tmp_path, "call.md", "at: 2026-09-21T08:00") + q = JobQueue(maxsize=10) + now = datetime(2026, 9, 21, 8, 1, tzinfo=ZoneInfo(TZ)) + run_tick(q, tasks_dir=tmp_path, state_path=tmp_path / "s.json", default_tz=TZ, now=now, default_timeout_s=300) + jobs = await _drain(q) + assert jobs[0].model is None + async def test_failing_task_does_not_lose_earlier_dedup(tmp_path, monkeypatch): # "a-cron.md" sorts before "b-oneoff.md" so the recurring task fires # first in the same tick as the one-off task that then blows up. From 3237f87f86e235323345067cb6f8a4df2d1fbb71 Mon Sep 17 00:00:00 2001 From: ClaydeCode Date: Thu, 15 Oct 2026 13:51:31 +0000 Subject: [PATCH 3/4] service: pass model through to the Claude CLI invocation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit invoke_claude_job() now accepts an optional model kwarg and, when set, appends ["--model", model] to the argv list passed to create_subprocess_exec — still a list element, never shell-interpolated, so a task-file-controlled value can't reach a shell. When absent, the command is byte-for-byte what it was before (no --model flag at all). process_job() wires job.model through from the queue. Co-Authored-By: Claude Sonnet 5 --- src/clayde/service/runner.py | 3 +++ src/clayde/service/worker.py | 1 + tests/service/test_runner.py | 21 +++++++++++++++++++++ 3 files changed, 25 insertions(+) diff --git a/src/clayde/service/runner.py b/src/clayde/service/runner.py index 24d9529..29bc7cb 100644 --- a/src/clayde/service/runner.py +++ b/src/clayde/service/runner.py @@ -25,6 +25,7 @@ async def invoke_claude_job( *, system_prompt: str, user_text: str, cwd: str, timeout_s: int, + model: str | None = None, ) -> str: """Run the Claude CLI for a single Pebble request and return its result text. @@ -42,6 +43,8 @@ async def invoke_claude_job( "--permission-mode", "auto", "--permission-prompts", "none", ] + if model: + cmd += ["--model", model] log.info("Invoking Claude CLI (cwd=%s, timeout=%ds)", cwd, timeout_s) proc = await asyncio.create_subprocess_exec( *cmd, diff --git a/src/clayde/service/worker.py b/src/clayde/service/worker.py index df145c4..6397824 100644 --- a/src/clayde/service/worker.py +++ b/src/clayde/service/worker.py @@ -68,6 +68,7 @@ async def process_job(job: Job, *, kb_path: str) -> None: user_text=user_text, cwd=kb_path, timeout_s=job.timeout_s, + model=job.model, ) payload = extract_notification_payload(output) if payload.title == _FALLBACK_TITLE: diff --git a/tests/service/test_runner.py b/tests/service/test_runner.py index 15f77e1..5046a53 100644 --- a/tests/service/test_runner.py +++ b/tests/service/test_runner.py @@ -157,6 +157,27 @@ async def test_runner_returns_text_on_zero_exit(fake_subproc, tmp_path): assert out == "ok" +async def test_runner_adds_model_flag_when_set(fake_subproc, tmp_path): + fake_subproc["proc"] = _FakeProc(json.dumps({"result": "ok"}).encode()) + await runner.invoke_claude_job( + system_prompt="s", user_text="t", cwd=str(tmp_path), timeout_s=10, + model="claude-fable-5-1", + ) + cmd = fake_subproc["args"] + assert "--model" in cmd + idx = cmd.index("--model") + assert cmd[idx + 1] == "claude-fable-5-1" + + +async def test_runner_omits_model_flag_when_absent(fake_subproc, tmp_path): + fake_subproc["proc"] = _FakeProc(json.dumps({"result": "ok"}).encode()) + await runner.invoke_claude_job( + system_prompt="s", user_text="t", cwd=str(tmp_path), timeout_s=10, + ) + cmd = fake_subproc["args"] + assert "--model" not in cmd + + async def test_invoke_uses_auto_permission_mode(monkeypatch, tmp_path): captured = {} From 761d77997a5484c0dd4cfb85967612a1ef69164e Mon Sep 17 00:00:00 2001 From: ClaydeCode Date: Thu, 15 Oct 2026 13:56:17 +0000 Subject: [PATCH 4/4] tests: cover worker forwarding job.model to invoke_claude_job MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The adversarial review of this change flagged that process_job's new model=job.model kwarg had no test exercising it directly — the scheduler-to-Job link and the runner's --model-flag link were each covered, but the middle hop (worker passing job.model through) was not, so a future refactor could silently drop it. Add two tests covering both the set and absent cases. Co-Authored-By: Claude Sonnet 5 --- tests/service/test_worker.py | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/tests/service/test_worker.py b/tests/service/test_worker.py index d0636ff..d104ae6 100644 --- a/tests/service/test_worker.py +++ b/tests/service/test_worker.py @@ -183,6 +183,33 @@ async def fake_invoke(**kwargs): assert captured_ntfy[0].title == "Pebble: timeout" +@pytest.mark.asyncio +async def test_process_job_forwards_model_to_invoke(monkeypatch, captured_ntfy, fake_skills): + captured = {} + + async def fake_invoke(**kwargs): + captured.update(kwargs) + return '```json\n{"title": "saved", "body": "ok", "success": true}\n```' + + monkeypatch.setattr(worker, "invoke_claude_job", fake_invoke) + job = Job(id="job-1", text="hello", timestamp=1000, timeout_s=10, model="claude-fable-5-1") + await worker.process_job(job, kb_path="/tmp") + assert captured["model"] == "claude-fable-5-1" + + +@pytest.mark.asyncio +async def test_process_job_forwards_none_model_when_absent(monkeypatch, captured_ntfy, fake_skills): + captured = {} + + async def fake_invoke(**kwargs): + captured.update(kwargs) + return '```json\n{"title": "saved", "body": "ok", "success": true}\n```' + + monkeypatch.setattr(worker, "invoke_claude_job", fake_invoke) + await worker.process_job(_job(), kb_path="/tmp") + assert captured["model"] is None + + @pytest.mark.asyncio async def test_pebble_success_still_notifies(monkeypatch, captured_ntfy, fake_skills): async def fake_invoke(**kwargs):