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
@@ -14,25 +14,45 @@ public sealed class AppSettingsRepository
{
var row = await _context.AppSettings.AsNoTracking()
.FirstOrDefaultAsync(s => s.Id == AppSettingsEntity.SingletonId, ct);
if (row is not null) return row;
if (row is null)
{
row = new AppSettingsEntity { Id = AppSettingsEntity.SingletonId, ModelPresets = ModelPresets.SerializeDefaults() };
_context.AppSettings.Add(row);
try
{
await _context.SaveChangesAsync(ct);
_context.Entry(row).State = EntityState.Detached;
}
catch (DbUpdateException)
{
// Concurrent process already inserted the singleton — discard our attempt and re-read.
_context.Entry(row).State = EntityState.Detached;
row = await _context.AppSettings.AsNoTracking()
.FirstAsync(s => s.Id == AppSettingsEntity.SingletonId, ct);
}
return row;
}
// First read after upgrading from a null model_presets column: persist the shipping
// defaults so the Settings UI shows real, editable rows instead of a code-only fallback.
if (row.ModelPresets is null)
row.ModelPresets = await BackfillModelPresetsAsync(ct);
row = new AppSettingsEntity { Id = AppSettingsEntity.SingletonId };
_context.AppSettings.Add(row);
try
{
await _context.SaveChangesAsync(ct);
_context.Entry(row).State = EntityState.Detached;
}
catch (DbUpdateException)
{
// Concurrent process already inserted the singleton — discard our attempt and re-read.
_context.Entry(row).State = EntityState.Detached;
row = await _context.AppSettings.AsNoTracking()
.FirstAsync(s => s.Id == AppSettingsEntity.SingletonId, ct);
}
return row;
}
private async Task<string> BackfillModelPresetsAsync(CancellationToken ct)
{
var defaults = ModelPresets.SerializeDefaults();
var tracked = await GetOrCreateTrackedRowAsync(ct);
if (tracked.ModelPresets is null)
{
tracked.ModelPresets = defaults;
await _context.SaveChangesAsync(ct);
}
return defaults;
}
private async Task<AppSettingsEntity> GetOrCreateTrackedRowAsync(CancellationToken ct)
{
var row = await _context.AppSettings
@@ -52,6 +72,7 @@ public sealed class AppSettingsRepository
row.DefaultClaudeInstructions = updated.DefaultClaudeInstructions ?? string.Empty;
row.DefaultModel = string.IsNullOrWhiteSpace(updated.DefaultModel) ? "sonnet" : updated.DefaultModel;
row.DefaultMaxTurns = updated.DefaultMaxTurns;
row.MaxTurnsCeiling = updated.MaxTurnsCeiling < 1 ? 1 : updated.MaxTurnsCeiling;
row.DefaultPermissionMode = string.IsNullOrWhiteSpace(updated.DefaultPermissionMode)
? "auto" : updated.DefaultPermissionMode;
row.MaxParallelExecutions = updated.MaxParallelExecutions < 1 ? 1 : updated.MaxParallelExecutions;