From 1a988ff4fc5b92ec3d8a32dec3a8e781b05be1b3 Mon Sep 17 00:00:00 2001 From: mika kuns Date: Thu, 6 Aug 2026 13:27:52 +0200 Subject: [PATCH] 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 ) 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. --- src/ClaudeDo.Data/CLAUDE.md | 3 +- .../Configuration/TaskEntityConfiguration.cs | 1 + ...111454_AddInteractiveSessionId.Designer.cs | 869 ++++++++++++++++++ .../20260806111454_AddInteractiveSessionId.cs | 28 + .../ClaudeDoDbContextModelSnapshot.cs | 4 + src/ClaudeDo.Data/Models/TaskEntity.cs | 6 + .../Repositories/TaskRepository.cs | 13 + .../Runner/InteractiveLaunchSpecService.cs | 39 +- .../IInteractiveLaunchSpecService.cs | 9 +- .../InteractiveLaunchSpecServiceTests.cs | 108 ++- 10 files changed, 1054 insertions(+), 26 deletions(-) create mode 100644 src/ClaudeDo.Data/Migrations/20260806111454_AddInteractiveSessionId.Designer.cs create mode 100644 src/ClaudeDo.Data/Migrations/20260806111454_AddInteractiveSessionId.cs diff --git a/src/ClaudeDo.Data/CLAUDE.md b/src/ClaudeDo.Data/CLAUDE.md index d270b9ef..e83d865f 100644 --- a/src/ClaudeDo.Data/CLAUDE.md +++ b/src/ClaudeDo.Data/CLAUDE.md @@ -4,9 +4,10 @@ Shared data layer: models, repositories, SQLite infrastructure, and git operatio ## 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. - `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. - **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). diff --git a/src/ClaudeDo.Data/Configuration/TaskEntityConfiguration.cs b/src/ClaudeDo.Data/Configuration/TaskEntityConfiguration.cs index 0126cf67..a42e2f7b 100644 --- a/src/ClaudeDo.Data/Configuration/TaskEntityConfiguration.cs +++ b/src/ClaudeDo.Data/Configuration/TaskEntityConfiguration.cs @@ -95,6 +95,7 @@ public class TaskEntityConfiguration : IEntityTypeConfiguration builder.Property(t => t.SessionSkills).HasColumnName("session_skills"); builder.Property(t => t.HandlerBaseCommit).HasColumnName("handler_base_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.PlanningSessionId).HasColumnName("planning_session_id"); diff --git a/src/ClaudeDo.Data/Migrations/20260806111454_AddInteractiveSessionId.Designer.cs b/src/ClaudeDo.Data/Migrations/20260806111454_AddInteractiveSessionId.Designer.cs new file mode 100644 index 00000000..35741f74 --- /dev/null +++ b/src/ClaudeDo.Data/Migrations/20260806111454_AddInteractiveSessionId.Designer.cs @@ -0,0 +1,869 @@ +// +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 + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "8.0.11"); + + modelBuilder.Entity("ClaudeDo.Data.Models.AppSettingsEntity", b => + { + b.Property("Id") + .HasColumnType("INTEGER") + .HasColumnName("id"); + + b.Property("CentralWorktreeRoot") + .HasColumnType("TEXT") + .HasColumnName("central_worktree_root"); + + b.Property("DailyPrepMaxTasks") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(5) + .HasColumnName("daily_prep_max_tasks"); + + b.Property("DefaultClaudeInstructions") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("") + .HasColumnName("default_claude_instructions"); + + b.Property("DefaultMaxTurns") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(40) + .HasColumnName("default_max_turns"); + + b.Property("DefaultModel") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("sonnet") + .HasColumnName("default_model"); + + b.Property("DefaultPermissionMode") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("bypassPermissions") + .HasColumnName("default_permission_mode"); + + b.Property("MaxParallelExecutions") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(1) + .HasColumnName("max_parallel_executions"); + + b.Property("MaxTurnsCeiling") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(80) + .HasColumnName("max_turns_ceiling"); + + b.Property("ModelPresets") + .HasColumnType("TEXT") + .HasColumnName("model_presets"); + + b.Property("RepoImportFolders") + .HasColumnType("TEXT") + .HasColumnName("repo_import_folders"); + + b.Property("ReportExcludedPaths") + .HasColumnType("TEXT") + .HasColumnName("report_excluded_paths"); + + b.Property("SessionSkills") + .HasColumnType("TEXT") + .HasColumnName("session_skills"); + + b.Property("StandupWeekday") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(3) + .HasColumnName("standup_weekday"); + + b.Property("UsageGateFiveHourPct") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(80) + .HasColumnName("usage_gate_five_hour_pct"); + + b.Property("UsageGateSevenDayPct") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(90) + .HasColumnName("usage_gate_seven_day_pct"); + + b.Property("UsageThrottleHardPct") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(65) + .HasColumnName("usage_throttle_hard_pct"); + + b.Property("UsageThrottleSoftPct") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(50) + .HasColumnName("usage_throttle_soft_pct"); + + b.Property("WorktreeAutoCleanupDays") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(7) + .HasColumnName("worktree_auto_cleanup_days"); + + b.Property("WorktreeAutoCleanupEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false) + .HasColumnName("worktree_auto_cleanup_enabled"); + + b.Property("WorktreeStrategy") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("sibling") + .HasColumnName("worktree_strategy"); + + b.HasKey("Id"); + + b.ToTable("app_settings", (string)null); + + b.HasData( + new + { + Id = 1, + DailyPrepMaxTasks = 5, + DefaultClaudeInstructions = "", + DefaultMaxTurns = 40, + DefaultModel = "sonnet", + DefaultPermissionMode = "auto", + MaxParallelExecutions = 1, + MaxTurnsCeiling = 80, + StandupWeekday = 3, + UsageGateFiveHourPct = 80, + UsageGateSevenDayPct = 90, + UsageThrottleHardPct = 65, + UsageThrottleSoftPct = 50, + WorktreeAutoCleanupDays = 7, + WorktreeAutoCleanupEnabled = false, + WorktreeStrategy = "sibling" + }); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.DailyNoteEntity", b => + { + b.Property("Id") + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("Date") + .HasColumnType("TEXT") + .HasColumnName("note_date"); + + b.Property("SortOrder") + .HasColumnType("INTEGER") + .HasColumnName("sort_order"); + + b.Property("Text") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("text"); + + b.HasKey("Id"); + + b.HasIndex("Date"); + + b.ToTable("daily_notes", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b => + { + b.Property("ListId") + .HasColumnType("TEXT") + .HasColumnName("list_id"); + + b.Property("AgentPath") + .HasColumnType("TEXT") + .HasColumnName("agent_path"); + + b.Property("MaxTurns") + .HasColumnType("INTEGER") + .HasColumnName("max_turns"); + + b.Property("Model") + .HasColumnType("TEXT") + .HasColumnName("model"); + + b.Property("SessionSkills") + .HasColumnType("TEXT") + .HasColumnName("session_skills"); + + b.Property("SystemPrompt") + .HasColumnType("TEXT") + .HasColumnName("system_prompt"); + + b.Property("VerifyCommand") + .HasColumnType("TEXT") + .HasColumnName("verify_command"); + + b.HasKey("ListId"); + + b.ToTable("list_config", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b => + { + b.Property("Id") + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("DefaultCommitType") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("chore") + .HasColumnName("default_commit_type"); + + b.Property("IsManual") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false) + .HasColumnName("is_manual"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property("SortOrder") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0) + .HasColumnName("sort_order"); + + b.Property("WorkingDir") + .HasColumnType("TEXT") + .HasColumnName("working_dir"); + + b.HasKey("Id"); + + b.HasIndex("SortOrder") + .HasDatabaseName("idx_lists_sort"); + + b.ToTable("lists", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.PrimeScheduleEntity", b => + { + b.Property("Id") + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("Days") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(31) + .HasColumnName("days_of_week"); + + b.Property("Enabled") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true) + .HasColumnName("enabled"); + + b.Property("LastRunAt") + .HasColumnType("TEXT") + .HasColumnName("last_run_at"); + + b.Property("PromptOverride") + .HasColumnType("TEXT") + .HasColumnName("prompt_override"); + + b.Property("TimeOfDay") + .HasColumnType("TEXT") + .HasColumnName("time_of_day"); + + b.HasKey("Id"); + + b.ToTable("prime_schedules", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.SessionSkillEntity", b => + { + b.Property("Name") + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property("AddedAt") + .HasColumnType("TEXT") + .HasColumnName("added_at"); + + b.Property("Description") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("description"); + + b.Property("PinnedRef") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("pinned_ref"); + + b.Property("SourceUrl") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("source_url"); + + b.Property("Subpath") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("subpath"); + + b.HasKey("Name"); + + b.ToTable("session_skills", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b => + { + b.Property("Id") + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("Completed") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false) + .HasColumnName("completed"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("OrderNum") + .HasColumnType("INTEGER") + .HasColumnName("order_num"); + + b.Property("TaskId") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("task_id"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("title"); + + b.HasKey("Id"); + + b.HasIndex("TaskId") + .HasDatabaseName("idx_subtasks_task_id"); + + b.ToTable("subtasks", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.TaskAttachmentEntity", b => + { + b.Property("Id") + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("ByteSize") + .HasColumnType("INTEGER") + .HasColumnName("byte_size"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("FileName") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("file_name"); + + b.Property("TaskId") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("task_id"); + + b.HasKey("Id"); + + b.HasIndex("TaskId") + .HasDatabaseName("idx_task_attachments_task_id"); + + b.ToTable("task_attachments", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b => + { + b.Property("Id") + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("AgentPath") + .HasColumnType("TEXT") + .HasColumnName("agent_path"); + + b.Property("BlockedByTaskId") + .HasColumnType("TEXT") + .HasColumnName("blocked_by_task_id"); + + b.Property("CommitType") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("chore") + .HasColumnName("commit_type"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .HasColumnType("TEXT") + .HasColumnName("created_by"); + + b.Property("Description") + .HasColumnType("TEXT") + .HasColumnName("description"); + + b.Property("FinishedAt") + .HasColumnType("TEXT") + .HasColumnName("finished_at"); + + b.Property("HandlerBaseCommit") + .HasColumnType("TEXT") + .HasColumnName("handler_base_commit"); + + b.Property("HandlerHeadCommit") + .HasColumnType("TEXT") + .HasColumnName("handler_head_commit"); + + b.Property("InteractiveSessionId") + .HasColumnType("TEXT") + .HasColumnName("interactive_session_id"); + + b.Property("IsManual") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false) + .HasColumnName("is_manual"); + + b.Property("IsMyDay") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false) + .HasColumnName("is_my_day"); + + b.Property("IsStarred") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false) + .HasColumnName("is_starred"); + + b.Property("ListId") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("list_id"); + + b.Property("LogPath") + .HasColumnType("TEXT") + .HasColumnName("log_path"); + + b.Property("MaxTurns") + .HasColumnType("INTEGER") + .HasColumnName("max_turns"); + + b.Property("Model") + .HasColumnType("TEXT") + .HasColumnName("model"); + + b.Property("Notes") + .HasColumnType("TEXT") + .HasColumnName("notes"); + + b.Property("ParentTaskId") + .HasColumnType("TEXT") + .HasColumnName("parent_task_id"); + + b.Property("PlanningFinalizedAt") + .HasColumnType("TEXT") + .HasColumnName("planning_finalized_at"); + + b.Property("PlanningPhase") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("none") + .HasColumnName("planning_phase"); + + b.Property("PlanningSessionId") + .HasColumnType("TEXT") + .HasColumnName("planning_session_id"); + + b.Property("PlanningSessionToken") + .HasColumnType("TEXT") + .HasColumnName("planning_session_token"); + + b.Property("Result") + .HasColumnType("TEXT") + .HasColumnName("result"); + + b.Property("ReviewFeedback") + .HasColumnType("TEXT") + .HasColumnName("review_feedback"); + + b.Property("RoadblockCount") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0) + .HasColumnName("roadblock_count"); + + b.Property("ScheduledFor") + .HasColumnType("TEXT") + .HasColumnName("scheduled_for"); + + b.Property("SessionSkills") + .HasColumnType("TEXT") + .HasColumnName("session_skills"); + + b.Property("SortOrder") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0) + .HasColumnName("sort_order"); + + b.Property("StartedAt") + .HasColumnType("TEXT") + .HasColumnName("started_at"); + + b.Property("Status") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("status"); + + b.Property("SystemPrompt") + .HasColumnType("TEXT") + .HasColumnName("system_prompt"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("title"); + + b.HasKey("Id"); + + b.HasIndex("BlockedByTaskId") + .HasDatabaseName("idx_tasks_blocked_by"); + + b.HasIndex("ListId") + .HasDatabaseName("idx_tasks_list_id"); + + b.HasIndex("ParentTaskId") + .HasDatabaseName("idx_tasks_parent_task_id"); + + b.HasIndex("Status") + .HasDatabaseName("idx_tasks_status"); + + b.HasIndex("ListId", "SortOrder") + .HasDatabaseName("idx_tasks_list_sort"); + + b.ToTable("tasks", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b => + { + b.Property("Id") + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("CacheReadTokens") + .HasColumnType("INTEGER") + .HasColumnName("cache_read_tokens"); + + b.Property("CacheWriteTokens") + .HasColumnType("INTEGER") + .HasColumnName("cache_write_tokens"); + + b.Property("ErrorMarkdown") + .HasColumnType("TEXT") + .HasColumnName("error_markdown"); + + b.Property("ExitCode") + .HasColumnType("INTEGER") + .HasColumnName("exit_code"); + + b.Property("FinishedAt") + .HasColumnType("TEXT") + .HasColumnName("finished_at"); + + b.Property("IsRetry") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false) + .HasColumnName("is_retry"); + + b.Property("LogPath") + .HasColumnType("TEXT") + .HasColumnName("log_path"); + + b.Property("Model") + .HasColumnType("TEXT") + .HasColumnName("model"); + + b.Property("Prompt") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("prompt"); + + b.Property("ResultMarkdown") + .HasColumnType("TEXT") + .HasColumnName("result_markdown"); + + b.Property("RunNumber") + .HasColumnType("INTEGER") + .HasColumnName("run_number"); + + b.Property("SessionId") + .HasColumnType("TEXT") + .HasColumnName("session_id"); + + b.Property("StartedAt") + .HasColumnType("TEXT") + .HasColumnName("started_at"); + + b.Property("StructuredOutputJson") + .HasColumnType("TEXT") + .HasColumnName("structured_output"); + + b.Property("TaskId") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("task_id"); + + b.Property("TokensIn") + .HasColumnType("INTEGER") + .HasColumnName("tokens_in"); + + b.Property("TokensOut") + .HasColumnType("INTEGER") + .HasColumnName("tokens_out"); + + b.Property("TurnCount") + .HasColumnType("INTEGER") + .HasColumnName("turn_count"); + + b.HasKey("Id"); + + b.HasIndex("TaskId") + .HasDatabaseName("idx_task_runs_task_id"); + + b.ToTable("task_runs", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.WeekReportEntity", b => + { + b.Property("Id") + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("EndDate") + .HasColumnType("TEXT") + .HasColumnName("end_date"); + + b.Property("GeneratedAt") + .HasColumnType("TEXT") + .HasColumnName("generated_at"); + + b.Property("Markdown") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("markdown"); + + b.Property("StartDate") + .HasColumnType("TEXT") + .HasColumnName("start_date"); + + b.HasKey("Id"); + + b.HasIndex("StartDate", "EndDate") + .IsUnique(); + + b.ToTable("week_reports", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b => + { + b.Property("TaskId") + .HasColumnType("TEXT") + .HasColumnName("task_id"); + + b.Property("BaseCommit") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("base_commit"); + + b.Property("BranchName") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("branch_name"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("DiffStat") + .HasColumnType("TEXT") + .HasColumnName("diff_stat"); + + b.Property("HeadCommit") + .HasColumnType("TEXT") + .HasColumnName("head_commit"); + + b.Property("MergeCommit") + .HasColumnType("TEXT") + .HasColumnName("merge_commit"); + + b.Property("Path") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("path"); + + b.Property("State") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("active") + .HasColumnName("state"); + + b.HasKey("TaskId"); + + b.ToTable("worktrees", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b => + { + b.HasOne("ClaudeDo.Data.Models.ListEntity", "List") + .WithOne("Config") + .HasForeignKey("ClaudeDo.Data.Models.ListConfigEntity", "ListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("List"); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b => + { + b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task") + .WithMany("Subtasks") + .HasForeignKey("TaskId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Task"); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.TaskAttachmentEntity", b => + { + b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task") + .WithMany() + .HasForeignKey("TaskId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Task"); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b => + { + b.HasOne("ClaudeDo.Data.Models.TaskEntity", null) + .WithMany() + .HasForeignKey("BlockedByTaskId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("ClaudeDo.Data.Models.ListEntity", "List") + .WithMany("Tasks") + .HasForeignKey("ListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Parent") + .WithMany("Children") + .HasForeignKey("ParentTaskId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("List"); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b => + { + b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task") + .WithMany("Runs") + .HasForeignKey("TaskId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Task"); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b => + { + b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task") + .WithOne("Worktree") + .HasForeignKey("ClaudeDo.Data.Models.WorktreeEntity", "TaskId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Task"); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b => + { + b.Navigation("Config"); + + b.Navigation("Tasks"); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b => + { + b.Navigation("Children"); + + b.Navigation("Runs"); + + b.Navigation("Subtasks"); + + b.Navigation("Worktree"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/ClaudeDo.Data/Migrations/20260806111454_AddInteractiveSessionId.cs b/src/ClaudeDo.Data/Migrations/20260806111454_AddInteractiveSessionId.cs new file mode 100644 index 00000000..ef0d71c0 --- /dev/null +++ b/src/ClaudeDo.Data/Migrations/20260806111454_AddInteractiveSessionId.cs @@ -0,0 +1,28 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace ClaudeDo.Data.Migrations +{ + /// + public partial class AddInteractiveSessionId : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "interactive_session_id", + table: "tasks", + type: "TEXT", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "interactive_session_id", + table: "tasks"); + } + } +} diff --git a/src/ClaudeDo.Data/Migrations/ClaudeDoDbContextModelSnapshot.cs b/src/ClaudeDo.Data/Migrations/ClaudeDoDbContextModelSnapshot.cs index c5ff204b..ef7c07ad 100644 --- a/src/ClaudeDo.Data/Migrations/ClaudeDoDbContextModelSnapshot.cs +++ b/src/ClaudeDo.Data/Migrations/ClaudeDoDbContextModelSnapshot.cs @@ -463,6 +463,10 @@ namespace ClaudeDo.Data.Migrations .HasColumnType("TEXT") .HasColumnName("handler_head_commit"); + b.Property("InteractiveSessionId") + .HasColumnType("TEXT") + .HasColumnName("interactive_session_id"); + b.Property("IsManual") .ValueGeneratedOnAdd() .HasColumnType("INTEGER") diff --git a/src/ClaudeDo.Data/Models/TaskEntity.cs b/src/ClaudeDo.Data/Models/TaskEntity.cs index 37f6d68f..97febdfe 100644 --- a/src/ClaudeDo.Data/Models/TaskEntity.cs +++ b/src/ClaudeDo.Data/Models/TaskEntity.cs @@ -60,6 +60,12 @@ public sealed class TaskEntity public string? HandlerBaseCommit { 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? PlanningSessionId { get; set; } public string? PlanningSessionToken { get; set; } diff --git a/src/ClaudeDo.Data/Repositories/TaskRepository.cs b/src/ClaudeDo.Data/Repositories/TaskRepository.cs index 12e20c5c..db5879c7 100644 --- a/src/ClaudeDo.Data/Repositories/TaskRepository.cs +++ b/src/ClaudeDo.Data/Repositories/TaskRepository.cs @@ -402,6 +402,19 @@ public sealed class TaskRepository .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 FindByPlanningTokenAsync( string token, CancellationToken ct = default) diff --git a/src/ClaudeDo.Worker/Runner/InteractiveLaunchSpecService.cs b/src/ClaudeDo.Worker/Runner/InteractiveLaunchSpecService.cs index 2696dcd8..86137de9 100644 --- a/src/ClaudeDo.Worker/Runner/InteractiveLaunchSpecService.cs +++ b/src/ClaudeDo.Worker/Runner/InteractiveLaunchSpecService.cs @@ -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. var effort = EffortFor(globalSettings, task.Model ?? listConfig?.Model); - // Resume an existing session as-is; 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. - var args = run?.SessionId is { Length: > 0 } sessionId - ? WithEffort(WindowsTerminalLauncher.BuildResumeArgs(sessionId), effort) - : await BuildFreshTaskArgsAsync(task, effort, ct); + // Resume this task's own last interactive conversation when it has one -- it takes + // precedence over the latest autonomous run's session, since an interactive session is a + // distinct conversation from an autonomous run even against the same worktree. Fall back + // to the autonomous run's session so opening a task interactively for the first time still + // picks up prior context. Neither survives a freshly (re)created worktree (isFreshWorktree + // 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 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 // 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 // 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. - private static async Task> 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> BuildFreshTaskArgsAsync( + TaskEntity task, string effort, string sessionId, CancellationToken ct) { var brief = BuildTaskBrief(task); 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); Directory.CreateDirectory(sessionDir); @@ -460,6 +482,7 @@ public sealed class InteractiveLaunchSpecService : IInteractiveLaunchSpecService { "--add-dir", sessionDir, "--effort", effort, + "--session-id", sessionId, $"Read the file {briefPath} first. It contains the task you must work on. " + "After reading it, begin the session as your instructions describe.", }; diff --git a/src/ClaudeDo.Worker/Runner/Interfaces/IInteractiveLaunchSpecService.cs b/src/ClaudeDo.Worker/Runner/Interfaces/IInteractiveLaunchSpecService.cs index f19cd2e1..6932f16e 100644 --- a/src/ClaudeDo.Worker/Runner/Interfaces/IInteractiveLaunchSpecService.cs +++ b/src/ClaudeDo.Worker/Runner/Interfaces/IInteractiveLaunchSpecService.cs @@ -17,9 +17,12 @@ public interface IInteractiveLaunchSpecService /// 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 /// 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 - /// that has never run, or whose worktree was just created fresh, gets a fresh-start spec - /// (no --resume); an existing worktree with a persisted SessionId gets --resume. + /// directory pointing at a git repo -- otherwise throws InvalidOperationException. Resumes + /// (--resume) this task's own last interactive session (TaskEntity.InteractiveSessionId) if + /// 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. Task BuildForTaskAsync(string taskId, CancellationToken ct); /// Builds a LaunchSpec for an ad-hoc interactive session in an arbitrary directory -- diff --git a/tests/ClaudeDo.Worker.Tests/Runner/InteractiveLaunchSpecServiceTests.cs b/tests/ClaudeDo.Worker.Tests/Runner/InteractiveLaunchSpecServiceTests.cs index 33e97d4e..41af9430 100644 --- a/tests/ClaudeDo.Worker.Tests/Runner/InteractiveLaunchSpecServiceTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Runner/InteractiveLaunchSpecServiceTests.cs @@ -194,7 +194,7 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable Assert.Equal(wtRow.Path, spec.Cwd); 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.Equal("T", File.ReadAllText(briefPath)); // brief file holds the task title } @@ -272,10 +272,14 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable var svc = BuildService(); 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.Equal("T", File.ReadAllText(briefPath)); 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] @@ -290,9 +294,73 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable var svc = BuildService(); 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.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] @@ -312,7 +380,7 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable var svc = BuildService(); var spec = await svc.BuildForTaskAsync(taskId, CancellationToken.None); - var (_, briefPath, kickoff) = ParseFreshTaskArgs(spec); + var (_, briefPath, kickoff, _) = ParseFreshTaskArgs(spec); Assert.Contains(briefPath, kickoff); Assert.Equal("T\n\nDo the thing", File.ReadAllText(briefPath)); } @@ -336,7 +404,7 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable var svc = BuildService(); var spec = await svc.BuildForTaskAsync(taskId, CancellationToken.None); - var (_, briefPath, kickoff) = ParseFreshTaskArgs(spec); + var (_, briefPath, kickoff, _) = ParseFreshTaskArgs(spec); Assert.Contains(briefPath, kickoff); Assert.DoesNotContain('\n', kickoff); @@ -364,7 +432,10 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable var svc = BuildService(); 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] @@ -427,22 +498,31 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable } /// A fresh (never-resumed) task with a title/description produces - /// `--add-dir --effort ` -- the brief travels via the file at - /// /brief.md, never as a raw CLI argument. Asserts that exact shape and returns - /// (sessionDir, briefPath, kickoff) for the test's own 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) + /// `--add-dir --effort --session-id ` -- the brief + /// travels via the file at /brief.md, never as a raw CLI argument. Asserts that + /// exact shape and returns (sessionDir, briefPath, kickoff, sessionId) for the test's own + /// checks. Cleanup happens via `_seededTaskIds` in Dispose (the session dir is keyed by task id). + private static (string SessionDir, string BriefPath, string Kickoff, string SessionId) ParseFreshTaskArgs(LaunchSpec spec) { var args = spec.Args.ToList(); - Assert.Equal(5, args.Count); + Assert.Equal(7, args.Count); Assert.Equal("--add-dir", args[0]); var sessionDir = args[1]; Assert.Equal("--effort", args[2]); 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"); - return (sessionDir, briefPath, kickoff); + return (sessionDir, briefPath, kickoff, sessionId); + } + + private async Task ReadInteractiveSessionIdAsync(string taskId) + { + using var ctx = _db.CreateContext(); + var task = await new TaskRepository(ctx).GetByIdAsync(taskId); + return task!.InteractiveSessionId; } private readonly List _mergeHelperSessionDirs = new();