workflow-ai
v1.26.6
Published
AI Agent Workflow Coordinator — kanban-based pipeline for AI coding agents
Readme
- Добавлено упоминание
human-gateв разделе про runner-стадии (пример конфига в pipeline.yaml: human-review-step типа manual-gate). - Добавлено упоминание
mark-blockedкак механизм нотификации autoblocked-тикетов (поля auto_blocked_reason/attempts/at).
Синглтон семантика Pipeline
Система workflow реализует паттерн синглтона для выполнения пайплайна: одновременно разрешена только одна активная инстанция пайплайна для каждого проекта.
Семантика Синглтона
При попытке запустить второй пайплайн, когда первый уже работает, система вернёт ошибку PIPELINE_ALREADY_RUNNING.
Структура .workflow/logs/.pipeline.lock
Файл пишется одной записью в момент захвата блокировки (wx), поэтому
плейсхолдеров null в нём не бывает. Между созданием файла и записью содержимого
есть микросекундное окно, в котором читатель может увидеть его пустым: пустой или
невалидный JSON следует трактовать как «повторить чтение», а не как «пайплайна нет».
Поля:
pid— ID процесса работающего пайплайнаstarted_at— временная метка запуска (ISO)timestamp— то же значение; оставлено для потребителей, читавших ранний форматstarted_by— кто запустил:cli,mcpилиextensionstarted_by_id— метка запустившего экземпляра; поля нет, если запускающий не представилсяrun_id— идентификатор запуска, совпадает с именем лог-файла без расширенияpipeline_log— путь к логу через/; относительный от корня проекта, а еслиexecution.log_fileуказывает на другой диск — абсолютныйproject_root— корневая директория проектаpipeline_version— версия workflow-ai, записавшего блокировку
Поле started_by заполняется из переменной окружения WORKFLOW_STARTED_BY,
которую выставляет тот, кто спавнит раннер (workflow-mcp — mcp, расширение
VS Code — extension). По умолчанию cli; неизвестное значение отбрасывается.
Переменная снимается из окружения сразу после чтения, поэтому дочерние агенты
её не наследуют.
Поле started_by_id заполняется из WORKFLOW_STARTED_BY_ID — той же стороной
и по тем же правилам: переменная снимается сразу после чтения, значение длиннее
128 символов или с пробелами отбрасывается. Раннер его не расшифровывает, для
него это непрозрачная метка. Нужна она тому, кто запускал: workflow-mcp по ней
узнаёт собственный запуск и отличает его от запуска соседней рабочей области.
До 1.7.0 сервер писал для этого свой файл рядом (.workflow/logs/.mcp-started-by),
и два файла про один запуск умели разойтись.
Полный payload появился в 1.6.0; более ранние версии писали только
pid, timestamp и — с 1.5.2 — started_at. Наличие started_by служит
признаком раннера ≥ 1.6.0, наличие started_by_id — ≥ 1.7.0.
Команды
workflow run --project <path> [--plan PLAN-X] [--config <path>]— запустить пайплайн- Остановка: программно через
stopPipeline()изsrc/lib/stop-command.mjsлибо через MCP-инструментstop_pipeline. Командыworkflow stopв CLI пока нет. SIGINT/SIGTERM(Ctrl+C; на POSIX —stopPipeline(), MCPabort_pipelineи остановка из расширения VS Code) раннер отрабатывает сам: снимает текущего агента, пишет его исход в журнал запусков (событиеrunсо статусомabortedиstop_requested: true), следующего агента не берёт, снимает.pipeline.lockи выходит с кодом 130. Повторный сигнал во время остановки — агент снимается сразу (на POSIX —SIGKILL, только сам процесс агента, без его потомков) и выход 130 без ожидания.- Снятие процесса раннера целиком (
taskkill /F— на WindowsstopPipeline(), MCPstop_pipeline,abort_pipelineи расширение VS Code;SIGKILL— MCPstop_pipelineна POSIX и эскалация, если раннер не вышел послеSIGTERM), падение раннера или машины исход записать не дают: остаётся запись открытого запуска.workflow/state/agent-run-open.json, её закрывает следующийworkflow run(см. «Восстановление»).
Восстановление
Блокировка от упавшего процесса (kill -9, ребут, принудительное завершение на
Windows) остаётся на диске, но помечена мёртвым pid. Специальных действий не
нужно: следующий workflow run проверяет живость процесса, пишет
stale marker found — removing и снимает блокировку сам. Проверить вручную:
node -e "console.log(require('fs').readFileSync('.workflow/logs/.pipeline.lock','utf8'))".
Запуск агента, прерванный вместе с раннером, раннер тоже закрывает сам. До старта
агента он пишет .workflow/state/agent-run-open.json (run_key, агент, стадия,
тикет, время старта) и удаляет файл после события run в журнале запусков. Файл
остался — следующий workflow run, взяв .pipeline.lock, до первой стадии дописывает
в журнал событие run с его полями, status: "aborted" и interrupted: true
(started_at — время старта агента, ts — время дописывания) и пишет в лог
agent run interrupted: agent=… ticket=… run_key=…. Событие с этим run_key уже
есть или файл не читается — файл удаляется без события. Второй workflow run при
живом раннере файл не трогает: он не берёт lock.
Окружение агентов: ~/.workflow/agent.env
Переменные из <WORKFLOW_HOME>/agent.env (по умолчанию ~/.workflow/agent.env)
получает каждый агент — и в стадиях pipeline, и в тестах скилов, кто бы ни запустил
раннер (MCP, расширение VS Code, терминал). Типичное содержимое — прокси:
# строки KEY=VALUE; комментарий — только целая строка с '#' в начале
HTTP_PROXY=http://user:pass@host:port
HTTPS_PROXY=http://user:pass@host:port
ALL_PROXY=# внутри значения — часть значения (не комментарий). Пустое значение
(ALL_PROXY=) убирает переменную из окружения агента. Файл перекрывает
окружение раннера, служебные WORKFLOW_RAILS_* главнее файла. Файл машинный и
вне git (в нём бывают учётные данные): в лог стадии пишутся только имена
переменных (agent.env: задано HTTP_PROXY, HTTPS_PROXY; снято ALL_PROXY),
значения — никогда; нечитаемый файл и строки не по формату — WARN с путём и
номерами строк. Читается при каждом запуске агента, перезапуск раннера после
правки не нужен. Нет файла — окружение агентов не меняется.
Зачем: 2026-09-24 автообновление kilo перезаписало обёртку kilo.cmd, которая
подставляла прокси; раннер из расширения запускал kilo напрямую, и все kilo-агенты
получили 403 Forbidden. Реализация — src/lib/agent-env.mjs.
Скилы для kilo
kilo CLI 7.7.x читает SKILL.md из проектного .kilocode/skills, только если
настоящий путь файла (после раскрытия всех ссылок) лежит внутри проекта; иначе —
failed to load skill … blocked file reference outside project config scope, и
агент работает без текста скила. Поэтому workflow init / update / eject:
- ставят ссылку
<каталог настроек kilo>/skills→~/.workflow/skills— канон для всех проектов (каталог настроек:KILO_CONFIG_DIR, иначе<XDG_CONFIG_HOME или ~/.config>/kilo); своему каталогу kilo доверяет без проверки. Существующий каталог или чужую ссылку там не трогают; - держат
.kilocode/skillsпроекта обычным каталогом со ссылками только на скилы, физически лежащие в проекте (эжектнутые и проектные). Прежняя ссылка на весь.workflow/src/skillsснимается — удаляется только ссылка, не цель.
Проверка: kilo run "List the names of all skills available to you" из корня
проекта — без строк failed to load skill.
MCP-серверы у агентов
Тикет с required_capabilities: [mcp] исполняет агент со способностью mcp в
capabilities; без такого агента в списке стадии тикет уходит в blocked/
(no_capable_agent). В configs/pipeline.yaml mcp есть у агентов claude:
claude подключает серверы из .mcp.json проекта, разрешённые в
.claude/settings.local.json (enabledMcpjsonServers), в том числе в режиме -p.
kilo .mcp.json не читает — у kilo-агентов mcp нет. Перед тикетами типов из
context.mcp_require_for стадия check-mcp проверяет, что серверы из .mcp.json
разрешены, http-серверы отвечают, а команда stdio-сервера находится в PATH.
Проверка: claude -p из корня проекта с просьбой найти инструменты сервера через
ToolSearch — в ответе имена mcp__<сервер>__…. claude mcp list для сервера из
.mcp.json может показывать Pending approval и при разрешении в
settings.local.json: 2026-09-27 в PulseProxy так было, а claude -p инструменты
mcp__playwright__* нашёл.
Фактическая модель kilo-агента
Роутеры kilo выбирают модель сами: kilo/kilo-auto/free — одну на сессию (от
сессии к сессии разную), kilo/openrouter/free — заново почти на каждом шаге.
Пока kilo-агент работает, раннер раз в 15 с смотрит, какие модели ответили, и при
смене набора пишет строку; после выхода агента — финальную, с числом шагов:
[execute-task] AGENT_MODELS agent="openrouter-free(nemotron, ling)" requested="kilo/openrouter/free" models="nvidia/nemotron-3-ultra-550b-a55b:free ×10, inclusionai/ling-3.0-flash-fin:free ×9"agent — подпись агента: модель одна и совпадает с запрошенной — просто id
(gpt-luna); одна другая — в скобках её имя (kilo-free(dots-3-note-preview));
несколько — семейства по убыванию шагов (openrouter-free(nemotron, ling), семейство —
имя модели до первого дефиса). Та же подпись встаёт в столбец «Агент» истории работы
тикета и показывается в панели pipeline расширения VS Code.
Источник — база kilo: kilo 7.7.9 записывает ответившую модель роутера в каждый
завершённый шаг; у агента с фиксированной моделью шаги без модели, и модель шага —
модель сессии (session.model.id, у субагента без своей модели — модель корневой
сессии). Сессию запуска раннер находит по метке kilo run --title workflow-<run id>
(свой --title в аргументах агента не перекрывается), шаги субагентов считаются
вместе с ней. За запуск в журнале запусков отвечает модель последнего шага корневой
сессии. Путь базы спрашивается у kilo db path один раз на процесс раннера,
чтение — встроенным node:sqlite (Node ≥ 22.5). Нет модуля, базы, сессии или шагов —
в логе строка «фактическая модель неизвестна», на ход стадии это не влияет.
Реализация — src/lib/kilo-models.mjs.
Журнал запусков и отсев моделей
Каждый запуск агента стадии со списком агентов пишется в журнал проекта
.workflow/metrics/agent-runs.jsonl — одна строка JSON на событие, файл только
дописывается. Промптов, вывода агента и содержимого файлов в журнале нет.
| Событие | Когда пишется |
|---------|---------------|
| run | агент завершился: успех, ошибка, model_banned, остановка пайплайна (aborted); за прерванный вместе с раннером запуск — при следующем старте (interrupted: true) |
| verify | завершилась стадия контроля артефактов (verify-artifacts.js) с блоком RESULT: статус, процент DoD, отсутствующие файлы, fail_reasons |
| review | завершилась стадия ревью (review-result с model_io или со скилом): статус, агент, модель |
| unban | человек снял запрет |
| reset | историю модели обнулили: её запуски до этого события не идут в градации, запреты и statsTable (раздел «Пулы моделей» → «Обнуление истории модели») |
Модель запуска: у kilo-агента — модель последнего шага (не прочитана — model: null,
такой запуск ни в одно правило не идёт); у стадии с model_io — модель из ответа; у
прочих агентов — значение --model / -m из args как есть, у kind: http — поле
model, иначе id агента.
У запуска стадии с выбором модели (раздел «Выбор модели стадии») в записи открытого
запуска и в событии run ещё семь полей: selection (selector — уровень назвал
селектор, ladder — лестница без ответа, cursor — ни у одного кандидата нет оценки,
прежний курсор), level — уровень запущенного кандидата,
levels — число уровней, required_level — требуемый уровень (у лестницы null),
floor_level — нижняя граница тикета, score — его оценка intelligence или null,
free. У остальных стадий ключи события прежние.
Градации — у запуска исполнителя (стадия со скилом execute-task), первая ступень,
на которой остановилась его работа; вычисляются при чтении журнала:
| Градация | Условие |
|----------|---------|
| crashed | сбой процесса (error, timeout, network_error, auth_error, aborted без признака остановки пайплайна), ни одного изменённого файла |
| refused | агент вернул status: blocked — класс запуска blocked или result_status: blocked (статус блока RESULT: класс запуска у такого ответа ok) |
| stopped | остановка пайплайна (aborted с stop_requested или interrupted) или model_banned — при любых изменениях |
| throttled | запуск закончился на ограничении провайдера — статус rate_limit (HTTP 429, rate limit, quota exceeded, too many requests в трёх последних строках stderr) — при любых изменениях |
| empty | не сбой, ни одного изменённого файла проекта, включая тикет |
| artifacts_failed | ближайший контроль артефактов — failed с причинами (в том числе проваленная проверка пункта DoD) |
| review_failed | контроль пройден, ближайшее ревью с вердиктом — failed |
| accepted | контроль all_green, или passed / legacy и ревью passed |
| pending | контроля или ревью с вердиктом ещё нет (default и error ревью — не вердикт) |
Сбой с изменёнными файлами получает градацию по контролю и пометку
crashed_after_work. Остановленный запуск модели не засчитывается: ни неудачи, ни
успеха, ни временного запрета — в том числе прерванный падением раннера или машины.
Агент, снятый сигналом не остановкой пайплайна (OOM, kill по pid агента), — сбой и
временный запрет модели.
Запуск throttled модели тоже не засчитывается: ни неудачи, ни успеха, ни
временного запрета, и прежний временный запрет он не снимает. Лимит провайдера — не
дефект модели, а запрет модели останавливал бы и других агентов — например, второй
роутер, выбравший ту же модель. Статус смотрит только конец stderr: kilo пишет туда
вывод инструментов и каждый 429, после которого сам повторил запрос и продолжил
работу, — запуск, упавший потом на другой ошибке, остаётся сбоем. Агента на время
ограничения выводит из выбора health-реестр: CLI-агента — общее правило
provider-rate-limit в agent-health-rules.yaml с тем же шаблоном, 15 минут, если у
агента нет своего правила на этот текст; агента kind: http и агента с командой на
шаге model_io — ошибка клиента модели rate_limit (запись model-client-rate_limit),
1 час.
Запреты действуют только на стадии исполнителя. Неудача — empty и
artifacts_failed; успех — пройденный контроль артефактов, даже если ревью потом не
пройдено.
| Запрет | Область | Условие | Срок |
|--------|---------|---------|------|
| постоянный, правило 1 | модель + тип тикета | с последнего снятия неудач ≥ 3, успехов 0 | до снятия человеком |
| постоянный, правило 2 | модель + тип тикета | с последнего снятия не меньше 10 оценённых запусков, среди последних 10 успехов < 3 | до снятия человеком |
| временный | модель целиком | последний запуск модели, кроме stopped и throttled, — сбой | TTL сработавшего правила agent-health-rules.yaml, иначе 1 час |
| временный «модель недоступна» | модель целиком | три запуска подряд со сбоем процесса, не прошедшие контроль артефактов (error, timeout, network_error — в том числе снятый по перегрузке провайдера, классы которого приходят и как error, и как network_error), без успеха между ними; stopped, throttled, auth_error и aborted серию не удлиняют и не прерывают | 1 час × 2^(серия − 1), не больше суток, от последнего запуска серии |
Тип тикета — task_type контекста или префикс id тикета (IMPL-12 → impl).
При выборе агента ключ модели — по записи конфига: у kilo-агента модель из -m без
провайдера, как хранит kilo (-m prov/x → x), у прочих — значение --model / -m
как есть, у kind: http — поле model, иначе id агента. Агент исполнителя с
запрещённой моделью пропускается (в логе
agent … skipped: model "…" — …); все агенты под запретом — стадия возвращает
blocked (all_banned) с причиной, и тикет уходит по goto.blocked. Роутер kilo по
ключу роутера не отсеивается: пока агент работает, раннер проверяет ответившие модели
и, если ответила запрещённая, снимает агента (MODEL_BANNED agent=… model=…) и сразу
берёт следующего агента стадии, даже если файлы изменены: исполнитель пишет прогресс в
тикет по ходу работы. Тот же агент заново не запускается — модель выбирает его роутер,
и перезапуск запрещённую модель не обходит; нездоровым агент не помечается, в следующей
попытке тикета он снова в списке. Запрещённая модель, ответившая уже после последней
проверки, тоже даёт MODEL_BANNED и переход к следующему агенту: её результат обычным
запуском не засчитывается.
Снятие запрета — событие unban (функция recordUnban модуля, инструмент MCP —
отдельно):
{"type":"unban","ts":"2026-09-27T10:00:00.000Z","model":"<модель>","ticket_type":"impl","reason":"<почему>"}С ticket_type снимается постоянный запрет пары, без него — временные запреты модели
(за сбой и «модель недоступна»; серия считается заново с этого события).
Запрета нет — отказ, строка не пишется. Таблица по модели и типу тикета (число
запусков по градациям, доля пройденного контроля, доля принятых на ревью, запреты с
доказательствами) — statsTable. Модуль экспортируется пакетом:
workflow-ai/lib/agent-runs.mjs. Реализация — src/lib/agent-runs.mjs, подсчёт
изменённых файлов — src/lib/agent-run-changes.mjs.
Пулы моделей
Пул — запись агента в pipeline.agents с полем models. Модели берутся из вывода
команды списка, в пул входят те, чей полный id совпал с маской. В списке стадии пул
занимает одно место, и на это место раннер ставит конкретную модель — участника пула.
Каждый запуск поэтому — конкретная модель: в журнале запусков agent — id участника,
model — ключ его модели.
В поставляемом configs/pipeline.yaml стадия execute-task начинается с двух пулов
вместо роутеров kilo/kilo-auto/free и kilo/openrouter/free:
free-kilo—kilo/*:freeиkilo/stealth/space-bunny-alpha;free-openrouter—openrouter/*:freeбезnvidia/nemotron-3.5-content-safety:free.
Стадия execute-task — стадия с выбором модели (раздел «Выбор модели стадии»): её пулы
раскрываются в участников, и участников вместе с остальными агентами списка упорядочивает
селектор стадии. Поэтому models.selector и models.scores у поставляемых пулов нет
(до 1.17.0 — селектор jev-select):
free-openrouter:
command: "kilo"
args: ["-m", "{model}", "--agent", "code", "--print-logs", "--log-level", "ERROR", "run", "--auto"]
workdir: "."
capabilities: [text]
models:
list: ["node", ".workflow/src/scripts/kilo-models-list.js"]
match:
- '^openrouter/nvidia/nemotron-3-ultra-550b-a55b:free$'
- '^openrouter/(?!nvidia/nemotron-3\.5-content-safety:free$)[^/]+/[^/]+:free$'
max_per_attempt: 3
gate: ["node", ".workflow/src/scripts/free-quota-gate.js", "--url", "https://openrouter.ai/api/v1/key", "--key-file", "~/.workflow/secrets/openrouter.key", "--timeout", "10"]Ниже — пул на стадии без selection: одно место списка, курсор попытки, селектор и
оценки пула. На стадии с selection отличия такие: место-пул заменяется участниками,
прошедшими фильтры, у каждого свой уровень и признак «бесплатная»; шлагбаум
опрашивается один раз в начале попытки и перед каждым запуском участника, кроме
участника, запускаемого первым в попытке;
models.selector и models.scores не вызываются, строки SELECT и SCORES нет.
Селектор или оценки у пула, все списки которого под выбором, validateConfig
отклоняет как мёртвый конфиг.
kilo-free (роутер kilo/kilo-auto/free) остаётся в конфиге обычным агентом
target_agents тестов скилов execute-task и deep-research; в списках стадий его нет.
Поля пула
| Поле | Значение | Обязательно | Смысл |
|------|----------|-------------|-------|
| models.list | непустой массив непустых строк | да | команда списка моделей и её аргументы |
| models.match | непустой массив строк — выражения RegExp без флагов и модификаторов ((?i) в JS не компилируется, (?i:…) — только с Node 23) | да | модель входит в пул, если её полный id совпал хотя бы с одним выражением; порядок участников — по индексу первого совпавшего выражения, при равенстве — порядок вывода команды |
| models.max_per_attempt | целое ≥ 1, по умолчанию 3 | нет | сколько участников пула пробовать за одну попытку тикета |
| models.selector | id агента с командой: не kind: http и не пул | нет | агент-селектор участника под тикет |
| models.scores | непустой массив непустых строк, только вместе с selector | нет | команда оценок кандидатов для промпта селектора |
| models.gate | непустой массив непустых строк | нет | команда-шлагбаум места пула |
| {model} в args | ровно одно вхождение у пула, ни одного у агента без models | да | место полного id участника |
Встроенного фильтра «бесплатная» нет: состав задаёт только маска, ею можно собрать и
платный пул. Исключить модель — отрицательным просмотром вперёд, как content-safety
выше. Свойства модели (картинки, инструменты, цена) пул не режут.
Проверка конфига при старте (validateConfig) отклоняет:
modelsуkind: http,modelsне объектом, id пула с@, id агента вида<пул>@…— такие id заняты участниками пула;- неверные
list,match(в том числе выражение, которое не компилируется, и модификатор(?i:…)),max_per_attempt,selector,scores,gate, а такжеscoresбезselector; selectorилиscoresу пула, все списки которого — на стадиях сselection;{model}вargsпула не ровно один раз и{model}у агента безmodels;- пул в одиночном
stage.agent(у стадии без спискаagents); - пул в списках стадии с
model_io:agents,agents_by_type.<тип>.agentsиdefault_agentsу стадии без своего списка.
Configuration validation failed:
- Agent "free-kilo" is a model pool: args must contain {model} exactly once, found 2
- Stage "ask" has model_io, but lists model pool "free-kilo" in agents: model pools are not allowed on model_io stagesПул в target_agents тестов скилов (index.yaml, --agent или файл кейса)
run-skill-tests.js отклоняет до первого кейса, status: error:
Agent(s) '<пул>' are model pools (models): пул моделей нельзя указывать в target_agents, назовите конкретного агента
(для файла кейса — с in target_agents of case <id>). Пул судьёй (judge_agent) или в
его escalate_to — тоже ошибка до прогона.
Команда списка
Раннер знает только интерфейс stdout команды списка, форматов kilo он не разбирает:
- строка, начинающаяся с
{, — JSON{"id": "<provider/model>", "capabilities": ["multimodal"], "note": "<текст>"}; обязателен только строковыйid,noteидёт только в строкуPOOL; - любая другая непустая строка — id без метаданных, поэтому командой списка может быть
и
["kilo", "models"]; - испорченная строка (JSON не разбирается, нет строкового
id) — WARN и пропуск; повтор id — первое вхождение.
Способности участника — capabilities пула вместе с capabilities строки.
Адаптер src/scripts/kilo-models-list.js запускает kilo models --verbose и печатает
все модели kilo без фильтрации, по JSON-строке на модель. capabilities:
["multimodal"] — у модели с картинкой на входе. note — цена входа и выхода, вызов
инструментов и признак isFree как есть, без единиц. Сбой kilo — выход 1, причина в
stderr. Испорченный объект модели или объект без закрывающей } — WARN в stderr, эта
модель пропускается, соседние остаются.
{"id":"kilo/qwen/qwen3.8-27b:free","capabilities":["multimodal"],"note":"in=0 out=0 tools=true free=true"}Раскрытие пула и участники
Пулы раскрываются один раз на процесс раннера, до первой стадии. Команда списка запускается так же, как агенты:
- рабочий каталог — корень проекта, окружение — с
~/.workflow/agent.env; - на Windows — через shell, кроме команды
node; - через 60 с команда снимается, при остановке пайплайна (SIGINT/SIGTERM) — сразу;
- одинаковая команда у нескольких пулов (равные массивы) запускается один раз;
- хвост её stderr идёт в лог строками WARN
models.list stderr: ….
На каждый пул — строка POOL с составом. Она — единственная защита от платной модели,
случайно попавшей в маску:
POOL agent="free-kilo" members=15 [kilo/stealth/space-bunny-alpha in=0 out=0 tools=true free=true, kilo/nvidia/nemotron-3-ultra-550b-a55b:free in=0 out=0 tools=true free=true, …]
POOL agent="free-openrouter" members=0 (exit 1)Причина пустого пула — exit <код>, timeout 60s, aborted (остановка пайплайна),
spawn error … или no match. Модель, чей id участника уже занят агентом конфига,
пропускается с WARN.
Пул без участников стадию не роняет: его место не выбирается, а стадия получает обычные
no_capable_agent, all_unhealthy или all_banned. У no_capable_agent в этом
случае приписка model pools without members: …. Id модели не под
^[A-Za-z0-9][A-Za-z0-9._:/~-]*$ пропускается с WARN: на Windows аргументы агента идут
через cmd.exe.
Участник — <пул>@<полный id>, например free-kilo@kilo/qwen/qwen3.8-27b:free. Этот id
видят журнал запусков (поле agent), health-реестр, строки Agent selected: и START,
история работы тикета. Запись участника — копия записи пула: {model} в args заменён
полным id, поля models нет, pool — id пула.
Ключ модели участника — полный id без провайдера (poolside/laguna-s-2.1:free). Он
общий для обоих маршрутов и совпадает с прежними ключами журнала; по нему действуют
запреты модели. Пока механика роутеров не удалена, ключ kilo-запуска в журнале берётся
из базы kilo (раздел «Фактическая модель kilo-агента»). Поэтому участник, снятый до
первого шага (первый 429 приходит через 16–27 с), пишет в журнал model: null.
Выбор участника
Место пула проходит фильтры стадии, если их проходит хотя бы один участник. Кандидаты места — участники в порядке маски, которые:
- покрывают
required_capabilitiesтикета; - здоровы по health-реестру — ключ id участника, то есть маршрут;
- не под запретом модели — на стадии со скилом
execute-task; - не пробовались в этой попытке;
- ещё не запускались на этом тикете: нет события
runс тем жеticketи ключом модели участника. Фильтр мягкий: если таких нет, кандидаты — прошедшие п. 1–4.
Место уходит из попытки, когда:
- пробовано
max_per_attemptего участников; - подходящих непробованных участников не осталось;
- шлагбаум закрыл место.
Неудача участника с пустым diff — следующий участник того же места, затем следующее место списка. При изменённых артефактах fallback, как и раньше, не идёт. Строка выбора прежняя:
Agent selected: free-kilo@kilo/qwen/qwen3.8-27b:free (attempt 1, compatible=[free-kilo, free-openrouter, gpt-luna, …])Правила health участника в agent-health-rules.yaml ищутся по id пула: состав пула
известен только после раскрытия, в файле его не описать. Пометка нездоровья и проверка
здоровья — по id участника. В поставляемом файле free-kilo и free-openrouter
наследуют правила kilo-free (extends): участник, получивший 429 провайдера,
снимается онлайн и выходит из выбора на 15 минут.
Шлагбаум
models.gate — команда, которую раннер запускает перед каждым запуском участника пула,
до селектора (на стадии с selection — ещё и одним проходом в начале попытки, и тогда
участник, запускаемый первым в попытке, опирается на ответ прохода: «Выбор модели
стадии», шаг 2). Запуск — по правилам команды списка, но с таймаутом 15 с. Ответ — блок
RESULT и выход 0:
---RESULT---
status: open
remaining: 37
---RESULT---open— участник запускается.closed— место закрыто до ближайших 00:00 UTC: участники не запускаются, селектор не вызывается, стадия берёт следующее место списка. Закрытие — запись health-реестра по id пула (правилоpool-gate-closed, TTLuntil_utc_midnight, не меньше 30 минут), так что оно держится во всех стадиях и после перезапуска раннера.- Выход ≠ 0, таймаут, нет блока RESULT или
status—error: WARN, место не закрывается. - Остановка пайплайна снимает шлагбаум сразу; после неё ни селектор, ни участники не запускаются.
Строка лога на каждый вызов:
GATE agent="free-openrouter" status=open remaining=37 until=- duration_ms=1085
GATE agent="free-openrouter" status=closed remaining=0 until=2026-09-29T00:00:00.000Z duration_ms=1100Скрипт src/scripts/free-quota-gate.js --url <https://…/key> --key-file <путь>
[--timeout <с>] делает один GET с ключом, без повторов, с таймаутом по умолчанию
10 с. Он читает data.free_model_daily_requests — дневной счётчик бесплатных запросов
аккаунта OpenRouter: один на все модели :free, 50 в сутки при покупках меньше 10
кредитов, сброс в 00:00 UTC.
remaining> 0 —status: open, ≤ 0 —status: closed; кромеremainingскрипт печатаетusedиlimit.- Поля нет, ответ не JSON, ошибка сети или HTTP не 2xx —
status: error,error_class,error, выход 1.
Чтение счётчика модель не вызывает и запрос не тратит. В поставляемом конфиге шлагбаум
только у free-openrouter: маршруты kilo/* счётчик аккаунта OpenRouter не тратят.
Селектор
models.selector — id агента с командой, который упорядочивает кандидатов места под
тикет. Раннер вызывает его один раз на место пула в попытке — при первом выборе
участника этого места, если:
- в контексте есть тикет;
- кандидатов не меньше двух;
- селектор здоров по health-реестру;
- шлагбаум пула не закрыл место.
Иначе вызова нет, берётся порядок маски. Следующий участник того же места в этой попытке — следующий по ранжиру, без нового вызова; в следующей попытке — новый вызов.
Промпт идёт через stdin: инструкция и JSON-блок в ограждении ```json,
кандидатов — первые 10 в порядке маски. DoD — текст секции «Критерии готовности
(Definition of Done)» тикета, не больше 4000 символов:
{
"pool": "free-kilo",
"ticket": {"id": "IMPL-12", "type": "impl", "title": "…", "dod": "…"},
"candidates": [
{"id": "kilo/qwen/qwen3.8-27b:free", "capabilities": ["text", "multimodal"], "note": "in=0 out=0 tools=true free=true", "scores": {"intelligence": 33.7, "coding": 68.1, "agentic": 45.8}},
{"id": "kilo/stealth/space-bunny-alpha", "capabilities": ["text", "multimodal"], "note": "in=0 out=0 tools=true free=true", "scores": null}
],
"scores_citation": "Source: Artificial Analysis (artificialanalysis.ai) via OpenRouter (openrouter.ai/rankings)."
}Ответ — id кандидатов через запятую, от самого подходящего:
---RESULT---
ranking: kilo/qwen/qwen3.8-27b:free, kilo/stealth/space-bunny-alpha
confidence: 0.6
cost_usd: 0.00007518
---RESULT---Id не из кандидатов пропускаются, кандидаты вне ранжира идут после него в порядке маски.
Ошибка — status: error, error_class, error и выход 1. Строка лога на каждый вызов:
SELECT agent="free-kilo" selector="jev-select" candidates=10 ranked=10 member="kilo/inclusionai/ling-3.0-flash-fin:free" fallback=none cost_usd=0.00007518 duration_ms=1286| fallback | Когда | Участник |
|------------|-------|----------|
| none | в ранжире есть хотя бы один кандидат | первый по ранжиру |
| unknown_id | поля ranking нет или в нём ни одного id кандидатов | порядок маски |
| error | выход ≠ 0 или status: error | порядок маски |
| timeout | селектор снят через 60 с | порядок маски |
Сбой помечает селектор в health-реестре по классу ошибки: auth и rate_limit — 1 час;
server, timeout, network — 5 минут; прочие классы не помечают. Селектор, снятый
онлайн-сканом по своему правилу health, помечается на класс и TTL этого правила.
Нездоровый селектор не вызывается, в лог идёт
INFO pool "…": selector "…" unhealthy in registry — mask order. Журнал запусков и
история тикета вызов селектора не пишут.
Селектор пула в поставляемом конфиге 1.16 — jev-select, модель решений Jev через обёртку
src/scripts/decisions-select.js --model <id> --url <https://…/decisions> --key-file
<путь> [--timeout <с>] (с 1.17.0 пулы стоят только на стадии с выбором модели, и
jev-select из конфига убран; обёртка та же, у селектора стадии — режим models):
- один вопрос decisions типа
choice, варианты — кандидаты; - ранжир — все кандидаты по убыванию вероятности, при равенстве — меньший индекс;
--timeout 15: все повторы клиента укладываются в 3 × 15 + 6 = 51 с, меньше 60 с раннера.
Цена — usage.cost ответа: живой вызов 2026-09-28 с 10 кандидатами — $0.000075. Вызовов
не больше одного на место пула в попытке — до 12 на тикет при двух пулах и 6 попытках.
Оценки
models.scores — команда оценок кандидатов для промпта селектора, задаётся только
вместе с selector. При раскрытии пула раннер пишет в её stdin полные id участников, по
одному на строку, и читает из stdout один JSON:
{"as_of":"2026-09-27T00:01:09.591Z","citation":"Source: Artificial Analysis (artificialanalysis.ai) via OpenRouter (openrouter.ai/rankings).","scores":{"kilo/qwen/qwen3.8-27b:free":{"intelligence":33.7,"coding":68.1,"agentic":45.8}}}Оценки пул не режут и не переупорядочивают — это только вход селектора. Одинаковая
команда у нескольких пулов запускается один раз. Сбой команды — кандидаты без оценок
("scores": null), селектор всё равно вызывается. Строка лога на пул:
SCORES agent="free-kilo" scored=8/15 as_of="2026-09-27T00:01:09.591Z" citation="Source: Artificial Analysis (artificialanalysis.ai) via OpenRouter (openrouter.ai/rankings)."
SCORES agent="free-openrouter" scored=0/16 (exit 1)Скрипт src/scripts/model-scores.js --benchmarks-url <url> --models-url <url>
--key-file <путь> [--cache <путь>] [--timeout <с>]:
- Источник — оценки Artificial Analysis (intelligence, coding, agentic) из
GET https://openrouter.ai/api/v1/benchmarks?source=artificial-analysisи каталогGET https://openrouter.ai/api/v1/models, оба запроса с ключом. Адреса — только из аргументов. - Сопоставление: полный id без первого сегмента (
kilo/qwen/qwen3.8-27b:free→qwen/qwen3.8-27b:free) → запись каталога с тем жеid→ еёcanonical_slug→ строка оценок с тем жеmodel_permaslug. Участника без записи каталога или без оценок в выводе нет. - Кэш —
--cache, по умолчанию~/.workflow/cache/model-scores.json(или$WORKFLOW_HOME/cache/…), общий для проектов машины. Оба GET идут заново, когда файла нет, он не читается или другой версии; кэшу 24 часа или дата загрузки в будущем (сбитые часы); ключа участника нет в каталоге кэша и кэшу не меньше часа. Загрузка не удалась — прежний кэш и WARN в stderr, кэша нет — выход 1. Запись — через временный файл и rename. --timeout— на каждый GET, по умолчанию 20 с.
Атрибуция данных оценок — meta.citation источника. Она хранится в кэше, печатается в
строке SCORES и идёт в промпт селектора:
Source: Artificial Analysis (artificialanalysis.ai) via OpenRouter (openrouter.ai/rankings).
Обнуление истории модели
Событие reset журнала запусков обнуляет историю модели:
{"type":"reset","ts":"2026-09-28T12:00:00.000Z","model":"poolside/laguna-s-2.1:free","reason":"<почему>"}Запуски модели, записанные в журнале раньше её последнего reset, выпадают из градаций.
«Раньше» — по позиции в журнале, как у unban. Вместе с градациями такие запуски уходят
из постоянных и временных запретов и из statsTable, то есть из MCP get_model_stats.
- Окна контроля и ревью строятся по-прежнему со всеми запусками: контроль исключённого запуска не засчитывается соседнему.
- Запуски с
model: nullне обнуляются. - Журнал только дописывается.
- Фильтр 5 выбора участника смотрит сырые события,
resetна него не влияет.
Запись — функция recordReset(projectRoot, {model, reason}) модуля журнала, рядом с
recordUnban. Результат — {ok: true, event} или {ok: false, code, error} с кодами
BAD_INPUT, READ_FAILED, NO_RUNS (в журнале нет запуска с этим ключом — защита от
опечатки) и WRITE_FAILED. Скрипт запускается из корня проекта:
node .workflow/src/scripts/reset-model-history.js --model poolside/laguna-s-2.1:free --reason "история роутеров"Успех — RESULT status: ok, model: <ключ>, выход 0. Ошибка — status: error, code,
error, выход 1: коды recordReset, BAD_INPUT за неверные аргументы и NO_PROJECT,
если корень проекта не найден. MCP покажет обнуление после обновления зависимости
workflow-ai в workflow-mcp.
Выбор модели стадии
Стадия с полем selection (в поставляемом конфиге — execute-task) выбирает исполнителя
не курсором попытки, а по силе моделей и сложности тикета. Правила стейкхолдера:
- «Самая СЛАБАЯ модель, которой ещё ДОСТАТОЧНО для тикета».
- Селектор видит только кандидатов, прошедших все фильтры: способности, health-реестр и
шлагбаум пула, запреты моделей, пробованные в попытке,
max_per_attemptпула, нижнюю границу тикета. Отсеянных он не видит, и их не запускает ни один порядок обхода. - Никаких данных моделей в конфиге и коде: ни уровней у агентов, ни соответствия агент →
оценка, ни порогов. Состав стадии меняется в любой момент (правка конфига, состав
пулов по
kilo models) — уровни считаются по оценкам заново. - Приоритет у бесплатных. Бесплатная модель kilo не становится платной оттого, что
её нет в каталоге OpenRouter или каталог называет цену: для модели kilo решает флаг
isFreeсамого kilo. «Неизвестно» — платная, но только если ни один источник не говорит «бесплатная».
Стадия без selection и тип тикета с agents_by_type.<тип>.selection: false выбирают
агента прежним курсором: код курсора, его порядок и строки лога — как до 1.17.0. Изменился
вход фильтра запретов: с 1.17.0 ответ агента status: blocked (result_status: blocked)
получает градацию refused на любом запуске execute-task и неудачей в запретах не
считается, а раньше такой ответ без изменённых файлов был empty — неудачей. Поэтому и
стадия без выбора может запустить модель, которую 1.16.7 после трёх таких отказов
запретил бы (раздел «Журнал запусков и отсев моделей»).
Запись в pipeline.yaml
execute-task:
skill: execute-task
counter: task_attempts
selection:
selector: jev-place
scores: ["node", ".workflow/src/scripts/model-scores.js", "--benchmarks-url", "https://openrouter.ai/api/v1/benchmarks?source=artificial-analysis", "--models-url", "https://openrouter.ai/api/v1/models", "--key-file", "~/.workflow/secrets/openrouter.key", "--anchor-high", "z-ai/glm-5.2", "--anchor-medium", "anthropic/claude-4.5-haiku", "--overrides", ".workflow/config/model-levels.yaml"]
escalate_on: [blocked]
levels:
- "mechanical edit from a precise recipe, 1-2 files, DoD checked by one command"
- "routine change in a known place, clear DoD, up to ~5 files"
- "change needing mcp/browser checks, or debugging with a known cause"
- "unknown root cause, several modules, repeat after a failed attempt"
- "regression after others failed, architecture, ambiguous DoD"
agents: [free-kilo, free-openrouter, gpt-luna, gemini-flash-lite, claude-haiku, claude-sonnet, claude-opus, deepseek-flash]
agents_by_type:
coach: { agents: [claude-opus, gpt-sol, gemini-flash, deepseek-pro] }
qa: { agents: [claude-haiku, gpt-luna, gemini-flash-lite, claude-sonnet, claude-opus] }| Поле | Значение | Смысл |
|------|----------|-------|
| selection.selector | id агента с командой: не kind: http и не пул | селектор: требуемый уровень и порядок кандидатов под тикет |
| selection.scores | непустой массив непустых строк | команда фактов: оценка и бесплатность моделей кандидатов |
| selection.levels | 2..10 непустых строк, от слабого к сильному | рубрика сложности тикета; её длина N — число уровней кандидатов |
| selection.escalate_on | подмножество [blocked], необязательно | агент сам ответил blocked — следующий строго сильнее в той же попытке |
| agents_by_type.<тип>.selection | только false | тип без выбора модели: прежний курсор |
validateConfig при старте отклоняет: другие ключи selection; selection у стадии не
со скилом execute-task, с model_io или с одиночным agent; селектор не агент
конфига, kind: http или пул; неверные scores, levels, escalate_on;
agents_by_type.<тип>.selection не false или у стадии без selection; селектор и
оценки пула, все списки которого под выбором.
Списки agents_by_type наследуют выбор стадии. Все кандидаты стадии — агенты списков под
выбором, пулы — участниками.
Факты: оценки и бесплатность
Раннер не читает форматы OpenRouter и kilo. Команду selection.scores он запускает один
раз на процесс, сразу после раскрытия пулов, по правилам команд пула: корень проекта,
окружение агентов, таймаут 60 с, снятие остановкой пайплайна. Равные команды нескольких
стадий — один запуск. На stdin — строка на каждую модель кандидатов:
{"id":"kilo/qwen/qwen3.8-27b:free","host":"kilo"}
{"id":"claude-haiku-4-5-20251001","host":"claude"}id — модель, как её вызывает агент (-m / --model из args, у kind: http — поле
model); host — хост рельс агента (kilo, claude или null). Агент без id модели на
вход не идёт: он без оценки и платный. Ответ — один JSON:
{"as_of":"…","citation":"…","levels":{"high":33.7,"medium":16.9,"anchors":{"high":"…","medium":"…"}},
"models":[{"id":"deepseek/deepseek-flash","host":"kilo","resolved":"deepseek/deepseek-v4.1-flash-20260910","intelligence":39.5,"coding":null,"agentic":null,"free":false,"free_source":"unknown","level":"high","level_source":"feed"}]}levels — пороги градации, сосчитанные по опорам --anchor-high и --anchor-medium
(раздел «Тег минимального уровня у места»); опоры не заданы — пороги null и WARN.
Сбой команды (выход ≠ 0, таймаут, не JSON, нет массива models) стадию не роняет: все
кандидаты без оценок; бесплатны только id с :free на конце (флагов kilo и цен каталога
нет), остальные платные. Если ни у одного выжившего кандидата попытки нет оценки — так
при сбое команды и при каталоге OpenRouter, не загруженном без кэша, — уровни, граница и
селектор не работают (все на уровне 1): попытка идёт прежним курсором по местам списка
с теми же фильтрами (место (попытка − 1) % число мест, пул — одним местом). Без тегов
порядок и курсор при этом прежние (числовых оценок нет — и градации нет); с тегами места
упорядочены по ступени, курсор вращается внутри лучшей ступени, а слабые ступени вступают
в дело, только когда лучшие исчерпаны (excludeAgents): уровень берётся из факта
level (поправка задаёт его и без числовой оценки). Селектор не
вызывается (fallback=skipped:no_scores), в журнале selection: cursor. Шлагбаум — как
на стадии с выбором: один проход в начале попытки; участник открытого пула, запускаемый
первым в попытке, его не опрашивает, любой запуск участника после другого запуска
попытки — опрашивает. Порядок запусков совпадает с порядком стадии без selection (без
тегов), пока проход не закрыл шлагбаум: закрытый проходом пул выбывает из мест до расчёта
курсора, а стадия без выбора узнаёт о закрытии, только дойдя курсором до пула. Эскалации
нет: все на уровне 1, blocked агента уходит стадии (ESCALATE … to="none"), как у курсора.
src/scripts/model-scores.js — та же команда оценок пула, режим выбирается по входу: все
строки начинаются с { — режим фактов, ни одна — прежний режим пула (вывод прежний),
смесь — bad input и выход 1. Кэш — прежний файл ~/.workflow/cache/model-scores.json
версии 1 с добавочным ключом models: цена pricing.prompt/pricing.completion,
created и alias_target записей каталога. Кэш без models режим фактов загружает
заново. Сбой загрузки без кэша — WARN, оценок и цен нет, флаги kilo остаются; выход 0.
Бесплатность — первое сработавшее правило:
- хост
kilo, kilo знает ровно этот id, и егоisFree—trueилиfalse: решает флаг поставщика (free_source: kilo); - id оканчивается на
:free(openrouter_id); - у прямой записи каталога OpenRouter (правило R1 ниже, а не новейшей версии), а у id без
прямой записи — у члена семейства R3 с датой id, числовые цены входа и выхода:
бесплатна при обеих нулевых (
openrouter_price); новейший член семейства и цель псевдонима~…-latestцену не дают (claude-haiku-4-5-20251001— ценаanthropic/claude-4.5-haiku-20251001); - иначе платная (
unknown).
Флаги kilo — kilo models --verbose (модуль src/lib/kilo-catalog.mjs, общий с адаптером
списка пула), только если во входе есть хост kilo, параллельно с GET каталога и без
своего кэша: флаг должен быть свежим. Сбой kilo — WARN, флаги только по OpenRouter.
Цены kilo (cost.*) не читаются: kilo models --verbose 2026-09-28 (789 моделей) даёт
cost.input = cost.output = 0 платным openai/gpt-5.6-luna, -terra и -sol без
isFree — по цене kilo три платных агента стали бы бесплатными. В том же списке 16
моделей с isFree: true, одна из них без :free (kilo/stealth/space-bunny-alpha), и
kilo/stealth/claude-opus-4.8 с isFree: false. У хоста claude флага поставщика нет:
его модели — записи anthropic/* каталога с ценой больше нуля.
Оценка — intelligence Artificial Analysis; coding и agentic идут селектору как
сведения. Термины: маршрут — первый сегмент id из трёх и больше сегментов; производитель —
сегмент перед именем модели; дата — хвост -YYYYMMDD (у id ещё -MMDD); ключ семейства —
имя без даты и варианта :…, короткие числа подряд слиты в версию (4, 5 → 4.5).
- R1, прямая запись — первый из [id, id без маршрута], который есть в каталоге; у id
без производителя (модель CLI claude,
claude-opus-5) — записи с тем же последним сегментом, ровно одного производителя. - R2, недатированный id — самая новая запись со slug без даты, равным slug прямой
записи без даты: каталог держит под
deepseek/deepseek-v4-proslug-20260423(intelligence 30.4), а новее есть-20260813(36). Датированный id держит свою версию. - R3, id без записи — сначала указатель OpenRouter на новейшую версию семейства
~<производитель>/<имя>-latest(alias_target), затем R2:deepseek/deepseek-flash→deepseek/deepseek-v4.1-flash, 39.5. Иначе — члены семейства одного производителя (у id без токена версии версии не сравниваются): при дате в id — член с этой датой (claude-haiku-4-5-20251001→anthropic/claude-4.5-haiku-20251001, 16.9), иначе самый новый. Члены разных производителей — оценки нет, WARN. - R4 — у выбранной версии нет
intelligence— берётся следующая по новизне версия семейства, у которой он есть;resolvedназывает slug, чья оценка взята. Нет ни у одной — без оценки.
Кандидаты поставляемого execute-task 2026-09-28: claude-haiku 16.9, claude-sonnet 38.2,
claude-opus 50.8, gpt-luna 37.3, gpt-terra 42.1, gpt-sol 47, gemini-flash-lite 22.2,
gemini-flash 40.9, deepseek-flash 39.5 (через псевдоним), deepseek-pro 36; все 29
участников пулов бесплатные (флаг kilo или :free), из них 18 без оценки; платные —
claude-*, gpt-luna и gemini-flash-lite по цене каталога и deepseek-flash (unknown).
Уровни
Уровни — относительные, по оценкам выживших кандидатов попытки: min и max —
наименьшая и наибольшая оценка, N — число текстов levels, ширина полосы
w = (max − min) / N. Уровень оценки s: без оценки — 1 (низ, наверх не поднимается);
ниже min — 0 (только для нижней границы); w = 0 — N; иначе
min(N, floor((s − min) / w) + 1). Полосы и уровни замораживаются на попытку: требуемый
уровень, граница и эскалация говорят на одной шкале. Следующая попытка считает их заново
по своим выжившим.
Тег минимального уровня у места
Запись списка агентов стадии может нести тег: free-kilo[medium] — «это место, но не
слабее уровня medium». Здесь речь о градации силы модели (абсолютной), а не о полосах
уровня попытки выше. Тег работает только на стадии с выбором модели: validateConfig
отклоняет тег в списке стадии без selection (и у типа с selection: false), неизвестное
имя уровня и тег у места, которого нет в agents.
Градация — low < medium < high, тег означает «не ниже». Пороги — оценки двух
опорных моделей из тех же данных, что и у кандидатов: команда фактов получает
--anchor-high <модель> и --anchor-medium <модель>; high — оценка не ниже опоры
высокого уровня, medium — строго выше опоры среднего, low — не выше неё. Опора не
названа, не оценена или опоры стоят в обратном порядке — порогов нет, вычисляемая
градация молчит (WARN), но строковая поправка из model-levels.yaml уровень сохраняет
(осознанно). Опора, которой нет в каталоге кэша, заказывает его обновление наравне с
ключом кандидата. Чисел порогов в коде и конфиге нет: обновился фид — поехали и пороги.
Порядок очереди на месте с тегом (мягкий: кандидат ниже уровня не выбрасывается, а ждёт своего часа):
- уровень подтверждён и не ниже требуемого — прежними ключами обхода (бесплатные ≥ R, платные ≥ R, хвост ниже R);
- уровня нет вовсе (нет ни оценки, ни поправки) — неоценённые обычно самые свежие модели, до которых фид не дошёл, и слабыми они не считаются; при недействительных опорах сюда попадает и модель с числовой оценкой (вычисляемой градации нет) — середина очереди;
- уровень подтверждён и ниже требуемого — последний резерв.
В ступенях 1–2 — уровень по убыванию, бесплатные раньше, незапускавшиеся на тикете
раньше, затем ранжир селектора и порядок списка. Место без тега ступени не имеет, его
порядок не меняется. Ступени видны в строке
LEVELSвместе с порогами,as_ofфида и числом кандидатов в каждой.
Градация: откуда берутся оценки и уровень
Ответ команды фактов несёт по каждой модели level (low/medium/high или null) и
level_source — откуда уровень: override — поправка из своей практики, older —
оценка младшей версии семейства (правило R4), feed — оценка самой модели; null —
уровня нет. Уровень и источник каждой модели видны в строке FACTS у её кандидата
(суффикс level=<уровень>(<источник>), например level=high(override)).
Фид знает не все модели: у части нет ни своей оценки, ни оценки младшей версии. Уровень
или свою оценку для таких моделей задают руками — файл configs/model-levels.yaml
(в проекте виден как .workflow/config/model-levels.yaml), команда получает его
аргументом --overrides. Ключ — модель в любой привычной форме: полный id, он же без
маршрута, имя модели или slug оценки; голое имя без производителя принимается, если на
это имя в каталоге не больше одного производителя: один — поправка применяется, ни
одного (модели в каталоге нет — так задают уровень новинкам) — тоже, два и больше —
ключ неоднозначен, WARN, и поправка не применяется. Значение — имя уровня или число
(своя оценка: уровень считается теми же порогами). Поправка бьёт фид.
Попытка
Одна попытка — один вызов executeWithFallback:
- Выжившие — фильтры курсора (способности, health-реестр — у пула и id пула,
запреты моделей, пробованные в попытке,
max_per_attemptпула), те же типизированныеblocked(no_capable_agent,all_unhealthy,all_banned); места-пулы заменяются участниками, прошедшими фильтры, пул в списке дважды — один раз, на первой позиции. Пересчитываются на каждом витке. - Шлагбаумы — один проход в начале попытки, до селектора и пока нет остановки: у
каждого пула с
models.gateи хотя бы одним выжившим участником.closed— место закрыто (health-реестр, как раньше) и выбывает из попытки;open— участник этого пула, запускаемый первым в попытке, шлагбаум не опрашивает; после первого запуска попытки (чьего угодно) ответ прохода устарел, и опрос идёт перед каждым запуском участника: пока работал другой кандидат, квоту могли израсходовать;error— место открыто. - Уровни — полосы по оценкам выживших после прохода шлагбаумов. Оценки нет ни у
одного — шаги 4–6 не выполняются, попытка идёт прежним курсором (
skipped:no_scores); без тегов он и вправду прежний, а с тегами места и участники пулов упорядочиваются по ступени, и курсор вращается внутри лучшей непустой ступени (раздел «Тег минимального уровня у места»). - Нижняя граница тикета — из журнала запусков: запуски исполнителя этого тикета с
градациями
refused,empty,artifacts_failed,review_failed(resetучтён;crashed,crashed_after_work,throttled,stopped,pendingне считаются). Оценка запуска — текущий факт агента, агента в фактах нет — полеscoreсобытия, нет и его — запуск пропускается с WARN. Граница — наибольший уровень таких запусков (без оценки — 1), не выше сильнейшего уровня выживших в начале попытки минус 1: после провала на верхнем уровне верхний уровень берётся снова. Кандидаты с уровнем не выше границы выбывают. Граница читается на каждом витке, поэтому переживает перезапуск раннера и смену тикета. - Селектор — вызов на попытку; сбой вызова повторяется один раз, кроме классов,
которые повтор с тем же промптом не исправит. Ответа нет (сбой и после повтора, ответ
без уровня, селектор нездоров) — требуемый уровень берётся по
complexityтикета среди уровней выживших (simple— нижний,medium— средний,complex— верхний; у тикета без поля — наименьший), строкаSELECT_FALLBACKназывает причину. Пропуск с причиной:no_ticket,unhealthy(селектор нездоров),stopped,single_candidate(над границей меньше двух),single_level(все на одном уровне);no_scores— ни у одного выжившего нет оценки, попытка идёт прежним курсором (раздел «Факты: оценки и бесплатность»). Ответ даёт требуемый уровень R и ранжир; R не выше границы поднимается до границы + 1. Сбой — выход ≠ 0,status: error, таймаут 60 с, илиrequired_levelнет, не целое, вне 1..N (unknown_level, его ранжир всё равно идёт в порядок внутри уровня): R — наименьший уровень выживших, лестница от слабых. Сбой помечает в health-реестре только селектор стадии, по тем же правилам, что селектор пула. - Порядок обхода (R' = max(R, граница + 1)) — на каждом витке из текущих выживших: сначала бесплатные с уровнем ≥ R' по возрастанию уровня, затем платные с уровнем ≥ R' по возрастанию, затем хвост ниже R' по убыванию, в уровне бесплатные раньше. Внутри уровня — ещё не запускавшиеся на тикете, затем место в ранжире, затем порядок списка. Первый в порядке запускается.
- Отказ кандидата внутри попытки — те же поводы, что у курсора: ошибка модели,
MODEL_BANNED, сбой с пустым diff, шлагбаум закрыт при запуске. Селектор заново не вызывается, следующий — из пересчитанного порядка. Сбой с изменёнными файлами, как раньше, стадию останавливает. Сбои границу не поднимают. Исчерпаны все кандидаты над границей — ошибка стадии (goto.error) и следующая попытка. - Эскалация (
escalate_on: [blocked]) — агент сам ответилstatus: blocked(уblockedраннера всегда естьblocked_reason): событиеrunзаписано сresult_status: blocked(градацияrefused), и если есть выживший строго выше уровня отказавшего — он запускается в той же попытке (равный уровень не пробуется), сделанное записано в тикет, как послеMODEL_BANNED. Сильнее нет —blockedуходит стадии, вgoto.blocked, как раньше.
Пример — FIX-032 PulseProxy (нужен mcp, выжившие claude-haiku 16.9, claude-sonnet 38.2,
claude-opus 50.8, N = 5): уровни haiku 1, sonnet 4, opus 5. При R = 1 запускается haiku; его
blocked — sonnet в той же попытке, затем opus, затем goto.blocked. Следующий выбор, и в
новом процессе, — граница 1: haiku выбывает, первым идёт sonnet.
Контракт селектора
Промпт — инструкция и JSON-блок в ограждении ```json; кандидаты — только
выжившие над границей, в порядке лестницы:
{
"mode": "models", "stage": "execute-task",
"ticket": {"id": "FIX-032", "type": "fix", "title": "…", "priority": 1, "complexity": "medium",
"required_capabilities": ["mcp"], "tags": ["…"], "description": "<до 2000>", "dod": "<до 4000>",
"notes": "<до 1000>", "files": 5, "references": 3, "executor_runs": 1, "floor_level": 1,
"history": [{"level": 1, "outcome": "refused"}]},
"levels": ["<рубрика 1>", "…", "<рубрика N>"],
"candidates": [
{"id": "free-kilo@kilo/qwen/qwen3.8-27b:free", "kind": "pool_member", "free": true, "level": 3,
"scores": {"intelligence": 33.7, "coding": 68.1, "agentic": 45.8}},
{"id": "claude-sonnet", "kind": "agent", "free": false, "level": 4,
"scores": {"intelligence": 38.2, "coding": 71.5, "agentic": 43.6}}
],
"scores_citation": "…"
}Заголовок и DoD — как у селектора пула; priority, complexity (по умолчанию medium),
required_capabilities, tags, а также notes и число files, references — из
frontmatter тикета (context); описание — текст тела до секции DoD. В истории — только
уровни и исходы, без id. Цен, причин отсева и долей успеха журнала в промпте нет;
кандидат без оценки — "scores": null.
Ответ — блок RESULT: required_level: <1..N>, ranking: <id>, <id>, …, cost_usd.
Id не из кандидатов пропускаются. Селектор поставляемого конфига jev-place — обёртка
src/scripts/decisions-select.js в режиме models (признак — levels в JSON-блоке; блок
без levels — режим пула без изменений) с --level-quantile 0.5. Один вызов decisions,
два вопроса:
level(score, уровни — тексты рубрики): «Which is the LOWEST level whose models can reliably complete this ticket end to end? …»;pick(choice, варианты — кандидатыid | level k | free|paid | scores …; при двух и больше, первые 255): порядок внутри уровня.
required_level — наименьший уровень k с суммой вероятностей уровней 1..k не меньше
--level-quantile (в (0, 1]); сумма не набралась — уровень наибольшей вероятности.
ranking — все кандидаты по вероятности pick. Уровней меньше 2 или больше 10, нет
кандидатов, повтор id — bad_prompt без запроса. Предел choice в слое оценки — 255
вариантов (score — 10), по документации Decisions API; живым вызовом не проверен.
jev-place — отдельный id от селектора пула: сбой одного не помечает другой.
Строки лога
Расширение VS Code и MCP эти строки не читают.
FACTS stage="execute-task" candidates=38 scored=20 free=29 as_of="…" citation="…" [claude-haiku=16.9/paid(openrouter_price)~anthropic/claude-4.5-haiku-20251001 level=low(feed), free-kilo@kilo/stealth/space-bunny-alpha=n/a/free(kilo) level=high(override), …]
FACTS stage="execute-task" scored=0/38 free=0 (exit 1)
LEVELS stage="execute-task" places=["free-kilo[medium]"] above=2 tiers=0:3,1:1,2:0 high=36 medium=16.9 as_of="…"
SELECT_MODEL stage="execute-task" selector="jev-place" candidates=35 levels=5 range=9.9..50.8 floor=0 required_level=3 ranked=35 pick="free-kilo@kilo/qwen/qwen3.8-27b:free" level=3 free=true fallback=none cost_usd=<цена> duration_ms=<мс>
SELECT_FALLBACK stage="execute-task" fallback=timeout complexity=complex level=5 available=1,3,5
ESCALATE stage="execute-task" from="claude-haiku" level=1 to="claude-sonnet" level=4
HEARTBEAT agent_pid=12345 elapsed_s=300 ticket=IMPL-126
RUNNER CRASH reason=uncaughtException exit_code=1 stage="analyze-report" — TypeError: …FACTS — раз на процесс на стадию; у кандидата с уровнем виден и он сам, и его источник
( level=<уровень>(<источник>)); SELECT_MODEL — раз на попытку (required_level=- у
лестницы и курсора; fallback — none, skipped:<причина>, error, timeout,
unknown_level);
LEVELS — раз на попытку при месте с тегом: места с тегом, сколько их кандидатов прошло
уровень (above) и разбивка всех кандидатов по ступеням очереди (tiers=0:…,1:…,2:…),
пороги (high, medium) и as_of фида.
ESCALATE … to="none" level=- — сильнее нет. Agent selected: <id> (attempt N,
compatible=[…]) — прежняя строка, compatible — порядок обхода (у курсора — места). GATE, MODEL_BANNED,
agent … skipped: model … — прежние; SELECT agent= и SCORES на стадии с выбором не
печатаются. Поля запуска в журнале — раздел «Журнал запусков и отсев моделей».
Откат и известные риски
Откат — убрать selection у стадии и вернуть пулам selector/scores (и агента
jev-select): путь курсора — прежний код.
- Суждение Jev о достаточности не измерено, квантиль 0.5 не откалиброван.
- Цена и время вызова с
choiceна ~35 вариантов не измерены — смотретьcost_usdиduration_msстрокиSELECT_MODELв первом живом прогоне. - Уровни относительные: смена состава стадии сдвигает их, одна и та же модель сегодня — уровень 2, завтра — 3. Граница берёт текущие уровни.
- Кандидаты без оценки — на уровне 1: сегодня это 18 из 29 участников пулов, и провал модели без оценки поднимает границу до 1 и убирает всех кандидатов уровня 1.
- Законный отказ (нет зависимости, нужен человек) тоже эскалирует — не больше одного запуска на каждый более сильный уровень.
- Приоритет бесплатных запускает сильную бесплатную модель раньше слабой платной — так задумано.
Runner-стадии
В системе поддерживаются различные типы стадий для управления процессами разработки:
Human Gate
human-gate — стадия ручного контроля, которая требует вмешательства человека для продолжения выполнения пайплайна. Используется для критических точек, где необходима ручная проверка или утверждение.
Условия срабатывания:
- Стадия
manual-gate-humanблокирует пайплайн до ручного снятия gate - Gate снимается через команду
move-ticket.js <id> unblock - Поддерживает настройку таймаутов и условий повторных попыток
Пример конфигурации pipeline.yaml:
stages:
- name: manual-gate-human
type: manual-gate-human
config:
timeout: 3600 # 1 hour
max_attempts: 3
message: "Requires human approval before deployment"Пример полного файла pipeline.yaml доступен в docs/samples/pipeline-with-human-gate.yaml.
Autoblock notifications
Для автоматического блокировки тикетов при возникновении проблем используется механизм mark-blocked. Блокированные тикеты получают дополнительные поля в frontmatter для отслеживания причин и попыток:
auto_blocked_reason— причина автоматической блокировкиauto_blocked_attempts— количество попыток разблокировкиauto_blocked_at— время автоматической блокировки
Отчёт только по изменившейся доске
Когда работы нет (check-conditions → empty), стадия check-report-needed
(src/scripts/check-report-needed.js) решает, нужен ли новый отчёт. Доска — подпись
колонок и полей updated_at/completed_at тикетов (их пишут скрипты перемещения; время
файла не годится: раннер дописывает тикету строки «Истории работы»). Подпись доски, для
которой пишется отчёт, и время запроса лежат в .workflow/state/report-gate.json. Исходы:
needed — доска изменилась или отчёта нет, дальше create-report; analyze — отчёт по
этой доске есть, а удачного запуска analyze-report после него в журнале запусков нет
или последний разбор нашёл пробелы, а удачного decompose-gaps после него не было (прогон
кончился на лимите plan_iterations), дальше разбор этого отчёта; close_plan — последний
разбор по этой доске completed, дальше complete-plan с plan_id из related_plan
отчёта (бесплатный скрипт, повтор ничего не меняет; так план закрывается, даже если прогон
с этим разбором остановили до закрытия; отчёт без related_plan — unchanged); удачный
запуск create-report после запроса (RESULT не error и не blocked) считается отчётом
для этой доски, даже если нового файла он не записал;
unchanged — после последнего разбора с пробелами было разбиение, пайплайн завершается; stuck — то же, но у плана отчёта есть тикеты в blocked/: доска не меняется не потому, что работы нет, а потому, что план стоит (в RESULT — plan_id и blocked_tickets с причиной блокировки, ключ конфига stuck: end, итог прогона — «Pipeline stopped: plan … is stuck (…) — needs a human decision»). Итог разбора раннер пишет в событие журнала полем
result_status (статус блока RESULT). Готовые тикеты (done/, archive/) входят в подпись
только полем completed_at: перенос в архив при закрытии плана доску не меняет. Последний
отчёт — REPORT-NNN с наибольшим номером. Без файла состояния отчёт относится к доске, если он новее всех перемещений.
После decompose-gaps пайплайн идёт в check-conditions. Разбиение без новых тикетов
доску не меняет, и check-report-needed завершает пайплайн: после разбора было разбиение.
Возврат заблокированного тикета после исправления
Тикет исправления называет тикеты, которые он разблокирует, полем frontmatter
unblocks: [QA-162] — данные автора тикета, а не команда (decompose-gaps пишет его в
создаваемое исправление: тикеты он не двигает). check-conditions до просм
