fix(mcp): report a Failed task's failureReason instead of a bare status
get_task/batch_get_tasks now return failureReason (max_turns|timeout|error| cancelled|unknown) plus failureTurnsUsed/failureMaxTurns on a Failed task, so max_turns (worktree usually fine, continue_task) is distinguishable from a real error (reset_failed_task) without pulling get_task_log's raw NDJSON. Classified and stamped onto TaskEntity by TaskRunner.MarkFailed via TaskStateService.FailAsync; TaskRunEntity also keeps the CLI's raw terminal_reason/result_subtype/errors for deeper diagnosis. reset_failed_task's description now warns explicitly that it discards the worktree and points at continue_task for max_turns. Surfaced on the task card's status-chip tooltip.
This commit is contained in:
@@ -78,6 +78,9 @@ public class TaskEntityConfiguration : IEntityTypeConfiguration<TaskEntity>
|
||||
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");
|
||||
|
||||
@@ -30,6 +30,9 @@ public class TaskRunEntityConfiguration : IEntityTypeConfiguration<TaskRunEntity
|
||||
builder.Property(r => 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)
|
||||
|
||||
@@ -0,0 +1,913 @@
|
||||
// <auto-generated />
|
||||
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
|
||||
{
|
||||
/// <inheritdoc />
|
||||
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<int>("Id")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<string>("CentralWorktreeRoot")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("central_worktree_root");
|
||||
|
||||
b.Property<int>("DailyPrepMaxTasks")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(5)
|
||||
.HasColumnName("daily_prep_max_tasks");
|
||||
|
||||
b.Property<string>("DefaultClaudeInstructions")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("")
|
||||
.HasColumnName("default_claude_instructions");
|
||||
|
||||
b.Property<int>("DefaultMaxTurns")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(40)
|
||||
.HasColumnName("default_max_turns");
|
||||
|
||||
b.Property<string>("DefaultModel")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("sonnet")
|
||||
.HasColumnName("default_model");
|
||||
|
||||
b.Property<string>("DefaultPermissionMode")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("bypassPermissions")
|
||||
.HasColumnName("default_permission_mode");
|
||||
|
||||
b.Property<int>("MaxParallelExecutions")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(1)
|
||||
.HasColumnName("max_parallel_executions");
|
||||
|
||||
b.Property<int>("MaxTurnsCeiling")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(80)
|
||||
.HasColumnName("max_turns_ceiling");
|
||||
|
||||
b.Property<string>("ModelPresets")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("model_presets");
|
||||
|
||||
b.Property<string>("RepoImportFolders")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("repo_import_folders");
|
||||
|
||||
b.Property<string>("ReportExcludedPaths")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("report_excluded_paths");
|
||||
|
||||
b.Property<string>("SessionSkills")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("session_skills");
|
||||
|
||||
b.Property<int>("StandupWeekday")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(3)
|
||||
.HasColumnName("standup_weekday");
|
||||
|
||||
b.Property<int>("UsageGateFiveHourPct")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(80)
|
||||
.HasColumnName("usage_gate_five_hour_pct");
|
||||
|
||||
b.Property<int>("UsageGateSevenDayPct")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(90)
|
||||
.HasColumnName("usage_gate_seven_day_pct");
|
||||
|
||||
b.Property<int>("UsageThrottleFiveHourHardPct")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(65)
|
||||
.HasColumnName("usage_throttle_five_hour_hard_pct");
|
||||
|
||||
b.Property<int>("UsageThrottleFiveHourSoftPct")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(50)
|
||||
.HasColumnName("usage_throttle_five_hour_soft_pct");
|
||||
|
||||
b.Property<int>("UsageThrottleSevenDayHardPct")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(65)
|
||||
.HasColumnName("usage_throttle_seven_day_hard_pct");
|
||||
|
||||
b.Property<int>("UsageThrottleSevenDaySoftPct")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(50)
|
||||
.HasColumnName("usage_throttle_seven_day_soft_pct");
|
||||
|
||||
b.Property<int>("WorktreeAutoCleanupDays")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(7)
|
||||
.HasColumnName("worktree_auto_cleanup_days");
|
||||
|
||||
b.Property<bool>("WorktreeAutoCleanupEnabled")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("worktree_auto_cleanup_enabled");
|
||||
|
||||
b.Property<string>("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<string>("Id")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<DateOnly>("Date")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("note_date");
|
||||
|
||||
b.Property<int>("SortOrder")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("sort_order");
|
||||
|
||||
b.Property<string>("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<string>("ListId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("list_id");
|
||||
|
||||
b.Property<string>("AgentPath")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("agent_path");
|
||||
|
||||
b.Property<int?>("MaxTurns")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("max_turns");
|
||||
|
||||
b.Property<string>("Model")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("model");
|
||||
|
||||
b.Property<string>("SessionSkills")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("session_skills");
|
||||
|
||||
b.Property<string>("SystemPrompt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("system_prompt");
|
||||
|
||||
b.Property<string>("VerifyCommand")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("verify_command");
|
||||
|
||||
b.HasKey("ListId");
|
||||
|
||||
b.ToTable("list_config", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<string>("DefaultCommitType")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("chore")
|
||||
.HasColumnName("default_commit_type");
|
||||
|
||||
b.Property<bool>("FindingsTracked")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("findings_tracked");
|
||||
|
||||
b.Property<bool>("IsManual")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("is_manual");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("name");
|
||||
|
||||
b.Property<int>("SortOrder")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(0)
|
||||
.HasColumnName("sort_order");
|
||||
|
||||
b.Property<string>("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<Guid>("Id")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<int>("Days")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(31)
|
||||
.HasColumnName("days_of_week");
|
||||
|
||||
b.Property<bool>("Enabled")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(true)
|
||||
.HasColumnName("enabled");
|
||||
|
||||
b.Property<DateTimeOffset?>("LastRunAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("last_run_at");
|
||||
|
||||
b.Property<string>("PromptOverride")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("prompt_override");
|
||||
|
||||
b.Property<TimeSpan>("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<string>("Name")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("name");
|
||||
|
||||
b.Property<DateTimeOffset>("AddedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("added_at");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("description");
|
||||
|
||||
b.Property<string>("PinnedRef")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("pinned_ref");
|
||||
|
||||
b.Property<string>("SourceUrl")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("source_url");
|
||||
|
||||
b.Property<string>("Subpath")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("subpath");
|
||||
|
||||
b.HasKey("Name");
|
||||
|
||||
b.ToTable("session_skills", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<bool>("Completed")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("completed");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<int>("OrderNum")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("order_num");
|
||||
|
||||
b.Property<string>("TaskId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("task_id");
|
||||
|
||||
b.Property<string>("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<string>("Id")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<long>("ByteSize")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("byte_size");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<string>("FileName")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("file_name");
|
||||
|
||||
b.Property<string>("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<string>("Id")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<string>("AgentPath")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("agent_path");
|
||||
|
||||
b.Property<string>("BlockedByTaskId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("blocked_by_task_id");
|
||||
|
||||
b.Property<string>("CommitType")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("chore")
|
||||
.HasColumnName("commit_type");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<string>("CreatedBy")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("created_by");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("description");
|
||||
|
||||
b.Property<int?>("FailureMaxTurns")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("failure_max_turns");
|
||||
|
||||
b.Property<string>("FailureReason")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("failure_reason");
|
||||
|
||||
b.Property<int?>("FailureTurnsUsed")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("failure_turns_used");
|
||||
|
||||
b.Property<DateTime?>("FinishedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("finished_at");
|
||||
|
||||
b.Property<string>("HandlerBaseCommit")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("handler_base_commit");
|
||||
|
||||
b.Property<string>("HandlerHeadCommit")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("handler_head_commit");
|
||||
|
||||
b.Property<string>("InteractiveSessionId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("interactive_session_id");
|
||||
|
||||
b.Property<bool>("IsManual")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("is_manual");
|
||||
|
||||
b.Property<bool>("IsMyDay")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("is_my_day");
|
||||
|
||||
b.Property<bool>("IsStarred")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("is_starred");
|
||||
|
||||
b.Property<string>("ListId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("list_id");
|
||||
|
||||
b.Property<string>("LogPath")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("log_path");
|
||||
|
||||
b.Property<int?>("MaxTurns")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("max_turns");
|
||||
|
||||
b.Property<string>("Model")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("model");
|
||||
|
||||
b.Property<string>("Notes")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("notes");
|
||||
|
||||
b.Property<string>("ParentTaskId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("parent_task_id");
|
||||
|
||||
b.Property<DateTime?>("PlanningFinalizedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("planning_finalized_at");
|
||||
|
||||
b.Property<string>("PlanningPhase")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("none")
|
||||
.HasColumnName("planning_phase");
|
||||
|
||||
b.Property<string>("PlanningSessionId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("planning_session_id");
|
||||
|
||||
b.Property<string>("PlanningSessionToken")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("planning_session_token");
|
||||
|
||||
b.Property<string>("Result")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("result");
|
||||
|
||||
b.Property<string>("ReviewFeedback")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("review_feedback");
|
||||
|
||||
b.Property<int>("RoadblockCount")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(0)
|
||||
.HasColumnName("roadblock_count");
|
||||
|
||||
b.Property<DateTime?>("ScheduledFor")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("scheduled_for");
|
||||
|
||||
b.Property<string>("SessionSkills")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("session_skills");
|
||||
|
||||
b.Property<int>("SortOrder")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(0)
|
||||
.HasColumnName("sort_order");
|
||||
|
||||
b.Property<DateTime?>("StartedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("started_at");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("status");
|
||||
|
||||
b.Property<string>("SystemPrompt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("system_prompt");
|
||||
|
||||
b.Property<string>("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<string>("Id")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<int?>("CacheReadTokens")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("cache_read_tokens");
|
||||
|
||||
b.Property<int?>("CacheWriteTokens")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("cache_write_tokens");
|
||||
|
||||
b.Property<string>("ErrorMarkdown")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("error_markdown");
|
||||
|
||||
b.Property<string>("Errors")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("errors");
|
||||
|
||||
b.Property<int?>("ExitCode")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("exit_code");
|
||||
|
||||
b.Property<DateTime?>("FinishedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("finished_at");
|
||||
|
||||
b.Property<bool>("IsRetry")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("is_retry");
|
||||
|
||||
b.Property<string>("LogPath")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("log_path");
|
||||
|
||||
b.Property<string>("Model")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("model");
|
||||
|
||||
b.Property<string>("Prompt")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("prompt");
|
||||
|
||||
b.Property<string>("ResultMarkdown")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("result_markdown");
|
||||
|
||||
b.Property<string>("ResultSubtype")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("result_subtype");
|
||||
|
||||
b.Property<int>("RunNumber")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("run_number");
|
||||
|
||||
b.Property<string>("SessionId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("session_id");
|
||||
|
||||
b.Property<DateTime?>("StartedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("started_at");
|
||||
|
||||
b.Property<string>("StructuredOutputJson")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("structured_output");
|
||||
|
||||
b.Property<string>("TaskId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("task_id");
|
||||
|
||||
b.Property<string>("TerminalReason")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("terminal_reason");
|
||||
|
||||
b.Property<int?>("TokensIn")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("tokens_in");
|
||||
|
||||
b.Property<int?>("TokensOut")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("tokens_out");
|
||||
|
||||
b.Property<int?>("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<string>("Id")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<DateOnly>("EndDate")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("end_date");
|
||||
|
||||
b.Property<DateTime>("GeneratedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("generated_at");
|
||||
|
||||
b.Property<string>("Markdown")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("markdown");
|
||||
|
||||
b.Property<DateOnly>("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<string>("TaskId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("task_id");
|
||||
|
||||
b.Property<string>("BaseCommit")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("base_commit");
|
||||
|
||||
b.Property<string>("BranchName")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("branch_name");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<string>("DiffStat")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("diff_stat");
|
||||
|
||||
b.Property<string>("HeadCommit")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("head_commit");
|
||||
|
||||
b.Property<string>("MergeCommit")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("merge_commit");
|
||||
|
||||
b.Property<string>("Path")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("path");
|
||||
|
||||
b.Property<string>("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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ClaudeDo.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddFailureReason : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "failure_max_turns",
|
||||
table: "tasks",
|
||||
type: "INTEGER",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "failure_reason",
|
||||
table: "tasks",
|
||||
type: "TEXT",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "failure_turns_used",
|
||||
table: "tasks",
|
||||
type: "INTEGER",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "errors",
|
||||
table: "task_runs",
|
||||
type: "TEXT",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "result_subtype",
|
||||
table: "task_runs",
|
||||
type: "TEXT",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "terminal_reason",
|
||||
table: "task_runs",
|
||||
type: "TEXT",
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -471,6 +471,18 @@ namespace ClaudeDo.Data.Migrations
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("description");
|
||||
|
||||
b.Property<int?>("FailureMaxTurns")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("failure_max_turns");
|
||||
|
||||
b.Property<string>("FailureReason")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("failure_reason");
|
||||
|
||||
b.Property<int?>("FailureTurnsUsed")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("failure_turns_used");
|
||||
|
||||
b.Property<DateTime?>("FinishedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("finished_at");
|
||||
@@ -633,6 +645,10 @@ namespace ClaudeDo.Data.Migrations
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("error_markdown");
|
||||
|
||||
b.Property<string>("Errors")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("errors");
|
||||
|
||||
b.Property<int?>("ExitCode")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("exit_code");
|
||||
@@ -664,6 +680,10 @@ namespace ClaudeDo.Data.Migrations
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("result_markdown");
|
||||
|
||||
b.Property<string>("ResultSubtype")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("result_subtype");
|
||||
|
||||
b.Property<int>("RunNumber")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("run_number");
|
||||
@@ -685,6 +705,10 @@ namespace ClaudeDo.Data.Migrations
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("task_id");
|
||||
|
||||
b.Property<string>("TerminalReason")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("terminal_reason");
|
||||
|
||||
b.Property<int?>("TokensIn")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("tokens_in");
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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!;
|
||||
|
||||
@@ -657,6 +657,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}" },
|
||||
|
||||
@@ -657,6 +657,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}" },
|
||||
|
||||
@@ -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.
|
||||
@@ -124,15 +129,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
|
||||
@@ -179,6 +202,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));
|
||||
@@ -245,10 +271,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));
|
||||
@@ -326,6 +363,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".
|
||||
|
||||
@@ -167,7 +167,7 @@
|
||||
<Border Classes="chip"
|
||||
IsVisible="{Binding ShowStatusChip}"
|
||||
Tapped="OnStatusChipTapped"
|
||||
ToolTip.Tip="{Binding InteractiveChipTooltip}"
|
||||
ToolTip.Tip="{Binding StatusChipTooltip}"
|
||||
Classes.interactive="{Binding HasInteractiveSession}"
|
||||
Classes.parked="{Binding IsParked}"
|
||||
Classes.running="{Binding Status, Converter={StaticResource EqStatus}, ConverterParameter=Running}"
|
||||
|
||||
+2
-1
@@ -42,7 +42,8 @@ public sealed class BatchMcpTools
|
||||
[McpServerTool, Description(
|
||||
"Fetch a snapshot of many tasks in one call — use for an overview or polling a fan-out instead of " +
|
||||
"calling get_task per id. A missing id comes back as found=false, not an error; error is only set " +
|
||||
"for an unexpected failure." + McpToolDocs.MaxBatch)]
|
||||
"for an unexpected failure. A Failed task's task/taskFull carries failureReason (see get_task)." +
|
||||
McpToolDocs.MaxBatch)]
|
||||
public async Task<IReadOnlyList<BatchGetTaskResult>> BatchGetTasks(
|
||||
string[] taskIds,
|
||||
[Description("If true, return the full task (incl. Description/Result) in `taskFull`; if false " +
|
||||
|
||||
+26
-5
@@ -45,7 +45,13 @@ public sealed record TaskDto(
|
||||
// A planning/improvement child reporting > 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.
|
||||
@@ -211,7 +220,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<TaskDto> GetTask(string taskId, CancellationToken cancellationToken)
|
||||
{
|
||||
var task = await _tasks.GetByIdAsync(taskId, cancellationToken)
|
||||
@@ -1326,7 +1336,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,
|
||||
@@ -1335,7 +1348,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
|
||||
|
||||
+6
-1
@@ -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<ResetFailedTaskResult> ResetFailedTask(string taskId, CancellationToken cancellationToken)
|
||||
{
|
||||
var task = await _tasks.GetByIdAsync(taskId, cancellationToken)
|
||||
|
||||
@@ -175,12 +175,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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -189,7 +191,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)
|
||||
{
|
||||
@@ -284,7 +286,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);
|
||||
@@ -292,7 +295,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)
|
||||
{
|
||||
@@ -388,6 +391,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;
|
||||
@@ -405,6 +411,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
|
||||
@@ -517,14 +524,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);
|
||||
@@ -535,6 +545,16 @@ public sealed class TaskRunner
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Classifies the CLI's raw <c>terminal_reason</c> into the small, MCP-facing enum
|
||||
/// (<c>max_turns|timeout|error</c>) 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).</summary>
|
||||
internal static string ClassifyFailureReason(string? terminalReason) => terminalReason switch
|
||||
{
|
||||
"max_turns" => "max_turns",
|
||||
"timeout" => "timeout",
|
||||
_ => "error",
|
||||
};
|
||||
|
||||
private string BuildRunMcpConfigJson(string token)
|
||||
{
|
||||
var payload = new
|
||||
|
||||
@@ -8,7 +8,9 @@ public interface ITaskStateService
|
||||
Task<TransitionResult> SubmitForReviewAsync(string taskId, DateTime finishedAt, string? result, CancellationToken ct);
|
||||
Task<TransitionResult> SubmitInteractiveForReviewAsync(string taskId, DateTime finishedAt, CancellationToken ct);
|
||||
Task<TransitionResult> SubmitForChildrenAsync(string taskId, DateTime finishedAt, string? result, CancellationToken ct);
|
||||
Task<TransitionResult> FailAsync(string taskId, DateTime finishedAt, string? error, CancellationToken ct);
|
||||
Task<TransitionResult> FailAsync(
|
||||
string taskId, DateTime finishedAt, string? error, CancellationToken ct,
|
||||
string failureReason = "error", int? turnsUsed = null, int? maxTurns = null);
|
||||
Task<TransitionResult> CancelAsync(string taskId, DateTime finishedAt, CancellationToken ct, bool allowFromIdle = false);
|
||||
Task<TransitionResult> ResetToIdleAsync(string taskId, CancellationToken ct);
|
||||
|
||||
|
||||
@@ -226,7 +226,9 @@ public sealed class TaskStateService : ITaskStateService
|
||||
: new TransitionResult(true, null);
|
||||
}
|
||||
|
||||
public async Task<TransitionResult> FailAsync(string taskId, DateTime finishedAt, string? error, CancellationToken ct)
|
||||
public async Task<TransitionResult> 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).");
|
||||
|
||||
@@ -224,6 +224,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()
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user