diff --git a/backend/packages/harness/deerflow/domain/schedule/__init__.py b/backend/packages/harness/deerflow/domain/schedule/__init__.py index b8e6268f9..c72071c3b 100644 --- a/backend/packages/harness/deerflow/domain/schedule/__init__.py +++ b/backend/packages/harness/deerflow/domain/schedule/__init__.py @@ -6,14 +6,12 @@ are contracts consumed by adapters and tests, not everyday call-site symbols. """ from deerflow.domain.schedule.commands import ( - UNSET, ContextChange, CreateScheduledTask, DeleteTask, PauseTask, ResumeTask, TriggerTask, - UnsetType, UpdateScheduledTask, ) from deerflow.domain.schedule.exceptions import ( @@ -51,7 +49,6 @@ __all__ = [ "ACTIVE_RUN_STATUSES", "TERMINAL_RUN_STATUSES", "TERMINAL_TASK_STATUSES", - "UNSET", "ActiveRunConflictError", "ConcurrentUpdateError", "ContextChange", @@ -82,6 +79,5 @@ __all__ = [ "ThreadNotFoundError", "TriggerKind", "TriggerTask", - "UnsetType", "UpdateScheduledTask", ] diff --git a/backend/packages/harness/deerflow/domain/schedule/commands.py b/backend/packages/harness/deerflow/domain/schedule/commands.py index 75cf4f09b..5e529660c 100644 --- a/backend/packages/harness/deerflow/domain/schedule/commands.py +++ b/backend/packages/harness/deerflow/domain/schedule/commands.py @@ -28,38 +28,12 @@ the client's intent. from __future__ import annotations from dataclasses import dataclass -from typing import TYPE_CHECKING, Final, final +from typing import TYPE_CHECKING if TYPE_CHECKING: from deerflow.domain.schedule.model import ContextMode, ScheduleSpec -@final -class UnsetType: - """The type of ``UNSET`` -- "the client did not supply this field". - - Partial updates need three states (absent, null, value). ``None`` cannot - carry two of them, so absence gets its own singleton; a field that is - ``UNSET`` is left untouched by the handler. - """ - - _instance: UnsetType | None = None - - def __new__(cls) -> UnsetType: - if cls._instance is None: - cls._instance = super().__new__(cls) - return cls._instance - - def __repr__(self) -> str: - return "UNSET" - - def __bool__(self) -> bool: - return False - - -UNSET: Final[UnsetType] = UnsetType() - - @dataclass(frozen=True) class ContextChange: """A requested change of execution context. @@ -88,14 +62,21 @@ class CreateScheduledTask: @dataclass(frozen=True) class UpdateScheduledTask: - """Partially update a task; ``UNSET`` means "not supplied".""" + """Partially update a task; ``None`` means "not supplied". + + ``None`` can double as the absence marker ONLY because every field here + is non-nullable as a business value -- there is no "set the title to + null". Keep it that way: a future field whose ``None`` is meaningful + must travel inside a small change object instead, the way the nullable + ``thread_id`` already rides inside ``ContextChange``. + """ task_id: str user_id: str - title: str | UnsetType = UNSET - prompt: str | UnsetType = UNSET - schedule: ScheduleSpec | UnsetType = UNSET - context: ContextChange | UnsetType = UNSET + title: str | None = None + prompt: str | None = None + schedule: ScheduleSpec | None = None + context: ContextChange | None = None @dataclass(frozen=True) diff --git a/backend/packages/harness/deerflow/domain/schedule/service.py b/backend/packages/harness/deerflow/domain/schedule/service.py index 11495f041..0e36e6288 100644 --- a/backend/packages/harness/deerflow/domain/schedule/service.py +++ b/backend/packages/harness/deerflow/domain/schedule/service.py @@ -20,7 +20,6 @@ from dataclasses import dataclass, replace from datetime import datetime from deerflow.domain.schedule.commands import ( - UNSET, CreateScheduledTask, DeleteTask, PauseTask, @@ -164,10 +163,11 @@ class ScheduleService: return await self._tasks.add(task) async def update_scheduled_task(self, cmd: UpdateScheduledTask, *, now: datetime) -> ScheduledTask: - """Partially update a task; ``UNSET`` means "not supplied". + """Partially update a task; ``None`` means "not supplied". - The one field for which ``None`` is a meaningful value, `thread_id`, - travels inside `ContextChange` where it is unambiguous. + Safe because every command field is non-nullable as a business value; + the one field for which ``None`` is meaningful, `thread_id`, travels + inside `ContextChange` where it is unambiguous. Context and schedule are applied through the aggregate's own transitions, so the re-arm rule and the running-task gate cannot be @@ -176,14 +176,14 @@ class ScheduleService: async def apply(task: ScheduledTask) -> ScheduledTask: task.ensure_mutable() - if cmd.context is not UNSET: + if cmd.context is not None: task = task.with_context(cmd.context.context_mode, cmd.context.thread_id) await self._require_thread(task) - if cmd.schedule is not UNSET: + if cmd.schedule is not None: task = task.with_schedule(cmd.schedule, now=now, policy=self._policy) - if cmd.title is not UNSET: + if cmd.title is not None: task = replace(task, title=cmd.title) - if cmd.prompt is not UNSET: + if cmd.prompt is not None: task = replace(task, prompt=cmd.prompt) return task diff --git a/backend/tests/test_schedule_service.py b/backend/tests/test_schedule_service.py index a9ecf3026..92af2988c 100644 --- a/backend/tests/test_schedule_service.py +++ b/backend/tests/test_schedule_service.py @@ -24,14 +24,12 @@ from schedule_fakes import ( ) from deerflow.domain.schedule.commands import ( - UNSET, ContextChange, CreateScheduledTask, DeleteTask, PauseTask, ResumeTask, TriggerTask, - UnsetType, UpdateScheduledTask, ) from deerflow.domain.schedule.exceptions import ( @@ -947,15 +945,16 @@ class TestTaskManagement: cmd = CreateScheduledTask(user_id="u", title="", prompt="", schedule=CRON, context_mode="not-a-mode", thread_id=None) assert cmd.context_mode == "not-a-mode" - async def test_unset_is_a_singleton_distinct_from_none(self): - # Three states, not two: an update field is UNSET (leave it alone), - # None can stay a meaningful value elsewhere, and UNSET is falsy so - # it cannot masquerade as a supplied value. - assert UnsetType() is UNSET + async def test_an_omitted_update_field_defaults_to_none(self): + # None means "not supplied" on every top-level update field. That is + # safe because none of them admits null as a business value -- the one + # nullable field, thread_id, travels inside ContextChange where None + # is unambiguous. cmd = UpdateScheduledTask(task_id="t", user_id="u") - assert cmd.title is UNSET - assert cmd.title is not None - assert not UNSET + assert cmd.title is None + assert cmd.prompt is None + assert cmd.schedule is None + assert cmd.context is None async def test_update_leaves_omitted_fields_alone(self): service = make_service() @@ -967,7 +966,7 @@ class TestTaskManagement: assert updated.prompt == task.prompt assert updated.schedule == task.schedule - async def test_update_with_everything_unset_changes_nothing(self): + async def test_update_with_everything_omitted_changes_nothing(self): service = make_service() task = await create_cron_task(service)