acty-core 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.
Files changed (114) hide show
  1. acty_core-0.1.0/.gitignore +8 -0
  2. acty_core-0.1.0/LICENSE +21 -0
  3. acty_core-0.1.0/PKG-INFO +98 -0
  4. acty_core-0.1.0/README.md +67 -0
  5. acty_core-0.1.0/examples/attach_tui_later.md +19 -0
  6. acty_core-0.1.0/examples/group_headless.py +256 -0
  7. acty_core-0.1.0/examples/groups_with_cache_registry.py +168 -0
  8. acty_core-0.1.0/examples/scheduler_headless.py +174 -0
  9. acty_core-0.1.0/examples/scheduler_jsonl.py +186 -0
  10. acty_core-0.1.0/pyproject.toml +46 -0
  11. acty_core-0.1.0/pytest.ini +4 -0
  12. acty_core-0.1.0/src/acty_core/__init__.py +56 -0
  13. acty_core-0.1.0/src/acty_core/cache/__init__.py +19 -0
  14. acty_core-0.1.0/src/acty_core/cache/handle.py +31 -0
  15. acty_core-0.1.0/src/acty_core/cache/provider.py +19 -0
  16. acty_core-0.1.0/src/acty_core/cache/registry.py +308 -0
  17. acty_core-0.1.0/src/acty_core/cache/storage.py +23 -0
  18. acty_core-0.1.0/src/acty_core/cache/storage_memory.py +59 -0
  19. acty_core-0.1.0/src/acty_core/cache/storage_sqlite.py +186 -0
  20. acty_core-0.1.0/src/acty_core/context.py +31 -0
  21. acty_core-0.1.0/src/acty_core/core/__init__.py +17 -0
  22. acty_core-0.1.0/src/acty_core/core/errors.py +17 -0
  23. acty_core-0.1.0/src/acty_core/core/types.py +53 -0
  24. acty_core-0.1.0/src/acty_core/events/__init__.py +30 -0
  25. acty_core-0.1.0/src/acty_core/events/bus.py +210 -0
  26. acty_core-0.1.0/src/acty_core/events/collector.py +620 -0
  27. acty_core-0.1.0/src/acty_core/events/payloads.py +212 -0
  28. acty_core-0.1.0/src/acty_core/events/sinks/base.py +11 -0
  29. acty_core-0.1.0/src/acty_core/events/sinks/jsonl.py +27 -0
  30. acty_core-0.1.0/src/acty_core/events/sinks/otel.py +204 -0
  31. acty_core-0.1.0/src/acty_core/events/sinks/prometheus.py +220 -0
  32. acty_core-0.1.0/src/acty_core/events/sinks/queue.py +19 -0
  33. acty_core-0.1.0/src/acty_core/events/snapshot.py +56 -0
  34. acty_core-0.1.0/src/acty_core/events/types.py +40 -0
  35. acty_core-0.1.0/src/acty_core/executors.py +1370 -0
  36. acty_core-0.1.0/src/acty_core/lifecycle/__init__.py +49 -0
  37. acty_core-0.1.0/src/acty_core/lifecycle/admission.py +592 -0
  38. acty_core-0.1.0/src/acty_core/lifecycle/burst.py +129 -0
  39. acty_core-0.1.0/src/acty_core/lifecycle/controller.py +1983 -0
  40. acty_core-0.1.0/src/acty_core/lifecycle/dependency_gate.py +844 -0
  41. acty_core-0.1.0/src/acty_core/lifecycle/dispatch.py +81 -0
  42. acty_core-0.1.0/src/acty_core/lifecycle/failure.py +56 -0
  43. acty_core-0.1.0/src/acty_core/lifecycle/groups.py +139 -0
  44. acty_core-0.1.0/src/acty_core/logging.py +192 -0
  45. acty_core-0.1.0/src/acty_core/result_handlers.py +108 -0
  46. acty_core-0.1.0/src/acty_core/results/__init__.py +17 -0
  47. acty_core-0.1.0/src/acty_core/results/async_queue.py +109 -0
  48. acty_core-0.1.0/src/acty_core/results/awaitable.py +151 -0
  49. acty_core-0.1.0/src/acty_core/results/base.py +11 -0
  50. acty_core-0.1.0/src/acty_core/results/fanout.py +60 -0
  51. acty_core-0.1.0/src/acty_core/results/in_memory.py +14 -0
  52. acty_core-0.1.0/src/acty_core/scheduler/__init__.py +29 -0
  53. acty_core-0.1.0/src/acty_core/scheduler/policies.py +36 -0
  54. acty_core-0.1.0/src/acty_core/scheduler/pool.py +13 -0
  55. acty_core-0.1.0/src/acty_core/scheduler/work_stealing.py +1505 -0
  56. acty_core-0.1.0/src/acty_core/telemetry/__init__.py +112 -0
  57. acty_core-0.1.0/src/acty_core/telemetry/acty_span.py +47 -0
  58. acty_core-0.1.0/src/acty_core/telemetry/llm_messages.py +274 -0
  59. acty_core-0.1.0/src/acty_core/telemetry/llm_tokens.py +123 -0
  60. acty_core-0.1.0/tests/__init__.py +1 -0
  61. acty_core-0.1.0/tests/conftest.py +5 -0
  62. acty_core-0.1.0/tests/support/__init__.py +1 -0
  63. acty_core-0.1.0/tests/support/asyncio_tools.py +77 -0
  64. acty_core-0.1.0/tests/support/event_bus_harness.py +69 -0
  65. acty_core-0.1.0/tests/support/scheduler_harness.py +33 -0
  66. acty_core-0.1.0/tests/support/time_control.py +38 -0
  67. acty_core-0.1.0/tests/test_acty_span.py +29 -0
  68. acty_core-0.1.0/tests/test_asyncio_tools.py +26 -0
  69. acty_core-0.1.0/tests/test_boundary_edge_cases.py +185 -0
  70. acty_core-0.1.0/tests/test_bounded_queue.py +39 -0
  71. acty_core-0.1.0/tests/test_burst_controller.py +112 -0
  72. acty_core-0.1.0/tests/test_cache_context_persistence.py +194 -0
  73. acty_core-0.1.0/tests/test_cache_integration.py +167 -0
  74. acty_core-0.1.0/tests/test_cache_interfaces.py +132 -0
  75. acty_core-0.1.0/tests/test_cache_registry.py +281 -0
  76. acty_core-0.1.0/tests/test_cache_registry_ttl.py +225 -0
  77. acty_core-0.1.0/tests/test_cache_storage_backends.py +93 -0
  78. acty_core-0.1.0/tests/test_concurrency_races.py +148 -0
  79. acty_core-0.1.0/tests/test_dependency_normalization.py +59 -0
  80. acty_core-0.1.0/tests/test_dispatch_policy.py +13 -0
  81. acty_core-0.1.0/tests/test_error_handling_failures.py +125 -0
  82. acty_core-0.1.0/tests/test_error_payload_policy.py +36 -0
  83. acty_core-0.1.0/tests/test_event_bus.py +76 -0
  84. acty_core-0.1.0/tests/test_event_queue_sink.py +35 -0
  85. acty_core-0.1.0/tests/test_executors.py +77 -0
  86. acty_core-0.1.0/tests/test_group_admission.py +212 -0
  87. acty_core-0.1.0/tests/test_group_failure_policies.py +220 -0
  88. acty_core-0.1.0/tests/test_group_lifecycle.py +256 -0
  89. acty_core-0.1.0/tests/test_headless_demo_cli.py +48 -0
  90. acty_core-0.1.0/tests/test_job_id_propagation.py +50 -0
  91. acty_core-0.1.0/tests/test_lane_targets.py +271 -0
  92. acty_core-0.1.0/tests/test_llm_messages.py +108 -0
  93. acty_core-0.1.0/tests/test_llm_tokens.py +42 -0
  94. acty_core-0.1.0/tests/test_logging_config.py +73 -0
  95. acty_core-0.1.0/tests/test_observability.py +159 -0
  96. acty_core-0.1.0/tests/test_open_groups.py +247 -0
  97. acty_core-0.1.0/tests/test_otel_event_integration.py +71 -0
  98. acty_core-0.1.0/tests/test_otel_event_sink.py +255 -0
  99. acty_core-0.1.0/tests/test_prometheus_metrics_sink.py +115 -0
  100. acty_core-0.1.0/tests/test_resource_management.py +170 -0
  101. acty_core-0.1.0/tests/test_resubmit_context_policy.py +115 -0
  102. acty_core-0.1.0/tests/test_result_handler_events.py +511 -0
  103. acty_core-0.1.0/tests/test_result_handler_otel.py +481 -0
  104. acty_core-0.1.0/tests/test_result_sinks.py +199 -0
  105. acty_core-0.1.0/tests/test_retry_integration.py +178 -0
  106. acty_core-0.1.0/tests/test_retry_result_streaming.py +95 -0
  107. acty_core-0.1.0/tests/test_scheduler.py +1274 -0
  108. acty_core-0.1.0/tests/test_scheduler_validation.py +11 -0
  109. acty_core-0.1.0/tests/test_scheduling_policy.py +47 -0
  110. acty_core-0.1.0/tests/test_state_machine.py +116 -0
  111. acty_core-0.1.0/tests/test_stats_collector.py +415 -0
  112. acty_core-0.1.0/tests/test_telemetry_privacy.py +93 -0
  113. acty_core-0.1.0/tests/test_work_stealing_behavior.py +191 -0
  114. acty_core-0.1.0/tests/utils.py +65 -0
