diff --git a/src/ClaudeDo.Data/CLAUDE.md b/src/ClaudeDo.Data/CLAUDE.md index 994280d3..2913814c 100644 --- a/src/ClaudeDo.Data/CLAUDE.md +++ b/src/ClaudeDo.Data/CLAUDE.md @@ -8,7 +8,7 @@ Shared data layer: models, repositories, SQLite infrastructure, and git operatio - **ListEntity** — Id, Name, WorkingDir, DefaultCommitType, CreatedAt, IsManual (reminder list — tasks created here default to `IsManual`) - **ListConfigEntity** — ListId (PK, 1:1 with list), Model, SystemPrompt, AgentPath, MaxTurns, SessionSkills, VerifyCommand (all nullable). `VerifyCommand` is an optional post-merge gate (see `ClaudeDo.Worker/CLAUDE.md` → TaskMergeService): null/blank = today's behavior, no gate. - **WorktreeEntity** — TaskId (PK, 1:1 with task), Path, BranchName, BaseCommit, HeadCommit, DiffStat, MergeCommit (nullable; SHA of the merge commit this worktree's branch produced on the target branch, stamped by `TaskMergeService` the moment a merge/continue-merge succeeds — the only thing that makes `revert_merge` possible without heuristically searching `git log`; null for any worktree merged before this field existed), State (Active|Merged|Discarded|Kept) -- **TaskRunEntity** — per-run record (session_id, tokens, turns, result, structured output, exit code, log path, nullable `Model` — the model the run actually executed with, column `model`) +- **TaskRunEntity** — per-run record (session_id, turns, result, structured output, exit code, log path, nullable `Model` — the model the run actually executed with, column `model`; token fields `TokensIn`/`TokensOut`/`CacheReadTokens`/`CacheWriteTokens`, columns `tokens_in`/`tokens_out`/`cache_read_tokens`/`cache_write_tokens` — populated from the session transcript, not the stream-json event, as a per-run delta against prior runs on the same `session_id`; see `ClaudeDo.Worker/CLAUDE.md` → Execution History) - **PrimeScheduleEntity** — Id, Days (`[Flags] PrimeDays` weekday bitmask, stored as `days_of_week` int), TimeOfDay, Enabled, LastRunAt, PromptOverride, CreatedAt. Recurs on the selected weekdays; no date range. - **DailyNoteEntity** — Id, Date (DateOnly), Text, SortOrder, CreatedAt → table `daily_notes` - **WeekReportEntity** — Id, StartDate/EndDate (DateOnly), Markdown, GeneratedAt → table `week_reports`, unique index on (start_date, end_date) @@ -45,7 +45,7 @@ All repositories use EF Core LINQ queries via `ClaudeDoDbContext`. The atomic `Q ## Schema -Tables: `lists`, `tasks`, `worktrees`, `list_config`, `task_runs`, `subtasks`, `app_settings`, `prime_schedules`, `daily_notes`, `week_reports`, `task_attachments`. Managed by EF Core migrations in the `Migrations/` folder. The `tasks` table holds `status`, `planning_phase` (default `none`), and `blocked_by_task_id` (FK to `tasks.id`, `ON DELETE SET NULL`). Migration `WeeklyReport` added `daily_notes`, `week_reports`, and the two new `app_settings` columns. Migration `DailyPrepMaxTasks` added the `daily_prep_max_tasks` column to `app_settings` (no new tables). Migration `AddTaskAttachments` created the `task_attachments` table. Migration `AddModelPresetsAndManualFlag` added `app_settings.model_presets` plus the `is_manual` flags on `tasks` and `lists`. Migration `AddHandlerCommitRange` added the nullable `handler_base_commit`/`handler_head_commit` columns to `tasks`. Migration `AddUsageGateAndRunModel` added `app_settings.usage_gate_five_hour_pct`/`usage_gate_seven_day_pct` (defaults 80/90) and the nullable `task_runs.model` column. Migration `AddWorktreeMergeCommit` added the nullable `worktrees.merge_commit` column. Migration `AddVerifyCommand` added the nullable `list_config.verify_command` column. `TaskRepository.DeleteAsync` and `ListRepository.DeleteAsync` also delete the on-disk attachment dir(s) via an optional `AttachmentStore` ctor param (defaults to the production store). +Tables: `lists`, `tasks`, `worktrees`, `list_config`, `task_runs`, `subtasks`, `app_settings`, `prime_schedules`, `daily_notes`, `week_reports`, `task_attachments`. Managed by EF Core migrations in the `Migrations/` folder. The `tasks` table holds `status`, `planning_phase` (default `none`), and `blocked_by_task_id` (FK to `tasks.id`, `ON DELETE SET NULL`). Migration `WeeklyReport` added `daily_notes`, `week_reports`, and the two new `app_settings` columns. Migration `DailyPrepMaxTasks` added the `daily_prep_max_tasks` column to `app_settings` (no new tables). Migration `AddTaskAttachments` created the `task_attachments` table. Migration `AddModelPresetsAndManualFlag` added `app_settings.model_presets` plus the `is_manual` flags on `tasks` and `lists`. Migration `AddHandlerCommitRange` added the nullable `handler_base_commit`/`handler_head_commit` columns to `tasks`. Migration `AddUsageGateAndRunModel` added `app_settings.usage_gate_five_hour_pct`/`usage_gate_seven_day_pct` (defaults 80/90) and the nullable `task_runs.model` column. Migration `AddWorktreeMergeCommit` added the nullable `worktrees.merge_commit` column. Migration `AddVerifyCommand` added the nullable `list_config.verify_command` column. Migration `AddRunCacheTokens` added the nullable `task_runs.cache_read_tokens`/`cache_write_tokens` columns. `TaskRepository.DeleteAsync` and `ListRepository.DeleteAsync` also delete the on-disk attachment dir(s) via an optional `AttachmentStore` ctor param (defaults to the production store). ## Conventions diff --git a/src/ClaudeDo.Data/Configuration/TaskRunEntityConfiguration.cs b/src/ClaudeDo.Data/Configuration/TaskRunEntityConfiguration.cs index 6333b03e..98c18858 100644 --- a/src/ClaudeDo.Data/Configuration/TaskRunEntityConfiguration.cs +++ b/src/ClaudeDo.Data/Configuration/TaskRunEntityConfiguration.cs @@ -24,6 +24,8 @@ public class TaskRunEntityConfiguration : IEntityTypeConfiguration r.TurnCount).HasColumnName("turn_count"); builder.Property(r => r.TokensIn).HasColumnName("tokens_in"); builder.Property(r => r.TokensOut).HasColumnName("tokens_out"); + builder.Property(r => r.CacheReadTokens).HasColumnName("cache_read_tokens"); + builder.Property(r => r.CacheWriteTokens).HasColumnName("cache_write_tokens"); builder.Property(r => r.LogPath).HasColumnName("log_path"); builder.Property(r => r.StartedAt).HasColumnName("started_at"); builder.Property(r => r.FinishedAt).HasColumnName("finished_at"); diff --git a/src/ClaudeDo.Data/Migrations/20260805132801_AddRunCacheTokens.Designer.cs b/src/ClaudeDo.Data/Migrations/20260805132801_AddRunCacheTokens.Designer.cs new file mode 100644 index 00000000..1ab8a6df --- /dev/null +++ b/src/ClaudeDo.Data/Migrations/20260805132801_AddRunCacheTokens.Designer.cs @@ -0,0 +1,844 @@ +// +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("20260805132801_AddRunCacheTokens")] + partial class AddRunCacheTokens + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "8.0.11"); + + modelBuilder.Entity("ClaudeDo.Data.Models.AppSettingsEntity", b => + { + b.Property("Id") + .HasColumnType("INTEGER") + .HasColumnName("id"); + + b.Property("CentralWorktreeRoot") + .HasColumnType("TEXT") + .HasColumnName("central_worktree_root"); + + b.Property("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(30) + .HasColumnName("default_max_turns"); + + b.Property("DefaultModel") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("sonnet") + .HasColumnName("default_model"); + + b.Property("DefaultPermissionMode") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("bypassPermissions") + .HasColumnName("default_permission_mode"); + + b.Property("MaxParallelExecutions") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(1) + .HasColumnName("max_parallel_executions"); + + b.Property("ModelPresets") + .HasColumnType("TEXT") + .HasColumnName("model_presets"); + + 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("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, + DailyPrepMaxTasks = 5, + DefaultClaudeInstructions = "", + DefaultMaxTurns = 100, + DefaultModel = "sonnet", + DefaultPermissionMode = "auto", + MaxParallelExecutions = 1, + StandupWeekday = 3, + UsageGateFiveHourPct = 80, + UsageGateSevenDayPct = 90, + 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("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("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("Description") + .HasColumnType("TEXT") + .HasColumnName("description"); + + 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("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("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("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.HasKey("Id"); + + b.HasIndex("BlockedByTaskId") + .HasDatabaseName("idx_tasks_blocked_by"); + + b.HasIndex("ListId") + .HasDatabaseName("idx_tasks_list_id"); + + b.HasIndex("ParentTaskId") + .HasDatabaseName("idx_tasks_parent_task_id"); + + b.HasIndex("Status") + .HasDatabaseName("idx_tasks_status"); + + b.HasIndex("ListId", "SortOrder") + .HasDatabaseName("idx_tasks_list_sort"); + + b.ToTable("tasks", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b => + { + b.Property("Id") + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("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("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("RunNumber") + .HasColumnType("INTEGER") + .HasColumnName("run_number"); + + b.Property("SessionId") + .HasColumnType("TEXT") + .HasColumnName("session_id"); + + b.Property("StartedAt") + .HasColumnType("TEXT") + .HasColumnName("started_at"); + + b.Property("StructuredOutputJson") + .HasColumnType("TEXT") + .HasColumnName("structured_output"); + + b.Property("TaskId") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("task_id"); + + b.Property("TokensIn") + .HasColumnType("INTEGER") + .HasColumnName("tokens_in"); + + b.Property("TokensOut") + .HasColumnType("INTEGER") + .HasColumnName("tokens_out"); + + b.Property("TurnCount") + .HasColumnType("INTEGER") + .HasColumnName("turn_count"); + + b.HasKey("Id"); + + b.HasIndex("TaskId") + .HasDatabaseName("idx_task_runs_task_id"); + + b.ToTable("task_runs", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.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.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/20260805132801_AddRunCacheTokens.cs b/src/ClaudeDo.Data/Migrations/20260805132801_AddRunCacheTokens.cs new file mode 100644 index 00000000..1a460707 --- /dev/null +++ b/src/ClaudeDo.Data/Migrations/20260805132801_AddRunCacheTokens.cs @@ -0,0 +1,38 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace ClaudeDo.Data.Migrations +{ + /// + public partial class AddRunCacheTokens : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "cache_read_tokens", + table: "task_runs", + type: "INTEGER", + nullable: true); + + migrationBuilder.AddColumn( + name: "cache_write_tokens", + table: "task_runs", + type: "INTEGER", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "cache_read_tokens", + table: "task_runs"); + + migrationBuilder.DropColumn( + name: "cache_write_tokens", + table: "task_runs"); + } + } +} diff --git a/src/ClaudeDo.Data/Migrations/ClaudeDoDbContextModelSnapshot.cs b/src/ClaudeDo.Data/Migrations/ClaudeDoDbContextModelSnapshot.cs index 331b471f..ab13392b 100644 --- a/src/ClaudeDo.Data/Migrations/ClaudeDoDbContextModelSnapshot.cs +++ b/src/ClaudeDo.Data/Migrations/ClaudeDoDbContextModelSnapshot.cs @@ -576,6 +576,14 @@ namespace ClaudeDo.Data.Migrations .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"); diff --git a/src/ClaudeDo.Data/Models/TaskRunEntity.cs b/src/ClaudeDo.Data/Models/TaskRunEntity.cs index 38058daa..dd5ecfb2 100644 --- a/src/ClaudeDo.Data/Models/TaskRunEntity.cs +++ b/src/ClaudeDo.Data/Models/TaskRunEntity.cs @@ -15,6 +15,8 @@ public sealed class TaskRunEntity public int? TurnCount { get; set; } public int? TokensIn { get; set; } public int? TokensOut { get; set; } + public int? CacheReadTokens { get; set; } + public int? CacheWriteTokens { get; set; } public string? LogPath { get; set; } public DateTime? StartedAt { get; set; } public DateTime? FinishedAt { get; set; } diff --git a/src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs index 3dba893d..7746b542 100644 --- a/src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs @@ -219,6 +219,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable [ObservableProperty] private string? _branchLine; [ObservableProperty] private int _turns; [ObservableProperty] private int _tokens; + [ObservableProperty] private string? _tokensBreakdown; [ObservableProperty] private int _diffAdditions; [ObservableProperty] private int _diffDeletions; [ObservableProperty] private int _commitsOnBranch; @@ -619,7 +620,15 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable // Restore turn/token counts from the last run so a reloaded terminal task // shows its real turns instead of "0/max". Turns = latestRun?.TurnCount ?? 0; - Tokens = (latestRun?.TokensIn ?? 0) + (latestRun?.TokensOut ?? 0); + // Raw total = what actually counts against the 5h/7d Claude usage limit: cached + // context resend (cache-read + cache-write) dwarfs fresh input/output on a + // resumed session, so it must be included, not just the uncached input/output. + var tokensIn = latestRun?.TokensIn ?? 0; + var tokensOut = latestRun?.TokensOut ?? 0; + var cacheRead = latestRun?.CacheReadTokens ?? 0; + var cacheWrite = latestRun?.CacheWriteTokens ?? 0; + Tokens = tokensIn + tokensOut + cacheRead + cacheWrite; + TokensBreakdown = $"in {tokensIn} · out {tokensOut} · cache-read {cacheRead} · cache-write {cacheWrite}"; Monitor.ApplyOutcome(entity.Result, latestRun?.ErrorMarkdown); Monitor.SetTaskId(row.Id); diff --git a/src/ClaudeDo.Ui/Views/Islands/Detail/WorkConsole.axaml b/src/ClaudeDo.Ui/Views/Islands/Detail/WorkConsole.axaml index 0110347a..f2e1dd07 100644 --- a/src/ClaudeDo.Ui/Views/Islands/Detail/WorkConsole.axaml +++ b/src/ClaudeDo.Ui/Views/Islands/Detail/WorkConsole.axaml @@ -115,6 +115,11 @@ Foreground="{DynamicResource TextMuteBrush}" /> + + diff --git a/src/ClaudeDo.Worker/CLAUDE.md b/src/ClaudeDo.Worker/CLAUDE.md index 29169cf9..594dd679 100644 --- a/src/ClaudeDo.Worker/CLAUDE.md +++ b/src/ClaudeDo.Worker/CLAUDE.md @@ -21,7 +21,7 @@ Worker/ Report/ — ClaudeHistoryReader, WeekReportPromptBuilder, WeekReportService; interfaces in Report/Interfaces/ Prime/ — daily-prep ("Prime Claude"): PrimeScheduler (BackgroundService), PrimeRunner (runs the daily prep), DailyPrepPrompt (fixed prompt + CLI args + LogPath() helper), NextDueCalculator, PrimeScheduleSignal; interfaces in Prime/Interfaces/ (IPrimeRunner, IPrimeClock, IPrimeScheduleSignal, IPrimeBroadcaster) Online/ — optional Online Inbox sync: OnlineInboxConfig (config record), Dtos (RemoteList/RemoteTask/MirrorTask), IOnlineInboxApi, OnlineInboxApiClient (typed HttpClient, bearer auth, HTTPS guard), OnlineTokenStore (DPAPI refresh-token store, Windows-only), StaticTokenAuthProvider (default/test IOnlineAuthProvider), ZitadelAuthProvider (OIDC discovery + refresh-token flow), OnlineSyncService (BackgroundService: reconcile loop), OnlineBacklog (Idle-backlog filter/query); interface in Online/Interfaces/ (IOnlineAuthProvider) - Usage/ — OAuth usage monitor: UsageModels (UsageBucket/UsageLimitRow/UsageSnapshot), ClaudeOAuthUsageClient (reads the access token Claude Code keeps fresh at `~/.claude/.credentials.json`, calls `GET https://api.anthropic.com/api/oauth/usage`; defensive parsing — missing/null buckets → null, missing `limits` → empty list; never logs the token), UsageState (threadsafe singleton; a failed poll never overwrites the last good snapshot, only sets `LastError`), UsageMonitorService (BackgroundService, polls on `usage_poll_interval_seconds`, one poll at startup, logs a failure at most once per distinct error message, broadcasts `HubBroadcaster.UsageUpdated` after every tick via `UsageSnapshotBuilder`), UsageSnapshotBuilder (builds the Hub-facing `UsageSnapshotDto` from `UsageState` + `IUsageGate` + `AppSettings` thresholds — the one place `WorkerHub.GetUsageSnapshot` and `UsageMonitorService` share the stale/threshold/gate logic), TranscriptUsageReader (aggregates Claude Code transcript token usage from `~/.claude/projects/**/*.jsonl` by date/model/scope (ClaudeDo vs Other), deduped by requestId, with a per-file length+mtime cache), UsageGate (reads `UsageState` + `AppSettings.UsageGateFiveHourPct`/`UsageGateSevenDayPct`, returns a `UsageGateDecision(IsBlocked, Reason)`; `Utilization` from `UsageBucket` is already a 0–100 percent, compared directly against the threshold with `>=`; threshold `0` = that bucket never gates; fail-open — no snapshot yet, a failed last poll, or a settings-read error all resolve to not-blocked); interfaces in Usage/Interfaces/ (IUsageClient, ITranscriptUsageReader, IUsageGate) + Usage/ — OAuth usage monitor: UsageModels (UsageBucket/UsageLimitRow/UsageSnapshot), ClaudeOAuthUsageClient (reads the access token Claude Code keeps fresh at `~/.claude/.credentials.json`, calls `GET https://api.anthropic.com/api/oauth/usage`; defensive parsing — missing/null buckets → null, missing `limits` → empty list; never logs the token), UsageState (threadsafe singleton; a failed poll never overwrites the last good snapshot, only sets `LastError`), UsageMonitorService (BackgroundService, polls on `usage_poll_interval_seconds`, one poll at startup, logs a failure at most once per distinct error message, broadcasts `HubBroadcaster.UsageUpdated` after every tick via `UsageSnapshotBuilder`), UsageSnapshotBuilder (builds the Hub-facing `UsageSnapshotDto` from `UsageState` + `IUsageGate` + `AppSettings` thresholds — the one place `WorkerHub.GetUsageSnapshot` and `UsageMonitorService` share the stale/threshold/gate logic), TranscriptUsageReader (aggregates Claude Code transcript token usage from `~/.claude/projects/**/*.jsonl` by date/model/scope (ClaudeDo vs Other), deduped by requestId, with a per-file length+mtime cache; ``-model lines are skipped everywhere, not real API calls; also exposes `ReadSessionTotalsAsync(sessionId)` — cumulative raw input/output/cache-read/cache-write totals for one session, located by `{sessionId}.jsonl`, used by `TaskRunner` to populate `task_runs`' per-run token fields), UsageGate (reads `UsageState` + `AppSettings.UsageGateFiveHourPct`/`UsageGateSevenDayPct`, returns a `UsageGateDecision(IsBlocked, Reason)`; `Utilization` from `UsageBucket` is already a 0–100 percent, compared directly against the threshold with `>=`; threshold `0` = that bucket never gates; fail-open — no snapshot yet, a failed last poll, or a settings-read error all resolve to not-blocked); interfaces in Usage/Interfaces/ (IUsageClient, ITranscriptUsageReader, IUsageGate) ``` Interfaces (e.g. `IQueueWaker`, `IPrimeClock`, `ITaskStateService`) live in an `Interfaces/` subfolder within their area; the namespace stays the area namespace. @@ -180,7 +180,15 @@ A child that hits a roadblock (fails, or reports `CLAUDEDO_BLOCKED` roadblocks) ## Execution History Each CLI invocation is recorded in the `task_runs` table via `TaskRunRepository`: -- Fields: `session_id`, input/output/cache token counts, turn count, `result` text, structured output JSON +- Fields: `session_id`, turn count, `result` text, structured output JSON, and the four raw token + fields (`tokens_in`/`tokens_out`/`cache_read_tokens`/`cache_write_tokens`) — **not** read from the + stream-json "result" event's `usage.input_tokens` (that's only the uncached remainder of one API + call and undercounts the real prompt size by orders of magnitude once caching kicks in). Instead + `TaskRunner.ApplyUsageAsync` reads `ITranscriptUsageReader.ReadSessionTotalsAsync(sessionId)` — + the session transcript's cumulative totals across every assistant message — and stores the + **delta** against prior `task_runs` rows sharing the same `session_id`, so a `--resume`'d run + doesn't double-count the turns already billed to an earlier run. A missing/unreadable transcript + leaves all four fields `null`; it never fails the run. - Enables auto-retry on failure (resume last session) and multi-turn follow-up via `ContinueAsync` ## Multi-Turn / Continue diff --git a/src/ClaudeDo.Worker/Runner/TaskRunner.cs b/src/ClaudeDo.Worker/Runner/TaskRunner.cs index 820b2457..7e00e336 100644 --- a/src/ClaudeDo.Worker/Runner/TaskRunner.cs +++ b/src/ClaudeDo.Worker/Runner/TaskRunner.cs @@ -6,6 +6,8 @@ using ClaudeDo.Worker.Config; using ClaudeDo.Worker.Hub; using ClaudeDo.Worker.Skills; using ClaudeDo.Worker.State; +using ClaudeDo.Worker.Usage; +using ClaudeDo.Worker.Usage.Interfaces; using Microsoft.EntityFrameworkCore; using TaskStatus = ClaudeDo.Data.Models.TaskStatus; @@ -24,6 +26,7 @@ public sealed class TaskRunner private readonly TaskRunTokenRegistry _tokens; private readonly AttachmentStore _attachments; private readonly ISessionSkillSeeder _skillSeeder; + private readonly ITranscriptUsageReader _usageReader; public TaskRunner( IClaudeProcess claude, @@ -36,7 +39,8 @@ public sealed class TaskRunner ITaskStateService state, TaskRunTokenRegistry tokens, AttachmentStore attachments, - ISessionSkillSeeder skillSeeder) + ISessionSkillSeeder skillSeeder, + ITranscriptUsageReader usageReader) { _claude = claude; _dbFactory = dbFactory; @@ -49,6 +53,7 @@ public sealed class TaskRunner _tokens = tokens; _attachments = attachments; _skillSeeder = skillSeeder; + _usageReader = usageReader; } public async Task RunAsync(TaskEntity task, string slot, CancellationToken ct, bool alreadyClaimed = false) @@ -361,8 +366,8 @@ public sealed class TaskRunner run.ErrorMarkdown = result.ErrorMarkdown; run.ExitCode = result.ExitCode; run.TurnCount = result.TurnCount; - run.TokensIn = result.TokensIn; - run.TokensOut = result.TokensOut; + if (result.SessionId is not null) + await ApplyUsageAsync(run, taskId, result.SessionId); run.FinishedAt = DateTime.UtcNow; using (var context = _dbFactory.CreateDbContext()) @@ -394,6 +399,40 @@ public sealed class TaskRunner } } + /// Populates the run's raw token fields from the session transcript (input, output, + /// cache-read, cache-write — the API's "input_tokens" alone is only the uncached + /// remainder and undercounts the real prompt size by orders of magnitude). A resumed + /// session's transcript is cumulative, so the delta against prior runs sharing the same + /// SessionId is stored, not the running total. Any failure here (missing/unreadable + /// transcript) leaves the fields null and must never fail the run itself. + private async Task ApplyUsageAsync(TaskRunEntity run, string taskId, string sessionId) + { + try + { + var totals = await _usageReader.ReadSessionTotalsAsync(sessionId, CancellationToken.None); + if (totals is null) return; + + List priorRuns; + using (var context = _dbFactory.CreateDbContext()) + priorRuns = await new TaskRunRepository(context).GetByTaskIdAsync(taskId, CancellationToken.None); + + var sameSession = priorRuns.Where(r => r.Id != run.Id && r.SessionId == sessionId).ToList(); + var priorInput = sameSession.Sum(r => (long)(r.TokensIn ?? 0)); + var priorOutput = sameSession.Sum(r => (long)(r.TokensOut ?? 0)); + var priorCacheRead = sameSession.Sum(r => (long)(r.CacheReadTokens ?? 0)); + var priorCacheWrite = sameSession.Sum(r => (long)(r.CacheWriteTokens ?? 0)); + + run.TokensIn = (int)Math.Max(0, totals.InputTokens - priorInput); + run.TokensOut = (int)Math.Max(0, totals.OutputTokens - priorOutput); + run.CacheReadTokens = (int)Math.Max(0, totals.CacheReadTokens - priorCacheRead); + run.CacheWriteTokens = (int)Math.Max(0, totals.CacheCreationTokens - priorCacheWrite); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to read session usage totals for task {TaskId}, session {SessionId}", taskId, sessionId); + } + } + private async Task HandleSuccess(TaskEntity task, ListEntity list, string slot, WorktreeContext? wtCtx, RunResult result, CancellationToken ct) { if (wtCtx is not null) diff --git a/src/ClaudeDo.Worker/Usage/Interfaces/ITranscriptUsageReader.cs b/src/ClaudeDo.Worker/Usage/Interfaces/ITranscriptUsageReader.cs index 2a297020..aaef1f63 100644 --- a/src/ClaudeDo.Worker/Usage/Interfaces/ITranscriptUsageReader.cs +++ b/src/ClaudeDo.Worker/Usage/Interfaces/ITranscriptUsageReader.cs @@ -4,4 +4,9 @@ public interface ITranscriptUsageReader { Task> ReadAsync( DateOnly start, DateOnly end, CancellationToken ct = default); + + /// Cumulative raw token totals for one session's transcript file + /// (located by {sessionId}.jsonl under the projects root), or null when + /// no matching transcript file can be found or read. + Task ReadSessionTotalsAsync(string sessionId, CancellationToken ct = default); } diff --git a/src/ClaudeDo.Worker/Usage/TranscriptUsageReader.cs b/src/ClaudeDo.Worker/Usage/TranscriptUsageReader.cs index 693c89a8..52db99fe 100644 --- a/src/ClaudeDo.Worker/Usage/TranscriptUsageReader.cs +++ b/src/ClaudeDo.Worker/Usage/TranscriptUsageReader.cs @@ -62,6 +62,33 @@ public sealed class TranscriptUsageReader : ITranscriptUsageReader return Task.FromResult>(rows); } + public Task ReadSessionTotalsAsync(string sessionId, CancellationToken ct = default) + { + if (string.IsNullOrWhiteSpace(sessionId) || !Directory.Exists(_projectsRoot)) + return Task.FromResult(null); + + var file = Directory + .EnumerateFiles(_projectsRoot, $"{sessionId}.jsonl", SearchOption.AllDirectories) + .FirstOrDefault(); + if (file is null) return Task.FromResult(null); + + var seenKeys = new HashSet(); + long input = 0, output = 0, cacheRead = 0, cacheCreation = 0; + foreach (var record in GetOrReadFile(file)) + { + ct.ThrowIfCancellationRequested(); + if (!seenKeys.Add(record.DedupeKey)) continue; + + input += record.InputTokens; + output += record.OutputTokens; + cacheRead += record.CacheReadTokens; + cacheCreation += record.CacheCreationTokens; + } + + return Task.FromResult( + new SessionUsageTotals(input, output, cacheRead, cacheCreation)); + } + private List GetOrReadFile(string file) { var info = new FileInfo(file); @@ -100,6 +127,7 @@ public sealed class TranscriptUsageReader : ITranscriptUsageReader var date = DateOnly.FromDateTime(ts.LocalDateTime); var model = modelEl.GetString()!; + if (model == "") continue; long input = 0, output = 0, cacheRead = 0, cacheCreation = 0; if (msg.TryGetProperty("usage", out var usage) && usage.ValueKind == JsonValueKind.Object) diff --git a/src/ClaudeDo.Worker/Usage/UsageModels.cs b/src/ClaudeDo.Worker/Usage/UsageModels.cs index 1a9bf988..f160e027 100644 --- a/src/ClaudeDo.Worker/Usage/UsageModels.cs +++ b/src/ClaudeDo.Worker/Usage/UsageModels.cs @@ -32,3 +32,11 @@ public sealed record UsageAggregateRow( long CacheReadTokens, long CacheCreationTokens, int Messages); + +/// Cumulative raw token usage for one Claude Code session (all its transcript +/// lines to date), synthetic messages excluded. Not a per-run delta. +public sealed record SessionUsageTotals( + long InputTokens, + long OutputTokens, + long CacheReadTokens, + long CacheCreationTokens); diff --git a/tests/ClaudeDo.Data.Tests/UsageGateAndRunModelTests.cs b/tests/ClaudeDo.Data.Tests/UsageGateAndRunModelTests.cs index c2fa06d6..e81b4d38 100644 --- a/tests/ClaudeDo.Data.Tests/UsageGateAndRunModelTests.cs +++ b/tests/ClaudeDo.Data.Tests/UsageGateAndRunModelTests.cs @@ -117,4 +117,58 @@ public sealed class UsageGateAndRunModelTests : IDisposable Assert.Null(reloaded.Model); } + + [Fact] + public async Task TaskRun_cache_token_fields_roundtrip_when_set() + { + var list = new ListEntity { Id = "l3", Name = "Test", CreatedAt = DateTime.UtcNow }; + var task = new TaskEntity { Id = "t3", ListId = "l3", Title = "T", Status = TaskStatus.Idle, CreatedAt = DateTime.UtcNow }; + var run = new TaskRunEntity + { + Id = "r3", + TaskId = "t3", + RunNumber = 1, + IsRetry = false, + Prompt = "do the thing", + CacheReadTokens = 12345, + CacheWriteTokens = 678, + }; + _ctx.Lists.Add(list); + _ctx.Tasks.Add(task); + _ctx.TaskRuns.Add(run); + await _ctx.SaveChangesAsync(); + + await using var freshCtx = new ClaudeDoDbContext( + new DbContextOptionsBuilder().UseSqlite($"Data Source={_dbPath}").Options); + var reloaded = await freshCtx.TaskRuns.AsNoTracking().FirstAsync(r => r.Id == "r3"); + + Assert.Equal(12345, reloaded.CacheReadTokens); + Assert.Equal(678, reloaded.CacheWriteTokens); + } + + [Fact] + public async Task TaskRun_cache_token_fields_stay_null_when_not_set() + { + var list = new ListEntity { Id = "l4", Name = "Test", CreatedAt = DateTime.UtcNow }; + var task = new TaskEntity { Id = "t4", ListId = "l4", Title = "T", Status = TaskStatus.Idle, CreatedAt = DateTime.UtcNow }; + var run = new TaskRunEntity + { + Id = "r4", + TaskId = "t4", + RunNumber = 1, + IsRetry = false, + Prompt = "do the thing", + }; + _ctx.Lists.Add(list); + _ctx.Tasks.Add(task); + _ctx.TaskRuns.Add(run); + await _ctx.SaveChangesAsync(); + + await using var freshCtx = new ClaudeDoDbContext( + new DbContextOptionsBuilder().UseSqlite($"Data Source={_dbPath}").Options); + var reloaded = await freshCtx.TaskRuns.AsNoTracking().FirstAsync(r => r.Id == "r4"); + + Assert.Null(reloaded.CacheReadTokens); + Assert.Null(reloaded.CacheWriteTokens); + } } diff --git a/tests/ClaudeDo.Worker.Tests/External/AddSubtaskToolTests.cs b/tests/ClaudeDo.Worker.Tests/External/AddSubtaskToolTests.cs index 89947de4..c8866d34 100644 --- a/tests/ClaudeDo.Worker.Tests/External/AddSubtaskToolTests.cs +++ b/tests/ClaudeDo.Worker.Tests/External/AddSubtaskToolTests.cs @@ -71,7 +71,7 @@ public sealed class AddSubtaskToolTests : IDisposable var argsBuilder = new ClaudeArgsBuilder(); var state = TaskStateServiceBuilder.Build(dbFactory).State; var runner = new TaskRunner(fake, dbFactory, broadcaster, wtManager, argsBuilder, cfg, - NullLogger.Instance, state, new TaskRunTokenRegistry(), new AttachmentStore(), new FakeSessionSkillSeeder()); + NullLogger.Instance, state, new TaskRunTokenRegistry(), new AttachmentStore(), new FakeSessionSkillSeeder(), new FakeTranscriptUsageReader()); var waker = new ClaudeDo.Worker.Queue.QueueWaker(); var picker = new ClaudeDo.Worker.Queue.QueuePicker(dbFactory); var runCancels = new RunCancellationRegistry(); diff --git a/tests/ClaudeDo.Worker.Tests/External/BatchMcpToolsTests.cs b/tests/ClaudeDo.Worker.Tests/External/BatchMcpToolsTests.cs index 6a339351..ee7b8830 100644 --- a/tests/ClaudeDo.Worker.Tests/External/BatchMcpToolsTests.cs +++ b/tests/ClaudeDo.Worker.Tests/External/BatchMcpToolsTests.cs @@ -93,7 +93,7 @@ public sealed class BatchMcpToolsTests : IDisposable var wtManager = new WorktreeManager(new GitService(), dbFactory, cfg, NullLogger.Instance); var state = TaskStateServiceBuilder.Build(dbFactory).State; var runner = new TaskRunner(new FakeClaudeProcess(), dbFactory, broadcaster, wtManager, new ClaudeArgsBuilder(), cfg, - NullLogger.Instance, state, new TaskRunTokenRegistry(), new AttachmentStore(), new FakeSessionSkillSeeder()); + NullLogger.Instance, state, new TaskRunTokenRegistry(), new AttachmentStore(), new FakeSessionSkillSeeder(), new FakeTranscriptUsageReader()); var runCancels = new RunCancellationRegistry(); var overrideSlot = new OverrideSlotService(dbFactory, runner, NullLogger.Instance, runCancels); return new QueueService(dbFactory, runner, cfg, NullLogger.Instance, diff --git a/tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs b/tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs index a2c67555..146539c7 100644 --- a/tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs +++ b/tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs @@ -157,7 +157,7 @@ public sealed class ExternalMcpServiceTests : IDisposable var argsBuilder = new ClaudeArgsBuilder(); var state = TaskStateServiceBuilder.Build(dbFactory).State; var runner = new TaskRunner(fake, dbFactory, broadcaster, wtManager, argsBuilder, cfg, - NullLogger.Instance, state, new TaskRunTokenRegistry(), new AttachmentStore(), new FakeSessionSkillSeeder()); + NullLogger.Instance, state, new TaskRunTokenRegistry(), new AttachmentStore(), new FakeSessionSkillSeeder(), new FakeTranscriptUsageReader()); var waker = new ClaudeDo.Worker.Queue.QueueWaker(); var picker = new ClaudeDo.Worker.Queue.QueuePicker(dbFactory); var runCancels = new RunCancellationRegistry(); diff --git a/tests/ClaudeDo.Worker.Tests/Infrastructure/FakeTranscriptUsageReader.cs b/tests/ClaudeDo.Worker.Tests/Infrastructure/FakeTranscriptUsageReader.cs new file mode 100644 index 00000000..f2541f7b --- /dev/null +++ b/tests/ClaudeDo.Worker.Tests/Infrastructure/FakeTranscriptUsageReader.cs @@ -0,0 +1,18 @@ +using ClaudeDo.Worker.Usage; +using ClaudeDo.Worker.Usage.Interfaces; + +namespace ClaudeDo.Worker.Tests.Infrastructure; + +public sealed class FakeTranscriptUsageReader : ITranscriptUsageReader +{ + private readonly Dictionary _totalsBySession = new(); + + public void SetTotals(string sessionId, SessionUsageTotals totals) => _totalsBySession[sessionId] = totals; + + public Task> ReadAsync( + DateOnly start, DateOnly end, CancellationToken ct = default) => + Task.FromResult>(Array.Empty()); + + public Task ReadSessionTotalsAsync(string sessionId, CancellationToken ct = default) => + Task.FromResult(_totalsBySession.TryGetValue(sessionId, out var totals) ? totals : null); +} diff --git a/tests/ClaudeDo.Worker.Tests/Runner/ContinueAsyncExceptionTests.cs b/tests/ClaudeDo.Worker.Tests/Runner/ContinueAsyncExceptionTests.cs index 223e4f17..941d0dd2 100644 --- a/tests/ClaudeDo.Worker.Tests/Runner/ContinueAsyncExceptionTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Runner/ContinueAsyncExceptionTests.cs @@ -36,7 +36,7 @@ public sealed class ContinueAsyncExceptionTests : IDisposable var state = TaskStateServiceBuilder.Build(dbFactory).State; var wt = new WorktreeManager(new ClaudeDo.Data.Git.GitService(), dbFactory, _cfg, NullLogger.Instance); return new TaskRunner(claude, dbFactory, broadcaster, wt, new ClaudeArgsBuilder(), _cfg, - NullLogger.Instance, state, new TaskRunTokenRegistry(), new AttachmentStore(), new FakeSessionSkillSeeder()); + NullLogger.Instance, state, new TaskRunTokenRegistry(), new AttachmentStore(), new FakeSessionSkillSeeder(), new FakeTranscriptUsageReader()); } [Fact] diff --git a/tests/ClaudeDo.Worker.Tests/Runner/ModelResolutionWireTests.cs b/tests/ClaudeDo.Worker.Tests/Runner/ModelResolutionWireTests.cs index 50f55b52..63467b09 100644 --- a/tests/ClaudeDo.Worker.Tests/Runner/ModelResolutionWireTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Runner/ModelResolutionWireTests.cs @@ -43,7 +43,7 @@ public sealed class ModelResolutionWireTests : IDisposable }); var runner = new TaskRunner(fake, dbFactory, new HubBroadcaster(new CapturingHubContext()), wt, new ClaudeArgsBuilder(), _cfg, NullLogger.Instance, state, new TaskRunTokenRegistry(), - new AttachmentStore(), new FakeSessionSkillSeeder()); + new AttachmentStore(), new FakeSessionSkillSeeder(), new FakeTranscriptUsageReader()); getArgs = () => captured!; return (runner, Array.Empty()); } diff --git a/tests/ClaudeDo.Worker.Tests/Runner/RunModelPersistenceTests.cs b/tests/ClaudeDo.Worker.Tests/Runner/RunModelPersistenceTests.cs index 33426fbb..b54418b5 100644 --- a/tests/ClaudeDo.Worker.Tests/Runner/RunModelPersistenceTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Runner/RunModelPersistenceTests.cs @@ -38,7 +38,7 @@ public sealed class RunModelPersistenceTests : IDisposable Task.FromResult(new RunResult { ExitCode = 0, ResultMarkdown = "ok" })); return new TaskRunner(fake, dbFactory, new HubBroadcaster(new CapturingHubContext()), wt, new ClaudeArgsBuilder(), _cfg, NullLogger.Instance, state, new TaskRunTokenRegistry(), - new AttachmentStore(), new FakeSessionSkillSeeder()); + new AttachmentStore(), new FakeSessionSkillSeeder(), new FakeTranscriptUsageReader()); } private async Task SeedAsync(string? taskModel, string? listModel) diff --git a/tests/ClaudeDo.Worker.Tests/Runner/RunUsagePersistenceTests.cs b/tests/ClaudeDo.Worker.Tests/Runner/RunUsagePersistenceTests.cs new file mode 100644 index 00000000..5771fbdd --- /dev/null +++ b/tests/ClaudeDo.Worker.Tests/Runner/RunUsagePersistenceTests.cs @@ -0,0 +1,165 @@ +using ClaudeDo.Data; +using ClaudeDo.Data.Git; +using ClaudeDo.Data.Models; +using ClaudeDo.Data.Repositories; +using ClaudeDo.Worker.Config; +using ClaudeDo.Worker.Hub; +using ClaudeDo.Worker.Runner; +using ClaudeDo.Worker.Tests.Infrastructure; +using ClaudeDo.Worker.Usage; +using ClaudeDo.Worker.Usage.Interfaces; +using Microsoft.Extensions.Logging.Abstractions; +using TaskStatus = ClaudeDo.Data.Models.TaskStatus; +using Xunit; + +namespace ClaudeDo.Worker.Tests.Runner; + +/// Verifies TaskRunner persists raw token usage (input, output, cache-read, cache-write) +/// aggregated from the session transcript via ITranscriptUsageReader, instead of the +/// uncached-only counters the stream-json "result" event carries. See the bug report: +/// task_runs.tokens_in was off by a factor of ~400,000 because it only read the API's +/// per-call "input_tokens" field, ignoring cache_read/cache_creation. +public sealed class RunUsagePersistenceTests : IDisposable +{ + private readonly DbFixture _db = new(); + private readonly string _tempDir; + private readonly WorkerConfig _cfg; + private readonly FakeTranscriptUsageReader _reader = new(); + + public RunUsagePersistenceTests() + { + _tempDir = Path.Combine(Path.GetTempPath(), $"cd_runusage_{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 TaskRunner BuildRunner(IClaudeProcess claude, ITranscriptUsageReader? reader = null) + { + var dbFactory = _db.CreateFactory(); + var state = TaskStateServiceBuilder.Build(dbFactory).State; + var wt = new WorktreeManager(new GitService(), dbFactory, _cfg, NullLogger.Instance); + return new TaskRunner(claude, dbFactory, new HubBroadcaster(new CapturingHubContext()), wt, + new ClaudeArgsBuilder(), _cfg, NullLogger.Instance, state, new TaskRunTokenRegistry(), + new AttachmentStore(), new FakeSessionSkillSeeder(), reader ?? _reader); + } + + private async Task SeedTaskAsync(string taskId, TaskStatus status = TaskStatus.Idle) + { + using var ctx = _db.CreateContext(); + var listId = Guid.NewGuid().ToString(); + ctx.Lists.Add(new ListEntity { Id = listId, Name = "L", WorkingDir = null, CreatedAt = DateTime.UtcNow }); + ctx.Tasks.Add(new TaskEntity + { + Id = taskId, ListId = listId, Title = "Task", Status = status, CreatedAt = DateTime.UtcNow, + }); + await ctx.SaveChangesAsync(); + } + + [Fact] + public async Task First_run_persists_all_four_token_fields_from_the_transcript_reader() + { + const string taskId = "t1"; + await SeedTaskAsync(taskId); + _reader.SetTotals("sess-a", new SessionUsageTotals(10, 20, 300, 5)); + + var fake = new FakeClaudeProcess((_, _, _, _, _) => + Task.FromResult(new RunResult { ExitCode = 0, ResultMarkdown = "ok", SessionId = "sess-a" })); + var runner = BuildRunner(fake); + + using (var ctx = _db.CreateContext()) + await runner.RunAsync((await new TaskRepository(ctx).GetByIdAsync(taskId))!, "slot-1", CancellationToken.None); + + using var verify = _db.CreateContext(); + var run = await new TaskRunRepository(verify).GetLatestByTaskIdAsync(taskId); + Assert.Equal(10, run!.TokensIn); + Assert.Equal(20, run.TokensOut); + Assert.Equal(300, run.CacheReadTokens); + Assert.Equal(5, run.CacheWriteTokens); + } + + [Fact] + public async Task Resumed_run_on_the_same_session_persists_only_the_delta() + { + const string taskId = "t2"; + await SeedTaskAsync(taskId, TaskStatus.WaitingForReview); + _reader.SetTotals("sess-b", new SessionUsageTotals(10, 20, 100, 0)); + + var fake = new FakeClaudeProcess((_, _, _, _, _) => + Task.FromResult(new RunResult { ExitCode = 0, ResultMarkdown = "ok", SessionId = "sess-b" })); + var runner = BuildRunner(fake); + + using (var ctx = _db.CreateContext()) + await runner.RunAsync((await new TaskRepository(ctx).GetByIdAsync(taskId))!, "slot-1", CancellationToken.None); + + // Cumulative transcript totals grow after the second (resumed) turn. + _reader.SetTotals("sess-b", new SessionUsageTotals(30, 50, 250, 10)); + await runner.ContinueAsync(taskId, "follow up", "slot-1", CancellationToken.None); + + using var verify = _db.CreateContext(); + var runs = await new TaskRunRepository(verify).GetByTaskIdAsync(taskId); + Assert.Equal(2, runs.Count); + var run2 = runs.Single(r => r.RunNumber == 2); + Assert.Equal(20, run2.TokensIn); + Assert.Equal(30, run2.TokensOut); + Assert.Equal(150, run2.CacheReadTokens); + Assert.Equal(10, run2.CacheWriteTokens); + } + + [Fact] + public async Task Missing_transcript_totals_leave_token_fields_null_but_run_still_succeeds() + { + const string taskId = "t3"; + await SeedTaskAsync(taskId); + // No totals registered for "sess-missing" -> reader returns null. + + var fake = new FakeClaudeProcess((_, _, _, _, _) => + Task.FromResult(new RunResult { ExitCode = 0, ResultMarkdown = "ok", SessionId = "sess-missing" })); + var runner = BuildRunner(fake); + + using (var ctx = _db.CreateContext()) + await runner.RunAsync((await new TaskRepository(ctx).GetByIdAsync(taskId))!, "slot-1", CancellationToken.None); + + using var verify = _db.CreateContext(); + var run = await new TaskRunRepository(verify).GetLatestByTaskIdAsync(taskId); + Assert.Null(run!.TokensIn); + Assert.Null(run.TokensOut); + Assert.Null(run.CacheReadTokens); + Assert.Null(run.CacheWriteTokens); + Assert.Equal(0, run.ExitCode); + + var task = await new TaskRepository(verify).GetByIdAsync(taskId); + Assert.Equal(TaskStatus.WaitingForReview, task!.Status); + } + + [Fact] + public async Task A_failing_usage_reader_does_not_fail_the_run() + { + const string taskId = "t4"; + await SeedTaskAsync(taskId); + + var fake = new FakeClaudeProcess((_, _, _, _, _) => + Task.FromResult(new RunResult { ExitCode = 0, ResultMarkdown = "ok", SessionId = "sess-throws" })); + var runner = BuildRunner(fake, new ThrowingUsageReader()); + + using (var ctx = _db.CreateContext()) + await runner.RunAsync((await new TaskRepository(ctx).GetByIdAsync(taskId))!, "slot-1", CancellationToken.None); + + using var verify = _db.CreateContext(); + var run = await new TaskRunRepository(verify).GetLatestByTaskIdAsync(taskId); + Assert.Null(run!.TokensIn); + var task = await new TaskRepository(verify).GetByIdAsync(taskId); + Assert.Equal(TaskStatus.WaitingForReview, task!.Status); + } + + private sealed class ThrowingUsageReader : ITranscriptUsageReader + { + public Task> ReadAsync( + DateOnly start, DateOnly end, CancellationToken ct = default) => + throw new IOException("boom"); + + public Task ReadSessionTotalsAsync(string sessionId, CancellationToken ct = default) => + throw new IOException("boom"); + } +} diff --git a/tests/ClaudeDo.Worker.Tests/Runner/SkillSeedingWireTests.cs b/tests/ClaudeDo.Worker.Tests/Runner/SkillSeedingWireTests.cs index 1e672090..4f173b19 100644 --- a/tests/ClaudeDo.Worker.Tests/Runner/SkillSeedingWireTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Runner/SkillSeedingWireTests.cs @@ -40,7 +40,7 @@ public sealed class SkillSeedingWireTests : IDisposable var seeder = new FakeSessionSkillSeeder(); var runner = new TaskRunner(fake, dbFactory, new HubBroadcaster(new CapturingHubContext()), wt, new ClaudeArgsBuilder(), _cfg, NullLogger.Instance, state, new TaskRunTokenRegistry(), - new AttachmentStore(), seeder); + new AttachmentStore(), seeder, new FakeTranscriptUsageReader()); return (runner, seeder); } diff --git a/tests/ClaudeDo.Worker.Tests/Runner/StandaloneChildrenRoutingTests.cs b/tests/ClaudeDo.Worker.Tests/Runner/StandaloneChildrenRoutingTests.cs index 7352c403..52051def 100644 --- a/tests/ClaudeDo.Worker.Tests/Runner/StandaloneChildrenRoutingTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Runner/StandaloneChildrenRoutingTests.cs @@ -45,7 +45,7 @@ public sealed class StandaloneChildrenRoutingTests : IDisposable var state = TaskStateServiceBuilder.Build(dbFactory).State; var wt = new WorktreeManager(new GitService(), dbFactory, _cfg, NullLogger.Instance); var runner = new TaskRunner(fake, dbFactory, broadcaster, wt, new ClaudeArgsBuilder(), _cfg, - NullLogger.Instance, state, new TaskRunTokenRegistry(), new AttachmentStore(), new FakeSessionSkillSeeder()); + NullLogger.Instance, state, new TaskRunTokenRegistry(), new AttachmentStore(), new FakeSessionSkillSeeder(), new FakeTranscriptUsageReader()); using (var ctx = _db.CreateContext()) await runner.RunAsync((await new TaskRepository(ctx).GetByIdAsync("p1"))!, "slot-1", default, alreadyClaimed: true); @@ -72,7 +72,7 @@ public sealed class StandaloneChildrenRoutingTests : IDisposable var state = TaskStateServiceBuilder.Build(dbFactory).State; var wt = new WorktreeManager(new GitService(), dbFactory, _cfg, NullLogger.Instance); var runner = new TaskRunner(fake, dbFactory, new HubBroadcaster(new CapturingHubContext()), wt, - new ClaudeArgsBuilder(), _cfg, NullLogger.Instance, state, new TaskRunTokenRegistry(), new AttachmentStore(), new FakeSessionSkillSeeder()); + new ClaudeArgsBuilder(), _cfg, NullLogger.Instance, state, new TaskRunTokenRegistry(), new AttachmentStore(), new FakeSessionSkillSeeder(), new FakeTranscriptUsageReader()); using (var ctx = _db.CreateContext()) await runner.RunAsync((await new TaskRepository(ctx).GetByIdAsync("solo"))!, "slot-1", default, alreadyClaimed: true); diff --git a/tests/ClaudeDo.Worker.Tests/Runner/StartRunningGuardTests.cs b/tests/ClaudeDo.Worker.Tests/Runner/StartRunningGuardTests.cs index 3e120783..745bf44f 100644 --- a/tests/ClaudeDo.Worker.Tests/Runner/StartRunningGuardTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Runner/StartRunningGuardTests.cs @@ -33,7 +33,7 @@ public sealed class StartRunningGuardTests : IDisposable var state = TaskStateServiceBuilder.Build(dbFactory).State; var wt = new WorktreeManager(new ClaudeDo.Data.Git.GitService(), dbFactory, _cfg, NullLogger.Instance); return new TaskRunner(claude, dbFactory, new HubBroadcaster(new CapturingHubContext()), wt, - new ClaudeArgsBuilder(), _cfg, NullLogger.Instance, state, new TaskRunTokenRegistry(), new AttachmentStore(), new FakeSessionSkillSeeder()); + new ClaudeArgsBuilder(), _cfg, NullLogger.Instance, state, new TaskRunTokenRegistry(), new AttachmentStore(), new FakeSessionSkillSeeder(), new FakeTranscriptUsageReader()); } [Fact] diff --git a/tests/ClaudeDo.Worker.Tests/Services/QueueServiceSlotGuardTests.cs b/tests/ClaudeDo.Worker.Tests/Services/QueueServiceSlotGuardTests.cs index e6fbaac8..0f8ce049 100644 --- a/tests/ClaudeDo.Worker.Tests/Services/QueueServiceSlotGuardTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Services/QueueServiceSlotGuardTests.cs @@ -56,7 +56,7 @@ public sealed class QueueServiceSlotGuardTests : IDisposable var built = TaskStateServiceBuilder.Build(dbFactory); var state = built.State; var runner = new TaskRunner(fake, dbFactory, broadcaster, wtManager, argsBuilder, _cfg, - NullLogger.Instance, state, new TaskRunTokenRegistry(), new AttachmentStore(), new FakeSessionSkillSeeder()); + NullLogger.Instance, state, new TaskRunTokenRegistry(), new AttachmentStore(), new FakeSessionSkillSeeder(), new FakeTranscriptUsageReader()); _waker = new QueueWaker(); var picker = new QueuePicker(dbFactory); var overrideSlot = new OverrideSlotService(dbFactory, runner, NullLogger.Instance, built.RunCancels); diff --git a/tests/ClaudeDo.Worker.Tests/Services/QueueServiceTests.cs b/tests/ClaudeDo.Worker.Tests/Services/QueueServiceTests.cs index 5430a721..baa516d1 100644 --- a/tests/ClaudeDo.Worker.Tests/Services/QueueServiceTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Services/QueueServiceTests.cs @@ -61,7 +61,7 @@ public sealed class QueueServiceTests : IDisposable var built = TaskStateServiceBuilder.Build(dbFactory); var state = built.State; var runner = new TaskRunner(fake, dbFactory, broadcaster, wtManager, argsBuilder, _cfg, - NullLogger.Instance, state, new TaskRunTokenRegistry(), new AttachmentStore(), new FakeSessionSkillSeeder()); + NullLogger.Instance, state, new TaskRunTokenRegistry(), new AttachmentStore(), new FakeSessionSkillSeeder(), new FakeTranscriptUsageReader()); _waker = new QueueWaker(); var picker = new QueuePicker(dbFactory); var overrideSlot = new OverrideSlotService(dbFactory, runner, NullLogger.Instance, built.RunCancels); diff --git a/tests/ClaudeDo.Worker.Tests/Usage/TranscriptUsageReaderTests.cs b/tests/ClaudeDo.Worker.Tests/Usage/TranscriptUsageReaderTests.cs index 2587fb5a..6a6cdf20 100644 --- a/tests/ClaudeDo.Worker.Tests/Usage/TranscriptUsageReaderTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Usage/TranscriptUsageReaderTests.cs @@ -199,4 +199,69 @@ public class TranscriptUsageReaderTests : IDisposable Assert.Empty(result); } + + [Fact] + public async Task ReadSessionTotalsAsync_Sums_All_Assistant_Messages_In_The_Session_File() + { + WriteSession("proj", "sess-1.jsonl", + AssistantLine(@"C:\Dev\App", "2026-06-01T08:00:00Z", "claude-sonnet-5", 10, 20, 100, 5, requestId: "r1"), + AssistantLine(@"C:\Dev\App", "2026-06-01T09:00:00Z", "claude-sonnet-5", 3, 7, 200, 0, requestId: "r2")); + + var reader = MakeReader(); + var totals = await reader.ReadSessionTotalsAsync("sess-1"); + + Assert.NotNull(totals); + Assert.Equal(13, totals!.InputTokens); + Assert.Equal(27, totals.OutputTokens); + Assert.Equal(300, totals.CacheReadTokens); + Assert.Equal(5, totals.CacheCreationTokens); + } + + [Fact] + public async Task ReadSessionTotalsAsync_Skips_Synthetic_Model_Messages() + { + WriteSession("proj", "sess-2.jsonl", + AssistantLine(@"C:\Dev\App", "2026-06-01T08:00:00Z", "claude-sonnet-5", 10, 20, 0, 0, requestId: "r1"), + AssistantLine(@"C:\Dev\App", "2026-06-01T09:00:00Z", "", 999, 999, 999, 999, requestId: "r2")); + + var reader = MakeReader(); + var totals = await reader.ReadSessionTotalsAsync("sess-2"); + + Assert.NotNull(totals); + Assert.Equal(10, totals!.InputTokens); + Assert.Equal(20, totals.OutputTokens); + } + + [Fact] + public async Task ReadSessionTotalsAsync_Dedupes_By_RequestId_Within_The_Session() + { + WriteSession("proj", "sess-3.jsonl", + AssistantLine(@"C:\Dev\App", "2026-06-01T08:00:00Z", "claude-sonnet-5", 10, 20, 0, 0, requestId: "dup"), + AssistantLine(@"C:\Dev\App", "2026-06-01T08:00:01Z", "claude-sonnet-5", 10, 20, 0, 0, requestId: "dup")); + + var reader = MakeReader(); + var totals = await reader.ReadSessionTotalsAsync("sess-3"); + + Assert.NotNull(totals); + Assert.Equal(10, totals!.InputTokens); + Assert.Equal(20, totals.OutputTokens); + } + + [Fact] + public async Task ReadSessionTotalsAsync_Returns_Null_When_No_Matching_Transcript_File() + { + var reader = MakeReader(); + var totals = await reader.ReadSessionTotalsAsync("does-not-exist"); + + Assert.Null(totals); + } + + [Fact] + public async Task ReadSessionTotalsAsync_Returns_Null_When_ProjectsRoot_Missing() + { + var reader = new TranscriptUsageReader(_cfg, Path.Combine(_root, "does-not-exist")); + var totals = await reader.ReadSessionTotalsAsync("sess-1"); + + Assert.Null(totals); + } }