From 009314e9309ee6caaa37f53ec1e2929d4ee69582 Mon Sep 17 00:00:00 2001 From: Mikhail Date: Fri, 21 Aug 2026 14:52:55 +0300 Subject: [PATCH 01/11] feat: keep domovoy alive in lifeboat mode --- cmd/cognitive-worker/main.go | 23 +++++++ cmd/cognitive-worker/main_test.go | 16 +++++ cmd/core-runtime/main.go | 30 ++++++++- cmd/core-runtime/main_test.go | 9 ++- cmd/creator-web/static/assets/chat.js | 2 +- cmd/creator-web/static/chat.html | 2 +- deploy/docker-compose.dev.yml | 1 + deploy/docker-compose.prod.yml | 1 + .../DEV-000083-domovoy-lifeboat-mode.json | 64 +++++++++++++++++++ 9 files changed, 142 insertions(+), 6 deletions(-) create mode 100644 cmd/cognitive-worker/main_test.go create mode 100644 dev/tasks/DEV-000083-domovoy-lifeboat-mode.json diff --git a/cmd/cognitive-worker/main.go b/cmd/cognitive-worker/main.go index 6dea53c..645cc6d 100644 --- a/cmd/cognitive-worker/main.go +++ b/cmd/cognitive-worker/main.go @@ -47,6 +47,12 @@ func main() { cpu := providerRegistration{ProviderInstanceID: "local-cpu-primary", Kind: "openai-compatible-chat-v1", EndpointRef: "local-cpu-primary-v1", Provider: cpuAdapter, Discovery: cpuAdapter} providers[cpu.Kind] = append(providers[cpu.Kind], cpu) discoveries["cpu"] = cpu + if lifeboat, enabled, lifeboatErr := newLifeboatProvider(os.Getenv("LOCAL_LIFEBOAT_PROVIDER_URL")); lifeboatErr != nil { + log.Fatal(lifeboatErr) + } else if enabled { + providers[lifeboat.Kind] = append(providers[lifeboat.Kind], lifeboat) + discoveries["lifeboat"] = lifeboat + } gpuAdapter, err := models.NewOllamaAdapter(envOr("LOCAL_GPU_PROVIDER_URL", "http://192.168.220.91:11434"), nil) if err != nil { log.Fatal(err) @@ -174,3 +180,20 @@ func envOr(name, fallback string) string { } return fallback } + +func newLifeboatProvider(rawURL string) (providerRegistration, bool, error) { + if rawURL == "" { + return providerRegistration{}, false, nil + } + adapter, err := models.NewOpenAICompatibleLocalAdapter(rawURL, nil) + if err != nil { + return providerRegistration{}, false, err + } + return providerRegistration{ + ProviderInstanceID: "local-lifeboat", + Kind: "openai-compatible-chat-v1", + EndpointRef: "local-lifeboat-v1", + Provider: adapter, + Discovery: adapter, + }, true, nil +} diff --git a/cmd/cognitive-worker/main_test.go b/cmd/cognitive-worker/main_test.go new file mode 100644 index 0000000..950f30c --- /dev/null +++ b/cmd/cognitive-worker/main_test.go @@ -0,0 +1,16 @@ +package main + +import "testing" + +func TestLifeboatProviderIsOptionalAndLocalAdapterBacked(t *testing.T) { + if _, enabled, err := newLifeboatProvider(""); err != nil || enabled { + t.Fatalf("empty lifeboat URL should disable provider: enabled=%v err=%v", enabled, err) + } + provider, enabled, err := newLifeboatProvider("http://lifeboat:8080/v1") + if err != nil || !enabled { + t.Fatalf("configured lifeboat should be enabled: enabled=%v err=%v", enabled, err) + } + if provider.ProviderInstanceID != "local-lifeboat" || provider.EndpointRef != "local-lifeboat-v1" || provider.Provider == nil { + t.Fatalf("unexpected lifeboat registration: %+v", provider) + } +} diff --git a/cmd/core-runtime/main.go b/cmd/core-runtime/main.go index eff7fc6..1fdad2b 100644 --- a/cmd/core-runtime/main.go +++ b/cmd/core-runtime/main.go @@ -293,7 +293,10 @@ func main() { http.Error(w, "interpret chat message", http.StatusServiceUnavailable) return } + cognitiveAttempted := false + cognitiveAvailable := true if shouldRequestBroaderInterpretation(semantic) { + cognitiveAttempted = true projectNames := make([]string, 0, len(semantic.Projects)) for _, ref := range semantic.Projects { if ref.ProjectDisplayName != "" { @@ -303,7 +306,11 @@ func main() { if response, interpretationErr := cognitiveClient.Interpret(r.Context(), buildGatewayInterpretationRequest(session, utterance, semantic, projectNames)); interpretationErr == nil && response.FinishReason == cognitive.FinishCompleted { if candidate, candidateErr := interactionCandidateFromGateway(utterance.ID, response.Candidate, now); candidateErr == nil { semantic = interactiondomain.ApplyCognitiveCandidate(semantic, utterance.Content, candidate, previousFocus) + } else { + cognitiveAvailable = false } + } else { + cognitiveAvailable = false } } if err := interactionRepository.AppendUtterance(r.Context(), conversation, utterance, intent, relations); err != nil { @@ -396,6 +403,10 @@ func main() { planningReply = helpPath.Reply needsCognition = false } + if planningReply == "" && needsCognition && cognitiveAttempted && !cognitiveAvailable { + planningReply = humanCognitiveDegradedReply(utterance.Content) + needsCognition = false + } var capabilityStatus capability.CapabilityResolutionStatus var capabilityKind capability.CapabilityKind var capabilityGapID capability.CapabilityGapID @@ -1114,9 +1125,9 @@ func humanCognitiveChannelReply(message string, health capability.Health, health changeRequested := strings.Contains(strings.ToLower(message), "переключ") || strings.Contains(strings.ToLower(message), "настрой") || strings.Contains(strings.ToLower(message), "измени") || strings.Contains(strings.ToLower(message), "замени") if healthErr != nil { if changeRequested { - return "Я не вижу подтверждённого состояния локального канала. Изменение модели не выполнено. Откройте консоль, проверьте настройку и запустите проверку ещё раз." + return "Я не вижу подтверждённого состояния локального канала. Домовой всё равно рядом: опишите, что изменилось, и я помогу подготовить аварийную настройку." } - return "Я не вижу подтверждённого состояния локального канала. Проверьте его в консоли или повторите вопрос позже." + return "Я не вижу подтверждённого состояния локального канала, но Домовой работает. Можно продолжать разговор и проверить канал ещё раз." } var observation string switch health.State { @@ -1130,11 +1141,24 @@ func humanCognitiveChannelReply(message string, health capability.Health, health observation = "Состояние основного локального канала пока не подтверждено." } if changeRequested { - return observation + " Я не меняю модель прямо из разговора: изменение выполняется в консоли с подтверждением. После изменения я увижу новую проверку канала автоматически." + if health.State == capability.HealthUnavailable { + return observation + " Но Домовой жив. Опишите новую локальную модель или канал — я помогу настроить аварийный режим и перед применением покажу итоговую конфигурацию." + } + return observation + " Я не меняю модель прямо из разговора: в обычном режиме изменение выполняется в консоли с подтверждением. После изменения я увижу новую проверку канала автоматически." + } + if health.State == capability.HealthUnavailable { + return observation + " Но Домовой не остановился: продолжаю принимать сообщения и помогу восстановить локальный канал." } return observation + " Это наблюдение о готовности capability, а не прямой доступ к модели." } +func humanCognitiveDegradedReply(message string) string { + if strings.TrimSpace(message) == "" { + return "Основной канал мышления сейчас недоступен. Я рядом и продолжаю работать в локальном аварийном режиме." + } + return "Основной канал мышления сейчас недоступен. Домовой жив: сообщение принято, а я могу помочь разобраться с локальным каналом и настройкой восстановления." +} + func humanCapabilityList(kinds []capability.CapabilityKind) string { labels := make([]string, 0, len(kinds)) for _, kind := range kinds { diff --git a/cmd/core-runtime/main_test.go b/cmd/core-runtime/main_test.go index e1a8a52..6fd671e 100644 --- a/cmd/core-runtime/main_test.go +++ b/cmd/core-runtime/main_test.go @@ -37,11 +37,18 @@ func TestCognitiveChannelQuestionUsesHumanSafeHealthReply(t *testing.T) { func TestCognitiveConfigurationRequestDoesNotPretendToMutate(t *testing.T) { reply := humanCognitiveChannelReply("Переключи локальную модель", capability.Health{State: capability.HealthUnavailable}, nil) - if !strings.Contains(reply, "не меняю модель") || !strings.Contains(reply, "консоли") { + if !strings.Contains(reply, "Домовой жив") || !strings.Contains(reply, "аварийн") { t.Fatalf("unexpected configuration reply: %q", reply) } } +func TestCognitiveDegradedReplyKeepsDomovoyAlive(t *testing.T) { + reply := humanCognitiveDegradedReply("Помоги настроить локальную модель") + if !strings.Contains(reply, "Домовой жив") || !strings.Contains(reply, "принято") { + t.Fatalf("unexpected degraded reply: %q", reply) + } +} + func TestCreatorSessionHandlerAcceptsConfiguredCredentials(t *testing.T) { handler := creatorSessionHandler("recovery", "m", "password", []byte("01234567890123456789012345678901"), func() time.Time { return time.Unix(1000, 0) }) request := httptest.NewRequest(http.MethodPost, "/v1/creator/sessions", strings.NewReader(`{"username":"m","password":"password"}`)) diff --git a/cmd/creator-web/static/assets/chat.js b/cmd/creator-web/static/assets/chat.js index 9c7e694..79f71df 100644 --- a/cmd/creator-web/static/assets/chat.js +++ b/cmd/creator-web/static/assets/chat.js @@ -43,7 +43,7 @@ function renderCognitiveStatus(state) { const labels = { available: "канал мышления доступен", degraded: "канал мышления работает с ограничениями", - unavailable: "основной канал мышления недоступен", + unavailable: "Домовой рядом · основной канал недоступен", }; cognitiveStatus.dataset.state = displayState; cognitiveStatus.textContent = labels[displayState]; diff --git a/cmd/creator-web/static/chat.html b/cmd/creator-web/static/chat.html index 8ffbc45..af74482 100644 --- a/cmd/creator-web/static/chat.html +++ b/cmd/creator-web/static/chat.html @@ -73,7 +73,7 @@