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), но не ждал завершения фоновой goroutinerun(). В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),run—defer 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-handlerPOST .../reindex/cancelоставлен наCancel()— ему нужен немедленный ответ 204 без блокировки.Тесты.
internal/embeddings/reindex_test.go: хрупкиеtime.Sleepзаменены на детерминированныйStop(ctx); добавленыTestReindexer_StopWaitsForRun(run активен в момент Stop → после возвратаInProgress==falseи jobstatus="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без guardif !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()идемпотентен (guardif !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/SIGINTsupervisor выходил (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); return—stopCoreзовётся монитором (владельцем текущего cmd), а не обработчиком сигнала (у которого доступ только к устаревшемуcoreCmdиз замыкания). Логика signal-handler вынесена в тестируемую функциюhandleShutdownSignal; общий helpershutdownCoreThenQuit(stopCh, monitorDone, quit, ...)используется и из signal-handler, и из Quit-меню: non-blocking запись вstopCh→ ожиданиеmonitorDoneс таймаутом (GracefulShutdownTimeout + 5s, чтобы не зависнуть вечно если монитор заблокирован) →quit().Тесты. Новый
internal/tray/lifecycle_test.go:TestHandleShutdownSignal_StopsCore(fake-coreexec.CommandContext("sleep")+ mock-монитор → сигнал →ProcessStateзаполнен =stopCoreдождался завершения,quitвызван, exit code 0),TestShutdownCoreThenQuit_WaitsForMonitor(quit строго послеmonitorDone),TestShutdownCoreThenQuit_TimeoutFallback(зависший монитор → уход по таймауту + quit всё равно зовётся).