From b1f4349dabfd3104788990c1212f163c380777b9 Mon Sep 17 00:00:00 2001 From: mika kuns Date: Mon, 1 Jun 2026 15:51:12 +0200 Subject: [PATCH] feat(worker): configurable max parallel task executions Add a "Max parallel executions" setting to the General settings tab so the queue can run more than one task concurrently. QueueService now tracks multiple active slots and reads the limit from app settings each cycle, so changes take effect without restarting the worker. Co-Authored-By: Claude Opus 4.7 --- .../AppSettingsEntityConfiguration.cs | 3 + ...33737_AddMaxParallelExecutions.Designer.cs | 607 ++++++++++++++++++ ...20260601133737_AddMaxParallelExecutions.cs | 36 ++ .../ClaudeDoDbContextModelSnapshot.cs | 7 + src/ClaudeDo.Data/Models/AppSettingsEntity.cs | 2 + .../Repositories/AppSettingsRepository.cs | 1 + src/ClaudeDo.Ui/Services/WorkerClient.cs | 1 + .../Settings/GeneralSettingsTabViewModel.cs | 3 + .../Modals/SettingsModalViewModel.cs | 2 + .../Views/Modals/SettingsModalView.axaml | 8 + src/ClaudeDo.Worker/Hub/WorkerHub.cs | 3 + src/ClaudeDo.Worker/Queue/QueueService.cs | 71 +- 12 files changed, 721 insertions(+), 23 deletions(-) create mode 100644 src/ClaudeDo.Data/Migrations/20260601133737_AddMaxParallelExecutions.Designer.cs create mode 100644 src/ClaudeDo.Data/Migrations/20260601133737_AddMaxParallelExecutions.cs diff --git a/src/ClaudeDo.Data/Configuration/AppSettingsEntityConfiguration.cs b/src/ClaudeDo.Data/Configuration/AppSettingsEntityConfiguration.cs index 42caac8..b9a0ed9 100644 --- a/src/ClaudeDo.Data/Configuration/AppSettingsEntityConfiguration.cs +++ b/src/ClaudeDo.Data/Configuration/AppSettingsEntityConfiguration.cs @@ -22,6 +22,9 @@ public class AppSettingsEntityConfiguration : IEntityTypeConfiguration s.DefaultPermissionMode) .HasColumnName("default_permission_mode").IsRequired().HasDefaultValue("bypassPermissions"); + builder.Property(s => s.MaxParallelExecutions) + .HasColumnName("max_parallel_executions").IsRequired().HasDefaultValue(1); + builder.Property(s => s.WorktreeStrategy) .HasColumnName("worktree_strategy").IsRequired().HasDefaultValue("sibling"); builder.Property(s => s.CentralWorktreeRoot) diff --git a/src/ClaudeDo.Data/Migrations/20260601133737_AddMaxParallelExecutions.Designer.cs b/src/ClaudeDo.Data/Migrations/20260601133737_AddMaxParallelExecutions.Designer.cs new file mode 100644 index 0000000..a1b12f6 --- /dev/null +++ b/src/ClaudeDo.Data/Migrations/20260601133737_AddMaxParallelExecutions.Designer.cs @@ -0,0 +1,607 @@ +// +using System; +using ClaudeDo.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace ClaudeDo.Data.Migrations +{ + [DbContext(typeof(ClaudeDoDbContext))] + [Migration("20260601133737_AddMaxParallelExecutions")] + partial class AddMaxParallelExecutions + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "8.0.11"); + + modelBuilder.Entity("ClaudeDo.Data.Models.AppSettingsEntity", b => + { + b.Property("Id") + .HasColumnType("INTEGER") + .HasColumnName("id"); + + b.Property("CentralWorktreeRoot") + .HasColumnType("TEXT") + .HasColumnName("central_worktree_root"); + + b.Property("DefaultClaudeInstructions") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("") + .HasColumnName("default_claude_instructions"); + + b.Property("DefaultMaxTurns") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(30) + .HasColumnName("default_max_turns"); + + b.Property("DefaultModel") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("sonnet") + .HasColumnName("default_model"); + + b.Property("DefaultPermissionMode") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("bypassPermissions") + .HasColumnName("default_permission_mode"); + + b.Property("MaxParallelExecutions") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(1) + .HasColumnName("max_parallel_executions"); + + b.Property("RepoImportFolders") + .HasColumnType("TEXT") + .HasColumnName("repo_import_folders"); + + b.Property("WorktreeAutoCleanupDays") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(7) + .HasColumnName("worktree_auto_cleanup_days"); + + b.Property("WorktreeAutoCleanupEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false) + .HasColumnName("worktree_auto_cleanup_enabled"); + + b.Property("WorktreeStrategy") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("sibling") + .HasColumnName("worktree_strategy"); + + b.HasKey("Id"); + + b.ToTable("app_settings", (string)null); + + b.HasData( + new + { + Id = 1, + DefaultClaudeInstructions = "", + DefaultMaxTurns = 100, + DefaultModel = "sonnet", + DefaultPermissionMode = "auto", + MaxParallelExecutions = 1, + WorktreeAutoCleanupDays = 7, + WorktreeAutoCleanupEnabled = false, + WorktreeStrategy = "sibling" + }); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b => + { + b.Property("ListId") + .HasColumnType("TEXT") + .HasColumnName("list_id"); + + b.Property("AgentPath") + .HasColumnType("TEXT") + .HasColumnName("agent_path"); + + b.Property("Model") + .HasColumnType("TEXT") + .HasColumnName("model"); + + b.Property("SystemPrompt") + .HasColumnType("TEXT") + .HasColumnName("system_prompt"); + + b.HasKey("ListId"); + + b.ToTable("list_config", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b => + { + b.Property("Id") + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("DefaultCommitType") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("chore") + .HasColumnName("default_commit_type"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property("SortOrder") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0) + .HasColumnName("sort_order"); + + b.Property("WorkingDir") + .HasColumnType("TEXT") + .HasColumnName("working_dir"); + + b.HasKey("Id"); + + b.HasIndex("SortOrder") + .HasDatabaseName("idx_lists_sort"); + + b.ToTable("lists", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.PrimeScheduleEntity", b => + { + b.Property("Id") + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("Enabled") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true) + .HasColumnName("enabled"); + + b.Property("EndDate") + .HasColumnType("TEXT") + .HasColumnName("end_date"); + + b.Property("LastRunAt") + .HasColumnType("TEXT") + .HasColumnName("last_run_at"); + + b.Property("PromptOverride") + .HasColumnType("TEXT") + .HasColumnName("prompt_override"); + + b.Property("StartDate") + .HasColumnType("TEXT") + .HasColumnName("start_date"); + + b.Property("TimeOfDay") + .HasColumnType("TEXT") + .HasColumnName("time_of_day"); + + b.Property("WorkdaysOnly") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true) + .HasColumnName("workdays_only"); + + b.HasKey("Id"); + + b.ToTable("prime_schedules", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b => + { + b.Property("Id") + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("Completed") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false) + .HasColumnName("completed"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("OrderNum") + .HasColumnType("INTEGER") + .HasColumnName("order_num"); + + b.Property("TaskId") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("task_id"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("title"); + + b.HasKey("Id"); + + b.HasIndex("TaskId") + .HasDatabaseName("idx_subtasks_task_id"); + + b.ToTable("subtasks", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b => + { + b.Property("Id") + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("AgentPath") + .HasColumnType("TEXT") + .HasColumnName("agent_path"); + + b.Property("BlockedByTaskId") + .HasColumnType("TEXT") + .HasColumnName("blocked_by_task_id"); + + b.Property("CommitType") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("chore") + .HasColumnName("commit_type"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .HasColumnType("TEXT") + .HasColumnName("created_by"); + + b.Property("Description") + .HasColumnType("TEXT") + .HasColumnName("description"); + + b.Property("FinishedAt") + .HasColumnType("TEXT") + .HasColumnName("finished_at"); + + b.Property("IsMyDay") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false) + .HasColumnName("is_my_day"); + + b.Property("IsStarred") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false) + .HasColumnName("is_starred"); + + b.Property("ListId") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("list_id"); + + b.Property("LogPath") + .HasColumnType("TEXT") + .HasColumnName("log_path"); + + b.Property("Model") + .HasColumnType("TEXT") + .HasColumnName("model"); + + b.Property("Notes") + .HasColumnType("TEXT") + .HasColumnName("notes"); + + b.Property("ParentTaskId") + .HasColumnType("TEXT") + .HasColumnName("parent_task_id"); + + b.Property("PlanningFinalizedAt") + .HasColumnType("TEXT") + .HasColumnName("planning_finalized_at"); + + b.Property("PlanningPhase") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("none") + .HasColumnName("planning_phase"); + + b.Property("PlanningSessionId") + .HasColumnType("TEXT") + .HasColumnName("planning_session_id"); + + b.Property("PlanningSessionToken") + .HasColumnType("TEXT") + .HasColumnName("planning_session_token"); + + b.Property("Result") + .HasColumnType("TEXT") + .HasColumnName("result"); + + b.Property("ScheduledFor") + .HasColumnType("TEXT") + .HasColumnName("scheduled_for"); + + b.Property("SortOrder") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0) + .HasColumnName("sort_order"); + + b.Property("StartedAt") + .HasColumnType("TEXT") + .HasColumnName("started_at"); + + b.Property("Status") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("status"); + + b.Property("SystemPrompt") + .HasColumnType("TEXT") + .HasColumnName("system_prompt"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("title"); + + b.HasKey("Id"); + + b.HasIndex("BlockedByTaskId") + .HasDatabaseName("idx_tasks_blocked_by"); + + b.HasIndex("ListId") + .HasDatabaseName("idx_tasks_list_id"); + + b.HasIndex("ParentTaskId") + .HasDatabaseName("idx_tasks_parent_task_id"); + + b.HasIndex("Status") + .HasDatabaseName("idx_tasks_status"); + + b.HasIndex("ListId", "SortOrder") + .HasDatabaseName("idx_tasks_list_sort"); + + b.ToTable("tasks", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b => + { + b.Property("Id") + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("ErrorMarkdown") + .HasColumnType("TEXT") + .HasColumnName("error_markdown"); + + b.Property("ExitCode") + .HasColumnType("INTEGER") + .HasColumnName("exit_code"); + + b.Property("FinishedAt") + .HasColumnType("TEXT") + .HasColumnName("finished_at"); + + b.Property("IsRetry") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false) + .HasColumnName("is_retry"); + + b.Property("LogPath") + .HasColumnType("TEXT") + .HasColumnName("log_path"); + + b.Property("Prompt") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("prompt"); + + b.Property("ResultMarkdown") + .HasColumnType("TEXT") + .HasColumnName("result_markdown"); + + b.Property("RunNumber") + .HasColumnType("INTEGER") + .HasColumnName("run_number"); + + b.Property("SessionId") + .HasColumnType("TEXT") + .HasColumnName("session_id"); + + b.Property("StartedAt") + .HasColumnType("TEXT") + .HasColumnName("started_at"); + + b.Property("StructuredOutputJson") + .HasColumnType("TEXT") + .HasColumnName("structured_output"); + + b.Property("TaskId") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("task_id"); + + b.Property("TokensIn") + .HasColumnType("INTEGER") + .HasColumnName("tokens_in"); + + b.Property("TokensOut") + .HasColumnType("INTEGER") + .HasColumnName("tokens_out"); + + b.Property("TurnCount") + .HasColumnType("INTEGER") + .HasColumnName("turn_count"); + + b.HasKey("Id"); + + b.HasIndex("TaskId") + .HasDatabaseName("idx_task_runs_task_id"); + + b.ToTable("task_runs", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b => + { + b.Property("TaskId") + .HasColumnType("TEXT") + .HasColumnName("task_id"); + + b.Property("BaseCommit") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("base_commit"); + + b.Property("BranchName") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("branch_name"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("DiffStat") + .HasColumnType("TEXT") + .HasColumnName("diff_stat"); + + b.Property("HeadCommit") + .HasColumnType("TEXT") + .HasColumnName("head_commit"); + + b.Property("Path") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("path"); + + b.Property("State") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("active") + .HasColumnName("state"); + + b.HasKey("TaskId"); + + b.ToTable("worktrees", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b => + { + b.HasOne("ClaudeDo.Data.Models.ListEntity", "List") + .WithOne("Config") + .HasForeignKey("ClaudeDo.Data.Models.ListConfigEntity", "ListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("List"); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b => + { + b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task") + .WithMany("Subtasks") + .HasForeignKey("TaskId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Task"); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b => + { + b.HasOne("ClaudeDo.Data.Models.TaskEntity", null) + .WithMany() + .HasForeignKey("BlockedByTaskId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("ClaudeDo.Data.Models.ListEntity", "List") + .WithMany("Tasks") + .HasForeignKey("ListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Parent") + .WithMany("Children") + .HasForeignKey("ParentTaskId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("List"); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b => + { + b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task") + .WithMany("Runs") + .HasForeignKey("TaskId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Task"); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b => + { + b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task") + .WithOne("Worktree") + .HasForeignKey("ClaudeDo.Data.Models.WorktreeEntity", "TaskId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Task"); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b => + { + b.Navigation("Config"); + + b.Navigation("Tasks"); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b => + { + b.Navigation("Children"); + + b.Navigation("Runs"); + + b.Navigation("Subtasks"); + + b.Navigation("Worktree"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/ClaudeDo.Data/Migrations/20260601133737_AddMaxParallelExecutions.cs b/src/ClaudeDo.Data/Migrations/20260601133737_AddMaxParallelExecutions.cs new file mode 100644 index 0000000..089844d --- /dev/null +++ b/src/ClaudeDo.Data/Migrations/20260601133737_AddMaxParallelExecutions.cs @@ -0,0 +1,36 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace ClaudeDo.Data.Migrations +{ + /// + public partial class AddMaxParallelExecutions : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "max_parallel_executions", + table: "app_settings", + type: "INTEGER", + nullable: false, + defaultValue: 1); + + migrationBuilder.UpdateData( + table: "app_settings", + keyColumn: "id", + keyValue: 1, + column: "max_parallel_executions", + value: 1); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "max_parallel_executions", + table: "app_settings"); + } + } +} diff --git a/src/ClaudeDo.Data/Migrations/ClaudeDoDbContextModelSnapshot.cs b/src/ClaudeDo.Data/Migrations/ClaudeDoDbContextModelSnapshot.cs index d88f9f9..0dbc6a3 100644 --- a/src/ClaudeDo.Data/Migrations/ClaudeDoDbContextModelSnapshot.cs +++ b/src/ClaudeDo.Data/Migrations/ClaudeDoDbContextModelSnapshot.cs @@ -54,6 +54,12 @@ namespace ClaudeDo.Data.Migrations .HasDefaultValue("bypassPermissions") .HasColumnName("default_permission_mode"); + b.Property("MaxParallelExecutions") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(1) + .HasColumnName("max_parallel_executions"); + b.Property("RepoImportFolders") .HasColumnType("TEXT") .HasColumnName("repo_import_folders"); @@ -89,6 +95,7 @@ namespace ClaudeDo.Data.Migrations DefaultMaxTurns = 100, DefaultModel = "sonnet", DefaultPermissionMode = "auto", + MaxParallelExecutions = 1, WorktreeAutoCleanupDays = 7, WorktreeAutoCleanupEnabled = false, WorktreeStrategy = "sibling" diff --git a/src/ClaudeDo.Data/Models/AppSettingsEntity.cs b/src/ClaudeDo.Data/Models/AppSettingsEntity.cs index 6587692..3a56713 100644 --- a/src/ClaudeDo.Data/Models/AppSettingsEntity.cs +++ b/src/ClaudeDo.Data/Models/AppSettingsEntity.cs @@ -11,6 +11,8 @@ public sealed class AppSettingsEntity public int DefaultMaxTurns { get; set; } = 100; public string DefaultPermissionMode { get; set; } = "auto"; + public int MaxParallelExecutions { get; set; } = 1; + public string WorktreeStrategy { get; set; } = "sibling"; public string? CentralWorktreeRoot { get; set; } public bool WorktreeAutoCleanupEnabled { get; set; } diff --git a/src/ClaudeDo.Data/Repositories/AppSettingsRepository.cs b/src/ClaudeDo.Data/Repositories/AppSettingsRepository.cs index 56ed935..ae22a5f 100644 --- a/src/ClaudeDo.Data/Repositories/AppSettingsRepository.cs +++ b/src/ClaudeDo.Data/Repositories/AppSettingsRepository.cs @@ -44,6 +44,7 @@ public sealed class AppSettingsRepository row.DefaultMaxTurns = updated.DefaultMaxTurns; row.DefaultPermissionMode = string.IsNullOrWhiteSpace(updated.DefaultPermissionMode) ? "auto" : updated.DefaultPermissionMode; + row.MaxParallelExecutions = updated.MaxParallelExecutions < 1 ? 1 : updated.MaxParallelExecutions; row.WorktreeStrategy = string.IsNullOrWhiteSpace(updated.WorktreeStrategy) ? "sibling" : updated.WorktreeStrategy; row.CentralWorktreeRoot = string.IsNullOrWhiteSpace(updated.CentralWorktreeRoot) ? null : updated.CentralWorktreeRoot; diff --git a/src/ClaudeDo.Ui/Services/WorkerClient.cs b/src/ClaudeDo.Ui/Services/WorkerClient.cs index 6d4fbe9..04d91a0 100644 --- a/src/ClaudeDo.Ui/Services/WorkerClient.cs +++ b/src/ClaudeDo.Ui/Services/WorkerClient.cs @@ -450,6 +450,7 @@ public sealed record AppSettingsDto( string DefaultModel, int DefaultMaxTurns, string DefaultPermissionMode, + int MaxParallelExecutions, string WorktreeStrategy, string? CentralWorktreeRoot, bool WorktreeAutoCleanupEnabled, diff --git a/src/ClaudeDo.Ui/ViewModels/Modals/Settings/GeneralSettingsTabViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Modals/Settings/GeneralSettingsTabViewModel.cs index 22d2536..72790da 100644 --- a/src/ClaudeDo.Ui/ViewModels/Modals/Settings/GeneralSettingsTabViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Modals/Settings/GeneralSettingsTabViewModel.cs @@ -9,6 +9,7 @@ public sealed partial class GeneralSettingsTabViewModel : ViewModelBase [ObservableProperty] private string _defaultModel = ModelRegistry.DefaultAlias; [ObservableProperty] private int _defaultMaxTurns = 100; [ObservableProperty] private string _defaultPermissionMode = PermissionModeRegistry.DefaultMode; + [ObservableProperty] private int _maxParallelExecutions = 1; public IReadOnlyList Models { get; } = ModelRegistry.Aliases; public IReadOnlyList PermissionModes { get; } = PermissionModeRegistry.Modes; @@ -17,6 +18,8 @@ public sealed partial class GeneralSettingsTabViewModel : ViewModelBase { if (DefaultMaxTurns < 1 || DefaultMaxTurns > 200) return "Max turns must be between 1 and 200."; + if (MaxParallelExecutions < 1 || MaxParallelExecutions > 20) + return "Max parallel executions must be between 1 and 20."; return null; } } diff --git a/src/ClaudeDo.Ui/ViewModels/Modals/SettingsModalViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Modals/SettingsModalViewModel.cs index e6d8253..9cde0c5 100644 --- a/src/ClaudeDo.Ui/ViewModels/Modals/SettingsModalViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Modals/SettingsModalViewModel.cs @@ -42,6 +42,7 @@ public sealed partial class SettingsModalViewModel : ViewModelBase General.DefaultModel = dto.DefaultModel ?? "sonnet"; General.DefaultMaxTurns = dto.DefaultMaxTurns; General.DefaultPermissionMode = dto.DefaultPermissionMode ?? "auto"; + General.MaxParallelExecutions = dto.MaxParallelExecutions; Worktrees.WorktreeStrategy = dto.WorktreeStrategy ?? "sibling"; Worktrees.CentralWorktreeRoot = dto.CentralWorktreeRoot; Worktrees.WorktreeAutoCleanupEnabled = dto.WorktreeAutoCleanupEnabled; @@ -69,6 +70,7 @@ public sealed partial class SettingsModalViewModel : ViewModelBase General.DefaultModel ?? "sonnet", General.DefaultMaxTurns, General.DefaultPermissionMode ?? "auto", + General.MaxParallelExecutions, Worktrees.WorktreeStrategy ?? "sibling", string.IsNullOrWhiteSpace(Worktrees.CentralWorktreeRoot) ? null : Worktrees.CentralWorktreeRoot, Worktrees.WorktreeAutoCleanupEnabled, diff --git a/src/ClaudeDo.Ui/Views/Modals/SettingsModalView.axaml b/src/ClaudeDo.Ui/Views/Modals/SettingsModalView.axaml index 5b2dcd7..8aaf31e 100644 --- a/src/ClaudeDo.Ui/Views/Modals/SettingsModalView.axaml +++ b/src/ClaudeDo.Ui/Views/Modals/SettingsModalView.axaml @@ -75,6 +75,14 @@ HorizontalAlignment="Stretch"/> + + + + + diff --git a/src/ClaudeDo.Worker/Hub/WorkerHub.cs b/src/ClaudeDo.Worker/Hub/WorkerHub.cs index f9acbc5..c5b14db 100644 --- a/src/ClaudeDo.Worker/Hub/WorkerHub.cs +++ b/src/ClaudeDo.Worker/Hub/WorkerHub.cs @@ -22,6 +22,7 @@ public record AppSettingsDto( string DefaultModel, int DefaultMaxTurns, string DefaultPermissionMode, + int MaxParallelExecutions, string WorktreeStrategy, string? CentralWorktreeRoot, bool WorktreeAutoCleanupEnabled, @@ -202,6 +203,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub row.DefaultModel, row.DefaultMaxTurns, row.DefaultPermissionMode, + row.MaxParallelExecutions, row.WorktreeStrategy, row.CentralWorktreeRoot, row.WorktreeAutoCleanupEnabled, @@ -219,6 +221,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub DefaultModel = dto.DefaultModel ?? ModelRegistry.DefaultAlias, DefaultMaxTurns = dto.DefaultMaxTurns, DefaultPermissionMode = dto.DefaultPermissionMode ?? PermissionModeRegistry.DefaultMode, + MaxParallelExecutions = dto.MaxParallelExecutions, WorktreeStrategy = dto.WorktreeStrategy ?? "sibling", CentralWorktreeRoot = dto.CentralWorktreeRoot, WorktreeAutoCleanupEnabled = dto.WorktreeAutoCleanupEnabled, diff --git a/src/ClaudeDo.Worker/Queue/QueueService.cs b/src/ClaudeDo.Worker/Queue/QueueService.cs index 55b035a..fd826be 100644 --- a/src/ClaudeDo.Worker/Queue/QueueService.cs +++ b/src/ClaudeDo.Worker/Queue/QueueService.cs @@ -18,7 +18,7 @@ public sealed class QueueService : BackgroundService private readonly OverrideSlotService _override; private readonly object _lock = new(); - private volatile QueueSlotState? _queueSlot; + private readonly Dictionary _queueSlots = new(); public QueueService( IDbContextFactory dbFactory, @@ -41,8 +41,11 @@ public sealed class QueueService : BackgroundService public IReadOnlyList<(string slot, string taskId, DateTime startedAt)> GetActive() { var list = new List<(string, string, DateTime)>(); - var q = _queueSlot; - if (q is not null) list.Add(("queue", q.TaskId, q.StartedAt)); + lock (_lock) + { + foreach (var slot in _queueSlots.Values) + list.Add(("queue", slot.TaskId, slot.StartedAt)); + } var o = _override.CurrentSlot; if (o is not null) list.Add(("override", o.TaskId, o.StartedAt)); return list; @@ -64,7 +67,7 @@ public sealed class QueueService : BackgroundService { lock (_lock) { - if (_queueSlot?.TaskId == taskId) + if (_queueSlots.ContainsKey(taskId)) throw new InvalidOperationException("task is already running in queue slot"); } } @@ -75,9 +78,9 @@ public sealed class QueueService : BackgroundService lock (_lock) { - if (_queueSlot is not null && _queueSlot.TaskId == taskId) + if (_queueSlots.TryGetValue(taskId, out var slot)) { - _queueSlot.Cts.Cancel(); + slot.Cts.Cancel(); return true; } } @@ -100,26 +103,33 @@ public sealed class QueueService : BackgroundService await Task.WhenAny(wakeTask, timerTask); - if (_queueSlot is not null) continue; + var maxParallel = await GetMaxParallelAsync(stoppingToken); - var task = await _picker.ClaimNextAsync(DateTime.UtcNow, stoppingToken); - if (task is null) continue; - - lock (_lock) + // Fill as many free slots as the limit allows. + while (!stoppingToken.IsCancellationRequested) { - if (_queueSlot is not null) continue; - - var cts = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken); - _queueSlot = new QueueSlotState { TaskId = task.Id, StartedAt = DateTime.UtcNow, Cts = cts }; - - _ = RunInSlotAsync(task.Id, cts.Token).ContinueWith(t => + lock (_lock) { - if (t.IsFaulted) - _logger.LogError(t.Exception, "RunInSlotAsync failed for task {TaskId} in queue slot", task.Id); - lock (_lock) { _queueSlot = null; } - cts.Dispose(); - _waker.Wake(); // Check for next task immediately. - }, TaskScheduler.Default); + if (_queueSlots.Count >= maxParallel) break; + } + + var task = await _picker.ClaimNextAsync(DateTime.UtcNow, stoppingToken); + if (task is null) break; + + lock (_lock) + { + var cts = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken); + _queueSlots[task.Id] = new QueueSlotState { TaskId = task.Id, StartedAt = DateTime.UtcNow, Cts = cts }; + + _ = RunInSlotAsync(task.Id, cts.Token).ContinueWith(t => + { + if (t.IsFaulted) + _logger.LogError(t.Exception, "RunInSlotAsync failed for task {TaskId} in queue slot", task.Id); + lock (_lock) { _queueSlots.Remove(task.Id); } + cts.Dispose(); + _waker.Wake(); // Check for next task immediately. + }, TaskScheduler.Default); + } } } catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) @@ -135,6 +145,21 @@ public sealed class QueueService : BackgroundService _logger.LogInformation("QueueService stopping"); } + private async Task GetMaxParallelAsync(CancellationToken ct) + { + try + { + using var context = _dbFactory.CreateDbContext(); + var settings = await new AppSettingsRepository(context).GetAsync(ct); + return Math.Max(1, settings.MaxParallelExecutions); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to read max parallel executions; defaulting to 1"); + return 1; + } + } + private async Task RunInSlotAsync(string taskId, CancellationToken ct) { try