Семантика планировщика — контракт исполнения¶
Эта страница — контракт исполнения Runtime: как запуск решает, что
выполняется, в каком порядке и когда останавливается. Всё здесь описывает
уже существующее поведение реализации (конституция §21–§24, §41–§44, §54,
§58–§59, §69) — это спецификация, а не роадмап.
Если код и эта страница когда-нибудь разойдутся — баг в одном из них. Тесты в
tests/test_scheduler_semantics.py фиксируют ключевые утверждения, чтобы
рефакторинг, меняющий их, падал в CI, а не тихо расходился с документом.
1. Термины¶
| Термин | Значение |
|---|---|
| Turn (ход) | Один вызов Runtime.arun() / arun_once() / astream(). Ограничен Budget и max_iterations. |
| Generation (поколение) | Один проход цикла диспетчеризации: drain событий → match → schedule → run → commit. |
| Run (запуск) | Одно выполнение агента внутри поколения — единица счёта против Budget.max_runs. |
| Event (событие) | Производная запись изменения состояния. Никогда не создаётся вручную: коммит create/update/delete артефакта и есть событие (reactifact.events). |
| Commit (коммит) | Скомпилированный Patch одного агента, применённый к Context, плюс записанные им reads. |
Задача рантайма — превратить «какие артефакты существуют» в «что выполняется дальше». Предварительно нарисованного графа исполнения нет; расписание — это функция от состояния (§63).
2. Одно поколение, пошагово¶
Реализация — Runtime._arun_once_impl (reactifact/runtime.py).
-
Drain.
context.drain_events()отдаёт очередь событий в порядке FIFO (порядок эмиссии) и очищает её. Если очередь пуста, поколение даёт 0 запусков, и ход завершается (quiescence, §3). -
Match. Для каждого события каждый агент (в порядке приоритета) опрашивается через
agent.matching_triggers(event, context). Триггер совпадает по: - типу события (
artifact_created/updated/deleted/stale), - точному равенству типа артефакта (не
issubclass), - необязательным
condition(artifact)и/илиcontext_condition(artifact, context)(последнее — то, что используютCorrelatedConsume/JoinConsume/AbsentConsumeдля корреляции между типами).
Consume(wakes=False) не даёт триггеров — он только питает inputs. Если
condition'у нужен артефакт, а тот больше не резолвится (удалён раньше в этом
же поколении или в предыдущем), триггер не совпадает (Trigger.matches).
-
Debounce. Если все совпавшие триггеры агента на событие несут
debounce=True, агент схлопывается: максимум одна запись на поколение, и побеждает последнее совпавшее событие в осушенном батче. Агент со смесью debounced и не-debounced совпадений на это событие трактуется как не-debounced. Схлопнутый запуск стоит один противmax_runs. Debounced produce обязан читатьinputs(собираются заново изContext), а неevent— «какое именно изменение» debounce как раз и отбрасывает. -
Order. Не-debounced работа собирается в порядке
(порядок событий × приоритет агента); debounced-записи добавляются после, затем весь список стабильно сортируется поAgent.priority(по возрастанию). То естьpriority— внешний ключ упорядочивания, а при равенстве сохраняется порядок вставки. -
Schedule (опционально). Если задан
Scheduler,scheduler(context, work)проходит по списку кандидатов. Встроенныйuncertainty_policy: filter → детерминированный rank → опционально просьба к LLM упорядочить близкую верхнюю пару → опционально top‑k. Зашиты два инварианта: HITL-resume pin (кандидат, разблокирующий отвеченныйPendingQuestion, принудительно идёт первым) и no-starvation fallback (фильтрация никогда не опустошает непустой список кандидатов). -
Budget slice. Работа обрезается по остатку
max_runs. Если не осталось ничего — исход становитсяbudget_runs_exceeded, поколение останавливается. Важно: события уже осушены на шаге 1 — работа, отброшенная бюджетом, не возвращается в очередь. -
Dispatch. Агенты исполняются на снапшоте поколения (шаг 5 в §4). При ограничениях конкурентности — через глобальный семафор
max_concurrencyплюс пер-агентные тирыconcurrency_limit(глобальный захватывается первым, в фиксированном порядке — против дедлоков). Каждый produce получает свой слотEffectsчерез contextvar, поэтому параллельные produces не видят незакоммиченные эффекты друг друга. -
Collect. Результаты собираются в порядке работы, независимо от того, в каком порядке задачи реально завершились (
asyncio.gatherсохраняет порядок аргументов). -
Validate. Каждый
Createв патче результата проверяется против типов tupleproducesэтого агента (основной output и дополнительные create). Необъявленный тип приводит к исключению до применения. -
Commit. Для каждого агента его
Patchприменяется как одно целое, операции — по порядку:Createс уже существующим id становится обновлением (новая версия той же сущности), а не дубликатом (§42);Updateс неизменившимися данными — no-op: ни новой версии, ни события (поэтому no-op патчи не каскадят, §41);- рост версии эмитит
ARTIFACT_STALEдля артефактов, чей производящий коммит читал старую версию (Context.update→_dependents_of).
Поколение возвращает число запусков (считается каждый исполненный агент — даже если патч был пуст или агент упал под изоляцией).
3. Завершение и quiescence¶
Runtime._arun_impl повторяет поколения. Останавливается, когда любое из:
- поколение дало 0 запусков (нет осушенных событий, ни один агент не
совпал, или вся оставшаяся работа обрезана) →
completed; - достигнут дедлайн по времени →
budget_time_exceeded; - исчерпан
max_runs→budget_runs_exceeded; - достигнут лимит итераций →
iterations_exhausted.
Структурной гарантии завершения нет. Цикл (агент A создаёт B, агент B
обновляет A) сам по себе никогда не квиесцится; останавливает его только
бюджет. Это осознанно: фреймворк не запускает детектор циклов, а реактивная
программа, которая действительно хочет продолжать реагировать, имеет на это
право. Защита — бюджеты.
Поскольку завершение управляется бюджетом, в продакшене реактивной программе
стоит всегда задавать бюджет. Budget(max_runs=…) — самый дешёвый
предохранитель; max_seconds ограничивает медленный ход.
4. Гарантии порядка¶
- События обрабатываются в порядке эмиссии (FIFO).
- Порядок работы и коммитов следует
priority(по возрастанию, стабильно) и внутри поколения не зависит от порядка завершения задач. Context.list_artifactsсохраняет порядок вставки (создания), поэтому собственный стабильный sort вызывающего по грубому ключу (например,updated_at) при равенстве разбивается в порядке создания.- Следствие: при детерминированных produces и фиксированном списке агентов последовательность коммитов детерминирована даже при параллельной диспетчеризации — чередование side-effect'ов нет, но результирующие переходы состояния да.
5. Гарантии конкурентности¶
- Снапшот-изоляция на поколение. Все агенты поколения читают один и тот же
Contextдо коммита; никто не видит записи соседа из этого же поколения. Изменённое чтение становится видимым только в следующем поколении. - Сериализованный коммит. Патчи применяются только после того, как все воркеры поколения завершились — поколение это барьер.
- Ограниченный параллелизм.
Runtime.max_concurrencyограничивает глобально;Agent.concurrency_limit— отдельного агента (удобно тормозить LLM-bound produces отдельно от дешёвого I/O). - Изолированные слоты эффектов. Один слот
Effectsна produce (contextvar, который пушит рантайм) — параллельные produces не наблюдают незакоммиченные эффекты друг друга. - Side-effect'ы внутри produce (LLM-вызовы, сеть, диск) выполняются параллельно; сериализуются только их изменения состояния.
6. Семантика бюджета¶
Budget (reactifact/budget.py) — на ход, сбрасывается в _begin_turn.
| Параметр | Где применяется | При исчерпании |
|---|---|---|
max_runs |
обрезка работы + цикл матчинга в _arun_once_impl |
budget_runs_exceeded |
max_seconds |
_budget_exhausted(), между поколениями; также публикуется в resources.budget_deadline для tool-циклов |
budget_time_exceeded |
max_iterations |
граница цикла _arun_impl |
iterations_exhausted |
max_tool_calls |
tool-циклы (ToolUse/LLMAgent), не ядро рантайма |
цикл останавливается, честный ответ |
max_tokens |
_budget_exhausted() между поколениями + tool-циклы (через resources.budget_tracker); считается из usage каждого ответа LLM, независимо от трейсера |
budget_tokens_exceeded |
max_cost |
то же, из RuntimeResources(pricer=…) |
budget_cost_exceeded (инертен без pricer) |
arun(max_iterations=…) используется только когда активный бюджет не задаёт
max_iterations; явный Budget.max_iterations побеждает.
Бюджеты на токены/стоимость останавливают следующий шаг поколения/цикла, а не
текущий вызов — поэтому прогон может перебрать максимум на один вызов, как и
max_seconds. Трейсер им не нужен: рантайм ставит собственный счётчик usage
(BudgetLLM) на ход, когда бюджет отслеживает токены/стоимость. Для стоимости
нужен инжектированный Pricer (reactifact.pricing); встроенную таблицу цен
фреймворк сознательно не поставляет.
7. Семантика ошибок¶
- Fail loud по умолчанию (§69). Исключение агента всплывает из
arun()/astream()и прерывает ход. Перед этим всем уже запланированным соседям в этом поколении дают завершиться (рантайм собирает черезreturn_exceptions=Trueи только потом пере-возбуждает первое исключение) — ни одна запущенная задача не остаётся висеть без await. isolate_errors=True— явный опт-ин в устойчивость: упавший агент не даёт патча, вызываетсяon_agent_error(agent, event, exc)(если задан), запуск продолжается, а сбой считается вRunStats.errors. Изоляция — осознанное продуктовое решение, а не страховка по умолчанию.- Reentrancy. Экземпляр
Runtime— одноходовой: второй конкурентныйarun/arun_once/astreamна том же экземпляре бросаетRuntimeError, а не гоняется за общим состоянием хода. Используйте одинRuntimeна конкурентный запрос;Context.resourcesони могут шарить. - Наблюдаемость делегирована
reactifact.tracing.RunTracer: спаны строятся по поколениям, аRunTraceхода доставляется один раз в конце (on_turn_end). Падающий приёмник или трейсер никогда не прерывает запуск — см. Наблюдаемость.
8. Детерминизм: что гарантируется, а что нет¶
Гарантируется (при том же стартовом состоянии, агентах и входах):
- события выводятся из закоммиченного состояния — причинная цепочка не может разойтись с состоянием;
- поколение — это снапшот, за которым следует упорядоченный сериализованный коммит;
- порядок коммитов не зависит от порядка завершения;
- неизменившийся
updateиcreate(id=existing)не создают ни новой версии, ни события; - порядок
Context.list_artifacts— это порядок вставки.
Не гарантируется (нужно контролировать приложению для воспроизводимости):
- вывод LLM/провайдера (вызовы
from_env(), результаты тулов); - wall-clock —
Artifact.created_at,Event.timestamp, тайминги спанов; - авто-id (
uuid4) и всё, что засеяно случайностью; - порядок обхода
set/dictвнутри собственной логики produce (у самого reactifact порядок основан на вставке, но код пользователя — на его совести); - относительное чередование конкурентных side-effect'ов;
- LLM tie-break планировщика, когда он включён (переставляет верхние две кандидатуры).
Чтобы сделать запуск воспроизводимым, гоняйте LLM через записанный провайдер, а
время/id считайте инъектируемыми — reactifact.replay (ReplayLLM) детерминированно
воспроизводит записанные решения; см. Replay.
9. Явные не-гарантии¶
- Один процесс. Рантайм планирует в одном event loop; распределённого исполнения и cross-process exactly-once нет.
- Durability на границах поколений, а не cross-process транзакции. При
дефолтном
session_save_policy="per_commit"сессия сохраняется после каждого поколения, вместе с очередью отложенных триггеров: откройте сессию заново и вызовитеarun(), чтобы продолжить прерванный прогон (at-least-once — produce, который уже закоммитился, может выполниться снова, используйте stable id). Это устойчивость, а не распределённая транзакция. - Нет структурного завершения (§3) и нет справедливости между поколениями — гарантия no-starvation действует внутри одного вызова планирования, а не на всём ходу.
- Бюджет отбрасывает работу. После коммита поколения его батч триггеров считается обработанным, даже если бюджет позволил выполнить лишь часть. (Упавший с исключением агент — другое: батч остаётся в очереди, и retry перезапустит его.)
10. Карта на код и конституцию¶
| Понятие | Код | Конституция |
|---|---|---|
| Цикл поколений | reactifact/runtime.py _arun_once_impl, _arun_impl |
§21, §23, §24 |
| Матчинг | reactifact/triggers.py Trigger.matches, reactifact/agents.py matching_triggers |
§22 |
| Debounce / порядок / обрезка бюджета | runtime.py _arun_once_impl |
§24, §58 |
| Политика планирования | reactifact/scheduler.py Scheduler |
§26 |
| Конкурентность | runtime.py _dispatch |
§42, §58 |
| Атомарный коммит / идемпотентность | runtime.py _commit_patches_to_apply, _apply_patch; context.py create/update |
§41, §42, §43 |
| Устаревание | context.py update → _dependents_of, stale_artifacts |
§43, §44 |
| Бюджет / исходы | reactifact/budget.py |
§58 |
| Модель ошибок | runtime.py _execute, _enter_turn |
§59, §69 |
| Наблюдаемость | reactifact/tracing/ |
§54 |