acty 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 (97) hide show
  1. acty-0.1.0/.gitignore +8 -0
  2. acty-0.1.0/LICENSE +21 -0
  3. acty-0.1.0/PKG-INFO +115 -0
  4. acty-0.1.0/README.md +81 -0
  5. acty-0.1.0/examples/group_tui_demo.py +378 -0
  6. acty-0.1.0/examples/lane_demo.py +37 -0
  7. acty-0.1.0/examples/tenacity_retry_demo.py +91 -0
  8. acty-0.1.0/examples/tui_console_bindings_manual_check.md +35 -0
  9. acty-0.1.0/examples/tui_console_bindings_manual_check.py +119 -0
  10. acty-0.1.0/examples/tui_console_capture_demo.py +69 -0
  11. acty-0.1.0/pyproject.toml +53 -0
  12. acty-0.1.0/pytest.ini +4 -0
  13. acty-0.1.0/src/acty/__init__.py +103 -0
  14. acty-0.1.0/src/acty/addons/__init__.py +1 -0
  15. acty-0.1.0/src/acty/addons/langchain/__init__.py +30 -0
  16. acty-0.1.0/src/acty/addons/langchain/messages.py +20 -0
  17. acty-0.1.0/src/acty/client.py +129 -0
  18. acty-0.1.0/src/acty/engine.py +1595 -0
  19. acty-0.1.0/src/acty/exec_resolver.py +138 -0
  20. acty-0.1.0/src/acty/executors.py +776 -0
  21. acty-0.1.0/src/acty/groups.py +139 -0
  22. acty-0.1.0/src/acty/lane.py +140 -0
  23. acty-0.1.0/src/acty/logging.py +39 -0
  24. acty-0.1.0/src/acty/result_handlers.py +28 -0
  25. acty-0.1.0/src/acty/retry_budget.py +33 -0
  26. acty-0.1.0/src/acty/tui/__init__.py +52 -0
  27. acty-0.1.0/src/acty/tui/app.py +733 -0
  28. acty-0.1.0/src/acty/tui/cli.py +92 -0
  29. acty-0.1.0/src/acty/tui/console_capture.py +237 -0
  30. acty-0.1.0/src/acty/tui/console_guardrails.py +37 -0
  31. acty-0.1.0/src/acty/tui/formatting.py +162 -0
  32. acty-0.1.0/src/acty/tui/history.py +90 -0
  33. acty-0.1.0/src/acty/tui/runtime_helpers.py +116 -0
  34. acty-0.1.0/src/acty/tui/selectable_rich_log.py +119 -0
  35. acty-0.1.0/src/acty/tui/sources/__init__.py +13 -0
  36. acty-0.1.0/src/acty/tui/sources/base.py +11 -0
  37. acty-0.1.0/src/acty/tui/sources/file.py +128 -0
  38. acty-0.1.0/src/acty/tui/sources/queue.py +25 -0
  39. acty-0.1.0/src/acty/tui/sources/replay.py +65 -0
  40. acty-0.1.0/src/acty/tui/state.py +43 -0
  41. acty-0.1.0/src/acty/tui/widgets/__init__.py +20 -0
  42. acty-0.1.0/src/acty/tui/widgets/burst_indicator.py +54 -0
  43. acty-0.1.0/src/acty/tui/widgets/diagnostic_widget.py +175 -0
  44. acty-0.1.0/src/acty/tui/widgets/queue_depth_plot.py +43 -0
  45. acty-0.1.0/src/acty/tui/widgets/throughput_plot.py +41 -0
  46. acty-0.1.0/src/acty/tui/widgets/timeline.py +1581 -0
  47. acty-0.1.0/src/acty/tui/widgets/work_structure.py +221 -0
  48. acty-0.1.0/src/acty/tui/widgets/worker_pool.py +65 -0
  49. acty-0.1.0/tests/__init__.py +1 -0
  50. acty-0.1.0/tests/conftest.py +14 -0
  51. acty-0.1.0/tests/support/__init__.py +1 -0
  52. acty-0.1.0/tests/support/asyncio_tools.py +77 -0
  53. acty-0.1.0/tests/support/tui_harness.py +17 -0
  54. acty-0.1.0/tests/test_acty_client.py +69 -0
  55. acty-0.1.0/tests/test_acty_client_integration.py +151 -0
  56. acty-0.1.0/tests/test_compose_handlers.py +96 -0
  57. acty-0.1.0/tests/test_context_propagation.py +114 -0
  58. acty-0.1.0/tests/test_deferred_primer.py +165 -0
  59. acty-0.1.0/tests/test_engine_admission.py +261 -0
  60. acty-0.1.0/tests/test_engine_boundary_edge_cases.py +16 -0
  61. acty-0.1.0/tests/test_engine_cache_integration.py +403 -0
  62. acty-0.1.0/tests/test_engine_close_all_groups.py +143 -0
  63. acty-0.1.0/tests/test_engine_dispatch_defaults.py +16 -0
  64. acty-0.1.0/tests/test_engine_policy_matrix.py +566 -0
  65. acty-0.1.0/tests/test_engine_resource_management.py +48 -0
  66. acty-0.1.0/tests/test_engine_results.py +489 -0
  67. acty-0.1.0/tests/test_engine_retry_policies.py +263 -0
  68. acty-0.1.0/tests/test_error_payload_policy_runtime.py +124 -0
  69. acty-0.1.0/tests/test_event_invoke_kwargs.py +62 -0
  70. acty-0.1.0/tests/test_event_observability.py +59 -0
  71. acty-0.1.0/tests/test_group_context.py +69 -0
  72. acty-0.1.0/tests/test_group_dependencies.py +582 -0
  73. acty-0.1.0/tests/test_group_leases.py +336 -0
  74. acty-0.1.0/tests/test_group_registry.py +143 -0
  75. acty-0.1.0/tests/test_group_registry_integration.py +132 -0
  76. acty-0.1.0/tests/test_integration_pipeline.py +198 -0
  77. acty-0.1.0/tests/test_lane.py +556 -0
  78. acty-0.1.0/tests/test_lane_system.py +258 -0
  79. acty-0.1.0/tests/test_open_group_api.py +111 -0
  80. acty-0.1.0/tests/test_resolver_executor.py +132 -0
  81. acty-0.1.0/tests/test_result_handlers.py +849 -0
  82. acty-0.1.0/tests/test_retry_budget.py +37 -0
  83. acty-0.1.0/tests/test_retry_payload_resolver.py +129 -0
  84. acty-0.1.0/tests/test_runtime_stats.py +74 -0
  85. acty-0.1.0/tests/test_tenacity_retry_adapter.py +377 -0
  86. acty-0.1.0/tests/test_tui_app_flow.py +147 -0
  87. acty-0.1.0/tests/test_tui_cli.py +94 -0
  88. acty-0.1.0/tests/test_tui_console_capture.py +321 -0
  89. acty-0.1.0/tests/test_tui_demo_cli.py +56 -0
  90. acty-0.1.0/tests/test_tui_formatting.py +83 -0
  91. acty-0.1.0/tests/test_tui_history.py +57 -0
  92. acty-0.1.0/tests/test_tui_replay_integration.py +37 -0
  93. acty-0.1.0/tests/test_tui_selectable_rich_log.py +87 -0
  94. acty-0.1.0/tests/test_tui_sources.py +204 -0
  95. acty-0.1.0/tests/test_tui_state.py +127 -0
  96. acty-0.1.0/tests/test_tui_timeline.py +198 -0
  97. acty-0.1.0/tests/test_tui_widgets.py +88 -0
