8 Commits

Author SHA1 Message Date
rayhpeng
f68fb0c4cf docs(schedule): lead the walkthrough with a behavioural spec
Restructure per review: state what the system promises before how the
code delivers it. The thin product-decision table becomes a full §1
behavioural spec written in zero implementation vocabulary -- user
operations, scheduling semantics, the skip-not-queue overlap decision,
the busy-wait guarantees (delayed, never lost; ordered by due time; no
catch-up of missed cron periods), the failure/recovery table, and an
explicit non-goals list (no queue, no punctuality guarantee, no task
credentials, single scheduler instance) -- each promise cross-referenced
to the section whose mechanism delivers it.

The mechanism chapters absorb what the spec now promises: §6.2 explains
where waiting goes (the table as an implicit priority queue via the
claim query's ordering), §6.3 and a new pitfall entry name the
budget-leak boundary when a completion callback is lost, §8's defence
table gains the race-window column and a per-layer error-translation
table as the journey's unhappy half, and the HTTP endpoint block moves
beside the commands it carries.
2026-07-31 10:18:46 +08:00
rayhpeng
563245d96f docs(schedule): rewrite the module walkthrough for the finished slice
Bring SCHEDULE_DESIGN_zh.md to the same reference-implementation
walkthrough shape as FEEDBACK_DESIGN_zh.md: it now assumes the spec has
been read, drops the mid-migration 'current state' warnings (the legacy
code is deleted), and covers what the old version predated -- the
commands chapter (UNSET three-state updates, ContextChange, why the
clock- and callback-driven writes stay plain methods), the adapters and
composition-root chapter (CAS field ownership, the corrupt-row error
split, the two anti-corruption layers and the inbound completion
listener), the dispatch journey with the three-driver macro diagram the
spec's §5.3 points at, and the test-layering table for the current
suites.

The spec's §6 marks the schedule slice done and drops the three
completed to-dos; the docs index gains the schedule walkthrough next to
its sibling.
2026-07-29 22:34:43 +08:00
rayhpeng
34ce7c259b refactor(schedule): make the run-completion hook an inbound adapter
`run_outcome_mapping.py` called itself "not a port implementation" and sat in
a package of secondary adapters, while the half that actually invoked the use
case lived as a closure in the composition root. It is one thing, and it is a
primary adapter: the run runtime calls it the way HTTP calls the router and
the clock calls the poller.

`ScheduleRunCompletionListener` now holds the whole responsibility -- decide
whether a finished run is ours, translate it, invoke the use case. Those are
not two jobs: "ignore this run" is only meaningful as "do not call the
service", so splitting them is what left the second half in a place where
behaviour is not asserted.

`build_run_completion_hook` drops to `return
ScheduleRunCompletionListener(service)`. The composition root's own docstring
says no adapter logic lives there; that is now true of it as well as of the
routers it was written about.

Placement
---------
Kept in `app/adapters/schedule/` rather than moved beside the other two
primary adapters. The context stays in one package; direction is stated by
the class name and each module's first line, and the package `__init__` --
previously empty -- now lists which of its modules point which way, so a file
added without that line is visibly a file whose direction nobody decided.
A subdirectory for a single inbound module would have made the other four
look like they had been sorted into something.

Tests
-----
This is the part that was not a rename. The conversion had 24 cases; the
invocation had none, because the composition root is not where behaviour is
asserted, so nothing covered "an ordinary chat run must not reach the
service" as opposed to "produces no outcome object".

The cases now drive `__call__` against a recording service, which asserts the
same mappings plus what was done with them, and adds the two that were
unreachable before: the service left entirely alone for a filtered run, and
the completion stamped with a tz-aware current instant.

`test_composition.py` gains `TestRunCompletionHook` for the assembly decision
that remains -- including that the hook is bound to the service it was given,
which a wrong wiring would type-check past. Confirmed by mutation that this
case fails when the binding is broken.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 20:14:27 +08:00
rayhpeng
680f044e58 refactor(schedule): move schedule_spec translation onto its owners
`spec_column.py` and `spec_wire.py` were the same 60 lines twice, kept in
sync by a parity suite. Both are gone; what they did is now split along the
line that actually separates the two boundaries.

Why two files existed
---------------------
They were one module until the slice split it, and the reason given for the
split holds: a primary adapter must not import a secondary one, and the two
shapes are equal only by coincidence. But that argument only requires the two
*shapes* to be independent -- it does not require the parsing *rule* to be
written twice, and writing it twice is what needed `test_schedule_spec_parity`
to assert the two agreed, down to identical error text.

Splitting it properly
---------------------
`ScheduleSpec.from_primitives(schedule_type, *, cron, run_at, timezone)` takes
four strings, not a `Mapping[str, Any]` -- the mapping was the thing that kept
this out of the domain, and four strings carry no transport or storage format
with them. It owns the whole rule: unknown type, missing or non-string field,
unparseable `run_at`, and (via __post_init__, unchanged) 5-field cron and
resolvable timezone. Values are checked rather than trusted, since both
callers read data a client can influence.

Each adapter keeps only what is genuinely its own -- which two keys its format
uses -- as private methods on the class that owns the boundary, matching how
`SqlFeedbackRepository` and AWS's own ports-and-adapters sample put the
conversion inside the adapter rather than beside it:

  SqlScheduledTaskRepository._spec_from_row / _spec_to_column
  models.ScheduledTaskCreateRequest.to_schedule / models._spec_to_wire

The emit direction stays duplicated, deliberately: it is three lines per side
with no rule in it, and the two are *allowed* to diverge -- one is an HTTP
contract, the other a storage format. Asserting they stay byte-identical was
a constraint neither side asked for, so that suite is not replaced.

The router stops building value objects
---------------------------------------
`create` passes `body.to_schedule()`. `update` passes
`body.to_schedule(current.schedule)`, replacing eight lines that re-emitted
the current spec to the wire shape purely to read defaults back out of it;
omitted parts now come off the value object directly.

Tests
-----
`test_schedule_spec_parity.py` is deleted (162 lines). Its structural and
value cases moved to `TestFromPrimitives` in the domain suite -- stated once
now instead of parametrized over two implementations. Emit coverage was
already elsewhere: wire in `test_schedule_response_models`, column via the
repository round-trip in `test_schedule_fakes`.

One gap found while removing it: the `once` half of the update fallback had
no coverage on either side (both existing cases use cron), and it is exactly
the branch this commit rewrites -- `spec_to_wire(current)` round-trip to
`current.run_at.isoformat()`. Added
`test_a_timezone_change_on_a_once_task_keeps_the_same_instant`, and confirmed
by mutation that it is the only case that catches that branch breaking.

Behaviour is unchanged: same wire shapes, same normalizations (whitespace in
cron, trailing Z re-emitted as +00:00), same error messages, so the 422
details clients see do not move.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 20:03:15 +08:00
rayhpeng
eb37df0779 refactor(schedule): wire the slice through a composition root
Switches production onto the hexagonal path. The legacy modules still
compile and still have tests, but nothing assembles them any more; deleting
them is the next commit, kept separate so it stays reviewable.

Composition root
----------------
`app/composition.py::build_domain_services()` is now the only place an
adapter is instantiated. It was extracted from `deps.py::langgraph_runtime`
rather than added to it: wiring there was tangled with engine startup,
orphan recovery and shutdown, so the one rule that governs it -- no SQL
backend means no service and the routes answer 503 -- could not be tested
without booting the whole application, and was held up by a single comment.
It is a pure function of already-built infrastructure, so that rule is now
an assertion. Feedback moved with it; doing this while adding schedule's
five objects costs one change instead of two.

Primary adapter
---------------
The router is protocol translation only. What is gone is the giveaway: cron
normalisation, `next_run_at` arithmetic, the re-arm rule and hand-written
ownership checks all now live in the aggregate. Domain errors map to status
codes through one table, so a new error surfaces as a 500 to be classified
rather than being swallowed by whichever `except` was nearest.

`spec_mapping` split in two (AWS's own layout puts the wire model under the
entrypoint that owns it, and a primary adapter must not import a secondary
one): `adapters/schedule/spec_column.py` for the JSON column,
`routers/schedule/spec_wire.py` for the HTTP body. The two shapes are equal
only by coincidence, so `test_schedule_spec_parity.py` runs every case
against both and compares their outputs and messages directly. Function
names differ per side so an import from the wrong one is visible.

Explicit responses
------------------
Routes returned the ORM row's `to_dict()`, leaking `user_id`,
`assistant_id`, `overlap_policy` and the two lease columns. The response
models publish exactly the field set the frontend declares -- asserted in
both directions, since an extra field is a leak and a missing one breaks a
client.

One wire detail was nearly changed by accident: Pydantic v2 serializes a UTC
datetime as `...Z`, while the legacy `coerce_iso` path emitted `+00:00`.
`UtcTimestamp` pins `isoformat()` so adopting a model does not silently
alter the wire format for every client parsing these.

Tests
-----
73 new cases: router behaviour driven through a real `ScheduleService` over
in-memory fakes (a mocked service would let the error mapping pass without
a domain error ever being raised), response shape, and the composition root.
Router mappings verified by mutation -- a wrong status code or a dropped
timezone fallback turns 9, 2 and 1 cases red respectively.

Two lifespan tests carried a `SimpleNamespace` config that predates this
change; `langgraph_runtime` now reads `config.scheduler`, so they were given
one. Tolerating the gap with `getattr` was rejected: `AppConfig.scheduler`
always exists, so the fallback would be unreachable in production and exist
purely to excuse an incomplete test double.

Full suite is back to its 24 pre-existing failures.
2026-07-28 19:09:22 +08:00
rayhpeng
0bae77ffc0 refactor(schedule): move the secondary adapters to app/adapters
Adopts the layout feedback landed in cb49dd67: secondary adapters live
under `app/adapters/<context>/`, one file per port, the file named after
the port in snake_case with the technology carried by the class name.
`app/infra/` is now gone entirely.

The rename also separates two meanings of "run" that shared one filename
space: `run_sql.py` held `ScheduledRun` (an execution record), while the
`run_launcher.py` still to come deals in Gateway runs.

  task_sql.py     -> scheduled_task_repository.py
  run_sql.py      -> scheduled_run_repository.py
  spec_mapping.py -> spec_mapping.py  (implements no port, so no rename)

Both SQL adapters now carry the `Secondary adapter (owned persistence)`
docstring marker -- this context owns both tables and writes its own
queries. `spec_mapping` says instead that it is a boundary mapping and
names its two callers. The package `__init__.py` is empty, so imports go
through the full path and a class's home file stays unambiguous.

Pure move: every top-level symbol was compared against its pre-move
original by AST dump (docstrings excluded), plus a separate per-class
method-name comparison, since a whole-class dump reports a docstring edit
and a renamed method the same way.

Also repoints three docstrings and one diagram that still named the
deleted `app/infra/` path.
2026-07-28 18:14:46 +08:00
rayhpeng
b56e939c4b docs(schedule): cover the ports and the application service
The design doc stopped at the model layer while ports.py and service.py
had already landed, so it described the inner ring as one third of what
is actually there and still called the other two "to be built".

Two new chapters. Ports covers the three contracts that matter more than
the signatures — another user's task reads as absent rather than
forbidden, only ThreadBusyError or LaunchFailedError may escape a
launch, and RunOutcome keeps the run runtime out of the domain — plus
the two deliberate absences (no Clock, no claimer identity) and the line
between single-threaded semantics, which the contract owns, and
atomicity, which it does not. The service chapter walks dispatch_task's
four exits as a diagram, explains why the global budget is not a
per-poll batch size, and records why the context change travels packaged
rather than behind a sentinel.

The rest follows: the migration map now shows the inner ring complete
and only adapters outstanding, the overview gains the discipline each of
the three layers is held to, the dev guide gains "add a use case" and a
warning against writing rules in the service, and the pitfalls and
glossary pick up what the new layers introduce.
2026-07-28 14:20:36 +08:00
rayhpeng
9d0b09558e feat(schedule): add the schedule domain model
First step of the scheduled-task context's hexagonal migration: the
inner-ring model, with zero infrastructure dependencies.

- ScheduleSpec / SchedulePolicy value objects
- ScheduledTask aggregate root
- ScheduledRun aggregate
- 9 domain errors, 5 enums

Rules are migrated verbatim from their current homes, each method's
docstring citing the source line: timezone/cron/next-run calculation
from deerflow/scheduler/schedules.py, context-mode and re-arm rules from
routers/scheduled_tasks.py, and the four status-derivation rules from
app/scheduler/service.py.

Two things previously held by convention are now enforced by
construction. Validation and normalization live in __post_init__, so
building a ScheduleSpec field-by-field cannot bypass them. The skipped
tombstone is a separate factory, so it can never be written as the
transient queued row that would collide with uq_scheduled_task_run_active.

The domain does not serialize itself: mapping the stored schedule_spec
JSON in and out stays with the adapter layer, keeping Mapping[str, Any]
out of every domain signature.

Production code still runs through app/scheduler/service.py -- this
commit adds no call sites and changes no behavior.
2026-07-28 11:16:16 +08:00