fix(tasks): a create that names a status is stamped like an update to it (#3683)
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 16s
CI & Build / integration (push) Successful in 55s
CI & Build / TypeScript typecheck (push) Successful in 57s
CI & Build / Python tests (push) Successful in 1m34s
CI & Build / Build & push image (push) Successful in 27s

build_note (create_note and create_records) set the status and nothing it
implies, so create_task(status="in_progress") wrote a started task with no
started_at, and done/cancelled had no completed_at or next recurrence.
The transition is now one function, apply_status_transition, called by
both paths; tests pin the invariant for every status.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-23 19:04:27 -04:00
co-authored by Claude Opus 5.5
parent f4e9cd429b
commit b8f543f45a
2 changed files with 70 additions and 19 deletions
+35 -19
View File
@@ -284,7 +284,7 @@ def build_note(
except ValueError:
raise ValueError(f"Invalid priority: {priority!r}. Must be one of: {[p.value for p in TaskPriority]}")
return Note(
note = Note(
user_id=user_id,
title=title,
body=body,
@@ -304,6 +304,39 @@ def build_note(
verify_with=verify_with,
expires_when=expires_when,
)
# A create that names a status is the same transition an update to it is
# (#3683) — otherwise `create_task(status="in_progress")` writes a row no
# update could produce: started, with no `started_at`.
if status is not None:
apply_status_transition(note)
return note
def apply_status_transition(note: Note) -> None:
"""Stamp what reaching `note.status` implies — the ONE statement of it.
Called by the update path whenever `status` is written and by `build_note`
whenever a create names one, so a task created at a status is
indistinguishable from one that reached it by update. Two copies of "what
a status implies" are where the two drift, and #3683 was that drift.
"""
_now = datetime.now(timezone.utc)
if note.status == TaskStatus.in_progress.value:
if note.started_at is None:
note.started_at = _now
elif note.status in (TaskStatus.done.value, TaskStatus.cancelled.value):
note.completed_at = _now
if note.recurrence_rule:
from scribe.services.recurrence import calculate_next_due
base = note.due_date or _now.date()
next_due = calculate_next_due(note.recurrence_rule, base)
note.recurrence_next_spawn_at = datetime(
next_due.year, next_due.month, next_due.day, tzinfo=timezone.utc
)
elif note.status == TaskStatus.todo.value:
note.started_at = None
note.completed_at = None
note.recurrence_next_spawn_at = None
async def get_note(user_id: int, note_id: int) -> Note | None:
@@ -653,25 +686,8 @@ async def update_note(
recompose = _mirror_recomposers().get(note.note_type or "")
if recompose is not None:
note.data = recompose(note)
# Auto-set lifecycle timestamps on status transitions
if "status" in fields:
_now = datetime.now(timezone.utc)
if note.status == TaskStatus.in_progress.value:
if note.started_at is None:
note.started_at = _now
elif note.status in (TaskStatus.done.value, TaskStatus.cancelled.value):
note.completed_at = _now
if note.recurrence_rule:
from scribe.services.recurrence import calculate_next_due
base = note.due_date or _now.date()
next_due = calculate_next_due(note.recurrence_rule, base)
note.recurrence_next_spawn_at = datetime(
next_due.year, next_due.month, next_due.day, tzinfo=timezone.utc
)
elif note.status == TaskStatus.todo.value:
note.started_at = None
note.completed_at = None
note.recurrence_next_spawn_at = None
apply_status_transition(note)
note.updated_at = datetime.now(timezone.utc)
await session.commit()
await session.refresh(note)