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
View File
@@ -8,6 +8,8 @@ MCP, recurrence, snippets — gets it by construction rather than by remembering
These test the helper directly. The point of the change is that there is now ONE
place to test.
"""
import pytest
from scribe.services import notes as notes_svc
# --- inline embedding (#2056) -----------------------------------------------
@@ -69,3 +71,36 @@ def test_embed_note_swallows_an_indexing_failure():
note = MagicMock(id=5, user_id=42, title="T", body="B")
with patch("asyncio.create_task", side_effect=ValueError("model gone")):
notes_svc.embed_note(note) # must not raise
# --- #3683: a create that names a status is the transition an update is ------
_LIFECYCLE = ("started_at", "completed_at", "recurrence_next_spawn_at")
@pytest.mark.parametrize("status", ["todo", "in_progress", "done", "cancelled"])
def test_a_task_created_at_a_status_matches_one_updated_to_it(status):
"""The invariant, not the instance that surfaced it: which lifecycle
stamps a row carries must not depend on whether it was created at a status
or reached it by update. Recurrence is included because done/cancelled
schedule the next spawn."""
rule = {"type": "interval", "unit": "week", "every": 1}
created = notes_svc.build_note(1, title="t", status=status, recurrence_rule=rule)
updated = notes_svc.build_note(1, title="t", recurrence_rule=rule)
updated.status = status
notes_svc.apply_status_transition(updated)
for field in _LIFECYCLE:
assert (getattr(created, field) is None) == (getattr(updated, field) is None), (status, field)
def test_a_task_created_in_progress_knows_when_it_started():
note = notes_svc.build_note(1, title="t", status="in_progress")
assert note.started_at is not None
assert note.completed_at is None
def test_a_note_with_no_status_gets_no_lifecycle_stamps():
note = notes_svc.build_note(1, title="t")
assert all(getattr(note, f) is None for f in _LIFECYCLE)