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/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/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/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/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. 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 == [] 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 = {} 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):