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