Саги: явная регистрация долгой работы (Sagas)
#1. Цель
Сделать сагу — продолжительное усилие, которое живёт дольше одного рана и
может переходить между агентами, — явной сущностью с явной регистрацией, а не
скрытой группировкой по task_id.
Первоначальная реализация бюджета (kernel/cost) называла сагой каждую задачу:
события группировались по ev.Context.Task, и любые раны одной задачи
складывались в «сагу» автоматически. Это давало сумму затрат, но не отвечало на
главный вопрос: какая работа считается одним целым, когда её ведут разные
люди и агенты. Продолжение беседы в чате, делегирование, перехват задачи лидом
у аналитика — всё это одна работа с общей стоимостью, и решение о том, что она
едина, должен принимать агент, а не эвристика группировки.
Ключевые принципы:
- Сага регистрируется явно. Инструмент
saga_registerсоздаёт сагу и привязывает к ней текущую задачу. Никаких неявных саг больше не возникает. - Привязка живёт на уровне задачи. Сага привязывается к
task_id, а не к рану: продолжения беседы (новые раны той же задачи) и делегированные дети (наследуютTaskIDкорня) попадают в сагу автоматически. - События — единственное состояние. Саги существуют только как journal
события
saga.registered/saga.attached; проекция строится при чтении. Ни одной новой таблицы, ни одного CRUD — консистентно с моделью knowledge.
#2. Модель
Saga (явная регистрация)
└── Task 1 ── раны (продолжения чата, retry)
│ └── делегированные дети (delegate/plan, наследуют TaskID)
└── Task 2 ── раны (задача лида, присоединилась через saga_attach)
- Saga — именованное усилие:
saga_id,title,goal, автор (ран, зарегистрировавший сагу), время регистрации. Идентификатор детерминирован:saga-<slug(title)>-<hash6(task_id)>— повторная регистрация после потерянного ответа попадает в тот жеsaga_id. - Task — основная единица привязки. У задачи не больше одной активной
саги: последнее событие привязки выигрывает (последовательные
registered/attachedсортируются по времени, а не по порядку прибытия). - Run — прежняя единица исполнения; ничего не меняется.
Задачи без саги никуда не исчезают: они сворачиваются в отдельный список «задачи без саги» на странице обзора проекта. Сага не обязана быть у каждой задачи — только у работы, которую агент считает продолжительной или сквозной.
#3. Инструменты агента
| Инструмент | Что делает | Идемпотентность |
|---|---|---|
saga_register(title, goal?) |
Регистрирует сагу и привязывает текущую задачу | Повторный вызов для уже привязанной задачи возвращает существующую сагу без новой эмиссии |
saga_attach(saga_id, note?) |
Привязывает текущую задачу к существующей саге | Повторная привязка к той же саге возвращает already_attached; привязка к другой саге переносит задачу (последний выигрывает) |
saga_list() |
Список саг проекта: id, title, goal, задачи, время | Чтение |
Требования к вызову: у рана должны быть Project и TaskID — иначе понятная
ошибка («этот ран без задачи, привязывать нечего»).
saga_attach проверяет, что сага зарегистрирована в этом проекте (по journal),
иначе отказ с подсказкой вызвать saga_list — защита от фантомных привязок по
опечатке в id.
Классификация инструментов:
saga_list— read-only (журнальное чтение);saga_register/saga_attach— policy-safe записи в observation-слой (какremember): не касаются воркспейса и внешних систем, approval не требуется.
#4. События
| Тип | Data | Context |
|---|---|---|
saga.registered |
saga_id, title, goal?, origin (agent-tool / human-ui), task_id? |
Project, Run?, Task?, Actor |
saga.attached |
saga_id, task_id, note?, origin? |
Project, Run?, Task?, Actor |
saga.renamed |
saga_id, title?, goal?, origin=human-ui |
Project, Actor |
saga.merged |
saga_id (цель), source_saga_ids[], origin=human-ui |
Project, Actor |
saga.detached |
task_id, origin=human-ui |
Project, Task, Actor |
EventID включает UnixNano (<run>/<type>/<nano> или saga-lifecycle/<saga>/<type>/<nano> для ручных) — повторные эмиссии после
ретрая активности не коллайдят. Дубликаты безопасны по смыслу: проекция
сворачивает их по saga_id/task_id.
Эмиссия идёт через durable outbox из activity (как skill.proposed), а не из
workflow-потока: тул выполняется в ActivityRunTool.
#5. Распространение саги
- Продолжение беседы. Продолжение создаёт новый ран той же задачи — сага
наследуется через
task_idбез каких-либо действий. - Делегирование.
delegateиplanкопируютTaskIDкорня в ребёнка (kernelResolveAgent), поэтому всё дерево делегирований уже внутри саги. - Кросс-агентский перехват (takeover). Аналитик зарегистрировал сагу;
лид берёт работу — его задача присоединяется через
saga_attach(saga_id).saga_idлид находит черезsaga_list. Дальше затраты обеих задач суммируются в одной саге.
Ничего из этого не требует новых механизмов распространения: сага ездит на задаче, задача — на ранах и делегациях.
#6. Агрегация (kernel/cost)
ProjectBudget теперь содержит три уровня:
- Тоталы проекта — сумма по всем задачам (привязанным и нет).
sagas[]— по строке на каждую зарегистрированную сагу: мета (title, goal, автор, время),task_ids(хронологически), суммарные раны/агенты/токены/ стоимость/approvals. Зарегистрированная сага без событий тоже показывается (нулевые тоталы) — регистрация сама по себе факт.tasks[]— по строке на задачу: те же тоталы плюсsaga_id, если задача привязана.
Алгоритм агрегации двухпроходный и устойчив к порядку событий:
- Проход по
saga.*событиям строит мету саг и карту task→saga (последняя по времени привязка выигрывает; привязка к неизвестной саге игнорируется). - Проход по остальным событиям накапливает задачу в её собственный аккумулятор и, если задача привязана, — параллельно в аккумулятор саги.
SagaCount сменил смысл с «число задач» на «число явных саг»; TaskCount —
новое поле. Пер-рановый /v1/agent/cost/task не изменился.
Детерминизм: сортировки саг и задач — по времени начала (новые сверху), tie-break по идентификатору; внутри саги задачи упорядочены по времени старта.
#7. UI
Вкладка Саги дашборда (/dashboard, рядом с «Запусками»):
- тоталы проекта: задачи, саги, раны, elapsed/active, токены, стоимость, approvals;
- таблица саг с раскрыванием в привязанные задачи;
- таблица «Задачи без саги»;
- ручное управление (§8): переименование, объединение дублей, привязка задач.
Из вкладки Runs секция саг убрана: раны — исполнение, саги — учёт. Пер-рановый
budget-вью (Task budget по выбранному рану) остался в Runs без изменений.
#8. Ручное управление (UI, writer)
Саги остаются event-sourced: ручные операции — это те же журнальные события,
что и у тулов агента, только с origin=human-ui и человеческим актором.
Никаких таблиц и переписываний истории — обе проекции (kernel/cost и
saga_list) перекладывают события заново.
API (cmd/agent-kernel/sagalifecycle.go, все writer-only, ?project= обязателен):
| Эндпоинт | Тело | Событие |
|---|---|---|
POST /v1/workspace/sagas |
title, goal?, task_id? |
saga.registered |
POST /v1/workspace/sagas/{id}/rename |
title?, goal? (хотя бы одно) |
saga.renamed |
POST /v1/workspace/sagas/{id}/merge |
source_saga_ids[] |
saga.merged |
POST /v1/workspace/sagas/{id}/delete |
— | saga.deleted |
POST /v1/workspace/tasks/{taskID}/saga |
saga_id (пустая — отвязать) |
saga.attached / saga.detached |
Правила проекции:
- rename: последний
saga.renamedпо времени перекрывает title/goal регистрации; пустые поля не затирают существующие; - merge: привязки саг-источников перепроецируются на цель (цепочки
слияний разворачиваются до финальной цели), источники остаются
строками-перенаправлениями (
merged_into) без задач и затрат;saga_attachк слитой саге отклоняется с подсказкой о цели; - detach: задача возвращается в пул «без саги», позднейший attach может привязать её снова;
- delete (гигиена оператора): криво созданная или неверно слитая сага
удаляется одним
saga.deleted— обе проекции (rollup дашборда иsaga_list) забывают сагу, её задачи возвращаются в пул «без саги»; раны, задачи и события журнала не трогаются (отклоняется группировка, не работа). Повторная регистрация с тем же id после удаления чисто воскрешает сагу. В UI — кнопка ✕ в строке саги с подтверждением.
Существование саги проверяется свёрткой жизненного цикла по журналу:
saga.registered создаёт, saga.deleted отзывает (позднейшая повторная
регистрация с тем же id воскрешает) — ровно та же свёртка, что строит
rollup, поэтому валидация роутов и список в UI не могут разойтись. Плюс
окно read-your-writes: только что созданная через роуты сага валидируется
~30 секунд, пока её регистрация стекает через outbox в журнал (bulk-create
привязывает задачи сразу после создания); удалённая сага окном не
воскрешается.
#Что сознательно не сделано
- Закрытие саг (
saga.close). Сага без активности естественным образом «остывает» (running=0, последний ран давно). Явное закрытие имеет смысл ввести, когда появится вопрос «какие саги вообще живы» на сотнях саг. - Саги между проектами. Привязка проверяется в рамках одного проекта; кросс-проектные усилия требуют другой модели владения.
#9. Открытые вопросы
- Стоит ли авто-предлагать
saga_registerагенту при длинных задачах (например, при N-м продолжении беседы) — и в каком месте системного промпта? - Нужен ли поиск/фильтр саг на странице обзора при большом их количестве?
- Как показывать саги, у которых все задачи давно завершились, но сага не закрыта — «stale»-индикатор по порогу неактивности?