Skip to content

Commit 5437a13

Browse files
yoomlamclaude
andcommitted
fix(lik-ui): re-base a schedule's next run when its cadence changes
Editing a scheduled run's cadence left next_run_at untouched, so shortening it (e.g. weekly -> daily) still waited out the old interval. The next due time is now recomputed from the last completed run, and lands in the past — i.e. due now — when the new cadence has already elapsed. The due time is still left alone when the cadence is unchanged, when the row has never completed a run (already due), and while a run is in flight, where complete_run recomputes from the new cadence. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 6697ef2 commit 5437a13

2 files changed

Lines changed: 63 additions & 10 deletions

File tree

lik-ui/src/lik_ui/db.py

Lines changed: 28 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -429,21 +429,40 @@ def update_scheduled_run(
429429
) -> bool:
430430
"""Edit an existing schedule in place (owner-scoped). Writes only the user-editable fields
431431
plus the derived ``max_runtime_s`` (re-materialized from the possibly-changed agent's roster
432-
value, same source as ``create_scheduled_run``). Deliberately leaves ``next_run_at``,
433-
``paused``, ``started_at``, and every ``last_*`` column untouched: editing the message or
434-
agent must not change *when* the schedule next fires, and a cadence change takes full effect
435-
after the next completion (``complete_run`` recomputes ``next_run_at`` from ``run_interval``).
436-
Leaving ``started_at`` alone preserves the scanner's double-run invariant. Returns whether a
437-
row matched (False when the row isn't the caller's or doesn't exist)."""
432+
value, same source as ``create_scheduled_run``).
433+
434+
Changing the cadence re-bases the next due time on the last completed run
435+
(``completed_at + <new interval>``), so a shortened cadence takes effect right away instead
436+
of waiting out the old one; if that lands in the past the row simply becomes due now. The
437+
next due time is left alone when the cadence is unchanged (editing the message or agent must
438+
not change *when* the schedule next fires), when the row has never completed a run (it is
439+
already due immediately), and while a run is in flight (``completed_at`` is NULL then, and
440+
``complete_run`` will recompute from the new cadence).
441+
442+
``paused``, ``started_at``, and every ``last_*`` column are untouched; leaving ``started_at``
443+
alone preserves the scanner's double-run invariant. Returns whether a row matched (False when
444+
the row isn't the caller's or doesn't exist)."""
438445
with self.db.connection() as conn:
439446
row = conn.execute(
440447
"""
441448
UPDATE scheduled_runs
442-
SET agent_name = %s, prompt = %s, run_interval = %s, max_runtime_s = %s
443-
WHERE id = %s AND user_id = %s
449+
SET agent_name = %(agent_name)s, prompt = %(prompt)s,
450+
run_interval = %(run_interval)s, max_runtime_s = %(max_runtime_s)s,
451+
next_run_at = CASE
452+
WHEN %(run_interval)s IS DISTINCT FROM run_interval AND completed_at IS NOT NULL
453+
THEN completed_at + %(run_interval)s
454+
ELSE next_run_at END
455+
WHERE id = %(run_id)s AND user_id = %(user_id)s
444456
RETURNING id
445457
""",
446-
(agent_name, prompt, run_interval, max_runtime_s, run_id, user_id),
458+
{
459+
"agent_name": agent_name,
460+
"prompt": prompt,
461+
"run_interval": run_interval,
462+
"max_runtime_s": max_runtime_s,
463+
"run_id": run_id,
464+
"user_id": user_id,
465+
},
447466
).fetchone()
448467
conn.commit()
449468
return row is not None

lik-ui/tests/test_db.py

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -213,13 +213,47 @@ def test_update_scheduled_run_preserves_schedule_state(store):
213213
before = store.list_scheduled_runs(a["id"])[0]
214214
store.update_scheduled_run(run["id"], a["id"], "agent", "edited", timedelta(days=3), max_runtime_s=1800)
215215
after = store.list_scheduled_runs(a["id"])[0]
216-
# Editing must not touch when the schedule next fires or its run/pause state.
216+
# A schedule that has never completed a run is already due now, so even a cadence change
217+
# leaves the due time alone — as does the run/pause state.
217218
assert after["next_run_at"] == before["next_run_at"]
218219
assert after["paused"] == before["paused"]
219220
assert after["started_at"] == before["started_at"]
220221
assert after["last_status"] == before["last_status"]
221222

222223

224+
def test_update_scheduled_run_rebases_next_run_on_cadence_change(store):
225+
a = store.upsert_user("a@navapbc.com")
226+
run = store.create_scheduled_run(a["id"], "agent", "go", timedelta(days=7))
227+
completed = datetime.now(timezone.utc) - timedelta(days=1)
228+
_set_run(store.db, run["id"], completed_at=completed, next_run_at=completed + timedelta(days=7))
229+
# Shortening the cadence must take effect from the last run, not wait out the old one.
230+
store.update_scheduled_run(run["id"], a["id"], "agent", "go", timedelta(days=2), max_runtime_s=1800)
231+
assert store.list_scheduled_runs(a["id"])[0]["next_run_at"] == completed + timedelta(days=2)
232+
233+
234+
def test_update_scheduled_run_keeps_next_run_when_cadence_unchanged(store):
235+
a = store.upsert_user("a@navapbc.com")
236+
run = store.create_scheduled_run(a["id"], "agent", "go", timedelta(days=7))
237+
completed = datetime.now(timezone.utc) - timedelta(days=1)
238+
due = completed + timedelta(days=3) # deliberately not completed_at + run_interval
239+
_set_run(store.db, run["id"], completed_at=completed, next_run_at=due)
240+
# Editing only the message/agent must not move the due time.
241+
store.update_scheduled_run(run["id"], a["id"], "other", "edited", timedelta(days=7), max_runtime_s=1800)
242+
assert store.list_scheduled_runs(a["id"])[0]["next_run_at"] == due
243+
244+
245+
def test_update_scheduled_run_leaves_in_flight_row_due_time(store):
246+
a = store.upsert_user("a@navapbc.com")
247+
run = store.create_scheduled_run(a["id"], "agent", "go", timedelta(days=7))
248+
store.claim_due_runs() # in flight: started_at set, completed_at cleared
249+
before = store.list_scheduled_runs(a["id"])[0]
250+
store.update_scheduled_run(run["id"], a["id"], "agent", "go", timedelta(days=2), max_runtime_s=1800)
251+
after = store.list_scheduled_runs(a["id"])[0]
252+
# complete_run recomputes from the new cadence, so the edit leaves the due time alone.
253+
assert after["next_run_at"] == before["next_run_at"]
254+
assert after["started_at"] == before["started_at"]
255+
256+
223257
def test_update_scheduled_run_is_owner_scoped(store):
224258
a = store.upsert_user("a@navapbc.com")
225259
b = store.upsert_user("b@navapbc.com")

0 commit comments

Comments
 (0)