From 74028624f55d8918b3bbc30dcb6237e7bbaa09d8 Mon Sep 17 00:00:00 2001 From: Adam Frisby Date: Mon, 14 Sep 2026 04:41:31 +0000 Subject: [PATCH] Fix router hot-reload fingerprint ignoring Pool and sibling fields SerializeRouterInputs omitted AgentMembership.Pool, so Pool-only edits produced an identical fingerprint, ApplyRouterIfChanged returned early, and the router kept the old membership with no error or log line. The same audit found Provider (member+instance) and ClaudeSession (member+class) missing; all are now fingerprinted. SerializeRouterInputs is internal with a coverage contract, plus a reflection-driven theory proving every settable member/instance/class field moves the fingerprint (fails when a new field is added uncovered) and a coordinator test proving Pool add/remove reloads into the live router catalog. CodeyBox-Prompt-Revision: 1 Co-Authored-By: CodeyBox --- src/CodeyBox.Api/AgentConfigHotReload.cs | 19 +- .../AgentConfigHotReloadTests.cs | 96 +++++++++ .../ConfigReloadClassificationTests.cs | 196 ++++++++++++++++++ 3 files changed, 310 insertions(+), 1 deletion(-) diff --git a/src/CodeyBox.Api/AgentConfigHotReload.cs b/src/CodeyBox.Api/AgentConfigHotReload.cs index e807d893..c717ec6f 100644 --- a/src/CodeyBox.Api/AgentConfigHotReload.cs +++ b/src/CodeyBox.Api/AgentConfigHotReload.cs @@ -1097,7 +1097,19 @@ private static string SerializePricing(AgentPricingOptions opts) => }, JsonOpts); - private static string SerializeRouterInputs( + /// + /// Hot-reload fingerprint for the AgentClasses / AgentInstances / + /// AgentScoreModifiers block. Must observe every field + /// consumes — a configured field + /// missing here silently behaves as restart-required (the Pool failure + /// mode: the edit is accepted, no reload fires, the router keeps the old value). + /// The only intentional exclusion is + /// , a readability annotation the + /// builder never reads. Internal for the fingerprint-coverage test, which proves + /// every settable field on the member/instance/class config POCOs is observed + /// here and fails when a new field is added without being covered. + /// + internal static string SerializeRouterInputs( List classes, List instances, AgentScoreModifiersOptions modifiers) => @@ -1115,6 +1127,7 @@ private static string SerializeRouterInputs( i.SettingsFilePath, i.DestinationPath, i.SandboxEnvironmentVariable, + i.Provider, }) .OrderBy(i => i.Id, StringComparer.OrdinalIgnoreCase) .ThenBy(i => i.Agent, StringComparer.OrdinalIgnoreCase) @@ -1124,11 +1137,13 @@ private static string SerializeRouterInputs( { c.Id, c.DisplayName, + ClaudeSession = c.ClaudeSession?.Enabled, Members = c.Members .Select(m => new { m.Agent, m.InstanceId, + m.Pool, m.Billing, m.ModelId, m.CredentialFilePath, @@ -1137,11 +1152,13 @@ private static string SerializeRouterInputs( m.SettingsFilePath, m.DestinationPath, m.SandboxEnvironmentVariable, + m.Provider, m.QualityScore, m.ReasoningMode, Capabilities = m.Capabilities .OrderBy(c => c, StringComparer.OrdinalIgnoreCase) .ToArray(), + ClaudeSession = m.ClaudeSession?.Enabled, }) .ToArray(), }) diff --git a/tests/CodeyBox.Tests/AgentConfigHotReloadTests.cs b/tests/CodeyBox.Tests/AgentConfigHotReloadTests.cs index 4d9bbaff..75698f19 100644 --- a/tests/CodeyBox.Tests/AgentConfigHotReloadTests.cs +++ b/tests/CodeyBox.Tests/AgentConfigHotReloadTests.cs @@ -920,6 +920,102 @@ [new ClaudeInVmSmokeProbe()], await coordinator.StopAsync(CancellationToken.None); } + [Fact] + public async Task Coordinator_OnChange_MemberPoolOnlyEdit_AppliesReload() + { + // Regression: SerializeRouterInputs omitted AgentMembership.Pool, so a + // Pool-only edit produced an identical fingerprint, the reload was + // skipped, and the router kept Pool=null with no error or log line. + // Adding, changing, or removing a member's Pool must each reload. + var initial = new CodeyBoxOptions + { + AgentClasses = + [ + new AgentClassOptions + { + Id = "frontier", + Members = + [ + new AgentMembershipOptions + { + Agent = "claude", + Billing = "Subscription", + QualityScore = 100, + }, + ], + }, + ], + }; + var monitor = new ManualOptionsMonitor(initial); + var router = new AgentClassRouter( + AgentClassesConfigBuilder.Build(initial.AgentClasses, NullLogger.Instance), + Array.Empty(), + new QuotaRouterOptions { MinQuotaPct = 5.0 }, + NullLogger.Instance); + using var orchFixture = OrchestratorFixture.Build(new AgentConcurrencyOptions()); + var burnEstimator = new AgentBurnEstimator( + new InertCostStore(), new AgentBurnEstimatorOptions(), + NullLogger.Instance); + + var coordinator = new AgentConfigHotReload( + monitor, orchFixture.Orchestrator, router, burnEstimator, + NullLogger.Instance); + await coordinator.StartAsync(CancellationToken.None); + + Assert.Null(router.GetClassMembers("frontier")[0].Pool); + + monitor.Fire(new CodeyBoxOptions + { + AgentClasses = + [ + new AgentClassOptions + { + Id = "frontier", + Members = + [ + new AgentMembershipOptions + { + Agent = "claude", + Billing = "Subscription", + QualityScore = 100, + Pool = "opencode-go", + }, + ], + }, + ], + }); + + var added = router.GetClassMembers("frontier"); + Assert.Single(added); + Assert.Equal("opencode-go", added[0].Pool); + + monitor.Fire(new CodeyBoxOptions + { + AgentClasses = + [ + new AgentClassOptions + { + Id = "frontier", + Members = + [ + new AgentMembershipOptions + { + Agent = "claude", + Billing = "Subscription", + QualityScore = 100, + }, + ], + }, + ], + }); + + var removed = router.GetClassMembers("frontier"); + Assert.Single(removed); + Assert.Null(removed[0].Pool); + + await coordinator.StopAsync(CancellationToken.None); + } + private static AgentClassOptions HotReloadClass(string id, params string[] agents) { var cls = new AgentClassOptions { Id = id, DisplayName = id }; diff --git a/tests/CodeyBox.Tests/ConfigReloadClassificationTests.cs b/tests/CodeyBox.Tests/ConfigReloadClassificationTests.cs index 2c2f0747..0b78d4e1 100644 --- a/tests/CodeyBox.Tests/ConfigReloadClassificationTests.cs +++ b/tests/CodeyBox.Tests/ConfigReloadClassificationTests.cs @@ -336,6 +336,42 @@ public void WorkerPoolFingerprint_IgnoresRestartRequiredFields(string field) AgentConfigHotReload.SerializeWorkerPool(mutated, legacyConcurrency: null)); } + public static TheoryData RouterFingerprintFields + { + get + { + // Driven by reflection over the config POCOs so a newly added + // settable property becomes a case automatically — and + // MutateRouterField throws for names it does not know, failing the + // new case until the fingerprint covers the field. + var data = new TheoryData(); + foreach (var p in typeof(AgentMembershipOptions).GetProperties()) + data.Add("Member." + p.Name); + foreach (var p in typeof(AgentInstanceOptions).GetProperties()) + data.Add("Instance." + p.Name); + foreach (var p in typeof(AgentClassOptions).GetProperties()) + data.Add("Class." + p.Name); + return data; + } + } + + [Theory] + [MemberData(nameof(RouterFingerprintFields))] + public void RouterFingerprint_ObservesEveryConfigurableField(string field) + { + // A configured router field missing from SerializeRouterInputs silently + // behaves as restart-required: the edit is accepted, no reload fires, + // and the router keeps the old value (the Pool failure mode). Mutating + // the field solo must move the fingerprint. + var baseline = BaselineRouterInputs(); + var mutated = BaselineRouterInputs(); + MutateRouterField(mutated, field); + + Assert.NotEqual( + AgentConfigHotReload.SerializeRouterInputs(baseline.Classes, baseline.Instances, baseline.Modifiers), + AgentConfigHotReload.SerializeRouterInputs(mutated.Classes, mutated.Instances, mutated.Modifiers)); + } + public static TheoryData GuardedKeyPaths { get @@ -558,6 +594,166 @@ private static void MutateWorkerPoolField(WorkerPoolOptions opts, string field) } } + private static (List Classes, List Instances, AgentScoreModifiersOptions Modifiers) + BaselineRouterInputs() => + ( + [ + new AgentClassOptions + { + Id = "frontier", + DisplayName = "Frontier", + ClaudeSession = new AgentClassClaudeSessionOptions { Enabled = true }, + Members = [BaselineRouterMember()], + }, + ], + [ + new AgentInstanceOptions + { + Id = "acct-a", + Agent = "claude", + CredentialFilePath = "/cred-a", + TokenEnvironmentVariable = "TOKEN_A", + AuthJsonEnvironmentVariable = "AUTH_A", + SettingsFilePath = "/settings-a", + DestinationPath = "/dest-a", + SandboxEnvironmentVariable = "SANDBOX_A", + Provider = "provider-a", + }, + ], + new AgentScoreModifiersOptions() + ); + + private static AgentMembershipOptions BaselineRouterMember() => new() + { + Agent = "claude", + InstanceId = "acct-a", + Pool = "pool-a", + Billing = "Subscription", + ModelId = "model-a", + CredentialFilePath = "/cred-a", + TokenEnvironmentVariable = "TOKEN_A", + AuthJsonEnvironmentVariable = "AUTH_A", + SettingsFilePath = "/settings-a", + DestinationPath = "/dest-a", + SandboxEnvironmentVariable = "SANDBOX_A", + Provider = "provider-a", + QualityScore = 100, + ReasoningMode = "high", + Capabilities = ["tag-a"], + ClaudeSession = new AgentClassClaudeSessionOptions { Enabled = true }, + }; + + private static void MutateRouterField( + (List Classes, List Instances, AgentScoreModifiersOptions Modifiers) inputs, + string field) + { + // Every case flips exactly one configured value. A property added to + // any of these POCOs without a case here (and without fingerprint + // coverage) fails loudly via the default arm — that is the point. + var member = inputs.Classes[0].Members[0]; + var instance = inputs.Instances[0]; + var cls = inputs.Classes[0]; + switch (field) + { + case "Member.Agent": + member.Agent = "codex"; + break; + case "Member.InstanceId": + member.InstanceId = "acct-b"; + break; + case "Member.Pool": + member.Pool = "pool-b"; + break; + case "Member.Billing": + member.Billing = "PayPerApi"; + break; + case "Member.ModelId": + member.ModelId = "model-b"; + break; + case "Member.CredentialFilePath": + member.CredentialFilePath = "/cred-b"; + break; + case "Member.TokenEnvironmentVariable": + member.TokenEnvironmentVariable = "TOKEN_B"; + break; + case "Member.AuthJsonEnvironmentVariable": + member.AuthJsonEnvironmentVariable = "AUTH_B"; + break; + case "Member.SettingsFilePath": + member.SettingsFilePath = "/settings-b"; + break; + case "Member.DestinationPath": + member.DestinationPath = "/dest-b"; + break; + case "Member.SandboxEnvironmentVariable": + member.SandboxEnvironmentVariable = "SANDBOX_B"; + break; + case "Member.Provider": + member.Provider = "provider-b"; + break; + case "Member.QualityScore": + member.QualityScore = 99; + break; + case "Member.ReasoningMode": + member.ReasoningMode = "low"; + break; + case "Member.Capabilities": + member.Capabilities = ["tag-b"]; + break; + case "Member.ClaudeSession": + member.ClaudeSession = new AgentClassClaudeSessionOptions { Enabled = false }; + break; + case "Instance.Id": + instance.Id = "acct-b"; + break; + case "Instance.Agent": + instance.Agent = "codex"; + break; + case "Instance.CredentialFilePath": + instance.CredentialFilePath = "/cred-b"; + break; + case "Instance.TokenEnvironmentVariable": + instance.TokenEnvironmentVariable = "TOKEN_B"; + break; + case "Instance.AuthJsonEnvironmentVariable": + instance.AuthJsonEnvironmentVariable = "AUTH_B"; + break; + case "Instance.SettingsFilePath": + instance.SettingsFilePath = "/settings-b"; + break; + case "Instance.DestinationPath": + instance.DestinationPath = "/dest-b"; + break; + case "Instance.SandboxEnvironmentVariable": + instance.SandboxEnvironmentVariable = "SANDBOX_B"; + break; + case "Instance.Provider": + instance.Provider = "provider-b"; + break; + case "Class.Id": + cls.Id = "other"; + break; + case "Class.DisplayName": + cls.DisplayName = "Other"; + break; + case "Class.ClaudeSession": + cls.ClaudeSession = new AgentClassClaudeSessionOptions { Enabled = false }; + break; + case "Class.Members": + cls.Members.Add(new AgentMembershipOptions + { + Agent = "codex", + Billing = "Subscription", + QualityScore = 50, + }); + break; + default: + throw new InvalidOperationException( + $"MutateRouterField has no mutation for '{field}'. " + + "Cover the new config property in SerializeRouterInputs and add its mutation here."); + } + } + private static (CodeyBoxOptions Startup, CodeyBoxOptions Candidate) BuildGuardedKeyPair(string keyPath) { var startup = new CodeyBoxOptions();