@@ -0,0 +1,8 @@
1
+ .venv/
2
+ build/
3
+ dist/
4
+ *.egg-info/
5
+ .pytest_cache/
6
+ __pycache__/
7
+ .coverage
8
+ .mypy_cache/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Konstantin Polev
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,98 @@
1
+ Metadata-Version: 2.4
2
+ Name: acty-core
3
+ Version: 0.1.0
4
+ Summary: Group-only runtime core for scheduling, lifecycle, events, and cache primitives
5
+ Project-URL: Homepage, https://github.com/conspol/acty-core
6
+ Project-URL: Repository, https://github.com/conspol/acty-core
7
+ Project-URL: Issues, https://github.com/conspol/acty-core/issues
8
+ Maintainer-email: Konstantin Polev <70580603+conspol@users.noreply.github.com>
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: asyncio,runtime,scheduler,workflow
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Topic :: Software Development :: Libraries
19
+ Classifier: Topic :: System :: Distributed Computing
20
+ Requires-Python: >=3.12
21
+ Requires-Dist: rich>=13.0.0
22
+ Requires-Dist: structlog>=21.5.0
23
+ Provides-Extra: dev
24
+ Requires-Dist: build>=1.2.2; extra == 'dev'
25
+ Requires-Dist: opentelemetry-sdk>=1.39.0; extra == 'dev'
26
+ Requires-Dist: prometheus-client>=0.22.0; extra == 'dev'
27
+ Requires-Dist: pytest-asyncio>=1.3.0; extra == 'dev'
28
+ Requires-Dist: pytest>=9.0.0; extra == 'dev'
29
+ Requires-Dist: twine>=5.1.1; extra == 'dev'
30
+ Description-Content-Type: text/markdown
31
+
32
+ # acty-core
33
+
34
+ `acty-core` is the low-level runtime package behind the Acty ecosystem. It
35
+ provides the scheduler, lifecycle controller, event bus, cache primitives, and
36
+ lightweight demo executors without the higher-level TUI API.
37
+
38
+ ## Install
39
+
40
+ ```bash
41
+ pip install acty-core
42
+ ```
43
+
44
+ For local development:
45
+
46
+ ```bash
47
+ pip install -e .[dev]
48
+ ```
49
+
50
+ ## Example
51
+
52
+ Run the headless group demo:
53
+
54
+ ```bash
55
+ python examples/group_headless.py --groups 3
56
+ ```
57
+
58
+ Run the scheduler-only demo:
59
+
60
+ ```bash
61
+ python examples/scheduler_headless.py --groups 3
62
+ ```
63
+
64
+ Write JSONL events that can be inspected later:
65
+
66
+ ```bash
67
+ python examples/scheduler_jsonl.py --event-jsonl /tmp/acty_events.jsonl
68
+ ```
69
+
70
+ ## What It Includes
71
+
72
+ - `GroupLifecycleController` for primer/follower orchestration
73
+ - `WorkStealingScheduler` and pool configuration
74
+ - event bus primitives and JSONL-friendly event emission
75
+ - cache registry and cache-handle propagation support
76
+ - development executors such as `NoopExecutor`, `EchoExecutor`, and `SimulatedExecutor`
77
+
78
+ ## Logging
79
+
80
+ `acty-core` uses `structlog`. For readable local logs and rich tracebacks:
81
+
82
+ ```python
83
+ from acty_core import configure_logging
84
+
85
+ configure_logging(handler="rich", show_locals=True)
86
+ ```
87
+
88
+ ## Cache Behavior
89
+
90
+ When a group uses a cache registry, the lifecycle attaches the resolved cache
91
+ handle to queued jobs as `job.cache_handle`. Executors can reuse provider
92
+ metadata from the cache handle instead of creating duplicate cache entries.
93
+
94
+ ## Development
95
+
96
+ - tests live under `tests/`
97
+ - example programs live under `examples/`
98
+ - the package is intentionally usable without the higher-level `acty` TUI layer
@@ -0,0 +1,67 @@
1
+ # acty-core
2
+
3
+ `acty-core` is the low-level runtime package behind the Acty ecosystem. It
4
+ provides the scheduler, lifecycle controller, event bus, cache primitives, and
5
+ lightweight demo executors without the higher-level TUI API.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ pip install acty-core
11
+ ```
12
+
13
+ For local development:
14
+
15
+ ```bash
16
+ pip install -e .[dev]
17
+ ```
18
+
19
+ ## Example
20
+
21
+ Run the headless group demo:
22
+
23
+ ```bash
24
+ python examples/group_headless.py --groups 3
25
+ ```
26
+
27
+ Run the scheduler-only demo:
28
+
29
+ ```bash
30
+ python examples/scheduler_headless.py --groups 3
31
+ ```
32
+
33
+ Write JSONL events that can be inspected later:
34
+
35
+ ```bash
36
+ python examples/scheduler_jsonl.py --event-jsonl /tmp/acty_events.jsonl
37
+ ```
38
+
39
+ ## What It Includes
40
+
41
+ - `GroupLifecycleController` for primer/follower orchestration
42
+ - `WorkStealingScheduler` and pool configuration
43
+ - event bus primitives and JSONL-friendly event emission
44
+ - cache registry and cache-handle propagation support
45
+ - development executors such as `NoopExecutor`, `EchoExecutor`, and `SimulatedExecutor`
46
+
47
+ ## Logging
48
+
49
+ `acty-core` uses `structlog`. For readable local logs and rich tracebacks:
50
+
51
+ ```python
52
+ from acty_core import configure_logging
53
+
54
+ configure_logging(handler="rich", show_locals=True)
55
+ ```
56
+
57
+ ## Cache Behavior
58
+
59
+ When a group uses a cache registry, the lifecycle attaches the resolved cache
60
+ handle to queued jobs as `job.cache_handle`. Executors can reuse provider
61
+ metadata from the cache handle instead of creating duplicate cache entries.
62
+
63
+ ## Development
64
+
65
+ - tests live under `tests/`
66
+ - example programs live under `examples/`
67
+ - the package is intentionally usable without the higher-level `acty` TUI layer
@@ -0,0 +1,19 @@
1
+ # Attach TUI Later (JSONL follow)
2
+
3
+ 1) Run a long job and write JSONL events:
4
+
5
+ ```bash
6
+ python examples/scheduler_jsonl.py --event-jsonl /tmp/acty_events.jsonl
7
+ ```
8
+
9
+ 2) In another shell, attach the TUI and follow:
10
+
11
+ ```bash
12
+ acty-tui follow /tmp/acty_events.jsonl
13
+ ```
14
+
15
+ 3) For a completed run, replay:
16
+
17
+ ```bash
18
+ acty-tui replay /tmp/acty_events.jsonl --speed 2.0
19
+ ```
@@ -0,0 +1,256 @@
1
+ """Group-only headless harness for acty-core."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import asyncio
7
+ import random
8
+ import time
9
+ from dataclasses import dataclass, field
10
+ from pathlib import Path
11
+
12
+ import structlog
13
+
14
+ from acty_core.core.types import Job, JobResult
15
+ from acty_core.events import EventBus, JsonlEventSink
16
+ from acty_core.lifecycle import (
17
+ FollowerDispatchPolicy,
18
+ GroupLifecycleController,
19
+ GroupSpec,
20
+ GroupTaskKind,
21
+ )
22
+ from acty_core.results import AsyncQueueResultSink
23
+ from acty_core.scheduler import PoolConfig, WorkStealingScheduler
24
+
25
+ logger = structlog.get_logger(__name__)
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class DemoConfig:
30
+ groups: int = 3
31
+ followers_per_group: int = 5
32
+ open_groups: bool = False
33
+ warm_delay_s: float = 0.0
34
+ max_followers_inflight: int = 2
35
+ follower_dispatch_policy: FollowerDispatchPolicy = field(default_factory=FollowerDispatchPolicy)
36
+ primer_workers: int = 1
37
+ follower_workers: int = 2
38
+ min_delay_s: float = 0.01
39
+ max_delay_s: float = 0.05
40
+ event_jsonl: str | None = None
41
+ seed: int = 0
42
+ result_queue_size: int = 0
43
+ result_drop_policy: str = "drop_oldest"
44
+
45
+
46
+ class DemoExecutor:
47
+ def __init__(
48
+ self,
49
+ controller: GroupLifecycleController,
50
+ *,
51
+ rng: random.Random,
52
+ min_delay_s: float,
53
+ max_delay_s: float,
54
+ ) -> None:
55
+ self._controller = controller
56
+ self._rng = rng
57
+ self._min_delay_s = min_delay_s
58
+ self._max_delay_s = max_delay_s
59
+
60
+ async def execute(self, job: Job, *, pool: str) -> JobResult:
61
+ group_id = str(job.group_id) if job.group_id is not None else ""
62
+ delay = self._rng.uniform(self._min_delay_s, self._max_delay_s)
63
+ if job.kind == GroupTaskKind.PRIMER.value:
64
+ await self._controller.mark_primer_started(group_id)
65
+ await asyncio.sleep(delay)
66
+ await self._controller.mark_primer_done(group_id)
67
+ elif job.kind == GroupTaskKind.FOLLOWER.value:
68
+ await asyncio.sleep(delay)
69
+ await self._controller.mark_follower_done(group_id)
70
+ else:
71
+ await asyncio.sleep(delay)
72
+ return JobResult(job_id=job.id, kind=job.kind, ok=True, output={"payload": job.payload})
73
+
74
+
75
+ async def _drain_results(
76
+ queue: asyncio.Queue[JobResult],
77
+ done_event: asyncio.Event,
78
+ ) -> tuple[int, int]:
79
+ ok = 0
80
+ failed = 0
81
+ last_timeout_log = 0.0
82
+ log_interval_s = 2.0
83
+ while True:
84
+ if done_event.is_set() and queue.empty():
85
+ break
86
+ try:
87
+ result = await asyncio.wait_for(queue.get(), timeout=0.1)
88
+ except asyncio.TimeoutError:
89
+ now = time.monotonic()
90
+ if now - last_timeout_log >= log_interval_s:
91
+ last_timeout_log = now
92
+ logger.debug(
93
+ "Result queue wait timed out; continuing",
94
+ done_event_set=done_event.is_set(),
95
+ queue_size=queue.qsize(),
96
+ )
97
+ continue
98
+ if result.ok:
99
+ ok += 1
100
+ else:
101
+ failed += 1
102
+ queue.task_done()
103
+ return ok, failed
104
+
105
+
106
+ async def run_headless(config: DemoConfig) -> None:
107
+ sinks = []
108
+ jsonl_sink = None
109
+ if config.event_jsonl:
110
+ jsonl_sink = JsonlEventSink(Path(config.event_jsonl))
111
+ sinks.append(jsonl_sink)
112
+ event_bus = EventBus(sinks) if sinks else None
113
+
114
+ result_sink = AsyncQueueResultSink(
115
+ maxsize=config.result_queue_size,
116
+ drop_policy=config.result_drop_policy,
117
+ )
118
+
119
+ controller = GroupLifecycleController(
120
+ event_bus=event_bus,
121
+ follower_dispatch_policy=config.follower_dispatch_policy,
122
+ )
123
+
124
+ scheduler = WorkStealingScheduler(
125
+ pools=[
126
+ PoolConfig(name="primer", preferred_kinds=[GroupTaskKind.PRIMER.value], workers=config.primer_workers),
127
+ PoolConfig(
128
+ name="follower",
129
+ preferred_kinds=[GroupTaskKind.FOLLOWER.value],
130
+ workers=config.follower_workers,
131
+ ),
132
+ ],
133
+ event_bus=event_bus,
134
+ result_sink=result_sink,
135
+ )
136
+
137
+ rng = random.Random(config.seed)
138
+ executor = DemoExecutor(
139
+ controller,
140
+ rng=rng,
141
+ min_delay_s=config.min_delay_s,
142
+ max_delay_s=config.max_delay_s,
143
+ )
144
+
145
+ group_ids: list[str] = []
146
+ for idx in range(config.groups):
147
+ group_id = f"group-{idx + 1}"
148
+ spec = GroupSpec(
149
+ group_id=group_id,
150
+ followers_total=None if config.open_groups else config.followers_per_group,
151
+ warm_delay_s=config.warm_delay_s,
152
+ max_followers_inflight=config.max_followers_inflight,
153
+ payload={"group_id": group_id, "primer": True},
154
+ )
155
+ await controller.add_group(spec)
156
+ if config.open_groups:
157
+ payloads = [
158
+ {"group_id": group_id, "follower": i + 1}
159
+ for i in range(config.followers_per_group)
160
+ ]
161
+ await controller.add_followers(group_id, payloads)
162
+ await controller.close_group(group_id)
163
+ group_ids.append(group_id)
164
+
165
+ scheduler_task = asyncio.create_task(scheduler.run(controller, executor))
166
+
167
+ done_event = asyncio.Event()
168
+ results_task = asyncio.create_task(_drain_results(result_sink.queue, done_event))
169
+
170
+ await asyncio.gather(*(controller.wait_group_done(gid) for gid in group_ids))
171
+ await controller.close()
172
+ report = await scheduler_task
173
+ done_event.set()
174
+ ok, failed = await results_task
175
+
176
+ if jsonl_sink is not None:
177
+ jsonl_sink.close()
178
+
179
+ print("Run complete.")
180
+ print(f"Queued: {report.queued}")
181
+ print(f"Finished: {report.finished}")
182
+ print(f"Failed: {report.failed}")
183
+ print(f"Results OK: {ok} Failed: {failed} Dropped: {result_sink.dropped}")
184
+
185
+
186
+ def build_parser() -> argparse.ArgumentParser:
187
+ parser = argparse.ArgumentParser(description="Run acty-core group-only headless demo.")
188
+ parser.add_argument("--groups", type=int, default=3)
189
+ parser.add_argument("--followers-per-group", type=int, default=5)
190
+ parser.add_argument("--open-groups", action="store_true")
191
+ parser.add_argument("--warm-delay-s", type=float, default=0.0)
192
+ parser.add_argument("--max-followers-inflight", type=int, default=2)
193
+ parser.add_argument("--dispatch-mode", type=str, default="target", choices=["serial", "target", "eager"])
194
+ parser.add_argument("--dispatch-target-total", type=int, default=None)
195
+ parser.add_argument("--dispatch-min-per-group", type=int, default=0)
196
+ parser.add_argument("--dispatch-max-per-group", type=int, default=None)
197
+ parser.add_argument("--primer-workers", type=int, default=1)
198
+ parser.add_argument("--follower-workers", type=int, default=2)
199
+ parser.add_argument("--min-delay-s", type=float, default=0.01)
200
+ parser.add_argument("--max-delay-s", type=float, default=0.05)
201
+ parser.add_argument("--event-jsonl", type=str, default="")
202
+ parser.add_argument("--seed", type=int, default=0)
203
+ parser.add_argument("--result-queue-size", type=int, default=0)
204
+ parser.add_argument(
205
+ "--result-drop-policy",
206
+ type=str,
207
+ default="drop_oldest",
208
+ choices=["block", "drop_newest", "drop_oldest"],
209
+ )
210
+ return parser
211
+
212
+
213
+ def _normalize_optional_int(value: int | None) -> int | None:
214
+ if value is None or value == 0:
215
+ return None
216
+ return value
217
+
218
+
219
+ def build_demo_config(args: argparse.Namespace) -> DemoConfig:
220
+ target_total = _normalize_optional_int(args.dispatch_target_total)
221
+ max_per_group = _normalize_optional_int(args.dispatch_max_per_group)
222
+ if args.dispatch_mode == "target" and target_total is None:
223
+ target_total = max(1, args.follower_workers)
224
+ return DemoConfig(
225
+ groups=args.groups,
226
+ followers_per_group=args.followers_per_group,
227
+ open_groups=args.open_groups,
228
+ warm_delay_s=args.warm_delay_s,
229
+ max_followers_inflight=args.max_followers_inflight,
230
+ follower_dispatch_policy=FollowerDispatchPolicy(
231
+ mode=args.dispatch_mode,
232
+ target_total=target_total,
233
+ min_per_group=args.dispatch_min_per_group,
234
+ max_per_group=max_per_group,
235
+ ),
236
+ primer_workers=args.primer_workers,
237
+ follower_workers=args.follower_workers,
238
+ min_delay_s=args.min_delay_s,
239
+ max_delay_s=args.max_delay_s,
240
+ event_jsonl=args.event_jsonl or None,
241
+ seed=args.seed,
242
+ result_queue_size=args.result_queue_size,
243
+ result_drop_policy=args.result_drop_policy,
244
+ )
245
+
246
+
247
+ def main(argv: list[str] | None = None) -> int:
248
+ parser = build_parser()
249
+ args = parser.parse_args(argv)
250
+ config = build_demo_config(args)
251
+ asyncio.run(run_headless(config))
252
+ return 0
253
+
254
+
255
+ if __name__ == "__main__":
256
+ raise SystemExit(main())
@@ -0,0 +1,168 @@
1
+ """Group-only example using CacheRegistry with SQLite storage."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import asyncio
7
+ import random
8
+ from dataclasses import dataclass
9
+
10
+ from acty_core.cache import CacheRegistry
11
+ from acty_core.cache.storage_sqlite import SQLiteStorage
12
+ from acty_core.core.types import Job, JobResult
13
+ from acty_core.lifecycle import GroupLifecycleController, GroupSpec, GroupTaskKind
14
+ from acty_core.scheduler import PoolConfig, WorkStealingScheduler
15
+
16
+
17
+ class DemoCacheProvider:
18
+ name = "demo"
19
+
20
+ def fingerprint(self, primer, context=None) -> str:
21
+ return f"{primer}:{context}"
22
+
23
+ async def create(self, primer, context=None) -> str | None:
24
+ return f"ref:{primer}"
25
+
26
+ async def wait_ready(self, provider_ref, timeout_s=None) -> None:
27
+ return None
28
+
29
+ async def invalidate(self, provider_ref) -> None:
30
+ return None
31
+
32
+
33
+ @dataclass
34
+ class DemoCounters:
35
+ primer_jobs: int = 0
36
+ follower_jobs: int = 0
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class DemoConfig:
41
+ followers_per_group: int = 3
42
+ primer_workers: int = 1
43
+ follower_workers: int = 2
44
+ min_delay_s: float = 0.01
45
+ max_delay_s: float = 0.05
46
+ seed: int = 0
47
+ cache_path: str = "cache.sqlite"
48
+
49
+
50
+ class DemoExecutor:
51
+ def __init__(
52
+ self,
53
+ controller: GroupLifecycleController,
54
+ counters: DemoCounters,
55
+ *,
56
+ rng: random.Random,
57
+ min_delay_s: float,
58
+ max_delay_s: float,
59
+ ) -> None:
60
+ self._controller = controller
61
+ self._counters = counters
62
+ self._rng = rng
63
+ self._min_delay_s = min_delay_s
64
+ self._max_delay_s = max_delay_s
65
+
66
+ async def execute(self, job: Job, *, pool: str) -> JobResult:
67
+ group_id = str(job.group_id) if job.group_id is not None else ""
68
+ delay = self._rng.uniform(self._min_delay_s, self._max_delay_s)
69
+ if job.kind == GroupTaskKind.PRIMER.value:
70
+ self._counters.primer_jobs += 1
71
+ await self._controller.mark_primer_started(group_id)
72
+ await asyncio.sleep(delay)
73
+ await self._controller.mark_primer_done(group_id)
74
+ elif job.kind == GroupTaskKind.FOLLOWER.value:
75
+ self._counters.follower_jobs += 1
76
+ await asyncio.sleep(delay)
77
+ await self._controller.mark_follower_done(group_id)
78
+ else:
79
+ await asyncio.sleep(delay)
80
+ return JobResult(job_id=job.id, kind=job.kind, ok=True, output={"payload": job.payload})
81
+
82
+
83
+ async def run_pass(label: str, registry: CacheRegistry, config: DemoConfig) -> None:
84
+ controller = GroupLifecycleController(cache_registry=registry)
85
+ scheduler = WorkStealingScheduler(
86
+ pools=[
87
+ PoolConfig(name="primer", preferred_kinds=[GroupTaskKind.PRIMER.value], workers=config.primer_workers),
88
+ PoolConfig(
89
+ name="follower",
90
+ preferred_kinds=[GroupTaskKind.FOLLOWER.value],
91
+ workers=config.follower_workers,
92
+ ),
93
+ ],
94
+ )
95
+
96
+ counters = DemoCounters()
97
+ rng = random.Random(config.seed)
98
+ executor = DemoExecutor(
99
+ controller,
100
+ counters,
101
+ rng=rng,
102
+ min_delay_s=config.min_delay_s,
103
+ max_delay_s=config.max_delay_s,
104
+ )
105
+
106
+ group_id = f"{label}-group"
107
+ await controller.add_group(
108
+ GroupSpec(
109
+ group_id=group_id,
110
+ followers_total=config.followers_per_group,
111
+ payload={"group_id": group_id},
112
+ cache_group_id="shared-cache",
113
+ primer_content="primer-v1",
114
+ )
115
+ )
116
+
117
+ await scheduler.run(controller, executor)
118
+ await controller.wait_group_done(group_id)
119
+ await controller.close()
120
+
121
+ print(f"{label}: primer_jobs={counters.primer_jobs} follower_jobs={counters.follower_jobs}")
122
+
123
+
124
+ async def run_demo(config: DemoConfig) -> None:
125
+ storage = SQLiteStorage(config.cache_path)
126
+ provider = DemoCacheProvider()
127
+
128
+ registry = CacheRegistry(provider, storage=storage)
129
+ await run_pass("first", registry, config)
130
+ await storage.close()
131
+
132
+ # New registry + storage simulates a new process using the same SQLite cache.
133
+ storage = SQLiteStorage(config.cache_path)
134
+ registry2 = CacheRegistry(DemoCacheProvider(), storage=storage)
135
+ await run_pass("second", registry2, config)
136
+ await storage.close()
137
+
138
+
139
+ def build_parser() -> argparse.ArgumentParser:
140
+ parser = argparse.ArgumentParser(description="Run group-only demo with CacheRegistry + SQLite.")
141
+ parser.add_argument("--followers-per-group", type=int, default=3)
142
+ parser.add_argument("--primer-workers", type=int, default=1)
143
+ parser.add_argument("--follower-workers", type=int, default=2)
144
+ parser.add_argument("--min-delay-s", type=float, default=0.01)
145
+ parser.add_argument("--max-delay-s", type=float, default=0.05)
146
+ parser.add_argument("--seed", type=int, default=0)
147
+ parser.add_argument("--cache-path", type=str, default="cache.sqlite")
148
+ return parser
149
+
150
+
151
+ def main(argv: list[str] | None = None) -> int:
152
+ parser = build_parser()
153
+ args = parser.parse_args(argv)
154
+ config = DemoConfig(
155
+ followers_per_group=args.followers_per_group,
156
+ primer_workers=args.primer_workers,
157
+ follower_workers=args.follower_workers,
158
+ min_delay_s=args.min_delay_s,
159
+ max_delay_s=args.max_delay_s,
160
+ seed=args.seed,
161
+ cache_path=args.cache_path,
162
+ )
163
+ asyncio.run(run_demo(config))
164
+ return 0
165
+
166
+
167
+ if __name__ == "__main__":
168
+ raise SystemExit(main())