# Fix: Cron Triggers Not Firing

## Root Cause Analysis

After tracing the full cron trigger lifecycle in [`src/trigger_manager.c`](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

In [`trigger_manager_init()`](src/trigger_manager.c:615):

```c
mgr->last_poll_at = time(NULL);
```

Then in [`trigger_manager_poll()`](src/trigger_manager.c:1263):

```c
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`](src/main.c:1450) is:

1. `trigger_manager_init()` — sets `last_poll_at = time(NULL)`
2. `trigger_manager_load_from_startup_events()` — loads cron triggers
3. `trigger_manager_load_from_skills()` — loads more cron triggers (after EOSE, async)
4. 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-monitor` has cron filter `*` — a single asterisk
- `c-relay-memory-watch` has cron filter `0 21,22,23,0,1,2 * * *` — valid 5-field

The [`cron_matches_now()`](src/trigger_manager.c:207) function strictly requires exactly 5 whitespace-separated fields:

```c
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()`](src/trigger_manager.c:223) 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()`](src/trigger_manager.c:987) copies the filter to `cron_expr` without validating it:

```c
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`](src/trigger_manager.c:615)

Change:
```c
mgr->last_poll_at = time(NULL);
```
To:
```c
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`](src/trigger_manager.c:987)

Add a static validation function:

```c
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`](src/trigger_manager.c:207)

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`](src/trigger_manager.c:1253)

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`](src/trigger_manager.c:1387)

In `trigger_manager_status_json()`, add the cron-specific fields so the agent can self-diagnose:

```c
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

```mermaid
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`](src/trigger_manager.c:615) | Fix `last_poll_at` init to 0 |
| [`src/trigger_manager.c`](src/trigger_manager.c:207) | Add `cron_expand_shorthand()` helper |
| [`src/trigger_manager.c`](src/trigger_manager.c:987) | Add `cron_expr_valid()` validation + warning log |
| [`src/trigger_manager.c`](src/trigger_manager.c:1253) | Add debug logging in poll loop |
| [`src/trigger_manager.c`](src/trigger_manager.c:1387) | Add cron fields to status JSON |
