Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/clayde/scheduler/loop.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
25 changes: 24 additions & 1 deletion src/clayde/scheduler/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}

Expand All @@ -29,6 +41,7 @@ class ScheduledTask:
timeout_s: int
tz: ZoneInfo
enabled: bool
model: str | None


def _split_frontmatter(text: str) -> tuple[dict, str]:
Expand Down Expand Up @@ -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())

Expand Down Expand Up @@ -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,
)


Expand Down
1 change: 1 addition & 0 deletions src/clayde/service/queue.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ class Job:
timestamp: int
origin: str = "pebble"
timeout_s: int = 300
model: str | None = None


class JobQueue:
Expand Down
3 changes: 3 additions & 0 deletions src/clayde/service/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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,
Expand Down
1 change: 1 addition & 0 deletions src/clayde/service/worker.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
16 changes: 16 additions & 0 deletions tests/scheduler/test_loop.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
20 changes: 20 additions & 0 deletions tests/scheduler/test_tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 == []
21 changes: 21 additions & 0 deletions tests/service/test_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {}

Expand Down
27 changes: 27 additions & 0 deletions tests/service/test_worker.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
Loading