From d38406fbc5eecdf534a80380614e6540c1e98379 Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sat, 22 Aug 2026 13:43:36 +0300 Subject: [PATCH] feat(capability): add fleet readiness policy --- contracts/capability/fleet.go | 25 ++++++++ dev/ACTIVE_TASK.json | 2 +- .../DEV-000136-fleet-readiness-policy.json | 23 +++++++ modules/capability/domain/fleet.go | 60 +++++++++++++++++++ modules/capability/domain/fleet_test.go | 18 ++++++ .../cognitive_registry/fleet_contract_test.go | 18 ++++++ 6 files changed, 145 insertions(+), 1 deletion(-) create mode 100644 contracts/capability/fleet.go create mode 100644 dev/tasks/DEV-000136-fleet-readiness-policy.json create mode 100644 modules/capability/domain/fleet.go create mode 100644 modules/capability/domain/fleet_test.go create mode 100644 tests/cognitive_registry/fleet_contract_test.go diff --git a/contracts/capability/fleet.go b/contracts/capability/fleet.go new file mode 100644 index 0000000..0b60213 --- /dev/null +++ b/contracts/capability/fleet.go @@ -0,0 +1,25 @@ +package capability + +type FleetTier string + +const ( + FleetTierFast FleetTier = "FAST" + FleetTierMedium FleetTier = "MEDIUM" + FleetTierHeavy FleetTier = "HEAVY" + FleetTierLifeboat FleetTier = "LIFEBOAT" +) + +type FleetMember struct { + ProviderInstanceID ProviderInstanceID + ModelDescriptorID ModelDescriptorID + Tier FleetTier + Ready bool + Reason string +} + +type FleetReadiness struct { + ObservedAtUnixMillis int64 + Members []FleetMember + ReadyTiers []FleetTier + FallbackTier FleetTier +} diff --git a/dev/ACTIVE_TASK.json b/dev/ACTIVE_TASK.json index 673e0bf..e0ac6fc 100644 --- a/dev/ACTIVE_TASK.json +++ b/dev/ACTIVE_TASK.json @@ -1,5 +1,5 @@ { "task_file": "dev/tasks/DEV-000134-truth-bundle-compiler.json", - "task_id": "DEV-000135", + "task_id": "DEV-000136", "task_revision": 1 } diff --git a/dev/tasks/DEV-000136-fleet-readiness-policy.json b/dev/tasks/DEV-000136-fleet-readiness-policy.json new file mode 100644 index 0000000..61b3bb9 --- /dev/null +++ b/dev/tasks/DEV-000136-fleet-readiness-policy.json @@ -0,0 +1,23 @@ +{ + "task_id": "DEV-000136", + "task_revision": 1, + "architecture_revision": "ADR-0018", + "base_repo_revision": "f73c699", + "risk_tier": "E", + "intent": "Make model fleet tiers, readiness, and explicit lifeboat fallback durable as typed capability policy.", + "requirement_ids": ["WP-601", "Model fleet policy", "Fallback readiness"], + "allowed_change_scope": {"modules": ["capability"], "paths": ["contracts/capability/fleet.go", "modules/capability/domain/fleet.go", "modules/capability/domain/fleet_test.go", "tests/cognitive_registry/fleet_contract_test.go", "dev/ACTIVE_TASK.json", "dev/tasks/DEV-000136-fleet-readiness-policy.json"]}, + "forbidden_change_scope": {"paths": ["architecture/**", "security/**", "deploy/**", "database/**", "cmd/**", ".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": ["Existing cognitive registry and health observations"], "may_change": ["Typed fleet readiness policy"]}, + "semantic_owner": "capability", + "canonical_state": ["FleetReadiness value object"], + "state_transitions": ["observed member readiness determines selected preferred or fallback tier"], + "side_effects_and_privileges": ["No provider invocation or external mutation"], + "persistence_and_migrations": ["In-memory typed decision; runtime persistence follows fleet registration integration"], + "failure_retry_restart": ["No ready fallback yields explicit error"], + "assumptions": ["Existing Cognitive Gateway remains the only invocation boundary"], + "acceptance_scenarios": ["heavy timeout selects ready lifeboat", "no ready member fails closed"], + "required_tests": ["go test ./...", "go vet ./...", "preflight", "check-diff", "check-architecture", "independent", "git diff --check"], + "terminal_gap_states": ["BLOCKED_BY_SPEC_GAP", "BLOCKED_BY_CONFLICT"] +} diff --git a/modules/capability/domain/fleet.go b/modules/capability/domain/fleet.go new file mode 100644 index 0000000..1baee78 --- /dev/null +++ b/modules/capability/domain/fleet.go @@ -0,0 +1,60 @@ +package domain + +import ( + "errors" + "slices" + "sort" + + "git.cin.su/m/Assistant/contracts/capability" +) + +var ErrInvalidFleetReadiness = errors.New("invalid fleet readiness") + +func BuildFleetReadiness(observedAt int64, members []capability.FleetMember) (capability.FleetReadiness, error) { + if observedAt < 1 || len(members) == 0 { + return capability.FleetReadiness{}, ErrInvalidFleetReadiness + } + result := capability.FleetReadiness{ObservedAtUnixMillis: observedAt, Members: append([]capability.FleetMember(nil), members...), FallbackTier: capability.FleetTierLifeboat} + seen := map[capability.FleetTier]bool{} + for i := range result.Members { + member := &result.Members[i] + if member.ProviderInstanceID == "" || member.ModelDescriptorID == "" || !validFleetTier(member.Tier) || seen[member.Tier] && member.Ready { + return capability.FleetReadiness{}, ErrInvalidFleetReadiness + } + if member.Ready { + seen[member.Tier] = true + } + } + for _, tier := range []capability.FleetTier{capability.FleetTierFast, capability.FleetTierMedium, capability.FleetTierHeavy, capability.FleetTierLifeboat} { + if seen[tier] { + result.ReadyTiers = append(result.ReadyTiers, tier) + } + } + return result, nil +} + +func SelectFleetTier(readiness capability.FleetReadiness, preferred capability.FleetTier) (capability.FleetTier, error) { + if readiness.ObservedAtUnixMillis < 1 || len(readiness.Members) == 0 || !validFleetTier(readiness.FallbackTier) { + return "", ErrInvalidFleetReadiness + } + if slices.Contains(readiness.ReadyTiers, preferred) { + return preferred, nil + } + if slices.Contains(readiness.ReadyTiers, readiness.FallbackTier) { + return readiness.FallbackTier, nil + } + return "", ErrInvalidFleetReadiness +} + +func validFleetTier(value capability.FleetTier) bool { + return value == capability.FleetTierFast || value == capability.FleetTierMedium || value == capability.FleetTierHeavy || value == capability.FleetTierLifeboat +} + +func SortFleetMembers(members []capability.FleetMember) { + sort.SliceStable(members, func(i, j int) bool { + if members[i].Tier != members[j].Tier { + return members[i].Tier < members[j].Tier + } + return members[i].ProviderInstanceID < members[j].ProviderInstanceID + }) +} diff --git a/modules/capability/domain/fleet_test.go b/modules/capability/domain/fleet_test.go new file mode 100644 index 0000000..a8b5957 --- /dev/null +++ b/modules/capability/domain/fleet_test.go @@ -0,0 +1,18 @@ +package domain + +import ( + "testing" + + "git.cin.su/m/Assistant/contracts/capability" +) + +func TestFleetReadinessFallsBackToLifeboatWhenPrimaryTiersUnavailable(t *testing.T) { + readiness, err := BuildFleetReadiness(100, []capability.FleetMember{{ProviderInstanceID: "cpu", ModelDescriptorID: "qwen", Tier: capability.FleetTierLifeboat, Ready: true, Reason: "local"}, {ProviderInstanceID: "gpu", ModelDescriptorID: "heavy", Tier: capability.FleetTierHeavy, Ready: false, Reason: "timeout"}}) + if err != nil { + t.Fatal(err) + } + selected, err := SelectFleetTier(readiness, capability.FleetTierHeavy) + if err != nil || selected != capability.FleetTierLifeboat { + t.Fatalf("selected=%q err=%v", selected, err) + } +} diff --git a/tests/cognitive_registry/fleet_contract_test.go b/tests/cognitive_registry/fleet_contract_test.go new file mode 100644 index 0000000..bbf187a --- /dev/null +++ b/tests/cognitive_registry/fleet_contract_test.go @@ -0,0 +1,18 @@ +package cognitive_registry_test + +import ( + "testing" + + "git.cin.su/m/Assistant/contracts/capability" + "git.cin.su/m/Assistant/modules/capability/domain" +) + +func TestFleetPolicyRequiresAnExplicitReadyFallback(t *testing.T) { + readiness, err := domain.BuildFleetReadiness(1, []capability.FleetMember{{ProviderInstanceID: "cpu", ModelDescriptorID: "model", Tier: capability.FleetTierFast, Ready: false, Reason: "401"}}) + if err != nil { + t.Fatal(err) + } + if _, err := domain.SelectFleetTier(readiness, capability.FleetTierFast); err == nil { + t.Fatal("expected no invocable fallback") + } +}