diff --git a/src/ClaudeDo.Data/Configuration/AppSettingsEntityConfiguration.cs b/src/ClaudeDo.Data/Configuration/AppSettingsEntityConfiguration.cs index 0fb606e9..02a82f62 100644 --- a/src/ClaudeDo.Data/Configuration/AppSettingsEntityConfiguration.cs +++ b/src/ClaudeDo.Data/Configuration/AppSettingsEntityConfiguration.cs @@ -67,6 +67,9 @@ public class AppSettingsEntityConfiguration : IEntityTypeConfiguration s.UsageThrottleSevenDayHardPct) .HasColumnName("usage_throttle_seven_day_hard_pct").IsRequired().HasDefaultValue(65); + builder.Property(s => s.AutoContinueOnUsageLimit) + .HasColumnName("auto_continue_on_usage_limit").IsRequired().HasDefaultValue(false); + builder.HasData(new AppSettingsEntity { Id = AppSettingsEntity.SingletonId }); } } diff --git a/src/ClaudeDo.Data/Configuration/TaskEntityConfiguration.cs b/src/ClaudeDo.Data/Configuration/TaskEntityConfiguration.cs index 87374c1d..64e79d9f 100644 --- a/src/ClaudeDo.Data/Configuration/TaskEntityConfiguration.cs +++ b/src/ClaudeDo.Data/Configuration/TaskEntityConfiguration.cs @@ -83,6 +83,7 @@ public class TaskEntityConfiguration : IEntityTypeConfiguration builder.Property(t => t.FailureReason).HasColumnName("failure_reason"); builder.Property(t => t.FailureTurnsUsed).HasColumnName("failure_turns_used"); builder.Property(t => t.FailureMaxTurns).HasColumnName("failure_max_turns"); + builder.Property(t => t.UsageLimitAutoContinuedAt).HasColumnName("usage_limit_auto_continued_at"); builder.Property(t => t.LogPath).HasColumnName("log_path"); builder.Property(t => t.CreatedAt).HasColumnName("created_at").IsRequired(); builder.Property(t => t.StartedAt).HasColumnName("started_at"); diff --git a/src/ClaudeDo.Data/Migrations/20260821155619_AddUsageLimitAutoContinue.Designer.cs b/src/ClaudeDo.Data/Migrations/20260821155619_AddUsageLimitAutoContinue.Designer.cs new file mode 100644 index 00000000..578bd86a --- /dev/null +++ b/src/ClaudeDo.Data/Migrations/20260821155619_AddUsageLimitAutoContinue.Designer.cs @@ -0,0 +1,961 @@ +// +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("20260821155619_AddUsageLimitAutoContinue")] + partial class AddUsageLimitAutoContinue + { + /// + 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("AutoContinueOnUsageLimit") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false) + .HasColumnName("auto_continue_on_usage_limit"); + + b.Property("CentralWorktreeRoot") + .HasColumnType("TEXT") + .HasColumnName("central_worktree_root"); + + b.Property("DailyPrepMaxTasks") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(5) + .HasColumnName("daily_prep_max_tasks"); + + b.Property("DefaultClaudeInstructions") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("") + .HasColumnName("default_claude_instructions"); + + b.Property("DefaultMaxTurns") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(40) + .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("MaxTurnsCeiling") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(80) + .HasColumnName("max_turns_ceiling"); + + b.Property("ModelPresets") + .HasColumnType("TEXT") + .HasColumnName("model_presets"); + + b.Property("NextTaskNumber") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(1) + .HasColumnName("next_task_number"); + + b.Property("RepoImportFolders") + .HasColumnType("TEXT") + .HasColumnName("repo_import_folders"); + + b.Property("ReportExcludedPaths") + .HasColumnType("TEXT") + .HasColumnName("report_excluded_paths"); + + b.Property("SessionSkills") + .HasColumnType("TEXT") + .HasColumnName("session_skills"); + + b.Property("StandupWeekday") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(3) + .HasColumnName("standup_weekday"); + + b.Property("UsageGateFiveHourPct") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(80) + .HasColumnName("usage_gate_five_hour_pct"); + + b.Property("UsageGateSevenDayPct") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(90) + .HasColumnName("usage_gate_seven_day_pct"); + + b.Property("UsageThrottleFiveHourHardPct") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(65) + .HasColumnName("usage_throttle_five_hour_hard_pct"); + + b.Property("UsageThrottleFiveHourSoftPct") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(50) + .HasColumnName("usage_throttle_five_hour_soft_pct"); + + b.Property("UsageThrottleSevenDayHardPct") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(65) + .HasColumnName("usage_throttle_seven_day_hard_pct"); + + b.Property("UsageThrottleSevenDaySoftPct") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(50) + .HasColumnName("usage_throttle_seven_day_soft_pct"); + + 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, + AutoContinueOnUsageLimit = false, + DailyPrepMaxTasks = 5, + DefaultClaudeInstructions = "", + DefaultMaxTurns = 40, + DefaultModel = "sonnet", + DefaultPermissionMode = "auto", + MaxParallelExecutions = 1, + MaxTurnsCeiling = 80, + NextTaskNumber = 1, + StandupWeekday = 3, + UsageGateFiveHourPct = 80, + UsageGateSevenDayPct = 90, + UsageThrottleFiveHourHardPct = 65, + UsageThrottleFiveHourSoftPct = 50, + UsageThrottleSevenDayHardPct = 65, + UsageThrottleSevenDaySoftPct = 50, + WorktreeAutoCleanupDays = 7, + WorktreeAutoCleanupEnabled = false, + WorktreeStrategy = "sibling" + }); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.DailyNoteEntity", b => + { + b.Property("Id") + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("Date") + .HasColumnType("TEXT") + .HasColumnName("note_date"); + + b.Property("SortOrder") + .HasColumnType("INTEGER") + .HasColumnName("sort_order"); + + b.Property("Text") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("text"); + + b.HasKey("Id"); + + b.HasIndex("Date"); + + b.ToTable("daily_notes", (string)null); + }); + + 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("MaxTurns") + .HasColumnType("INTEGER") + .HasColumnName("max_turns"); + + b.Property("Model") + .HasColumnType("TEXT") + .HasColumnName("model"); + + b.Property("SerializeOnFileOverlap") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false) + .HasColumnName("serialize_on_file_overlap"); + + b.Property("SessionSkills") + .HasColumnType("TEXT") + .HasColumnName("session_skills"); + + b.Property("SystemPrompt") + .HasColumnType("TEXT") + .HasColumnName("system_prompt"); + + b.Property("VerifyCommand") + .HasColumnType("TEXT") + .HasColumnName("verify_command"); + + 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("FindingsTracked") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false) + .HasColumnName("findings_tracked"); + + b.Property("IsManual") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false) + .HasColumnName("is_manual"); + + 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("Days") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(31) + .HasColumnName("days_of_week"); + + b.Property("Enabled") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true) + .HasColumnName("enabled"); + + b.Property("LastRunAt") + .HasColumnType("TEXT") + .HasColumnName("last_run_at"); + + b.Property("PromptOverride") + .HasColumnType("TEXT") + .HasColumnName("prompt_override"); + + b.Property("TimeOfDay") + .HasColumnType("TEXT") + .HasColumnName("time_of_day"); + + b.HasKey("Id"); + + b.ToTable("prime_schedules", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.SessionSkillEntity", b => + { + b.Property("Name") + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property("AddedAt") + .HasColumnType("TEXT") + .HasColumnName("added_at"); + + b.Property("Description") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("description"); + + b.Property("PinnedRef") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("pinned_ref"); + + b.Property("SourceUrl") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("source_url"); + + b.Property("Subpath") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("subpath"); + + b.HasKey("Name"); + + b.ToTable("session_skills", (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.TaskAttachmentEntity", b => + { + b.Property("Id") + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("ByteSize") + .HasColumnType("INTEGER") + .HasColumnName("byte_size"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("FileName") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("file_name"); + + b.Property("TaskId") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("task_id"); + + b.HasKey("Id"); + + b.HasIndex("TaskId") + .HasDatabaseName("idx_task_attachments_task_id"); + + b.ToTable("task_attachments", (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("DependsOnTaskId") + .HasColumnType("TEXT") + .HasColumnName("depends_on_task_id"); + + b.Property("Description") + .HasColumnType("TEXT") + .HasColumnName("description"); + + b.Property("FailureMaxTurns") + .HasColumnType("INTEGER") + .HasColumnName("failure_max_turns"); + + b.Property("FailureReason") + .HasColumnType("TEXT") + .HasColumnName("failure_reason"); + + b.Property("FailureTurnsUsed") + .HasColumnType("INTEGER") + .HasColumnName("failure_turns_used"); + + b.Property("FinishedAt") + .HasColumnType("TEXT") + .HasColumnName("finished_at"); + + b.Property("HandlerBaseCommit") + .HasColumnType("TEXT") + .HasColumnName("handler_base_commit"); + + b.Property("HandlerHeadCommit") + .HasColumnType("TEXT") + .HasColumnName("handler_head_commit"); + + b.Property("InteractiveSessionId") + .HasColumnType("TEXT") + .HasColumnName("interactive_session_id"); + + b.Property("IsManual") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false) + .HasColumnName("is_manual"); + + 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("MaxTurns") + .HasColumnType("INTEGER") + .HasColumnName("max_turns"); + + b.Property("Model") + .HasColumnType("TEXT") + .HasColumnName("model"); + + b.Property("Notes") + .HasColumnType("TEXT") + .HasColumnName("notes"); + + b.Property("Number") + .HasColumnType("INTEGER") + .HasColumnName("number"); + + 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("ReviewFeedback") + .HasColumnType("TEXT") + .HasColumnName("review_feedback"); + + b.Property("RoadblockCount") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0) + .HasColumnName("roadblock_count"); + + b.Property("ScheduledFor") + .HasColumnType("TEXT") + .HasColumnName("scheduled_for"); + + b.Property("ScopeGlobs") + .HasColumnType("TEXT") + .HasColumnName("scope_globs"); + + b.Property("SessionSkills") + .HasColumnType("TEXT") + .HasColumnName("session_skills"); + + 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.Property("UsageLimitAutoContinuedAt") + .HasColumnType("TEXT") + .HasColumnName("usage_limit_auto_continued_at"); + + b.HasKey("Id"); + + b.HasIndex("BlockedByTaskId") + .HasDatabaseName("idx_tasks_blocked_by"); + + b.HasIndex("DependsOnTaskId") + .HasDatabaseName("idx_tasks_depends_on"); + + b.HasIndex("ListId") + .HasDatabaseName("idx_tasks_list_id"); + + b.HasIndex("Number") + .IsUnique() + .HasDatabaseName("idx_tasks_number"); + + 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("CacheReadTokens") + .HasColumnType("INTEGER") + .HasColumnName("cache_read_tokens"); + + b.Property("CacheWriteTokens") + .HasColumnType("INTEGER") + .HasColumnName("cache_write_tokens"); + + b.Property("ErrorMarkdown") + .HasColumnType("TEXT") + .HasColumnName("error_markdown"); + + b.Property("Errors") + .HasColumnType("TEXT") + .HasColumnName("errors"); + + 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("Model") + .HasColumnType("TEXT") + .HasColumnName("model"); + + b.Property("Prompt") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("prompt"); + + b.Property("ResultMarkdown") + .HasColumnType("TEXT") + .HasColumnName("result_markdown"); + + b.Property("ResultSubtype") + .HasColumnType("TEXT") + .HasColumnName("result_subtype"); + + 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("TerminalReason") + .HasColumnType("TEXT") + .HasColumnName("terminal_reason"); + + 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.WeekReportEntity", b => + { + b.Property("Id") + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("EndDate") + .HasColumnType("TEXT") + .HasColumnName("end_date"); + + b.Property("GeneratedAt") + .HasColumnType("TEXT") + .HasColumnName("generated_at"); + + b.Property("Markdown") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("markdown"); + + b.Property("StartDate") + .HasColumnType("TEXT") + .HasColumnName("start_date"); + + b.HasKey("Id"); + + b.HasIndex("StartDate", "EndDate") + .IsUnique(); + + b.ToTable("week_reports", (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("MergeCommit") + .HasColumnType("TEXT") + .HasColumnName("merge_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.TaskAttachmentEntity", b => + { + b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task") + .WithMany() + .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.TaskEntity", null) + .WithMany() + .HasForeignKey("DependsOnTaskId") + .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/20260821155619_AddUsageLimitAutoContinue.cs b/src/ClaudeDo.Data/Migrations/20260821155619_AddUsageLimitAutoContinue.cs new file mode 100644 index 00000000..fe7d59d4 --- /dev/null +++ b/src/ClaudeDo.Data/Migrations/20260821155619_AddUsageLimitAutoContinue.cs @@ -0,0 +1,40 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace ClaudeDo.Data.Migrations +{ + /// + public partial class AddUsageLimitAutoContinue : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "usage_limit_auto_continued_at", + table: "tasks", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "auto_continue_on_usage_limit", + table: "app_settings", + type: "INTEGER", + nullable: false, + defaultValue: false); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "usage_limit_auto_continued_at", + table: "tasks"); + + migrationBuilder.DropColumn( + name: "auto_continue_on_usage_limit", + table: "app_settings"); + } + } +} diff --git a/src/ClaudeDo.Data/Migrations/ClaudeDoDbContextModelSnapshot.cs b/src/ClaudeDo.Data/Migrations/ClaudeDoDbContextModelSnapshot.cs index 79216225..43e03444 100644 --- a/src/ClaudeDo.Data/Migrations/ClaudeDoDbContextModelSnapshot.cs +++ b/src/ClaudeDo.Data/Migrations/ClaudeDoDbContextModelSnapshot.cs @@ -23,6 +23,12 @@ namespace ClaudeDo.Data.Migrations .HasColumnType("INTEGER") .HasColumnName("id"); + b.Property("AutoContinueOnUsageLimit") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false) + .HasColumnName("auto_continue_on_usage_limit"); + b.Property("CentralWorktreeRoot") .HasColumnType("TEXT") .HasColumnName("central_worktree_root"); @@ -163,6 +169,7 @@ namespace ClaudeDo.Data.Migrations new { Id = 1, + AutoContinueOnUsageLimit = false, DailyPrepMaxTasks = 5, DefaultClaudeInstructions = "", DefaultMaxTurns = 40, @@ -632,6 +639,10 @@ namespace ClaudeDo.Data.Migrations .HasColumnType("TEXT") .HasColumnName("title"); + b.Property("UsageLimitAutoContinuedAt") + .HasColumnType("TEXT") + .HasColumnName("usage_limit_auto_continued_at"); + b.HasKey("Id"); b.HasIndex("BlockedByTaskId") diff --git a/src/ClaudeDo.Data/Models/AppSettingsEntity.cs b/src/ClaudeDo.Data/Models/AppSettingsEntity.cs index 12dc4a0b..bd55464c 100644 --- a/src/ClaudeDo.Data/Models/AppSettingsEntity.cs +++ b/src/ClaudeDo.Data/Models/AppSettingsEntity.cs @@ -55,4 +55,9 @@ public sealed class AppSettingsEntity public int UsageThrottleFiveHourHardPct { get; set; } = 65; public int UsageThrottleSevenDaySoftPct { get; set; } = 50; public int UsageThrottleSevenDayHardPct { get; set; } = 65; + + // Off by default. When on: a task that failed on the Anthropic usage limit gets exactly one + // automatic ContinueTask once the 5h window resets, and the gate-blocked queue wakes exactly + // at that reset time instead of only on the 30s backstop. + public bool AutoContinueOnUsageLimit { get; set; } } diff --git a/src/ClaudeDo.Data/Models/TaskEntity.cs b/src/ClaudeDo.Data/Models/TaskEntity.cs index ded967f7..80e53f0a 100644 --- a/src/ClaudeDo.Data/Models/TaskEntity.cs +++ b/src/ClaudeDo.Data/Models/TaskEntity.cs @@ -49,6 +49,10 @@ public sealed class TaskEntity public string? FailureReason { get; set; } public int? FailureTurnsUsed { get; set; } public int? FailureMaxTurns { get; set; } + // Set once QueueService's UsageLimitAutoContinueCoordinator fires ContinueTask for a + // FailureReason=="usage_limit" task. Guards against firing it a second time for the same + // task — never reset automatically, so auto-continue never loops on a repeat limit hit. + public DateTime? UsageLimitAutoContinuedAt { get; set; } public string? LogPath { get; set; } public required DateTime CreatedAt { get; init; } public DateTime? StartedAt { get; set; } diff --git a/src/ClaudeDo.Data/Repositories/AppSettingsRepository.cs b/src/ClaudeDo.Data/Repositories/AppSettingsRepository.cs index e35bb1ca..2c2326ad 100644 --- a/src/ClaudeDo.Data/Repositories/AppSettingsRepository.cs +++ b/src/ClaudeDo.Data/Repositories/AppSettingsRepository.cs @@ -92,6 +92,7 @@ public sealed class AppSettingsRepository row.UsageThrottleFiveHourHardPct = Math.Clamp(updated.UsageThrottleFiveHourHardPct, 0, 100); row.UsageThrottleSevenDaySoftPct = Math.Clamp(updated.UsageThrottleSevenDaySoftPct, 0, 100); row.UsageThrottleSevenDayHardPct = Math.Clamp(updated.UsageThrottleSevenDayHardPct, 0, 100); + row.AutoContinueOnUsageLimit = updated.AutoContinueOnUsageLimit; await _context.SaveChangesAsync(ct); } diff --git a/src/ClaudeDo.Data/Repositories/TaskRepository.cs b/src/ClaudeDo.Data/Repositories/TaskRepository.cs index c3d52c11..6ad5d575 100644 --- a/src/ClaudeDo.Data/Repositories/TaskRepository.cs +++ b/src/ClaudeDo.Data/Repositories/TaskRepository.cs @@ -214,6 +214,27 @@ public sealed class TaskRepository .ExecuteUpdateAsync(s => s.SetProperty(t => t.RoadblockCount, count), ct); } + /// Tasks eligible for UsageLimitAutoContinueCoordinator: failed on the usage limit and never + /// auto-continued before (that flag is never reset, so a task is only offered this once). + public async Task> GetUsageLimitAutoContinueCandidatesAsync(CancellationToken ct = default) + { + return await _context.Tasks + .Where(t => t.Status == TaskStatus.Failed + && t.FailureReason == "usage_limit" + && t.UsageLimitAutoContinuedAt == null) + .ToListAsync(ct); + } + + /// Atomically claims the auto-continue slot for one task. Returns false if it was already + /// claimed (e.g. by an earlier tick) — the caller must not fire ContinueTask in that case. + public async Task TryClaimUsageLimitAutoContinueAsync(string taskId, DateTime firedAtUtc, CancellationToken ct = default) + { + var affected = await _context.Tasks + .Where(t => t.Id == taskId && t.UsageLimitAutoContinuedAt == null) + .ExecuteUpdateAsync(s => s.SetProperty(t => t.UsageLimitAutoContinuedAt, firedAtUtc), ct); + return affected > 0; + } + internal async Task FlipAllRunningToFailedAsync(string reason, CancellationToken ct = default) { var resultText = "[stale] " + reason; diff --git a/src/ClaudeDo.Localization/locales/de.json b/src/ClaudeDo.Localization/locales/de.json index 3031f6bd..3e2096c1 100644 --- a/src/ClaudeDo.Localization/locales/de.json +++ b/src/ClaudeDo.Localization/locales/de.json @@ -43,6 +43,8 @@ "usageGateHint": "Ab dieser Auslastung holt die Queue keine neuen Tasks mehr; laufende Tasks laufen zu Ende. 0 schaltet den Stopp ab.", "usageGateFiveHourPct": "5-Stunden-Fenster (%)", "usageGateSevenDayPct": "7-Tage-Fenster (%)", + "autoContinueOnUsageLimit": "Nach Limit-Reset fortsetzen", + "autoContinueOnUsageLimitHint": "Ist ein Run am Nutzungslimit abgebrochen, wird er nach Ablauf des 5-Stunden-Fensters automatisch fortgesetzt. Weckt außerdem die pausierte Queue genau zum Reset-Zeitpunkt statt erst beim nächsten Backstop-Check.", "reportExcludedPaths": "Bericht: ausgeschlossene Pfade (einer pro Zeile)", "standupWeekday": "Standup-Wochentag", "weekdaySunday": "Sonntag", diff --git a/src/ClaudeDo.Localization/locales/en.json b/src/ClaudeDo.Localization/locales/en.json index ca29d581..6d1b8c12 100644 --- a/src/ClaudeDo.Localization/locales/en.json +++ b/src/ClaudeDo.Localization/locales/en.json @@ -43,6 +43,8 @@ "usageGateHint": "Once usage reaches this level, the queue stops picking up new tasks; running tasks finish normally. 0 turns the stop off.", "usageGateFiveHourPct": "5-hour window (%)", "usageGateSevenDayPct": "7-day window (%)", + "autoContinueOnUsageLimit": "Continue on session limit reset", + "autoContinueOnUsageLimitHint": "When a run stops because it hit the usage limit, automatically continue it once the 5-hour window resets. Also wakes the paused queue exactly at reset time instead of waiting for the next backstop check.", "reportExcludedPaths": "Report: excluded paths (one per line)", "standupWeekday": "Standup weekday", "weekdaySunday": "Sunday", diff --git a/src/ClaudeDo.Ui/Services/WorkerClient.cs b/src/ClaudeDo.Ui/Services/WorkerClient.cs index 2c5482bf..a5f8a85c 100644 --- a/src/ClaudeDo.Ui/Services/WorkerClient.cs +++ b/src/ClaudeDo.Ui/Services/WorkerClient.cs @@ -732,7 +732,8 @@ public sealed record AppSettingsDto( int UsageThrottleFiveHourSoftPct = 50, int UsageThrottleFiveHourHardPct = 65, int UsageThrottleSevenDaySoftPct = 50, - int UsageThrottleSevenDayHardPct = 65); + int UsageThrottleSevenDayHardPct = 65, + bool AutoContinueOnUsageLimit = false); // Per-model run defaults (effort + turn budget) edited in Settings → General. public sealed record ModelPresetDto(string Model, string Effort, int MaxTurns); diff --git a/src/ClaudeDo.Ui/ViewModels/Modals/Settings/GeneralSettingsTabViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Modals/Settings/GeneralSettingsTabViewModel.cs index 75a8fdfd..e9af7a6b 100644 --- a/src/ClaudeDo.Ui/ViewModels/Modals/Settings/GeneralSettingsTabViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Modals/Settings/GeneralSettingsTabViewModel.cs @@ -24,6 +24,9 @@ public sealed partial class GeneralSettingsTabViewModel : ViewModelBase // Percentage of the 5h/7d Claude usage window at which the autonomous queue pauses. 0 = gate off. [ObservableProperty] private int _usageGateFiveHourPct = 80; [ObservableProperty] private int _usageGateSevenDayPct = 90; + // Off by default: auto-continues a usage-limit-failed task once the 5h window resets, and + // wakes a gate-blocked queue exactly at that reset time instead of only the 30s backstop. + [ObservableProperty] private bool _autoContinueOnUsageLimit; // Newline-separated path prefixes excluded from the weekly report. [ObservableProperty] private string _reportExcludedPaths = @"C:\Private"; // 0=Sunday..6=Saturday (System.DayOfWeek); default Wednesday. diff --git a/src/ClaudeDo.Ui/ViewModels/Modals/SettingsModalViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Modals/SettingsModalViewModel.cs index 273592ce..d65cf16c 100644 --- a/src/ClaudeDo.Ui/ViewModels/Modals/SettingsModalViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Modals/SettingsModalViewModel.cs @@ -129,6 +129,7 @@ public sealed partial class SettingsModalViewModel : ViewModelBase General.MaxParallelExecutions = dto.MaxParallelExecutions; General.UsageGateFiveHourPct = dto.UsageGateFiveHourPct; General.UsageGateSevenDayPct = dto.UsageGateSevenDayPct; + General.AutoContinueOnUsageLimit = dto.AutoContinueOnUsageLimit; _throttleStages = ( dto.UsageThrottleFiveHourSoftPct, dto.UsageThrottleFiveHourHardPct, dto.UsageThrottleSevenDaySoftPct, dto.UsageThrottleSevenDayHardPct); @@ -189,7 +190,8 @@ public sealed partial class SettingsModalViewModel : ViewModelBase _throttleStages.FiveSoft, _throttleStages.FiveHard, _throttleStages.SevenSoft, - _throttleStages.SevenHard); + _throttleStages.SevenHard, + General.AutoContinueOnUsageLimit); await _worker.UpdateAppSettingsAsync(dto); await Prime.SaveAsync(); await OnlineInbox.SaveAsync(); diff --git a/src/ClaudeDo.Ui/Views/Modals/SettingsModalView.axaml b/src/ClaudeDo.Ui/Views/Modals/SettingsModalView.axaml index 1313c205..8cbb96de 100644 --- a/src/ClaudeDo.Ui/Views/Modals/SettingsModalView.axaml +++ b/src/ClaudeDo.Ui/Views/Modals/SettingsModalView.axaml @@ -237,6 +237,10 @@ HorizontalAlignment="Left" Width="140"/> + + diff --git a/src/ClaudeDo.Worker/Hub/WorkerHub.cs b/src/ClaudeDo.Worker/Hub/WorkerHub.cs index 96be513c..dfaed606 100644 --- a/src/ClaudeDo.Worker/Hub/WorkerHub.cs +++ b/src/ClaudeDo.Worker/Hub/WorkerHub.cs @@ -55,7 +55,8 @@ public record AppSettingsDto( int UsageThrottleFiveHourSoftPct = 50, int UsageThrottleFiveHourHardPct = 65, int UsageThrottleSevenDaySoftPct = 50, - int UsageThrottleSevenDayHardPct = 65); + int UsageThrottleSevenDayHardPct = 65, + bool AutoContinueOnUsageLimit = false); // Per-model run defaults (effort + turn budget) edited in Settings -> General. public record ModelPresetDto(string Model, string Effort, int MaxTurns); @@ -471,7 +472,8 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub row.UsageThrottleFiveHourSoftPct, row.UsageThrottleFiveHourHardPct, row.UsageThrottleSevenDaySoftPct, - row.UsageThrottleSevenDayHardPct); + row.UsageThrottleSevenDayHardPct, + row.AutoContinueOnUsageLimit); } public async Task UpdateAppSettings(AppSettingsDto dto) @@ -506,6 +508,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub UsageThrottleFiveHourHardPct = dto.UsageThrottleFiveHourHardPct, UsageThrottleSevenDaySoftPct = dto.UsageThrottleSevenDaySoftPct, UsageThrottleSevenDayHardPct = dto.UsageThrottleSevenDayHardPct, + AutoContinueOnUsageLimit = dto.AutoContinueOnUsageLimit, }); } diff --git a/src/ClaudeDo.Worker/Program.cs b/src/ClaudeDo.Worker/Program.cs index 17685a00..2334fbc9 100644 --- a/src/ClaudeDo.Worker/Program.cs +++ b/src/ClaudeDo.Worker/Program.cs @@ -156,6 +156,9 @@ builder.Services.AddHostedService(); builder.Services.AddSingleton(); builder.Services.AddSingleton(sp => sp.GetRequiredService()); +// "Continue on session limit reset" toggle — depends on UsageState, registered below. +builder.Services.AddSingleton(); + // QueueService: singleton + hosted service (same instance). builder.Services.AddSingleton(); builder.Services.AddHostedService(sp => sp.GetRequiredService()); diff --git a/src/ClaudeDo.Worker/Queue/QueueService.cs b/src/ClaudeDo.Worker/Queue/QueueService.cs index 8d993b6e..b262a6e0 100644 --- a/src/ClaudeDo.Worker/Queue/QueueService.cs +++ b/src/ClaudeDo.Worker/Queue/QueueService.cs @@ -26,11 +26,13 @@ public sealed class QueueService : BackgroundService private readonly IUsageGate _usageGate; private readonly UsageState _usageState; private readonly HubBroadcaster _broadcaster; + private readonly UsageLimitAutoContinueCoordinator _usageLimitAutoContinue; private readonly object _lock = new(); private readonly Dictionary _queueSlots = new(); private bool _usageGateBlocked; private int? _lastEffectiveSlots; + private DateTimeOffset? _scheduledResetWakeAt; public QueueService( IDbContextFactory dbFactory, @@ -44,7 +46,8 @@ public sealed class QueueService : BackgroundService RunCancellationRegistry runCancels, IUsageGate usageGate, UsageState usageState, - HubBroadcaster broadcaster) + HubBroadcaster broadcaster, + UsageLimitAutoContinueCoordinator usageLimitAutoContinue) { _dbFactory = dbFactory; _runner = runner; @@ -58,6 +61,7 @@ public sealed class QueueService : BackgroundService _usageGate = usageGate; _usageState = usageState; _broadcaster = broadcaster; + _usageLimitAutoContinue = usageLimitAutoContinue; } public IReadOnlyList<(string slot, string taskId, DateTime startedAt)> GetActive() @@ -130,6 +134,14 @@ public sealed class QueueService : BackgroundService var gateDecision = await _usageGate.EvaluateAsync(stoppingToken); await ReportUsageGateTransitionAsync(gateDecision); + // "Continue on session limit reset" toggle: schedule an exact wake for the reset + // time (instead of only relying on the 30s backstop above) and fire any pending + // auto-continues. No-ops entirely while the toggle is off or no reset time is known. + var scheduledWakeAt = await _usageLimitAutoContinue.GetScheduledWakeAtAsync(stoppingToken); + if (scheduledWakeAt is not null) + ScheduleResetWake(scheduledWakeAt.Value, stoppingToken); + await _usageLimitAutoContinue.RunAsync(stoppingToken); + // Only queue refill is gated. Runs already in flight (RunNow, ContinueTask, // interactive sessions, planning, daily prep) keep going regardless. if (!gateDecision.IsBlocked) @@ -210,6 +222,28 @@ public sealed class QueueService : BackgroundService return false; } + // Wakes the queue loop exactly at the usage-limit reset time instead of leaving it to the + // (up to 30s late) backstop — schedules once per distinct target and is a no-op for a target + // already scheduled or already in the past (this tick's own check already covers "now"). + private void ScheduleResetWake(DateTimeOffset resetsAt, CancellationToken ct) + { + if (_scheduledResetWakeAt == resetsAt) return; + _scheduledResetWakeAt = resetsAt; + + var delay = resetsAt - DateTimeOffset.UtcNow; + if (delay <= TimeSpan.Zero) return; + + _ = Task.Run(async () => + { + try + { + await Task.Delay(delay, ct); + _waker.Wake(); + } + catch (OperationCanceledException) { } + }, ct); + } + private async Task ReportUsageGateTransitionAsync(UsageGateDecision decision) { if (decision.IsBlocked == _usageGateBlocked) return; diff --git a/src/ClaudeDo.Worker/Queue/UsageLimitAutoContinueCoordinator.cs b/src/ClaudeDo.Worker/Queue/UsageLimitAutoContinueCoordinator.cs new file mode 100644 index 00000000..041d19a0 --- /dev/null +++ b/src/ClaudeDo.Worker/Queue/UsageLimitAutoContinueCoordinator.cs @@ -0,0 +1,99 @@ +using ClaudeDo.Data; +using ClaudeDo.Data.Models; +using ClaudeDo.Data.Repositories; +using ClaudeDo.Worker.Hub; +using ClaudeDo.Worker.Usage; +using Microsoft.EntityFrameworkCore; + +namespace ClaudeDo.Worker.Queue; + +/// +/// The "Continue on session limit reset" toggle (). +/// Off by default and fails open on every missing signal — no snapshot, no reset time — never a +/// timer, never a crash, never a change from today's behaviour. Called once per +/// tick (both from its wake and its 30s backstop): +/// tells the caller when to schedule an exact wake so a +/// gate-blocked queue doesn't wait out the backstop, and fires exactly one +/// ContinueTask per eligible task once the window has actually reset. +/// +public sealed class UsageLimitAutoContinueCoordinator +{ + public const string ContinuePrompt = + "The Anthropic usage limit window that stopped this run has since reset. Continue the task from where the previous run left off."; + + private readonly IDbContextFactory _dbFactory; + private readonly UsageState _usageState; + private readonly OverrideSlotService _override; + private readonly HubBroadcaster _broadcaster; + private readonly ILogger _logger; + + public UsageLimitAutoContinueCoordinator( + IDbContextFactory dbFactory, UsageState usageState, OverrideSlotService overrideSlot, + HubBroadcaster broadcaster, ILogger logger) + { + _dbFactory = dbFactory; + _usageState = usageState; + _override = overrideSlot; + _broadcaster = broadcaster; + _logger = logger; + } + + /// The reset time should schedule an exact wake for, or + /// null when the toggle is off or no reset time is known yet — fail open, no timer. + public async Task GetScheduledWakeAtAsync(CancellationToken ct) + { + var settings = await TryReadSettingsAsync(ct); + if (settings is null || !settings.AutoContinueOnUsageLimit) return null; + return _usageState.Snapshot?.FiveHour?.ResetsAt; + } + + /// Fires exactly one ContinueTask per task failed on the usage limit, once the + /// 5h window has actually reset. No-ops (never throws) when the toggle is off, no reset time + /// is known, or the window hasn't reset yet. + public async Task RunAsync(CancellationToken ct) + { + var settings = await TryReadSettingsAsync(ct); + if (settings is null || !settings.AutoContinueOnUsageLimit) return; + + var resetsAt = _usageState.Snapshot?.FiveHour?.ResetsAt; + if (resetsAt is null || DateTimeOffset.UtcNow < resetsAt.Value) return; + + List candidates; + using (var context = _dbFactory.CreateDbContext()) + candidates = await new TaskRepository(context).GetUsageLimitAutoContinueCandidatesAsync(ct); + + foreach (var task in candidates) + { + bool claimed; + using (var context = _dbFactory.CreateDbContext()) + claimed = await new TaskRepository(context).TryClaimUsageLimitAutoContinueAsync(task.Id, DateTime.UtcNow, ct); + if (!claimed) continue; // already fired for this task — never twice. + + try + { + await _override.ContinueTask(task.Id, ContinuePrompt); + await _broadcaster.WorkerLog( + $"Auto-continuing #{task.Number} \"{task.Title}\" after usage-limit reset", + WorkerLogLevel.Info, DateTime.UtcNow); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to auto-continue task {TaskId} after usage-limit reset", task.Id); + } + } + } + + private async Task TryReadSettingsAsync(CancellationToken ct) + { + try + { + using var context = _dbFactory.CreateDbContext(); + return await new AppSettingsRepository(context).GetAsync(ct); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "UsageLimitAutoContinueCoordinator: failed to read settings; skipping tick"); + return null; + } + } +} diff --git a/src/ClaudeDo.Worker/Runner/TaskRunner.cs b/src/ClaudeDo.Worker/Runner/TaskRunner.cs index 9f602601..23d1170c 100644 --- a/src/ClaudeDo.Worker/Runner/TaskRunner.cs +++ b/src/ClaudeDo.Worker/Runner/TaskRunner.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using System.Text.RegularExpressions; using ClaudeDo.Data; using ClaudeDo.Data.Models; using ClaudeDo.Data.Repositories; @@ -159,13 +160,13 @@ public sealed class TaskRunner else { await MarkFailed(task.Id, task.Number, task.Title, slot, retryResult.ErrorMarkdown, retryResult.TurnCount, - retryConfig.MaxTurns, ClassifyFailureReason(retryResult.TerminalReason)); + retryConfig.MaxTurns, ClassifyFailureReason(retryResult.TerminalReason, retryResult.ErrorMarkdown)); } } else { await MarkFailed(task.Id, task.Number, task.Title, slot, result.ErrorMarkdown, result.TurnCount, - resolvedConfig.MaxTurns, ClassifyFailureReason(result.TerminalReason)); + resolvedConfig.MaxTurns, ClassifyFailureReason(result.TerminalReason, result.ErrorMarkdown)); } } @@ -276,7 +277,7 @@ public sealed class TaskRunner else { await MarkFailed(taskId, task.Number, task.Title, slot, result.ErrorMarkdown, result.TurnCount, - resolvedConfig.MaxTurns, ClassifyFailureReason(result.TerminalReason)); + resolvedConfig.MaxTurns, ClassifyFailureReason(result.TerminalReason, result.ErrorMarkdown)); } await _broadcaster.TaskUpdated(taskId); @@ -603,13 +604,24 @@ public sealed class TaskRunner internal static bool IsWriteTool(string toolName) => WriteTools.Contains(toolName); - /// Classifies the CLI's raw terminal_reason into the small, MCP-facing enum - /// (max_turns|timeout|error) get_task/batch_get_tasks report as failureReason. + // The CLI has no dedicated terminal_reason for a session/account usage-limit stop — it comes + // back as a plain "api_error" with the provider's own text (session/rate limit + reset time) + // riding in the result/error text (see ClaudeProcess.RunAsync). Matched against the rendered + // failure message (BuildFailureMarkdown's output), never against Status alone. + private static readonly Regex UsageLimitPattern = new( + "usage limit|session limit|rate.?limit", RegexOptions.IgnoreCase | RegexOptions.Compiled); + + internal static bool IsUsageLimitMessage(string? text) => !string.IsNullOrEmpty(text) && UsageLimitPattern.IsMatch(text); + + /// Classifies the CLI's raw terminal_reason (plus, for "api_error", the + /// rendered failure message) into the small, MCP-facing enum + /// (max_turns|timeout|usage_limit|error) get_task/batch_get_tasks report as failureReason. /// "cancelled" is set explicitly at the call sites that know it (there's no CLI signal for it). - internal static string ClassifyFailureReason(string? terminalReason) => terminalReason switch + internal static string ClassifyFailureReason(string? terminalReason, string? messageText = null) => terminalReason switch { "max_turns" => "max_turns", "timeout" => "timeout", + "api_error" when IsUsageLimitMessage(messageText) => "usage_limit", _ => "error", }; diff --git a/tests/ClaudeDo.Worker.Tests/External/AddSubtaskToolTests.cs b/tests/ClaudeDo.Worker.Tests/External/AddSubtaskToolTests.cs index 0c445d5b..cd7bbd96 100644 --- a/tests/ClaudeDo.Worker.Tests/External/AddSubtaskToolTests.cs +++ b/tests/ClaudeDo.Worker.Tests/External/AddSubtaskToolTests.cs @@ -77,8 +77,10 @@ public sealed class AddSubtaskToolTests : IDisposable var picker = new ClaudeDo.Worker.Queue.QueuePicker(dbFactory); var runCancels = new RunCancellationRegistry(NullLogger.Instance); var overrideSlot = new OverrideSlotService(dbFactory, runner, NullLogger.Instance, runCancels); + var usageState = new UsageState(); var queue = new QueueService(dbFactory, runner, cfg, NullLogger.Instance, waker, picker, overrideSlot, state, runCancels, - new FakeUsageGate(), new UsageState(), broadcaster); + new FakeUsageGate(), usageState, broadcaster, + new UsageLimitAutoContinueCoordinator(dbFactory, usageState, overrideSlot, broadcaster, NullLogger.Instance)); var maintenance = new WorktreeMaintenanceService(dbFactory, git, NullLogger.Instance); var merge = new TaskMergeService(dbFactory, git, broadcaster, state, new VerifyCommandRunner(), NullLogger.Instance); var aggregator = new PlanningAggregator(dbFactory, git, NullLogger.Instance); diff --git a/tests/ClaudeDo.Worker.Tests/External/BatchMcpToolsTests.cs b/tests/ClaudeDo.Worker.Tests/External/BatchMcpToolsTests.cs index 2df57df6..366d546f 100644 --- a/tests/ClaudeDo.Worker.Tests/External/BatchMcpToolsTests.cs +++ b/tests/ClaudeDo.Worker.Tests/External/BatchMcpToolsTests.cs @@ -111,9 +111,11 @@ public sealed class BatchMcpToolsTests : IDisposable NullLogger.Instance, state, new TaskRunTokenRegistry(), new AttachmentStore(), new FakeSessionSkillSeeder(), new FakeTranscriptUsageReader()); var runCancels = new RunCancellationRegistry(NullLogger.Instance); var overrideSlot = new OverrideSlotService(dbFactory, runner, NullLogger.Instance, runCancels); + var usageState = new UsageState(); return new QueueService(dbFactory, runner, cfg, NullLogger.Instance, new QueueWaker(), new QueuePicker(dbFactory), overrideSlot, state, runCancels, - new FakeUsageGate(), new UsageState(), broadcaster); + new FakeUsageGate(), usageState, broadcaster, + new UsageLimitAutoContinueCoordinator(dbFactory, usageState, overrideSlot, broadcaster, NullLogger.Instance)); } [Fact] diff --git a/tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs b/tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs index 665d73f0..56426233 100644 --- a/tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs +++ b/tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs @@ -178,8 +178,10 @@ public sealed class ExternalMcpServiceTests : IDisposable var picker = new ClaudeDo.Worker.Queue.QueuePicker(dbFactory); var runCancels = new RunCancellationRegistry(NullLogger.Instance); var overrideSlot = new OverrideSlotService(dbFactory, runner, NullLogger.Instance, runCancels); + var usageState = new UsageState(); return new QueueService(dbFactory, runner, cfg, NullLogger.Instance, waker, picker, overrideSlot, state, runCancels, - new FakeUsageGate(), new UsageState(), broadcaster); + new FakeUsageGate(), usageState, broadcaster, + new UsageLimitAutoContinueCoordinator(dbFactory, usageState, overrideSlot, broadcaster, NullLogger.Instance)); } [Fact] diff --git a/tests/ClaudeDo.Worker.Tests/External/LifecycleMcpToolsTests.cs b/tests/ClaudeDo.Worker.Tests/External/LifecycleMcpToolsTests.cs index eb70f5dd..568c7375 100644 --- a/tests/ClaudeDo.Worker.Tests/External/LifecycleMcpToolsTests.cs +++ b/tests/ClaudeDo.Worker.Tests/External/LifecycleMcpToolsTests.cs @@ -83,9 +83,11 @@ public sealed class LifecycleMcpToolsTests : IDisposable NullLogger.Instance, state, new TaskRunTokenRegistry(), new AttachmentStore(), new FakeSessionSkillSeeder(), new FakeTranscriptUsageReader()); var runCancels = new RunCancellationRegistry(NullLogger.Instance); var overrideSlot = new OverrideSlotService(dbFactory, runner, NullLogger.Instance, runCancels); + var usageState = new UsageState(); return new QueueService(dbFactory, runner, cfg, NullLogger.Instance, new QueueWaker(), new QueuePicker(dbFactory), overrideSlot, state, runCancels, - new FakeUsageGate(), new UsageState(), broadcaster); + new FakeUsageGate(), usageState, broadcaster, + new UsageLimitAutoContinueCoordinator(dbFactory, usageState, overrideSlot, broadcaster, NullLogger.Instance)); } private async Task SeedTaskAsync( diff --git a/tests/ClaudeDo.Worker.Tests/External/QueueStateMcpToolsTests.cs b/tests/ClaudeDo.Worker.Tests/External/QueueStateMcpToolsTests.cs index 6ba88bad..9e11bf44 100644 --- a/tests/ClaudeDo.Worker.Tests/External/QueueStateMcpToolsTests.cs +++ b/tests/ClaudeDo.Worker.Tests/External/QueueStateMcpToolsTests.cs @@ -60,8 +60,10 @@ public sealed class QueueStateMcpToolsTests : IDisposable new FakeSessionSkillSeeder(), new FakeTranscriptUsageReader()); var picker = new QueuePicker(dbFactory); var overrideSlot = new OverrideSlotService(dbFactory, runner, NullLogger.Instance, built.RunCancels); + var resolvedUsageState = usageState ?? new UsageState(); var queue = new QueueService(dbFactory, runner, _cfg, NullLogger.Instance, new QueueWaker(), picker, - overrideSlot, built.State, built.RunCancels, new FakeUsageGate(), usageState ?? new UsageState(), broadcaster); + overrideSlot, built.State, built.RunCancels, new FakeUsageGate(), resolvedUsageState, broadcaster, + new UsageLimitAutoContinueCoordinator(dbFactory, resolvedUsageState, overrideSlot, broadcaster, NullLogger.Instance)); return (queue, new QueueStateMcpTools(queue, dbFactory)); } diff --git a/tests/ClaudeDo.Worker.Tests/Queue/UsageLimitAutoContinueCoordinatorTests.cs b/tests/ClaudeDo.Worker.Tests/Queue/UsageLimitAutoContinueCoordinatorTests.cs new file mode 100644 index 00000000..cb596851 --- /dev/null +++ b/tests/ClaudeDo.Worker.Tests/Queue/UsageLimitAutoContinueCoordinatorTests.cs @@ -0,0 +1,207 @@ +using ClaudeDo.Data; +using ClaudeDo.Data.Models; +using ClaudeDo.Data.Repositories; +using ClaudeDo.Worker.Config; +using ClaudeDo.Worker.Hub; +using ClaudeDo.Worker.Queue; +using ClaudeDo.Worker.Runner; +using ClaudeDo.Worker.Tests.Infrastructure; +using ClaudeDo.Worker.Usage; +using Microsoft.Extensions.Logging.Abstractions; +using TaskStatus = ClaudeDo.Data.Models.TaskStatus; + +namespace ClaudeDo.Worker.Tests.Queue; + +/// Covers the "Continue on session limit reset" toggle's coordinator in isolation from +/// QueueService's own timer plumbing (see QueueService.ScheduleResetWake for that part). +public sealed class UsageLimitAutoContinueCoordinatorTests : IDisposable +{ + private readonly DbFixture _db = new(); + private readonly string _tempDir; + private readonly WorkerConfig _cfg; + + public UsageLimitAutoContinueCoordinatorTests() + { + _tempDir = Path.Combine(Path.GetTempPath(), $"cd_usagelimit_{Guid.NewGuid():N}"); + Directory.CreateDirectory(_tempDir); + _cfg = new WorkerConfig { SandboxRoot = _tempDir, LogRoot = _tempDir }; + } + + public void Dispose() { _db.Dispose(); try { Directory.Delete(_tempDir, true); } catch { } } + + private (UsageLimitAutoContinueCoordinator Coordinator, UsageState UsageState, FakeClaudeProcess Process) BuildCoordinator() + { + var dbFactory = _db.CreateFactory(); + var state = TaskStateServiceBuilder.Build(dbFactory).State; + var wt = new WorktreeManager(new ClaudeDo.Data.Git.GitService(), dbFactory, _cfg, NullLogger.Instance); + var fake = new FakeClaudeProcess(); + var runner = new TaskRunner(fake, dbFactory, new HubBroadcaster(new CapturingHubContext()), wt, + new ClaudeArgsBuilder(), _cfg, NullLogger.Instance, state, new TaskRunTokenRegistry(), + new AttachmentStore(), new FakeSessionSkillSeeder(), new FakeTranscriptUsageReader()); + var runCancels = new RunCancellationRegistry(NullLogger.Instance); + var overrideSlot = new OverrideSlotService(dbFactory, runner, NullLogger.Instance, runCancels); + var usageState = new UsageState(); + var coordinator = new UsageLimitAutoContinueCoordinator( + dbFactory, usageState, overrideSlot, + new HubBroadcaster(new CapturingHubContext()), NullLogger.Instance); + return (coordinator, usageState, fake); + } + + private async Task SeedUsageLimitTaskAsync(bool withSessionId = true) + { + var listId = Guid.NewGuid().ToString(); + var taskId = Guid.NewGuid().ToString(); + using (var ctx = _db.CreateContext()) + { + ctx.Lists.Add(new ListEntity { Id = listId, Name = "L", CreatedAt = DateTime.UtcNow }); + ctx.Tasks.Add(new TaskEntity + { + Id = taskId, ListId = listId, Title = "T", Status = TaskStatus.Failed, + FailureReason = "usage_limit", CreatedAt = DateTime.UtcNow, FinishedAt = DateTime.UtcNow, + }); + if (withSessionId) + { + ctx.TaskRuns.Add(new TaskRunEntity + { + Id = Guid.NewGuid().ToString(), TaskId = taskId, RunNumber = 1, IsRetry = false, Prompt = "p", + LogPath = Path.Combine(_tempDir, "log.ndjson"), StartedAt = DateTime.UtcNow, + FinishedAt = DateTime.UtcNow, SessionId = "sess-1", ExitCode = 1, + }); + } + await ctx.SaveChangesAsync(); + } + return taskId; + } + + private async Task EnableToggleAsync() + { + using var ctx = _db.CreateContext(); + var repo = new AppSettingsRepository(ctx); + var settings = await repo.GetAsync(); + settings.AutoContinueOnUsageLimit = true; + await repo.UpdateAsync(settings); + } + + private async Task PollUntilCalledAsync(string taskId, FakeClaudeProcess fake) + { + var deadline = DateTime.UtcNow.AddSeconds(10); + while (DateTime.UtcNow < deadline && fake.CallCount == 0) + await Task.Delay(25); + await Task.Delay(150); // let the fire-and-forget continuation fully settle + using var ctx = _db.CreateContext(); + return await new TaskRepository(ctx).GetByIdAsync(taskId); + } + + [Fact] + public async Task Toggle_Off_Never_Schedules_Or_Continues() + { + var (coordinator, usageState, fake) = BuildCoordinator(); + var taskId = await SeedUsageLimitTaskAsync(); + usageState.ReportSuccess(new UsageSnapshot( + new UsageBucket(85, DateTimeOffset.UtcNow.AddSeconds(-1)), null, Array.Empty(), DateTime.UtcNow)); + + var wakeAt = await coordinator.GetScheduledWakeAtAsync(default); + await coordinator.RunAsync(default); + await Task.Delay(150); + + Assert.Null(wakeAt); + Assert.Equal(0, fake.CallCount); + using var verify = _db.CreateContext(); + var task = await new TaskRepository(verify).GetByIdAsync(taskId); + Assert.Null(task!.UsageLimitAutoContinuedAt); + } + + [Fact] + public async Task Toggle_On_No_Snapshot_Yet_Schedules_Nothing_And_Does_Not_Crash() + { + var (coordinator, _, fake) = BuildCoordinator(); + await SeedUsageLimitTaskAsync(); + await EnableToggleAsync(); + + var wakeAt = await coordinator.GetScheduledWakeAtAsync(default); + await coordinator.RunAsync(default); // must not throw despite no usage snapshot + + Assert.Null(wakeAt); + Assert.Equal(0, fake.CallCount); + } + + [Fact] + public async Task Toggle_On_Reset_In_Future_Schedules_Wake_But_Does_Not_Continue_Yet() + { + var (coordinator, usageState, fake) = BuildCoordinator(); + await SeedUsageLimitTaskAsync(); + await EnableToggleAsync(); + var resetsAt = DateTimeOffset.UtcNow.AddMinutes(5); + usageState.ReportSuccess(new UsageSnapshot( + new UsageBucket(85, resetsAt), null, Array.Empty(), DateTime.UtcNow)); + + var wakeAt = await coordinator.GetScheduledWakeAtAsync(default); + await coordinator.RunAsync(default); + await Task.Delay(150); + + Assert.Equal(resetsAt, wakeAt); + Assert.Equal(0, fake.CallCount); + } + + [Fact] + public async Task Toggle_On_Reset_Already_Passed_Continues_Exactly_Once() + { + var (coordinator, usageState, fake) = BuildCoordinator(); + var taskId = await SeedUsageLimitTaskAsync(); + await EnableToggleAsync(); + usageState.ReportSuccess(new UsageSnapshot( + new UsageBucket(85, DateTimeOffset.UtcNow.AddSeconds(-1)), null, Array.Empty(), DateTime.UtcNow)); + + await coordinator.RunAsync(default); + var task = await PollUntilCalledAsync(taskId, fake); + + Assert.Equal(1, fake.CallCount); + Assert.NotNull(task!.UsageLimitAutoContinuedAt); + + // A second tick (e.g. the next backstop) must not fire it again. + await coordinator.RunAsync(default); + await Task.Delay(150); + Assert.Equal(1, fake.CallCount); + } + + [Fact] + public async Task Missing_Reset_Time_Never_Continues_And_Never_Throws() + { + var (coordinator, usageState, fake) = BuildCoordinator(); + await SeedUsageLimitTaskAsync(); + await EnableToggleAsync(); + // A snapshot exists, but this bucket has no reset time — fail open. + usageState.ReportSuccess(new UsageSnapshot( + new UsageBucket(85, null), null, Array.Empty(), DateTime.UtcNow)); + + var wakeAt = await coordinator.GetScheduledWakeAtAsync(default); + await coordinator.RunAsync(default); + await Task.Delay(150); + + Assert.Null(wakeAt); + Assert.Equal(0, fake.CallCount); + } + + [Fact] + public async Task Non_UsageLimit_Failure_Is_Never_Touched() + { + var (coordinator, usageState, fake) = BuildCoordinator(); + var listId = Guid.NewGuid().ToString(); + var taskId = Guid.NewGuid().ToString(); + using (var ctx = _db.CreateContext()) + { + ctx.Lists.Add(new ListEntity { Id = listId, Name = "L", CreatedAt = DateTime.UtcNow }); + ctx.Tasks.Add(new TaskEntity { Id = taskId, ListId = listId, Title = "T", Status = TaskStatus.Failed, + FailureReason = "error", CreatedAt = DateTime.UtcNow }); + await ctx.SaveChangesAsync(); + } + await EnableToggleAsync(); + usageState.ReportSuccess(new UsageSnapshot( + new UsageBucket(85, DateTimeOffset.UtcNow.AddSeconds(-1)), null, Array.Empty(), DateTime.UtcNow)); + + await coordinator.RunAsync(default); + await Task.Delay(150); + + Assert.Equal(0, fake.CallCount); + } +} diff --git a/tests/ClaudeDo.Worker.Tests/Runner/FailureDiagnosisTests.cs b/tests/ClaudeDo.Worker.Tests/Runner/FailureDiagnosisTests.cs index 9686b09c..20ee3913 100644 --- a/tests/ClaudeDo.Worker.Tests/Runner/FailureDiagnosisTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Runner/FailureDiagnosisTests.cs @@ -99,6 +99,29 @@ public class FailureDiagnosisTests { Assert.Equal(expected, TaskRunner.ClassifyFailureReason(terminalReason)); } + + [Theory] + [InlineData("You've hit your session limit – resets 1pm (Europe/Berlin)")] + [InlineData("Claude usage limit reached, resets at 3pm")] + [InlineData("rate limited, please retry later")] + public void ClassifyFailureReason_ApiError_With_Limit_Text_Returns_UsageLimit(string messageText) + { + Assert.Equal("usage_limit", TaskRunner.ClassifyFailureReason("api_error", messageText)); + } + + [Fact] + public void ClassifyFailureReason_ApiError_With_Generic_Text_Stays_Error() + { + Assert.Equal("error", TaskRunner.ClassifyFailureReason("api_error", "Internal server error, please retry")); + } + + [Theory] + [InlineData("max_turns")] + [InlineData("timeout")] + public void ClassifyFailureReason_Ignores_Limit_Text_For_NonApiError_Reasons(string terminalReason) + { + Assert.Equal(terminalReason, TaskRunner.ClassifyFailureReason(terminalReason, "usage limit reached")); + } } public sealed class FailureDiagnosisEndToEndTests : IDisposable @@ -282,4 +305,32 @@ public sealed class FailureDiagnosisEndToEndTests : IDisposable Assert.Equal("error", task.FailureReason); Assert.NotEqual("max_turns", task.FailureReason); } + + [Fact] + public async Task Usage_Limit_Run_Sets_FailureReason_UsageLimit() + { + var dbFactory = _db.CreateFactory(); + using (var ctx = _db.CreateContext()) + { + ctx.Lists.Add(new ListEntity { Id = "l1", Name = "L", WorkingDir = null, CreatedAt = DateTime.UtcNow }); + ctx.Tasks.Add(new TaskEntity { Id = "t1", ListId = "l1", Title = "T", + Status = TaskStatus.Running, CreatedAt = DateTime.UtcNow }); + await ctx.SaveChangesAsync(); + } + var fake = new FakeClaudeProcess((_, _, _, _, _) => Task.FromResult(new RunResult + { + ExitCode = 1, + TerminalReason = "api_error", + ResultMarkdown = "You've hit your session limit – resets 1pm (Europe/Berlin)", + })); + var runner = MakeRunner(dbFactory, fake); + + using (var ctx = _db.CreateContext()) + await runner.RunAsync((await new TaskRepository(ctx).GetByIdAsync("t1"))!, "slot-1", default, alreadyClaimed: true); + + using var verify = _db.CreateContext(); + var task = await new TaskRepository(verify).GetByIdAsync("t1"); + Assert.Equal(TaskStatus.Failed, task!.Status); + Assert.Equal("usage_limit", task.FailureReason); + } } diff --git a/tests/ClaudeDo.Worker.Tests/Services/QueueServiceSlotFailureTests.cs b/tests/ClaudeDo.Worker.Tests/Services/QueueServiceSlotFailureTests.cs index 3a913bf4..ca5a47a1 100644 --- a/tests/ClaudeDo.Worker.Tests/Services/QueueServiceSlotFailureTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Services/QueueServiceSlotFailureTests.cs @@ -65,8 +65,10 @@ public sealed class QueueServiceSlotFailureTests : IDisposable new FakeSessionSkillSeeder(), new FakeTranscriptUsageReader()); var waker = new QueueWaker(); var overrideSlot = new OverrideSlotService(dbFactory, runner, NullLogger.Instance, built.RunCancels); + var usageState = new UsageState(); var service = new QueueService(dbFactory, runner, _cfg, NullLogger.Instance, waker, picker, - overrideSlot, built.State, built.RunCancels, new FakeUsageGate(), new UsageState(), broadcaster); + overrideSlot, built.State, built.RunCancels, new FakeUsageGate(), usageState, broadcaster, + new UsageLimitAutoContinueCoordinator(dbFactory, usageState, overrideSlot, broadcaster, NullLogger.Instance)); return (service, built.Hub, waker); } diff --git a/tests/ClaudeDo.Worker.Tests/Services/QueueServiceSlotGuardTests.cs b/tests/ClaudeDo.Worker.Tests/Services/QueueServiceSlotGuardTests.cs index 0f8ce049..b85a6026 100644 --- a/tests/ClaudeDo.Worker.Tests/Services/QueueServiceSlotGuardTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Services/QueueServiceSlotGuardTests.cs @@ -60,8 +60,10 @@ public sealed class QueueServiceSlotGuardTests : IDisposable _waker = new QueueWaker(); var picker = new QueuePicker(dbFactory); var overrideSlot = new OverrideSlotService(dbFactory, runner, NullLogger.Instance, built.RunCancels); + var usageState = new UsageState(); var service = new QueueService(dbFactory, runner, _cfg, NullLogger.Instance, _waker, picker, overrideSlot, state, built.RunCancels, - new FakeUsageGate(), new UsageState(), broadcaster); + new FakeUsageGate(), usageState, broadcaster, + new UsageLimitAutoContinueCoordinator(dbFactory, usageState, overrideSlot, broadcaster, NullLogger.Instance)); return (service, fake); } diff --git a/tests/ClaudeDo.Worker.Tests/Services/QueueServiceTests.cs b/tests/ClaudeDo.Worker.Tests/Services/QueueServiceTests.cs index e0cf4a92..dd72a320 100644 --- a/tests/ClaudeDo.Worker.Tests/Services/QueueServiceTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Services/QueueServiceTests.cs @@ -69,8 +69,10 @@ public sealed class QueueServiceTests : IDisposable var overrideSlot = new OverrideSlotService(dbFactory, runner, NullLogger.Instance, built.RunCancels); _usageGate = usageGate ?? new FakeUsageGate(); _runCancels = built.RunCancels; + var resolvedUsageState = usageState ?? new UsageState(); var service = new QueueService(dbFactory, runner, _cfg, NullLogger.Instance, _waker, picker, overrideSlot, state, built.RunCancels, - _usageGate, usageState ?? new UsageState(), broadcaster); + _usageGate, resolvedUsageState, broadcaster, + new UsageLimitAutoContinueCoordinator(dbFactory, resolvedUsageState, overrideSlot, broadcaster, NullLogger.Instance)); return (service, fake); }