(t: CronTask, isSession: boolean)
| 246 | // session tasks are removed synchronously from memory, file tasks go |
| 247 | // through the async removeCronTasks + chokidar reload. |
| 248 | function process(t: CronTask, isSession: boolean) { |
| 249 | if (filter && !filter(t)) return |
| 250 | seen.add(t.id) |
| 251 | if (inFlight.has(t.id)) return |
| 252 | |
| 253 | let next = nextFireAt.get(t.id) |
| 254 | if (next === undefined) { |
| 255 | // First sight — anchor from lastFiredAt (recurring) or createdAt. |
| 256 | // Never-fired recurring tasks use createdAt: if isLoading delayed |
| 257 | // this tick past the fire time, anchoring from `now` would compute |
| 258 | // next-year for pinned crons (`30 14 27 2 *`). Fired-before tasks |
| 259 | // use lastFiredAt: the reschedule below writes `now` back to disk, |
| 260 | // so on next process spawn first-sight computes the SAME newNext we |
| 261 | // set in-memory here. Without this, a daemon child despawning on |
| 262 | // idle loses nextFireAt and the next spawn re-anchors from 10-day- |
| 263 | // old createdAt → fires every task every cycle. |
| 264 | next = t.recurring |
| 265 | ? (jitteredNextCronRunMs( |
| 266 | t.cron, |
| 267 | t.lastFiredAt ?? t.createdAt, |
| 268 | t.id, |
| 269 | jitterCfg, |
| 270 | ) ?? Infinity) |
| 271 | : (oneShotJitteredNextCronRunMs( |
| 272 | t.cron, |
| 273 | t.createdAt, |
| 274 | t.id, |
| 275 | jitterCfg, |
| 276 | ) ?? Infinity) |
| 277 | nextFireAt.set(t.id, next) |
| 278 | logForDebugging( |
| 279 | `[ScheduledTasks] scheduled ${t.id} for ${next === Infinity ? 'never' : new Date(next).toISOString()}`, |
| 280 | ) |
| 281 | } |
| 282 | |
| 283 | if (now < next) return |
| 284 | |
| 285 | logForDebugging( |
| 286 | `[ScheduledTasks] firing ${t.id}${t.recurring ? ' (recurring)' : ''}`, |
| 287 | ) |
| 288 | logEvent('tengu_scheduled_task_fire', { |
| 289 | recurring: t.recurring ?? false, |
| 290 | taskId: |
| 291 | t.id as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, |
| 292 | }) |
| 293 | if (onFireTask) { |
| 294 | onFireTask(t) |
| 295 | } else { |
| 296 | onFire(t.prompt) |
| 297 | } |
| 298 | |
| 299 | // Aged-out recurring tasks fall through to the one-shot delete paths |
| 300 | // below (session tasks get synchronous removal; file tasks get the |
| 301 | // async inFlight/chokidar path). Fires one last time, then is removed. |
| 302 | const aged = isRecurringTaskAged(t, now, jitterCfg.recurringMaxAgeMs) |
| 303 | if (aged) { |
| 304 | const ageHours = Math.floor((now - t.createdAt) / 1000 / 60 / 60) |
| 305 | logForDebugging( |
no test coverage detected