diff --git a/src/ClaudeDo.Data/Configuration/TaskEntityConfiguration.cs b/src/ClaudeDo.Data/Configuration/TaskEntityConfiguration.cs index a83feed7..de8aa8a3 100644 --- a/src/ClaudeDo.Data/Configuration/TaskEntityConfiguration.cs +++ b/src/ClaudeDo.Data/Configuration/TaskEntityConfiguration.cs @@ -78,6 +78,9 @@ public class TaskEntityConfiguration : IEntityTypeConfiguration builder.Property(t => t.Result).HasColumnName("result"); builder.Property(t => t.ReviewFeedback).HasColumnName("review_feedback"); builder.Property(t => t.RoadblockCount).HasColumnName("roadblock_count").HasDefaultValue(0); + builder.Property(t => t.FailureReason).HasColumnName("failure_reason"); + builder.Property(t => t.FailureTurnsUsed).HasColumnName("failure_turns_used"); + builder.Property(t => t.FailureMaxTurns).HasColumnName("failure_max_turns"); builder.Property(t => t.LogPath).HasColumnName("log_path"); builder.Property(t => t.CreatedAt).HasColumnName("created_at").IsRequired(); builder.Property(t => t.StartedAt).HasColumnName("started_at"); diff --git a/src/ClaudeDo.Data/Configuration/TaskRunEntityConfiguration.cs b/src/ClaudeDo.Data/Configuration/TaskRunEntityConfiguration.cs index 98c18858..0980da0a 100644 --- a/src/ClaudeDo.Data/Configuration/TaskRunEntityConfiguration.cs +++ b/src/ClaudeDo.Data/Configuration/TaskRunEntityConfiguration.cs @@ -30,6 +30,9 @@ public class TaskRunEntityConfiguration : IEntityTypeConfiguration r.StartedAt).HasColumnName("started_at"); builder.Property(r => r.FinishedAt).HasColumnName("finished_at"); builder.Property(r => r.Model).HasColumnName("model"); + builder.Property(r => r.ResultSubtype).HasColumnName("result_subtype"); + builder.Property(r => r.TerminalReason).HasColumnName("terminal_reason"); + builder.Property(r => r.Errors).HasColumnName("errors"); builder.HasOne(r => r.Task) .WithMany(t => t.Runs) diff --git a/src/ClaudeDo.Data/Migrations/20260810115437_AddFailureReason.Designer.cs b/src/ClaudeDo.Data/Migrations/20260810115437_AddFailureReason.Designer.cs new file mode 100644 index 00000000..ea960241 --- /dev/null +++ b/src/ClaudeDo.Data/Migrations/20260810115437_AddFailureReason.Designer.cs @@ -0,0 +1,913 @@ +// +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("20260810115437_AddFailureReason")] + partial class AddFailureReason + { + /// + 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(40) + .HasColumnName("default_max_turns"); + + b.Property("DefaultModel") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("sonnet") + .HasColumnName("default_model"); + + b.Property("DefaultPermissionMode") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("bypassPermissions") + .HasColumnName("default_permission_mode"); + + b.Property("MaxParallelExecutions") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(1) + .HasColumnName("max_parallel_executions"); + + b.Property("MaxTurnsCeiling") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(80) + .HasColumnName("max_turns_ceiling"); + + b.Property("ModelPresets") + .HasColumnType("TEXT") + .HasColumnName("model_presets"); + + b.Property("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, + DailyPrepMaxTasks = 5, + DefaultClaudeInstructions = "", + DefaultMaxTurns = 40, + DefaultModel = "sonnet", + DefaultPermissionMode = "auto", + MaxParallelExecutions = 1, + MaxTurnsCeiling = 80, + StandupWeekday = 3, + UsageGateFiveHourPct = 80, + UsageGateSevenDayPct = 90, + UsageThrottleFiveHourHardPct = 65, + UsageThrottleFiveHourSoftPct = 50, + UsageThrottleSevenDayHardPct = 65, + UsageThrottleSevenDaySoftPct = 50, + WorktreeAutoCleanupDays = 7, + WorktreeAutoCleanupEnabled = false, + WorktreeStrategy = "sibling" + }); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.DailyNoteEntity", b => + { + b.Property("Id") + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("Date") + .HasColumnType("TEXT") + .HasColumnName("note_date"); + + b.Property("SortOrder") + .HasColumnType("INTEGER") + .HasColumnName("sort_order"); + + b.Property("Text") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("text"); + + b.HasKey("Id"); + + b.HasIndex("Date"); + + b.ToTable("daily_notes", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b => + { + b.Property("ListId") + .HasColumnType("TEXT") + .HasColumnName("list_id"); + + b.Property("AgentPath") + .HasColumnType("TEXT") + .HasColumnName("agent_path"); + + b.Property("MaxTurns") + .HasColumnType("INTEGER") + .HasColumnName("max_turns"); + + b.Property("Model") + .HasColumnType("TEXT") + .HasColumnName("model"); + + b.Property("SessionSkills") + .HasColumnType("TEXT") + .HasColumnName("session_skills"); + + b.Property("SystemPrompt") + .HasColumnType("TEXT") + .HasColumnName("system_prompt"); + + b.Property("VerifyCommand") + .HasColumnType("TEXT") + .HasColumnName("verify_command"); + + b.HasKey("ListId"); + + b.ToTable("list_config", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b => + { + b.Property("Id") + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("DefaultCommitType") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("chore") + .HasColumnName("default_commit_type"); + + b.Property("FindingsTracked") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false) + .HasColumnName("findings_tracked"); + + b.Property("IsManual") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false) + .HasColumnName("is_manual"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property("SortOrder") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0) + .HasColumnName("sort_order"); + + b.Property("WorkingDir") + .HasColumnType("TEXT") + .HasColumnName("working_dir"); + + b.HasKey("Id"); + + b.HasIndex("SortOrder") + .HasDatabaseName("idx_lists_sort"); + + b.ToTable("lists", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.PrimeScheduleEntity", b => + { + b.Property("Id") + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("Days") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(31) + .HasColumnName("days_of_week"); + + b.Property("Enabled") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true) + .HasColumnName("enabled"); + + b.Property("LastRunAt") + .HasColumnType("TEXT") + .HasColumnName("last_run_at"); + + b.Property("PromptOverride") + .HasColumnType("TEXT") + .HasColumnName("prompt_override"); + + b.Property("TimeOfDay") + .HasColumnType("TEXT") + .HasColumnName("time_of_day"); + + b.HasKey("Id"); + + b.ToTable("prime_schedules", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.SessionSkillEntity", b => + { + b.Property("Name") + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property("AddedAt") + .HasColumnType("TEXT") + .HasColumnName("added_at"); + + b.Property("Description") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("description"); + + b.Property("PinnedRef") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("pinned_ref"); + + b.Property("SourceUrl") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("source_url"); + + b.Property("Subpath") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("subpath"); + + b.HasKey("Name"); + + b.ToTable("session_skills", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b => + { + b.Property("Id") + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("Completed") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false) + .HasColumnName("completed"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("OrderNum") + .HasColumnType("INTEGER") + .HasColumnName("order_num"); + + b.Property("TaskId") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("task_id"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("title"); + + b.HasKey("Id"); + + b.HasIndex("TaskId") + .HasDatabaseName("idx_subtasks_task_id"); + + b.ToTable("subtasks", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.TaskAttachmentEntity", b => + { + b.Property("Id") + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("ByteSize") + .HasColumnType("INTEGER") + .HasColumnName("byte_size"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("FileName") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("file_name"); + + b.Property("TaskId") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("task_id"); + + b.HasKey("Id"); + + b.HasIndex("TaskId") + .HasDatabaseName("idx_task_attachments_task_id"); + + b.ToTable("task_attachments", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b => + { + b.Property("Id") + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("AgentPath") + .HasColumnType("TEXT") + .HasColumnName("agent_path"); + + b.Property("BlockedByTaskId") + .HasColumnType("TEXT") + .HasColumnName("blocked_by_task_id"); + + b.Property("CommitType") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("chore") + .HasColumnName("commit_type"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .HasColumnType("TEXT") + .HasColumnName("created_by"); + + b.Property("Description") + .HasColumnType("TEXT") + .HasColumnName("description"); + + b.Property("FailureMaxTurns") + .HasColumnType("INTEGER") + .HasColumnName("failure_max_turns"); + + b.Property("FailureReason") + .HasColumnType("TEXT") + .HasColumnName("failure_reason"); + + b.Property("FailureTurnsUsed") + .HasColumnType("INTEGER") + .HasColumnName("failure_turns_used"); + + b.Property("FinishedAt") + .HasColumnType("TEXT") + .HasColumnName("finished_at"); + + b.Property("HandlerBaseCommit") + .HasColumnType("TEXT") + .HasColumnName("handler_base_commit"); + + b.Property("HandlerHeadCommit") + .HasColumnType("TEXT") + .HasColumnName("handler_head_commit"); + + b.Property("InteractiveSessionId") + .HasColumnType("TEXT") + .HasColumnName("interactive_session_id"); + + b.Property("IsManual") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false) + .HasColumnName("is_manual"); + + b.Property("IsMyDay") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false) + .HasColumnName("is_my_day"); + + b.Property("IsStarred") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false) + .HasColumnName("is_starred"); + + b.Property("ListId") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("list_id"); + + b.Property("LogPath") + .HasColumnType("TEXT") + .HasColumnName("log_path"); + + b.Property("MaxTurns") + .HasColumnType("INTEGER") + .HasColumnName("max_turns"); + + b.Property("Model") + .HasColumnType("TEXT") + .HasColumnName("model"); + + b.Property("Notes") + .HasColumnType("TEXT") + .HasColumnName("notes"); + + b.Property("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("Errors") + .HasColumnType("TEXT") + .HasColumnName("errors"); + + b.Property("ExitCode") + .HasColumnType("INTEGER") + .HasColumnName("exit_code"); + + b.Property("FinishedAt") + .HasColumnType("TEXT") + .HasColumnName("finished_at"); + + b.Property("IsRetry") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false) + .HasColumnName("is_retry"); + + b.Property("LogPath") + .HasColumnType("TEXT") + .HasColumnName("log_path"); + + b.Property("Model") + .HasColumnType("TEXT") + .HasColumnName("model"); + + b.Property("Prompt") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("prompt"); + + b.Property("ResultMarkdown") + .HasColumnType("TEXT") + .HasColumnName("result_markdown"); + + b.Property("ResultSubtype") + .HasColumnType("TEXT") + .HasColumnName("result_subtype"); + + b.Property("RunNumber") + .HasColumnType("INTEGER") + .HasColumnName("run_number"); + + b.Property("SessionId") + .HasColumnType("TEXT") + .HasColumnName("session_id"); + + b.Property("StartedAt") + .HasColumnType("TEXT") + .HasColumnName("started_at"); + + b.Property("StructuredOutputJson") + .HasColumnType("TEXT") + .HasColumnName("structured_output"); + + b.Property("TaskId") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("task_id"); + + b.Property("TerminalReason") + .HasColumnType("TEXT") + .HasColumnName("terminal_reason"); + + b.Property("TokensIn") + .HasColumnType("INTEGER") + .HasColumnName("tokens_in"); + + b.Property("TokensOut") + .HasColumnType("INTEGER") + .HasColumnName("tokens_out"); + + b.Property("TurnCount") + .HasColumnType("INTEGER") + .HasColumnName("turn_count"); + + b.HasKey("Id"); + + b.HasIndex("TaskId") + .HasDatabaseName("idx_task_runs_task_id"); + + b.ToTable("task_runs", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.WeekReportEntity", b => + { + b.Property("Id") + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("EndDate") + .HasColumnType("TEXT") + .HasColumnName("end_date"); + + b.Property("GeneratedAt") + .HasColumnType("TEXT") + .HasColumnName("generated_at"); + + b.Property("Markdown") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("markdown"); + + b.Property("StartDate") + .HasColumnType("TEXT") + .HasColumnName("start_date"); + + b.HasKey("Id"); + + b.HasIndex("StartDate", "EndDate") + .IsUnique(); + + b.ToTable("week_reports", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b => + { + b.Property("TaskId") + .HasColumnType("TEXT") + .HasColumnName("task_id"); + + b.Property("BaseCommit") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("base_commit"); + + b.Property("BranchName") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("branch_name"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("DiffStat") + .HasColumnType("TEXT") + .HasColumnName("diff_stat"); + + b.Property("HeadCommit") + .HasColumnType("TEXT") + .HasColumnName("head_commit"); + + b.Property("MergeCommit") + .HasColumnType("TEXT") + .HasColumnName("merge_commit"); + + b.Property("Path") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("path"); + + b.Property("State") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("active") + .HasColumnName("state"); + + b.HasKey("TaskId"); + + b.ToTable("worktrees", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b => + { + b.HasOne("ClaudeDo.Data.Models.ListEntity", "List") + .WithOne("Config") + .HasForeignKey("ClaudeDo.Data.Models.ListConfigEntity", "ListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("List"); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b => + { + b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task") + .WithMany("Subtasks") + .HasForeignKey("TaskId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Task"); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.TaskAttachmentEntity", b => + { + b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task") + .WithMany() + .HasForeignKey("TaskId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Task"); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b => + { + b.HasOne("ClaudeDo.Data.Models.TaskEntity", null) + .WithMany() + .HasForeignKey("BlockedByTaskId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("ClaudeDo.Data.Models.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/20260810115437_AddFailureReason.cs b/src/ClaudeDo.Data/Migrations/20260810115437_AddFailureReason.cs new file mode 100644 index 00000000..7b8d60f0 --- /dev/null +++ b/src/ClaudeDo.Data/Migrations/20260810115437_AddFailureReason.cs @@ -0,0 +1,78 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace ClaudeDo.Data.Migrations +{ + /// + public partial class AddFailureReason : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "failure_max_turns", + table: "tasks", + type: "INTEGER", + nullable: true); + + migrationBuilder.AddColumn( + name: "failure_reason", + table: "tasks", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "failure_turns_used", + table: "tasks", + type: "INTEGER", + nullable: true); + + migrationBuilder.AddColumn( + name: "errors", + table: "task_runs", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "result_subtype", + table: "task_runs", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "terminal_reason", + table: "task_runs", + type: "TEXT", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "failure_max_turns", + table: "tasks"); + + migrationBuilder.DropColumn( + name: "failure_reason", + table: "tasks"); + + migrationBuilder.DropColumn( + name: "failure_turns_used", + table: "tasks"); + + migrationBuilder.DropColumn( + name: "errors", + table: "task_runs"); + + migrationBuilder.DropColumn( + name: "result_subtype", + table: "task_runs"); + + migrationBuilder.DropColumn( + name: "terminal_reason", + table: "task_runs"); + } + } +} diff --git a/src/ClaudeDo.Data/Migrations/ClaudeDoDbContextModelSnapshot.cs b/src/ClaudeDo.Data/Migrations/ClaudeDoDbContextModelSnapshot.cs index 8e1c8ee5..a5559555 100644 --- a/src/ClaudeDo.Data/Migrations/ClaudeDoDbContextModelSnapshot.cs +++ b/src/ClaudeDo.Data/Migrations/ClaudeDoDbContextModelSnapshot.cs @@ -477,6 +477,18 @@ namespace ClaudeDo.Data.Migrations .HasColumnType("TEXT") .HasColumnName("description"); + b.Property("FailureMaxTurns") + .HasColumnType("INTEGER") + .HasColumnName("failure_max_turns"); + + b.Property("FailureReason") + .HasColumnType("TEXT") + .HasColumnName("failure_reason"); + + b.Property("FailureTurnsUsed") + .HasColumnType("INTEGER") + .HasColumnName("failure_turns_used"); + b.Property("FinishedAt") .HasColumnType("TEXT") .HasColumnName("finished_at"); @@ -643,6 +655,10 @@ namespace ClaudeDo.Data.Migrations .HasColumnType("TEXT") .HasColumnName("error_markdown"); + b.Property("Errors") + .HasColumnType("TEXT") + .HasColumnName("errors"); + b.Property("ExitCode") .HasColumnType("INTEGER") .HasColumnName("exit_code"); @@ -674,6 +690,10 @@ namespace ClaudeDo.Data.Migrations .HasColumnType("TEXT") .HasColumnName("result_markdown"); + b.Property("ResultSubtype") + .HasColumnType("TEXT") + .HasColumnName("result_subtype"); + b.Property("RunNumber") .HasColumnType("INTEGER") .HasColumnName("run_number"); @@ -695,6 +715,10 @@ namespace ClaudeDo.Data.Migrations .HasColumnType("TEXT") .HasColumnName("task_id"); + b.Property("TerminalReason") + .HasColumnType("TEXT") + .HasColumnName("terminal_reason"); + b.Property("TokensIn") .HasColumnType("INTEGER") .HasColumnName("tokens_in"); diff --git a/src/ClaudeDo.Data/Models/TaskEntity.cs b/src/ClaudeDo.Data/Models/TaskEntity.cs index 3a98de53..76c9efca 100644 --- a/src/ClaudeDo.Data/Models/TaskEntity.cs +++ b/src/ClaudeDo.Data/Models/TaskEntity.cs @@ -32,6 +32,12 @@ public sealed class TaskEntity public string? Result { get; set; } public string? ReviewFeedback { get; set; } public int RoadblockCount { get; set; } + // Denormalized from the failing run, same pattern as RoadblockCount, so get_task/ + // batch_get_tasks can report why a Failed task stopped without a second query. + // Null on a non-Failed task, or on a Failed task predating this field ("unknown"). + public string? FailureReason { get; set; } + public int? FailureTurnsUsed { get; set; } + public int? FailureMaxTurns { get; set; } public string? LogPath { get; set; } public required DateTime CreatedAt { get; init; } public DateTime? StartedAt { get; set; } diff --git a/src/ClaudeDo.Data/Models/TaskRunEntity.cs b/src/ClaudeDo.Data/Models/TaskRunEntity.cs index dd5ecfb2..71c732d5 100644 --- a/src/ClaudeDo.Data/Models/TaskRunEntity.cs +++ b/src/ClaudeDo.Data/Models/TaskRunEntity.cs @@ -21,6 +21,11 @@ public sealed class TaskRunEntity public DateTime? StartedAt { get; set; } public DateTime? FinishedAt { get; set; } public string? Model { get; set; } + // Raw diagnostics from the CLI's stream-json result event (StreamAnalyzer), kept verbatim + // for get_run/get_task_log — TaskEntity.FailureReason is the classified, MCP-facing summary. + public string? ResultSubtype { get; set; } + public string? TerminalReason { get; set; } + public string? Errors { get; set; } // Navigation property public TaskEntity Task { get; set; } = null!; diff --git a/src/ClaudeDo.Localization/locales/de.json b/src/ClaudeDo.Localization/locales/de.json index 95522036..39cc6b86 100644 --- a/src/ClaudeDo.Localization/locales/de.json +++ b/src/ClaudeDo.Localization/locales/de.json @@ -663,6 +663,8 @@ "shell": { "restartingWorker": "Worker wird neu gestartet…", "unexpectedError": "Unerwarteter Fehler: {0}" }, "agentStatus": { "idle": "Leerlauf", "queued": "In Warteschlange", "running": "Läuft", "review": "Prüfung", "children": "Wartet auf Teilaufgaben", "done": "Fertig", "failed": "Fehlgeschlagen", "cancelled": "Abgebrochen" }, "taskStatus": { "idle": "Leerlauf", "queued": "In Warteschlange", "running": "Läuft", "waitingForReview": "Wartet auf Prüfung", "waitingForChildren": "Wartet auf Teilaufgaben", "done": "Fertig", "failed": "Fehlgeschlagen", "cancelled": "Abgebrochen", "parked": "Geparkt", "interactive": "Interaktiv" }, + "failureReason": { "maxTurns": "Turn-Limit erreicht", "timeout": "Zeitüberschreitung", "error": "Fehler", "cancelled": "Abgebrochen", "unknown": "Grund unbekannt" }, + "failureReasonTooltip": { "maxTurns": "Turn-Limit erreicht ({0}/{1} Turns) — der Worktree ist meist brauchbar; Task fortsetzen statt zurücksetzen." }, "planningBadge": { "active": "PLANUNG", "finalized": "GEPLANT" }, "taskRow": { "createdPrefix": "Erstellt {0}", "stepsText": "{0}/{1} Schritte" }, "tasksIsland": { "completedHeader": "ABGESCHLOSSEN", "completedHeaderCount": "ABGESCHLOSSEN · {0}", "planningOpenFailed": "Planungssitzung konnte nicht geöffnet werden: {0}", "planningResumeFailed": "Planungssitzung konnte nicht fortgesetzt werden: {0}", "approveFailed": "Genehmigen & Mergen fehlgeschlagen: {0}", "cancelReviewFailed": "Prüfung abbrechen fehlgeschlagen: {0}", "sendToQueueFailed": "In die Warteschlange stellen fehlgeschlagen: {0}", "queuePlanBlockedInteractive": "Plan kann nicht in die Warteschlange gestellt werden — {0} hat eine offene interaktive Sitzung und muss zuerst geschlossen werden.", "moveRunningRejected": "Ein laufender Task kann nicht in eine andere Liste verschoben werden.", "moveWorktreeRejected": "Verschieben nicht möglich — dieser Task hat einen aktiven Worktree, der auf sein aktuelles Repo zeigt.", "moveRepoConfirm": "Unterschiedliche Repos — {0} → {1}. Task trotzdem verschieben?", "moveConfirmUnavailable": "Verschieben nicht möglich — der Bestätigungsdialog ist nicht verfügbar.", "quickClaudeNoWorkingDir": "Für diese Liste ist kein Arbeitsverzeichnis konfiguriert.", "quickClaudeDirMissing": "Arbeitsverzeichnis existiert nicht mehr: {0}" }, diff --git a/src/ClaudeDo.Localization/locales/en.json b/src/ClaudeDo.Localization/locales/en.json index a787ed1d..dfa5f847 100644 --- a/src/ClaudeDo.Localization/locales/en.json +++ b/src/ClaudeDo.Localization/locales/en.json @@ -663,6 +663,8 @@ "shell": { "restartingWorker": "Restarting worker…", "unexpectedError": "Unexpected error: {0}" }, "agentStatus": { "idle": "Idle", "queued": "Queued", "running": "Running", "review": "Review", "children": "Waiting for Subtasks", "done": "Done", "failed": "Failed", "cancelled": "Cancelled" }, "taskStatus": { "idle": "Idle", "queued": "Queued", "running": "Running", "waitingForReview": "Waiting for Review", "waitingForChildren": "Waiting for Subtasks", "done": "Done", "failed": "Failed", "cancelled": "Cancelled", "parked": "Parked", "interactive": "Interactive" }, + "failureReason": { "maxTurns": "Turn limit reached", "timeout": "Timed out", "error": "Error", "cancelled": "Cancelled", "unknown": "Unknown reason" }, + "failureReasonTooltip": { "maxTurns": "Turn limit reached ({0}/{1} turns) — the worktree is usually fine; continue the task instead of resetting it." }, "planningBadge": { "active": "PLANNING", "finalized": "PLANNED" }, "taskRow": { "createdPrefix": "Created {0}", "stepsText": "{0}/{1} steps" }, "tasksIsland": { "completedHeader": "COMPLETED", "completedHeaderCount": "COMPLETED · {0}", "planningOpenFailed": "Couldn't open planning session: {0}", "planningResumeFailed": "Couldn't resume planning session: {0}", "approveFailed": "Approve & merge failed: {0}", "cancelReviewFailed": "Cancel review failed: {0}", "sendToQueueFailed": "Send to queue failed: {0}", "queuePlanBlockedInteractive": "Can't queue the plan — {0} has an open interactive session and must be closed first.", "moveRunningRejected": "Can't move a running task to another list.", "moveWorktreeRejected": "Can't move — this task has an active worktree pointing at its current repo.", "moveRepoConfirm": "Different repos — {0} → {1}. Move the task anyway?", "moveConfirmUnavailable": "Can't move — the confirmation dialog isn't available.", "quickClaudeNoWorkingDir": "This list has no working directory configured.", "quickClaudeDirMissing": "Working directory no longer exists: {0}" }, diff --git a/src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs index c0649ebe..d278ce72 100644 --- a/src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs @@ -34,6 +34,11 @@ public sealed partial class TaskRowViewModel : ViewModelBase [ObservableProperty] private bool _parentFinalized; [ObservableProperty] private bool _parentInView = true; [ObservableProperty] private int _roadblockCount; + // Only meaningful when Status=Failed; stamped by TaskRunner.MarkFailed. "unknown" for a + // Failed task that predates this field. Null on every other status. + [ObservableProperty] private string? _failureReason; + [ObservableProperty] private int? _failureTurnsUsed; + [ObservableProperty] private int? _failureMaxTurns; [ObservableProperty] private bool _isRefining; // Manual = a reminder only the user can do. Every "hand this to Claude" affordance is hidden // and automation skips it; opening a hand-driven ConPTY session stays allowed. @@ -128,15 +133,33 @@ public sealed partial class TaskRowViewModel : ViewModelBase ? "1 roadblock reported during the run — see details" : $"{RoadblockCount} roadblocks reported during the run — see details"; + // True for every Failed task, even one that predates this field — FailureReasonLabel then + // falls back to "unknown" instead of leaving the tooltip blank. + public bool HasFailureReason => Status == TaskStatus.Failed; + public string FailureReasonLabel => FailureReason switch + { + "max_turns" => Loc.T("vm.failureReason.maxTurns"), + "timeout" => Loc.T("vm.failureReason.timeout"), + "cancelled" => Loc.T("vm.failureReason.cancelled"), + "error" => Loc.T("vm.failureReason.error"), + _ => Loc.T("vm.failureReason.unknown"), + }; + // max_turns gets the actionable detail (turns used/configured) since that's the one case + // where the fix is "raise maxTurns and continue_task", not "reset and re-run". + public string? FailureReasonTooltip => !HasFailureReason ? null + : FailureReason == "max_turns" && FailureTurnsUsed is { } used && FailureMaxTurns is { } max + ? Loc.T("vm.failureReasonTooltip.maxTurns", used, max) + : FailureReasonLabel; + + // Drives the status chip tooltip: an open interactive session takes priority (it's tappable), + // otherwise a Failed task's reason, otherwise no tooltip. + public string? StatusChipTooltip + => HasInteractiveSession ? Loc.T("tasks.interactiveChipTip") : FailureReasonTooltip; + public string DiffAdditionsText => $"+{DiffAdditions}"; public string DiffDeletionsText => $"−{DiffDeletions}"; public string StepsText => Loc.T("vm.taskRow.stepsText", StepsCompleted, StepsCount); - // Null on a plain status chip so no tooltip shows there — the chip is only actionable - // while a session is open. - public string? InteractiveChipTooltip - => HasInteractiveSession ? Loc.T("tasks.interactiveChipTip") : null; - public string StatusLabel => HasInteractiveSession ? Loc.T("vm.taskStatus.interactive") : IsParked ? Loc.T("vm.taskStatus.parked") : Status switch @@ -183,6 +206,9 @@ public sealed partial class TaskRowViewModel : ViewModelBase OnPropertyChanged(nameof(IsParked)); OnPropertyChanged(nameof(IsQueued)); OnPropertyChanged(nameof(IsWaiting)); + OnPropertyChanged(nameof(HasFailureReason)); + OnPropertyChanged(nameof(FailureReasonTooltip)); + OnPropertyChanged(nameof(StatusChipTooltip)); OnPropertyChanged(nameof(IsDraft)); OnPropertyChanged(nameof(IsPlanned)); OnPropertyChanged(nameof(CanOpenPlanningSession)); @@ -249,10 +275,21 @@ public sealed partial class TaskRowViewModel : ViewModelBase OnPropertyChanged(nameof(StatusChipClass)); OnPropertyChanged(nameof(StatusLabel)); OnPropertyChanged(nameof(ShowStatusChip)); - OnPropertyChanged(nameof(InteractiveChipTooltip)); + OnPropertyChanged(nameof(StatusChipTooltip)); OnPropertyChanged(nameof(CanSendToQueue)); } + partial void OnFailureReasonChanged(string? value) + { + OnPropertyChanged(nameof(HasFailureReason)); + OnPropertyChanged(nameof(FailureReasonLabel)); + OnPropertyChanged(nameof(FailureReasonTooltip)); + OnPropertyChanged(nameof(StatusChipTooltip)); + } + + partial void OnFailureTurnsUsedChanged(int? value) => OnPropertyChanged(nameof(FailureReasonTooltip)); + partial void OnFailureMaxTurnsChanged(int? value) => OnPropertyChanged(nameof(FailureReasonTooltip)); + partial void OnHasQueuedSubtasksChanged(bool value) { OnPropertyChanged(nameof(CanRemoveFromQueue)); @@ -332,6 +369,9 @@ public sealed partial class TaskRowViewModel : ViewModelBase CreatedBy = t.CreatedBy; BlockedByTaskId = t.BlockedByTaskId; RoadblockCount = t.RoadblockCount; + FailureReason = t.FailureReason; + FailureTurnsUsed = t.FailureTurnsUsed; + FailureMaxTurns = t.FailureMaxTurns; } // Best-effort parse of diff stat strings like "+12 -3" or "12 additions, 3 deletions". diff --git a/src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml b/src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml index cf4bc51e..ad763270 100644 --- a/src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml +++ b/src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml @@ -119,7 +119,7 @@ 0 still goes straight to Done (see // ClaudeDo.Worker/CLAUDE.md → Unified parent model) -- this is the only MCP-visible signal // that it may have delivered nothing despite that Done status. - int RoadblockCount = 0); + int RoadblockCount = 0, + // The three below are non-null only when Status=Failed; stamped by TaskRunner.MarkFailed via + // TaskStateService.FailAsync. failureReason is "unknown" for a Failed task that predates this + // field. Lets a caller triage without pulling get_task_log's raw NDJSON. + string? FailureReason = null, + int? FailureTurnsUsed = null, + int? FailureMaxTurns = null); // Lean counterpart to TaskDto for writing/status-changing tools: echoes back what changed // without re-sending Description/Result, which the caller just sent or already has. @@ -56,7 +62,10 @@ public sealed record TaskRefDto( string Status, int SortOrder, bool IsMyDay, - int RoadblockCount = 0); + int RoadblockCount = 0, + string? FailureReason = null, + int? FailureTurnsUsed = null, + int? FailureMaxTurns = null); // tasks is populated when includeDescription=false (the default): lean references, no // Description/Result. tasksFull is populated when includeDescription=true: full tasks incl. @@ -236,7 +245,8 @@ public sealed class ExternalMcpService "A successful run lands in WaitingForReview; use review_task to approve, reject or cancel it. " + "Done/Failed/Cancelled tasks can be reset to Idle for re-execution. A Queued task with a blocker waits " + "for its predecessor before the picker will claim it, and WaitingForChildren is a parent whose own work " + - "is done but whose children are still running.")] + "is done but whose children are still running. For Status=Failed, failureReason (max_turns|timeout|" + + "error|cancelled|unknown) plus failureTurnsUsed/failureMaxTurns say why without pulling get_task_log.")] public async Task GetTask(string taskId, CancellationToken cancellationToken) { var task = await _tasks.GetByIdAsync(taskId, cancellationToken) @@ -1500,7 +1510,10 @@ public sealed class ExternalMcpService t.FinishedAt, t.IsMyDay, t.SortOrder, - t.RoadblockCount); + t.RoadblockCount, + FailureReasonOf(t), + t.Status == TaskStatus.Failed ? t.FailureTurnsUsed : null, + t.Status == TaskStatus.Failed ? t.FailureMaxTurns : null); private static TaskRefDto ToRefDto(TaskEntity t) => new( t.Id, @@ -1509,7 +1522,15 @@ public sealed class ExternalMcpService t.Status.ToString(), t.SortOrder, t.IsMyDay, - t.RoadblockCount); + t.RoadblockCount, + FailureReasonOf(t), + t.Status == TaskStatus.Failed ? t.FailureTurnsUsed : null, + t.Status == TaskStatus.Failed ? t.FailureMaxTurns : null); + + // "unknown" covers a Failed task that predates this field (never got a classified reason + // stamped) — a defined value rather than null so callers don't have to special-case it. + private static string? FailureReasonOf(TaskEntity t) => + t.Status == TaskStatus.Failed ? (t.FailureReason ?? "unknown") : null; } internal static class DailyPrepFilter diff --git a/src/ClaudeDo.Worker/External/LifecycleMcpTools.cs b/src/ClaudeDo.Worker/External/LifecycleMcpTools.cs index 3980f51f..68d20918 100644 --- a/src/ClaudeDo.Worker/External/LifecycleMcpTools.cs +++ b/src/ClaudeDo.Worker/External/LifecycleMcpTools.cs @@ -20,7 +20,12 @@ public sealed class LifecycleMcpTools _reset = reset; } - [McpServerTool, Description("Reset a failed task back to Idle so it can be run again, discarding its now-stale worktree. Only tasks with Status=Failed are accepted; other statuses throw.")] + [McpServerTool, Description( + "Discards a failed task's worktree (and all uncommitted work in it) and resets it to Idle for a fresh " + + "run. Only tasks with Status=Failed are accepted; other statuses throw. Check failureReason from " + + "get_task/batch_get_tasks first: for failureReason=\"max_turns\" the worktree's work is usually still " + + "good and continue_task (resume the session, keep the worktree) is almost always the right call instead " + + "— reach for this tool only for a real error, not a task that just ran out of turns.")] public async Task ResetFailedTask(string taskId, CancellationToken cancellationToken) { var task = await _tasks.GetByIdAsync(taskId, cancellationToken) diff --git a/src/ClaudeDo.Worker/Runner/TaskRunner.cs b/src/ClaudeDo.Worker/Runner/TaskRunner.cs index 31050f97..4ad95b6b 100644 --- a/src/ClaudeDo.Worker/Runner/TaskRunner.cs +++ b/src/ClaudeDo.Worker/Runner/TaskRunner.cs @@ -158,12 +158,14 @@ public sealed class TaskRunner } else { - await MarkFailed(task.Id, task.Title, slot, retryResult.ErrorMarkdown, retryResult.TurnCount); + await MarkFailed(task.Id, task.Title, slot, retryResult.ErrorMarkdown, retryResult.TurnCount, + retryConfig.MaxTurns, ClassifyFailureReason(retryResult.TerminalReason)); } } else { - await MarkFailed(task.Id, task.Title, slot, result.ErrorMarkdown, result.TurnCount); + await MarkFailed(task.Id, task.Title, slot, result.ErrorMarkdown, result.TurnCount, + resolvedConfig.MaxTurns, ClassifyFailureReason(result.TerminalReason)); } } @@ -172,7 +174,7 @@ public sealed class TaskRunner catch (OperationCanceledException) { _logger.LogInformation("Task {TaskId} was cancelled", task.Id); - await MarkFailed(task.Id, task.Title, slot, "Task cancelled."); + await MarkFailed(task.Id, task.Title, slot, "Task cancelled.", failureReason: "cancelled"); } catch (Exception ex) { @@ -270,7 +272,8 @@ public sealed class TaskRunner } else { - await MarkFailed(taskId, task.Title, slot, result.ErrorMarkdown, result.TurnCount); + await MarkFailed(taskId, task.Title, slot, result.ErrorMarkdown, result.TurnCount, + resolvedConfig.MaxTurns, ClassifyFailureReason(result.TerminalReason)); } await _broadcaster.TaskUpdated(taskId); @@ -278,7 +281,7 @@ public sealed class TaskRunner catch (OperationCanceledException) { _logger.LogInformation("Task {TaskId} was cancelled during continue", taskId); - await MarkFailed(taskId, task.Title, slot, "Task cancelled."); + await MarkFailed(taskId, task.Title, slot, "Task cancelled.", failureReason: "cancelled"); } catch (Exception ex) { @@ -404,6 +407,9 @@ public sealed class TaskRunner run.ErrorMarkdown = result.ErrorMarkdown; run.ExitCode = result.ExitCode; run.TurnCount = result.TurnCount; + run.ResultSubtype = result.ResultSubtype; + run.TerminalReason = result.TerminalReason; + run.Errors = result.Errors.Count > 0 ? string.Join("\n", result.Errors) : null; if (result.SessionId is not null) await ApplyUsageAsync(run, taskId, result.SessionId); run.FinishedAt = DateTime.UtcNow; @@ -421,6 +427,7 @@ public sealed class TaskRunner // Ensure the run row is completed so ContinueAsync / inspection // isn't left staring at a null session_id / finished_at. run.ErrorMarkdown = "Cancelled."; + run.TerminalReason = "cancelled"; run.ExitCode = -1; run.FinishedAt = DateTime.UtcNow; try @@ -550,14 +557,17 @@ public sealed class TaskRunner task.Id, result.TurnCount, result.TokensIn, result.TokensOut); } - private async Task MarkFailed(string taskId, string taskTitle, string slot, string? error, int turnCount = 0) + private async Task MarkFailed( + string taskId, string taskTitle, string slot, string? error, int turnCount = 0, + int? maxTurns = null, string failureReason = "error") { // Terminal write for a failed task: never cancel (the status must always // be persisted) and never throw (a logging failure must not mask the error). try { var finishedAt = DateTime.UtcNow; - await _state.FailAsync(taskId, finishedAt, error, CancellationToken.None); + await _state.FailAsync(taskId, finishedAt, error, CancellationToken.None, + failureReason, turnCount > 0 ? turnCount : null, maxTurns); await _broadcaster.WorkerLog($"Finished \"{taskTitle}\" (failed)", WorkerLogLevel.Error, DateTime.UtcNow); await _broadcaster.TaskFinished(slot, taskId, "failed", finishedAt); _logger.LogWarning("Task {TaskId} failed (turns={Turns}): {Error}", taskId, turnCount, error); @@ -568,6 +578,16 @@ public sealed class TaskRunner } } + /// Classifies the CLI's raw terminal_reason into the small, MCP-facing enum + /// (max_turns|timeout|error) get_task/batch_get_tasks report as failureReason. + /// "cancelled" is set explicitly at the call sites that know it (there's no CLI signal for it). + internal static string ClassifyFailureReason(string? terminalReason) => terminalReason switch + { + "max_turns" => "max_turns", + "timeout" => "timeout", + _ => "error", + }; + private string BuildRunMcpConfigJson(string token) { var payload = new diff --git a/src/ClaudeDo.Worker/State/Interfaces/ITaskStateService.cs b/src/ClaudeDo.Worker/State/Interfaces/ITaskStateService.cs index 18061ad6..ffcc047d 100644 --- a/src/ClaudeDo.Worker/State/Interfaces/ITaskStateService.cs +++ b/src/ClaudeDo.Worker/State/Interfaces/ITaskStateService.cs @@ -8,7 +8,9 @@ public interface ITaskStateService Task SubmitForReviewAsync(string taskId, DateTime finishedAt, string? result, CancellationToken ct); Task SubmitInteractiveForReviewAsync(string taskId, DateTime finishedAt, CancellationToken ct); Task SubmitForChildrenAsync(string taskId, DateTime finishedAt, string? result, CancellationToken ct); - Task FailAsync(string taskId, DateTime finishedAt, string? error, CancellationToken ct); + Task FailAsync( + string taskId, DateTime finishedAt, string? error, CancellationToken ct, + string failureReason = "error", int? turnsUsed = null, int? maxTurns = null); Task CancelAsync(string taskId, DateTime finishedAt, CancellationToken ct, bool allowFromIdle = false); Task ResetToIdleAsync(string taskId, CancellationToken ct); diff --git a/src/ClaudeDo.Worker/State/TaskStateService.cs b/src/ClaudeDo.Worker/State/TaskStateService.cs index ee551639..4653400e 100644 --- a/src/ClaudeDo.Worker/State/TaskStateService.cs +++ b/src/ClaudeDo.Worker/State/TaskStateService.cs @@ -226,7 +226,9 @@ public sealed class TaskStateService : ITaskStateService : new TransitionResult(true, null); } - public async Task FailAsync(string taskId, DateTime finishedAt, string? error, CancellationToken ct) + public async Task FailAsync( + string taskId, DateTime finishedAt, string? error, CancellationToken ct, + string failureReason = "error", int? turnsUsed = null, int? maxTurns = null) { await using (var ctx = await _dbFactory.CreateDbContextAsync(ct)) { @@ -239,7 +241,10 @@ public sealed class TaskStateService : ITaskStateService .ExecuteUpdateAsync(s => s .SetProperty(t => t.Status, TaskStatus.Failed) .SetProperty(t => t.FinishedAt, finishedAt) - .SetProperty(t => t.Result, error), ct); + .SetProperty(t => t.Result, error) + .SetProperty(t => t.FailureReason, failureReason) + .SetProperty(t => t.FailureTurnsUsed, turnsUsed) + .SetProperty(t => t.FailureMaxTurns, maxTurns), ct); if (affected == 0) return new TransitionResult(false, "Task not in a failable state (must be Running or Queued)."); diff --git a/tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs b/tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs index d760d973..e2699304 100644 --- a/tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs +++ b/tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs @@ -227,6 +227,68 @@ public sealed class ExternalMcpServiceTests : IDisposable Assert.Equal("the full description text", dto.Description); } + [Fact] + public async Task GetTask_Failed_ReturnsClassifiedFailureReason() + { + var listId = await SeedListAsync(); + var task = await SeedTaskAsync(listId, status: TaskStatus.Failed); + task.FailureReason = "max_turns"; + task.FailureTurnsUsed = 55; + task.FailureMaxTurns = 60; + await _tasks.UpdateAsync(task, CancellationToken.None); + var sut = BuildSut(CreateQueue()); + + var dto = await sut.GetTask(task.Id, CancellationToken.None); + + Assert.Equal("max_turns", dto.FailureReason); + Assert.Equal(55, dto.FailureTurnsUsed); + Assert.Equal(60, dto.FailureMaxTurns); + } + + [Fact] + public async Task GetTask_FailedWithoutStoredReason_ReturnsUnknownNotError() + { + // A Failed task written before this field existed has FailureReason=null in the DB — + // the MCP surface must still hand back a defined value, not null or a throw. + var listId = await SeedListAsync(); + var task = await SeedTaskAsync(listId, status: TaskStatus.Failed); + var sut = BuildSut(CreateQueue()); + + var dto = await sut.GetTask(task.Id, CancellationToken.None); + + Assert.Equal("unknown", dto.FailureReason); + Assert.Null(dto.FailureTurnsUsed); + Assert.Null(dto.FailureMaxTurns); + } + + [Fact] + public async Task GetTask_NotFailed_FailureReasonIsNull() + { + var listId = await SeedListAsync(); + var task = await SeedTaskAsync(listId, status: TaskStatus.Idle); + var sut = BuildSut(CreateQueue()); + + var dto = await sut.GetTask(task.Id, CancellationToken.None); + + Assert.Null(dto.FailureReason); + } + + [Fact] + public async Task GetTaskRefAsync_Failed_ReturnsClassifiedFailureReason() + { + // GetTaskRefAsync backs batch_get_tasks' default (lean) path — the same field must be + // present there, not just on the full get_task DTO. + var listId = await SeedListAsync(); + var task = await SeedTaskAsync(listId, status: TaskStatus.Failed); + task.FailureReason = "error"; + await _tasks.UpdateAsync(task, CancellationToken.None); + var sut = BuildSut(CreateQueue()); + + var dto = await sut.GetTaskRefAsync(task.Id, CancellationToken.None); + + Assert.Equal("error", dto.FailureReason); + } + [Fact] public async Task UpdateTask_OnRunning_Throws() { diff --git a/tests/ClaudeDo.Worker.Tests/Runner/FailureDiagnosisTests.cs b/tests/ClaudeDo.Worker.Tests/Runner/FailureDiagnosisTests.cs index 1490fba6..9686b09c 100644 --- a/tests/ClaudeDo.Worker.Tests/Runner/FailureDiagnosisTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Runner/FailureDiagnosisTests.cs @@ -88,6 +88,17 @@ public class FailureDiagnosisTests var message = TaskRunner.BuildFailureMarkdown(result, configuredMaxTurns: 60); Assert.Equal($"{ClaudeProcess.NoResultPrefix} 1 and no result.", message); } + + [Theory] + [InlineData("max_turns", "max_turns")] + [InlineData("timeout", "timeout")] + [InlineData("api_error", "error")] + [InlineData("some_new_reason", "error")] + [InlineData(null, "error")] + public void ClassifyFailureReason_Maps_TerminalReason_To_McpEnum(string? terminalReason, string expected) + { + Assert.Equal(expected, TaskRunner.ClassifyFailureReason(terminalReason)); + } } public sealed class FailureDiagnosisEndToEndTests : IDisposable @@ -203,4 +214,72 @@ public sealed class FailureDiagnosisEndToEndTests : IDisposable Assert.Equal(TaskStatus.Failed, task!.Status); Assert.Equal($"{ClaudeProcess.NoResultPrefix} 1 and no result.", task.Result); } + + // The bug this feature fixes: get_task/batch_get_tasks must be able to tell error_max_turns + // apart from a real failure without pulling get_task_log. These two tests land a run of each + // kind and check the classified failureReason (plus turns/budget) that ends up on the task — + // exactly what those MCP tools read. + [Fact] + public async Task Max_Turns_Run_Sets_FailureReason_MaxTurns_With_Turns_And_Budget() + { + var dbFactory = _db.CreateFactory(); + using (var ctx = _db.CreateContext()) + { + ctx.Lists.Add(new ListEntity { Id = "l1", Name = "L", WorkingDir = null, CreatedAt = DateTime.UtcNow }); + ctx.Tasks.Add(new TaskEntity { Id = "t1", ListId = "l1", Title = "T", MaxTurns = 60, + Status = TaskStatus.Running, CreatedAt = DateTime.UtcNow }); + await ctx.SaveChangesAsync(); + } + var fake = new FakeClaudeProcess((_, _, _, _, _) => Task.FromResult(new RunResult + { + ExitCode = 1, + TerminalReason = "max_turns", + ResultSubtype = "error_max_turns", + Errors = new[] { "Reached maximum number of turns (60)" }, + TurnCount = 60, + })); + var runner = MakeRunner(dbFactory, fake); + + using (var ctx = _db.CreateContext()) + await runner.RunAsync((await new TaskRepository(ctx).GetByIdAsync("t1"))!, "slot-1", default, alreadyClaimed: true); + + using var verify = _db.CreateContext(); + var task = await new TaskRepository(verify).GetByIdAsync("t1"); + Assert.Equal(TaskStatus.Failed, task!.Status); + Assert.Equal("max_turns", task.FailureReason); + Assert.Equal(60, task.FailureTurnsUsed); + Assert.Equal(60, task.FailureMaxTurns); + + var run = await new TaskRunRepository(verify).GetLatestByTaskIdAsync("t1"); + Assert.Equal("max_turns", run!.TerminalReason); + Assert.Equal("error_max_turns", run.ResultSubtype); + } + + [Fact] + public async Task Real_Error_Run_Sets_FailureReason_Error_Distinct_From_MaxTurns() + { + var dbFactory = _db.CreateFactory(); + using (var ctx = _db.CreateContext()) + { + ctx.Lists.Add(new ListEntity { Id = "l1", Name = "L", WorkingDir = null, CreatedAt = DateTime.UtcNow }); + ctx.Tasks.Add(new TaskEntity { Id = "t1", ListId = "l1", Title = "T", + Status = TaskStatus.Running, CreatedAt = DateTime.UtcNow }); + await ctx.SaveChangesAsync(); + } + var fake = new FakeClaudeProcess((_, _, _, _, _) => Task.FromResult(new RunResult + { + ExitCode = 1, + ErrorMarkdown = $"{ClaudeProcess.NoResultPrefix} 1 and no result.", + })); + var runner = MakeRunner(dbFactory, fake); + + using (var ctx = _db.CreateContext()) + await runner.RunAsync((await new TaskRepository(ctx).GetByIdAsync("t1"))!, "slot-1", default, alreadyClaimed: true); + + using var verify = _db.CreateContext(); + var task = await new TaskRepository(verify).GetByIdAsync("t1"); + Assert.Equal(TaskStatus.Failed, task!.Status); + Assert.Equal("error", task.FailureReason); + Assert.NotEqual("max_turns", task.FailureReason); + } }