chrono-daemon 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- chrono_daemon-0.1.0/.github/dependabot.yml +7 -0
- chrono_daemon-0.1.0/.github/workflows/ci.yml +30 -0
- chrono_daemon-0.1.0/.github/workflows/publish.yml +39 -0
- chrono_daemon-0.1.0/.gitignore +9 -0
- chrono_daemon-0.1.0/.python-version +1 -0
- chrono_daemon-0.1.0/CLAUDE.md +63 -0
- chrono_daemon-0.1.0/LICENSE +21 -0
- chrono_daemon-0.1.0/Makefile +23 -0
- chrono_daemon-0.1.0/PKG-INFO +126 -0
- chrono_daemon-0.1.0/README.md +111 -0
- chrono_daemon-0.1.0/docs/README.md +30 -0
- chrono_daemon-0.1.0/docs/adr/0001-channel-is-the-sole-comm-primitive.md +74 -0
- chrono_daemon-0.1.0/docs/adr/0002-wall-and-sim-clocks-as-pluggable-protocol.md +74 -0
- chrono_daemon-0.1.0/docs/adr/0003-daemon-dual-api-class-and-decorator.md +61 -0
- chrono_daemon-0.1.0/docs/adr/0004-on-error-shutdown-by-default.md +70 -0
- chrono_daemon-0.1.0/docs/adr/0005-no-lifecycle-states-beyond-start-run-stop.md +58 -0
- chrono_daemon-0.1.0/docs/adr/0006-in-process-v0-transport-adapter-slot.md +70 -0
- chrono_daemon-0.1.0/docs/adr/0007-anyio-only-runtime-dependency.md +58 -0
- chrono_daemon-0.1.0/docs/adr/0008-sim-aware-logging-and-supervisor-diagnostics.md +81 -0
- chrono_daemon-0.1.0/docs/adr/0009-cooperative-stop-signaling.md +89 -0
- chrono_daemon-0.1.0/docs/adr/0010-channel-endpoints-are-single-owner.md +63 -0
- chrono_daemon-0.1.0/docs/adr/0011-zmq-transport-extra.md +64 -0
- chrono_daemon-0.1.0/docs/adr/README.md +32 -0
- chrono_daemon-0.1.0/docs/archive/proposal/01_PROPOSAL.md +131 -0
- chrono_daemon-0.1.0/docs/archive/proposal/02_COMPETITIVE_LANDSCAPE.md +159 -0
- chrono_daemon-0.1.0/docs/archive/proposal/03_REFERENCE_SURVEY/apollo_cyberrt.md +75 -0
- chrono_daemon-0.1.0/docs/archive/proposal/03_REFERENCE_SURVEY/dora_rs.md +87 -0
- chrono_daemon-0.1.0/docs/archive/proposal/03_REFERENCE_SURVEY/drake.md +80 -0
- chrono_daemon-0.1.0/docs/archive/proposal/03_REFERENCE_SURVEY/erdos.md +80 -0
- chrono_daemon-0.1.0/docs/archive/proposal/03_REFERENCE_SURVEY/holoscan.md +90 -0
- chrono_daemon-0.1.0/docs/archive/proposal/03_REFERENCE_SURVEY/horus.md +86 -0
- chrono_daemon-0.1.0/docs/archive/proposal/03_REFERENCE_SURVEY/orocos_rtt.md +77 -0
- chrono_daemon-0.1.0/docs/archive/proposal/03_REFERENCE_SURVEY/redisros.md +74 -0
- chrono_daemon-0.1.0/docs/archive/proposal/03_REFERENCE_SURVEY/simple_env.md +97 -0
- chrono_daemon-0.1.0/docs/concepts.md +151 -0
- chrono_daemon-0.1.0/docs/recipes.md +75 -0
- chrono_daemon-0.1.0/examples/README.md +66 -0
- chrono_daemon-0.1.0/examples/system_stack_mock.py +208 -0
- chrono_daemon-0.1.0/examples/system_stack_multi_session.py +134 -0
- chrono_daemon-0.1.0/pyproject.toml +57 -0
- chrono_daemon-0.1.0/src/chrono_daemon/__init__.py +53 -0
- chrono_daemon-0.1.0/src/chrono_daemon/_logging.py +32 -0
- chrono_daemon-0.1.0/src/chrono_daemon/_types.py +38 -0
- chrono_daemon-0.1.0/src/chrono_daemon/channel.py +212 -0
- chrono_daemon-0.1.0/src/chrono_daemon/clock.py +190 -0
- chrono_daemon-0.1.0/src/chrono_daemon/context.py +38 -0
- chrono_daemon-0.1.0/src/chrono_daemon/daemon.py +80 -0
- chrono_daemon-0.1.0/src/chrono_daemon/recipes/__init__.py +35 -0
- chrono_daemon-0.1.0/src/chrono_daemon/recipes/_flow.py +44 -0
- chrono_daemon-0.1.0/src/chrono_daemon/recipes/batcher.py +5 -0
- chrono_daemon-0.1.0/src/chrono_daemon/recipes/cooperative_every.py +5 -0
- chrono_daemon-0.1.0/src/chrono_daemon/recipes/coordination/__init__.py +7 -0
- chrono_daemon-0.1.0/src/chrono_daemon/recipes/coordination/batcher.py +166 -0
- chrono_daemon-0.1.0/src/chrono_daemon/recipes/coordination/cooperative_every.py +17 -0
- chrono_daemon-0.1.0/src/chrono_daemon/recipes/coordination/select.py +48 -0
- chrono_daemon-0.1.0/src/chrono_daemon/recipes/fanout.py +5 -0
- chrono_daemon-0.1.0/src/chrono_daemon/recipes/hosting/__init__.py +6 -0
- chrono_daemon-0.1.0/src/chrono_daemon/recipes/hosting/supervisor_host.py +107 -0
- chrono_daemon-0.1.0/src/chrono_daemon/recipes/hosting/sync_bridge.py +92 -0
- chrono_daemon-0.1.0/src/chrono_daemon/recipes/latest.py +5 -0
- chrono_daemon-0.1.0/src/chrono_daemon/recipes/load_balance.py +5 -0
- chrono_daemon-0.1.0/src/chrono_daemon/recipes/lossy.py +5 -0
- chrono_daemon-0.1.0/src/chrono_daemon/recipes/merge.py +5 -0
- chrono_daemon-0.1.0/src/chrono_daemon/recipes/routing/__init__.py +8 -0
- chrono_daemon-0.1.0/src/chrono_daemon/recipes/routing/fanout.py +29 -0
- chrono_daemon-0.1.0/src/chrono_daemon/recipes/routing/load_balance.py +49 -0
- chrono_daemon-0.1.0/src/chrono_daemon/recipes/routing/merge.py +53 -0
- chrono_daemon-0.1.0/src/chrono_daemon/recipes/routing/worker_pool.py +89 -0
- chrono_daemon-0.1.0/src/chrono_daemon/recipes/select.py +5 -0
- chrono_daemon-0.1.0/src/chrono_daemon/recipes/state/__init__.py +6 -0
- chrono_daemon-0.1.0/src/chrono_daemon/recipes/state/latest.py +24 -0
- chrono_daemon-0.1.0/src/chrono_daemon/recipes/state/lossy.py +102 -0
- chrono_daemon-0.1.0/src/chrono_daemon/recipes/supervisor_host.py +5 -0
- chrono_daemon-0.1.0/src/chrono_daemon/recipes/sync_bridge.py +5 -0
- chrono_daemon-0.1.0/src/chrono_daemon/recipes/worker_pool.py +5 -0
- chrono_daemon-0.1.0/src/chrono_daemon/supervisor.py +376 -0
- chrono_daemon-0.1.0/src/chrono_daemon/transports/__init__.py +5 -0
- chrono_daemon-0.1.0/src/chrono_daemon/transports/zmq.py +345 -0
- chrono_daemon-0.1.0/tests/__init__.py +0 -0
- chrono_daemon-0.1.0/tests/conftest.py +11 -0
- chrono_daemon-0.1.0/tests/test_channel.py +184 -0
- chrono_daemon-0.1.0/tests/test_clock.py +272 -0
- chrono_daemon-0.1.0/tests/test_daemon.py +69 -0
- chrono_daemon-0.1.0/tests/test_examples.py +64 -0
- chrono_daemon-0.1.0/tests/test_integration.py +78 -0
- chrono_daemon-0.1.0/tests/test_logging.py +49 -0
- chrono_daemon-0.1.0/tests/test_recipes.py +611 -0
- chrono_daemon-0.1.0/tests/test_recipes_lossy.py +128 -0
- chrono_daemon-0.1.0/tests/test_supervisor.py +614 -0
- chrono_daemon-0.1.0/tests/test_zmq_transport.py +243 -0
- chrono_daemon-0.1.0/uv.lock +435 -0
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
pull_request:
|
|
5
|
+
push:
|
|
6
|
+
branches:
|
|
7
|
+
- main
|
|
8
|
+
|
|
9
|
+
jobs:
|
|
10
|
+
check:
|
|
11
|
+
runs-on: ubuntu-latest
|
|
12
|
+
steps:
|
|
13
|
+
- uses: actions/checkout@v7
|
|
14
|
+
|
|
15
|
+
- name: Set up uv
|
|
16
|
+
uses: astral-sh/setup-uv@v7
|
|
17
|
+
with:
|
|
18
|
+
enable-cache: true
|
|
19
|
+
|
|
20
|
+
- name: Install Python
|
|
21
|
+
run: uv python install 3.11
|
|
22
|
+
|
|
23
|
+
- name: Install dependencies
|
|
24
|
+
run: uv sync --dev --extra zmq --locked
|
|
25
|
+
|
|
26
|
+
- name: Check
|
|
27
|
+
run: make check
|
|
28
|
+
|
|
29
|
+
- name: Test
|
|
30
|
+
run: make test
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
name: Publish
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
tags:
|
|
6
|
+
- "v*"
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
publish:
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
environment:
|
|
12
|
+
name: pypi
|
|
13
|
+
permissions:
|
|
14
|
+
contents: write
|
|
15
|
+
id-token: write
|
|
16
|
+
steps:
|
|
17
|
+
- name: Checkout
|
|
18
|
+
uses: actions/checkout@v7
|
|
19
|
+
with:
|
|
20
|
+
fetch-depth: 0
|
|
21
|
+
|
|
22
|
+
- name: Set up uv
|
|
23
|
+
uses: astral-sh/setup-uv@v7
|
|
24
|
+
with:
|
|
25
|
+
enable-cache: true
|
|
26
|
+
|
|
27
|
+
- name: Install Python
|
|
28
|
+
run: uv python install 3.11
|
|
29
|
+
|
|
30
|
+
- name: Build
|
|
31
|
+
run: uv build
|
|
32
|
+
|
|
33
|
+
- name: Publish to PyPI
|
|
34
|
+
run: uv publish
|
|
35
|
+
|
|
36
|
+
- name: Create GitHub release
|
|
37
|
+
env:
|
|
38
|
+
GH_TOKEN: ${{ github.token }}
|
|
39
|
+
run: gh release create "$GITHUB_REF_NAME" dist/* --title "$GITHUB_REF_NAME" --generate-notes
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
3.11
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# CLAUDE.md — chrono-daemon
|
|
2
|
+
|
|
3
|
+
Tiny anyio-based concurrency library. Four primitives: `Channel`, `Clock`
|
|
4
|
+
(Wall/Sim), `Daemon`, `Supervisor`. See `docs/concepts.md` for the user-facing
|
|
5
|
+
explanation and `docs/adr/` for why each piece looks the way it does.
|
|
6
|
+
|
|
7
|
+
This file is the editing-rule sheet for AI agents working on this project.
|
|
8
|
+
The *why* of any decision lives in an ADR, not here.
|
|
9
|
+
|
|
10
|
+
## Commands
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
uv sync --dev
|
|
14
|
+
make check # ruff lint + format check + pyrefly
|
|
15
|
+
make test # pytest on asyncio + trio
|
|
16
|
+
make all # format + check + test
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Editing rules
|
|
20
|
+
|
|
21
|
+
Each of these has an ADR. Don't violate them without writing a superseding
|
|
22
|
+
ADR first.
|
|
23
|
+
|
|
24
|
+
- **`anyio` is the only direct runtime dependency.** No `msgspec`, no
|
|
25
|
+
`structlog`, no `pydantic`. (ADR 0007.)
|
|
26
|
+
- **`Channel` is the sole communication primitive on the core surface.**
|
|
27
|
+
Don't add `Topic`, pub/sub broadcast, services, RPC, or a parameter
|
|
28
|
+
system to `chrono_daemon.*`. Fanout lives at `chrono_daemon.recipes.fanout.tee` —
|
|
29
|
+
importable, but under the weaker-stability recipes namespace. (ADR 0001.)
|
|
30
|
+
- **Daemons must reach for `ctx.clock.sleep(...)` or
|
|
31
|
+
`ctx.clock.wait_until(...)`** — never `anyio.sleep` directly.
|
|
32
|
+
Library-internal code must obey this so `SimClock` can intercept.
|
|
33
|
+
(ADR 0002.)
|
|
34
|
+
- **`Daemon` has exactly three hooks**: `on_start`, `run`, `on_stop`. No
|
|
35
|
+
pause/resume, no lifecycle state machine. (ADR 0005.)
|
|
36
|
+
- **`Channel` protocol signatures stay transport-agnostic.** Don't bake
|
|
37
|
+
in-process Python-identity or sync assumptions; future transport adapters
|
|
38
|
+
share the same surface. (ADR 0006.)
|
|
39
|
+
|
|
40
|
+
## Test discipline
|
|
41
|
+
|
|
42
|
+
- Every async test is parametrized over `["asyncio", "trio"]` via
|
|
43
|
+
`tests/conftest.py`. New tests must pass on both.
|
|
44
|
+
- Canary: `tests/test_clock.py::test_simclock_burst_step_deterministic_order`
|
|
45
|
+
— if this fails on either backend, the rest of the system is unreliable.
|
|
46
|
+
- `tests/test_integration.py` is the "would chrono-daemon replace simple_env"
|
|
47
|
+
check. Don't weaken it.
|
|
48
|
+
- `tests/test_examples.py` pins the demos in `examples/`. Don't bypass it
|
|
49
|
+
by skipping; fix the demo instead.
|
|
50
|
+
|
|
51
|
+
## What lives where
|
|
52
|
+
|
|
53
|
+
- `src/chrono_daemon/` — the seven core modules. Each one is short and has one job.
|
|
54
|
+
- `docs/concepts.md` — user-facing explanation of the four primitives.
|
|
55
|
+
- `docs/adr/` — frozen-in-time decision records. Immutable once accepted;
|
|
56
|
+
add a new ADR to revise.
|
|
57
|
+
- `src/chrono_daemon/recipes/` — patterns kept off the core surface but
|
|
58
|
+
importable under `chrono_daemon.recipes.*`. Weaker stability guarantees than
|
|
59
|
+
the core (see `src/chrono_daemon/recipes/__init__.py`).
|
|
60
|
+
- `docs/recipes.md` — user-facing index for the above.
|
|
61
|
+
- `examples/` — end-to-end demos exercised by CI (`tests/test_examples.py`).
|
|
62
|
+
- `CLAUDE.md` (this file) — editing rules + commands only.
|
|
63
|
+
- Top-level `README.md` — the 5-minute pitch.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Suhwan Choi
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
.PHONY: check test lint format install all
|
|
2
|
+
|
|
3
|
+
# Source paths that participate in lint/format/type-check.
|
|
4
|
+
SRC_PATHS = src/ tests/ examples/
|
|
5
|
+
|
|
6
|
+
check: lint
|
|
7
|
+
uv run pyrefly check
|
|
8
|
+
|
|
9
|
+
lint:
|
|
10
|
+
uv run ruff check $(SRC_PATHS)
|
|
11
|
+
uv run ruff format --check $(SRC_PATHS)
|
|
12
|
+
|
|
13
|
+
format:
|
|
14
|
+
uv run ruff format $(SRC_PATHS)
|
|
15
|
+
uv run ruff check --fix $(SRC_PATHS)
|
|
16
|
+
|
|
17
|
+
test:
|
|
18
|
+
uv run pytest tests/ -v
|
|
19
|
+
|
|
20
|
+
install:
|
|
21
|
+
uv sync --dev --extra zmq
|
|
22
|
+
|
|
23
|
+
all: format check test
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: chrono-daemon
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Tiny anyio-based concurrency primitives: Channel, Clock (wall + sim), Daemon, Supervisor.
|
|
5
|
+
Project-URL: Repository, https://github.com/MilkClouds/chrono-daemon
|
|
6
|
+
Author: Suhwan Choi
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Requires-Python: >=3.11
|
|
10
|
+
Requires-Dist: anyio>=4
|
|
11
|
+
Provides-Extra: zmq
|
|
12
|
+
Requires-Dist: msgspec>=0.18; extra == 'zmq'
|
|
13
|
+
Requires-Dist: pyzmq>=26; extra == 'zmq'
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
|
|
16
|
+
# chrono-daemon
|
|
17
|
+
|
|
18
|
+
[](https://github.com/MilkClouds/chrono-daemon/actions/workflows/ci.yml)
|
|
19
|
+
[](https://pypi.org/project/chrono-daemon/)
|
|
20
|
+

|
|
21
|
+

|
|
22
|
+
|
|
23
|
+
chrono-daemon is a small Python library for long-running async components whose time
|
|
24
|
+
can be replayed deterministically. It wraps
|
|
25
|
+
[anyio](https://anyio.readthedocs.io/) with four primitives:
|
|
26
|
+
|
|
27
|
+
- `Channel[T]`: typed single-producer / single-consumer queues.
|
|
28
|
+
- `Clock`: real time with `WallClock`, virtual time with `SimClock`.
|
|
29
|
+
- `Daemon`: a lifecycle unit with `on_start`, `run`, and `on_stop`.
|
|
30
|
+
- `Supervisor`: a structured-concurrency root for hosting daemons.
|
|
31
|
+
|
|
32
|
+
The practical payoff is simple: production daemons sleep on `ctx.clock`; tests
|
|
33
|
+
swap in `SimClock` and advance seconds of work without waiting for wall time.
|
|
34
|
+
|
|
35
|
+
## Quick Example
|
|
36
|
+
|
|
37
|
+
```python
|
|
38
|
+
from chrono_daemon import Channel, Context, SimClock, Supervisor, daemon, open_channel
|
|
39
|
+
|
|
40
|
+
@daemon
|
|
41
|
+
async def producer(ctx: Context, out: Channel[int]) -> None:
|
|
42
|
+
for i in range(10):
|
|
43
|
+
await ctx.clock.sleep(0.1)
|
|
44
|
+
await out.send.send(i)
|
|
45
|
+
await out.send.aclose()
|
|
46
|
+
|
|
47
|
+
@daemon
|
|
48
|
+
async def consumer(ctx: Context, src: Channel[int]) -> None:
|
|
49
|
+
async for item in src.recv:
|
|
50
|
+
ctx.logger.info("got %d", item)
|
|
51
|
+
|
|
52
|
+
async def main() -> None:
|
|
53
|
+
ch: Channel[int] = open_channel(maxsize=4)
|
|
54
|
+
clock = SimClock()
|
|
55
|
+
async with Supervisor(clock=clock) as sup:
|
|
56
|
+
sup.add(producer(ch))
|
|
57
|
+
sup.add(consumer(ch))
|
|
58
|
+
await clock.advance(1.5) # replay 1.5 s of work immediately
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Why Use It
|
|
62
|
+
|
|
63
|
+
- Time-dependent async code is testable without sleeps, polling, or fake
|
|
64
|
+
task schedulers.
|
|
65
|
+
- Wiring is explicit. Every edge is a named SPSC channel, so ownership and
|
|
66
|
+
backpressure stay visible.
|
|
67
|
+
- Lifecycle behavior is structured. Daemons get startup, shutdown, logging,
|
|
68
|
+
cancellation, and error policy in one place.
|
|
69
|
+
- The core runtime is small: pure Python, `anyio` underneath, and no
|
|
70
|
+
runtime dependency beyond `anyio`.
|
|
71
|
+
|
|
72
|
+
## Core API
|
|
73
|
+
|
|
74
|
+
| | What it is |
|
|
75
|
+
|---|---|
|
|
76
|
+
| **`Channel[T]`** | typed bounded SPSC queue, the only inter-daemon communication primitive |
|
|
77
|
+
| **`Clock`** | `WallClock` (real time) or `SimClock` (deterministic, burst `advance(dt)` / `advance_to(t)`) |
|
|
78
|
+
| **`Daemon`** | long-running async unit; `on_start` / `run` / `on_stop` hooks. Use a subclass or the `@daemon` decorator |
|
|
79
|
+
| **`Supervisor`** | `async with Supervisor(...)` structured-concurrency root; hosts daemons, dispatches errors (`shutdown` / `restart` / `ignore`) |
|
|
80
|
+
|
|
81
|
+
See [`docs/concepts.md`](docs/concepts.md) for details.
|
|
82
|
+
|
|
83
|
+
## Scope
|
|
84
|
+
|
|
85
|
+
chrono-daemon is for in-process async systems where explicit wiring and deterministic
|
|
86
|
+
time matter: evaluation loops, agent pipelines, robotics-style control mocks,
|
|
87
|
+
and testable service internals.
|
|
88
|
+
|
|
89
|
+
It is intentionally not a topic broker, service registry, RPC framework,
|
|
90
|
+
parameter server, CLI launcher, network runtime, or continuous-time numerical
|
|
91
|
+
simulator.
|
|
92
|
+
|
|
93
|
+
## Install / dev
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
uv sync --dev --extra zmq
|
|
97
|
+
make check # ruff + pyrefly
|
|
98
|
+
make test # pytest on asyncio + trio
|
|
99
|
+
make all # format + check + test
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Python 3.11+. Only runtime dependency is `anyio>=4`; `--extra zmq` installs
|
|
103
|
+
the optional transport dependencies needed for the full test suite.
|
|
104
|
+
Remote ZMQ channels are optional:
|
|
105
|
+
|
|
106
|
+
```bash
|
|
107
|
+
pip install "chrono-daemon[zmq]"
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
## Where to look next
|
|
111
|
+
|
|
112
|
+
- [`docs/concepts.md`](docs/concepts.md): what each primitive is and the
|
|
113
|
+
invariants the test suite pins.
|
|
114
|
+
- [`docs/adr/`](docs/adr/): why each decision looks the way it does
|
|
115
|
+
(Topic-less, on-error-shutdown by default, anyio-only).
|
|
116
|
+
- [`docs/recipes.md`](docs/recipes.md): patterns kept off the core
|
|
117
|
+
surface but importable under `chrono_daemon.recipes.*`, grouped as routing,
|
|
118
|
+
coordination, state, buffering, and hosting helpers under
|
|
119
|
+
`src/chrono_daemon/recipes/`.
|
|
120
|
+
- [`examples/`](examples/): runnable System 2/1/0 demos and notes.
|
|
121
|
+
|
|
122
|
+
## Status
|
|
123
|
+
|
|
124
|
+
Early. In-process channels are the default and keep zero runtime dependencies
|
|
125
|
+
beyond `anyio`. ZMQ remote channels are available as an optional extra
|
|
126
|
+
(`chrono-daemon[zmq]`) for asyncio-backed deployments.
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# chrono-daemon
|
|
2
|
+
|
|
3
|
+
[](https://github.com/MilkClouds/chrono-daemon/actions/workflows/ci.yml)
|
|
4
|
+
[](https://pypi.org/project/chrono-daemon/)
|
|
5
|
+

|
|
6
|
+

|
|
7
|
+
|
|
8
|
+
chrono-daemon is a small Python library for long-running async components whose time
|
|
9
|
+
can be replayed deterministically. It wraps
|
|
10
|
+
[anyio](https://anyio.readthedocs.io/) with four primitives:
|
|
11
|
+
|
|
12
|
+
- `Channel[T]`: typed single-producer / single-consumer queues.
|
|
13
|
+
- `Clock`: real time with `WallClock`, virtual time with `SimClock`.
|
|
14
|
+
- `Daemon`: a lifecycle unit with `on_start`, `run`, and `on_stop`.
|
|
15
|
+
- `Supervisor`: a structured-concurrency root for hosting daemons.
|
|
16
|
+
|
|
17
|
+
The practical payoff is simple: production daemons sleep on `ctx.clock`; tests
|
|
18
|
+
swap in `SimClock` and advance seconds of work without waiting for wall time.
|
|
19
|
+
|
|
20
|
+
## Quick Example
|
|
21
|
+
|
|
22
|
+
```python
|
|
23
|
+
from chrono_daemon import Channel, Context, SimClock, Supervisor, daemon, open_channel
|
|
24
|
+
|
|
25
|
+
@daemon
|
|
26
|
+
async def producer(ctx: Context, out: Channel[int]) -> None:
|
|
27
|
+
for i in range(10):
|
|
28
|
+
await ctx.clock.sleep(0.1)
|
|
29
|
+
await out.send.send(i)
|
|
30
|
+
await out.send.aclose()
|
|
31
|
+
|
|
32
|
+
@daemon
|
|
33
|
+
async def consumer(ctx: Context, src: Channel[int]) -> None:
|
|
34
|
+
async for item in src.recv:
|
|
35
|
+
ctx.logger.info("got %d", item)
|
|
36
|
+
|
|
37
|
+
async def main() -> None:
|
|
38
|
+
ch: Channel[int] = open_channel(maxsize=4)
|
|
39
|
+
clock = SimClock()
|
|
40
|
+
async with Supervisor(clock=clock) as sup:
|
|
41
|
+
sup.add(producer(ch))
|
|
42
|
+
sup.add(consumer(ch))
|
|
43
|
+
await clock.advance(1.5) # replay 1.5 s of work immediately
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Why Use It
|
|
47
|
+
|
|
48
|
+
- Time-dependent async code is testable without sleeps, polling, or fake
|
|
49
|
+
task schedulers.
|
|
50
|
+
- Wiring is explicit. Every edge is a named SPSC channel, so ownership and
|
|
51
|
+
backpressure stay visible.
|
|
52
|
+
- Lifecycle behavior is structured. Daemons get startup, shutdown, logging,
|
|
53
|
+
cancellation, and error policy in one place.
|
|
54
|
+
- The core runtime is small: pure Python, `anyio` underneath, and no
|
|
55
|
+
runtime dependency beyond `anyio`.
|
|
56
|
+
|
|
57
|
+
## Core API
|
|
58
|
+
|
|
59
|
+
| | What it is |
|
|
60
|
+
|---|---|
|
|
61
|
+
| **`Channel[T]`** | typed bounded SPSC queue, the only inter-daemon communication primitive |
|
|
62
|
+
| **`Clock`** | `WallClock` (real time) or `SimClock` (deterministic, burst `advance(dt)` / `advance_to(t)`) |
|
|
63
|
+
| **`Daemon`** | long-running async unit; `on_start` / `run` / `on_stop` hooks. Use a subclass or the `@daemon` decorator |
|
|
64
|
+
| **`Supervisor`** | `async with Supervisor(...)` structured-concurrency root; hosts daemons, dispatches errors (`shutdown` / `restart` / `ignore`) |
|
|
65
|
+
|
|
66
|
+
See [`docs/concepts.md`](docs/concepts.md) for details.
|
|
67
|
+
|
|
68
|
+
## Scope
|
|
69
|
+
|
|
70
|
+
chrono-daemon is for in-process async systems where explicit wiring and deterministic
|
|
71
|
+
time matter: evaluation loops, agent pipelines, robotics-style control mocks,
|
|
72
|
+
and testable service internals.
|
|
73
|
+
|
|
74
|
+
It is intentionally not a topic broker, service registry, RPC framework,
|
|
75
|
+
parameter server, CLI launcher, network runtime, or continuous-time numerical
|
|
76
|
+
simulator.
|
|
77
|
+
|
|
78
|
+
## Install / dev
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
uv sync --dev --extra zmq
|
|
82
|
+
make check # ruff + pyrefly
|
|
83
|
+
make test # pytest on asyncio + trio
|
|
84
|
+
make all # format + check + test
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Python 3.11+. Only runtime dependency is `anyio>=4`; `--extra zmq` installs
|
|
88
|
+
the optional transport dependencies needed for the full test suite.
|
|
89
|
+
Remote ZMQ channels are optional:
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
pip install "chrono-daemon[zmq]"
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
## Where to look next
|
|
96
|
+
|
|
97
|
+
- [`docs/concepts.md`](docs/concepts.md): what each primitive is and the
|
|
98
|
+
invariants the test suite pins.
|
|
99
|
+
- [`docs/adr/`](docs/adr/): why each decision looks the way it does
|
|
100
|
+
(Topic-less, on-error-shutdown by default, anyio-only).
|
|
101
|
+
- [`docs/recipes.md`](docs/recipes.md): patterns kept off the core
|
|
102
|
+
surface but importable under `chrono_daemon.recipes.*`, grouped as routing,
|
|
103
|
+
coordination, state, buffering, and hosting helpers under
|
|
104
|
+
`src/chrono_daemon/recipes/`.
|
|
105
|
+
- [`examples/`](examples/): runnable System 2/1/0 demos and notes.
|
|
106
|
+
|
|
107
|
+
## Status
|
|
108
|
+
|
|
109
|
+
Early. In-process channels are the default and keep zero runtime dependencies
|
|
110
|
+
beyond `anyio`. ZMQ remote channels are available as an optional extra
|
|
111
|
+
(`chrono-daemon[zmq]`) for asyncio-backed deployments.
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# chrono-daemon docs
|
|
2
|
+
|
|
3
|
+
This directory holds chrono-daemon's design and usage docs.
|
|
4
|
+
|
|
5
|
+
## Layout
|
|
6
|
+
|
|
7
|
+
- `concepts.md`: the four primitives (`Channel`, `Clock`, `Daemon`,
|
|
8
|
+
`Supervisor`), how they compose, and which invariants hold.
|
|
9
|
+
- `adr/`: Architecture Decision Records. Each ADR is a frozen-in-time
|
|
10
|
+
statement of a load-bearing decision. New ADRs are added rather than
|
|
11
|
+
editing old ones; superseded ADRs link forward.
|
|
12
|
+
- `recipes.md`: the user-facing index for `chrono_daemon.recipes`, grouped as
|
|
13
|
+
routing, coordination, state, buffering, and hosting helpers. Recipes are
|
|
14
|
+
importable but carry weaker stability guarantees than the core surface.
|
|
15
|
+
- Optional transport adapters live in `src/chrono_daemon/transports/`. The first
|
|
16
|
+
one is `chrono_daemon.transports.zmq`, covered by `concepts.md` and ADR 0011.
|
|
17
|
+
- `archive/`: long-form proposal notes preserved for design history, not as
|
|
18
|
+
the primary user documentation.
|
|
19
|
+
|
|
20
|
+
## When to add what
|
|
21
|
+
|
|
22
|
+
- A user-facing API change → update `concepts.md` and (if a tradeoff was
|
|
23
|
+
involved) a new ADR.
|
|
24
|
+
- A "let's not do X" decision → ADR.
|
|
25
|
+
- "How do I X" question that recurs → recipe.
|
|
26
|
+
|
|
27
|
+
## When not to write docs here
|
|
28
|
+
|
|
29
|
+
- Editing rules for agents belong outside the user-facing docs.
|
|
30
|
+
- Per-PR justifications belong in the PR description.
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# ADR 0001: Channel is the sole communication primitive
|
|
2
|
+
|
|
3
|
+
Status: Accepted (2026-05-18); superseded in part by ADR 0010
|
|
4
|
+
|
|
5
|
+
Note: this ADR chose `Channel` as the only core communication primitive.
|
|
6
|
+
ADR 0010 later changed the endpoint ownership rule from MPMC sharing to
|
|
7
|
+
single-owner endpoints.
|
|
8
|
+
|
|
9
|
+
## Context
|
|
10
|
+
|
|
11
|
+
ROS-style frameworks expose pub/sub `Topic`, point-to-point queues, services,
|
|
12
|
+
parameters, and broadcast events as distinct primitives. Each adds API
|
|
13
|
+
surface, QoS knobs, and edge cases, most notably the *slow-consumer policy*
|
|
14
|
+
problem: when a topic has N subscribers and one is slow, the publisher has to
|
|
15
|
+
choose between blocking everyone, dropping for everyone, or dropping
|
|
16
|
+
per-subscriber, and that choice has to live somewhere in the configuration
|
|
17
|
+
matrix. ROS2's QoS profile sprawl is the load-bearing evidence that this is
|
|
18
|
+
not a small cost.
|
|
19
|
+
|
|
20
|
+
The target workloads for chrono-daemon, including robotics control loops, ML eval
|
|
21
|
+
harnesses, and agent orchestration, are dominated by **statically wired** dataflow: the
|
|
22
|
+
producer and the consumer of any given message are known at supervisor
|
|
23
|
+
construction. Where dynamic subscription happens at all (log taps,
|
|
24
|
+
visualizers), it is rare enough that handling it as an explicit, in-code
|
|
25
|
+
fanout rather than a runtime-discovered subscription is acceptable.
|
|
26
|
+
|
|
27
|
+
A 1:N broadcast can always be expressed as N 1:1 channels plus a fanout
|
|
28
|
+
daemon. The reverse direction, taking a Topic and recovering "exactly one
|
|
29
|
+
consumer gets each message", requires fighting the primitive.
|
|
30
|
+
|
|
31
|
+
## Decision
|
|
32
|
+
|
|
33
|
+
`Channel[T]` is the sole communication primitive in chrono_daemon. It is a typed,
|
|
34
|
+
bounded queue with two endpoints, `send` and `recv`. As originally accepted,
|
|
35
|
+
this ADR allowed multiple producers and consumers to share endpoints. ADR 0010
|
|
36
|
+
supersedes that part: core channels now use single-owner endpoints and raise
|
|
37
|
+
`ChannelInUse` on concurrent blocking endpoint sharing. Closing the send side
|
|
38
|
+
propagates `EndOfStream` to receivers after the buffer drains.
|
|
39
|
+
|
|
40
|
+
There is no `Topic`, no broadcast primitive, no service/RPC primitive, no
|
|
41
|
+
parameter system, no discovery on the core surface. Users that need 1:N
|
|
42
|
+
broadcast import `chrono_daemon.recipes.fanout.tee`. Recipes live under a
|
|
43
|
+
sibling namespace (`chrono_daemon.recipes`) with weaker stability guarantees
|
|
44
|
+
than the core (see `docs/recipes.md`), so they're available without
|
|
45
|
+
copy-paste but are signaled as best-effort rather than part of the
|
|
46
|
+
load-bearing API.
|
|
47
|
+
|
|
48
|
+
## Consequences
|
|
49
|
+
|
|
50
|
+
+ One API to learn; static type checking on a single `Channel[T]`.
|
|
51
|
+
+ Backpressure has one meaning: `send` blocks while the receiver is slow.
|
|
52
|
+
No slow-consumer policy matrix, no QoS profile to negotiate.
|
|
53
|
+
+ Replay determinism is straightforward. There is no multi-subscriber
|
|
54
|
+
ordering ambiguity to specify.
|
|
55
|
+
+ Wiring is visible in code (every channel is a named local variable), so a
|
|
56
|
+
reader can statically trace dataflow.
|
|
57
|
+
- 1:N broadcast costs the user a one-line `from chrono_daemon.recipes.fanout
|
|
58
|
+
import tee` plus the wiring. For workloads with pervasive broadcast
|
|
59
|
+
(e.g. a single `on_tick` driving multiple inference loops), the wiring
|
|
60
|
+
is still explicit, just import-able.
|
|
61
|
+
- Migration from ROS code that relies on runtime subscriber discovery is
|
|
62
|
+
not a mechanical port. Every dynamic subscription has to become an
|
|
63
|
+
explicit channel handed in at construction.
|
|
64
|
+
- Lifecycle-event streams that ROS users would express as a topic
|
|
65
|
+
("session created/destroyed broadcasts") have to be modeled as either
|
|
66
|
+
N pre-allocated channels or a single channel with the `tee` recipe.
|
|
67
|
+
|
|
68
|
+
## Related
|
|
69
|
+
|
|
70
|
+
- ADR 0006 (transport adapter slot): `Channel` is a Protocol so multi-process
|
|
71
|
+
and network backends can be added later without reopening this decision.
|
|
72
|
+
- `chrono_daemon.recipes.fanout.tee`: the canonical 1:N broadcast helper.
|
|
73
|
+
- `chrono_daemon.recipes.batcher`: shows how request/response is built from
|
|
74
|
+
channels alone (no service primitive needed).
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# ADR 0002: Wall and Sim clocks as a pluggable Clock protocol
|
|
2
|
+
|
|
3
|
+
Status: Accepted (2026-05-18)
|
|
4
|
+
|
|
5
|
+
## Context
|
|
6
|
+
|
|
7
|
+
The library's distinguishing capability vs. dora-rs, HORUS, Apollo CyberRT,
|
|
8
|
+
and any "ROS for Python" attempt is **deterministic burst-step replay**: a
|
|
9
|
+
driver task should be able to advance an entire scenario's worth of time in
|
|
10
|
+
a single call (`await clock.advance(10.0)`) and observe daemons fire their
|
|
11
|
+
sleeps and timers in exactly the order and at exactly the virtual instants
|
|
12
|
+
they would have at wall-clock speed.
|
|
13
|
+
|
|
14
|
+
`anyio` provides `current_time()` and `sleep()` as monotonic, backend-agnostic
|
|
15
|
+
primitives, but does not provide a controllable virtual clock. trio's
|
|
16
|
+
`MockClock` exists but is trio-specific. asyncio has nothing comparable.
|
|
17
|
+
|
|
18
|
+
If daemon code is free to call `anyio.sleep` directly, no virtual clock can
|
|
19
|
+
intercept it. Determinism would then depend on every daemon author
|
|
20
|
+
remembering to use a `Clock` object. a passive convention is not strong
|
|
21
|
+
enough.
|
|
22
|
+
|
|
23
|
+
## Decision
|
|
24
|
+
|
|
25
|
+
Time is reached through a `Clock` protocol with four methods: `now() -> float`,
|
|
26
|
+
`async sleep(seconds)`, `async wait_until(deadline)`, and
|
|
27
|
+
`every(period) -> AsyncIterator[float]`. Two
|
|
28
|
+
implementations are provided:
|
|
29
|
+
|
|
30
|
+
- `WallClock` uses `time.monotonic()` for `now()` and delegates sleeps to
|
|
31
|
+
`anyio.sleep`.
|
|
32
|
+
- `SimClock` keeps an internal heap of `(deadline, sequence, anyio.Event)`
|
|
33
|
+
tuples. `sleep` enqueues; `advance(dt)` walks the heap in deadline order,
|
|
34
|
+
setting each event and yielding to let the woken task register a
|
|
35
|
+
follow-up sleep before the next iteration looks at the heap.
|
|
36
|
+
|
|
37
|
+
`Context` exposes the active clock as `ctx.clock`. The CLAUDE.md editing rule
|
|
38
|
+
"daemons must use `ctx.clock.sleep`, never `anyio.sleep`" is the convention
|
|
39
|
+
that makes the interception complete; it is enforced socially, not by code.
|
|
40
|
+
|
|
41
|
+
`SimClock.advance_to` performs a configurable `settle_rounds` checkpoint
|
|
42
|
+
budget between successive wakes. The default is 8. This is empirically
|
|
43
|
+
required on the trio backend, which does not necessarily resume a woken
|
|
44
|
+
task on a single fairness round. anyio does not expose a backend-agnostic
|
|
45
|
+
"run until all tasks are idle" primitive, so the budget is explicit rather
|
|
46
|
+
than inferred.
|
|
47
|
+
|
|
48
|
+
## Consequences
|
|
49
|
+
|
|
50
|
+
+ Deterministic burst-step replay is a first-class capability, not a
|
|
51
|
+
research-mode toggle.
|
|
52
|
+
+ Production daemons swap `SimClock` for `WallClock` with no code change.
|
|
53
|
+
+ Restart backoff, periodic timers, and any "wait until X seconds elapsed"
|
|
54
|
+
pattern all become deterministic under simulation, because they all go
|
|
55
|
+
through `Clock.sleep`.
|
|
56
|
+
- The "always use `ctx.clock`" rule is a discipline, not a constraint
|
|
57
|
+
enforced by the type system. A daemon that imports `anyio.sleep` directly
|
|
58
|
+
silently breaks `SimClock`. The test suite has integration tests that would
|
|
59
|
+
detect it for in-tree code; user code is on the honor system.
|
|
60
|
+
- `settle_rounds` is still a scheduler-settlement budget, not a proof of
|
|
61
|
+
global quiescence. Raising or lowering it changes how much downstream task
|
|
62
|
+
chaining one `advance_to()` call will absorb before finalizing.
|
|
63
|
+
- Backend-agnostic deterministic primitives are an ongoing area of churn
|
|
64
|
+
in the anyio ecosystem; if a future anyio version ships an equivalent,
|
|
65
|
+
`SimClock` will likely be re-implemented on top of it rather than
|
|
66
|
+
remaining a hand-rolled heap.
|
|
67
|
+
|
|
68
|
+
## Related
|
|
69
|
+
|
|
70
|
+
- ADR 0008 (sim-aware logging) extends this by making `Context.logger`
|
|
71
|
+
carry sim-time on records, so burst-replay log output is interpretable.
|
|
72
|
+
- `tests/test_clock.py::test_simclock_burst_step_deterministic_order` is
|
|
73
|
+
the canary: if this test fails on either backend, the rest of the system
|
|
74
|
+
is unreliable.
|