diff --git a/backend/src/CodeSpace.Core/Services/Supervisor/Deciders/LlmSupervisorDecider.cs b/backend/src/CodeSpace.Core/Services/Supervisor/Deciders/LlmSupervisorDecider.cs index af513a37..71131e80 100644 --- a/backend/src/CodeSpace.Core/Services/Supervisor/Deciders/LlmSupervisorDecider.cs +++ b/backend/src/CodeSpace.Core/Services/Supervisor/Deciders/LlmSupervisorDecider.cs @@ -560,10 +560,13 @@ private static string BuildUserPrompt(SupervisorTurnContext context, string cata builder.AppendLine(budget); } - // P5-3 — the RUN BOUNDS recitation: the no-progress streak + total-spawn count that silently force-stop the - // run (SupervisorBounds). The model previously saw neither and marched into the kill blind. Null while both - // counters are zero ⇒ byte-identical prompt for a fresh run. - if (SupervisorBoundsRecitation.Render(context.NoProgressDecisions, context.MaxNoProgressDecisions, context.TotalSpawnedAgents, context.MaxTotalSpawns) is { } bounds) + // P5-3/P5-5 — the RUN BOUNDS recitation: the no-progress streak + total-spawn count + resolve attempts that + // silently force-stop the run (SupervisorBounds). The model previously saw none of them and marched into + // the kill blind. The resolve count mirrors PostDecision's own tape count exactly (the CURRENT resolve + // isn't on the tape yet, so cap=1 shows 1-of-1 AFTER the first attempt — the moment the next one dies). + // Null while every counter is zero ⇒ byte-identical prompt for a fresh run. + if (SupervisorBoundsRecitation.Render(context.NoProgressDecisions, context.MaxNoProgressDecisions, context.TotalSpawnedAgents, context.MaxTotalSpawns, + context.PriorDecisions.Count(d => d.DecisionKind == SupervisorDecisionKinds.Resolve), context.MaxResolveAttempts) is { } bounds) { builder.AppendLine(); builder.AppendLine(bounds); diff --git a/backend/src/CodeSpace.Core/Services/Supervisor/Deciders/SupervisorBoundsRecitation.cs b/backend/src/CodeSpace.Core/Services/Supervisor/Deciders/SupervisorBoundsRecitation.cs index c56e2e77..a93607ea 100644 --- a/backend/src/CodeSpace.Core/Services/Supervisor/Deciders/SupervisorBoundsRecitation.cs +++ b/backend/src/CodeSpace.Core/Services/Supervisor/Deciders/SupervisorBoundsRecitation.cs @@ -16,10 +16,10 @@ public static class SupervisorBoundsRecitation /// The block's pinned header — a stable prompt landmark (tests + the model key on it), mirroring . public const string Header = "RUN BOUNDS (recite before deciding — hitting a bound force-stops the run):"; - /// Render the bounds block for the decider's prompt, or null when both counters are zero (nothing is at risk yet). - public static string? Render(int noProgressDecisions, int maxNoProgressDecisions, int totalSpawnedAgents, int? maxTotalSpawns) + /// Render the bounds block for the decider's prompt, or null when every counter is zero (nothing is at risk yet). + public static string? Render(int noProgressDecisions, int maxNoProgressDecisions, int totalSpawnedAgents, int? maxTotalSpawns, int resolveAttempts = 0, int? maxResolveAttempts = null) { - if (noProgressDecisions <= 0 && totalSpawnedAgents <= 0) return null; + if (noProgressDecisions <= 0 && totalSpawnedAgents <= 0 && resolveAttempts <= 0) return null; var builder = new StringBuilder(Header); @@ -34,6 +34,11 @@ public static class SupervisorBoundsRecitation if (totalSpawnedAgents > 0) builder.AppendLine().Append($"- agents spawned: {totalSpawnedAgents} of {maxTotalSpawns ?? SupervisorLane.DefaultMaxTotalSpawns} total-spawn cap (every spawn fan-out member and every retry counts one; a wave that would exceed the cap is refused)."); + // P5-5: the resolver runway — a resolve PAST the cap doesn't get refused, it force-stops the whole run + // (ResolveAttemptsExceeded), so the model must know the count before spending the run's life on one more. + if (resolveAttempts > 0) + builder.AppendLine().Append($"- resolve attempts: {resolveAttempts} of {maxResolveAttempts ?? SupervisorLane.DefaultMaxResolveAttempts} resolve cap — a resolve past the cap force-stops this run. If the reconciliation still is not VERIFIED within the cap, stop and leave the conflict to a human."); + return builder.ToString(); } } diff --git a/backend/src/CodeSpace.Core/Services/Supervisor/SupervisorTurnService.Rehydrate.cs b/backend/src/CodeSpace.Core/Services/Supervisor/SupervisorTurnService.Rehydrate.cs index 9bf3f686..2e9c0601 100644 --- a/backend/src/CodeSpace.Core/Services/Supervisor/SupervisorTurnService.Rehydrate.cs +++ b/backend/src/CodeSpace.Core/Services/Supervisor/SupervisorTurnService.Rehydrate.cs @@ -132,6 +132,7 @@ public async Task RehydrateFromDecisionLogAsync(Guid supe BrainPlaneSpendByKind = brainPlaneSpend.ByKind, MaxCostUsd = plan.MaxCostUsd, MaxTotalSpawns = plan.MaxTotalSpawns, + MaxResolveAttempts = plan.MaxResolveAttempts, NoProgressDecisions = FoldNoProgressDecisions(priorDecisions), MaxNoProgressDecisions = plan.MaxNoProgressDecisions, ApprovalPolicy = plan.ApprovalPolicy, diff --git a/backend/src/CodeSpace.Messages/Agents/SupervisorTurnContext.cs b/backend/src/CodeSpace.Messages/Agents/SupervisorTurnContext.cs index 01621973..13f54cf5 100644 --- a/backend/src/CodeSpace.Messages/Agents/SupervisorTurnContext.cs +++ b/backend/src/CodeSpace.Messages/Agents/SupervisorTurnContext.cs @@ -86,6 +86,9 @@ public sealed record SupervisorTurnContext /// W-hard — the run's total-spawn cap (carried from SupervisorGoalPlan.MaxTotalSpawns, same plumbing as ): the budget ledger's per-attempt estimate divisor (cap ÷ spawns = the natural reservation slice). Null on legacy contexts; readers fall back to the lane default. public int? MaxTotalSpawns { get; init; } + /// P5-5 — the run's resolve-attempt cap (carried from SupervisorGoalPlan.MaxResolveAttempts, same plumbing as ) so the RUN BOUNDS recitation can show the resolver runway — a resolve past the cap force-stops the run. Null on legacy contexts; readers fall back to the lane default. + public int? MaxResolveAttempts { get; init; } + private static readonly IReadOnlyDictionary EmptySpendByKind = new Dictionary(); /// diff --git a/backend/tests/CodeSpace.UnitTests/Agents/SupervisorBoundsRecitationTests.cs b/backend/tests/CodeSpace.UnitTests/Agents/SupervisorBoundsRecitationTests.cs index 2a467292..7d74c9c5 100644 --- a/backend/tests/CodeSpace.UnitTests/Agents/SupervisorBoundsRecitationTests.cs +++ b/backend/tests/CodeSpace.UnitTests/Agents/SupervisorBoundsRecitationTests.cs @@ -62,6 +62,35 @@ public void A_legacy_context_without_a_cap_falls_back_to_the_lane_default() .ShouldContain($"5 of {SupervisorLane.DefaultMaxTotalSpawns} total-spawn cap", Case.Sensitive, "readers fall back to the lane default — the recitation mirrors them"); } + // ── P5-5: the resolver runway ─────────────────────────────────────────────────── + + [Fact] + public void The_resolve_line_names_the_count_the_cap_and_the_human_fallback() + { + var block = SupervisorBoundsRecitation.Render(0, 8, 0, 50, resolveAttempts: 1, maxResolveAttempts: 2); + + block.ShouldNotBeNull("a spent resolve attempt alone warrants the block — the next one past the cap kills the run"); + block!.ShouldContain("resolve attempts: 1 of 2 resolve cap", Case.Sensitive); + block.ShouldContain("a resolve past the cap force-stops this run", Case.Sensitive, "unlike a spawn wave, an over-cap resolve is not refused — it is the run's death; the model must know which"); + block.ShouldContain("stop and leave the conflict to a human", Case.Sensitive, "the fail-safe exit is named, mirroring the resolution-verdict copy"); + block.ShouldNotContain("no-progress decisions", Case.Sensitive); + block.ShouldNotContain("agents spawned", Case.Sensitive); + } + + [Fact] + public void A_legacy_context_without_a_resolve_cap_falls_back_to_the_lane_default() + { + SupervisorBoundsRecitation.Render(0, 8, 0, 50, resolveAttempts: 1, maxResolveAttempts: null)! + .ShouldContain($"resolve attempts: 1 of {SupervisorLane.DefaultMaxResolveAttempts} resolve cap", Case.Sensitive); + } + + [Fact] + public void Zero_resolve_attempts_render_no_resolve_line() + { + SupervisorBoundsRecitation.Render(6, 8, 0, 50)! + .ShouldNotContain("resolve attempts", Case.Sensitive, "no resolve on the tape → no resolver line, the default params keep every P5-3 caller byte-identical"); + } + [Fact] public void The_header_is_pinned() { @@ -91,4 +120,23 @@ public void A_fresh_runs_prompt_has_no_bounds_block() LlmSupervisorDecider.BuildUserPromptForTest(new SupervisorTurnContext { Goal = "ship it", TurnNumber = 0, PriorDecisions = Array.Empty() }) .ShouldNotContain("RUN BOUNDS", Case.Sensitive, "byte-identical prompt while nothing is at risk"); } + + [Fact] + public void The_user_prompt_counts_resolves_off_the_tape_exactly_like_the_bound_does() + { + // The prompt's count mirrors SupervisorBounds.PostDecision's own tape count (prior Resolve decisions) — + // producer and recitation can't drift because both read the same rows the same way. + var resolve = new SupervisorPriorDecision + { + Id = Guid.NewGuid(), Sequence = 3, DecisionKind = SupervisorDecisionKinds.Resolve, Status = SupervisorDecisionStatus.Succeeded, + PayloadJson = "{}", OutcomeJson = """{"agentRunIds":[],"agentCount":0}""", + }; + + var prompt = LlmSupervisorDecider.BuildUserPromptForTest(new SupervisorTurnContext + { + Goal = "ship it", TurnNumber = 4, PriorDecisions = new[] { resolve }, MaxResolveAttempts = 2, + }); + + prompt.ShouldContain("resolve attempts: 1 of 2 resolve cap", Case.Sensitive); + } }