# Напоминания живут в data/, диспетчер — минутный Schedule

Владелец поставил две планки: «надёжно как кусок железа» и «использовать то, что eve уже умеет».
Разбор 12.09 показал, что штатный путь напоминаний мёртв сразу по нескольким причинам.

**Модель не видит инструкцию.** Живой системный промпт — не `agent/instructions.md` из репозитория,
а личный оверрайд владельца `data/custom/agent/instructions.md`: блок про напоминания велит слать
мини-скрипт, слов `iva remind` в нём нет. Репозиторная инструкция (`agent/instructions.md:82-97`)
на этой установке мертва; пока оверрайд жив, туда не доедет и правка про напоминания.

**Напоминание — абзац прозы, а не инструмент.** У модели нет тула со словом remind; единственный
долговременный примитив — `bash`, и обходами он уже воспользовался дважды: 24.08 — свой скрипт с
инлайн-токеном и `curl` на Bot API, 31.08 — отправка через userbot-прокси `tg`.

**Носитель не durable и требует ручной арифметики.** `systemd-run --on-calendar` пишет транзиентный
юнит в `/run` (tmpfs): ожидающее напоминание исчезает при ребуте, и `Persistent=true` не помогает —
исчезает сам файл юнита. Явную зону systemd принимает (`deploy/iva-brain.timer:5` —
`OnCalendar=*-*-* 05:00:00 __TIMEZONE__`), но инструкция велела конвертировать время руками; хост и
`systemd --user` живут в UTC, сервис — в `TZ=Asia/Tashkent` (`scripts/cli/systemd.ts:161`), отсюда
инцидент 24.08: напоминание на 13:00 Ташкента сработало в 08:00 UTC. Адресации нет ни у
`systemd-run`, ни у `iva remind`: ни топика форума, ни userbot-адресата, хотя оба самописных
сценария хотели именно их.

**Отказ невидим.** За 01.08–12.09 в журнале нет ни одного вызова `iva remind`; транзиентные юниты
`run-r<hex>` не попадают в фильтр `/^iva-/` на экране `/menu` (`scripts/lib/menu/crons.ts:106`).

## Что решено

**Таблица напоминаний в `data/`.** Разовые и повторяющиеся строки лежат в `data/` — единственной
части установки, которая переживает `iva update` и откат (`scripts/lib/version-store.ts:37`:
`STATE_DIRS = ["data", "vault", ".eve/.workflow-data"]`). Стор строится на существующем
`agent/lib/json-store.ts` (атомарная запись `saveJsonAtomic:61`, строгое чтение
`loadJsonStrict:36`) по образцу `agent/tools/tasks.ts:6-9`. У файла есть `schemaVersion`; файл
новее кода — явная ошибка, а не «прочитаем как-нибудь».

**Минутный Schedule — диспетчер.** Один authored-файл `agent/schedules/reminders.ts` с
`defineSchedule({ cron: "* * * * *" })` на каждом тике забирает созревшие строки и запускает
каждой своего ребёнка `scripts/reminders/fire.ts`. Взятие строки — один атомарный переход
`pending → fired` с `firedAt`; повторное срабатывание невозможно по построению, поэтому у
строки нет ни аренды, ни счётчиков попыток, ни окна повторов. Повторяющаяся строка тем же
переходом получает от croner следующий срок и остаётся `pending`; факт срабатывания
(`firedAt`, `delivered`, `error`) при ней. Сработавшие разовые строки живут сутки — столько
владелец видит факт в `remind_list`, — и убираются тиком одной строкой. Пульс тика — mtime
файла `data/reminders.tick`, обновляется каждый проход: по нему `iva doctor` и `remind_list`
говорят, жив ли диспетчер. Пропущенное время догоняется бесплатно: всё, у чего срок в
прошлом, берётся на следующем тике.

**Две независимые ветки в ребёнке.** В срок `scripts/reminders/fire.ts` делает два дела
сразу, через `Promise.allSettled`, и ни одно не ждёт другого: (а) код отправляет текст
владельцу как есть через `sendTelegramHtml` (`scripts/lib/telegram-send.ts:193`) — тот же
Outbox-шов с гейтом, которым уходят ночные отчёты, — и сразу пишет в строку `delivered` и
причину; (б) агент просыпается отдельной сессией (`scripts/lib/reminder-turn.ts`) с номером
напоминания, читает факт через `remind_list` и, если текст не дошёл, говорит владельцу сам.
Ветка (а) работает без модели и её токенов, ветка (б) не отменяет отправку и не зависит от
её провала. Если ребёнок не стартовал или умер, не сказав факта, тик пишет `delivered: false`
с причиной по коду выхода. Ни повторов, ни `alertOnce`, ни окон: сбой виден строкой и ходом,
а не лестницей попыток.

