From 3334a67c16e06dca181f5c36f166107f94e10fae Mon Sep 17 00:00:00 2001 From: Nigel Date: Thu, 24 Sep 2026 21:25:26 +0100 Subject: [PATCH] Say what resuming a run's lead is likely to cost, before it is spent Run 20260923-1216-be60 was resumed at 109.47 of a 120 budget, and its lead spent 15.08 in one turn of 14 exchanges: a long session carries its whole conversation into every exchange. The budget is checked between rounds, and a turn that crosses it finishes, because stopping a node mid-turn loses what was paid for; checking more often would not have caught one turn. Nothing said beforehand that this was likely. team resume now says, where the lead resumes its own session, what its last turn cost and over how many exchanges, and warns when that is more than the budget has left, or when the budget is already spent. A fresh lead gets no figure: it starts small and the old conversation's cost would overstate it. The dashboard's resume runs the same command, and --dry-run shows it too. The budget check itself is unchanged. Tests: ResumeCostTests, five cases including the be60 figures. Mutation-checked: counting every node's turns, warning against the whole budget rather than what is left, and giving a fresh lead a figure each fail a test. --- src/Loadout.Cli/Commands/TeamResumeCommand.cs | 7 ++ src/Loadout.Core/Teams/RunResumption.cs | 48 +++++++++++ tests/Loadout.Tests/Unit/ResumeCostTests.cs | 84 +++++++++++++++++++ 3 files changed, 139 insertions(+) create mode 100644 tests/Loadout.Tests/Unit/ResumeCostTests.cs diff --git a/src/Loadout.Cli/Commands/TeamResumeCommand.cs b/src/Loadout.Cli/Commands/TeamResumeCommand.cs index 3349d80..c64ad2d 100644 --- a/src/Loadout.Cli/Commands/TeamResumeCommand.cs +++ b/src/Loadout.Cli/Commands/TeamResumeCommand.cs @@ -239,6 +239,13 @@ protected override async Task ExecuteAsync( + $", {summary.Rounds} round(s)" + (rounds > 0 ? $" of {rounds}" : string.Empty) + ".[/]"); + + // Before anything is spent, because the budget is only checked + // between rounds and a long lead's one turn can pass it on its own. + if (resuming.LikelyCost(spent, cap) is { } likely) + { + output.WriteLine($"[yellow]{Markup.Escape(likely)}[/]"); + } } return await TeamRunCommand.DriveAsync( diff --git a/src/Loadout.Core/Teams/RunResumption.cs b/src/Loadout.Core/Teams/RunResumption.cs index 83767e7..bc35437 100644 --- a/src/Loadout.Core/Teams/RunResumption.cs +++ b/src/Loadout.Core/Teams/RunResumption.cs @@ -41,6 +41,54 @@ public sealed record RunResumption( /// The lead's node name: the first node the run launched. public string? Lead => Summary.Nodes.Count > 0 ? Summary.Nodes[0].Node : null; + /// + /// What the lead's next turn is likely to cost, said before it starts, or + /// null where there is nothing to go on. + /// + /// What the run has spent so far. + /// What it may spend in all, or null for no budget. + /// + /// + /// Only for a lead resuming its own session, which carries the whole + /// conversation into every exchange, so its last turn is the best guess at + /// its next. A fresh lead starts small and this would overstate it. + /// + /// + /// Said rather than enforced. The budget is checked between rounds, and a + /// turn that crosses it finishes, because stopping a node mid-turn loses the + /// work already paid for. One lead turn of a long run cost 15.08 over 14 + /// exchanges against 10.53 left, and nothing said so until it had been spent. + /// + /// + public string? LikelyCost(decimal spent, decimal? cap) + { + if (LeadSession is not { Length: > 0 } || Lead is not { } lead) + { + return null; + } + + var last = Summary.Turns.LastOrDefault(one => + string.Equals(one.Node, lead, StringComparison.Ordinal) && one.CostUsd > 0m); + + if (last is null) + { + return null; + } + + var said = + $"The lead's last turn cost ${last.CostUsd:0.00} over {last.Exchanges} exchange(s), " + + "and it carries that whole conversation into the next, so expect about as much."; + + if (cap is { } limit && last.CostUsd > limit - spent) + { + said += limit > spent + ? $" That is more than the ${limit - spent:0.00} the budget has left, so one turn could take the run past it." + : " The budget is already spent, so any turn takes the run past it."; + } + + return said; + } + /// /// Reads an ended run back, or says why it cannot be picked up. /// diff --git a/tests/Loadout.Tests/Unit/ResumeCostTests.cs b/tests/Loadout.Tests/Unit/ResumeCostTests.cs new file mode 100644 index 0000000..e3e7178 --- /dev/null +++ b/tests/Loadout.Tests/Unit/ResumeCostTests.cs @@ -0,0 +1,84 @@ +using System.Text.Json; +using FluentAssertions; +using Loadout.Core.Teams; +using Xunit; + +namespace Loadout.Tests.Unit; + +/// +/// Saying what picking a run up is likely to cost, before it is spent. +/// +/// +/// The case these come from: a run resumed at 109.47 of a 120 budget, whose +/// lead then spent 15.08 in one turn of 14 exchanges. The budget is checked +/// between rounds, so nothing stopped it, and nothing said it was likely. +/// +public sealed class ResumeCostTests +{ + [Fact] + public void A_lead_whose_last_turn_costs_more_than_is_left_is_said_to_before_it_starts() + { + var said = Resumed(lastTurn: 15.08m, session: "3c38").LikelyCost(spent: 109.47m, cap: 120m); + + said.Should().Contain("$15.08").And.Contain("14 exchange(s)"); + said.Should().Contain("more than the $10.53 the budget has left"); + } + + [Fact] + public void A_lead_whose_last_turn_fits_in_what_is_left_is_given_the_figure_alone() + { + var said = Resumed(lastTurn: 0.15m, session: "3c38").LikelyCost(spent: 109.47m, cap: 120m); + + said.Should().Contain("$0.15").And.NotContain("budget has left"); + } + + [Fact] + public void A_run_already_past_its_budget_says_any_turn_goes_further_past_it() + { + Resumed(lastTurn: 1m, session: "3c38").LikelyCost(spent: 124.56m, cap: 120m) + .Should().Contain("already spent"); + } + + [Fact] + public void A_fresh_lead_is_not_given_a_figure_from_a_conversation_it_will_not_carry() + { + Resumed(lastTurn: 15.08m, session: null).LikelyCost(spent: 109.47m, cap: 120m).Should().BeNull(); + } + + [Fact] + public void Only_the_leads_own_turns_count() + { + // The last turn in the journal is the verifier's, and cheap; the lead's + // own, earlier, is the one it will be like. + var said = Resumed(lastTurn: 15.08m, session: "3c38", after: ("verifier", 0.46m)) + .LikelyCost(spent: 109.47m, cap: 120m); + + said.Should().Contain("$15.08"); + } + + private static RunResumption Resumed(decimal lastTurn, string? session, (string Node, decimal Cost)? after = null) + { + var at = new DateTimeOffset(2026, 9, 24, 17, 0, 0, TimeSpan.Zero); + var events = new List + { + Event(at, null, "run.started", new { team = "t", goal = "g", autonomy = "autonomous", budget = 120 }), + Event(at.AddMinutes(1), "lead", "node.launched", new { role = "role.project-lead" }), + Event(at.AddMinutes(2), "lead", "node.turn", new { attempt = 1, turns = 14, cost = lastTurn, completed = true }), + }; + + if (after is { } other) + { + events.Add(Event(at.AddMinutes(3), other.Node, "node.launched", new { role = "role.verifier" })); + events.Add(Event(at.AddMinutes(4), other.Node, "node.turn", new { attempt = 1, turns = 7, cost = other.Cost, completed = true })); + } + + events.Add(Event(at.AddMinutes(5), null, "run.finished", new { ended = "blocked", outcome = "blocked", cost = 109.47 })); + + var summary = RunJournal.Fold("r", "C:/runs/r", events); + + return new RunResumption(summary, null, false, new Dictionary(), new Dictionary(), session, null); + } + + private static RunEvent Event(DateTimeOffset at, string? node, string kind, object data) => + new(at, node, kind, JsonSerializer.SerializeToElement(data)); +}