Repository navigation
fix(cron): schedule validation and serialized SQLite access - #122
adriannoes wants to merge 6 commits into
Conversation
The vendored SQLite build is SQLITE_THREADSAFE=0, and the gateway HTTP thread shares that connection with the main loop. Dashboard memory, session list, and cron routes, plus cron_poll's due-job query, touched g_db without agent_lock. A dashboard refresh during a turn or a cron poll is undefined and can crash the process or corrupt memory.db. Supersedes #104.
An interval whose seconds do not fit in now+sec wrapped next_run into the past. Delivery ack recomputed the same sum, so the job stayed due and the agent ran it on every poll. Supersedes #105.
strtol and strtoll accepted a numeric prefix, so at:2026-10-08 stored next_run 2026. That one-shot was already due, ran on the next poll, and ack deleted it. Comma lists such as 1,3,5 matched only the first value and still scheduled. Supersedes #111.
There was a problem hiding this comment.
Context. ShellClaw is a C99 edge agent (Jetson / Raspberry Pi) with SQLite memory/cron (SQLITE_THREADSAFE=0, one shared g_db), a libwebsockets dashboard thread, and a main-loop/channel thread serialized by a non-recursive agent_lock(). This PR consolidates #104 (serialize dashboard + cron SQLite), #105 (interval overflow) and #111 (partial-number schedules) into one branch.
I built test_cron with CC=gcc and linked it with -lm -pthread -ldl (this image has no libcurl). It passes. I could not run GATEWAY=1 test_gateway_db_lock here (no libwebsockets).
Summary. The cron parsing fixes are correct and well tested. The locking fix is incomplete: it adds agent_lock around most dashboard SQLite calls but misses one, DELETE /api/sessions/{id}. That leaves the same data race the PR sets out to close, and the new test cannot see it.
Must Fix
src/gateway/routes.c:509-517handle_session_deletestill callssession_delete(id)with noagent_lock(). It runs on the lws thread against the sameg_dbasagent_runandcron_poll.dispatch.c:50-55(/reset) already locks this exact call, so the dashboard path is the only unlockedsession_deletecaller.agent.hnow says dashboardg_dbhandlers must hold the lock, and the PR title says "serialized SQLite access", so this is a gap against the stated contract. Fix: wrap it like the sibling handlers (rc = session_delete(id)under lock, then build the response after unlock).tests/test_gateway_db_lock.c: addDELETE /api/sessions/<id>to the route list. It would fail today. The probe lives inmemory.c, butsession_deletehas nomemory_sqlite_entry_probe()call, so the hook cannot observe it either. Add the probe there, otherwise the test passes while the bug is present. Also consider probing everyg_dbentry point (session_load,session_save,memory_save,cron_job_update_next_run) instead of a hand-picked subset, so a future unlocked caller trips the test.
Should Fix
CHANGELOG.md[Unreleased] / Fixedhas no entry for any of the three fixes (dashboard/cron SQLite serialization, interval overflow, partial-number schedules). Recent consolidations (#119 to #121) had the same gap.- Behavior change for already-stored jobs:
parse_fieldandinterval_next_runnow reject input that used to parse (interval:60abc,0 9 1,15 * *,at:2026-...).cron_ack_delivery(cron.c, parse failure branch) silently moves such a recurring job tonow + 365d. A job someone created with a comma list before this PR will stop firing for a year with no log line. Either log the job id and schedule on that fallback, or note the migration in the CHANGELOG. Rejecting comma lists at creation is right; the point is the existing rows. cron.ccron_pick_due_rowand the dashboard handlers now holdagent_lockwhile doing SQL.agent_runholds that same lock for a whole LLM round trip, so during a long agent turnGET /api/cron,/api/memoryand/api/sessionsblock the single lws service thread, which also stalls WebSocket and/health./asapalready behaves this way, so this is consistent with existing design, but it makes the dashboard visibly freeze. Worth a doc line, or a follow-up to give SQLite its own mutex instead of reusing the agent lock.handle_cron_createholds the lock acrosscron_create_job, which for a cron expression can scan up to 3662460 minutes withlocaltime_rper step. Parse/validate first and take the lock only forcron_job_create. Minor on desktop, but it is measurable on a Pi.
Nice to Have
at:still acceptsts <= 0(e.g.at:-5,at:0), which is due immediately. Consider rejectingts <= 0the wayinterval:rejectssec <= 0.parse_fieldstill usesstrtol, which accepts leading whitespace and+/-signs (+5,5). Harmless after the*endcheck, but a digit-only check would match the strictness the comments describe.test_schedule_rejects_partial_numberscoversat:,interval:and comma lists but notcron:0 9 * * 1-5xorcron:*/5 * * * *(step syntax is also rejected now, via the same path). A one-line assert for step syntax documents that it is unsupported.memory_sqlite_entry_set_hook_for_testis compiled into production builds, like the two existing test hooks. Fine for now, but a#ifdef SHELLCLAW_TESTwould keep it out of the shipped binary.Makefile:test_cronnow linksagent.o,skill.o,config.o,toml.oandprovider_common.o, and the test defines a stubprovider_router_active_backend_snapshot. That is a heavy link for a cron unit test, and the stub will go stale if the agent adds more router symbols. Acceptable, but it is the first thing to break whenagent.cgrows.
Positive Highlights
interval_next_runis small and correct:errno == ERANGE,endcheck,sec <= 0, andsec > LLONG_MAX - nowas a pre-check rather than a post-hoc overflow test. Folding #111 into the #105 refactor avoided two overlapping edits of the same parse path.cron_pick_due_rowreleases the lock before the re-offer sleep and keeps the early-return paths balanced (agent_unlock()beforereturn 1, and once on the fall-through). I found no lock leak or double unlock, androwis a heap-owned copy so using it after unlock is safe.- Tests assert the real outcomes rather than just return codes: the overflow test checks that ack lands at
now + 365d, the parse test checksnextis untouched on failure, and both lock tests check the mutex is released afterwards. Thewsi == NULLguard indispatch_routethat makes the route testable without a socket is a clean small change.
Sent by Cursor Automation: Code review
DELETE /api/sessions/{id} ran session_delete() on the lws service
thread with no agent_lock(), leaving the exact dashboard-vs-agent
SQLite race this branch sets out to close (SQLITE_THREADSAFE=0, one
shared g_db). Every sibling dashboard g_db handler already holds the
lock, and handle_message /reset locks this same call.
Wrap the session_delete() call in agent_lock()/agent_unlock() like
the sibling handlers and build the HTTP response after unlock, so the
route honors the contract documented in agent.h.
The route is now exercised by test_gateway_db_lock: seed a session
with session_save() and DELETE it through dispatch_route. That
requires session_delete to call memory_sqlite_entry_probe(), matching
the other probed entry points; without it the hook cannot observe the
call and the test would stay green while the bug is present. Verified
red first (probe present, lock missing: FAIL g_sqlite_unlocked),
then green after the lock.
The parse-failure branch of cron_ack_delivery moves a recurring job to now+365d without a trace. Since the stricter schedule parser (interval overflow, partial-number rejection), a stored job created with a now invalid schedule (e.g. a legacy comma list) would silently stop firing for a year after its first delivery. Log the job id and schedule on that branch, house style (shellclaw: cron:), before the row is freed (cron_job_row_free clears row.schedule, so it stays ahead of the branch). Fail-close behavior is unchanged.
Document the three consolidated fixes under Unreleased/Fixed: dashboard
and cron SQLite serialization including DELETE /api/sessions/{id},
interval-overflow rejection, and partial-number schedule rejection.
Also note the stored-jobs behavior change: a stored schedule the stricter
parser rejects now snoozes +365d on ack and is logged, so the gap is
diagnosable instead of silent. Reviews of the sibling consolidations
called out the same changelog gap.
|
Review disposition (all Must Fix / Should Fix / Nice to Have items): Must Fix
Should Fix
Nice to Have — Not changed, all five: |


Summary
Consolidates three cursor high-severity cron/gateway fixes as operator-authored commits:
Consolidation notes:
Testing
make test_cron— passed (overflow + partial-number + serialization tests)GATEWAY=1 make test_gateway_db_lock— passedCI=true make test— all 46 test binaries passedmake static— passedSupersedes #104, #105 and #111.