Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions src/kai/cron.py
Original file line number Diff line number Diff line change
Expand Up @@ -357,6 +357,13 @@ async def _job_callback(context: ContextTypes.DEFAULT_TYPE) -> None:
log.exception("Failed to send job %d progress update", job_id)
log.info("Job %d condition not met, continuing (notified=%s)", job_id, notify_on_check)

# One-shot jobs will never fire again; deactivate the DB row.
# APScheduler's run_once already removed it from the queue.
# Runs even if delivery failed above - the job can't retry
# regardless, so deactivating prevents a stale active=1 row.
if data["schedule_type"] == "once":
await sessions.deactivate_job(job_id)

else:
# Non-conditional or non-auto-remove: always deliver the response
msg = f"[Job: {data['name']}]\n{response_text}"
Expand All @@ -369,3 +376,10 @@ async def _job_callback(context: ContextTypes.DEFAULT_TYPE) -> None:
job.schedule_removal()
except Exception:
log.exception("Failed to send job %d result", job_id)

# One-shot jobs will never fire again; deactivate the DB row.
# APScheduler's run_once already removed it from the queue.
# Runs even if delivery failed above - the job can't retry
# regardless, so deactivating prevents a stale active=1 row.
if data["schedule_type"] == "once":
await sessions.deactivate_job(job_id)
37 changes: 37 additions & 0 deletions tests/test_cron.py
Original file line number Diff line number Diff line change
Expand Up @@ -501,6 +501,34 @@ async def test_other_send_exception_does_not_deactivate(self):
await _job_callback(self.ctx)
mock_deactivate.assert_not_called()

@pytest.mark.asyncio()
async def test_one_shot_claude_deactivates_after_sending(self):
"""One-shot Claude jobs (schedule_type=once) deactivate after delivery."""
self.ctx.job.data["schedule_type"] = "once"
self.ctx.bot_data = {"pool": _make_claude_mock("The answer is 42")}
with (
patch("kai.cron.log_message"),
patch("kai.cron.sessions.deactivate_job", new_callable=AsyncMock) as mock_deactivate,
):
await _job_callback(self.ctx)
mock_deactivate.assert_called_once_with(1)
# APScheduler handles queue removal for run_once jobs automatically.
# schedule_removal() is only needed for recurring jobs being manually
# removed (e.g., in the Forbidden handler). One-shot jobs never need it.
self.ctx.job.schedule_removal.assert_not_called()

@pytest.mark.asyncio()
async def test_recurring_claude_does_not_deactivate(self):
"""Recurring Claude jobs (daily/interval) are not deactivated after delivery."""
self.ctx.job.data["schedule_type"] = "daily"
self.ctx.bot_data = {"pool": _make_claude_mock("Daily report")}
with (
patch("kai.cron.log_message"),
patch("kai.cron.sessions.deactivate_job", new_callable=AsyncMock) as mock_deactivate,
):
await _job_callback(self.ctx)
mock_deactivate.assert_not_called()


# ── _job_callback: CONDITION_MET ─────────────────────────────────────

Expand Down Expand Up @@ -652,3 +680,12 @@ async def test_forbidden_with_notify_deactivates(self):
await _job_callback(self.ctx)
mock_deactivate.assert_called_once_with(1)
self.ctx.job.schedule_removal.assert_called_once()

@pytest.mark.asyncio()
async def test_one_shot_condition_not_met_deactivates(self):
"""One-shot auto-remove jobs deactivate even when condition is not met."""
self.ctx.job.data["schedule_type"] = "once"
self.ctx.bot_data = {"pool": _make_claude_mock("CONDITION_NOT_MET")}
with patch("kai.cron.sessions.deactivate_job", new_callable=AsyncMock) as mock_deactivate:
await _job_callback(self.ctx)
mock_deactivate.assert_called_once_with(1)
Loading