Skip to content

Семантика планировщика — контракт исполнения

Эта страница — контракт исполнения 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).

  1. Drain. context.drain_events() отдаёт очередь событий в порядке FIFO (порядок эмиссии) и очищает её. Если очередь пуста, поколение даёт 0 запусков, и ход завершается (quiescence, §3).

  2. Match. Для каждого события каждый агент (в порядке приоритета) опрашивается через agent.matching_triggers(event, context). Триггер совпадает по:

  3. типу события (artifact_created / updated / deleted / stale),
  4. точному равенству типа артефакта (не issubclass),
  5. необязательным condition(artifact) и/или context_condition(artifact, context) (последнее — то, что используют CorrelatedConsume/JoinConsume/AbsentConsume для корреляции между типами).

Consume(wakes=False) не даёт триггеров — он только питает inputs. Если condition'у нужен артефакт, а тот больше не резолвится (удалён раньше в этом же поколении или в предыдущем), триггер не совпадает (Trigger.matches).

  1. Debounce. Если все совпавшие триггеры агента на событие несут debounce=True, агент схлопывается: максимум одна запись на поколение, и побеждает последнее совпавшее событие в осушенном батче. Агент со смесью debounced и не-debounced совпадений на это событие трактуется как не-debounced. Схлопнутый запуск стоит один против max_runs. Debounced produce обязан читать inputs (собираются заново из Context), а не event — «какое именно изменение» debounce как раз и отбрасывает.

  2. Order. Не-debounced работа собирается в порядке (порядок событий × приоритет агента); debounced-записи добавляются после, затем весь список стабильно сортируется по Agent.priority (по возрастанию). То есть priority — внешний ключ упорядочивания, а при равенстве сохраняется порядок вставки.

  3. Schedule (опционально). Если задан Scheduler, scheduler(context, work) проходит по списку кандидатов. Встроенный uncertainty_policy: filter → детерминированный rank → опционально просьба к LLM упорядочить близкую верхнюю пару → опционально top‑k. Зашиты два инварианта: HITL-resume pin (кандидат, разблокирующий отвеченный PendingQuestion, принудительно идёт первым) и no-starvation fallback (фильтрация никогда не опустошает непустой список кандидатов).

  4. Budget slice. Работа обрезается по остатку max_runs. Если не осталось ничего — исход становится budget_runs_exceeded, поколение останавливается. Важно: события уже осушены на шаге 1 — работа, отброшенная бюджетом, не возвращается в очередь.

  5. Dispatch. Агенты исполняются на снапшоте поколения (шаг 5 в §4). При ограничениях конкурентности — через глобальный семафор max_concurrency плюс пер-агентные тиры concurrency_limit (глобальный захватывается первым, в фиксированном порядке — против дедлоков). Каждый produce получает свой слот Effects через contextvar, поэтому параллельные produces не видят незакоммиченные эффекты друг друга.

  6. Collect. Результаты собираются в порядке работы, независимо от того, в каком порядке задачи реально завершились (asyncio.gather сохраняет порядок аргументов).

  7. Validate. Каждый Create в патче результата проверяется против типов tuple produces этого агента (основной output и дополнительные create). Необъявленный тип приводит к исключению до применения.

  8. 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