acty-0.1.0/.gitignore ADDED
@@ -0,0 +1,8 @@
1
+ .venv/
2
+ build/
3
+ dist/
4
+ *.egg-info/
5
+ .pytest_cache/
6
+ __pycache__/
7
+ .coverage
8
+ .mypy_cache/
acty-0.1.0/LICENSE ADDED
@@ -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.
acty-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,115 @@
1
+ Metadata-Version: 2.4
2
+ Name: acty
3
+ Version: 0.1.0
4
+ Summary: High-level group runtime with TUI and CLI built on top of acty-core
5
+ Project-URL: Homepage, https://github.com/conspol/acty
6
+ Project-URL: Repository, https://github.com/conspol/acty
7
+ Project-URL: Issues, https://github.com/conspol/acty/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,tui,workflow
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Environment :: Console
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Topic :: Software Development :: Libraries
20
+ Classifier: Topic :: Terminals
21
+ Requires-Python: >=3.12
22
+ Requires-Dist: acty-core<0.2.0,>=0.1.0
23
+ Requires-Dist: tenacity>=8.2.0
24
+ Requires-Dist: textual-plotext>=1.0.1
25
+ Requires-Dist: textual>=6.11.0
26
+ Provides-Extra: dev
27
+ Requires-Dist: build>=1.2.2; extra == 'dev'
28
+ Requires-Dist: pytest-asyncio>=1.3.0; extra == 'dev'
29
+ Requires-Dist: pytest>=9.0.0; extra == 'dev'
30
+ Requires-Dist: twine>=5.1.1; extra == 'dev'
31
+ Provides-Extra: langchain
32
+ Requires-Dist: acty-langchain<0.2.0,>=0.1.0; extra == 'langchain'
33
+ Description-Content-Type: text/markdown
34
+
35
+ # acty
36
+
37
+ `acty` is the high-level runtime package in the Acty stack. It builds on
38
+ `acty-core` and adds the engine API, TUI application, and the `acty-tui` CLI
39
+ for following or replaying JSONL event streams.
40
+
41
+ ## Install
42
+
43
+ ```bash
44
+ pip install acty
45
+ ```
46
+
47
+ Install the optional LangChain integration:
48
+
49
+ ```bash
50
+ pip install "acty[langchain]"
51
+ ```
52
+
53
+ For local development:
54
+
55
+ ```bash
56
+ pip install -e .[dev]
57
+ ```
58
+
59
+ ## Python Usage
60
+
61
+ ```python
62
+ import asyncio
63
+
64
+ from acty import ActyEngine, EngineConfig, EchoExecutor
65
+
66
+
67
+ async def main() -> None:
68
+ engine = ActyEngine(executor=EchoExecutor(), config=EngineConfig())
69
+ try:
70
+ submission = await engine.submit_group("demo", {"primer": 1}, [{"follower": 1}])
71
+ if submission.primer is not None:
72
+ print((await submission.primer).output)
73
+ for fut in submission.followers:
74
+ print((await fut).output)
75
+ finally:
76
+ await engine.close()
77
+
78
+
79
+ asyncio.run(main())
80
+ ```
81
+
82
+ ## CLI Usage
83
+
84
+ The package exposes `acty-tui`:
85
+
86
+ ```bash
87
+ acty-tui follow /tmp/acty_events.jsonl
88
+ acty-tui replay /tmp/acty_events.jsonl --speed 2.0
89
+ ```
90
+
91
+ ## Example Programs
92
+
93
+ Run the TUI demo:
94
+
95
+ ```bash
96
+ python examples/group_tui_demo.py --groups 3 --followers-per-group 2
97
+ ```
98
+
99
+ Run the retry demo:
100
+
101
+ ```bash
102
+ python examples/tenacity_retry_demo.py --event-jsonl /tmp/acty_tenacity_events.jsonl
103
+ ```
104
+
105
+ ## Package Relationships
106
+
107
+ - `acty-core` provides the low-level scheduler, lifecycle, event, and cache primitives
108
+ - `acty` provides the engine, client, TUI, and CLI built on top of `acty-core`
109
+ - `acty-langchain`, `acty-openai`, and `acty-gigachat` provide optional executor integrations
110
+
111
+ ## Development
112
+
113
+ - tests live under `tests/`
114
+ - example programs live under `examples/`
115
+ - adapter-specific integration coverage belongs in the adapter repos, not in this repo
acty-0.1.0/README.md ADDED
@@ -0,0 +1,81 @@
1
+ # acty
2
+
3
+ `acty` is the high-level runtime package in the Acty stack. It builds on
4
+ `acty-core` and adds the engine API, TUI application, and the `acty-tui` CLI
5
+ for following or replaying JSONL event streams.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ pip install acty
11
+ ```
12
+
13
+ Install the optional LangChain integration:
14
+
15
+ ```bash
16
+ pip install "acty[langchain]"
17
+ ```
18
+
19
+ For local development:
20
+
21
+ ```bash
22
+ pip install -e .[dev]
23
+ ```
24
+
25
+ ## Python Usage
26
+
27
+ ```python
28
+ import asyncio
29
+
30
+ from acty import ActyEngine, EngineConfig, EchoExecutor
31
+
32
+
33
+ async def main() -> None:
34
+ engine = ActyEngine(executor=EchoExecutor(), config=EngineConfig())
35
+ try:
36
+ submission = await engine.submit_group("demo", {"primer": 1}, [{"follower": 1}])
37
+ if submission.primer is not None:
38
+ print((await submission.primer).output)
39
+ for fut in submission.followers:
40
+ print((await fut).output)
41
+ finally:
42
+ await engine.close()
43
+
44
+
45
+ asyncio.run(main())
46
+ ```
47
+
48
+ ## CLI Usage
49
+
50
+ The package exposes `acty-tui`:
51
+
52
+ ```bash
53
+ acty-tui follow /tmp/acty_events.jsonl
54
+ acty-tui replay /tmp/acty_events.jsonl --speed 2.0
55
+ ```
56
+
57
+ ## Example Programs
58
+
59
+ Run the TUI demo:
60
+
61
+ ```bash
62
+ python examples/group_tui_demo.py --groups 3 --followers-per-group 2
63
+ ```
64
+
65
+ Run the retry demo:
66
+
67
+ ```bash
68
+ python examples/tenacity_retry_demo.py --event-jsonl /tmp/acty_tenacity_events.jsonl
69
+ ```
70
+
71
+ ## Package Relationships
72
+
73
+ - `acty-core` provides the low-level scheduler, lifecycle, event, and cache primitives
74
+ - `acty` provides the engine, client, TUI, and CLI built on top of `acty-core`
75
+ - `acty-langchain`, `acty-openai`, and `acty-gigachat` provide optional executor integrations
76
+
77
+ ## Development
78
+
79
+ - tests live under `tests/`
80
+ - example programs live under `examples/`
81
+ - adapter-specific integration coverage belongs in the adapter repos, not in this repo
@@ -0,0 +1,378 @@
1
+ """Group-only TUI demo harness for acty."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import asyncio
7
+ import contextlib
8
+ import random
9
+ import time
10
+ from dataclasses import dataclass, field
11
+ from pathlib import Path
12
+
13
+ import structlog
14
+
15
+ from acty import TuiState, create_tui_app
16
+ from acty.tui.sources import QueueEventSource
17
+ from acty_core.core.types import Job, JobResult
18
+ from acty_core.events import EventBus, JsonlEventSink
19
+ from acty_core.events.sinks.base import EventSink
20
+ from acty_core.events.types import Event
21
+ from acty_core.lifecycle import (
22
+ FollowerDispatchPolicy,
23
+ GroupLifecycleController,
24
+ GroupSpec,
25
+ GroupTaskKind,
26
+ )
27
+ from acty_core.results import AsyncQueueResultSink
28
+ from acty_core.scheduler import PoolConfig, WorkStealingScheduler
29
+
30
+ logger = structlog.get_logger(__name__)
31
+
32
+
33
+ class EventQueueSink(EventSink):
34
+ def __init__(
35
+ self,
36
+ queue: asyncio.Queue[Event],
37
+ *,
38
+ drop_policy: str = "drop_oldest",
39
+ drop_log_interval_s: float = 2.0,
40
+ ) -> None:
41
+ if drop_policy not in {"drop_oldest", "drop_newest", "block"}:
42
+ raise ValueError("drop_policy must be block, drop_oldest, or drop_newest")
43
+ self._queue = queue
44
+ self._drop_policy = drop_policy
45
+ self._dropped = 0
46
+ self._drop_log_interval_s = drop_log_interval_s
47
+ self._last_drop_log = 0.0
48
+
49
+ @property
50
+ def dropped(self) -> int:
51
+ return self._dropped
52
+
53
+ async def handle(self, event: Event) -> None:
54
+ if self._drop_policy == "block":
55
+ await self._queue.put(event)
56
+ return
57
+ try:
58
+ self._queue.put_nowait(event)
59
+ except asyncio.QueueFull:
60
+ if self._drop_policy == "drop_newest":
61
+ self._dropped += 1
62
+ now = time.monotonic()
63
+ if now - self._last_drop_log >= self._drop_log_interval_s:
64
+ self._last_drop_log = now
65
+ logger.debug(
66
+ "Event queue full in demo sink",
67
+ drop_policy=self._drop_policy,
68
+ dropped=self._dropped,
69
+ queue_maxsize=self._queue.maxsize,
70
+ queue_size=self._queue.qsize(),
71
+ )
72
+ return
73
+ try:
74
+ _ = self._queue.get_nowait()
75
+ self._queue.task_done()
76
+ self._dropped += 1
77
+ except asyncio.QueueEmpty:
78
+ self._dropped += 1
79
+ now = time.monotonic()
80
+ if now - self._last_drop_log >= self._drop_log_interval_s:
81
+ self._last_drop_log = now
82
+ logger.debug(
83
+ "Event queue empty while dropping oldest in demo sink",
84
+ drop_policy=self._drop_policy,
85
+ dropped=self._dropped,
86
+ queue_maxsize=self._queue.maxsize,
87
+ queue_size=self._queue.qsize(),
88
+ )
89
+ return
90
+ with contextlib.suppress(asyncio.QueueFull):
91
+ self._queue.put_nowait(event)
92
+
93
+
94
+ @dataclass(frozen=True)
95
+ class DemoConfig:
96
+ groups: int = 3
97
+ followers_per_group: int = 5
98
+ open_groups: bool = False
99
+ warm_delay_s: float = 0.0
100
+ max_followers_inflight: int = 2
101
+ follower_dispatch_policy: FollowerDispatchPolicy = field(default_factory=FollowerDispatchPolicy)
102
+ primer_workers: int = 1
103
+ follower_workers: int = 2
104
+ min_delay_s: float = 0.01
105
+ max_delay_s: float = 0.05
106
+ event_jsonl: str | None = None
107
+ seed: int = 0
108
+ result_queue_size: int = 0
109
+ result_drop_policy: str = "drop_oldest"
110
+ event_queue_size: int = 5000
111
+ event_drop_policy: str = "drop_oldest"
112
+
113
+
114
+ class DemoExecutor:
115
+ def __init__(
116
+ self,
117
+ controller: GroupLifecycleController,
118
+ *,
119
+ rng: random.Random,
120
+ min_delay_s: float,
121
+ max_delay_s: float,
122
+ ) -> None:
123
+ self._controller = controller
124
+ self._rng = rng
125
+ self._min_delay_s = min_delay_s
126
+ self._max_delay_s = max_delay_s
127
+
128
+ async def execute(self, job: Job, *, pool: str) -> JobResult:
129
+ group_id = str(job.group_id) if job.group_id is not None else ""
130
+ delay = self._rng.uniform(self._min_delay_s, self._max_delay_s)
131
+ if job.kind == GroupTaskKind.PRIMER.value:
132
+ await self._controller.mark_primer_started(group_id)
133
+ await asyncio.sleep(delay)
134
+ await self._controller.mark_primer_done(group_id)
135
+ elif job.kind == GroupTaskKind.FOLLOWER.value:
136
+ await asyncio.sleep(delay)
137
+ await self._controller.mark_follower_done(group_id)
138
+ else:
139
+ await asyncio.sleep(delay)
140
+ return JobResult(job_id=job.id, kind=job.kind, ok=True, output={"payload": job.payload})
141
+
142
+
143
+ async def _drain_results(
144
+ queue: asyncio.Queue[JobResult],
145
+ done_event: asyncio.Event,
146
+ ) -> tuple[int, int]:
147
+ ok = 0
148
+ failed = 0
149
+ last_timeout_log = 0.0
150
+ log_interval_s = 2.0
151
+ while True:
152
+ if done_event.is_set() and queue.empty():
153
+ break
154
+ try:
155
+ result = await asyncio.wait_for(queue.get(), timeout=0.1)
156
+ except asyncio.TimeoutError:
157
+ now = time.monotonic()
158
+ if now - last_timeout_log >= log_interval_s:
159
+ last_timeout_log = now
160
+ logger.debug(
161
+ "Result queue wait timed out; continuing",
162
+ done_event_set=done_event.is_set(),
163
+ queue_size=queue.qsize(),
164
+ )
165
+ continue
166
+ if result.ok:
167
+ ok += 1
168
+ else:
169
+ failed += 1
170
+ queue.task_done()
171
+ return ok, failed
172
+
173
+
174
+ async def run_tui_demo(config: DemoConfig) -> None:
175
+ event_queue: asyncio.Queue[Event] = asyncio.Queue(maxsize=config.event_queue_size)
176
+ queue_sink = EventQueueSink(event_queue, drop_policy=config.event_drop_policy)
177
+
178
+ sinks: list[EventSink] = [queue_sink]
179
+ jsonl_sink = None
180
+ if config.event_jsonl:
181
+ jsonl_sink = JsonlEventSink(Path(config.event_jsonl))
182
+ sinks.append(jsonl_sink)
183
+
184
+ event_bus = EventBus(sinks)
185
+
186
+ result_sink = AsyncQueueResultSink(
187
+ maxsize=config.result_queue_size,
188
+ drop_policy=config.result_drop_policy,
189
+ )
190
+
191
+ controller = GroupLifecycleController(
192
+ event_bus=event_bus,
193
+ follower_dispatch_policy=config.follower_dispatch_policy,
194
+ )
195
+
196
+ scheduler = WorkStealingScheduler(
197
+ pools=[
198
+ PoolConfig(name="primer", preferred_kinds=[GroupTaskKind.PRIMER.value], workers=config.primer_workers),
199
+ PoolConfig(
200
+ name="follower",
201
+ preferred_kinds=[GroupTaskKind.FOLLOWER.value],
202
+ workers=config.follower_workers,
203
+ ),
204
+ ],
205
+ event_bus=event_bus,
206
+ result_sink=result_sink,
207
+ )
208
+
209
+ rng = random.Random(config.seed)
210
+ executor = DemoExecutor(
211
+ controller,
212
+ rng=rng,
213
+ min_delay_s=config.min_delay_s,
214
+ max_delay_s=config.max_delay_s,
215
+ )
216
+
217
+ group_ids: list[str] = []
218
+ for idx in range(config.groups):
219
+ group_id = f"group-{idx + 1}"
220
+ spec = GroupSpec(
221
+ group_id=group_id,
222
+ followers_total=None if config.open_groups else config.followers_per_group,
223
+ warm_delay_s=config.warm_delay_s,
224
+ max_followers_inflight=config.max_followers_inflight,
225
+ payload={"group_id": group_id, "primer": True},
226
+ )
227
+ await controller.add_group(spec)
228
+ if config.open_groups:
229
+ payloads = [
230
+ {"group_id": group_id, "follower": i + 1}
231
+ for i in range(config.followers_per_group)
232
+ ]
233
+ await controller.add_followers(group_id, payloads)
234
+ await controller.close_group(group_id)
235
+ group_ids.append(group_id)
236
+
237
+ async def _wait_groups() -> None:
238
+ await asyncio.gather(*(controller.wait_group_done(gid) for gid in group_ids))
239
+ await controller.close()
240
+
241
+ app = create_tui_app(
242
+ QueueEventSource(event_queue),
243
+ state=TuiState(),
244
+ queue_maxsize=config.event_queue_size,
245
+ )
246
+ app_task = asyncio.create_task(app.run_async())
247
+ scheduler_task = asyncio.create_task(scheduler.run(controller, executor))
248
+ close_task = asyncio.create_task(_wait_groups())
249
+
250
+ done_event = asyncio.Event()
251
+ results_task = asyncio.create_task(_drain_results(result_sink.queue, done_event))
252
+
253
+ done, _ = await asyncio.wait({app_task, scheduler_task}, return_when=asyncio.FIRST_COMPLETED)
254
+
255
+ if scheduler_task in done:
256
+ try:
257
+ report = scheduler_task.result()
258
+ except Exception:
259
+ app.exit()
260
+ await app_task
261
+ close_task.cancel()
262
+ with contextlib.suppress(asyncio.CancelledError):
263
+ await close_task
264
+ if jsonl_sink is not None:
265
+ jsonl_sink.close()
266
+ raise
267
+ done_event.set()
268
+ ok, failed = await results_task
269
+ app.exit()
270
+ await app_task
271
+ close_task.cancel()
272
+ with contextlib.suppress(asyncio.CancelledError):
273
+ await close_task
274
+ if jsonl_sink is not None:
275
+ jsonl_sink.close()
276
+ print("Run complete.")
277
+ print(f"Queued: {report.queued}")
278
+ print(f"Finished: {report.finished}")
279
+ print(f"Failed: {report.failed}")
280
+ print(f"Results OK: {ok} Failed: {failed} Dropped: {result_sink.dropped}")
281
+ if queue_sink.dropped:
282
+ print(f"Events dropped: {queue_sink.dropped}")
283
+ return
284
+
285
+ done_event.set()
286
+ results_task.cancel()
287
+ close_task.cancel()
288
+ scheduler_task.cancel()
289
+ with contextlib.suppress(asyncio.CancelledError):
290
+ await results_task
291
+ with contextlib.suppress(asyncio.CancelledError):
292
+ await close_task
293
+ with contextlib.suppress(asyncio.CancelledError):
294
+ await scheduler_task
295
+ if jsonl_sink is not None:
296
+ jsonl_sink.close()
297
+
298
+
299
+ def build_parser() -> argparse.ArgumentParser:
300
+ parser = argparse.ArgumentParser(description="Run group-only runtime with the acty TUI.")
301
+ parser.add_argument("--groups", type=int, default=3)
302
+ parser.add_argument("--followers-per-group", type=int, default=5)
303
+ parser.add_argument("--open-groups", action="store_true")
304
+ parser.add_argument("--warm-delay-s", type=float, default=0.0)
305
+ parser.add_argument("--max-followers-inflight", type=int, default=2)
306
+ parser.add_argument("--dispatch-mode", type=str, default="target", choices=["serial", "target", "eager"])
307
+ parser.add_argument("--dispatch-target-total", type=int, default=None)
308
+ parser.add_argument("--dispatch-min-per-group", type=int, default=0)
309
+ parser.add_argument("--dispatch-max-per-group", type=int, default=None)
310
+ parser.add_argument("--primer-workers", type=int, default=1)
311
+ parser.add_argument("--follower-workers", type=int, default=2)
312
+ parser.add_argument("--min-delay-s", type=float, default=0.01)
313
+ parser.add_argument("--max-delay-s", type=float, default=0.05)
314
+ parser.add_argument("--event-jsonl", type=str, default="")
315
+ parser.add_argument("--seed", type=int, default=0)
316
+ parser.add_argument("--result-queue-size", type=int, default=0)
317
+ parser.add_argument(
318
+ "--result-drop-policy",
319
+ type=str,
320
+ default="drop_oldest",
321
+ choices=["block", "drop_newest", "drop_oldest"],
322
+ )
323
+ parser.add_argument("--event-queue-size", type=int, default=5000)
324
+ parser.add_argument(
325
+ "--event-drop-policy",
326
+ type=str,
327
+ default="drop_oldest",
328
+ choices=["block", "drop_newest", "drop_oldest"],
329
+ )
330
+ return parser
331
+
332
+
333
+ def _normalize_optional_int(value: int | None) -> int | None:
334
+ if value is None or value == 0:
335
+ return None
336
+ return value
337
+
338
+
339
+ def build_demo_config(args: argparse.Namespace) -> DemoConfig:
340
+ target_total = _normalize_optional_int(args.dispatch_target_total)
341
+ max_per_group = _normalize_optional_int(args.dispatch_max_per_group)
342
+ if args.dispatch_mode == "target" and target_total is None:
343
+ target_total = max(1, args.follower_workers)
344
+ return DemoConfig(
345
+ groups=args.groups,
346
+ followers_per_group=args.followers_per_group,
347
+ open_groups=args.open_groups,
348
+ warm_delay_s=args.warm_delay_s,
349
+ max_followers_inflight=args.max_followers_inflight,
350
+ follower_dispatch_policy=FollowerDispatchPolicy(
351
+ mode=args.dispatch_mode,
352
+ target_total=target_total,
353
+ min_per_group=args.dispatch_min_per_group,
354
+ max_per_group=max_per_group,
355
+ ),
356
+ primer_workers=args.primer_workers,
357
+ follower_workers=args.follower_workers,
358
+ min_delay_s=args.min_delay_s,
359
+ max_delay_s=args.max_delay_s,
360
+ event_jsonl=args.event_jsonl or None,
361
+ seed=args.seed,
362
+ result_queue_size=args.result_queue_size,
363
+ result_drop_policy=args.result_drop_policy,
364
+ event_queue_size=args.event_queue_size,
365
+ event_drop_policy=args.event_drop_policy,
366
+ )
367
+
368
+
369
+ def main(argv: list[str] | None = None) -> int:
370
+ parser = build_parser()
371
+ args = parser.parse_args(argv)
372
+ config = build_demo_config(args)
373
+ asyncio.run(run_tui_demo(config))
374
+ return 0
375
+
376
+
377
+ if __name__ == "__main__":
378
+ raise SystemExit(main())
@@ -0,0 +1,37 @@
1
+ """Lane-aware scheduling demo for Acty."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+
7
+ from acty import ActyEngine, EngineConfig, NoopExecutor
8
+ from acty_core.scheduler import LaneConfig
9
+
10
+
11
+ async def main() -> None:
12
+ config = EngineConfig(
13
+ primer_workers=2,
14
+ follower_workers=2,
15
+ lane_configs={
16
+ "run-a": LaneConfig(weight=2),
17
+ "run-b": LaneConfig(weight=1),
18
+ },
19
+ )
20
+ engine = ActyEngine(executor=NoopExecutor(), config=config)
21
+ try:
22
+ lane_a = engine.lane("run-a")
23
+ lane_b = engine.lane("run-b")
24
+ registry_a = lane_a.registry(group_id_resolver=lambda key: f"{key[0]}:{key[1]}")
25
+ registry_b = lane_b.registry(group_id_resolver=lambda key: f"{key[0]}:{key[1]}")
26
+
27
+ handle_a = await registry_a.ensure(("lane-a", "group-1"))
28
+ handle_b = await registry_b.ensure(("lane-b", "group-1"))
29
+
30
+ await handle_a.submit_primer({"p": "a"})
31
+ await handle_b.submit_primer({"p": "b"})
32
+ finally:
33
+ await engine.close()
34
+
35
+
36
+ if __name__ == "__main__":
37
+ asyncio.run(main())