matrx-assignment 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.
- matrx_assignment-0.1.0/.gitignore +271 -0
- matrx_assignment-0.1.0/AGENTS.md +1 -0
- matrx_assignment-0.1.0/CLAUDE.md +20 -0
- matrx_assignment-0.1.0/FEATURE.md +39 -0
- matrx_assignment-0.1.0/PKG-INFO +30 -0
- matrx_assignment-0.1.0/README.md +18 -0
- matrx_assignment-0.1.0/matrx_assignment/__init__.py +60 -0
- matrx_assignment-0.1.0/matrx_assignment/coordinator.py +189 -0
- matrx_assignment-0.1.0/matrx_assignment/in_memory.py +416 -0
- matrx_assignment-0.1.0/matrx_assignment/models.py +271 -0
- matrx_assignment-0.1.0/matrx_assignment/planner.py +143 -0
- matrx_assignment-0.1.0/matrx_assignment/store.py +57 -0
- matrx_assignment-0.1.0/pyproject.toml +40 -0
- matrx_assignment-0.1.0/tests/test_coordinator.py +145 -0
- matrx_assignment-0.1.0/tests/test_planner.py +87 -0
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
*.pyc
|
|
2
|
+
secrets/
|
|
3
|
+
ignore/
|
|
4
|
+
temp/
|
|
5
|
+
logs/
|
|
6
|
+
# The broad `logs/` rule above is for RUNTIME log output, but it also matched
|
|
7
|
+
# the dashboard's SOURCE directory and silently swallowed an entire feature's
|
|
8
|
+
# files (only the pre-existing index.tsx stayed tracked), breaking the prod
|
|
9
|
+
# Docker build with "Could not resolve ./structured-tab". Re-include the source.
|
|
10
|
+
!apps/dashboard/src/features/logs/
|
|
11
|
+
!apps/dashboard/src/features/logs/**
|
|
12
|
+
todo
|
|
13
|
+
text_notes/
|
|
14
|
+
aidream/secrets/2.env
|
|
15
|
+
automation_matrix/matrix_processing/temp/*
|
|
16
|
+
cd
|
|
17
|
+
# Byte-compiled / optimized / DLL files
|
|
18
|
+
__pycache__/
|
|
19
|
+
*.py[cod]
|
|
20
|
+
*$py.class
|
|
21
|
+
|
|
22
|
+
# C extensions
|
|
23
|
+
*.so
|
|
24
|
+
.venv/
|
|
25
|
+
|
|
26
|
+
# Distribution / packaging
|
|
27
|
+
.Python
|
|
28
|
+
build/
|
|
29
|
+
develop-eggs/
|
|
30
|
+
dist/
|
|
31
|
+
downloads/
|
|
32
|
+
eggs/
|
|
33
|
+
.eggs/
|
|
34
|
+
lib/
|
|
35
|
+
lib64/
|
|
36
|
+
# The blanket lib/ rule above is from the standard Python .gitignore template
|
|
37
|
+
# and was silently swallowing TS source under the SPA `src/lib/` folders.
|
|
38
|
+
# Re-allow them explicitly so frontend builds don't ship without their lib layer.
|
|
39
|
+
!apps/dashboard/src/lib/
|
|
40
|
+
!apps/dashboard/src/lib/**
|
|
41
|
+
!apps/workflow-studio/src/lib/
|
|
42
|
+
!apps/workflow-studio/src/lib/**
|
|
43
|
+
parts/
|
|
44
|
+
sdist/
|
|
45
|
+
var/
|
|
46
|
+
wheels/
|
|
47
|
+
share/python-wheels/
|
|
48
|
+
*.egg-info/
|
|
49
|
+
.installed.cfg
|
|
50
|
+
*.egg
|
|
51
|
+
MANIFEST
|
|
52
|
+
|
|
53
|
+
# PyInstaller
|
|
54
|
+
# Usually these files are written by a python script from a template
|
|
55
|
+
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
|
56
|
+
*.manifest
|
|
57
|
+
*.spec
|
|
58
|
+
|
|
59
|
+
# Installer logs
|
|
60
|
+
pip-log.txt
|
|
61
|
+
pip-delete-this-directory.txt
|
|
62
|
+
|
|
63
|
+
# Unit test / coverage reports
|
|
64
|
+
ai/tests/clean_response.json
|
|
65
|
+
ai/tests/cx_storage_response.json
|
|
66
|
+
ai/tests/execution_test.py
|
|
67
|
+
ai/tests/final_response.json
|
|
68
|
+
htmlcov/
|
|
69
|
+
.tox/
|
|
70
|
+
.nox/
|
|
71
|
+
.coverage
|
|
72
|
+
.coverage.*
|
|
73
|
+
.cache
|
|
74
|
+
nosetests.xml
|
|
75
|
+
coverage.xml
|
|
76
|
+
*.cover
|
|
77
|
+
*.py,cover
|
|
78
|
+
.hypothesis/
|
|
79
|
+
.pytest_cache/
|
|
80
|
+
cover/
|
|
81
|
+
|
|
82
|
+
# Translations
|
|
83
|
+
*.mo
|
|
84
|
+
*.pot
|
|
85
|
+
|
|
86
|
+
# Django stuff:
|
|
87
|
+
*.log
|
|
88
|
+
local_settings.py
|
|
89
|
+
db.sqlite3
|
|
90
|
+
db.sqlite3-journal
|
|
91
|
+
|
|
92
|
+
# Flask stuff:
|
|
93
|
+
instance/
|
|
94
|
+
.webassets-cache
|
|
95
|
+
|
|
96
|
+
# Scrapy stuff:
|
|
97
|
+
.scrapy
|
|
98
|
+
|
|
99
|
+
# Sphinx documentation
|
|
100
|
+
docs/_build/
|
|
101
|
+
|
|
102
|
+
# PyBuilder
|
|
103
|
+
.pybuilder/
|
|
104
|
+
target/
|
|
105
|
+
|
|
106
|
+
# Jupyter Notebook
|
|
107
|
+
.ipynb_checkpoints
|
|
108
|
+
|
|
109
|
+
# IPython
|
|
110
|
+
profile_default/
|
|
111
|
+
ipython_config.py
|
|
112
|
+
|
|
113
|
+
# pyenv
|
|
114
|
+
# For a library or package, you might want to ignore these files since the code is
|
|
115
|
+
# intended to run in multiple environments; otherwise, check them in:
|
|
116
|
+
# .python-version
|
|
117
|
+
|
|
118
|
+
# pipenv
|
|
119
|
+
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
|
120
|
+
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
|
121
|
+
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
|
122
|
+
# install all needed dependencies.
|
|
123
|
+
#Pipfile.lock
|
|
124
|
+
|
|
125
|
+
# poetry
|
|
126
|
+
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
|
127
|
+
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
|
128
|
+
# commonly ignored for libraries.
|
|
129
|
+
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
|
130
|
+
|
|
131
|
+
# pdm
|
|
132
|
+
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
|
133
|
+
#pdm.lock
|
|
134
|
+
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
|
|
135
|
+
# in version control.
|
|
136
|
+
# https://pdm.fming.dev/#use-with-ide
|
|
137
|
+
.pdm.toml
|
|
138
|
+
|
|
139
|
+
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
|
140
|
+
__pypackages__/
|
|
141
|
+
|
|
142
|
+
# Celery stuff
|
|
143
|
+
celerybeat-schedule
|
|
144
|
+
celerybeat.pid
|
|
145
|
+
|
|
146
|
+
# SageMath parsed files
|
|
147
|
+
*.sage.py
|
|
148
|
+
|
|
149
|
+
# Environments
|
|
150
|
+
.env
|
|
151
|
+
.env_remote
|
|
152
|
+
.venv
|
|
153
|
+
env/
|
|
154
|
+
venv/
|
|
155
|
+
ENV/
|
|
156
|
+
env.bak/
|
|
157
|
+
venv.bak/
|
|
158
|
+
.env.armanonly
|
|
159
|
+
|
|
160
|
+
# Spyder project settings
|
|
161
|
+
.spyderproject
|
|
162
|
+
.spyproject
|
|
163
|
+
|
|
164
|
+
# Rope project settings
|
|
165
|
+
.ropeproject
|
|
166
|
+
|
|
167
|
+
# mkdocs documentation
|
|
168
|
+
/site
|
|
169
|
+
|
|
170
|
+
# mypy
|
|
171
|
+
.mypy_cache/
|
|
172
|
+
.dmypy.json
|
|
173
|
+
dmypy.json
|
|
174
|
+
|
|
175
|
+
# Pyre type checker
|
|
176
|
+
.pyre/
|
|
177
|
+
|
|
178
|
+
# random armani files
|
|
179
|
+
/armani_dev/secrets/
|
|
180
|
+
/armani/
|
|
181
|
+
/_armani/
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
# pytype static type analyzer
|
|
186
|
+
.pytype/
|
|
187
|
+
|
|
188
|
+
# Cython debug symbols
|
|
189
|
+
cython_debug/
|
|
190
|
+
|
|
191
|
+
.idea/
|
|
192
|
+
.vscode/
|
|
193
|
+
/node_modules/
|
|
194
|
+
|
|
195
|
+
# Frontend pnpm workspace (apps/) — node_modules at the workspace root and any
|
|
196
|
+
# member, plus Vite caches and build output. The unified lockfile (apps/pnpm-lock.yaml)
|
|
197
|
+
# IS committed; everything below is regenerated.
|
|
198
|
+
node_modules/
|
|
199
|
+
apps/**/.vite/
|
|
200
|
+
apps/**/dist/
|
|
201
|
+
.vite/
|
|
202
|
+
|
|
203
|
+
dump.rdb
|
|
204
|
+
|
|
205
|
+
frontend/
|
|
206
|
+
|
|
207
|
+
# AME Temp Files and directory structure
|
|
208
|
+
# Ignore all files in the temp directory and its subdirectories
|
|
209
|
+
/temp/**/*
|
|
210
|
+
/tmp/**/*
|
|
211
|
+
|
|
212
|
+
# Allow .gitkeep files to retain directory structure
|
|
213
|
+
!/temp/**/.gitkeep
|
|
214
|
+
!/tmp/**/.gitkeep
|
|
215
|
+
|
|
216
|
+
# Armani
|
|
217
|
+
.history*
|
|
218
|
+
.history/
|
|
219
|
+
local_data/
|
|
220
|
+
local_reports_data/
|
|
221
|
+
webscraper/quick_scrapes/temp/
|
|
222
|
+
automation_matrix/ai_apis/fireworks/_dev/*
|
|
223
|
+
automation_matrix/ai_apis/fireworks/_dev/fireworks_sample.py
|
|
224
|
+
*.pdf
|
|
225
|
+
*.flac
|
|
226
|
+
*.mp3
|
|
227
|
+
*.wav
|
|
228
|
+
miniconda.sh
|
|
229
|
+
/database/python_sql/temp_data/
|
|
230
|
+
.history*
|
|
231
|
+
.history/
|
|
232
|
+
.history/
|
|
233
|
+
|
|
234
|
+
_dev/
|
|
235
|
+
/_dev/
|
|
236
|
+
requirements_filtered.txt
|
|
237
|
+
|
|
238
|
+
# matrx-dev-tools backups
|
|
239
|
+
.env-backups/
|
|
240
|
+
# Matrx Ship config (contains API key)
|
|
241
|
+
.matrx-ship.json
|
|
242
|
+
|
|
243
|
+
# Matrx config (contains API keys)
|
|
244
|
+
.matrx.json
|
|
245
|
+
.matrx-tools.conf
|
|
246
|
+
|
|
247
|
+
# Claude Code local worktrees and per-user settings
|
|
248
|
+
.claude/worktrees/
|
|
249
|
+
.claude/settings.local.json
|
|
250
|
+
|
|
251
|
+
# Append-only snapshots from matrx_utils.update_history (unbounded; do not commit)
|
|
252
|
+
common/utils/data_in_code/data_history.json
|
|
253
|
+
packages/matrx-utils/matrx_utils/data_in_code/data_history.json
|
|
254
|
+
|
|
255
|
+
# Tool-dispatch debug logs — one file per server start, never committed
|
|
256
|
+
.matrx-debug/
|
|
257
|
+
|
|
258
|
+
# macOS Finder metadata
|
|
259
|
+
.DS_Store
|
|
260
|
+
**/.DS_Store
|
|
261
|
+
|
|
262
|
+
# Environment files
|
|
263
|
+
.env
|
|
264
|
+
.env.*
|
|
265
|
+
*.env
|
|
266
|
+
*.env.*
|
|
267
|
+
|
|
268
|
+
# Keep safe templates trackable
|
|
269
|
+
!.env.example
|
|
270
|
+
!.env.sample
|
|
271
|
+
!.env.template
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
CLAUDE.md
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# CLAUDE.md — matrx-assignment
|
|
2
|
+
|
|
3
|
+
`matrx-assignment` is the payload-agnostic assignment planner and coordinator. It
|
|
4
|
+
materializes variable values, tracks resumable item state through an injected
|
|
5
|
+
`AssignmentStore`, and calls a host-provided executor. It never imports an agent,
|
|
6
|
+
workflow, HTTP, database, cache, or host application.
|
|
7
|
+
|
|
8
|
+
Hard rules:
|
|
9
|
+
|
|
10
|
+
- Every known contract is a strict Pydantic model with `extra="forbid"`.
|
|
11
|
+
- Random choices use `secrets.SystemRandom`; pseudo-random seeds and time-derived
|
|
12
|
+
selection are forbidden.
|
|
13
|
+
- A plan is materialized exactly once. Retries consume persisted items and never
|
|
14
|
+
re-randomize them.
|
|
15
|
+
- Persistence is a protocol. Database implementations live in the host and must
|
|
16
|
+
use matrx-orm.
|
|
17
|
+
- The coordinator owns assignment lifecycle only. It does not execute agents or
|
|
18
|
+
workflows itself.
|
|
19
|
+
|
|
20
|
+
Run package tests with `uv run pytest packages/matrx-assignment/tests`.
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# Coordinated assignment engine
|
|
2
|
+
|
|
3
|
+
`matrx-assignment` is the payload-blind planning and coordination library for
|
|
4
|
+
turning variable option sets into durable, executable items. It has no agent,
|
|
5
|
+
workflow, database, or HTTP dependency.
|
|
6
|
+
|
|
7
|
+
## Contracts
|
|
8
|
+
|
|
9
|
+
- All known request, plan, item, claim, error, and result shapes are strict,
|
|
10
|
+
frozen Pydantic models (`extra="forbid"`).
|
|
11
|
+
- `coordinated_rows` preserves paired values such as `topic` + `research`.
|
|
12
|
+
- `independent_random` draws combinations with `secrets.SystemRandom`; it can
|
|
13
|
+
allow repeats or sample without replacement.
|
|
14
|
+
- `cartesian` enumerates every combination, in declared or randomized order.
|
|
15
|
+
- A plan is materialized exactly once. Recovery always reuses the persisted
|
|
16
|
+
materialization and never redraws random values.
|
|
17
|
+
|
|
18
|
+
## Host seams
|
|
19
|
+
|
|
20
|
+
`AssignmentStore` is the only persistence protocol. `AssignmentCoordinator`
|
|
21
|
+
leases items with `SKIP LOCKED` semantics supplied by the host, heartbeats long
|
|
22
|
+
executions, retries retryable failures, reaps expired leases, and returns a
|
|
23
|
+
complete typed result. The injected executor receives a resolved value map and
|
|
24
|
+
can run an agent, image generator, or any future workload without changing the
|
|
25
|
+
engine.
|
|
26
|
+
|
|
27
|
+
## Recovery and idempotency
|
|
28
|
+
|
|
29
|
+
The host supplies a logical session key. A retry with the same key resumes the
|
|
30
|
+
same session and deterministic item identities. Each item owns a deterministic
|
|
31
|
+
conversation identity, while attempts are separately audited. Provider calls
|
|
32
|
+
remain at-least-once at the unavoidable crash boundary between an external side
|
|
33
|
+
effect and its database acknowledgement; deterministic request identities make
|
|
34
|
+
that boundary reconcilable instead of invisible.
|
|
35
|
+
|
|
36
|
+
## Change log
|
|
37
|
+
|
|
38
|
+
- **2026-07-18** — Initial strict planner, in-memory reference store, lease-based
|
|
39
|
+
coordinator, heartbeat/retry recovery, and standalone tests.
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: matrx-assignment
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Typed planning and durable coordination for resolved variable assignments
|
|
5
|
+
Requires-Python: >=3.13
|
|
6
|
+
Requires-Dist: pydantic>=2.12
|
|
7
|
+
Provides-Extra: dev
|
|
8
|
+
Requires-Dist: pytest-asyncio>=0.25.0; extra == 'dev'
|
|
9
|
+
Requires-Dist: pytest>=8.3.0; extra == 'dev'
|
|
10
|
+
Requires-Dist: ruff>=0.9.0; extra == 'dev'
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
|
|
13
|
+
# matrx-assignment
|
|
14
|
+
|
|
15
|
+
Typed, payload-agnostic assignment planning and durable coordination.
|
|
16
|
+
|
|
17
|
+
The library turns a strict plan into immutable work items, then drives those
|
|
18
|
+
items through an injected store and executor. Hosts can begin with coordinated
|
|
19
|
+
rows (for example, 50 paired `topic` + `research` inputs) and later add policies
|
|
20
|
+
without changing the agent or workflow execution systems.
|
|
21
|
+
|
|
22
|
+
Supported plans:
|
|
23
|
+
|
|
24
|
+
- `coordinated_rows`: preserve paired values and run every row once.
|
|
25
|
+
- `independent_random`: choose every variable independently with unbiased system
|
|
26
|
+
randomness, optionally without repeating a full combination.
|
|
27
|
+
- `cartesian`: enumerate combinations, optionally in unbiased random order.
|
|
28
|
+
|
|
29
|
+
Random plans are materialized once and persisted by the host. Recovery therefore
|
|
30
|
+
resumes the same items instead of producing a new draw.
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# matrx-assignment
|
|
2
|
+
|
|
3
|
+
Typed, payload-agnostic assignment planning and durable coordination.
|
|
4
|
+
|
|
5
|
+
The library turns a strict plan into immutable work items, then drives those
|
|
6
|
+
items through an injected store and executor. Hosts can begin with coordinated
|
|
7
|
+
rows (for example, 50 paired `topic` + `research` inputs) and later add policies
|
|
8
|
+
without changing the agent or workflow execution systems.
|
|
9
|
+
|
|
10
|
+
Supported plans:
|
|
11
|
+
|
|
12
|
+
- `coordinated_rows`: preserve paired values and run every row once.
|
|
13
|
+
- `independent_random`: choose every variable independently with unbiased system
|
|
14
|
+
randomness, optionally without repeating a full combination.
|
|
15
|
+
- `cartesian`: enumerate combinations, optionally in unbiased random order.
|
|
16
|
+
|
|
17
|
+
Random plans are materialized once and persisted by the host. Recovery therefore
|
|
18
|
+
resumes the same items instead of producing a new draw.
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
from matrx_assignment.coordinator import (
|
|
2
|
+
AssignmentCoordinator,
|
|
3
|
+
AssignmentExecutionFailure,
|
|
4
|
+
AssignmentExecutor,
|
|
5
|
+
)
|
|
6
|
+
from matrx_assignment.in_memory import InMemoryAssignmentStore
|
|
7
|
+
from matrx_assignment.models import (
|
|
8
|
+
AssignmentBatchResult,
|
|
9
|
+
AssignmentClaim,
|
|
10
|
+
AssignmentError,
|
|
11
|
+
AssignmentExecutionOutput,
|
|
12
|
+
AssignmentItemResult,
|
|
13
|
+
AssignmentItemSnapshot,
|
|
14
|
+
AssignmentItemStatus,
|
|
15
|
+
AssignmentOrder,
|
|
16
|
+
AssignmentPlan,
|
|
17
|
+
AssignmentRow,
|
|
18
|
+
AssignmentSessionRequest,
|
|
19
|
+
AssignmentSessionStatus,
|
|
20
|
+
AssignmentSessionSummary,
|
|
21
|
+
AssignmentSource,
|
|
22
|
+
AssignmentUniqueness,
|
|
23
|
+
CartesianPlan,
|
|
24
|
+
CoordinatedRowsPlan,
|
|
25
|
+
IndependentRandomPlan,
|
|
26
|
+
MaterializedAssignment,
|
|
27
|
+
MaterializedPlan,
|
|
28
|
+
)
|
|
29
|
+
from matrx_assignment.planner import AssignmentPlanner, fingerprint_json
|
|
30
|
+
from matrx_assignment.store import AssignmentStore
|
|
31
|
+
|
|
32
|
+
__all__ = [
|
|
33
|
+
"AssignmentBatchResult",
|
|
34
|
+
"AssignmentClaim",
|
|
35
|
+
"AssignmentCoordinator",
|
|
36
|
+
"AssignmentError",
|
|
37
|
+
"AssignmentExecutionFailure",
|
|
38
|
+
"AssignmentExecutionOutput",
|
|
39
|
+
"AssignmentExecutor",
|
|
40
|
+
"AssignmentItemResult",
|
|
41
|
+
"AssignmentItemSnapshot",
|
|
42
|
+
"AssignmentItemStatus",
|
|
43
|
+
"AssignmentOrder",
|
|
44
|
+
"AssignmentPlan",
|
|
45
|
+
"AssignmentPlanner",
|
|
46
|
+
"AssignmentRow",
|
|
47
|
+
"AssignmentSessionRequest",
|
|
48
|
+
"AssignmentSessionStatus",
|
|
49
|
+
"AssignmentSessionSummary",
|
|
50
|
+
"AssignmentSource",
|
|
51
|
+
"AssignmentStore",
|
|
52
|
+
"AssignmentUniqueness",
|
|
53
|
+
"CartesianPlan",
|
|
54
|
+
"CoordinatedRowsPlan",
|
|
55
|
+
"IndependentRandomPlan",
|
|
56
|
+
"InMemoryAssignmentStore",
|
|
57
|
+
"MaterializedAssignment",
|
|
58
|
+
"MaterializedPlan",
|
|
59
|
+
"fingerprint_json",
|
|
60
|
+
]
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import socket
|
|
5
|
+
from collections.abc import Awaitable, Callable
|
|
6
|
+
from datetime import UTC, datetime, timedelta
|
|
7
|
+
from typing import Protocol
|
|
8
|
+
|
|
9
|
+
from matrx_assignment.models import (
|
|
10
|
+
AssignmentBatchResult,
|
|
11
|
+
AssignmentClaim,
|
|
12
|
+
AssignmentError,
|
|
13
|
+
AssignmentExecutionOutput,
|
|
14
|
+
AssignmentItemResult,
|
|
15
|
+
AssignmentSessionRequest,
|
|
16
|
+
AssignmentSessionStatus,
|
|
17
|
+
)
|
|
18
|
+
from matrx_assignment.store import AssignmentStore
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class AssignmentExecutor(Protocol):
|
|
22
|
+
async def __call__(self, claim: AssignmentClaim) -> AssignmentExecutionOutput: ...
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
ProgressCallback = Callable[[int, int], Awaitable[None]]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class AssignmentExecutionFailure(RuntimeError):
|
|
29
|
+
def __init__(
|
|
30
|
+
self,
|
|
31
|
+
message: str,
|
|
32
|
+
*,
|
|
33
|
+
code: str = "assignment_execution_failed",
|
|
34
|
+
retryable: bool = True,
|
|
35
|
+
details: dict[str, object] | None = None,
|
|
36
|
+
) -> None:
|
|
37
|
+
super().__init__(message)
|
|
38
|
+
self.error = AssignmentError(
|
|
39
|
+
code=code,
|
|
40
|
+
message=message,
|
|
41
|
+
retryable=retryable,
|
|
42
|
+
details=details or {},
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class AssignmentCoordinator:
|
|
47
|
+
"""Lease and execute persisted items without knowing what the payload does."""
|
|
48
|
+
|
|
49
|
+
def __init__(self, store: AssignmentStore, *, holder: str | None = None) -> None:
|
|
50
|
+
self._store = store
|
|
51
|
+
self._holder = holder or socket.gethostname()
|
|
52
|
+
|
|
53
|
+
async def run(
|
|
54
|
+
self,
|
|
55
|
+
session_id: str,
|
|
56
|
+
request: AssignmentSessionRequest,
|
|
57
|
+
executor: AssignmentExecutor,
|
|
58
|
+
*,
|
|
59
|
+
progress: ProgressCallback | None = None,
|
|
60
|
+
) -> AssignmentBatchResult:
|
|
61
|
+
await self._store.reap_expired_claims(session_id, now=datetime.now(UTC))
|
|
62
|
+
while True:
|
|
63
|
+
session = await self._store.get_session(session_id)
|
|
64
|
+
if session.status in {
|
|
65
|
+
AssignmentSessionStatus.COMPLETED,
|
|
66
|
+
AssignmentSessionStatus.PARTIALLY_FAILED,
|
|
67
|
+
AssignmentSessionStatus.FAILED,
|
|
68
|
+
AssignmentSessionStatus.CANCELLED,
|
|
69
|
+
}:
|
|
70
|
+
break
|
|
71
|
+
|
|
72
|
+
claims = await self._store.claim_items(
|
|
73
|
+
session_id,
|
|
74
|
+
holder=self._holder,
|
|
75
|
+
limit=request.max_concurrency,
|
|
76
|
+
lease_seconds=request.lease_seconds,
|
|
77
|
+
)
|
|
78
|
+
if not claims:
|
|
79
|
+
session = await self._store.finalize_session(session_id)
|
|
80
|
+
if session.status is AssignmentSessionStatus.RUNNING:
|
|
81
|
+
await asyncio.sleep(
|
|
82
|
+
min(1.0, max(0.05, float(request.retry_delay_seconds)))
|
|
83
|
+
)
|
|
84
|
+
continue
|
|
85
|
+
break
|
|
86
|
+
|
|
87
|
+
await asyncio.gather(
|
|
88
|
+
*(
|
|
89
|
+
self._execute_one(
|
|
90
|
+
claim,
|
|
91
|
+
request=request,
|
|
92
|
+
executor=executor,
|
|
93
|
+
)
|
|
94
|
+
for claim in claims
|
|
95
|
+
)
|
|
96
|
+
)
|
|
97
|
+
if progress is not None:
|
|
98
|
+
snapshot = await self._store.get_session(session_id)
|
|
99
|
+
await progress(snapshot.completed_items + snapshot.failed_items, snapshot.total_items)
|
|
100
|
+
|
|
101
|
+
await self._store.finalize_session(session_id)
|
|
102
|
+
return await self.snapshot(session_id)
|
|
103
|
+
|
|
104
|
+
async def snapshot(self, session_id: str) -> AssignmentBatchResult:
|
|
105
|
+
"""Load the durable result without claiming or executing any work."""
|
|
106
|
+
|
|
107
|
+
final_session = await self._store.get_session(session_id)
|
|
108
|
+
items = await self._store.list_items(session_id)
|
|
109
|
+
return AssignmentBatchResult(
|
|
110
|
+
session=final_session,
|
|
111
|
+
items=[
|
|
112
|
+
AssignmentItemResult(
|
|
113
|
+
id=item.id,
|
|
114
|
+
ordinal=item.ordinal,
|
|
115
|
+
key=item.key,
|
|
116
|
+
status=item.status,
|
|
117
|
+
values=item.values,
|
|
118
|
+
output=item.output,
|
|
119
|
+
output_kind=item.output_kind,
|
|
120
|
+
output_kind_version=item.output_kind_version,
|
|
121
|
+
conversation_id=item.conversation_id,
|
|
122
|
+
runtime_execution_id=item.runtime_execution_id,
|
|
123
|
+
error=item.error,
|
|
124
|
+
)
|
|
125
|
+
for item in items
|
|
126
|
+
],
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
async def _execute_one(
|
|
130
|
+
self,
|
|
131
|
+
claim: AssignmentClaim,
|
|
132
|
+
*,
|
|
133
|
+
request: AssignmentSessionRequest,
|
|
134
|
+
executor: AssignmentExecutor,
|
|
135
|
+
) -> None:
|
|
136
|
+
await self._store.mark_running(claim)
|
|
137
|
+
heartbeat_stop = asyncio.Event()
|
|
138
|
+
heartbeat = asyncio.create_task(
|
|
139
|
+
self._renew_lease(
|
|
140
|
+
claim,
|
|
141
|
+
lease_seconds=request.lease_seconds,
|
|
142
|
+
stop=heartbeat_stop,
|
|
143
|
+
)
|
|
144
|
+
)
|
|
145
|
+
try:
|
|
146
|
+
output = await executor(claim)
|
|
147
|
+
except AssignmentExecutionFailure as exc:
|
|
148
|
+
error = exc.error
|
|
149
|
+
except Exception as exc: # executor boundary: unknown failures are retryable by default
|
|
150
|
+
error = AssignmentError(
|
|
151
|
+
code=type(exc).__name__,
|
|
152
|
+
message=str(exc) or type(exc).__name__,
|
|
153
|
+
retryable=True,
|
|
154
|
+
)
|
|
155
|
+
else:
|
|
156
|
+
await self._store.complete_item(claim, output)
|
|
157
|
+
return
|
|
158
|
+
finally:
|
|
159
|
+
heartbeat_stop.set()
|
|
160
|
+
await heartbeat
|
|
161
|
+
|
|
162
|
+
retryable = error.retryable and claim.attempt_number < claim.item.max_attempts
|
|
163
|
+
if retryable != error.retryable:
|
|
164
|
+
error = error.model_copy(update={"retryable": False})
|
|
165
|
+
delay = request.retry_delay_seconds * (2 ** max(0, claim.attempt_number - 1))
|
|
166
|
+
await self._store.fail_item(
|
|
167
|
+
claim,
|
|
168
|
+
error,
|
|
169
|
+
retry_at=datetime.now(UTC) + timedelta(seconds=delay),
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
async def _renew_lease(
|
|
173
|
+
self,
|
|
174
|
+
claim: AssignmentClaim,
|
|
175
|
+
*,
|
|
176
|
+
lease_seconds: int,
|
|
177
|
+
stop: asyncio.Event,
|
|
178
|
+
) -> None:
|
|
179
|
+
interval = max(10.0, lease_seconds / 3)
|
|
180
|
+
while True:
|
|
181
|
+
try:
|
|
182
|
+
await asyncio.wait_for(stop.wait(), timeout=interval)
|
|
183
|
+
return
|
|
184
|
+
except TimeoutError:
|
|
185
|
+
if not await self._store.renew_claim(
|
|
186
|
+
claim,
|
|
187
|
+
lease_seconds=lease_seconds,
|
|
188
|
+
):
|
|
189
|
+
return
|