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
@@ -1,6 +1,7 @@
using System.Collections.ObjectModel;
using ClaudeDo.Data.Models;
using ClaudeDo.Localization;
using ClaudeDo.Ui.Localization;
using ClaudeDo.Ui.Services;
using ClaudeDo.Ui.ViewModels.Agent;
using CommunityToolkit.Mvvm.ComponentModel;
@@ -15,7 +16,11 @@ public sealed partial class GeneralSettingsTabViewModel : ViewModelBase
[ObservableProperty] private string _defaultClaudeInstructions = "";
[ObservableProperty] private string _defaultModel = ModelRegistry.DefaultAlias;
[ObservableProperty] private int _defaultMaxTurns = 100;
[ObservableProperty] private int _defaultMaxTurns = 40;
// Hard ceiling every resolved max-turns value is clamped to before a run starts. Not directly
// editable here yet — loaded and echoed back on save so it round-trips, and drives the clamp
// hints on the preset rows below and on AgentConfigEditorViewModel.
[ObservableProperty] private int _maxTurnsCeiling = 80;
[ObservableProperty] private string _defaultPermissionMode = PermissionModeRegistry.DefaultMode;
[ObservableProperty] private int _maxParallelExecutions = 1;
// Percentage of the 5h/7d Claude usage window at which the autonomous queue pauses. 0 = gate off.
@@ -56,14 +61,14 @@ public sealed partial class GeneralSettingsTabViewModel : ViewModelBase
/// model. Supplies the global defaults; list- and task-level max-turns overrides still win.</summary>
public ObservableCollection<ModelPresetRowViewModel> ModelPresets { get; } = new();
public void LoadModelPresets(IReadOnlyCollection<ModelPresetDto>? presets)
public void LoadModelPresets(IReadOnlyCollection<ModelPresetDto>? presets, int ceiling)
{
ModelPresets.Clear();
var source = presets is { Count: > 0 }
? presets.Select(p => new ModelPreset(p.Model, p.Effort, p.MaxTurns)).ToList()
: Data.Models.ModelPresets.Defaults.ToList();
foreach (var p in Data.Models.ModelPresets.Parse(Data.Models.ModelPresets.Serialize(source)))
ModelPresets.Add(new ModelPresetRowViewModel(p));
ModelPresets.Add(new ModelPresetRowViewModel(p, ceiling));
}
public List<ModelPresetDto> ModelPresetDtos()
@@ -130,11 +135,20 @@ public sealed partial class ModelPresetRowViewModel : ViewModelBase
[ObservableProperty] private string _effort;
// decimal so it binds straight to a NumericUpDown, like the other numeric settings.
[ObservableProperty] private decimal _maxTurns;
[ObservableProperty] private int _ceiling;
public ModelPresetRowViewModel(ModelPreset preset)
public ModelPresetRowViewModel(ModelPreset preset, int ceiling)
{
Model = preset.Model;
_effort = preset.Effort;
_maxTurns = preset.MaxTurns;
_ceiling = ceiling;
}
public string ClampHint => MaxTurns > Ceiling
? Loc.T("settings.turnsCeilingHint", Ceiling)
: "";
partial void OnMaxTurnsChanged(decimal value) => OnPropertyChanged(nameof(ClampHint));
partial void OnCeilingChanged(int value) => OnPropertyChanged(nameof(ClampHint));
}