Merge claudedo/1f4f59b14e4d481d97e843b6b2014af5

This commit is contained in:
mika kuns
2026-08-06 13:48:37 +02:00
11 changed files with 1084 additions and 28 deletions
+30 -2
View File
@@ -1,7 +1,7 @@
# ConPTY interactive sessions & launch specs # ConPTY interactive sessions & launch specs
> **Explore-note — verify before trusting.** Distilled map of a subsystem, not authoritative. > **Explore-note — verify before trusting.** Distilled map of a subsystem, not authoritative.
> Last verified against commit `aac84e4` (2026-08-06). > Last verified against commit `1a988ff` (2026-08-06).
> Drift check: `git log --oneline bdee731..HEAD -- src/ClaudeDo.Worker/Planning src/ClaudeDo.Worker/Hub src/ClaudeDo.Worker/Runner/ClaudeArgsBuilder.cs src/ClaudeDo.Ui/ViewModels/MissionControlViewModel.cs src/ClaudeDo.Ui/Views/InteractiveTerminalView.axaml` > Drift check: `git log --oneline bdee731..HEAD -- src/ClaudeDo.Worker/Planning src/ClaudeDo.Worker/Hub src/ClaudeDo.Worker/Runner/ClaudeArgsBuilder.cs src/ClaudeDo.Ui/ViewModels/MissionControlViewModel.cs src/ClaudeDo.Ui/Views/InteractiveTerminalView.axaml`
> Stable structure only (no line numbers). See docs/explore-notes/README.md. > Stable structure only (no line numbers). See docs/explore-notes/README.md.
@@ -35,7 +35,7 @@ Two independent reasons:
A fresh task session's brief lives at `~/.todo-app/task-sessions/<taskId>/brief.md` A fresh task session's brief lives at `~/.todo-app/task-sessions/<taskId>/brief.md`
(`InteractiveLaunchSpecService.BuildFreshTaskArgsAsync`). A task with neither title nor (`InteractiveLaunchSpecService.BuildFreshTaskArgsAsync`). A task with neither title nor
description skips the file **and** the positional arg entirely. description skips the file **and** the positional arg entirely — it still gets `--session-id`.
## Argument ordering ## Argument ordering
@@ -46,6 +46,34 @@ except for a fresh task session with a brief, where `--add-dir <sessionDir>` mus
`--model` is deliberately **NOT** forced on an interactive session — the user can still switch `--model` is deliberately **NOT** forced on an interactive session — the user can still switch
models in the TUI. models in the TUI.
## Resuming a task session (`TaskEntity.InteractiveSessionId`)
`claude --session-id <uuid>` lets the caller pre-assign a conversation's session id instead of
waiting for the CLI to generate one. `BuildForTaskAsync` uses this so a closed or aborted
interactive task session can be resumed even if it never got far enough to write anything to its
own transcript:
1. **Resume check.** If the task isn't on a freshly (re)created worktree, `BuildForTaskAsync`
picks a session to resume with `task.InteractiveSessionId ?? run?.SessionId` — this task's own
last *interactive* conversation takes precedence over the latest *autonomous* run's session,
since they're distinct conversations even against the same worktree. A task that has only ever
run autonomously still resumes into that run's session the first time it's opened interactively
(this is the pre-existing behavior `run?.SessionId` alone used to provide).
2. **Fresh path.** If neither is available (never run any way, or `isFreshWorktree`), a new
`Guid.NewGuid()` is generated and persisted to `TaskEntity.InteractiveSessionId` via
`TaskRepository.SetInteractiveSessionIdAsync`**before** the `LaunchSpec` is returned, i.e.
before the ConPTY host ever spawns `claude`. `BuildFreshTaskArgsAsync` then passes it as
`--session-id <guid>`, placed as the single-value flag directly before the positional kickoff
(or, with no brief, right after `--effort`).
3. **Fresh worktree wins.** `isFreshWorktree` forces `run` to `null` *and* is checked before
reading `task.InteractiveSessionId`, so a recreated worktree never resumes a stale id from
either source — it always takes the fresh path, which overwrites the stale
`InteractiveSessionId` with the new one.
Net effect: reopening an interactive session for a task (pane closed, process killed, whatever)
resumes the same claude conversation, because the id was committed to the DB before the previous
launch even started.
## List handler ("Let Claude handle it") ## List handler ("Let Claude handle it")
`BuildForMergeHelperAsync` uses `--permission-mode auto` so it runs unattended. The `BuildForMergeHelperAsync` uses `--permission-mode auto` so it runs unattended. The
+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();