Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions src/Loadout.Cli/Commands/TeamResumeCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,13 @@ protected override async Task<int> 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(
Expand Down
48 changes: 48 additions & 0 deletions src/Loadout.Core/Teams/RunResumption.cs
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,54 @@ public sealed record RunResumption(
/// <summary>The lead's node name: the first node the run launched.</summary>
public string? Lead => Summary.Nodes.Count > 0 ? Summary.Nodes[0].Node : null;

/// <summary>
/// What the lead's next turn is likely to cost, said before it starts, or
/// null where there is nothing to go on.
/// </summary>
/// <param name="spent">What the run has spent so far.</param>
/// <param name="cap">What it may spend in all, or null for no budget.</param>
/// <remarks>
/// <para>
/// 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.
/// </para>
/// <para>
/// 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.
/// </para>
/// </remarks>
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;
}

/// <summary>
/// Reads an ended run back, or says why it cannot be picked up.
/// </summary>
Expand Down
84 changes: 84 additions & 0 deletions tests/Loadout.Tests/Unit/ResumeCostTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
using System.Text.Json;
using FluentAssertions;
using Loadout.Core.Teams;
using Xunit;

namespace Loadout.Tests.Unit;

/// <summary>
/// Saying what picking a run up is likely to cost, before it is spent.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
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<RunEvent>
{
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<string, string>(), new Dictionary<string, string>(), session, null);
}

private static RunEvent Event(DateTimeOffset at, string? node, string kind, object data) =>
new(at, node, kind, JsonSerializer.SerializeToElement(data));
}
Loading