Summary
One-shot Claude jobs (schedule_type == "once", job_type == "claude") are never deactivated in the database after they fire. The reminder path correctly handles this, but the Claude job path does not.
APScheduler's run_once removes the job from the in-memory scheduler queue (so it won't fire again during the current process), but the database row remains active=1. On restart, _register_new_jobs catches it as an "expired one-shot" (lines 108-113) and deactivates it then, but between firing and the next restart, the stale row pollutes /api/jobs listings and could theoretically re-fire if timing is unlucky.
Root Cause
Reminder path (correct) at cron.py:251-257:
# One-shot reminders auto-deactivate after firing.
if data["schedule_type"] == "once":
await sessions.deactivate_job(job_id)
return
Claude job path (missing) at cron.py:342-353:
else:
# Non-conditional or non-auto-remove: always deliver the response
msg = f"[Job: {data['name']}]\n{response_text}"
try:
log_message(direction="assistant", chat_id=chat_id, text=msg)
await context.bot.send_message(chat_id=chat_id, text=msg)
except Forbidden:
log.warning("Job %d: chat %d is gone, deactivating", job_id, chat_id)
await sessions.deactivate_job(job_id)
job.schedule_removal()
except Exception:
log.exception("Failed to send job %d result", job_id)
The function ends with no schedule_type == "once" check. The same gap exists in the CONDITION_NOT_MET path (lines 317-340).
Affected Code Paths
Three Claude job delivery paths need the one-shot deactivation check:
- CONDITION_MET path (lines 299-314) - already calls
deactivate_job via auto_remove logic, but only if auto_remove is set. A non-auto-remove one-shot Claude job taking this path (not possible currently, but defensive) would not deactivate.
- CONDITION_NOT_MET path (lines 317-340) - no deactivation for one-shot
- Non-conditional else path (lines 342-353) - no deactivation for one-shot
Fix
Add the same pattern used by the reminder path after each Claude job delivery path:
if data["schedule_type"] == "once":
await sessions.deactivate_job(job_id)
This should go after the successful send_message call in each path, mirroring the reminder logic at line 255.
Severity
High - database inconsistency; one-shot Claude jobs remain active forever in job listings until the next restart.
Summary
One-shot Claude jobs (
schedule_type == "once",job_type == "claude") are never deactivated in the database after they fire. The reminder path correctly handles this, but the Claude job path does not.APScheduler's
run_onceremoves the job from the in-memory scheduler queue (so it won't fire again during the current process), but the database row remainsactive=1. On restart,_register_new_jobscatches it as an "expired one-shot" (lines 108-113) and deactivates it then, but between firing and the next restart, the stale row pollutes/api/jobslistings and could theoretically re-fire if timing is unlucky.Root Cause
Reminder path (correct) at
cron.py:251-257:Claude job path (missing) at
cron.py:342-353:The function ends with no
schedule_type == "once"check. The same gap exists in the CONDITION_NOT_MET path (lines 317-340).Affected Code Paths
Three Claude job delivery paths need the one-shot deactivation check:
deactivate_jobvia auto_remove logic, but only ifauto_removeis set. A non-auto-remove one-shot Claude job taking this path (not possible currently, but defensive) would not deactivate.Fix
Add the same pattern used by the reminder path after each Claude job delivery path:
This should go after the successful
send_messagecall in each path, mirroring the reminder logic at line 255.Severity
High - database inconsistency; one-shot Claude jobs remain active forever in job listings until the next restart.