7.0 KiB
Fix: Cron Triggers Not Firing
Root Cause Analysis
After tracing the full cron trigger lifecycle in src/trigger_manager.c, I identified three bugs and one usability gap that together explain why cron triggers never fire.
Bug 1: last_poll_at initialized to time(NULL) — first poll always skipped
mgr->last_poll_at = time(NULL);
Then in trigger_manager_poll():
if (mgr->last_poll_at > 0 && (now - mgr->last_poll_at) < 30) {
return 0; // skip
}
This means the first 30 seconds after init, all cron polls are silently skipped. This is a minor delay, not the primary cause, but it compounds with Bug 2.
Fix: Initialize last_poll_at = 0 so the first poll runs immediately.
Bug 2: Cron triggers loaded AFTER init — poll window already consumed
The startup sequence in src/main.c is:
trigger_manager_init()— setslast_poll_at = time(NULL)trigger_manager_load_from_startup_events()— loads cron triggerstrigger_manager_load_from_skills()— loads more cron triggers (after EOSE, async)- Main loop starts calling
trigger_manager_poll()
Because last_poll_at is set at init time, and loading happens after init, the first poll after loading may still be within the 30-second window. Combined with Bug 1, this means the first cron evaluation is delayed.
This is not the primary cause either — after 30 seconds, polls should start working. But it contributes to the perception that cron is broken.
Bug 3 (PRIMARY): Invalid cron expressions — * instead of * * * * *
The agent's diagnosis shows:
infrastructure-monitorhas cron filter*— a single asteriskc-relay-memory-watchhas cron filter0 21,22,23,0,1,2 * * *— valid 5-field
The cron_matches_now() function strictly requires exactly 5 whitespace-separated fields:
if (nf != 5 || tok != NULL) {
return 0; // silently rejects
}
A single * produces nf == 1, which fails the nf != 5 check. This is the primary reason infrastructure-monitor never fires.
For c-relay-memory-watch with 0 21,22,23,0,1,2 * * * — this is a valid 5-field expression. The hour field 21,22,23,0,1,2 means hours 21-23 and 0-2 UTC. If the agent was tested outside those hours, it would correctly not fire. However, the agent reports last_fired: 0 which means it has never fired, suggesting either:
- The agent hasn't been running during those hours, OR
- There's a secondary issue with how the expression was stored
Usability Gap: No cron shorthand support
Standard cron implementations support shorthands like @hourly, @daily, @every_5m. Didactyl only supports raw 5-field expressions. The LLM creating skills may generate * thinking it means "every minute" when it should be * * * * *.
Usability Gap: No validation or error logging on invalid cron expressions
When cron_matches_now() rejects an expression, it returns 0 silently. There is no log message indicating the expression was invalid. This makes debugging impossible without reading the source code.
Similarly, trigger_manager_add() copies the filter to cron_expr without validating it:
if (t->trigger_type == TRIGGER_TYPE_CRON) {
snprintf(t->cron_expr, sizeof(t->cron_expr), "%s", filter_json);
}
No validation that filter_json is a valid 5-field cron expression.
Fix Plan
Fix 1: Initialize last_poll_at to 0
File: src/trigger_manager.c
Change:
mgr->last_poll_at = time(NULL);
To:
mgr->last_poll_at = 0;
This allows the first poll to run immediately after triggers are loaded.
Fix 2: Add cron expression validation in trigger_manager_add and trigger_manager_update
File: src/trigger_manager.c
Add a static validation function:
static int cron_expr_valid(const char* expr) {
if (!expr || expr[0] == '\0') return 0;
char buf[128];
snprintf(buf, sizeof(buf), "%s", expr);
int nf = 0;
char* saveptr = NULL;
char* tok = strtok_r(buf, " \t", &saveptr);
while (tok && nf < 6) { nf++; tok = strtok_r(NULL, " \t", &saveptr); }
return (nf == 5 && tok == NULL) ? 1 : 0;
}
Call it in trigger_manager_add() and trigger_manager_update() when trigger_type == TRIGGER_TYPE_CRON, logging a warning if invalid.
Fix 3: Add cron shorthand expansion
File: src/trigger_manager.c
Add a helper that expands common shorthands before parsing:
| Shorthand | Expansion |
|---|---|
* |
* * * * * |
@yearly / @annually |
0 0 1 1 * |
@monthly |
0 0 1 * * |
@weekly |
0 0 * * 0 |
@daily / @midnight |
0 0 * * * |
@hourly |
0 * * * * |
This directly fixes the infrastructure-monitor case where * should mean "every minute".
Fix 4: Add debug logging for cron evaluation
File: src/trigger_manager.c
Add DEBUG_INFO or DEBUG_WARN logging in trigger_manager_poll() when:
- A cron expression fails to parse — log the expression and skill d_tag
- A cron expression matches — log the match before firing
- The poll is skipped due to the 30-second throttle — log at DEBUG level
Fix 5: Add last_cron_fire and cron_expr to status JSON
File: src/trigger_manager.c
In trigger_manager_status_json(), add the cron-specific fields so the agent can self-diagnose:
if (t->trigger_type == TRIGGER_TYPE_CRON) {
cJSON_AddStringToObject(item, "cron_expr", t->cron_expr);
cJSON_AddNumberToObject(item, "last_cron_fire", (double)t->last_cron_fire);
}
Flow After Fix
flowchart TD
INIT[trigger_manager_init - last_poll_at=0] --> LOAD[Load triggers from startup/skills]
LOAD --> POLL[trigger_manager_poll called from main loop]
POLL --> CHECK{now - last_poll_at >= 30?}
CHECK -->|No| SKIP[Return 0 - throttled]
CHECK -->|Yes| ITER[Iterate triggers]
ITER --> CRON{trigger_type == CRON?}
CRON -->|No| NEXT[Next trigger]
CRON -->|Yes| EXPAND[Expand shorthand if needed]
EXPAND --> VALID{Valid 5-field expr?}
VALID -->|No| WARN[Log warning + skip]
VALID -->|Yes| MATCH{cron_matches_now?}
MATCH -->|No| NEXT
MATCH -->|Yes| DEDUP{last_cron_fire < 50s ago?}
DEDUP -->|Yes| NEXT
DEDUP -->|No| FIRE[execute_llm_action]
FIRE --> NEXT
WARN --> NEXT
Files Changed
| File | Change |
|---|---|
src/trigger_manager.c |
Fix last_poll_at init to 0 |
src/trigger_manager.c |
Add cron_expand_shorthand() helper |
src/trigger_manager.c |
Add cron_expr_valid() validation + warning log |
src/trigger_manager.c |
Add debug logging in poll loop |
src/trigger_manager.c |
Add cron fields to status JSON |