**Инструмент вместо прозы.** `remind_add`, `remind_list`, `remind_remove`. Срок — разовый
или повторяющийся (расписание с явной зоной владельца); выбора режима нет: в срок происходят
обе ветки, описанные выше. Время задаётся словами или ISO в зоне владельца. Абсолютный момент
считает код и возвращает модели готовый `next_run_at`; ответ говорит, жив ли диспетчер.
`remind_list` показывает и недавно сработавшие строки с фактом `delivered`/`error`.

**Гвард удалён 19.10.2026.** Решение владельца: без запретов в bash; риск — в скилле.
Расписание — инструмент `remind`, отправка — ответ хода, во внешний чат — `iva post`.
Перезапуск через `iva` из хода сохраняет заявку в `data/`; мост запускает её после конца хода
отдельным CLI-процессом через service-manager. Заявка и факт исполнения переживают рестарт моста.
Пока команда ожидает или исполняется, другая команда получает явный отказ; одинаковая команда подтверждает уже принятую заявку.

## Что отвергнуто

- **Политики повторов и две оси режимов.** Первая редакция этой схемы несла аренду строки,
  троттлинг повторов, окно на 6 часов для разовых и 30 минут для повторяющихся, `alertOnce` и
  отдельный режим на строку (verbatim/agent). Владелец отверг это 12.09 как переусложнение:
  надёжность даёт один атомарный переход `pending → fired` и две независимые ветки в срок, а не
  лестница повторов и не выбор режима.
- **Вариант A — усилить `systemd-run`.** Транзиентный юнит живёт в tmpfs, ребут теряет ожидающие
  напоминания; durability требует своих постоянных юнитов, а это противоречит правилу
  «`scripts/cli/systemd.ts` — единственный источник правды для юнитов». Остаются зависимость от
  linger, ручная конверсия зоны и отсутствие адресации.
- **Вариант C — durable workflow-тул («Remind me later» из документации eve).** Его состояние
  лежит в `.eve/.workflow-data`, а `iva update` этот каталог карантинит
  (`scripts/lib/wf-store.ts:69-82`): каждый апдейт уничтожал бы спящие напоминания. Вдобавок при
  пине `ai` 7.0.39 не линкуется `@ai-sdk/code-mode`, и любой workflow-тул роняет старт сервера
  (`docs/tech-debt.md:296`). Вариант отложен до апгрейда `ai` — пометка в `docs/tech-debt.md`.
- **Отправка напоминания в живую сессию.** Отменяет ход пользователя или ждёт его; для
  повторяющихся напоминаний под нагрузкой это худший из вариантов (см. «Что решено»).
- **Свой планировщик-процесс.** Лишняя трущаяся деталь: минутный Schedule уже есть, а состояние
  обязано жить в `data/` независимо от носителя тика.

Последствия. В `data/` появляется новая поверхность данных с версией схемы и правилом «файл новее
кода — ошибка»; она обязана переживать откат версии. Наблюдаемость переносится в `/menu` и
`iva doctor` (ближайшие напоминания в списке). Рецепт `systemd-run` из `agent/instructions.md:82-97`
удаляется целиком; но пока живой промпт — оверрайд владельца, это не действует, и сведение
оверрайда с authored-файлом идёт первым шагом.

## Обновление (2026-09-12)

Три тула из «Инструмент вместо прозы» свёрнуты в один — `remind` с полем `action`
(`add` | `list` | `remove`) и плоской схемой. Поведение и ответы прежние, причина одна:
три схемы тулов ехали в контекст на каждом ходу, а действий у напоминания три и сами они
не пересекаются. Имена `remind_add` / `remind_list` / `remind_remove` выше — запись
решения на момент его принятия; живой тул один, и это сторожит
`scripts/remind-tool.test.ts`.

## Обновление (2026-09-17)

Двух веток в срок больше нет: **текст напоминания — это промпт, и в срок агент выполняет его
одним ходом**. `scripts/reminders/fire.ts` запускает ход свежей сессией
(`scripts/lib/reminder-turn.ts`, один промпт на срабатывание и на `iva remind`), а код
отправляет финальный текст хода в чат и тему строки. Отправки «текста как есть» остались
ровно одной — страховкой на случай, когда хода не было.

**Почему.** Напоминание просят действием: «через 3 минуты найди новости и пришли»
(@andrey_mic, 17.09). Ветка (а) слала обратно формулировку просьбы («Поиск новостей про
Австралию и отправка в чат»), а ветка (б) просыпалась только проверить доставку и молчать —
то есть агент в срок не делал ничего. Так же это решено у Hermes Agent и OpenClaw: текст
напоминания идёт промптом в свежую сессию с инструментами, ответ уходит туда, откуда
просили.

