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
@@ -43,6 +43,7 @@ public sealed partial class AgentConfigEditorViewModel : ViewModelBase, IDisposa
[ObservableProperty] private string _modelInheritedHint = "";
[ObservableProperty] private string _turnsBadge = "";
[ObservableProperty] private string _turnsInheritedHint = "";
[ObservableProperty] private string _turnsCeilingHint = "";
[ObservableProperty] private string _agentBadge = "";
[ObservableProperty] private string _effectiveSystemPromptHint = "";
@@ -50,6 +51,7 @@ public sealed partial class AgentConfigEditorViewModel : ViewModelBase, IDisposa
// The global max-turns default is per-model (Settings -> General), so it moves with whichever
// model actually ends up in effect here.
private IReadOnlyList<ModelPreset> _presets = ModelPresets.Defaults;
private int _maxTurnsCeiling = 80;
private string EffectiveModel => Model ?? _listModel ?? _globalModel;
private int GlobalMaxTurns => ModelPresets.For(_presets, EffectiveModel).MaxTurns;
private string? _listModel; // Task scope only
@@ -145,6 +147,9 @@ public sealed partial class AgentConfigEditorViewModel : ViewModelBase, IDisposa
: InheritanceResolver.ResolveList(own, GlobalMaxTurns.ToString());
TurnsInheritedHint = value;
TurnsBadge = BadgeFor(source, MaxTurns is not null);
TurnsCeilingHint = MaxTurns is decimal t && (int)t > _maxTurnsCeiling
? Loc.T("settings.turnsCeilingHint", _maxTurnsCeiling)
: "";
}
private void RecomputeAgentBadge()
@@ -301,6 +306,7 @@ public sealed partial class AgentConfigEditorViewModel : ViewModelBase, IDisposa
_presets = app?.ModelPresets is { Count: > 0 } rows
? rows.Select(r => new ModelPreset(r.Model, r.Effort, r.MaxTurns)).ToList()
: ModelPresets.Defaults;
_maxTurnsCeiling = app?.MaxTurnsCeiling ?? 80;
}
private void ApplyConfig(string? model, int? maxTurns, string? systemPrompt, string? agentPath)