feat(cron): add cron tool surface with stub execution and global json store #36
No reviewers
Labels
No labels
bug
commercial
documentation
duplicate
enhancement
feature
good first issue
help wanted
invalid
question
wontfix
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
jasoncouture/llama-shears!36
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "feat/cron-tool-stub"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
Five MCP tools —
cron_schedule/cron_list/cron_cancel/cron_edit/cron_trigger— let an agent register, manage, and manually fire its own cron jobs. Jobs persist to a single globalcron.jsonunderPathKind.Data. The execution path is intentionally a stub today: when a job'sNextFireAtelapses (orcron_triggeris called), the scheduler logs the would-have-been agent input and recomputesNextFireAt. The single site to upgrade when fires graduate from stub to real isCronScheduler.FireSingleAsync.Closes the Cron tool item under TASKS.md → "Agent orchestration & context".
What's in here
src/LlamaShears.Core/Cron/CronJob(record) +CronJobEdit(record) — domain types.ICronStore+JsonCronStore— single-file persistence.SemaphoreSlimserialises mutations; saves are atomic (write tocron.json.tmp, thenFile.Move(..., overwrite: true)). Corrupt JSON on load logs and starts empty rather than refusing to boot.ICronScheduler+CronScheduler— Cronos expression parsing, agent scoping (every public mutation refuses cross-agent reads/writes), and the stub fire path.CronExecutor : BackgroundService— wakes onCronOptions.TickInterval(default 30s) and asks the scheduler to fire any due jobs.CronOptions—FileName(defaultcron.json),TickInterval(default 30s).CronServiceCollectionExtensions.AddCron()— wires store + scheduler + executor; called fromAddCore.src/LlamaShears.Api/Tools/ModelContextProtocol/Cron/Five
[McpServerToolType]classes, one tool method each, mirroring the existing memory/filesystem tool layout:cron_schedule(name, cronExpression, prompt)cron_list()cron_cancel(id)cron_edit(id, name?, cronExpression?, prompt?, enabled?)cron_trigger(id)— manual fire via the sameFireSingleAsyncpath the executor usesAll tools resolve the calling agent through
IAgentWorkspaceLocator(same pattern as the memory tools). Refused: missing agent on the request, malformed GUID, unparseable cron expression.Storage
Anchored on
IShearsPaths.GetPath(PathKind.Data, ensureExists: true). Single global file, by spec — there is one cron store regardless of how many agents are configured. Agent scoping is enforced at the scheduler boundary.Cron expression library
Cronos 0.10.0, MIT-licensed. Default 5-field form (minute, hour, day-of-month, month, day-of-week), evaluated in UTC.
Tests
13 new TUnit cases under
tests/LlamaShears.UnitTests/Cron/:JsonCronStoreTests— fresh-empty, upsert round-trip across instances, upsert-replaces-by-id, remove idempotence, garbage JSON loads empty.CronSchedulerTests— schedule computes next fire, schedule rejects unparseable expression, list is agent-scoped, cancel refuses other-agent jobs, edit patches only provided fields, edit recomputes next fire when expression changes, trigger updates last/next, fire-due skips disabled and not-yet-due jobs.FakeTimeProvider(Microsoft.Extensions.TimeProvider.Testing) drives the time-sensitive cases;JsonCronStoreis exercised against a real tempdir per test for round-trip realism.Test plan
dotnet test --solution LlamaShears.slnx— all 382 passing (was 369 before this PR).dotnet-test-on-source-changepre-commit check passed.docs-api-up-to-datepre-push check passed.ICronStore,ICronScheduler— carry doc comments).Stacking note
Targets
ci/container-publish(PR #35). Will retargetmainautomatically as the chain merges.🤖 Generated with Claude Code
All committers have signed the CLA.
Pull request overview
Adds a new cron scheduling subsystem and MCP tool surface so agents can schedule, list, edit, cancel, and manually trigger cron jobs that persist to a single JSON store under
PathKind.Data. Execution is intentionally stubbed: due jobs log the would-have-been agent prompt and advanceNextFireAt.Changes:
JsonCronStore), scheduling (CronScheduler), and a background executor (CronExecutor) wired viaAddCron()andAddCore().cron_schedule,cron_list,cron_cancel,cron_edit,cron_trigger) registered in the MCP tool collection.Reviewed changes
Copilot reviewed 19 out of 19 changed files in this pull request and generated 6 comments.
Show a summary per file
FakeTimeProvider.AddCron()DI wiring and options validation.FireDueAsync.AddCore().cron_scheduleMCP tool wrapper around the scheduler.cron_listMCP tool wrapper around the scheduler.cron_cancelMCP tool wrapper around the scheduler.cron_editMCP tool wrapper around the scheduler.cron_triggerMCP tool wrapper around the scheduler.💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
@ -0,0 +1,238 @@using Cronos;FireDueAsync doesn’t isolate failures per job. If FireSingleAsync throws for one persisted job (e.g., invalid CronExpression from manual edits), the method exits and remaining due jobs won’t fire on that tick. Consider catching/logging exceptions around the per-job FireSingleAsync call so other jobs still advance.
@ -0,0 +112,4 @@var newPrompt = edit.Prompt ?? existing.Prompt;var newEnabled = edit.Enabled ?? existing.Enabled;var newExpression = edit.CronExpression ?? existing.CronExpression;EditAsync allows setting Name/Prompt to empty/whitespace (because it only null-coalesces). ScheduleAsync rejects blank values, so this creates an inconsistent API and can persist invalid job state. Consider validating that any provided edit.Name/edit.Prompt are non-empty (and rejecting whitespace-only updates).
@ -0,0 +192,4 @@// intentionally not logged — it is user/agent-provided text that// can carry secrets; only the length is recorded.LogStubFire(_logger, job.Id, job.AgentId, manual, job.Prompt.Length);The stub fire path logs the full user-provided Prompt at Information level. Prompts can contain secrets or other sensitive content, so this can leak data into application logs. Consider truncating/redacting the prompt, logging only metadata (length/hash), or lowering the log level.
@ -0,0 +200,4 @@LastFiredAt = firedAt,NextFireAt = nextFireAt,};await _store.UpsertAsync(updated, cancellationToken).ConfigureAwait(false);FireSingleAsync persists an updated CronJob built from the instance passed in. If the job was concurrently edited after it was read (via GetAllAsync/GetAsync), this UpsertAsync can overwrite those edits (lost updates to name/prompt/enabled/expression). Consider reloading the latest job inside FireSingleAsync (or adding an atomic store update) before writing LastFiredAt/NextFireAt.
@ -0,0 +1,159 @@using System.Text.Json;loaded.ToDictionary(j => j.Id) will throw if the JSON file contains duplicate ids (e.g., from manual edits/merge conflicts), preventing the cron store from loading. Consider deduping (e.g., last-write-wins) or logging and skipping duplicates instead of throwing.
@ -0,0 +112,4 @@{await using var stream = File.OpenRead(path);var loaded = await JsonSerializer.DeserializeAsync<List<CronJob>>(stream, _jsonOptions, cancellationToken)JsonCronStore only catches JsonException during load. File.OpenRead / DeserializeAsync can also throw IO-related exceptions (IOException/UnauthorizedAccessException), which would currently bubble up and potentially fail startup. Consider catching those as well (logging and starting empty, similar to AgentConfigProvider).