2 Commits

Author SHA1 Message Date
rayhpeng
c72bccb916 fix(schedule): guard task saves with an optimistic version CAS
update/pause/resume read the aggregate and persist it whole; a dispatch
or completion committing between those operations previously got
overwritten by the stale snapshot -- rolling back next_run_at,
run_count, and last_run_id, after which the next poll re-launches an
already-executed occurrence.

- ScheduledTask gains a `version` token owned by the storage write path;
  every committed write (save CAS, record_launch, record_completion,
  claim_due, cancel_stuck_once_tasks) increments it.
- `save()` is now a compare-and-set on that version: a stale write
  raises the new ConcurrentUpdateError instead of committing.
- The service retries the read-modify-write (re-applying the aggregate
  transitions to a fresh read) up to 3 times, then surfaces the
  conflict for the router to map to a retryable 409.

Also exports LaunchIndeterminateError from the package root, missed in
the previous commit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 16:49:03 +08:00
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