rayhpeng d856ae8573 feat(schedule): add the domain model, ports, and application service
The inner ring of the schedule slice, added on its own so it can be read
as domain modelling rather than as a diff against the old implementation:
two aggregates with their state machines, the policy value object, the
output ports the service depends on, and the errors it raises.

Nothing wires it up yet -- no existing code path changes. The service is
exercised end to end against in-memory fakes, which is what makes the
rules (overlap policy, lease handling, which write owns which timestamp)
assertable without a database at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 11:43:35 +08:00

95 lines
2.8 KiB
Python

from __future__ import annotations
from enum import StrEnum
class TaskStatus(StrEnum):
"""Lifecycle of a standing instruction.
RUNNING does not mean "the agent is executing" -- it means this dispatch
round's scheduling ownership is held. That is why `ensure_mutable` refuses
edits in that state, and why a crash can strand a task there.
The last three are terminal for the *current* schedule, not forever: moving
the schedule into the future re-arms them (see TERMINAL_TASK_STATUSES).
"""
ENABLED = "enabled"
PAUSED = "paused"
RUNNING = "running"
COMPLETED = "completed"
FAILED = "failed"
CANCELLED = "cancelled"
class ContextMode(StrEnum):
"""Which thread a dispatch executes in.
REUSE_THREAD lets consecutive executions see each other's history and
requires a thread up front; the default gives each one a fresh thread and
therefore no shared context.
"""
FRESH_THREAD_PER_RUN = "fresh_thread_per_run"
REUSE_THREAD = "reuse_thread"
class ScheduleType(StrEnum):
"""How the next fire time is derived.
The two branch almost every rule in this context: CRON always has a next
occurrence, ONCE is consumed by its first one.
"""
ONCE = "once"
CRON = "cron"
class RunStatus(StrEnum):
"""Lifecycle of one execution record.
QUEUED and RUNNING are the two that occupy a task's single active slot;
everything else is terminal.
SKIPPED is created directly terminal and never passes through QUEUED -- a
queued tombstone would collide with the very run it is recording the
overlap with (see ScheduledRun.skipped_tombstone). INTERRUPTED is kept
distinct from FAILED because a cancel or takeover is not an execution
failure, and the two lead a `once` task to different terminal states.
"""
QUEUED = "queued"
RUNNING = "running"
SUCCESS = "success"
FAILED = "failed"
INTERRUPTED = "interrupted"
SKIPPED = "skipped"
class DispatchOutcome(StrEnum):
"""What one dispatch attempt produced.
A domain vocabulary rather than a set of strings: the caller branches on
all four, and the distinction between SKIPPED and CONFLICT is itself a
business rule -- a dropped scheduled occurrence is accounted for, while a
rejected on-demand trigger is reported and leaves no trace.
"""
LAUNCHED = "launched"
SKIPPED = "skipped"
CONFLICT = "conflict"
FAILED = "failed"
class TriggerKind(StrEnum):
"""What caused a dispatch.
The two kinds diverge in almost every decision the domain makes — how an
overlap is handled, what status survives a failed launch, whether a paused
task stays paused — so this is a first-class concept rather than the raw
string the old service compared in four places.
"""
SCHEDULED = "scheduled"
MANUAL = "manual"