diff --git a/src/ClaudeDo.Data/CLAUDE.md b/src/ClaudeDo.Data/CLAUDE.md index f6d1b486..411b203d 100644 --- a/src/ClaudeDo.Data/CLAUDE.md +++ b/src/ClaudeDo.Data/CLAUDE.md @@ -41,10 +41,18 @@ Beyond the basics it carries: | `NextTaskNumber` | `next_task_number` | 1 | Persistent counter for `TaskEntity.Number` allocation. Incremented atomically in `TaskNumberAllocator.AddWithNumberAsync` on every task insert. **Monotonic only:** the counter itself advances across all tasks (no per-list numbering), and it never resets. A deleted task leaves a gap. | | `PowerUseEnabled` | `power_use_enabled` | false | PowerUse master switch, toggled on the **usage monitor** (not Settings). ⚠️ Never sufficient alone — a handler run is in PowerUse only when `list_config.power_use_allowed` is also set. | | `ListHandlerMaxHandoffs` | `list_handler_max_handoffs` | 4 | Cap on a list-handler run's wait↔merge handoff chain (`HandoffRoundTracker`). 4 = two merge rounds. Independent of PowerUse — applies to interactive runs too. Clamped to min 1 in `WorkerHub.UpdateAppSettings` and again in `Register`, because 0 would remove the only bound on a runaway chain. | +| `PowerUseWindowStart` / `PowerUseWindowEnd` | `power_use_window_*` | null | Optional local-time window (`HH:mm`) narrowing when PowerUse applies. Both null (or only one set) = round the clock, i.e. byte-identical to the pre-window behaviour; end before start wraps over midnight. Semantics live in the pure `PowerUseWindow`; checked in `InteractiveLaunchSpecService.ComposeHandlerPromptAsync` **per phase** at prompt-build time, so a chain handing off past the end time drops the addendum from there on. Unparseable input is stored as null, not rejected. | +| `PowerUseHandoffPct` | `power_use_handoff_pct` | 95 | 5h-usage percentage at which a PowerUse handler session should hand off. Clamped 50..99. Stored + shown on the usage monitor; the consumer is the handoff prompt. | All four usage percentages are clamped 0..100 by `AppSettingsRepository.UpdateAsync`. Gate/throttle semantics → [usage-monitoring](../../docs/explore-notes/usage-monitoring.md). +⚠️ **`AppSettingsRepository.UpdateAsync` copies the incoming entity field by field** onto the +singleton row — a property missing from that block never persists, however correctly the caller +filled the entity, and nothing fails. `ModelPresets`, `PowerUseEnabled` and +`ListHandlerMaxHandoffs` were all silently unwritable that way until 2026-09-02. Add a new column +to that block, not just to the entity + the hub. + ### Model / effort registries - **ModelPresets / ModelPreset** — per-model run defaults (`Model`, `Effort`, `MaxTurns`), one row per `ModelRegistry.Aliases` entry, supplying the **global** effort and max-turns defaults. Ship defaults: haiku medium/20, sonnet high/30, opus high/40, fable high/25. `Parse`/`Serialize` normalize (unknown models dropped, missing aliases filled from `Defaults`, effort validated, turns clamped 1–200) and **never throw** — a malformed settings row must not stop a run. `For(presets, model, fallbackMaxTurns)` always returns a usable row; resolution order and the fallback trap → [worker-task-pipeline](../../docs/explore-notes/worker-task-pipeline.md). diff --git a/src/ClaudeDo.Data/Configuration/AppSettingsEntityConfiguration.cs b/src/ClaudeDo.Data/Configuration/AppSettingsEntityConfiguration.cs index a6b952b6..94b2e38c 100644 --- a/src/ClaudeDo.Data/Configuration/AppSettingsEntityConfiguration.cs +++ b/src/ClaudeDo.Data/Configuration/AppSettingsEntityConfiguration.cs @@ -72,6 +72,12 @@ public class AppSettingsEntityConfiguration : IEntityTypeConfiguration s.PowerUseEnabled) .HasColumnName("power_use_enabled").IsRequired().HasDefaultValue(false); + builder.Property(s => s.PowerUseWindowStart) + .HasColumnName("power_use_window_start"); + builder.Property(s => s.PowerUseWindowEnd) + .HasColumnName("power_use_window_end"); + builder.Property(s => s.PowerUseHandoffPct) + .HasColumnName("power_use_handoff_pct").IsRequired().HasDefaultValue(95); builder.Property(s => s.ListHandlerMaxHandoffs) .HasColumnName("list_handler_max_handoffs").IsRequired().HasDefaultValue(4); diff --git a/src/ClaudeDo.Data/Migrations/20260902170501_AddPowerUseWindow.Designer.cs b/src/ClaudeDo.Data/Migrations/20260902170501_AddPowerUseWindow.Designer.cs new file mode 100644 index 00000000..95976c49 --- /dev/null +++ b/src/ClaudeDo.Data/Migrations/20260902170501_AddPowerUseWindow.Designer.cs @@ -0,0 +1,1044 @@ +// +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("20260902170501_AddPowerUseWindow")] + partial class AddPowerUseWindow + { + /// + 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("ListHandlerMaxHandoffs") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(4) + .HasColumnName("list_handler_max_handoffs"); + + 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("PowerUseEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false) + .HasColumnName("power_use_enabled"); + + b.Property("PowerUseHandoffPct") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(95) + .HasColumnName("power_use_handoff_pct"); + + b.Property("PowerUseWindowEnd") + .HasColumnType("TEXT") + .HasColumnName("power_use_window_end"); + + b.Property("PowerUseWindowStart") + .HasColumnType("TEXT") + .HasColumnName("power_use_window_start"); + + 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", + ListHandlerMaxHandoffs = 4, + MaxParallelExecutions = 1, + MaxTurnsCeiling = 80, + NextTaskNumber = 1, + PowerUseEnabled = false, + PowerUseHandoffPct = 95, + 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("FromAgent") + .HasColumnType("INTEGER") + .HasColumnName("from_agent"); + + 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("PermissionMode") + .HasColumnType("TEXT") + .HasColumnName("permission_mode"); + + b.Property("PowerUseAllowed") + .HasColumnType("INTEGER") + .HasColumnName("power_use_allowed"); + + b.Property("SessionSkills") + .HasColumnType("TEXT") + .HasColumnName("session_skills"); + + b.Property("SystemPrompt") + .HasColumnType("TEXT") + .HasColumnName("system_prompt"); + + b.Property("TicketProjectId") + .HasColumnType("INTEGER") + .HasColumnName("ticket_project_id"); + + 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("Icon") + .HasColumnType("TEXT") + .HasColumnName("icon"); + + 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("Kind") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("ping") + .HasColumnName("action_kind"); + + 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.TaskDependencyEntity", b => + { + b.Property("TaskId") + .HasColumnType("TEXT") + .HasColumnName("task_id"); + + b.Property("DependsOnTaskId") + .HasColumnType("TEXT") + .HasColumnName("depends_on_task_id"); + + b.HasKey("TaskId", "DependsOnTaskId"); + + b.HasIndex("DependsOnTaskId") + .HasDatabaseName("idx_task_dependencies_depends_on"); + + b.ToTable("task_dependencies", (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("ExpectsNoDiff") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false) + .HasColumnName("expects_no_diff"); + + 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("PermissionMode") + .HasColumnType("TEXT") + .HasColumnName("permission_mode"); + + 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("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("TicketRef") + .HasColumnType("TEXT") + .HasColumnName("ticket_ref"); + + 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("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.TaskDependencyEntity", b => + { + b.HasOne("ClaudeDo.Data.Models.TaskEntity", null) + .WithMany() + .HasForeignKey("DependsOnTaskId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ClaudeDo.Data.Models.TaskEntity", null) + .WithMany("Dependencies") + .HasForeignKey("TaskId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + 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("Dependencies"); + + b.Navigation("Runs"); + + b.Navigation("Subtasks"); + + b.Navigation("Worktree"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/ClaudeDo.Data/Migrations/20260902170501_AddPowerUseWindow.cs b/src/ClaudeDo.Data/Migrations/20260902170501_AddPowerUseWindow.cs new file mode 100644 index 00000000..8c3fb858 --- /dev/null +++ b/src/ClaudeDo.Data/Migrations/20260902170501_AddPowerUseWindow.cs @@ -0,0 +1,56 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace ClaudeDo.Data.Migrations +{ + /// + public partial class AddPowerUseWindow : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "power_use_handoff_pct", + table: "app_settings", + type: "INTEGER", + nullable: false, + defaultValue: 95); + + migrationBuilder.AddColumn( + name: "power_use_window_end", + table: "app_settings", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "power_use_window_start", + table: "app_settings", + type: "TEXT", + nullable: true); + + migrationBuilder.UpdateData( + table: "app_settings", + keyColumn: "id", + keyValue: 1, + columns: new[] { "power_use_handoff_pct", "power_use_window_end", "power_use_window_start" }, + values: new object[] { 95, null, null }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "power_use_handoff_pct", + table: "app_settings"); + + migrationBuilder.DropColumn( + name: "power_use_window_end", + table: "app_settings"); + + migrationBuilder.DropColumn( + name: "power_use_window_start", + table: "app_settings"); + } + } +} diff --git a/src/ClaudeDo.Data/Migrations/ClaudeDoDbContextModelSnapshot.cs b/src/ClaudeDo.Data/Migrations/ClaudeDoDbContextModelSnapshot.cs index 848fab3a..e6e072b7 100644 --- a/src/ClaudeDo.Data/Migrations/ClaudeDoDbContextModelSnapshot.cs +++ b/src/ClaudeDo.Data/Migrations/ClaudeDoDbContextModelSnapshot.cs @@ -100,6 +100,20 @@ namespace ClaudeDo.Data.Migrations .HasDefaultValue(false) .HasColumnName("power_use_enabled"); + b.Property("PowerUseHandoffPct") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(95) + .HasColumnName("power_use_handoff_pct"); + + b.Property("PowerUseWindowEnd") + .HasColumnType("TEXT") + .HasColumnName("power_use_window_end"); + + b.Property("PowerUseWindowStart") + .HasColumnType("TEXT") + .HasColumnName("power_use_window_start"); + b.Property("RepoImportFolders") .HasColumnType("TEXT") .HasColumnName("repo_import_folders"); @@ -192,6 +206,7 @@ namespace ClaudeDo.Data.Migrations MaxTurnsCeiling = 80, NextTaskNumber = 1, PowerUseEnabled = false, + PowerUseHandoffPct = 95, StandupWeekday = 3, UsageGateFiveHourPct = 80, UsageGateSevenDayPct = 90, diff --git a/src/ClaudeDo.Data/Models/AppSettingsEntity.cs b/src/ClaudeDo.Data/Models/AppSettingsEntity.cs index ecd7cae8..6b86f378 100644 --- a/src/ClaudeDo.Data/Models/AppSettingsEntity.cs +++ b/src/ClaudeDo.Data/Models/AppSettingsEntity.cs @@ -69,6 +69,17 @@ public sealed class AppSettingsEntity // run unattended. Composition lives in InteractiveLaunchSpecService. public bool PowerUseEnabled { get; set; } + // Optional local-time window (both `HH:mm`) narrowing when PowerUse applies, checked per phase + // at prompt-build time. Both null (the default) = round the clock, byte-identical to the + // pre-window behaviour; only one set counts as unset too. End before start wraps over midnight. + // Semantics live in PowerUseWindow. + public string? PowerUseWindowStart { get; set; } + public string? PowerUseWindowEnd { get; set; } + + // Percentage of the 5h usage window at which a PowerUse handler session is told to hand off + // rather than start more work. Stored and displayed here; the consumer is the handoff prompt. + public int PowerUseHandoffPct { get; set; } = 95; + // Server-side cap on a list-handler run's wait<->merge handoff chain (HandoffRoundTracker). // 4 = two full wait/merge cycles, the long-standing default. Raise it for a run meant to keep // going for hours. It exists to bound runaway handoff loops, so it must stay >= 1. diff --git a/src/ClaudeDo.Data/Models/PowerUseWindow.cs b/src/ClaudeDo.Data/Models/PowerUseWindow.cs new file mode 100644 index 00000000..97c7bbf1 --- /dev/null +++ b/src/ClaudeDo.Data/Models/PowerUseWindow.cs @@ -0,0 +1,32 @@ +namespace ClaudeDo.Data.Models; + +/// +/// The optional local-time window restricting when PowerUse applies +/// ( / End, both HH:mm). +/// Pure on purpose — the caller supplies "now", so the window logic is testable without a clock. +/// +public static class PowerUseWindow +{ + /// Parses an HH:mm string; anything else (including null/blank) is null. + public static TimeOnly? Parse(string? value) + => TimeOnly.TryParseExact(value?.Trim(), "HH:mm", out var t) ? t : null; + + /// Round-trips a stored/typed value through , so an unparseable + /// string is stored as null instead of sitting in the column pretending to be a window. + public static string? Normalize(string? value) => Parse(value)?.ToString("HH:mm"); + + /// + /// True when falls inside the window. Half-open: start is included, + /// end is not. Either bound missing = no window at all = always true (the pre-window + /// behaviour). A window whose end is before its start wraps over midnight. + /// + public static bool Contains(TimeOnly now, TimeOnly? start, TimeOnly? end) + { + if (start is not { } from || end is not { } to) return true; + return from < to ? now >= from && now < to : now >= from || now < to; + } + + /// Convenience overload for the two stored strings and a wall clock. + public static bool IsOpen(DateTime now, string? start, string? end) + => Contains(TimeOnly.FromDateTime(now), Parse(start), Parse(end)); +} diff --git a/src/ClaudeDo.Data/Repositories/AppSettingsRepository.cs b/src/ClaudeDo.Data/Repositories/AppSettingsRepository.cs index 2c2326ad..38f47b90 100644 --- a/src/ClaudeDo.Data/Repositories/AppSettingsRepository.cs +++ b/src/ClaudeDo.Data/Repositories/AppSettingsRepository.cs @@ -93,6 +93,16 @@ public sealed class AppSettingsRepository row.UsageThrottleSevenDaySoftPct = Math.Clamp(updated.UsageThrottleSevenDaySoftPct, 0, 100); row.UsageThrottleSevenDayHardPct = Math.Clamp(updated.UsageThrottleSevenDayHardPct, 0, 100); row.AutoContinueOnUsageLimit = updated.AutoContinueOnUsageLimit; + // ModelPresets / PowerUse* / ListHandlerMaxHandoffs were missing from this copy block, so + // every write of them was silently dropped (the row keeps whatever it already had). This + // block is the single write path for the singleton row — a field absent here does not + // persist, no matter how correctly the hub fills the entity. + row.ModelPresets = string.IsNullOrWhiteSpace(updated.ModelPresets) ? row.ModelPresets : updated.ModelPresets; + row.PowerUseEnabled = updated.PowerUseEnabled; + row.PowerUseWindowStart = PowerUseWindow.Normalize(updated.PowerUseWindowStart); + row.PowerUseWindowEnd = PowerUseWindow.Normalize(updated.PowerUseWindowEnd); + row.PowerUseHandoffPct = Math.Clamp(updated.PowerUseHandoffPct, 50, 99); + row.ListHandlerMaxHandoffs = Math.Max(1, updated.ListHandlerMaxHandoffs); await _context.SaveChangesAsync(ct); } diff --git a/src/ClaudeDo.Data/Wire.cs b/src/ClaudeDo.Data/Wire.cs index 50c6335e..91a6474d 100644 --- a/src/ClaudeDo.Data/Wire.cs +++ b/src/ClaudeDo.Data/Wire.cs @@ -40,7 +40,13 @@ public record AppSettingsDto( // PowerUse master switch — toggled on the usage monitor, not typed in Settings. Only takes // effect for a list that also set PowerUseAllowed. bool PowerUseEnabled = false, - int ListHandlerMaxHandoffs = 4); + int ListHandlerMaxHandoffs = 4, + // PowerUse time window (`HH:mm`, local, null = round the clock) + the 5h-usage percentage at + // which a PowerUse handler session should hand off. Also usage-monitor-owned, so Settings has + // to round-trip them too. + string? PowerUseWindowStart = null, + string? PowerUseWindowEnd = null, + int PowerUseHandoffPct = 95); public record ModelPresetDto(string Model, string Effort, int MaxTurns); diff --git a/src/ClaudeDo.Localization/locales/de.json b/src/ClaudeDo.Localization/locales/de.json index 7e1cda5b..653cd727 100644 --- a/src/ClaudeDo.Localization/locales/de.json +++ b/src/ClaudeDo.Localization/locales/de.json @@ -511,6 +511,10 @@ "noGauges": "Noch keine Nutzungslimits gemeldet.", "powerUse": "PowerUse", "powerUseHint": "Das restliche Fenster ausnutzen: ein List-Handler-Run fragt nichts, löst Konflikte selbst, sammelt neue Arbeit und handoffet weiter, solange sein Budget reicht. Wirkt nur auf Listen, die PowerUse in ihren Listen-Einstellungen erlaubt haben.", + "powerUseWindow": "Nur zwischen", + "powerUseWindowPlaceholder": "HH:mm", + "powerUseHandoffPct": "Übergabe bei (% von 5h)", + "powerUseWindowHint": "Beide Zeiten leer = rund um die Uhr. Das Fenster wird bei jedem Start einer Handler-Phase geprüft — eine Kette, die über das Ende hinausläuft, macht ohne PowerUse weiter.", "refresh": "Jetzt aktualisieren", "refreshHint": "Abruf alle 5 Min., während ein Task läuft, sonst alle 15 Min.", "staleFormat": "Werte veraltet (Stand {0})", diff --git a/src/ClaudeDo.Localization/locales/en.json b/src/ClaudeDo.Localization/locales/en.json index d1a21582..baf7c3c1 100644 --- a/src/ClaudeDo.Localization/locales/en.json +++ b/src/ClaudeDo.Localization/locales/en.json @@ -511,6 +511,10 @@ "noGauges": "No usage limits reported yet.", "powerUse": "PowerUse", "powerUseHint": "Spend the rest of the window: a list-handler run asks nothing, resolves conflicts itself, harvests new work, and keeps handing off for as long as its budget allows. Only affects lists that allowed PowerUse in their list settings.", + "powerUseWindow": "Only between", + "powerUseWindowPlaceholder": "HH:mm", + "powerUseHandoffPct": "Hand off at (% of 5h)", + "powerUseWindowHint": "Leave both times empty for round the clock. A window is checked whenever a handler phase starts, so a chain that runs past the end time continues without PowerUse.", "refresh": "Refresh now", "refreshHint": "Polled every 5 min while a task runs, otherwise every 15 min.", "staleFormat": "Values stale (as of {0})", diff --git a/src/ClaudeDo.Ui/ViewModels/Modals/SettingsModalViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Modals/SettingsModalViewModel.cs index b24fa6f7..aadfb141 100644 --- a/src/ClaudeDo.Ui/ViewModels/Modals/SettingsModalViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Modals/SettingsModalViewModel.cs @@ -134,6 +134,10 @@ public sealed partial class SettingsModalViewModel : ViewModelBase // opening and saving Settings can't switch it off behind the user's back. private bool _powerUseEnabled; + // Ditto for the PowerUse time window and handoff threshold — usage-monitor-owned, invisible + // here, but they must survive a Settings save. + private (string? Start, string? End, int HandoffPct) _powerUseWindow = (null, null, 95); + public async Task LoadAsync() { IsBusy = true; @@ -156,6 +160,7 @@ public sealed partial class SettingsModalViewModel : ViewModelBase // because SaveAsync rebuilds the whole DTO positionally and would otherwise write // the record default (false) back and silently switch PowerUse off. _powerUseEnabled = dto.PowerUseEnabled; + _powerUseWindow = (dto.PowerUseWindowStart, dto.PowerUseWindowEnd, dto.PowerUseHandoffPct); _throttleStages = ( dto.UsageThrottleFiveHourSoftPct, dto.UsageThrottleFiveHourHardPct, dto.UsageThrottleSevenDaySoftPct, dto.UsageThrottleSevenDayHardPct); @@ -224,7 +229,10 @@ public sealed partial class SettingsModalViewModel : ViewModelBase _throttleStages.SevenHard, General.AutoContinueOnUsageLimit, _powerUseEnabled, - General.ListHandlerMaxHandoffs); + General.ListHandlerMaxHandoffs, + _powerUseWindow.Start, + _powerUseWindow.End, + _powerUseWindow.HandoffPct); await _worker.UpdateAppSettingsAsync(dto); await Prime.SaveAsync(); await OnlineInbox.SaveAsync(); diff --git a/src/ClaudeDo.Ui/ViewModels/Modals/UsageMonitorModalViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Modals/UsageMonitorModalViewModel.cs index d4e93539..92bbb23e 100644 --- a/src/ClaudeDo.Ui/ViewModels/Modals/UsageMonitorModalViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Modals/UsageMonitorModalViewModel.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; +using ClaudeDo.Data.Models; using ClaudeDo.Ui.Localization; using ClaudeDo.Ui.Services; using CommunityToolkit.Mvvm.ComponentModel; @@ -108,6 +109,14 @@ public sealed partial class UsageMonitorModalViewModel : ViewModelBase /// [ObservableProperty] private bool _powerUseEnabled; + /// Optional local-time window (HH:mm) narrowing when PowerUse applies. Both + /// blank = round the clock. Committed on focus loss / Enter, not per keystroke. + [ObservableProperty] private string? _powerUseWindowStart; + [ObservableProperty] private string? _powerUseWindowEnd; + + /// 5h-usage percentage at which a PowerUse handler session should hand off. + [ObservableProperty] private int _powerUseHandoffPct = 95; + // Set while LoadAsync writes the stored value in, so seeding the property doesn't immediately // write it straight back to the worker. private bool _loadingPowerUse; @@ -115,13 +124,16 @@ public sealed partial class UsageMonitorModalViewModel : ViewModelBase partial void OnPowerUseEnabledChanged(bool value) { if (_loadingPowerUse) return; - _ = SavePowerUseAsync(value); + _ = SavePowerUse(); } /// Read-modify-write, same discipline as SaveStagesAsync: never clobber a field this modal - /// doesn't own. - private async Task SavePowerUseAsync(bool value) + /// doesn't own. All four PowerUse fields go in one write, so committing one box can't drop a + /// value the user just typed into a sibling box. + [RelayCommand] + private async Task SavePowerUse() { + if (_loadingPowerUse) return; try { var settings = await _worker.GetAppSettingsAsync(); @@ -131,7 +143,28 @@ public sealed partial class UsageMonitorModalViewModel : ViewModelBase return; } - await _worker.UpdateAppSettingsAsync(settings with { PowerUseEnabled = value }); + // The worker stores an unparseable time as null; normalize here too so the box shows + // what was actually saved instead of a typo that looks like a live window. + var start = PowerUseWindow.Normalize(PowerUseWindowStart); + var end = PowerUseWindow.Normalize(PowerUseWindowEnd); + var pct = Math.Clamp(PowerUseHandoffPct, 50, 99); + + await _worker.UpdateAppSettingsAsync(settings with + { + PowerUseEnabled = PowerUseEnabled, + PowerUseWindowStart = start, + PowerUseWindowEnd = end, + PowerUseHandoffPct = pct, + }); + + _loadingPowerUse = true; + try + { + PowerUseWindowStart = start; + PowerUseWindowEnd = end; + PowerUseHandoffPct = pct; + } + finally { _loadingPowerUse = false; } } catch (Exception ex) { @@ -192,7 +225,13 @@ public sealed partial class UsageMonitorModalViewModel : ViewModelBase if (settings is not null) { _loadingPowerUse = true; - try { PowerUseEnabled = settings.PowerUseEnabled; } + try + { + PowerUseEnabled = settings.PowerUseEnabled; + PowerUseWindowStart = settings.PowerUseWindowStart; + PowerUseWindowEnd = settings.PowerUseWindowEnd; + PowerUseHandoffPct = settings.PowerUseHandoffPct; + } finally { _loadingPowerUse = false; } } diff --git a/src/ClaudeDo.Ui/Views/Modals/UsageMonitorModalView.axaml b/src/ClaudeDo.Ui/Views/Modals/UsageMonitorModalView.axaml index 76b76ac3..2b90bb74 100644 --- a/src/ClaudeDo.Ui/Views/Modals/UsageMonitorModalView.axaml +++ b/src/ClaudeDo.Ui/Views/Modals/UsageMonitorModalView.axaml @@ -144,6 +144,29 @@ Content="{loc:Tr modals.usageMonitor.powerUse}"/> + + + + + + + + + + diff --git a/src/ClaudeDo.Ui/Views/Modals/UsageMonitorModalView.axaml.cs b/src/ClaudeDo.Ui/Views/Modals/UsageMonitorModalView.axaml.cs index 11546480..1056bf54 100644 --- a/src/ClaudeDo.Ui/Views/Modals/UsageMonitorModalView.axaml.cs +++ b/src/ClaudeDo.Ui/Views/Modals/UsageMonitorModalView.axaml.cs @@ -33,4 +33,19 @@ public partial class UsageMonitorModalView : Window OnStageBoxCommit(sender, e); e.Handled = true; } + + /// Persists the PowerUse window / handoff threshold on blur — a save per keystroke + /// would be one hub round-trip per typed digit. + private void OnPowerUseBoxCommit(object? sender, RoutedEventArgs e) + { + if (DataContext is not UsageMonitorModalViewModel vm) return; + if (vm.SavePowerUseCommand.CanExecute(null)) vm.SavePowerUseCommand.Execute(null); + } + + private void OnPowerUseBoxKeyDown(object? sender, KeyEventArgs e) + { + if (e.Key != Key.Enter) return; + OnPowerUseBoxCommit(sender, e); + e.Handled = true; + } } diff --git a/src/ClaudeDo.Worker/Hub/WorkerHub.cs b/src/ClaudeDo.Worker/Hub/WorkerHub.cs index 6db12bd9..e9310b17 100644 --- a/src/ClaudeDo.Worker/Hub/WorkerHub.cs +++ b/src/ClaudeDo.Worker/Hub/WorkerHub.cs @@ -323,7 +323,10 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub row.UsageThrottleSevenDayHardPct, row.AutoContinueOnUsageLimit, row.PowerUseEnabled, - row.ListHandlerMaxHandoffs); + row.ListHandlerMaxHandoffs, + row.PowerUseWindowStart, + row.PowerUseWindowEnd, + row.PowerUseHandoffPct); } public async Task UpdateAppSettings(AppSettingsDto dto) @@ -363,6 +366,12 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub // The cap is what bounds a runaway handoff loop; a 0 from a stale or hand-written // client would remove that bound entirely, so clamp instead of trusting the wire value. ListHandlerMaxHandoffs = Math.Max(1, dto.ListHandlerMaxHandoffs), + // An unparseable time string is stored as null (= no window) rather than rejected: the + // window is an optional narrowing, and a typo must not silently mean "PowerUse never + // applies". Below 50% a handoff threshold would fire before the session got anywhere. + PowerUseWindowStart = PowerUseWindow.Normalize(dto.PowerUseWindowStart), + PowerUseWindowEnd = PowerUseWindow.Normalize(dto.PowerUseWindowEnd), + PowerUseHandoffPct = Math.Clamp(dto.PowerUseHandoffPct, 50, 99), }); } diff --git a/src/ClaudeDo.Worker/Runner/InteractiveLaunchSpecService.cs b/src/ClaudeDo.Worker/Runner/InteractiveLaunchSpecService.cs index d5e041bc..50f8bd74 100644 --- a/src/ClaudeDo.Worker/Runner/InteractiveLaunchSpecService.cs +++ b/src/ClaudeDo.Worker/Runner/InteractiveLaunchSpecService.cs @@ -45,6 +45,9 @@ public sealed class InteractiveLaunchSpecService private readonly string _claudePath; private readonly HandoffRoundTracker _handoffRounds; + // Local wall clock, injectable so the PowerUse time window is testable without sleeping. + private readonly Func _now; + public InteractiveLaunchSpecService( IDbContextFactory dbFactory, ISessionSkillSeeder skillSeeder, @@ -52,8 +55,10 @@ public sealed class InteractiveLaunchSpecService WorktreeManager wtManager, GitService git, WorkerConfig cfg, - HandoffRoundTracker handoffRounds) + HandoffRoundTracker handoffRounds, + Func? now = null) { + _now = now ?? (() => DateTime.Now); _dbFactory = dbFactory; _skillSeeder = skillSeeder; _skillRegistry = skillRegistry; @@ -580,7 +585,11 @@ public sealed class InteractiveLaunchSpecService // Appending rather than substituting a second prompt set keeps the shared 95% in one place -- // the addendum is written to read as an override of the base prompt's interactive rules. A run // that is not in PowerUse gets a byte-identical prompt to what it got before PowerUse existed. - private static async Task ComposeHandlerPromptAsync( + // + // The optional time window is checked HERE, i.e. per phase at prompt-build time: a chain that + // hands off out of the window drops the addendum from that point on instead of staying + // unattended because the first phase happened to start inside it. + private async Task ComposeHandlerPromptAsync( ClaudeDoDbContext ctx, string listId, PromptKind kind, CancellationToken ct) { var basePrompt = PromptFiles.ReadOrDefault(kind); @@ -588,6 +597,9 @@ public sealed class InteractiveLaunchSpecService var settings = await new AppSettingsRepository(ctx).GetAsync(ct); if (!settings.PowerUseEnabled) return basePrompt; + if (!PowerUseWindow.IsOpen(_now(), settings.PowerUseWindowStart, settings.PowerUseWindowEnd)) + return basePrompt; + var listConfig = await new ListRepository(ctx).GetConfigAsync(listId, ct); if (listConfig?.PowerUseAllowed != true) return basePrompt; diff --git a/tests/ClaudeDo.Data.Tests/PowerUseTests.cs b/tests/ClaudeDo.Data.Tests/PowerUseTests.cs index 77969c63..b7349d66 100644 --- a/tests/ClaudeDo.Data.Tests/PowerUseTests.cs +++ b/tests/ClaudeDo.Data.Tests/PowerUseTests.cs @@ -95,4 +95,56 @@ public sealed class PowerUseTests : IDisposable Assert.True(settings.PowerUseEnabled); Assert.Equal(40, settings.ListHandlerMaxHandoffs); } + + [Fact] + public async Task AppSettings_WindowDefaults_AreEmptyAnd95Percent() + { + var settings = await new AppSettingsRepository(_ctx).GetAsync(); + + Assert.Null(settings.PowerUseWindowStart); + Assert.Null(settings.PowerUseWindowEnd); + Assert.Equal(95, settings.PowerUseHandoffPct); + } + + /// UpdateAsync copies the incoming entity field by field onto the singleton row — a field + /// missing from that block never persists, however correctly the hub filled the entity. That + /// was true for PowerUseEnabled, ListHandlerMaxHandoffs and ModelPresets until 2026-09-02, so + /// pin the whole PowerUse group through the real write path. + [Fact] + public async Task AppSettings_UpdateAsync_PersistsThePowerUseGroup() + { + var repo = new AppSettingsRepository(_ctx); + var current = await repo.GetAsync(); + current.PowerUseEnabled = true; + current.PowerUseWindowStart = "22:00"; + current.PowerUseWindowEnd = "07:00"; + current.PowerUseHandoffPct = 88; + current.ListHandlerMaxHandoffs = 40; + await repo.UpdateAsync(current); + + using var reload = Reload(); + var settings = await new AppSettingsRepository(reload).GetAsync(); + Assert.True(settings.PowerUseEnabled); + Assert.Equal("22:00", settings.PowerUseWindowStart); + Assert.Equal("07:00", settings.PowerUseWindowEnd); + Assert.Equal(88, settings.PowerUseHandoffPct); + Assert.Equal(40, settings.ListHandlerMaxHandoffs); + } + + [Fact] + public async Task AppSettings_UpdateAsync_NormalizesAndClamps() + { + var repo = new AppSettingsRepository(_ctx); + var current = await repo.GetAsync(); + current.PowerUseWindowStart = "nonsense"; + current.PowerUseWindowEnd = " 07:00 "; + current.PowerUseHandoffPct = 5; + await repo.UpdateAsync(current); + + using var reload = Reload(); + var settings = await new AppSettingsRepository(reload).GetAsync(); + Assert.Null(settings.PowerUseWindowStart); + Assert.Equal("07:00", settings.PowerUseWindowEnd); + Assert.Equal(50, settings.PowerUseHandoffPct); + } } diff --git a/tests/ClaudeDo.Data.Tests/PowerUseWindowTests.cs b/tests/ClaudeDo.Data.Tests/PowerUseWindowTests.cs new file mode 100644 index 00000000..97fd0d05 --- /dev/null +++ b/tests/ClaudeDo.Data.Tests/PowerUseWindowTests.cs @@ -0,0 +1,54 @@ +using ClaudeDo.Data.Models; + +namespace ClaudeDo.Data.Tests; + +/// The window decides whether a handler session runs unattended, so its edges matter: an +/// off-by-one at midnight would leave an overnight window either always or never open. +public sealed class PowerUseWindowTests +{ + private static TimeOnly T(string hhmm) => TimeOnly.ParseExact(hhmm, "HH:mm"); + + [Theory] + // Plain window, half-open: start included, end excluded. + [InlineData("09:00", "08:00", "17:00", true)] + [InlineData("08:00", "08:00", "17:00", true)] + [InlineData("17:00", "08:00", "17:00", false)] + [InlineData("07:59", "08:00", "17:00", false)] + [InlineData("23:30", "08:00", "17:00", false)] + // Wrap over midnight. + [InlineData("23:30", "22:00", "07:00", true)] + [InlineData("00:30", "22:00", "07:00", true)] + [InlineData("12:00", "22:00", "07:00", false)] + [InlineData("07:00", "22:00", "07:00", false)] + public void Contains_BothBoundsSet(string now, string start, string end, bool expected) + => Assert.Equal(expected, PowerUseWindow.Contains(T(now), T(start), T(end))); + + /// Only one bound is not a window — it must behave exactly like no window, or a half-filled + /// pair of boxes would silently switch PowerUse off for most of the day. + [Fact] + public void Contains_OnlyOneBoundSet_IsAlwaysOpen() + { + Assert.True(PowerUseWindow.Contains(T("12:00"), T("22:00"), null)); + Assert.True(PowerUseWindow.Contains(T("12:00"), null, T("07:00"))); + Assert.True(PowerUseWindow.Contains(T("12:00"), null, null)); + } + + [Theory] + [InlineData("22:00", "22:00")] + [InlineData(" 07:30 ", "07:30")] + [InlineData("7:30", null)] + [InlineData("22:00:00", null)] + [InlineData("nonsense", null)] + [InlineData("", null)] + [InlineData(null, null)] + public void Normalize_KeepsOnlyExactHhMm(string? input, string? expected) + => Assert.Equal(expected, PowerUseWindow.Normalize(input)); + + [Fact] + public void IsOpen_UsesTheTimeOfDayOfTheGivenInstant() + { + Assert.True(PowerUseWindow.IsOpen(new DateTime(2026, 9, 2, 23, 30, 0), "22:00", "07:00")); + Assert.False(PowerUseWindow.IsOpen(new DateTime(2026, 9, 2, 12, 0, 0), "22:00", "07:00")); + Assert.True(PowerUseWindow.IsOpen(new DateTime(2026, 9, 2, 12, 0, 0), null, null)); + } +} diff --git a/tests/ClaudeDo.Worker.Tests/Hub/PowerUseSettingsHubTests.cs b/tests/ClaudeDo.Worker.Tests/Hub/PowerUseSettingsHubTests.cs new file mode 100644 index 00000000..c00ea538 --- /dev/null +++ b/tests/ClaudeDo.Worker.Tests/Hub/PowerUseSettingsHubTests.cs @@ -0,0 +1,101 @@ +using ClaudeDo.Worker.Hub; +using ClaudeDo.Worker.Tests.Infrastructure; +using Xunit; + +namespace ClaudeDo.Worker.Tests.Hub; + +/// The PowerUse window and handoff threshold are edited on the usage monitor, but Settings→General +/// rebuilds the whole DTO positionally on save. So the contract that matters is: whatever +/// GetAppSettings hands out, feeding it straight back must change nothing. +public sealed class PowerUseSettingsHubTests : IDisposable +{ + private readonly DbFixture _db = new(); + + public void Dispose() => _db.Dispose(); + + private WorkerHub CreateHub() + { + var broadcaster = new HubBroadcaster(new CapturingHubContext()); + var hub = new WorkerHub( + null!, null!, null!, null!, broadcaster, _db.CreateFactory(), + null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, + null!, new ClaudeDo.Worker.Online.OnlineInboxConfig(), new ClaudeDo.Worker.OnlineRefreshTokenStore(new ClaudeDo.Worker.DpapiTokenStore("unused.token")), + new ClaudeDo.Worker.Runner.PendingQuestionRegistry(), null!); + hub.Clients = new FakeHubCallerClients(new RecordingClientProxy()); + hub.Context = new FakeHubCallerContext(); + return hub; + } + + [Fact] + public async Task GetAppSettings_Defaults_HaveNoWindowAnd95Percent() + { + var settings = await CreateHub().GetAppSettings(); + + Assert.Null(settings.PowerUseWindowStart); + Assert.Null(settings.PowerUseWindowEnd); + Assert.Equal(95, settings.PowerUseHandoffPct); + } + + [Fact] + public async Task UpdateAppSettings_RoundTripsTheWindowAndThreshold() + { + var hub = CreateHub(); + + var before = await hub.GetAppSettings(); + await hub.UpdateAppSettings(before with + { + PowerUseEnabled = true, + PowerUseWindowStart = "22:00", + PowerUseWindowEnd = "07:00", + PowerUseHandoffPct = 88, + }); + var after = await hub.GetAppSettings(); + + Assert.True(after.PowerUseEnabled); + Assert.Equal("22:00", after.PowerUseWindowStart); + Assert.Equal("07:00", after.PowerUseWindowEnd); + Assert.Equal(88, after.PowerUseHandoffPct); + } + + /// The Settings→General case: a save that touches none of the PowerUse fields must leave them + /// exactly as they were. + [Fact] + public async Task UpdateAppSettings_UnchangedDto_LeavesPowerUseFieldsAlone() + { + var hub = CreateHub(); + await hub.UpdateAppSettings((await hub.GetAppSettings()) with + { + PowerUseEnabled = true, + PowerUseWindowStart = "22:00", + PowerUseWindowEnd = "07:00", + PowerUseHandoffPct = 88, + }); + + var stored = await hub.GetAppSettings(); + await hub.UpdateAppSettings(stored); + var after = await hub.GetAppSettings(); + + Assert.Equal(stored.PowerUseEnabled, after.PowerUseEnabled); + Assert.Equal(stored.PowerUseWindowStart, after.PowerUseWindowStart); + Assert.Equal(stored.PowerUseWindowEnd, after.PowerUseWindowEnd); + Assert.Equal(stored.PowerUseHandoffPct, after.PowerUseHandoffPct); + } + + [Fact] + public async Task UpdateAppSettings_InvalidTimeAndOutOfRangePct_AreNormalized() + { + var hub = CreateHub(); + + await hub.UpdateAppSettings((await hub.GetAppSettings()) with + { + PowerUseWindowStart = "25:99", + PowerUseWindowEnd = "07:00", + PowerUseHandoffPct = 120, + }); + var after = await hub.GetAppSettings(); + + Assert.Null(after.PowerUseWindowStart); + Assert.Equal("07:00", after.PowerUseWindowEnd); + Assert.Equal(99, after.PowerUseHandoffPct); + } +} diff --git a/tests/ClaudeDo.Worker.Tests/Runner/InteractiveLaunchSpecServiceTests.cs b/tests/ClaudeDo.Worker.Tests/Runner/InteractiveLaunchSpecServiceTests.cs index 80e9b453..133f4844 100644 --- a/tests/ClaudeDo.Worker.Tests/Runner/InteractiveLaunchSpecServiceTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Runner/InteractiveLaunchSpecServiceTests.cs @@ -73,12 +73,14 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable => Task.FromResult>(Installed); } - private InteractiveLaunchSpecService BuildService(HandoffRoundTracker? handoffRounds = null) => + private InteractiveLaunchSpecService BuildService( + HandoffRoundTracker? handoffRounds = null, DateTime? now = null) => new(_db.CreateFactory(), _seeder, _registry, new WorktreeManager(new GitService(), _db.CreateFactory(), new WorkerConfig(), NullLogger.Instance), new GitService(), new WorkerConfig { ClaudeBin = _claudeStubPath }, - handoffRounds ?? new HandoffRoundTracker()); + handoffRounds ?? new HandoffRoundTracker(), + now is { } t ? () => t : null); private async Task SeedListAsync(string? workingDir = null, string name = "L") { @@ -685,9 +687,19 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable }); } - private async Task ReadHandlerSystemPromptAsync(string listId, string taskId) + private async Task SetPowerUseWindowAsync(string? start, string? end) { - var svc = BuildService(); + using var ctx = _db.CreateContext(); + await new AppSettingsRepository(ctx).GetAsync(); + var row = await ctx.AppSettings.FirstAsync(s => s.Id == AppSettingsEntity.SingletonId); + row.PowerUseWindowStart = start; + row.PowerUseWindowEnd = end; + await ctx.SaveChangesAsync(); + } + + private async Task ReadHandlerSystemPromptAsync(string listId, string taskId, DateTime? now = null) + { + var svc = BuildService(now: now); var spec = await svc.BuildForMergeHelperAsync( new[] { taskId }, listId, Guid.NewGuid().ToString(), CancellationToken.None); var sessionDir = TrackSessionDir(spec); @@ -752,6 +764,67 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable Assert.StartsWith(PromptFiles.ReadOrDefault(PromptKind.MergeHelperTriage), prompt); } + /// The window is a third key on top of the global switch and the list opt-in. Both corners + /// use the same overnight window, so only the injected clock differs — that is the whole point + /// of injecting it rather than reading DateTime.Now inside the composition. + [Fact] + public async Task BuildForMergeHelperAsync_PowerUseOutsideWindow_LeavesPromptUntouched() + { + var repo = Path.Combine(_tempDir, "puOutsideWindow"); + Directory.CreateDirectory(repo); + var listId = await SeedListAsync(workingDir: repo); + var taskId = Guid.NewGuid().ToString(); + await SeedTaskAsync(taskId, listId, TaskStatus.WaitingForReview); + + await SetPowerUseEnabledAsync(true); + await SetPowerUseAllowedAsync(listId, true); + await SetPowerUseWindowAsync("22:00", "07:00"); + + var prompt = await ReadHandlerSystemPromptAsync( + listId, taskId, now: new DateTime(2026, 9, 2, 12, 0, 0)); + + Assert.DoesNotContain(AddendumMarker, prompt); + Assert.Equal(PromptFiles.ReadOrDefault(PromptKind.MergeHelperTriage), prompt); + } + + [Fact] + public async Task BuildForMergeHelperAsync_PowerUseInsideWindow_AppendsAddendum() + { + var repo = Path.Combine(_tempDir, "puInsideWindow"); + Directory.CreateDirectory(repo); + var listId = await SeedListAsync(workingDir: repo); + var taskId = Guid.NewGuid().ToString(); + await SeedTaskAsync(taskId, listId, TaskStatus.WaitingForReview); + + await SetPowerUseEnabledAsync(true); + await SetPowerUseAllowedAsync(listId, true); + await SetPowerUseWindowAsync("22:00", "07:00"); + + var prompt = await ReadHandlerSystemPromptAsync( + listId, taskId, now: new DateTime(2026, 9, 2, 23, 30, 0)); + + Assert.Contains(AddendumMarker, prompt); + } + + [Fact] + public async Task BuildForMergeHelperAsync_HalfSetWindow_AppendsAddendumAtAnyTime() + { + var repo = Path.Combine(_tempDir, "puHalfWindow"); + Directory.CreateDirectory(repo); + var listId = await SeedListAsync(workingDir: repo); + var taskId = Guid.NewGuid().ToString(); + await SeedTaskAsync(taskId, listId, TaskStatus.WaitingForReview); + + await SetPowerUseEnabledAsync(true); + await SetPowerUseAllowedAsync(listId, true); + await SetPowerUseWindowAsync("22:00", null); + + var prompt = await ReadHandlerSystemPromptAsync( + listId, taskId, now: new DateTime(2026, 9, 2, 12, 0, 0)); + + Assert.Contains(AddendumMarker, prompt); + } + [Fact] public async Task BuildForMergeHelperAsync_EmptyTaskIds_ThrowsInvalidOperation() {