feat(worker): clamp max-turns to a configurable ceiling

Runaway sessions were the single biggest cost driver: model_presets was
never persisted (stayed code-only), default_max_turns shipped at 100, and
ResolveMaxTurns had no upper bound, so a task/list override could run
hundreds of turns unchecked.

- TaskRunner.ResolveMaxTurns now clamps the resolved value to
  AppSettings.MaxTurnsCeiling (new column, default 80) and logs a warning
  with task id / requested / effective value when it clamps.
- default_max_turns default lowered from 100 to 40 (entity, EF config,
  and the seeded row via the new AddMaxTurnsCeiling migration).
- AppSettingsRepository.GetAsync backfills model_presets with the
  shipping defaults on first read instead of leaving the column null.
- Settings > General's per-model preset table and the task/list agent
  editor now show a hint when a set max-turns value exceeds the ceiling.
This commit is contained in:
mika kuns
2026-08-05 15:40:02 +02:00
parent 83ea429b8a
commit 08ac8bf7b1
22 changed files with 1093 additions and 50 deletions
@@ -7,13 +7,21 @@ public class MaxTurnsResolutionTests
{
[Fact]
public void Task_override_wins()
=> Assert.Equal(5, TaskRunner.ResolveMaxTurns(taskTurns: 5, listTurns: 20, globalDefault: 100));
=> Assert.Equal(5, TaskRunner.ResolveMaxTurns(taskTurns: 5, listTurns: 20, globalDefault: 100, ceiling: 200));
[Fact]
public void List_override_used_when_no_task_override()
=> Assert.Equal(20, TaskRunner.ResolveMaxTurns(taskTurns: null, listTurns: 20, globalDefault: 100));
=> Assert.Equal(20, TaskRunner.ResolveMaxTurns(taskTurns: null, listTurns: 20, globalDefault: 100, ceiling: 200));
[Fact]
public void Falls_back_to_global_default()
=> Assert.Equal(100, TaskRunner.ResolveMaxTurns(taskTurns: null, listTurns: null, globalDefault: 100));
=> Assert.Equal(100, TaskRunner.ResolveMaxTurns(taskTurns: null, listTurns: null, globalDefault: 100, ceiling: 200));
[Fact]
public void Below_ceiling_is_unchanged()
=> Assert.Equal(60, TaskRunner.ResolveMaxTurns(taskTurns: 60, listTurns: null, globalDefault: 40, ceiling: 80));
[Fact]
public void Above_ceiling_is_clamped()
=> Assert.Equal(80, TaskRunner.ResolveMaxTurns(taskTurns: 200, listTurns: null, globalDefault: 40, ceiling: 80));
}
@@ -87,9 +87,7 @@ public sealed class ModelResolutionWireTests : IDisposable
using (var ctx = _db.CreateContext())
{
var settingsRepo = new AppSettingsRepository(ctx);
var settings = await settingsRepo.GetAsync();
settings.DefaultMaxTurns = 100;
await ctx.SaveChangesAsync();
await settingsRepo.UpdateAsync(new AppSettingsEntity { DefaultMaxTurns = 50 });
}
Exception? thrown = null;
@@ -106,6 +104,6 @@ public sealed class ModelResolutionWireTests : IDisposable
Assert.Null(thrown);
var args = getArgs().ToList();
Assert.Contains("--max-turns", args);
Assert.Equal("100", args[args.IndexOf("--max-turns") + 1]);
Assert.Equal("50", args[args.IndexOf("--max-turns") + 1]);
}
}