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);
+ }
}