Skip to content

Deactivate one-shot Claude jobs after firing #112

@dcellison

Description

@dcellison

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:

  1. 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.
  2. CONDITION_NOT_MET path (lines 317-340) - no deactivation for one-shot
  3. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workinggood first issueGood for newcomers

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions