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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user