Files
ClaudeDo/tests/ClaudeDo.Worker.Tests/Repositories/AppSettingsRepositoryTests.cs
T
mika kuns 08ac8bf7b1 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.
2026-08-05 15:40:02 +02:00

165 lines
5.5 KiB
C#

using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Tests.Infrastructure;
namespace ClaudeDo.Worker.Tests.Repositories;
public class AppSettingsRepositoryTests : IDisposable
{
private readonly DbFixture _db = new();
public void Dispose() => _db.Dispose();
[Fact]
public async Task GetAsync_Returns_Seeded_Defaults()
{
using var ctx = _db.CreateContext();
var repo = new AppSettingsRepository(ctx);
var row = await repo.GetAsync();
Assert.Equal(AppSettingsEntity.SingletonId, row.Id);
Assert.Equal("sonnet", row.DefaultModel);
Assert.Equal(40, row.DefaultMaxTurns);
Assert.Equal(80, row.MaxTurnsCeiling);
Assert.Equal("auto", row.DefaultPermissionMode);
Assert.Equal("sibling", row.WorktreeStrategy);
Assert.Null(row.CentralWorktreeRoot);
Assert.False(row.WorktreeAutoCleanupEnabled);
}
[Fact]
public async Task UpdateAsync_Persists_MaxTurnsCeiling()
{
using (var ctx = _db.CreateContext())
{
var repo = new AppSettingsRepository(ctx);
await repo.UpdateAsync(new AppSettingsEntity { MaxTurnsCeiling = 60 });
}
using var readCtx = _db.CreateContext();
var row = await new AppSettingsRepository(readCtx).GetAsync();
Assert.Equal(60, row.MaxTurnsCeiling);
}
[Fact]
public async Task GetAsync_Backfills_Null_ModelPresets_With_Defaults()
{
using (var ctx = _db.CreateContext())
{
var repo = new AppSettingsRepository(ctx);
// Force a row to exist with model_presets left null (the pre-upgrade state).
await repo.UpdateAsync(new AppSettingsEntity());
}
using var readCtx = _db.CreateContext();
var row = await new AppSettingsRepository(readCtx).GetAsync();
Assert.NotNull(row.ModelPresets);
Assert.Equal(ModelPresets.SerializeDefaults(), row.ModelPresets);
using var rereadCtx = _db.CreateContext();
var reread = await new AppSettingsRepository(rereadCtx).GetAsync();
Assert.Equal(ModelPresets.SerializeDefaults(), reread.ModelPresets);
}
[Fact]
public async Task UpdateAsync_Persists_And_RoundTrips()
{
using (var ctx = _db.CreateContext())
{
var repo = new AppSettingsRepository(ctx);
await repo.UpdateAsync(new AppSettingsEntity
{
DefaultClaudeInstructions = "be terse",
DefaultModel = "opus",
DefaultMaxTurns = 42,
DefaultPermissionMode = "acceptEdits",
WorktreeStrategy = "central",
CentralWorktreeRoot = "C:/worktrees",
WorktreeAutoCleanupEnabled = true,
WorktreeAutoCleanupDays = 14,
});
}
using var readCtx = _db.CreateContext();
var readRepo = new AppSettingsRepository(readCtx);
var row = await readRepo.GetAsync();
Assert.Equal("be terse", row.DefaultClaudeInstructions);
Assert.Equal("opus", row.DefaultModel);
Assert.Equal(42, row.DefaultMaxTurns);
Assert.Equal("acceptEdits", row.DefaultPermissionMode);
Assert.Equal("central", row.WorktreeStrategy);
Assert.Equal("C:/worktrees", row.CentralWorktreeRoot);
Assert.True(row.WorktreeAutoCleanupEnabled);
Assert.Equal(14, row.WorktreeAutoCleanupDays);
}
[Fact]
public async Task UpdateAsync_Blank_CentralRoot_Stored_As_Null()
{
using var ctx = _db.CreateContext();
var repo = new AppSettingsRepository(ctx);
await repo.UpdateAsync(new AppSettingsEntity { CentralWorktreeRoot = " " });
var row = await repo.GetAsync();
Assert.Null(row.CentralWorktreeRoot);
}
[Fact]
public async Task UpdateAsync_Persists_SessionSkills_Json()
{
using (var ctx = _db.CreateContext())
{
var repo = new AppSettingsRepository(ctx);
await repo.UpdateAsync(new AppSettingsEntity
{
SessionSkills = "[\"ponytail\",\"ponytail-help\"]",
});
}
using var readCtx = _db.CreateContext();
var row = await new AppSettingsRepository(readCtx).GetAsync();
Assert.Equal("[\"ponytail\",\"ponytail-help\"]", row.SessionSkills);
}
[Fact]
public async Task UpdateAsync_Blank_SessionSkills_Stored_As_Null()
{
using (var ctx = _db.CreateContext())
{
var repo = new AppSettingsRepository(ctx);
await repo.UpdateAsync(new AppSettingsEntity { SessionSkills = "[\"a\"]" });
}
using (var ctx = _db.CreateContext())
{
var repo = new AppSettingsRepository(ctx);
await repo.UpdateAsync(new AppSettingsEntity { SessionSkills = null });
}
using var readCtx = _db.CreateContext();
var row = await new AppSettingsRepository(readCtx).GetAsync();
Assert.Null(row.SessionSkills);
}
[Fact]
public async Task DailyPrepMaxTasks_defaults_to_5_and_persists()
{
using var ctx = _db.CreateContext();
var repo = new AppSettingsRepository(ctx);
var initial = await repo.GetAsync();
Assert.Equal(5, initial.DailyPrepMaxTasks);
initial.DailyPrepMaxTasks = 8;
await repo.UpdateAsync(initial);
using var readCtx = _db.CreateContext();
var reloaded = await new AppSettingsRepository(readCtx).GetAsync();
Assert.Equal(8, reloaded.DailyPrepMaxTasks);
}
}