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:
@@ -625,7 +625,8 @@ public sealed record AppSettingsDto(
|
||||
List<string>? SessionSkills = null,
|
||||
List<ModelPresetDto>? ModelPresets = null,
|
||||
int UsageGateFiveHourPct = 80,
|
||||
int UsageGateSevenDayPct = 90);
|
||||
int UsageGateSevenDayPct = 90,
|
||||
int MaxTurnsCeiling = 80);
|
||||
|
||||
// Per-model run defaults (effort + turn budget) edited in Settings → General.
|
||||
public sealed record ModelPresetDto(string Model, string Effort, int MaxTurns);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -59,6 +59,7 @@ public sealed partial class SettingsModalViewModel : ViewModelBase
|
||||
General.DefaultClaudeInstructions = dto.DefaultClaudeInstructions ?? "";
|
||||
General.DefaultModel = dto.DefaultModel ?? "sonnet";
|
||||
General.DefaultMaxTurns = dto.DefaultMaxTurns;
|
||||
General.MaxTurnsCeiling = dto.MaxTurnsCeiling;
|
||||
General.DefaultPermissionMode = dto.DefaultPermissionMode ?? "auto";
|
||||
General.MaxParallelExecutions = dto.MaxParallelExecutions;
|
||||
General.UsageGateFiveHourPct = dto.UsageGateFiveHourPct;
|
||||
@@ -80,7 +81,7 @@ public sealed partial class SettingsModalViewModel : ViewModelBase
|
||||
await OnlineInbox.LoadAsync();
|
||||
await SessionSkills.LoadAsync();
|
||||
await General.LoadSessionSkillsAsync(_worker, dto?.SessionSkills);
|
||||
General.LoadModelPresets(dto?.ModelPresets);
|
||||
General.LoadModelPresets(dto?.ModelPresets, General.MaxTurnsCeiling);
|
||||
}
|
||||
finally { IsBusy = false; }
|
||||
}
|
||||
@@ -113,7 +114,8 @@ public sealed partial class SettingsModalViewModel : ViewModelBase
|
||||
General.SelectedSessionSkillNames(),
|
||||
General.ModelPresetDtos(),
|
||||
General.UsageGateFiveHourPct,
|
||||
General.UsageGateSevenDayPct);
|
||||
General.UsageGateSevenDayPct,
|
||||
General.MaxTurnsCeiling);
|
||||
await _worker.UpdateAppSettingsAsync(dto);
|
||||
await Prime.SaveAsync();
|
||||
await OnlineInbox.SaveAsync();
|
||||
|
||||
@@ -34,6 +34,9 @@
|
||||
PlaceholderText="{Binding TurnsInheritedHint}"
|
||||
Minimum="1" Maximum="200" Increment="1" FormatString="0"
|
||||
HorizontalAlignment="Stretch"/>
|
||||
<TextBlock Classes="meta" Opacity="0.6" TextWrapping="Wrap"
|
||||
Text="{Binding TurnsCeilingHint}"
|
||||
IsVisible="{Binding TurnsCeilingHint, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||
</StackPanel>
|
||||
|
||||
<!-- System prompt -->
|
||||
|
||||
@@ -129,14 +129,19 @@
|
||||
<ItemsControl ItemsSource="{Binding General.ModelPresets}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="settings:ModelPresetRowViewModel">
|
||||
<Grid ColumnDefinitions="90,12,*,12,110" Margin="0,0,0,6">
|
||||
<TextBlock Grid.Column="0" Text="{Binding Model}" VerticalAlignment="Center"/>
|
||||
<ComboBox Grid.Column="2" ItemsSource="{Binding EffortLevels}"
|
||||
SelectedItem="{Binding Effort, Mode=TwoWay}"
|
||||
HorizontalAlignment="Stretch"/>
|
||||
<NumericUpDown Grid.Column="4" Value="{Binding MaxTurns, Mode=TwoWay}"
|
||||
Minimum="1" Maximum="200" Increment="1" FormatString="0"/>
|
||||
</Grid>
|
||||
<StackPanel Spacing="2" Margin="0,0,0,6">
|
||||
<Grid ColumnDefinitions="90,12,*,12,110">
|
||||
<TextBlock Grid.Column="0" Text="{Binding Model}" VerticalAlignment="Center"/>
|
||||
<ComboBox Grid.Column="2" ItemsSource="{Binding EffortLevels}"
|
||||
SelectedItem="{Binding Effort, Mode=TwoWay}"
|
||||
HorizontalAlignment="Stretch"/>
|
||||
<NumericUpDown Grid.Column="4" Value="{Binding MaxTurns, Mode=TwoWay}"
|
||||
Minimum="1" Maximum="200" Increment="1" FormatString="0"/>
|
||||
</Grid>
|
||||
<TextBlock Classes="meta" Opacity="0.6" TextWrapping="Wrap"
|
||||
Text="{Binding ClampHint}"
|
||||
IsVisible="{Binding ClampHint, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
|
||||
Reference in New Issue
Block a user