# Как устроены задачи (/ru/docs/tasks-architecture)

Разберитесь, как хранится задача, откуда берётся её ключ и как инструмент будет находить задачи,
которые могут быть одной. Это пригодится разработчикам и авторам агентов, которые читают локальное хранилище или
строят что-то поверх задач. Сначала — данные, потом планируемое: ключи по типам, бронь и проверка на
дубликаты с разобранным примером. Для повседневной работы начните со страницы [Задачи](/llms.mdx/docs/ru/tasks/content.md).

Устройство описано в утверждённом плане задач в cli-core
([WireCatLabs/cli-core#1216](https://github.com/WireCatLabs/cli-core/pull/1216)). В каждом разделе
сказано, что уже есть, а что **планируется**.

## Данные

**Сейчас.** Одна таблица, `tasks`, хранит все задачи локального хранилища. Полный список столбцов —
в [модели данных](/llms.mdx/docs/ru/data-model/content.md#tasks).

| Что | Как хранится |
| --- | --- |
| Тип | `question`, `request`, `mention`, `promise` для задач из сообщений; `bug`, `feature`, `chore` для задач проекта |
| План | название, описание, приоритет от 0 (срочно) до 4 (низкий), срок, время следующей проверки, родительская задача |
| Состояние | `open`, `done` или `dismissed`, с тем, кто закрыл и почему |
| Источник | один указатель на сообщение или заметку, никогда не текст сообщения, поэтому удалённое сообщение ничего не оставляет |
| Оценка | `useful` или `not_useful` — как вы оценили задачу, поднятую агентом |
| Люди | назначения с ролью: исполнитель, проверяющий, наблюдатель |
| История | `task_events`: создана, статус, назначена, срок, приоритет, перенесена, привязка, отвязка |
| Привязки | строки `links` к сообщениям, чатам, людям, документам, заметкам и адресам, с видом |

Каждая задача принадлежит проекту. Задача из сообщения попадает во входящий проект своего аккаунта;
задача проекта — в проект, который создали вы. Привязка к сообщению имеет вид `related-to`,
`evidence` или `created-from`. Отношение между двумя задачами — тоже строка `links`, с видом
`blocked-by`, `follow-up-of` или `related-to`. Отношения работают только между задачами ваших
собственных проектов, и устанавливаете их только вы. Перед записью `blocked-by` или `follow-up-of`
рекурсивный запрос проверяет, что новая строка не замыкает цикл.

**Планируется.** Новые поля: `category`, `claimed_by`, `claimed_until`. Задача становится ещё одним
типом в существующей таблице `taggings`, поэтому команды и фильтры `tags` работают и на задачах. Новые виды
отношений: `duplicate-of` и `discovered-from`. Две новые полнотекстовые таблицы, `task_words` и
`task_stems`, устроенные как таблицы заметок. Каждое изменение категории становится строкой
`task_events` вида `category`.

## Идентификаторы и ключи

**Сейчас.** У задачи проекта есть ключ из ключа проекта и номера: `HRB-12`. Номер берётся из
счётчика в строке проекта, в той же записи, что и вставка задачи:

```sql
UPDATE projects SET tasks_count = tasks_count + 1 WHERE id = ? RETURNING key, tasks_count
```

У задачи из сообщения есть внутренний id (UUID) и ключ из входящего проекта её аккаунта: `IN`,
8 шестнадцатеричных символов и номер, например `IN1A2B3C4D-17`.
`tasks list` показывает начало id; `tasks show` и `tasks agenda` показывают ключ.

**Планируется.** Новая задача из сообщения получает вместо ключа входящего проекта читаемый ключ по
типу: `Q-15` вопрос, `REQ-22` просьба, `M-7` упоминание, `PR-4` обещание. Старые задачи сохраняют
ключ `IN…`. У каждого типа своя строка-счётчик, которая берётся в той же
записи, что и вставка, как у проектов. UUID и оба вида ключей находятся как `task:<…>`. Категория — поле,
а не часть ключа, поэтому исправленная категория не ломает ключ, уже процитированный в чате или
заметке.

**Почему не хеш-идентификаторы.** [beads](https://github.com/gastownhall/beads) использует
хеш-идентификаторы вроде `bd-a3f8`, которые удлиняются с ростом базы
([`internal/idgen/hash.go`](https://github.com/gastownhall/beads/blob/main/internal/idgen/hash.go)).
Хеши решают задачу многих писателей на многих копиях, которые потом сливаются. Здесь одно хранилище
на компьютер и один писатель за раз, поэтому счётчик даёт короткие ключи, которые человек может
прочитать. Если хранилища когда-нибудь начнут синхронизироваться между устройствами, ключи получат
префикс устройства; это решение на потом.

## Бронь (планируется)

Два агента или агент и вы не должны одновременно работать над одной задачей. `tasks claim`
выполняет один запрос внутри `BEGIN IMMEDIATE`:

```sql
UPDATE tasks SET claimed_by = ?, claimed_until = ?
WHERE id = ? AND status = 'open' AND (claimed_by IS NULL OR claimed_until < ?)
RETURNING id
```

Если строка не вернулась, задачу держит кто-то другой; ошибка называет кто и до какого времени.
SQLite пускает только одного писателя за раз во всех процессах, поэтому бронь атомарна между `tg`,
`max`, `memo` и агентами на одном компьютере. Бронь — это **аренда**: она заканчивается сама (по
умолчанию через 30 минут), поэтому упавший агент не держит задачу вечно. `tasks release` снимает
бронь раньше; закрытие задачи снимает её тоже. `tasks agenda` скрывает задачи, забронированные
другими, если не попросить их через `--claimed`.

## Проверка на дубликаты (планируется)

Проверка запускается для каждой новой задачи и для каждого нового сообщения, привязанного к задаче.
Она работает в cli-core, на SQLite, без эмбеддингов и без обращения к провайдеру ИИ.

```mermaid Как новая задача проверяется на дубликаты
graph TD
  New("Новая задача или сообщение") --> Norm("1. Нормализация<br>основы, триграммы, подпись")
  Norm --> Certain{"2. Точное совпадение?"}
  Certain -->|"да"| Attach("Привязать к той задаче")
  Certain -->|"нет"| Cand("3. Кандидаты<br>объединение выборок")
  Cand --> Compare("4. Сравнение по полям")
  Compare --> Score("5. Сумма весов")
  Score --> Decide{"6. Полоса"}
  Decide -->|"вероятно или возможно"| Ask("Выбирает агент, или список на проверку")
  Decide -->|"другая"| Create("Создать задачу")
  Ask --> Learn("7. Учёт ответа")
```

Словами: новая задача нормализуется; точное совпадение сразу привязывает её; иначе собираются
кандидаты, каждый сравнивается по полям, веса складываются, и сумма решает: создать или спросить.
Счёт никогда не объединяет две задачи; решает агент или человек. Каждый ответ сохраняется, чтобы
потом подстроить веса.

### 1. Нормализация

- Название и описание проходят через нормализатор поиска: нижний регистр, без диакритики, `ё`
  становится `е`, группы цифр склеиваются (`15 000` становится `15000`).
- Стеммер Snowball для каждой письменности (русский, английский, испанский) сводит каждое слово к
  основе; стоп-слова убираются. Получается **набор основ**.
- Нормализованное название также режется на **символьные триграммы** — все окна по три буквы. Они
  ловят опечатки и формы, которые стеммер пропускает.
- Шаблоны извлекают **подпись**: сумму с валютой (число рядом с `₽`, `руб`, `€`, `$`, `usd`), срок
  (даты и фразы вроде `by Friday` или `до пятницы`) и человека (`person:<id>` автора исходного
  сообщения, через `identities`). Агент может передать их сам (`--amount`, `--due`, `--person`).

Основы и нормализованный текст попадают в `task_words` и `task_stems`, которые обновляются теми же
записями.

### 2. Точные совпадения

Без подсчёта баллов привязывает только одно совпадение: **то же правило на том же сообщении**, его
держит уникальный индекс по источнику и правилу. Новая задача не создаётся, а в ответе сказано, к
какой задаче и почему.

В первом варианте точными считались ещё ответ и то же название. На тестовом наборе они ошиблись 6 раз
из 11: ответ с новой просьбой, чужое «+1», то же название для другого дела. Теперь ответ — поле с
весом (шаг 4), а то же название — просто высокий балл текста.

### 3. Кандидаты

Кандидаты — объединение дешёвых выборок:

```sql
SELECT task_id FROM task_stems
WHERE task_stems MATCH '<stems joined by OR>'
ORDER BY bm25(task_stems) LIMIT 20
```

плюс задачи того же человека, той же категории и той же цепочки ответов — обычные запросы по
индексам. Учитываются только открытые задачи и задачи, закрытые за последние 14 дней. В объединении
не больше примерно 40 задач. [`bm25()`](https://www.sqlite.org/fts5.html#the_bm25_function) —
функция ранжирования SQLite FTS5.

Два правила не дают потерять хороших кандидатов:

- **Один только общий чат никогда не делает кандидата.** В чате много несвязанных задач, а одна
  просьба может прийти и в группу, и в личный чат.
- **Одна выборка никогда не решает в одиночку.** Graphiti терял точные дубликаты, потому что брал
  кандидатов из одного поиска с отсечкой ([getzep/graphiti#1734](https://github.com/getzep/graphiti/issues/1734)).

### 4. Сравнение по полям

| Поле | Сравнение | Уровни |
| --- | --- | --- |
| Текст | Жаккар (общие элементы, делённые на все различные) по наборам основ названия и описания и по триграммам названия; берётся большее | 0,6 и выше · от 0,3 до 0,6 · ниже 0,3 |
| Человек | `person:<id>` | тот же · другой · неизвестен |
| Сумма | значение и валюта | равны · разные · нет |
| Категория | название | та же · другая |
| Срок | разница в днях | 2 и меньше · больше · нет |
| Цепочка ответов | исходные сообщения отвечают друг на друга | да · нет |
| Время | дней между задачами | 3 и меньше · от 4 до 14 · больше |

### 5. Сумма весов

Счёт следует модели Феллеги — Сантера для связывания записей. У каждого уровня есть вес
`log2(m / u)`. Здесь `m` — как часто этот уровень встречается у настоящих дубликатов, а `u` — у
несвязанных задач. Уровень, который у дубликатов обычен, поднимает сумму; уровень, который у них
редок, опускает её. Метод объясняет [руководство Splink по модели Феллеги — Сантера](https://moj-analytical-services.github.io/splink/topic_guides/theory/fellegi_sunter.html).

Первые веса заданы вручную, затем подстроены на синтетическом тестовом наборе:

| Уровень | Вес |
| --- | --- |
| Текст: 0,6 и выше / от 0,3 до 0,6 / ниже 0,3 | +5 / +5 / −2 |
| Человек: тот же / другой / неизвестен | +1 / −3 / 0 |
| Сумма: равны / разные / нет | +6 / −4 / 0 |
| Категория: та же / другая | +1 / −1 |
| Срок: 2 дня и меньше / больше / нет | +4 / −1 / 0 |
| Цепочка ответов: да / нет | +6 / 0 |
| Время: 3 дня и меньше / от 4 до 14 дней / больше | +2 / −2 / −3 |

Ни у одного дубликата в наборе не было другого человека, суммы или категории, поэтому подсчёт дал бы
этим уровням −5 или −6 без единого наблюдения. Они сохраняют веса, заданные вручную.

Полосы: **10 и больше — «вероятно», от 5 до 9 — «возможно», меньше 5 — «другая».** «Вероятно» только
ставит кандидата первым в списке; по счёту ничего не объединяется. Веса и полосы живут в одном
модуле, с причиной для каждого числа; больше нигде они не зашиты.

### Что показал тестовый набор

В наборе 251 синтетическая задача на русском и английском и 80 пар дубликатов. Используются настоящий
`bm25()` из FTS5 и стеммер cli-core; веса подстроены на одной половине и проверены на другой
([WireCatLabs/cli-testing#60](https://github.com/WireCatLabs/cli-testing/pull/60)).

- «Возможно» и «вероятно» вместе находят 90% дубликатов. 58% показанных пар — настоящие дубликаты,
  а 21% новых задач отклоняется до выбора.
- Одна полоса «вероятно» права в 85% случаев и находит 43% дубликатов.
- **Ни один счёт не безопасен для автоматического объединения.** Первый счёт без ошибочных
  объединений — 16, и он ловит 4% дубликатов. Оставшиеся похожие пары, например подарок маме и
  подарок папе на ту же сумму с разницей в два дня, отличаются смыслом одного слова, а его не видит
  ни одно поле.

Ограничения: данные синтетические, данные и оценку писал один автор, дубликаты на разных языках
находятся плохо (2 из 5 получили «другая»). Измерений на настоящих чатах пока нет.

### 6. Решение

| Полоса | Задачу добавляет агент | Задачу открывает правило |
| --- | --- | --- |
| Точное (шаг 2) | не применяется | ничего нового |
| Вероятно | показана первой; агент обязан выбрать | в список на проверку |
| Возможно | `tasks add` отказывает и показывает до 5 кандидатов со счётом и причинами; агент повторяет с `--attach <task>` или `--new` | в список на проверку |
| Другая | создаётся | создаётся |

Инструмент отказывает, а не предупреждает: предупреждение агент может пропустить, код выхода — нет.
Спросить агента, который создаёт задачу, не стоит лишнего обращения к провайдеру ИИ: этот агент и
так судья. Mem0 устроен так же: агент, который записывает воспоминание, выбирает — добавить его,
обновить существующее, удалить или ничего не делать
([Mem0, arXiv 2504.19413](https://arxiv.org/abs/2504.19413)).

**Список на проверку.** `tasks duplicates` показывает пары, которые правило оставило в полосе
«вероятно» или «возможно», сначала новые, с причинами. `tasks merge <dup> --into <task>` привязывает все
сообщения дубликата к оставшейся задаче, закрывает дубликат как `dismissed` с отношением
`duplicate-of` и записывает решение. `tasks duplicates reject <dup> <task>` записывает «не одно и
то же», и пара больше не предлагается.

**Объединение можно отменить.** `tasks unmerge <dup>` снова открывает дубликат и убирает отношение.
Привязки остаются на месте, пока их не перенесут. Объединение никогда ничего не удаляет.

### 7. Учёт ответов

Каждое объединение, отказ, `--attach` и `--new` после списка кандидатов — размеченная пара. Когда
пар достаточно, `m` и `u` для каждого уровня пересчитываются по ним — та же оценка подсчётом, что в
Splink, без шага обучения, — и полосы сдвигаются. Счёт никогда не объединяет сам. Если настоящие
ответы покажут, что пары «вероятно» вы почти всегда принимаете, объединять ли их — решать вам.

## Разобранный пример

Иван Пример пишет в личном чате: «Пришлёшь счёт на 15 000 ₽ до пятницы?» Правило открывает
просьбу. До её создания проверка находит двух кандидатов от того же человека:

- **REQ-22**, создана вчера: «Send the invoice for 15000 ₽» (отправить счёт на 15000 ₽), категория
  `money`, без срока.
- **REQ-19**, создана 10 дней назад: «Send the contract invoice, 40 000 ₽» (отправить счёт по
  договору), категория `money`, без срока.

Названия задач записаны по-английски, поэтому работает английский стеммер. Новая задача названа
«Send the invoice for 15 000 ₽ by Friday»; её основы: `send`, `invoic`, `15000`, `friday`. В этом примере триграммы дают меньше, чем основы, для обеих
пар, поэтому считаются основы.

| Поле | REQ-22 | Баллы | REQ-19 | Баллы |
| --- | --- | --- | --- | --- |
| Текст | основы `send`, `invoic`, `15000`: 3 общих из 4 различных = 0,75 | +5 | основы `send`, `contract`, `invoic`, `40000`: 2 общих из 6 различных = 0,33 | +5 |
| Человек | тот же | +1 | тот же | +1 |
| Сумма | 15000 ₽ = 15000 ₽ | +6 | 15000 ₽ ≠ 40000 ₽ | −4 |
| Категория | `money` = `money` | +1 | `money` = `money` | +1 |
| Срок | нет у REQ-22 | 0 | нет у REQ-19 | 0 |
| Цепочка ответов | нет | 0 | нет | 0 |
| Время | 1 день | +2 | 10 дней | −2 |
| **Итого** | | **15: вероятно** | | **1: другая** |

Просьбу нашло правило, поэтому инструмент создаёт её как REQ-23 и кладёт пару REQ-23 и REQ-22 в
список на проверку. По счёту ничего не объединяется: агент или вы проверяете пару и запускаете
`tasks merge REQ-23 --into REQ-22`. Если бы задачу добавлял агент, `tasks add` отказал бы, показал
REQ-22 первой с 15 баллами и причиной «тот же человек, та же сумма, общих основ 3 из 4», и агент повторил бы команду с
`--attach REQ-22`. У REQ-19 тот же человек и те же слова «отправить счёт», но сумма и давность тянут
её вниз: это другое дело.

## Сравнение с beads

[beads](https://github.com/gastownhall/beads) — трекер задач для агентов-программистов. Часть его
идей взята сюда, часть — нет.

| beads | Здесь |
| --- | --- |
| Хеш-идентификаторы `bd-a3f8` | Читаемые ключи из счётчика: одно хранилище, один писатель за раз |
| [Метки](https://github.com/gastownhall/beads/blob/main/docs/core-concepts/labels.md) для всего, что не поле | Одна категория из фиксированного списка плюс существующие теги |
| Виды связей, например `duplicates` и `discovered-from` | `duplicate-of` и `discovered-from` добавляются к видам отношений |
| `bd ready`: открытая работа без блокировок | `tasks agenda`; фильтры по категории и тегу планируются |
| `bd update --claim`: атомарный захват | `tasks claim` — аренда, которая заканчивается сама |
| `external_ref` на один внешний объект | Каждая задача привязана ко всем своим сообщениям |
| `bd duplicates` объединяет точные копии; `bd find-duplicates` считает совпадение слов и только сообщает | Проверка до создания, веса полей и выбор агента или список на проверку |

Текст в чатах никогда не бывает точной копией, и ничто в beads не проверяет задачу до создания.
Поэтому проверка здесь работает по полям и спрашивает, когда не уверена.

## Похожие работы

Дубликаты отчётов об ошибках — старая задача. REP сравнивает два отчёта по тексту и по полям вроде
продукта и компонента, с весами, подобранными на известных дубликатах
([Sun et al., ASE 2011](https://doi.org/10.1109/ASE.2011.6100061)). Проверка здесь использует ту же
идею — текст плюс поля, — но веса считаются, а не обучаются.

## Чего нет

- **Ни эмбеддингов, ни обращения к провайдеру ИИ** для сравнения задач. Если тестовый набор покажет,
  что текстовое поле слишком слабое, следующий шаг — локальный эмбеддинг, измеренный против этой
  проверки.
- **Никаких удалений.** Дубликат закрывается и привязывается; его никогда не удаляют.
- **Никакого ограничения «одна задача на чат или на человека».**
- **Никаких хеш-идентификаторов**, пока хранилища не синхронизируются между устройствами.
- **Открытые вопросы:** список категорий по умолчанию и что делать с сообщением, похожим на задачу,
  закрытую за последние 14 дней: снова открыть её или открыть новую со ссылкой `follow-up-of`.
