0.82.3

Fixed

  • Graceful shutdown не дожидался goroutine reindex (A-013, ). Medium-дефект graceful-shutdown / stuck-job из аудита 2026-06-24 (internal/embeddings/reindex.go, cmd/taigaclaw/main.go). Reindexer.Cancel() только вызывал r.cancel() (отмена ctx), но не ждал завершения фоновой goroutine run(). В gracefulShutdown шаг reindexer имел componentTimeout=10s: если provider.Embed был в середине HTTP-запроса к OpenAI, отмена доходила не мгновенно, и через 10s процесс закрывал db.Close() раньше, чем run() успевал записать финальный status='cancelled'. Итог: регулярный источник stuck reindex-jobs при рестартах во время reindex (тот же симптом что A-005, но по причине shutdown).

  • Фикс. В Reindexer добавлен sync.WaitGroup: Start делает wg.Add(1) под r.mu (как в эталонном skill_reflection.go), rundefer wg.Done() первым defer’ом (LIFO: выполняется последним, после сброса running/cancel/inProgress). Добавлен метод Stop(ctx) = Cancel() + wg.Wait() с ctx-bound ожиданием через обёртку goroutine+select (т.к. WaitGroup.Wait не принимает ctx) — позволяет прервать ожидание по таймауту, если run() зависнет в provider.Embed. gracefulShutdown (main.go) вызывает Stop(ctx) вместо Cancel(), где ctx ограничен componentTimeout. HTTP-handler POST .../reindex/cancel оставлен на Cancel() — ему нужен немедленный ответ 204 без блокировки.

  • Тесты. internal/embeddings/reindex_test.go: хрупкие time.Sleep заменены на детерминированный Stop(ctx); добавлены TestReindexer_StopWaitsForRun (run активен в момент Stop → после возврата InProgress==false и job status="cancelled" в БД), TestReindexer_StopWithoutStart (nil-safe no-op), TestReindexer_StopRespectsCtxTimeout (Stop с коротким ctx возвращается по таймауту, не дожидаясь provider.delay).

  • ConsolidatorService / DreamService / AutoCompactService — lifecycle (A-011, ). Medium-дефект concurrency/lifecycle из аудита 2026-06-24 (internal/memory/{consolidator,dream,autocompact}_service.go). Три периодических сервиса имели общие дефекты относительно эталона internal/agent/skill_reflection.go: (1) Start без guard if !stopped → двойной вызов порождал вторую горутину runLoop (data race на emptyRunCounts у consolidator); (2) Stop делал только close(stopCh) без wg.Wait() (у dream — вообще без wg) → после Stop горутина runOnce могла работать ещё до 10 минут; (3) runOnce использовал context.Background() как parent таймаута → Stop не мог отменить активную итерацию. Дополнительно в AutoCompactService конструктор ставил stopped=false + guard через select-on-open-stopCh, из-за чего первый Start уходил в default: return и сервис вообще не запускался.

  • Фикс. Все три сервиса приведены к единому паттерну SkillReflectionService: убран stopCh, добавлены cancelFn context.CancelFunc + wg sync.WaitGroup; конструктор ставит stopped: true; Start() идемпотентен (guard if !stopped → warn+return), context.WithCancel(Background) создаёт loop-ctx, wg.Add(1) под локом; Stop() отпускает лок до wg.Wait(), зовёт cancel() и wg.Wait(); runLoop(ctx)/runOnce(ctx) принимают ctx, parent таймаута — loop-ctx (немедленная отмена активной итерации при Stop). Сигнатуры Start()/Stop() без аргументов сохранены (7 call sites в main.go + handler/memory.go без изменений). handler/memory.go: убрано избыточное пересоздание DreamService после Stop (теперь Start после Stop снова работает через свежий cancelFn — поведение приведено в соответствие с AutoCompactService).

  • Тесты. Новый internal/memory/service_lifecycle_test.go: для каждого сервиса — StartTwice_SecondIsNoOp (главный кейс: двойной Start не плодит горутины), StopBeforeStart (nil-safe), RestartAfterStop (Start→Stop→Start→Stop без зависания). Для ConsolidatorService — StopCancelsInflightRunOnce (runOnce зависает в ListAgents блокирующим mock-store → Stop прерывает его за «10min таймаута, доказывая отмену через loop-ctx). Переписан internal/memory/autocompact_service_test.go (старые тесты манипулировали удалённым stopCh). go test -race ./internal/memory/... green.

  • SIGTERM/SIGINT и Quit не звали stopCore → orphan core-процесс (A-012, ). Medium-дефект resource-leak/shutdown из аудита 2026-06-24 (internal/tray/lifecycle.go). При внешнем SIGTERM/SIGINT supervisor выходил (systray.Quit() + exitCodeCh <- 0), но core-процесс не получал сигнала через stopCore. Аналогично меню «Quit». Если сигнал приходил только supervisor’у (systemd, launchd без process-group kill, kill <supervisor_pid>) — core оставался orphan, продолжал держать порт 14888 и БД → «порт занят» при следующем старте. Под терминалом с Ctrl+C обычно работало (сигнал уходил всей process-group), под launchd/systemd — orphan.

  • Фикс. Добавлены каналы stopCh (просьба монитору остановить core) и monitorDone (сигнал что stopCore завершился) рядом с restartCh. В select монитора добавлена ветка case <-stopCh: stopCore(cmd, cfg.GracefulShutdownTimeout); close(monitorDone); returnstopCore зовётся монитором (владельцем текущего cmd), а не обработчиком сигнала (у которого доступ только к устаревшему coreCmd из замыкания). Логика signal-handler вынесена в тестируемую функцию handleShutdownSignal; общий helper shutdownCoreThenQuit(stopCh, monitorDone, quit, ...) используется и из signal-handler, и из Quit-меню: non-blocking запись в stopCh → ожидание monitorDone с таймаутом (GracefulShutdownTimeout + 5s, чтобы не зависнуть вечно если монитор заблокирован) → quit().

  • Тесты. Новый internal/tray/lifecycle_test.go: TestHandleShutdownSignal_StopsCore (fake-core exec.CommandContext("sleep") + mock-монитор → сигнал → ProcessState заполнен = stopCore дождался завершения, quit вызван, exit code 0), TestShutdownCoreThenQuit_WaitsForMonitor (quit строго после monitorDone), TestShutdownCoreThenQuit_TimeoutFallback (зависший монитор → уход по таймауту + quit всё равно зовётся).