**Что изменилось.** Ветка (б) и её промпт-проверка (`firePrompt`) удалены вместе с фенсами
«ждать ветку (а)», «row moved to firedAt» и функцией стора `recordWakeError`: судить о чужой
отправке больше некому. Ход один, отправка одна, `recordDelivery` пишет `delivered` по итогу
`send`. `status === "waiting"` — нормальный конец хода (T40), провал — только `failed`.
Промпт запрещает агенту слать что-либо самому и ставить новые напоминания этим ходом
(`list` и `remove` разрешены). Описание тула и `agent/instructions.md` говорят модели то же
самое: text — инструкция самой себе, писать самодостаточно.

**Что осталось.** Одна попытка: ни повторов, ни лестницы, ни окон. Факт живёт в строке
(`firedAt`, `delivered`, `error`), схема стора и минутный тик не тронуты. Страховка без
модели: ход упал, вернул `failed` или промолчал — код шлёт `row.text` как есть, `delivered`
по итогу отправки, `error` — причина провала хода.

## Обновление (2026-10-19)

`schedule` добавляет перенос с сохранением id и текста, запуск в ближайший тик и
команды без модели. `remind` сохраняет прежний контракт add/list/remove текстовых
напоминаний. Оба инструмента используют одно хранилище и минутный диспетчер.

Команды лежат в `data/schedule-commands.json` в существующем формате строки:
`text` содержит команду, id начинается с `c-`. Отдельный файл нужен для отката.
Строгий читатель 0.5.5 отправляет строки с неизвестными ключами в карантин,
а следующее сохранение убирает их из активной таблицы. Файл команд старый выпуск
вообще не открывает; напоминания сохраняют прежний путь и схему версии 2.
После возврата новой версии просроченные команды забирает ближайший тик.

Один минутный Schedule запускает диспетчер обеих таблиц и шаг helper/goal
независимо. Диспетчер возвращается после атомарного взятия строк и запуска детей;
результаты детей наблюдаются в фоне. Долгий ход напоминания оставляет следующий
минутный тик доступным помощникам. Порядок внутри helper/goal сохранён.

Команда выполняется в bash из корня установки с окружением сервиса и сроком
2 минуты по умолчанию; `timeout_minutes` у add/move задаёт целое 1–60.
Срок текущего запуска хранится отдельно в `running_timeout_minutes`: перенос меняет срок
следующего запуска, сохраняя текущий. Оба поля живут только в файле команд. Таймаут убивает группу процессов. Последний факт остаётся в строке,
журнал в `data/` ограничен 32000 символами; командные строки хранятся до снятия.
Успех тихий, сбой идёт через существующие Alert и Outbox с недельным дросселем.
Атомарное взятие допускает потерю запуска при остановке между взятием и спавном,
как у прежних напоминаний; автоматических повторов после этой границы нет.

Код держит состояние, дедупликацию, точное время, срок процесса, предел журнала и
доставку Alert. Выбор текста или команды, самодостаточное задание и порядок
переноса systemd-таймера описывает скилл `scheduling`.

### Ремонт после проверки 2026-10-19

Шаг helper/goal наблюдается отдельно от `waitUntil` общего тика; одновременно идёт
один такой шаг. Его сбой попадает в журнал, напоминания и команды продолжают свой
минутный ход.

Команда наследует `IVA_AGENT_TURN=1`, как bash-инструмент. Её 120 секунд считаются
от взятия строки, ребёнок диспетчера ограничен 130 секундами. Таймаут закрывает
захваченные потоки. Диспетчер завершает ожидание в срок и при потомке со своей
POSIX-сессией. Идущая команда определяется по `firedAt` и отсутствию
факта: до результата или предельного срока следующее взятие ждёт её окончания.
Текстовая строка использует ту же защиту: до факта доставки действует `RUN_STALE_MS`
от `firedAt`, а обновление активности её running-сессии продлевает этот срок. `run_now` и `move`
сохраняют первый факт и сообщают об отложенном переносе в ответе инструмента.
Формат строки сохраняется. Для команд доступен минутный cron; десятиминутный
предел частоты текстовых напоминаний сохранён.

Факт команды и журнал сохраняются после проверки наличия строки под замком таблицы.
После удаления строки поздний результат оставляет журнал удалённым. Снятие
задачи также удаляет её ключ Alert; доставка, завершившаяся после снятия, очищает
его повторно. Человеческое имя команды и причина проходят `notice.redactNotice`
перед транспортом Outbox.
