diff --git a/cmd/cognitive-worker/fleet_status.go b/cmd/cognitive-worker/fleet_status.go new file mode 100644 index 0000000..004c8cb --- /dev/null +++ b/cmd/cognitive-worker/fleet_status.go @@ -0,0 +1,70 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "strings" + "time" + + "git.cin.su/m/Assistant/contracts/capability" + fleetdomain "git.cin.su/m/Assistant/modules/capability/domain" +) + +type fleetEndpoint struct { + ProviderID string + ModelID string + Tier capability.FleetTier + URL string +} + +type fleetStatusResponse struct { + Readiness capability.FleetReadiness `json:"readiness"` + Preferred capability.FleetTier `json:"preferred_tier"` + Selected capability.FleetTier `json:"selected_tier,omitempty"` + Error string `json:"error,omitempty"` +} + +func fleetReadinessHandler(client *http.Client, preferred capability.FleetTier, endpoints []fleetEndpoint) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + members := make([]capability.FleetMember, 0, len(endpoints)) + for _, endpoint := range endpoints { + member := capability.FleetMember{ProviderInstanceID: capability.ProviderInstanceID(endpoint.ProviderID), ModelDescriptorID: capability.ModelDescriptorID(endpoint.ModelID), Tier: endpoint.Tier, Reason: "NOT_CONFIGURED"} + if strings.TrimSpace(endpoint.URL) == "" { + members = append(members, member) + continue + } + request, err := http.NewRequestWithContext(r.Context(), http.MethodGet, endpoint.URL, nil) + if err == nil { + response, requestErr := client.Do(request) + if requestErr == nil { + response.Body.Close() + if response.StatusCode >= 200 && response.StatusCode < 300 { + member.Ready, member.Reason = true, "AVAILABLE" + } else { + member.Reason = fmt.Sprintf("status=%s", response.Status) + } + } else { + member.Reason = requestErr.Error() + } + } + members = append(members, member) + } + readiness, err := fleetdomain.BuildFleetReadiness(time.Now().UnixMilli(), members) + response := fleetStatusResponse{Readiness: readiness, Preferred: preferred} + if err == nil { + response.Selected, err = fleetdomain.SelectFleetTier(readiness, preferred) + } + if err != nil { + response.Error = err.Error() + } + w.Header().Set("Content-Type", "application/json") + if response.Selected == "" { + w.WriteHeader(http.StatusServiceUnavailable) + } + _ = json.NewEncoder(w).Encode(response) + }) +} + +func fleetContext(ctx context.Context) context.Context { return ctx } diff --git a/cmd/cognitive-worker/fleet_status_test.go b/cmd/cognitive-worker/fleet_status_test.go new file mode 100644 index 0000000..70d8571 --- /dev/null +++ b/cmd/cognitive-worker/fleet_status_test.go @@ -0,0 +1,27 @@ +package main + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "git.cin.su/m/Assistant/contracts/capability" +) + +func TestFleetReadinessSelectsLifeboatWhenPrimaryTiersUnavailable(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) })) + defer server.Close() + handler := fleetReadinessHandler(server.Client(), capability.FleetTierHeavy, []fleetEndpoint{ + {ProviderID: "cpu", ModelID: "cpu-model", Tier: capability.FleetTierFast, URL: server.URL}, + {ProviderID: "medium", ModelID: "medium-model", Tier: capability.FleetTierMedium}, + {ProviderID: "heavy", ModelID: "heavy-model", Tier: capability.FleetTierHeavy}, + {ProviderID: "lifeboat", ModelID: "lifeboat-model", Tier: capability.FleetTierLifeboat, URL: server.URL}, + }) + recorder := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodGet, "/v1/fleet/readiness", nil) + handler.ServeHTTP(recorder, request) + if recorder.Code != http.StatusOK || !strings.Contains(recorder.Body.String(), `"selected_tier":"LIFEBOAT"`) || !strings.Contains(recorder.Body.String(), `"tier":"MEDIUM"`) { + t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String()) + } +} diff --git a/cmd/cognitive-worker/main.go b/cmd/cognitive-worker/main.go index 4047f1b..fa04841 100644 --- a/cmd/cognitive-worker/main.go +++ b/cmd/cognitive-worker/main.go @@ -80,6 +80,14 @@ func main() { health.HTTPProbe(readinessClient, lifeboatURL), health.OptionalHTTPProbe(readinessClient, strings.TrimRight(envOr("LOCAL_GPU_PROVIDER_URL", "http://192.168.220.91:11434"), "/")+"/api/tags"), }}) + fleetClient := &http.Client{Timeout: 2 * time.Second} + preferredFleet := capability.FleetTier(envOr("COGNITIVE_PREFERRED_FLEET_TIER", string(capability.FleetTierHeavy))) + mux.Handle("/v1/fleet/readiness", fleetReadinessHandler(fleetClient, preferredFleet, []fleetEndpoint{ + {ProviderID: "local-cpu-primary", ModelID: "local-cpu-model", Tier: capability.FleetTierFast, URL: strings.TrimRight(envOr("LOCAL_CPU_PROVIDER_URL", "http://docker.cin.su:18082/v1"), "/") + "/models"}, + {ProviderID: "local-medium-unconfigured", ModelID: "medium-unconfigured", Tier: capability.FleetTierMedium}, + {ProviderID: "local-gpu-opportunistic", ModelID: "gpu-opportunistic", Tier: capability.FleetTierHeavy, URL: strings.TrimRight(envOr("LOCAL_GPU_PROVIDER_URL", "http://192.168.220.91:11434"), "/") + "/api/tags"}, + {ProviderID: "cognitive-lifeboat", ModelID: "qwen3-8b-lifeboat", Tier: capability.FleetTierLifeboat, URL: lifeboatURL}, + })) mux.HandleFunc("POST /v1/proposals/deterministic", deterministicProposal) mux.HandleFunc("POST /v1/advisory-policy", gateway.policyHandler) mux.HandleFunc("POST /v1/advisories", gateway.advisoryHandler) diff --git a/contracts/capability/fleet.go b/contracts/capability/fleet.go index 0b60213..9743ace 100644 --- a/contracts/capability/fleet.go +++ b/contracts/capability/fleet.go @@ -10,16 +10,16 @@ const ( ) type FleetMember struct { - ProviderInstanceID ProviderInstanceID - ModelDescriptorID ModelDescriptorID - Tier FleetTier - Ready bool - Reason string + ProviderInstanceID ProviderInstanceID `json:"provider_instance_id"` + ModelDescriptorID ModelDescriptorID `json:"model_descriptor_id"` + Tier FleetTier `json:"tier"` + Ready bool `json:"ready"` + Reason string `json:"reason"` } type FleetReadiness struct { - ObservedAtUnixMillis int64 - Members []FleetMember - ReadyTiers []FleetTier - FallbackTier FleetTier + ObservedAtUnixMillis int64 `json:"observed_at_unix_millis"` + Members []FleetMember `json:"members"` + ReadyTiers []FleetTier `json:"ready_tiers"` + FallbackTier FleetTier `json:"fallback_tier"` } diff --git a/dev/ACTIVE_TASK.json b/dev/ACTIVE_TASK.json index 99648a9..1a587d0 100644 --- a/dev/ACTIVE_TASK.json +++ b/dev/ACTIVE_TASK.json @@ -1,5 +1,5 @@ { - "task_file": "dev/tasks/DEV-000143-research-runtime-orchestration.json", - "task_id": "DEV-000143", - "task_revision": 3 + "task_file": "dev/tasks/DEV-000144-fleet-readiness-runtime.json", + "task_id": "DEV-000144", + "task_revision": 1 } diff --git a/dev/tasks/DEV-000144-fleet-readiness-runtime.json b/dev/tasks/DEV-000144-fleet-readiness-runtime.json new file mode 100644 index 0000000..184a628 --- /dev/null +++ b/dev/tasks/DEV-000144-fleet-readiness-runtime.json @@ -0,0 +1,23 @@ +{ + "task_id": "DEV-000144", + "task_revision": 1, + "architecture_revision": "ADR-0018", + "base_repo_revision": "23b04aa", + "risk_tier": "E", + "intent": "Expose explainable runtime fleet readiness and explicit fallback tier from live provider probes.", + "requirement_ids": ["WP-203", "WP-601", "Model fleet/failover"], + "allowed_change_scope": {"modules": ["capability", "cognitive-worker"], "paths": ["contracts/capability/fleet.go", "cmd/cognitive-worker/**", "tests/cognitive_registry/**", "dev/ACTIVE_TASK.json", "dev/tasks/DEV-000144-fleet-readiness-runtime.json"]}, + "forbidden_change_scope": {"paths": ["architecture/**", "database/**", "deploy/**", "security/**", ".gitea/**"]}, + "allowed_architecture_delta": {"new_cross_module_dependencies": 0, "new_canonical_owners": 0, "new_privileges": 0, "new_third_party_dependencies": 0, "new_database_owner_roles": 0}, + "contracts": {"consume": ["FleetReadiness and SelectFleetTier"], "may_change": ["JSON projection and live probe endpoint"]}, + "semantic_owner": "capability and cognitive-worker", + "canonical_state": [], + "state_transitions": ["live provider probes -> typed member states -> preferred tier or explicit Lifeboat fallback"], + "side_effects_and_privileges": ["Read-only HTTP probes only"], + "persistence_and_migrations": ["None"], + "failure_retry_restart": ["Each request re-probes providers; no in-memory health authority"], + "assumptions": ["Lifeboat is the explicit fallback tier"], + "acceptance_scenarios": ["unavailable CPU/GPU selects Lifeboat", "unconfigured Medium is visible", "no fallback returns typed 503"], + "required_tests": ["go test ./...", "go vet ./...", "preflight", "check-diff", "check-architecture", "independent", "git diff --check", "docker compose config"], + "terminal_gap_states": ["BLOCKED_BY_SPEC_GAP", "BLOCKED_BY_CONFLICT"] +}