5 Commits

Author SHA1 Message Date
rayhpeng
376c272f52 refactor(schedule): drop the UNSET sentinel for plain None defaults
Every top-level update field is non-nullable as a business value, so
None can safely double as "not supplied" -- the one field whose None is
meaningful, thread_id, already travels inside ContextChange where it is
unambiguous. The UnsetType singleton solved a three-state problem this
command does not currently have; plain `| None = None` reads better.

The constraint is documented on UpdateScheduledTask: a future field
whose None is meaningful must ride inside a small change object the way
thread_id does, rather than reintroducing a second convention.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 17:16:42 +08:00
rayhpeng
71169c9f83 fix(schedule): forbid launcher thread redirection instead of recording it
LaunchedRun's docstring permitted the adapter to return a different
thread than requested, but the execution record is created with the
requested thread before the launch and update_status cannot correct
it -- a redirecting launcher left history on a thread nothing ran on
while the task pointed at the actual one.

No real adapter redirects (the Gateway launch path runs on exactly the
thread it is given), so the contract now requires launching on the
requested thread; the echoed thread_id is demoted to a verification
field. The service checks the echo: on a mismatch a run is still live
somewhere, so retention applies, the bookkeeping stays on the requested
thread the record row was created with, and the violation is surfaced
on the dispatch result and logged as an adapter bug.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 16:51:44 +08:00
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
e876cfac3a fix(schedule): retain the active slot across post-launch failures
Port the #4504 retention semantics (#4452 duplicate-execution fix) into
the hexagonal dispatch path. Once launch() returns -- or raises without
being able to say whether a run started -- a live run may exist, so
bookkeeping failures must not release the task's single active slot:

- The two post-launch writes are best-effort: failures are logged and
  surfaced on DispatchResult.error while the outcome stays LAUNCHED.
- New LaunchIndeterminateError expresses main's launch_succeeded-before-
  unpack semantics at the port boundary: the adapter raises it when the
  side effect may have happened but the identity is unknown, and the
  service retains the slot with run_id=None.
- LaunchFailedError is narrowed to "the adapter is CERTAIN no run
  started", since that path releases the slot.

Regression tests ported from tests/test_scheduled_task_service.py.

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