feat(worker,data): persist interactive session id so a closed/aborted ConPTY session can be resumed

Generates the claude session id up front (--session-id <guid>) for a fresh interactive task
session and persists it to TaskEntity.InteractiveSessionId before launch, so an abort at any
point still leaves a resumable id. BuildForTaskAsync now resumes this task's own last
interactive session in preference to the latest autonomous run's session, but never across a
freshly (re)created worktree.
This commit is contained in:
mika kuns
2026-08-06 13:27:52 +02:00
parent 0d1e3b9a6f
commit 1a988ff4fc
10 changed files with 1054 additions and 26 deletions
+2 -1
View File
@@ -4,9 +4,10 @@ Shared data layer: models, repositories, SQLite infrastructure, and git operatio
## Models ## Models
- **TaskEntity** — Id, ListId, Title, Description, Status, PlanningPhase, BlockedByTaskId (FK to predecessor in a chain), ScheduledFor, Result, ReviewFeedback, LogPath, timestamps, CommitType, Model / SystemPrompt / AgentPath / MaxTurns (nullable overrides), IsStarred, IsMyDay, IsManual, Notes, ParentTaskId, PlanningSessionId / PlanningSessionToken / PlanningFinalizedAt, CreatedBy, HandlerBaseCommit / HandlerHeadCommit. - **TaskEntity** — Id, ListId, Title, Description, Status, PlanningPhase, BlockedByTaskId (FK to predecessor in a chain), ScheduledFor, Result, ReviewFeedback, LogPath, timestamps, CommitType, Model / SystemPrompt / AgentPath / MaxTurns (nullable overrides), IsStarred, IsMyDay, IsManual, Notes, ParentTaskId, PlanningSessionId / PlanningSessionToken / PlanningFinalizedAt, CreatedBy, HandlerBaseCommit / HandlerHeadCommit, InteractiveSessionId.
- Status / PlanningPhase / BlockedByTaskId semantics + allowed transitions: `ClaudeDo.Worker/CLAUDE.md` → Status Model. - Status / PlanningPhase / BlockedByTaskId semantics + allowed transitions: `ClaudeDo.Worker/CLAUDE.md` → Status Model.
- `HandlerBaseCommit`/`HandlerHeadCommit` = the review range for a **worktree-less "list handler" host task** ("Let Claude handle it"), which commits straight into the list's working dir instead of a per-task worktree. Everything that reads a task's diff falls back to this pair whenever `Worktree` is null → [conpty-sessions](../../docs/explore-notes/conpty-sessions.md). - `HandlerBaseCommit`/`HandlerHeadCommit` = the review range for a **worktree-less "list handler" host task** ("Let Claude handle it"), which commits straight into the list's working dir instead of a per-task worktree. Everything that reads a task's diff falls back to this pair whenever `Worktree` is null → [conpty-sessions](../../docs/explore-notes/conpty-sessions.md).
- `InteractiveSessionId` = the claude session id an embedded ConPTY interactive task session runs under, persisted by `InteractiveLaunchSpecService` before launch so a closed/aborted session can be resumed → [conpty-sessions](../../docs/explore-notes/conpty-sessions.md).
- Legacy status values `Manual`/`Planning`/`Planned`/`Draft`/`Waiting` were retired; existing rows backfill via the `RetireLegacyTaskStatus` migration. - Legacy status values `Manual`/`Planning`/`Planned`/`Draft`/`Waiting` were retired; existing rows backfill via the `RetireLegacyTaskStatus` migration.
- **ListEntity** — Id, Name, WorkingDir, DefaultCommitType, CreatedAt, IsManual (reminder list — tasks created here default to `IsManual`) - **ListEntity** — Id, Name, WorkingDir, DefaultCommitType, CreatedAt, IsManual (reminder list — tasks created here default to `IsManual`)
- **ListConfigEntity** — ListId (PK, 1:1), Model, SystemPrompt, AgentPath, MaxTurns, SessionSkills, VerifyCommand (all nullable). `VerifyCommand` is an optional post-merge gate; null/blank = no gate → [review-merge](../../docs/explore-notes/review-merge.md). - **ListConfigEntity** — ListId (PK, 1:1), Model, SystemPrompt, AgentPath, MaxTurns, SessionSkills, VerifyCommand (all nullable). `VerifyCommand` is an optional post-merge gate; null/blank = no gate → [review-merge](../../docs/explore-notes/review-merge.md).
@@ -95,6 +95,7 @@ public class TaskEntityConfiguration : IEntityTypeConfiguration<TaskEntity>
builder.Property(t => t.SessionSkills).HasColumnName("session_skills"); builder.Property(t => t.SessionSkills).HasColumnName("session_skills");
builder.Property(t => t.HandlerBaseCommit).HasColumnName("handler_base_commit"); builder.Property(t => t.HandlerBaseCommit).HasColumnName("handler_base_commit");
builder.Property(t => t.HandlerHeadCommit).HasColumnName("handler_head_commit"); builder.Property(t => t.HandlerHeadCommit).HasColumnName("handler_head_commit");
builder.Property(t => t.InteractiveSessionId).HasColumnName("interactive_session_id");
builder.Property(t => t.ParentTaskId).HasColumnName("parent_task_id"); builder.Property(t => t.ParentTaskId).HasColumnName("parent_task_id");
builder.Property(t => t.PlanningSessionId).HasColumnName("planning_session_id"); builder.Property(t => t.PlanningSessionId).HasColumnName("planning_session_id");
@@ -0,0 +1,869 @@
// <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("20260806111454_AddInteractiveSessionId")]
partial class AddInteractiveSessionId
{
/// <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>("UsageThrottleHardPct")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(65)
.HasColumnName("usage_throttle_hard_pct");
b.Property<int>("UsageThrottleSoftPct")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(50)
.HasColumnName("usage_throttle_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,
UsageThrottleHardPct = 65,
UsageThrottleSoftPct = 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>("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<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<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<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<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,28 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ClaudeDo.Data.Migrations
{
/// <inheritdoc />
public partial class AddInteractiveSessionId : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "interactive_session_id",
table: "tasks",
type: "TEXT",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "interactive_session_id",
table: "tasks");
}
}
}
@@ -463,6 +463,10 @@ namespace ClaudeDo.Data.Migrations
.HasColumnType("TEXT") .HasColumnType("TEXT")
.HasColumnName("handler_head_commit"); .HasColumnName("handler_head_commit");
b.Property<string>("InteractiveSessionId")
.HasColumnType("TEXT")
.HasColumnName("interactive_session_id");
b.Property<bool>("IsManual") b.Property<bool>("IsManual")
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
.HasColumnType("INTEGER") .HasColumnType("INTEGER")
+6
View File
@@ -60,6 +60,12 @@ public sealed class TaskEntity
public string? HandlerBaseCommit { get; set; } public string? HandlerBaseCommit { get; set; }
public string? HandlerHeadCommit { get; set; } public string? HandlerHeadCommit { get; set; }
// The claude session id an embedded ConPTY interactive task session is (or was last)
// running under -- generated up front and persisted before launch so a closed/aborted
// session can be resumed even if the process never got past startup. Cleared implicitly
// whenever the task's worktree is recreated (a fresh worktree has nothing to resume into).
public string? InteractiveSessionId { get; set; }
public string? ParentTaskId { get; set; } public string? ParentTaskId { get; set; }
public string? PlanningSessionId { get; set; } public string? PlanningSessionId { get; set; }
public string? PlanningSessionToken { get; set; } public string? PlanningSessionToken { get; set; }
@@ -402,6 +402,19 @@ public sealed class TaskRepository
.SetProperty(t => t.HandlerHeadCommit, headCommit), ct); .SetProperty(t => t.HandlerHeadCommit, headCommit), ct);
} }
// Persists the claude session id a fresh embedded ConPTY interactive task session will run
// under, written BEFORE launch so a closed/aborted session still leaves a resumable id.
public async Task SetInteractiveSessionIdAsync(
string taskId,
string? sessionId,
CancellationToken ct = default)
{
await _context.Tasks
.Where(t => t.Id == taskId)
.ExecuteUpdateAsync(s => s
.SetProperty(t => t.InteractiveSessionId, sessionId), ct);
}
public async Task<TaskEntity?> FindByPlanningTokenAsync( public async Task<TaskEntity?> FindByPlanningTokenAsync(
string token, string token,
CancellationToken ct = default) CancellationToken ct = default)
@@ -104,12 +104,30 @@ public sealed class InteractiveLaunchSpecService : IInteractiveLaunchSpecService
// The model itself is deliberately NOT forced here — the user can still switch it in the TUI. // The model itself is deliberately NOT forced here — the user can still switch it in the TUI.
var effort = EffortFor(globalSettings, task.Model ?? listConfig?.Model); var effort = EffortFor(globalSettings, task.Model ?? listConfig?.Model);
// Resume an existing session as-is; for a fresh session, seed the interactive TUI with // Resume this task's own last interactive conversation when it has one -- it takes
// the task's brief (title + description) via a file, never as a positional CLI argument -- // precedence over the latest autonomous run's session, since an interactive session is a
// see BuildFreshTaskArgsAsync for why. // distinct conversation from an autonomous run even against the same worktree. Fall back
var args = run?.SessionId is { Length: > 0 } sessionId // to the autonomous run's session so opening a task interactively for the first time still
? WithEffort(WindowsTerminalLauncher.BuildResumeArgs(sessionId), effort) // picks up prior context. Neither survives a freshly (re)created worktree (isFreshWorktree
: await BuildFreshTaskArgsAsync(task, effort, ct); // already forced `run` to null above).
var resumeSessionId = isFreshWorktree ? null : task.InteractiveSessionId ?? run?.SessionId;
// For a fresh session, seed the interactive TUI with the task's brief (title +
// description) via a file, never as a positional CLI argument -- see
// BuildFreshTaskArgsAsync for why. The session id claude will run under is generated and
// persisted HERE, before launch, so a closed/aborted session -- even one that never got
// past startup -- still leaves an id the next open can resume.
IReadOnlyList<string> args;
if (resumeSessionId is { Length: > 0 })
{
args = WithEffort(WindowsTerminalLauncher.BuildResumeArgs(resumeSessionId), effort);
}
else
{
var sessionId = Guid.NewGuid().ToString();
await new TaskRepository(ctx).SetInteractiveSessionIdAsync(taskId, sessionId, ct);
args = await BuildFreshTaskArgsAsync(task, effort, sessionId, ct);
}
// Same run environment variable ClaudeProcess sets for every headless run: the // Same run environment variable ClaudeProcess sets for every headless run: the
// AskUser MCP tool call and wait_for_task_change cap at 60s unless raised, and lifting // AskUser MCP tool call and wait_for_task_change cap at 60s unless raised, and lifting
@@ -445,11 +463,15 @@ public sealed class InteractiveLaunchSpecService : IInteractiveLaunchSpecService
// Read; --effort (single-value) must sit directly before the positional kickoff so the // Read; --effort (single-value) must sit directly before the positional kickoff so the
// preceding variadic --add-dir doesn't swallow the kickoff as another directory. // preceding variadic --add-dir doesn't swallow the kickoff as another directory.
// No brief (task has neither a title nor a description) -> no positional arg at all. // No brief (task has neither a title nor a description) -> no positional arg at all.
private static async Task<IReadOnlyList<string>> BuildFreshTaskArgsAsync(TaskEntity task, string effort, CancellationToken ct) // `--session-id` pre-assigns the claude session id the caller already persisted (see
// BuildForTaskAsync) so this fresh conversation is resumable from its very first turn --
// it's a single-value flag, so it may sit directly before the positional kickoff.
private static async Task<IReadOnlyList<string>> BuildFreshTaskArgsAsync(
TaskEntity task, string effort, string sessionId, CancellationToken ct)
{ {
var brief = BuildTaskBrief(task); var brief = BuildTaskBrief(task);
if (string.IsNullOrEmpty(brief)) if (string.IsNullOrEmpty(brief))
return new[] { "--effort", effort }; return new[] { "--effort", effort, "--session-id", sessionId };
var sessionDir = Path.Combine(Paths.AppDataRoot(), "task-sessions", task.Id); var sessionDir = Path.Combine(Paths.AppDataRoot(), "task-sessions", task.Id);
Directory.CreateDirectory(sessionDir); Directory.CreateDirectory(sessionDir);
@@ -460,6 +482,7 @@ public sealed class InteractiveLaunchSpecService : IInteractiveLaunchSpecService
{ {
"--add-dir", sessionDir, "--add-dir", sessionDir,
"--effort", effort, "--effort", effort,
"--session-id", sessionId,
$"Read the file {briefPath} first. It contains the task you must work on. " + $"Read the file {briefPath} first. It contains the task you must work on. " +
"After reading it, begin the session as your instructions describe.", "After reading it, begin the session as your instructions describe.",
}; };
@@ -17,9 +17,12 @@ public interface IInteractiveLaunchSpecService
/// Throws KeyNotFoundException if the task doesn't exist, InvalidOperationException /// Throws KeyNotFoundException if the task doesn't exist, InvalidOperationException
/// if it's Running/Queued. If the task has no usable worktree yet, one is created on /// if it's Running/Queued. If the task has no usable worktree yet, one is created on
/// demand (same mechanism as an autonomous run) provided the task's list has a working /// demand (same mechanism as an autonomous run) provided the task's list has a working
/// directory pointing at a git repo -- otherwise throws InvalidOperationException. A task /// directory pointing at a git repo -- otherwise throws InvalidOperationException. Resumes
/// that has never run, or whose worktree was just created fresh, gets a fresh-start spec /// (--resume) this task's own last interactive session (TaskEntity.InteractiveSessionId) if
/// (no --resume); an existing worktree with a persisted SessionId gets --resume.</summary> /// it has one, else the latest autonomous run's session; a task that has never run either
/// way, or whose worktree was just created fresh, gets a fresh-start spec instead -- pre-
/// assigned a new session id via --session-id and persisted to InteractiveSessionId before
/// launch, so a closed/aborted session can be resumed next time.</summary>
Task<LaunchSpec> BuildForTaskAsync(string taskId, CancellationToken ct); Task<LaunchSpec> BuildForTaskAsync(string taskId, CancellationToken ct);
/// <summary>Builds a LaunchSpec for an ad-hoc interactive session in an arbitrary directory -- /// <summary>Builds a LaunchSpec for an ad-hoc interactive session in an arbitrary directory --
@@ -194,7 +194,7 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
Assert.Equal(wtRow.Path, spec.Cwd); Assert.Equal(wtRow.Path, spec.Cwd);
Assert.Equal(_claudeStubPath, spec.Exe); Assert.Equal(_claudeStubPath, spec.Exe);
var (_, briefPath, kickoff) = ParseFreshTaskArgs(spec); var (_, briefPath, kickoff, _) = ParseFreshTaskArgs(spec);
Assert.Contains(briefPath, kickoff); // fresh session points claude at the brief file, not a raw prompt Assert.Contains(briefPath, kickoff); // fresh session points claude at the brief file, not a raw prompt
Assert.Equal("T", File.ReadAllText(briefPath)); // brief file holds the task title Assert.Equal("T", File.ReadAllText(briefPath)); // brief file holds the task title
} }
@@ -272,10 +272,14 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
var svc = BuildService(); var svc = BuildService();
var spec = await svc.BuildForTaskAsync(taskId, CancellationToken.None); var spec = await svc.BuildForTaskAsync(taskId, CancellationToken.None);
var (_, briefPath, kickoff) = ParseFreshTaskArgs(spec); // fresh: no --resume, brief file holds the title var (_, briefPath, kickoff, sessionId) = ParseFreshTaskArgs(spec); // fresh: no --resume, brief file holds the title
Assert.Contains(briefPath, kickoff); Assert.Contains(briefPath, kickoff);
Assert.Equal("T", File.ReadAllText(briefPath)); Assert.Equal("T", File.ReadAllText(briefPath));
Assert.Equal(_worktreeDir, spec.Cwd); Assert.Equal(_worktreeDir, spec.Cwd);
// The generated session id is persisted BEFORE this call returns, so a closed/aborted
// session still leaves a resumable id.
Assert.Equal(sessionId, await ReadInteractiveSessionIdAsync(taskId));
} }
[Fact] [Fact]
@@ -290,9 +294,73 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
var svc = BuildService(); var svc = BuildService();
var spec = await svc.BuildForTaskAsync(taskId, CancellationToken.None); var spec = await svc.BuildForTaskAsync(taskId, CancellationToken.None);
var (_, briefPath, kickoff) = ParseFreshTaskArgs(spec); // fresh: brief file holds the title var (_, briefPath, kickoff, sessionId) = ParseFreshTaskArgs(spec); // fresh: brief file holds the title
Assert.Contains(briefPath, kickoff); Assert.Contains(briefPath, kickoff);
Assert.Equal("T", File.ReadAllText(briefPath)); Assert.Equal("T", File.ReadAllText(briefPath));
Assert.Equal(sessionId, await ReadInteractiveSessionIdAsync(taskId));
}
[Fact]
public async Task BuildForTaskAsync_ExistingInteractiveSessionId_TakesPrecedenceOverRunSessionId()
{
var listId = await SeedListAsync();
var taskId = Guid.NewGuid().ToString();
await SeedTaskAsync(taskId, listId, TaskStatus.Idle);
await SeedWorktreeAsync(taskId, WorktreeState.Active);
await SeedRunAsync(taskId, "autonomous-run-session");
using (var ctx = _db.CreateContext())
{
await new TaskRepository(ctx).SetInteractiveSessionIdAsync(taskId, "interactive-session-1");
}
var svc = BuildService();
var spec = await svc.BuildForTaskAsync(taskId, CancellationToken.None);
Assert.Equal(new[] { "--resume", "interactive-session-1" }, ArgsAfterEffort(spec));
}
[Fact]
public async Task BuildForTaskAsync_ReopeningInteractiveSession_ResumesItsOwnPriorSessionId()
{
var listId = await SeedListAsync();
var taskId = Guid.NewGuid().ToString();
await SeedTaskAsync(taskId, listId, TaskStatus.Idle);
await SeedWorktreeAsync(taskId, WorktreeState.Active);
// No autonomous run at all -- only a prior interactive session for this task.
var svc = BuildService();
var firstSpec = await svc.BuildForTaskAsync(taskId, CancellationToken.None);
var (_, _, _, firstSessionId) = ParseFreshTaskArgs(firstSpec);
// Reopen (e.g. the pane was closed/aborted) -- must resume the SAME session id, not
// start a new conversation.
var secondSpec = await svc.BuildForTaskAsync(taskId, CancellationToken.None);
Assert.Equal(new[] { "--resume", firstSessionId }, ArgsAfterEffort(secondSpec));
}
[Fact]
public async Task BuildForTaskAsync_FreshlyRecreatedWorktree_IgnoresAndOverwritesStaleInteractiveSessionId()
{
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
var repo = CreateRepo();
var listId = await SeedListAsync(workingDir: repo.RepoDir);
var taskId = Guid.NewGuid().ToString();
await SeedTaskAsync(taskId, listId, TaskStatus.Idle);
// No worktree row seeded -- BuildForTaskAsync will create one fresh.
using (var ctx = _db.CreateContext())
{
await new TaskRepository(ctx).SetInteractiveSessionIdAsync(taskId, "stale-interactive-session");
}
var svc = BuildService();
var spec = await svc.BuildForTaskAsync(taskId, CancellationToken.None);
// Fresh-start, not a resume of the stale id.
var (_, _, _, newSessionId) = ParseFreshTaskArgs(spec);
Assert.NotEqual("stale-interactive-session", newSessionId);
Assert.Equal(newSessionId, await ReadInteractiveSessionIdAsync(taskId));
} }
[Fact] [Fact]
@@ -312,7 +380,7 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
var svc = BuildService(); var svc = BuildService();
var spec = await svc.BuildForTaskAsync(taskId, CancellationToken.None); var spec = await svc.BuildForTaskAsync(taskId, CancellationToken.None);
var (_, briefPath, kickoff) = ParseFreshTaskArgs(spec); var (_, briefPath, kickoff, _) = ParseFreshTaskArgs(spec);
Assert.Contains(briefPath, kickoff); Assert.Contains(briefPath, kickoff);
Assert.Equal("T\n\nDo the thing", File.ReadAllText(briefPath)); Assert.Equal("T\n\nDo the thing", File.ReadAllText(briefPath));
} }
@@ -336,7 +404,7 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
var svc = BuildService(); var svc = BuildService();
var spec = await svc.BuildForTaskAsync(taskId, CancellationToken.None); var spec = await svc.BuildForTaskAsync(taskId, CancellationToken.None);
var (_, briefPath, kickoff) = ParseFreshTaskArgs(spec); var (_, briefPath, kickoff, _) = ParseFreshTaskArgs(spec);
Assert.Contains(briefPath, kickoff); Assert.Contains(briefPath, kickoff);
Assert.DoesNotContain('\n', kickoff); Assert.DoesNotContain('\n', kickoff);
@@ -364,7 +432,10 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
var svc = BuildService(); var svc = BuildService();
var spec = await svc.BuildForTaskAsync(taskId, CancellationToken.None); var spec = await svc.BuildForTaskAsync(taskId, CancellationToken.None);
Assert.Empty(ArgsAfterEffort(spec)); // no brief -> no --add-dir, no positional arg // no brief -> no --add-dir, no positional arg -- but a session id is still pre-assigned
var rest = ArgsAfterEffort(spec);
Assert.Equal("--session-id", rest[0]);
Assert.Equal(rest[1], await ReadInteractiveSessionIdAsync(taskId));
} }
[Fact] [Fact]
@@ -427,22 +498,31 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
} }
/// A fresh (never-resumed) task with a title/description produces /// A fresh (never-resumed) task with a title/description produces
/// `--add-dir <sessionDir> --effort <level> <kickoff>` -- the brief travels via the file at /// `--add-dir <sessionDir> --effort <level> --session-id <guid> <kickoff>` -- the brief
/// <sessionDir>/brief.md, never as a raw CLI argument. Asserts that exact shape and returns /// travels via the file at <sessionDir>/brief.md, never as a raw CLI argument. Asserts that
/// (sessionDir, briefPath, kickoff) for the test's own checks. Cleanup happens via /// exact shape and returns (sessionDir, briefPath, kickoff, sessionId) for the test's own
/// `_seededTaskIds` in Dispose (the session dir is keyed by task id). /// checks. Cleanup happens via `_seededTaskIds` in Dispose (the session dir is keyed by task id).
private static (string SessionDir, string BriefPath, string Kickoff) ParseFreshTaskArgs(LaunchSpec spec) private static (string SessionDir, string BriefPath, string Kickoff, string SessionId) ParseFreshTaskArgs(LaunchSpec spec)
{ {
var args = spec.Args.ToList(); var args = spec.Args.ToList();
Assert.Equal(5, args.Count); Assert.Equal(7, args.Count);
Assert.Equal("--add-dir", args[0]); Assert.Equal("--add-dir", args[0]);
var sessionDir = args[1]; var sessionDir = args[1];
Assert.Equal("--effort", args[2]); Assert.Equal("--effort", args[2]);
Assert.Equal(ModelPresets.For(ModelPresets.Defaults, ModelRegistry.DefaultAlias).Effort, args[3]); Assert.Equal(ModelPresets.For(ModelPresets.Defaults, ModelRegistry.DefaultAlias).Effort, args[3]);
var kickoff = args[4]; Assert.Equal("--session-id", args[4]);
var sessionId = args[5];
var kickoff = args[6];
var briefPath = Path.Combine(sessionDir, "brief.md"); var briefPath = Path.Combine(sessionDir, "brief.md");
return (sessionDir, briefPath, kickoff); return (sessionDir, briefPath, kickoff, sessionId);
}
private async Task<string?> ReadInteractiveSessionIdAsync(string taskId)
{
using var ctx = _db.CreateContext();
var task = await new TaskRepository(ctx).GetByIdAsync(taskId);
return task!.InteractiveSessionId;
} }
private readonly List<string> _mergeHelperSessionDirs = new(); private readonly List<string> _mergeHelperSessionDirs = new();