diff --git a/src/ClaudeDo.Data/PromptFiles.cs b/src/ClaudeDo.Data/PromptFiles.cs index 52e2429f..4969643f 100644 --- a/src/ClaudeDo.Data/PromptFiles.cs +++ b/src/ClaudeDo.Data/PromptFiles.cs @@ -168,6 +168,12 @@ public static class PromptFiles - sonnet — normal coding work; the sensible default when unsure. - opus — only for genuinely complex, cross-cutting, or hard-to-debug work. Do not default everything to opus — most subtasks are haiku or sonnet. + + Only for a subtask you can tell will need noticeably more turns than the + default budget (a large or sprawling piece of work), also pass CreateChildTask's + `maxTurns` argument with a generous turn count, so the run doesn't die mid-work + at the turn limit. Leave `maxTurns` null for everything else — it inherits the + list/global default. """; private const string PlanningInitialDefault = """ diff --git a/src/ClaudeDo.Data/Repositories/TaskRepository.cs b/src/ClaudeDo.Data/Repositories/TaskRepository.cs index 58a77995..12e20c5c 100644 --- a/src/ClaudeDo.Data/Repositories/TaskRepository.cs +++ b/src/ClaudeDo.Data/Repositories/TaskRepository.cs @@ -276,8 +276,12 @@ public sealed class TaskRepository string? commitType, string? createdBy = null, string? model = null, + int? maxTurns = null, CancellationToken ct = default) { + if (maxTurns.HasValue && maxTurns.Value <= 0) + throw new ArgumentException($"maxTurns must be positive, got {maxTurns.Value}."); + // AsNoTracking: SetPlanningStartedAsync mutates via ExecuteUpdate which // bypasses the change tracker; a tracked Find would return stale data. var parent = await _context.Tasks.AsNoTracking() @@ -303,6 +307,7 @@ public sealed class TaskRepository SortOrder = (maxSort ?? -1) + 1, CreatedBy = createdBy, Model = ModelRegistry.NormalizeAlias(model), + MaxTurns = maxTurns, }; _context.Tasks.Add(child); await _context.SaveChangesAsync(ct); diff --git a/src/ClaudeDo.Worker/CLAUDE.md b/src/ClaudeDo.Worker/CLAUDE.md index 0dd9906e..1ca22742 100644 --- a/src/ClaudeDo.Worker/CLAUDE.md +++ b/src/ClaudeDo.Worker/CLAUDE.md @@ -184,7 +184,7 @@ Loaded from `~/.todo-app/worker.config.json`: - The refresh token is NOT in this file — stored encrypted via DPAPI at `~/.todo-app/online-inbox.token` - `usage_poll_interval_seconds` (default 60, clamped to a minimum of 15 on load) — poll interval for `UsageMonitorService` -Per-list config (`list_config` in DB) provides defaults for `model`, `system_prompt`, `agent_path`; tasks can override each individually. Task-generating MCP tools (`AddTask`, planning `CreateChildTask`, `SuggestImprovement`) accept an optional `model` (alias-validated via `ModelRegistry.NormalizeAlias` — `haiku`/`sonnet`/`opus`, blank = inherit) so Claude assigns the cheapest capable model at creation time; the planning/system/improvement prompts instruct it to do so (`ModelRegistry.ByCostAscending` = the cost order). +Per-list config (`list_config` in DB) provides defaults for `model`, `system_prompt`, `agent_path`; tasks can override each individually. Task-generating MCP tools (`AddTask`, planning `CreateChildTask`, `SuggestImprovement`) accept an optional `model` (alias-validated via `ModelRegistry.NormalizeAlias` — `haiku`/`sonnet`/`opus`, blank = inherit) so Claude assigns the cheapest capable model at creation time; the planning/system/improvement prompts instruct it to do so (`ModelRegistry.ByCostAscending` = the cost order). Planning's `CreateChildTask` additionally accepts an optional `maxTurns` (positive int; `0`/negative rejected with `ArgumentException`, null = inherit list/global default) so the planner can raise the turn budget for a subtask it knows will run long; `SuggestImprovement`/`AddTask` do not expose it. ## Notes diff --git a/src/ClaudeDo.Worker/Planning/PlanningMcpService.cs b/src/ClaudeDo.Worker/Planning/PlanningMcpService.cs index c0fc01a0..8beec7ae 100644 --- a/src/ClaudeDo.Worker/Planning/PlanningMcpService.cs +++ b/src/ClaudeDo.Worker/Planning/PlanningMcpService.cs @@ -41,16 +41,18 @@ public sealed class PlanningMcpService "Create a new draft child task under the current planning session's parent task. " + "Set model to the cheapest model that can do this subtask well — 'haiku' for trivial/mechanical " + "work, 'sonnet' for normal coding (the default), 'opus' only for complex or cross-cutting work. " + - "Leave model null to inherit the list/global default.")] + "Leave model null to inherit the list/global default. Set maxTurns only when this subtask is " + + "visibly larger than the default turn budget; leave it null to inherit the list/global default.")] public async Task CreateChildTask( string title, string? description, string? commitType, string? model, - CancellationToken cancellationToken) + int? maxTurns = null, + CancellationToken cancellationToken = default) { var ctx = _contextAccessor.Current; - var child = await _tasks.CreateChildAsync(ctx.ParentTaskId, title, description, commitType, createdBy: null, model: model, ct: cancellationToken); + var child = await _tasks.CreateChildAsync(ctx.ParentTaskId, title, description, commitType, createdBy: null, model: model, maxTurns: maxTurns, ct: cancellationToken); await BroadcastTaskUpdatedAsync(child.Id, cancellationToken); await BroadcastTaskUpdatedAsync(ctx.ParentTaskId, cancellationToken); return new CreatedChildDto(child.Id, child.Status.ToString()); diff --git a/tests/ClaudeDo.Worker.Tests/Planning/PlanningEndToEndTests.cs b/tests/ClaudeDo.Worker.Tests/Planning/PlanningEndToEndTests.cs index 212ce0f8..e2ec165c 100644 --- a/tests/ClaudeDo.Worker.Tests/Planning/PlanningEndToEndTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Planning/PlanningEndToEndTests.cs @@ -111,8 +111,8 @@ public sealed class PlanningEndToEndTests : IDisposable // Wire the ambient context so _svc reads the correct parent _httpContext.Items["PlanningContext"] = new PlanningMcpContext { ParentTaskId = parent.Id }; - await _svc.CreateChildTask("sub 1", null, null, null, CancellationToken.None); - await _svc.CreateChildTask("sub 2", null, null, null, CancellationToken.None); + await _svc.CreateChildTask("sub 1", null, null, null, cancellationToken: CancellationToken.None); + await _svc.CreateChildTask("sub 2", null, null, null, cancellationToken: CancellationToken.None); var count = await _svc.Finalize(true, CancellationToken.None); Assert.Equal(2, count); @@ -155,9 +155,9 @@ public sealed class PlanningEndToEndTests : IDisposable await _manager.StartAsync(parent.Id, CancellationToken.None); _httpContext.Items["PlanningContext"] = new PlanningMcpContext { ParentTaskId = parent.Id }; - await _svc.CreateChildTask("c1", null, null, null, CancellationToken.None); - await _svc.CreateChildTask("c2", null, null, null, CancellationToken.None); - await _svc.CreateChildTask("c3", null, null, null, CancellationToken.None); + await _svc.CreateChildTask("c1", null, null, null, cancellationToken: CancellationToken.None); + await _svc.CreateChildTask("c2", null, null, null, cancellationToken: CancellationToken.None); + await _svc.CreateChildTask("c3", null, null, null, cancellationToken: CancellationToken.None); var kidsBefore = await _tasks.GetChildrenAsync(parent.Id); var firstChildId = kidsBefore[0].Id; diff --git a/tests/ClaudeDo.Worker.Tests/Planning/PlanningMcpServiceTests.cs b/tests/ClaudeDo.Worker.Tests/Planning/PlanningMcpServiceTests.cs index aba374b9..637ff170 100644 --- a/tests/ClaudeDo.Worker.Tests/Planning/PlanningMcpServiceTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Planning/PlanningMcpServiceTests.cs @@ -108,7 +108,7 @@ public sealed class PlanningMcpServiceTests : IDisposable var parent = await SeedPlanningParentAsync(); var sut = BuildSut(parent.Id); - var result = await sut.CreateChildTask("My child", "desc", null, model: null, CancellationToken.None); + var result = await sut.CreateChildTask("My child", "desc", null, model: null, cancellationToken: CancellationToken.None); Assert.Equal("Idle", result.Status); var child = await _tasks.GetByIdAsync(result.TaskId); @@ -123,7 +123,7 @@ public sealed class PlanningMcpServiceTests : IDisposable var parent = await SeedPlanningParentAsync(); var sut = BuildSut(parent.Id); - var result = await sut.CreateChildTask("c", null, null, model: "Opus", CancellationToken.None); + var result = await sut.CreateChildTask("c", null, null, model: "Opus", cancellationToken: CancellationToken.None); var child = await _tasks.GetByIdAsync(result.TaskId); Assert.Equal("opus", child!.Model); @@ -136,7 +136,43 @@ public sealed class PlanningMcpServiceTests : IDisposable var sut = BuildSut(parent.Id); await Assert.ThrowsAsync( - () => sut.CreateChildTask("c", null, null, model: "turbo", CancellationToken.None)); + () => sut.CreateChildTask("c", null, null, model: "turbo", cancellationToken: CancellationToken.None)); + } + + [Fact] + public async Task CreateChildTask_NoMaxTurns_ChildInherits() + { + var parent = await SeedPlanningParentAsync(); + var sut = BuildSut(parent.Id); + + var result = await sut.CreateChildTask("c", null, null, model: null, cancellationToken: CancellationToken.None); + + var child = await _tasks.GetByIdAsync(result.TaskId); + Assert.Null(child!.MaxTurns); + } + + [Fact] + public async Task CreateChildTask_PersistsMaxTurns() + { + var parent = await SeedPlanningParentAsync(); + var sut = BuildSut(parent.Id); + + var result = await sut.CreateChildTask("c", null, null, model: null, maxTurns: 200, cancellationToken: CancellationToken.None); + + var child = await _tasks.GetByIdAsync(result.TaskId); + Assert.Equal(200, child!.MaxTurns); + } + + [Theory] + [InlineData(0)] + [InlineData(-1)] + public async Task CreateChildTask_RejectsInvalidMaxTurns(int maxTurns) + { + var parent = await SeedPlanningParentAsync(); + var sut = BuildSut(parent.Id); + + await Assert.ThrowsAsync( + () => sut.CreateChildTask("c", null, null, model: null, maxTurns: maxTurns, cancellationToken: CancellationToken.None)); } [Fact] @@ -267,7 +303,7 @@ public sealed class PlanningMcpServiceTests : IDisposable var parent = await SeedPlanningParentAsync(); var sut = BuildSut(parent.Id); - var result = await sut.CreateChildTask("c", null, null, model: null, CancellationToken.None); + var result = await sut.CreateChildTask("c", null, null, model: null, cancellationToken: CancellationToken.None); var ids = TaskUpdatedIds(); Assert.Contains(result.TaskId, ids); diff --git a/tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryPlanningTests.cs b/tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryPlanningTests.cs index bdfaae7d..37cd0251 100644 --- a/tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryPlanningTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryPlanningTests.cs @@ -119,6 +119,47 @@ public sealed class TaskRepositoryPlanningTests : IDisposable _tasks.CreateChildAsync("nonexistent-parent-id", "t", null, null)); } + [Fact] + public async Task CreateChildAsync_NoMaxTurns_ChildInherits() + { + var listId = await CreateListAsync(); + var parent = MakeTask(listId, phase: PlanningPhase.Active); + await _tasks.AddAsync(parent); + + var child = await _tasks.CreateChildAsync(parent.Id, "child", null, null); + + Assert.Null(child.MaxTurns); + var loaded = await _tasks.GetByIdAsync(child.Id); + Assert.Null(loaded!.MaxTurns); + } + + [Fact] + public async Task CreateChildAsync_WithMaxTurns_Persists() + { + var listId = await CreateListAsync(); + var parent = MakeTask(listId, phase: PlanningPhase.Active); + await _tasks.AddAsync(parent); + + var child = await _tasks.CreateChildAsync(parent.Id, "child", null, null, maxTurns: 200); + + Assert.Equal(200, child.MaxTurns); + var loaded = await _tasks.GetByIdAsync(child.Id); + Assert.Equal(200, loaded!.MaxTurns); + } + + [Theory] + [InlineData(0)] + [InlineData(-5)] + public async Task CreateChildAsync_InvalidMaxTurns_Throws(int maxTurns) + { + var listId = await CreateListAsync(); + var parent = MakeTask(listId, phase: PlanningPhase.Active); + await _tasks.AddAsync(parent); + + await Assert.ThrowsAsync(() => + _tasks.CreateChildAsync(parent.Id, "child", null, null, maxTurns: maxTurns)); + } + [Fact] public async Task SetPlanningStartedAsync_IdleTask_TransitionsToActivePhase() {