# Worker CLI Modernization Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Modernize the Worker's Claude CLI integration with per-list/task configuration, execution history tracking, multi-turn support, auto-retry, structured output, and agent file management. **Architecture:** Add `list_config` and `task_runs` tables to the existing SQLite schema. Extract CLI argument building and stream parsing into dedicated classes. TaskRunner gains retry/continue logic. Agent `.md` files live on the filesystem at `~/.todo-app/agents/`, discoverable via SignalR. **Tech Stack:** .NET 8.0, SQLite (raw ADO.NET), SignalR, xUnit --- ### Task 1: Schema — Add list_config and task_runs tables **Files:** - Modify: `schema/schema.sql:49-62` - Modify: `src/ClaudeDo.Data/SchemaInitializer.cs` This task adds the new tables and columns. Since SQLite doesn't support `ALTER TABLE ADD COLUMN IF NOT EXISTS`, the new columns on `tasks` need a try/catch approach in SchemaInitializer. - [ ] **Step 1: Add list_config table to schema.sql** Append before the worktrees table: ```sql CREATE TABLE IF NOT EXISTS list_config ( list_id TEXT PRIMARY KEY REFERENCES lists(id) ON DELETE CASCADE, model TEXT NULL, system_prompt TEXT NULL, agent_path TEXT NULL ); ``` - [ ] **Step 2: Add task_runs table to schema.sql** Append after worktrees table: ```sql CREATE TABLE IF NOT EXISTS task_runs ( id TEXT PRIMARY KEY, task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE, run_number INTEGER NOT NULL, session_id TEXT NULL, is_retry INTEGER NOT NULL DEFAULT 0, prompt TEXT NOT NULL, result_markdown TEXT NULL, structured_output TEXT NULL, error_markdown TEXT NULL, exit_code INTEGER NULL, turn_count INTEGER NULL, tokens_in INTEGER NULL, tokens_out INTEGER NULL, log_path TEXT NULL, started_at TIMESTAMP NULL, finished_at TIMESTAMP NULL ); CREATE INDEX IF NOT EXISTS idx_task_runs_task_id ON task_runs(task_id); ``` - [ ] **Step 3: Add migration columns to tasks via SchemaInitializer** In `SchemaInitializer.cs`, after applying the main schema, add a new `ApplyMigrations` method that runs `ALTER TABLE` in try/catch blocks (SQLite throws "duplicate column name" if the column already exists): ```csharp private static void ApplyMigrations(SqliteConnection conn) { string[] alterStatements = [ "ALTER TABLE tasks ADD COLUMN model TEXT NULL", "ALTER TABLE tasks ADD COLUMN system_prompt TEXT NULL", "ALTER TABLE tasks ADD COLUMN agent_path TEXT NULL", ]; foreach (var sql in alterStatements) { try { using var cmd = conn.CreateCommand(); cmd.CommandText = sql; cmd.ExecuteNonQuery(); } catch (Microsoft.Data.Sqlite.SqliteException ex) when (ex.SqliteErrorCode == 1) { // Column already exists — safe to ignore. } } } ``` Call `ApplyMigrations(conn)` right after the main schema `ExecuteNonQuery` in the `ApplyTo` method. - [ ] **Step 4: Verify schema applies cleanly** Run: ```bash dotnet build src/ClaudeDo.Data/ClaudeDo.Data.csproj dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~Repository" -v minimal ``` Expected: all existing repository tests pass (schema changes are additive). - [ ] **Step 5: Commit** ```bash git add schema/schema.sql src/ClaudeDo.Data/SchemaInitializer.cs git commit -m "feat(data): add list_config, task_runs tables and task config columns" ``` --- ### Task 2: Models — ListConfigEntity, TaskRunEntity, AgentInfo **Files:** - Create: `src/ClaudeDo.Data/Models/ListConfigEntity.cs` - Create: `src/ClaudeDo.Data/Models/TaskRunEntity.cs` - Create: `src/ClaudeDo.Data/Models/AgentInfo.cs` - Modify: `src/ClaudeDo.Data/Models/TaskEntity.cs:13-26` - [ ] **Step 1: Create ListConfigEntity** ```csharp namespace ClaudeDo.Data.Models; public sealed class ListConfigEntity { public required string ListId { get; init; } public string? Model { get; set; } public string? SystemPrompt { get; set; } public string? AgentPath { get; set; } } ``` - [ ] **Step 2: Create TaskRunEntity** ```csharp namespace ClaudeDo.Data.Models; public sealed class TaskRunEntity { public required string Id { get; init; } public required string TaskId { get; init; } public required int RunNumber { get; init; } public string? SessionId { get; set; } public required bool IsRetry { get; init; } public required string Prompt { get; init; } public string? ResultMarkdown { get; set; } public string? StructuredOutputJson { get; set; } public string? ErrorMarkdown { get; set; } public int? ExitCode { get; set; } public int? TurnCount { get; set; } public int? TokensIn { get; set; } public int? TokensOut { get; set; } public string? LogPath { get; set; } public DateTime? StartedAt { get; set; } public DateTime? FinishedAt { get; set; } } ``` - [ ] **Step 3: Create AgentInfo** ```csharp namespace ClaudeDo.Data.Models; public sealed record AgentInfo(string Name, string Description, string Path); ``` - [ ] **Step 4: Add config fields to TaskEntity** Add after the `CommitType` property (line 25): ```csharp public string? Model { get; set; } public string? SystemPrompt { get; set; } public string? AgentPath { get; set; } ``` - [ ] **Step 5: Build and verify** Run: ```bash dotnet build src/ClaudeDo.Data/ClaudeDo.Data.csproj ``` Expected: clean build, no errors. - [ ] **Step 6: Commit** ```bash git add src/ClaudeDo.Data/Models/ListConfigEntity.cs src/ClaudeDo.Data/Models/TaskRunEntity.cs src/ClaudeDo.Data/Models/AgentInfo.cs src/ClaudeDo.Data/Models/TaskEntity.cs git commit -m "feat(data): add ListConfigEntity, TaskRunEntity, AgentInfo models and task config fields" ``` --- ### Task 3: TaskRunRepository — CRUD for execution history **Files:** - Create: `src/ClaudeDo.Data/Repositories/TaskRunRepository.cs` - Create: `tests/ClaudeDo.Worker.Tests/Repositories/TaskRunRepositoryTests.cs` - [ ] **Step 1: Write failing tests** ```csharp using ClaudeDo.Data.Models; using ClaudeDo.Data.Repositories; using ClaudeDo.Worker.Tests.Infrastructure; namespace ClaudeDo.Worker.Tests.Repositories; public sealed class TaskRunRepositoryTests : IDisposable { private readonly DbFixture _db = new(); private readonly TaskRunRepository _repo; private readonly TaskRepository _taskRepo; private readonly ListRepository _listRepo; private readonly string _listId; private readonly string _taskId; public TaskRunRepositoryTests() { _repo = new TaskRunRepository(_db.Factory); _taskRepo = new TaskRepository(_db.Factory); _listRepo = new ListRepository(_db.Factory); _listId = Guid.NewGuid().ToString(); _listRepo.AddAsync(new ListEntity { Id = _listId, Name = "Test List", CreatedAt = DateTime.UtcNow }).GetAwaiter().GetResult(); _taskId = Guid.NewGuid().ToString(); _taskRepo.AddAsync(new TaskEntity { Id = _taskId, ListId = _listId, Title = "Test Task", Status = TaskStatus.Queued, CreatedAt = DateTime.UtcNow, }).GetAwaiter().GetResult(); } [Fact] public async Task Add_And_GetById_Roundtrips() { var run = MakeRun(1); await _repo.AddAsync(run); var fetched = await _repo.GetByIdAsync(run.Id); Assert.NotNull(fetched); Assert.Equal(run.Id, fetched.Id); Assert.Equal(run.TaskId, fetched.TaskId); Assert.Equal(1, fetched.RunNumber); Assert.False(fetched.IsRetry); Assert.Equal("do the thing", fetched.Prompt); } [Fact] public async Task GetByTaskId_Returns_Ordered_By_RunNumber() { await _repo.AddAsync(MakeRun(1)); await _repo.AddAsync(MakeRun(2, isRetry: true)); await _repo.AddAsync(MakeRun(3)); var runs = await _repo.GetByTaskIdAsync(_taskId); Assert.Equal(3, runs.Count); Assert.Equal(1, runs[0].RunNumber); Assert.Equal(2, runs[1].RunNumber); Assert.Equal(3, runs[2].RunNumber); Assert.True(runs[1].IsRetry); } [Fact] public async Task GetLatestByTaskId_Returns_Highest_RunNumber() { await _repo.AddAsync(MakeRun(1)); var run2 = MakeRun(2); run2.SessionId = "sess-abc"; await _repo.AddAsync(run2); var latest = await _repo.GetLatestByTaskIdAsync(_taskId); Assert.NotNull(latest); Assert.Equal(2, latest.RunNumber); Assert.Equal("sess-abc", latest.SessionId); } [Fact] public async Task Update_Persists_Completion_Fields() { var run = MakeRun(1); await _repo.AddAsync(run); run.SessionId = "sess-123"; run.ResultMarkdown = "## Done"; run.StructuredOutputJson = """{"summary":"all good"}"""; run.ExitCode = 0; run.TurnCount = 5; run.TokensIn = 1200; run.TokensOut = 800; run.FinishedAt = DateTime.UtcNow; await _repo.UpdateAsync(run); var fetched = await _repo.GetByIdAsync(run.Id); Assert.NotNull(fetched); Assert.Equal("sess-123", fetched.SessionId); Assert.Equal("## Done", fetched.ResultMarkdown); Assert.Equal("""{"summary":"all good"}""", fetched.StructuredOutputJson); Assert.Equal(0, fetched.ExitCode); Assert.Equal(5, fetched.TurnCount); Assert.Equal(1200, fetched.TokensIn); Assert.Equal(800, fetched.TokensOut); } [Fact] public async Task GetLatestByTaskId_Returns_Null_When_No_Runs() { var latest = await _repo.GetLatestByTaskIdAsync(_taskId); Assert.Null(latest); } private TaskRunEntity MakeRun(int runNumber, bool isRetry = false) => new() { Id = Guid.NewGuid().ToString(), TaskId = _taskId, RunNumber = runNumber, IsRetry = isRetry, Prompt = "do the thing", StartedAt = DateTime.UtcNow, }; public void Dispose() => _db.Dispose(); } ``` - [ ] **Step 2: Run tests to verify they fail** Run: ```bash dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~TaskRunRepository" -v minimal ``` Expected: build error — `TaskRunRepository` does not exist. - [ ] **Step 3: Implement TaskRunRepository** ```csharp using ClaudeDo.Data.Models; using Microsoft.Data.Sqlite; namespace ClaudeDo.Data.Repositories; public sealed class TaskRunRepository { private readonly SqliteConnectionFactory _factory; public TaskRunRepository(SqliteConnectionFactory factory) => _factory = factory; public async Task AddAsync(TaskRunEntity entity, CancellationToken ct = default) { await using var conn = _factory.Open(); await using var cmd = conn.CreateCommand(); cmd.CommandText = """ INSERT INTO task_runs (id, task_id, run_number, session_id, is_retry, prompt, result_markdown, structured_output, error_markdown, exit_code, turn_count, tokens_in, tokens_out, log_path, started_at, finished_at) VALUES (@id, @task_id, @run_number, @session_id, @is_retry, @prompt, @result_markdown, @structured_output, @error_markdown, @exit_code, @turn_count, @tokens_in, @tokens_out, @log_path, @started_at, @finished_at) """; BindRun(cmd, entity); await cmd.ExecuteNonQueryAsync(ct); } public async Task UpdateAsync(TaskRunEntity entity, CancellationToken ct = default) { await using var conn = _factory.Open(); await using var cmd = conn.CreateCommand(); cmd.CommandText = """ UPDATE task_runs SET session_id = @session_id, result_markdown = @result_markdown, structured_output = @structured_output, error_markdown = @error_markdown, exit_code = @exit_code, turn_count = @turn_count, tokens_in = @tokens_in, tokens_out = @tokens_out, log_path = @log_path, finished_at = @finished_at WHERE id = @id """; cmd.Parameters.AddWithValue("@id", entity.Id); cmd.Parameters.AddWithValue("@session_id", (object?)entity.SessionId ?? DBNull.Value); cmd.Parameters.AddWithValue("@result_markdown", (object?)entity.ResultMarkdown ?? DBNull.Value); cmd.Parameters.AddWithValue("@structured_output", (object?)entity.StructuredOutputJson ?? DBNull.Value); cmd.Parameters.AddWithValue("@error_markdown", (object?)entity.ErrorMarkdown ?? DBNull.Value); cmd.Parameters.AddWithValue("@exit_code", entity.ExitCode.HasValue ? entity.ExitCode.Value : DBNull.Value); cmd.Parameters.AddWithValue("@turn_count", entity.TurnCount.HasValue ? entity.TurnCount.Value : DBNull.Value); cmd.Parameters.AddWithValue("@tokens_in", entity.TokensIn.HasValue ? entity.TokensIn.Value : DBNull.Value); cmd.Parameters.AddWithValue("@tokens_out", entity.TokensOut.HasValue ? entity.TokensOut.Value : DBNull.Value); cmd.Parameters.AddWithValue("@log_path", (object?)entity.LogPath ?? DBNull.Value); cmd.Parameters.AddWithValue("@finished_at", entity.FinishedAt.HasValue ? entity.FinishedAt.Value.ToString("o") : DBNull.Value); await cmd.ExecuteNonQueryAsync(ct); } public async Task GetByIdAsync(string runId, CancellationToken ct = default) { await using var conn = _factory.Open(); await using var cmd = conn.CreateCommand(); cmd.CommandText = "SELECT id, task_id, run_number, session_id, is_retry, prompt, result_markdown, structured_output, error_markdown, exit_code, turn_count, tokens_in, tokens_out, log_path, started_at, finished_at FROM task_runs WHERE id = @id"; cmd.Parameters.AddWithValue("@id", runId); await using var reader = await cmd.ExecuteReaderAsync(ct); if (!await reader.ReadAsync(ct)) return null; return ReadRun(reader); } public async Task> GetByTaskIdAsync(string taskId, CancellationToken ct = default) { await using var conn = _factory.Open(); await using var cmd = conn.CreateCommand(); cmd.CommandText = "SELECT id, task_id, run_number, session_id, is_retry, prompt, result_markdown, structured_output, error_markdown, exit_code, turn_count, tokens_in, tokens_out, log_path, started_at, finished_at FROM task_runs WHERE task_id = @task_id ORDER BY run_number"; cmd.Parameters.AddWithValue("@task_id", taskId); await using var reader = await cmd.ExecuteReaderAsync(ct); var result = new List(); while (await reader.ReadAsync(ct)) result.Add(ReadRun(reader)); return result; } public async Task GetLatestByTaskIdAsync(string taskId, CancellationToken ct = default) { await using var conn = _factory.Open(); await using var cmd = conn.CreateCommand(); cmd.CommandText = "SELECT id, task_id, run_number, session_id, is_retry, prompt, result_markdown, structured_output, error_markdown, exit_code, turn_count, tokens_in, tokens_out, log_path, started_at, finished_at FROM task_runs WHERE task_id = @task_id ORDER BY run_number DESC LIMIT 1"; cmd.Parameters.AddWithValue("@task_id", taskId); await using var reader = await cmd.ExecuteReaderAsync(ct); if (!await reader.ReadAsync(ct)) return null; return ReadRun(reader); } private static void BindRun(SqliteCommand cmd, TaskRunEntity e) { cmd.Parameters.AddWithValue("@id", e.Id); cmd.Parameters.AddWithValue("@task_id", e.TaskId); cmd.Parameters.AddWithValue("@run_number", e.RunNumber); cmd.Parameters.AddWithValue("@session_id", (object?)e.SessionId ?? DBNull.Value); cmd.Parameters.AddWithValue("@is_retry", e.IsRetry ? 1 : 0); cmd.Parameters.AddWithValue("@prompt", e.Prompt); cmd.Parameters.AddWithValue("@result_markdown", (object?)e.ResultMarkdown ?? DBNull.Value); cmd.Parameters.AddWithValue("@structured_output", (object?)e.StructuredOutputJson ?? DBNull.Value); cmd.Parameters.AddWithValue("@error_markdown", (object?)e.ErrorMarkdown ?? DBNull.Value); cmd.Parameters.AddWithValue("@exit_code", e.ExitCode.HasValue ? e.ExitCode.Value : DBNull.Value); cmd.Parameters.AddWithValue("@turn_count", e.TurnCount.HasValue ? e.TurnCount.Value : DBNull.Value); cmd.Parameters.AddWithValue("@tokens_in", e.TokensIn.HasValue ? e.TokensIn.Value : DBNull.Value); cmd.Parameters.AddWithValue("@tokens_out", e.TokensOut.HasValue ? e.TokensOut.Value : DBNull.Value); cmd.Parameters.AddWithValue("@log_path", (object?)e.LogPath ?? DBNull.Value); cmd.Parameters.AddWithValue("@started_at", e.StartedAt.HasValue ? e.StartedAt.Value.ToString("o") : DBNull.Value); cmd.Parameters.AddWithValue("@finished_at", e.FinishedAt.HasValue ? e.FinishedAt.Value.ToString("o") : DBNull.Value); } private static TaskRunEntity ReadRun(SqliteDataReader r) => new() { Id = r.GetString(0), TaskId = r.GetString(1), RunNumber = r.GetInt32(2), SessionId = r.IsDBNull(3) ? null : r.GetString(3), IsRetry = r.GetInt32(4) != 0, Prompt = r.GetString(5), ResultMarkdown = r.IsDBNull(6) ? null : r.GetString(6), StructuredOutputJson = r.IsDBNull(7) ? null : r.GetString(7), ErrorMarkdown = r.IsDBNull(8) ? null : r.GetString(8), ExitCode = r.IsDBNull(9) ? null : r.GetInt32(9), TurnCount = r.IsDBNull(10) ? null : r.GetInt32(10), TokensIn = r.IsDBNull(11) ? null : r.GetInt32(11), TokensOut = r.IsDBNull(12) ? null : r.GetInt32(12), LogPath = r.IsDBNull(13) ? null : r.GetString(13), StartedAt = r.IsDBNull(14) ? null : DateTime.Parse(r.GetString(14)), FinishedAt = r.IsDBNull(15) ? null : DateTime.Parse(r.GetString(15)), }; } ``` - [ ] **Step 4: Run tests to verify they pass** Run: ```bash dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~TaskRunRepository" -v minimal ``` Expected: 5 tests pass. - [ ] **Step 5: Commit** ```bash git add src/ClaudeDo.Data/Repositories/TaskRunRepository.cs tests/ClaudeDo.Worker.Tests/Repositories/TaskRunRepositoryTests.cs git commit -m "feat(data): add TaskRunRepository with CRUD and query methods" ``` --- ### Task 4: ListRepository — Config methods **Files:** - Modify: `src/ClaudeDo.Data/Repositories/ListRepository.cs:116-124` - Create: `tests/ClaudeDo.Worker.Tests/Repositories/ListRepositoryConfigTests.cs` - [ ] **Step 1: Write failing tests** ```csharp using ClaudeDo.Data.Models; using ClaudeDo.Data.Repositories; using ClaudeDo.Worker.Tests.Infrastructure; namespace ClaudeDo.Worker.Tests.Repositories; public sealed class ListRepositoryConfigTests : IDisposable { private readonly DbFixture _db = new(); private readonly ListRepository _repo; private readonly string _listId; public ListRepositoryConfigTests() { _repo = new ListRepository(_db.Factory); _listId = Guid.NewGuid().ToString(); _repo.AddAsync(new ListEntity { Id = _listId, Name = "Test", CreatedAt = DateTime.UtcNow }).GetAwaiter().GetResult(); } [Fact] public async Task GetConfig_Returns_Null_When_No_Config() { var config = await _repo.GetConfigAsync(_listId); Assert.Null(config); } [Fact] public async Task SetConfig_And_GetConfig_Roundtrips() { var config = new ListConfigEntity { ListId = _listId, Model = "sonnet-4-6", SystemPrompt = "You are helpful.", AgentPath = "/home/user/.todo-app/agents/dev.md", }; await _repo.SetConfigAsync(config); var fetched = await _repo.GetConfigAsync(_listId); Assert.NotNull(fetched); Assert.Equal("sonnet-4-6", fetched.Model); Assert.Equal("You are helpful.", fetched.SystemPrompt); Assert.Equal("/home/user/.todo-app/agents/dev.md", fetched.AgentPath); } [Fact] public async Task SetConfig_Upserts_On_Duplicate() { await _repo.SetConfigAsync(new ListConfigEntity { ListId = _listId, Model = "opus-4-6" }); await _repo.SetConfigAsync(new ListConfigEntity { ListId = _listId, Model = "haiku-4-5" }); var fetched = await _repo.GetConfigAsync(_listId); Assert.NotNull(fetched); Assert.Equal("haiku-4-5", fetched.Model); } public void Dispose() => _db.Dispose(); } ``` - [ ] **Step 2: Run tests to verify they fail** Run: ```bash dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~ListRepositoryConfig" -v minimal ``` Expected: build error — methods don't exist on `ListRepository`. - [ ] **Step 3: Add GetConfigAsync and SetConfigAsync to ListRepository** Add after the `RemoveTagAsync` method (before the `ReadList` helper): ```csharp public async Task GetConfigAsync(string listId, CancellationToken ct = default) { await using var conn = _factory.Open(); await using var cmd = conn.CreateCommand(); cmd.CommandText = "SELECT list_id, model, system_prompt, agent_path FROM list_config WHERE list_id = @list_id"; cmd.Parameters.AddWithValue("@list_id", listId); await using var reader = await cmd.ExecuteReaderAsync(ct); if (!await reader.ReadAsync(ct)) return null; return new ListConfigEntity { ListId = reader.GetString(0), Model = reader.IsDBNull(1) ? null : reader.GetString(1), SystemPrompt = reader.IsDBNull(2) ? null : reader.GetString(2), AgentPath = reader.IsDBNull(3) ? null : reader.GetString(3), }; } public async Task SetConfigAsync(ListConfigEntity entity, CancellationToken ct = default) { await using var conn = _factory.Open(); await using var cmd = conn.CreateCommand(); cmd.CommandText = """ INSERT OR REPLACE INTO list_config (list_id, model, system_prompt, agent_path) VALUES (@list_id, @model, @system_prompt, @agent_path) """; cmd.Parameters.AddWithValue("@list_id", entity.ListId); cmd.Parameters.AddWithValue("@model", (object?)entity.Model ?? DBNull.Value); cmd.Parameters.AddWithValue("@system_prompt", (object?)entity.SystemPrompt ?? DBNull.Value); cmd.Parameters.AddWithValue("@agent_path", (object?)entity.AgentPath ?? DBNull.Value); await cmd.ExecuteNonQueryAsync(ct); } ``` - [ ] **Step 4: Run tests to verify they pass** Run: ```bash dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~ListRepositoryConfig" -v minimal ``` Expected: 3 tests pass. - [ ] **Step 5: Commit** ```bash git add src/ClaudeDo.Data/Repositories/ListRepository.cs tests/ClaudeDo.Worker.Tests/Repositories/ListRepositoryConfigTests.cs git commit -m "feat(data): add GetConfigAsync and SetConfigAsync to ListRepository" ``` --- ### Task 5: TaskRepository — Read/write config columns **Files:** - Modify: `src/ClaudeDo.Data/Repositories/TaskRepository.cs:39-51,53-66,77-87,89-101,266-280,282-296` The `AddAsync`, `UpdateAsync`, `GetByIdAsync`, `GetByListAsync`, `BindTask`, and `ReadTask` methods must include the three new columns (`model`, `system_prompt`, `agent_path`). - [ ] **Step 1: Update BindTask to include new columns** Replace the `BindTask` method: ```csharp private static void BindTask(SqliteCommand cmd, TaskEntity e) { cmd.Parameters.AddWithValue("@id", e.Id); cmd.Parameters.AddWithValue("@list_id", e.ListId); cmd.Parameters.AddWithValue("@title", e.Title); cmd.Parameters.AddWithValue("@description", (object?)e.Description ?? DBNull.Value); cmd.Parameters.AddWithValue("@status", ToDb(e.Status)); cmd.Parameters.AddWithValue("@scheduled_for", e.ScheduledFor.HasValue ? e.ScheduledFor.Value.ToString("o") : DBNull.Value); cmd.Parameters.AddWithValue("@result", (object?)e.Result ?? DBNull.Value); cmd.Parameters.AddWithValue("@log_path", (object?)e.LogPath ?? DBNull.Value); cmd.Parameters.AddWithValue("@created_at", e.CreatedAt.ToString("o")); cmd.Parameters.AddWithValue("@started_at", e.StartedAt.HasValue ? e.StartedAt.Value.ToString("o") : DBNull.Value); cmd.Parameters.AddWithValue("@finished_at", e.FinishedAt.HasValue ? e.FinishedAt.Value.ToString("o") : DBNull.Value); cmd.Parameters.AddWithValue("@commit_type", e.CommitType); cmd.Parameters.AddWithValue("@model", (object?)e.Model ?? DBNull.Value); cmd.Parameters.AddWithValue("@system_prompt", (object?)e.SystemPrompt ?? DBNull.Value); cmd.Parameters.AddWithValue("@agent_path", (object?)e.AgentPath ?? DBNull.Value); } ``` - [ ] **Step 2: Update AddAsync SQL to include new columns** ```csharp cmd.CommandText = """ INSERT INTO tasks (id, list_id, title, description, status, scheduled_for, result, log_path, created_at, started_at, finished_at, commit_type, model, system_prompt, agent_path) VALUES (@id, @list_id, @title, @description, @status, @scheduled_for, @result, @log_path, @created_at, @started_at, @finished_at, @commit_type, @model, @system_prompt, @agent_path) """; ``` - [ ] **Step 3: Update UpdateAsync SQL to include new columns** ```csharp cmd.CommandText = """ UPDATE tasks SET list_id = @list_id, title = @title, description = @description, status = @status, scheduled_for = @scheduled_for, result = @result, log_path = @log_path, started_at = @started_at, finished_at = @finished_at, commit_type = @commit_type, model = @model, system_prompt = @system_prompt, agent_path = @agent_path WHERE id = @id """; ``` - [ ] **Step 4: Update all SELECT statements and ReadTask** Every `SELECT` in `GetByIdAsync`, `GetByListAsync`, and `GetNextQueuedAgentTaskAsync` must add the three new columns. Update the column list to: ``` SELECT id, list_id, title, description, status, scheduled_for, result, log_path, created_at, started_at, finished_at, commit_type, model, system_prompt, agent_path FROM tasks ... ``` Update `ReadTask`: ```csharp private static TaskEntity ReadTask(SqliteDataReader r) => new() { Id = r.GetString(0), ListId = r.GetString(1), Title = r.GetString(2), Description = r.IsDBNull(3) ? null : r.GetString(3), Status = FromDb(r.GetString(4)), ScheduledFor = r.IsDBNull(5) ? null : DateTime.Parse(r.GetString(5)), Result = r.IsDBNull(6) ? null : r.GetString(6), LogPath = r.IsDBNull(7) ? null : r.GetString(7), CreatedAt = DateTime.Parse(r.GetString(8)), StartedAt = r.IsDBNull(9) ? null : DateTime.Parse(r.GetString(9)), FinishedAt = r.IsDBNull(10) ? null : DateTime.Parse(r.GetString(10)), CommitType = r.GetString(11), Model = r.IsDBNull(12) ? null : r.GetString(12), SystemPrompt = r.IsDBNull(13) ? null : r.GetString(13), AgentPath = r.IsDBNull(14) ? null : r.GetString(14), }; ``` - [ ] **Step 5: Run all existing TaskRepository tests** Run: ```bash dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~TaskRepositoryTests" -v minimal ``` Expected: all existing tests still pass (new columns are nullable, backward compatible). - [ ] **Step 6: Commit** ```bash git add src/ClaudeDo.Data/Repositories/TaskRepository.cs git commit -m "feat(data): extend TaskRepository with model, system_prompt, agent_path columns" ``` --- ### Task 6: ClaudeArgsBuilder — CLI argument construction **Files:** - Create: `src/ClaudeDo.Worker/Runner/ClaudeArgsBuilder.cs` - Create: `tests/ClaudeDo.Worker.Tests/Runner/ClaudeArgsBuilderTests.cs` - [ ] **Step 1: Write failing tests** ```csharp using ClaudeDo.Worker.Runner; namespace ClaudeDo.Worker.Tests.Runner; public sealed class ClaudeArgsBuilderTests { private readonly ClaudeArgsBuilder _builder = new(); [Fact] public void Default_Config_Produces_Base_Args() { var args = _builder.Build(new ClaudeRunConfig(null, null, null, null)); Assert.Contains("-p", args); Assert.Contains("--output-format stream-json", args); Assert.Contains("--verbose", args); Assert.Contains("--dangerously-skip-permissions", args); Assert.Contains("--json-schema", args); Assert.DoesNotContain("--model", args); Assert.DoesNotContain("--append-system-prompt", args); Assert.DoesNotContain("--agents", args); Assert.DoesNotContain("--resume", args); } [Fact] public void Model_Adds_Model_Flag() { var args = _builder.Build(new ClaudeRunConfig("sonnet-4-6", null, null, null)); Assert.Contains("--model sonnet-4-6", args); } [Fact] public void SystemPrompt_Adds_Append_System_Prompt_Flag() { var args = _builder.Build(new ClaudeRunConfig(null, "Be concise.", null, null)); Assert.Contains("--append-system-prompt", args); Assert.Contains("Be concise.", args); } [Fact] public void AgentPath_Adds_Agents_Flag_As_Json() { var args = _builder.Build(new ClaudeRunConfig(null, null, "/path/to/agent.md", null)); Assert.Contains("--agents", args); Assert.Contains("/path/to/agent.md", args); } [Fact] public void ResumeSessionId_Adds_Resume_Flag() { var args = _builder.Build(new ClaudeRunConfig(null, null, null, "sess-abc-123")); Assert.Contains("--resume sess-abc-123", args); } [Fact] public void All_Options_Set_Includes_All_Flags() { var args = _builder.Build(new ClaudeRunConfig("opus-4-6", "Be thorough.", "/agents/dev.md", "sess-xyz")); Assert.Contains("--model opus-4-6", args); Assert.Contains("--append-system-prompt", args); Assert.Contains("--agents", args); Assert.Contains("--resume sess-xyz", args); Assert.Contains("--json-schema", args); } [Fact] public void SystemPrompt_With_Quotes_Is_Escaped() { var args = _builder.Build(new ClaudeRunConfig(null, """Don't say "hello".""", null, null)); // Should not break argument parsing — the prompt is passed as a single argument Assert.Contains("--append-system-prompt", args); } } ``` - [ ] **Step 2: Run tests to verify they fail** Run: ```bash dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~ClaudeArgsBuilder" -v minimal ``` Expected: build error — `ClaudeArgsBuilder` and `ClaudeRunConfig` don't exist. - [ ] **Step 3: Implement ClaudeArgsBuilder** ```csharp using System.Text; using System.Text.Json; namespace ClaudeDo.Worker.Runner; public sealed record ClaudeRunConfig( string? Model, string? SystemPrompt, string? AgentPath, string? ResumeSessionId ); public sealed class ClaudeArgsBuilder { private static readonly string ResultSchema = JsonSerializer.Serialize(new { type = "object", properties = new { summary = new { type = "string" }, files_changed = new { type = "array", items = new { type = "string" } }, commit_type = new { type = "string" }, }, required = new[] { "summary" }, }); public string Build(ClaudeRunConfig config) { var args = new List { "-p", "--output-format stream-json", "--verbose", "--dangerously-skip-permissions", }; if (config.Model is not null) args.Add($"--model {config.Model}"); if (config.SystemPrompt is not null) args.Add($"--append-system-prompt {Escape(config.SystemPrompt)}"); if (config.AgentPath is not null) { var agentJson = JsonSerializer.Serialize(new[] { new { file = config.AgentPath } }); args.Add($"--agents {Escape(agentJson)}"); } args.Add($"--json-schema {Escape(ResultSchema)}"); if (config.ResumeSessionId is not null) args.Add($"--resume {config.ResumeSessionId}"); return string.Join(" ", args); } private static string Escape(string value) { // Wrap in double quotes if the value contains spaces or special characters. if (value.Contains(' ') || value.Contains('"') || value.Contains('\'')) { var escaped = value.Replace("\\", "\\\\").Replace("\"", "\\\""); return $"\"{escaped}\""; } return value; } } ``` - [ ] **Step 4: Run tests to verify they pass** Run: ```bash dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~ClaudeArgsBuilder" -v minimal ``` Expected: 7 tests pass. - [ ] **Step 5: Commit** ```bash git add src/ClaudeDo.Worker/Runner/ClaudeArgsBuilder.cs tests/ClaudeDo.Worker.Tests/Runner/ClaudeArgsBuilderTests.cs git commit -m "feat(worker): add ClaudeArgsBuilder for dynamic CLI argument construction" ``` --- ### Task 7: StreamAnalyzer — Rich NDJSON stream parsing **Files:** - Create: `src/ClaudeDo.Worker/Runner/StreamAnalyzer.cs` - Create: `src/ClaudeDo.Worker/Runner/StreamResult.cs` - Create: `tests/ClaudeDo.Worker.Tests/Runner/StreamAnalyzerTests.cs` - [ ] **Step 1: Write failing tests** ```csharp using ClaudeDo.Worker.Runner; namespace ClaudeDo.Worker.Tests.Runner; public sealed class StreamAnalyzerTests { [Fact] public void Extracts_Result_Markdown() { var analyzer = new StreamAnalyzer(); analyzer.ProcessLine("""{"type":"result","result":"## Done","session_id":"sess-1"}"""); var result = analyzer.GetResult(); Assert.Equal("## Done", result.ResultMarkdown); Assert.Equal("sess-1", result.SessionId); } [Fact] public void Extracts_Structured_Output() { var analyzer = new StreamAnalyzer(); analyzer.ProcessLine("""{"type":"result","result":"ok","structured_output":{"summary":"all good"},"session_id":"s1"}"""); var result = analyzer.GetResult(); Assert.Equal("ok", result.ResultMarkdown); Assert.Contains("all good", result.StructuredOutputJson); } [Fact] public void Counts_Assistant_Turns() { var analyzer = new StreamAnalyzer(); analyzer.ProcessLine("""{"type":"assistant","message":"hi"}"""); analyzer.ProcessLine("""{"type":"assistant","message":"working on it"}"""); analyzer.ProcessLine("""{"type":"result","result":"done","session_id":"s1"}"""); var result = analyzer.GetResult(); Assert.Equal(2, result.TurnCount); } [Fact] public void Accumulates_Token_Usage() { var analyzer = new StreamAnalyzer(); analyzer.ProcessLine("""{"type":"stream_event","event":{"type":"message_start","message":{"usage":{"input_tokens":100,"output_tokens":50}}}}"""); analyzer.ProcessLine("""{"type":"stream_event","event":{"type":"message_start","message":{"usage":{"input_tokens":200,"output_tokens":80}}}}"""); analyzer.ProcessLine("""{"type":"result","result":"done","session_id":"s1"}"""); var result = analyzer.GetResult(); Assert.Equal(300, result.TokensIn); Assert.Equal(130, result.TokensOut); } [Fact] public void Counts_Api_Retry_Events() { var analyzer = new StreamAnalyzer(); analyzer.ProcessLine("""{"type":"system","subtype":"api_retry","attempt":1,"error":"rate_limit"}"""); analyzer.ProcessLine("""{"type":"system","subtype":"api_retry","attempt":2,"error":"rate_limit"}"""); analyzer.ProcessLine("""{"type":"result","result":"done","session_id":"s1"}"""); var result = analyzer.GetResult(); Assert.Equal(2, result.ApiRetryCount); } [Fact] public void Malformed_Json_Is_Ignored() { var analyzer = new StreamAnalyzer(); analyzer.ProcessLine("not json {{{"); analyzer.ProcessLine(""); analyzer.ProcessLine(" "); var result = analyzer.GetResult(); Assert.Null(result.ResultMarkdown); Assert.Equal(0, result.TurnCount); } [Fact] public void No_Result_Event_Returns_Null_Fields() { var analyzer = new StreamAnalyzer(); analyzer.ProcessLine("""{"type":"assistant","message":"hi"}"""); var result = analyzer.GetResult(); Assert.Null(result.ResultMarkdown); Assert.Null(result.SessionId); } } ``` - [ ] **Step 2: Run tests to verify they fail** Run: ```bash dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~StreamAnalyzer" -v minimal ``` Expected: build error — `StreamAnalyzer` and `StreamResult` don't exist. - [ ] **Step 3: Create StreamResult** ```csharp namespace ClaudeDo.Worker.Runner; public sealed class StreamResult { public string? ResultMarkdown { get; set; } public string? StructuredOutputJson { get; set; } public string? SessionId { get; set; } public int TurnCount { get; set; } public int TokensIn { get; set; } public int TokensOut { get; set; } public int ApiRetryCount { get; set; } } ``` - [ ] **Step 4: Implement StreamAnalyzer** ```csharp using System.Text.Json; namespace ClaudeDo.Worker.Runner; public sealed class StreamAnalyzer { private string? _resultMarkdown; private string? _structuredOutputJson; private string? _sessionId; private int _turnCount; private int _tokensIn; private int _tokensOut; private int _apiRetryCount; public void ProcessLine(string ndjsonLine) { if (string.IsNullOrWhiteSpace(ndjsonLine)) return; try { using var doc = JsonDocument.Parse(ndjsonLine); var root = doc.RootElement; if (!root.TryGetProperty("type", out var typeProp)) return; var type = typeProp.GetString(); switch (type) { case "result": if (root.TryGetProperty("result", out var resultProp)) _resultMarkdown = resultProp.GetString(); if (root.TryGetProperty("structured_output", out var structuredProp)) _structuredOutputJson = structuredProp.ToString(); if (root.TryGetProperty("session_id", out var sessionProp)) _sessionId = sessionProp.GetString(); break; case "assistant": _turnCount++; break; case "system": if (root.TryGetProperty("subtype", out var subtypeProp) && subtypeProp.GetString() == "api_retry") _apiRetryCount++; break; case "stream_event": TryAccumulateUsage(root); break; } } catch (JsonException) { // Malformed JSON — skip. } } public StreamResult GetResult() => new() { ResultMarkdown = _resultMarkdown, StructuredOutputJson = _structuredOutputJson, SessionId = _sessionId, TurnCount = _turnCount, TokensIn = _tokensIn, TokensOut = _tokensOut, ApiRetryCount = _apiRetryCount, }; private void TryAccumulateUsage(JsonElement root) { // Path: .event.message.usage.input_tokens / .event.message.usage.output_tokens // Also: .event.delta.usage for delta events if (!root.TryGetProperty("event", out var eventProp)) return; if (eventProp.TryGetProperty("message", out var msgProp) && msgProp.TryGetProperty("usage", out var usageProp)) { AccumulateUsage(usageProp); } } private void AccumulateUsage(JsonElement usage) { if (usage.TryGetProperty("input_tokens", out var inp)) _tokensIn += inp.GetInt32(); if (usage.TryGetProperty("output_tokens", out var outp)) _tokensOut += outp.GetInt32(); } } ``` - [ ] **Step 5: Run tests to verify they pass** Run: ```bash dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~StreamAnalyzer" -v minimal ``` Expected: 7 tests pass. - [ ] **Step 6: Commit** ```bash git add src/ClaudeDo.Worker/Runner/StreamResult.cs src/ClaudeDo.Worker/Runner/StreamAnalyzer.cs tests/ClaudeDo.Worker.Tests/Runner/StreamAnalyzerTests.cs git commit -m "feat(worker): add StreamAnalyzer for rich NDJSON stream parsing" ``` --- ### Task 8: RunResult — Extend with stream metrics **Files:** - Modify: `src/ClaudeDo.Worker/Runner/RunResult.cs:1-11` - [ ] **Step 1: Update RunResult** Replace entire file: ```csharp namespace ClaudeDo.Worker.Runner; public sealed class RunResult { public required int ExitCode { get; init; } public string? ResultMarkdown { get; init; } public string? ErrorMarkdown { get; init; } public string? StructuredOutputJson { get; init; } public string? SessionId { get; init; } public int TurnCount { get; init; } public int TokensIn { get; init; } public int TokensOut { get; init; } public bool IsSuccess => ExitCode == 0 && ResultMarkdown is not null; } ``` - [ ] **Step 2: Build to check for downstream breakage** Run: ```bash dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj ``` Expected: build succeeds — new properties are optional (init-only with defaults). The existing `new RunResult { ExitCode = ..., ResultMarkdown = ... }` sites still compile. - [ ] **Step 3: Commit** ```bash git add src/ClaudeDo.Worker/Runner/RunResult.cs git commit -m "feat(worker): extend RunResult with structured output, session ID, and token metrics" ``` --- ### Task 9: IClaudeProcess + ClaudeProcess — Simplified interface **Files:** - Modify: `src/ClaudeDo.Worker/Runner/IClaudeProcess.cs:1-12` - Modify: `src/ClaudeDo.Worker/Runner/ClaudeProcess.cs:1-96` - [ ] **Step 1: Update IClaudeProcess** Replace entire file: ```csharp namespace ClaudeDo.Worker.Runner; public interface IClaudeProcess { Task RunAsync( string arguments, string prompt, string workingDirectory, Func onStdoutLine, CancellationToken ct); } ``` - [ ] **Step 2: Update ClaudeProcess** Replace entire file: ```csharp using System.Diagnostics; using System.Text; using ClaudeDo.Worker.Config; namespace ClaudeDo.Worker.Runner; public sealed class ClaudeProcess : IClaudeProcess { private readonly WorkerConfig _cfg; private readonly ILogger _logger; public ClaudeProcess(WorkerConfig cfg, ILogger logger) { _cfg = cfg; _logger = logger; } public async Task RunAsync( string arguments, string prompt, string workingDirectory, Func onStdoutLine, CancellationToken ct) { var psi = new ProcessStartInfo { FileName = _cfg.ClaudeBin, Arguments = arguments, WorkingDirectory = workingDirectory, RedirectStandardInput = true, RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, CreateNoWindow = true, StandardOutputEncoding = Encoding.UTF8, StandardErrorEncoding = Encoding.UTF8, }; using var process = new Process { StartInfo = psi }; process.Start(); // Write prompt to stdin, then close. await process.StandardInput.WriteAsync(prompt); process.StandardInput.Close(); var analyzer = new StreamAnalyzer(); var lastStderr = new StringBuilder(); // Register cancellation to kill the process tree. await using var ctr = ct.Register(() => { try { process.Kill(entireProcessTree: true); } catch { /* already exited */ } }); // Read stdout and stderr concurrently. var stdoutTask = Task.Run(async () => { while (await process.StandardOutput.ReadLineAsync(ct) is { } line) { if (string.IsNullOrEmpty(line)) continue; await onStdoutLine(line); analyzer.ProcessLine(line); } }, ct); var stderrTask = Task.Run(async () => { while (await process.StandardError.ReadLineAsync(ct) is { } line) { if (string.IsNullOrEmpty(line)) continue; lastStderr.AppendLine(line); await onStdoutLine($"[stderr] {line}"); } }, ct); await Task.WhenAll(stdoutTask, stderrTask); await process.WaitForExitAsync(ct); var exitCode = process.ExitCode; var streamResult = analyzer.GetResult(); if (exitCode == 0 && streamResult.ResultMarkdown is not null) { return new RunResult { ExitCode = exitCode, ResultMarkdown = streamResult.ResultMarkdown, StructuredOutputJson = streamResult.StructuredOutputJson, SessionId = streamResult.SessionId, TurnCount = streamResult.TurnCount, TokensIn = streamResult.TokensIn, TokensOut = streamResult.TokensOut, }; } var error = lastStderr.Length > 0 ? lastStderr.ToString().Trim() : $"Claude exited with code {exitCode} and no result."; return new RunResult { ExitCode = exitCode, ErrorMarkdown = error, SessionId = streamResult.SessionId, TurnCount = streamResult.TurnCount, TokensIn = streamResult.TokensIn, TokensOut = streamResult.TokensOut, }; } } ``` - [ ] **Step 3: Update FakeClaudeProcess in tests** The `FakeClaudeProcess` in `QueueServiceTests.cs` must match the new `IClaudeProcess` signature. Update it: ```csharp internal sealed class FakeClaudeProcess : IClaudeProcess { private readonly Func, CancellationToken, Task> _handler; private int _callCount; public int CallCount => _callCount; public FakeClaudeProcess( Func, CancellationToken, Task>? handler = null) { _handler = handler ?? ((_, _, _, _, _) => Task.FromResult(new RunResult { ExitCode = 0, ResultMarkdown = "ok" })); } public async Task RunAsync(string arguments, string prompt, string workingDirectory, Func onStdoutLine, CancellationToken ct) { Interlocked.Increment(ref _callCount); return await _handler(prompt, workingDirectory, arguments, onStdoutLine, ct); } } ``` Update all sites in `QueueServiceTests.cs` that construct `FakeClaudeProcess` with a handler lambda — the lambda signature changes from 6 parameters to 5 (remove `logPath` and `taskId`). - [ ] **Step 4: Build and run all tests** Run: ```bash dotnet build ClaudeDo.slnx && dotnet test tests/ClaudeDo.Worker.Tests -v minimal ``` Expected: all tests pass. - [ ] **Step 5: Commit** ```bash git add src/ClaudeDo.Worker/Runner/IClaudeProcess.cs src/ClaudeDo.Worker/Runner/ClaudeProcess.cs tests/ClaudeDo.Worker.Tests/Services/QueueServiceTests.cs git commit -m "refactor(worker): simplify ClaudeProcess to accept pre-built args and use StreamAnalyzer" ``` --- ### Task 10: AgentFileService — Filesystem agent management **Files:** - Create: `src/ClaudeDo.Worker/Services/AgentFileService.cs` - Create: `tests/ClaudeDo.Worker.Tests/Services/AgentFileServiceTests.cs` - [ ] **Step 1: Write failing tests** ```csharp using ClaudeDo.Worker.Services; namespace ClaudeDo.Worker.Tests.Services; public sealed class AgentFileServiceTests : IDisposable { private readonly string _agentDir; private readonly AgentFileService _service; public AgentFileServiceTests() { _agentDir = Path.Combine(Path.GetTempPath(), $"claudedo_agents_test_{Guid.NewGuid():N}"); Directory.CreateDirectory(_agentDir); _service = new AgentFileService(_agentDir); } [Fact] public async Task Scan_Returns_Empty_For_Empty_Directory() { var agents = await _service.ScanAsync(); Assert.Empty(agents); } [Fact] public async Task Scan_Parses_Frontmatter() { var content = """ --- name: Test Agent description: A test agent for unit tests --- You are a test agent. """; await File.WriteAllTextAsync(Path.Combine(_agentDir, "test.md"), content); var agents = await _service.ScanAsync(); Assert.Single(agents); Assert.Equal("Test Agent", agents[0].Name); Assert.Equal("A test agent for unit tests", agents[0].Description); Assert.EndsWith("test.md", agents[0].Path); } [Fact] public async Task Scan_Uses_Filename_When_No_Frontmatter() { await File.WriteAllTextAsync(Path.Combine(_agentDir, "simple.md"), "Just instructions."); var agents = await _service.ScanAsync(); Assert.Single(agents); Assert.Equal("simple", agents[0].Name); Assert.Equal("", agents[0].Description); } [Fact] public async Task Write_And_Read_Roundtrips() { var path = Path.Combine(_agentDir, "new-agent.md"); var content = "---\nname: New\ndescription: Desc\n---\nBody"; await _service.WriteAsync(path, content); var read = await _service.ReadAsync(path); Assert.Equal(content, read); } [Fact] public async Task Delete_Removes_File() { var path = Path.Combine(_agentDir, "to-delete.md"); await File.WriteAllTextAsync(path, "temp"); await _service.DeleteAsync(path); Assert.False(File.Exists(path)); } [Fact] public async Task Scan_Ignores_Non_Md_Files() { await File.WriteAllTextAsync(Path.Combine(_agentDir, "notes.txt"), "not an agent"); await File.WriteAllTextAsync(Path.Combine(_agentDir, "agent.md"), "---\nname: Real\ndescription: Yes\n---\nBody"); var agents = await _service.ScanAsync(); Assert.Single(agents); Assert.Equal("Real", agents[0].Name); } public void Dispose() { try { Directory.Delete(_agentDir, true); } catch { } } } ``` - [ ] **Step 2: Run tests to verify they fail** Run: ```bash dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~AgentFileService" -v minimal ``` Expected: build error — `AgentFileService` doesn't exist. - [ ] **Step 3: Implement AgentFileService** ```csharp using ClaudeDo.Data.Models; namespace ClaudeDo.Worker.Services; public sealed class AgentFileService { private readonly string _agentsDir; public AgentFileService(string agentsDir) { _agentsDir = agentsDir; } public Task> ScanAsync(CancellationToken ct = default) { var agents = new List(); if (!Directory.Exists(_agentsDir)) return Task.FromResult(agents); foreach (var file in Directory.EnumerateFiles(_agentsDir, "*.md")) { ct.ThrowIfCancellationRequested(); var (name, description) = ParseFrontmatter(file); agents.Add(new AgentInfo(name, description, file)); } agents.Sort((a, b) => string.Compare(a.Name, b.Name, StringComparison.OrdinalIgnoreCase)); return Task.FromResult(agents); } public async Task ReadAsync(string path, CancellationToken ct = default) { return await File.ReadAllTextAsync(path, ct); } public async Task WriteAsync(string path, string content, CancellationToken ct = default) { var dir = Path.GetDirectoryName(path); if (dir is not null) Directory.CreateDirectory(dir); await File.WriteAllTextAsync(path, content, ct); } public Task DeleteAsync(string path, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); if (File.Exists(path)) File.Delete(path); return Task.CompletedTask; } private static (string name, string description) ParseFrontmatter(string filePath) { var fileName = Path.GetFileNameWithoutExtension(filePath); string name = fileName; string description = ""; try { using var reader = new StreamReader(filePath); var firstLine = reader.ReadLine(); if (firstLine?.Trim() != "---") return (name, description); while (reader.ReadLine() is { } line) { if (line.Trim() == "---") break; if (line.StartsWith("name:")) name = line["name:".Length..].Trim(); else if (line.StartsWith("description:")) description = line["description:".Length..].Trim(); } } catch { // Can't read file — use filename fallback. } return (name, description); } } ``` - [ ] **Step 4: Run tests to verify they pass** Run: ```bash dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~AgentFileService" -v minimal ``` Expected: 6 tests pass. - [ ] **Step 5: Commit** ```bash git add src/ClaudeDo.Worker/Services/AgentFileService.cs tests/ClaudeDo.Worker.Tests/Services/AgentFileServiceTests.cs git commit -m "feat(worker): add AgentFileService for filesystem agent management" ``` --- ### Task 11: TaskRunner — Refactor with retry/continue and config resolution **Files:** - Modify: `src/ClaudeDo.Worker/Runner/TaskRunner.cs:1-149` This is the largest change. TaskRunner gains config resolution, task_runs tracking, auto-retry, and a new `ContinueAsync` method. - [ ] **Step 1: Rewrite TaskRunner** Replace entire file: ```csharp using ClaudeDo.Data.Models; using ClaudeDo.Data.Repositories; using ClaudeDo.Worker.Config; using ClaudeDo.Worker.Hub; namespace ClaudeDo.Worker.Runner; public sealed class TaskRunner { private readonly IClaudeProcess _claude; private readonly TaskRepository _taskRepo; private readonly TaskRunRepository _runRepo; private readonly ListRepository _listRepo; private readonly WorktreeRepository _wtRepo; // for ContinueAsync worktree lookup private readonly HubBroadcaster _broadcaster; private readonly WorktreeManager _wtManager; private readonly ClaudeArgsBuilder _argsBuilder; private readonly WorkerConfig _cfg; private readonly ILogger _logger; public TaskRunner( IClaudeProcess claude, TaskRepository taskRepo, TaskRunRepository runRepo, ListRepository listRepo, WorktreeRepository wtRepo, HubBroadcaster broadcaster, WorktreeManager wtManager, ClaudeArgsBuilder argsBuilder, WorkerConfig cfg, ILogger logger) { _claude = claude; _taskRepo = taskRepo; _runRepo = runRepo; _listRepo = listRepo; _wtRepo = wtRepo; _broadcaster = broadcaster; _wtManager = wtManager; _argsBuilder = argsBuilder; _cfg = cfg; _logger = logger; } public async Task RunAsync(TaskEntity task, string slot, CancellationToken ct) { try { var list = await _listRepo.GetByIdAsync(task.ListId, ct); if (list is null) { await MarkFailed(task.Id, slot, "List not found."); return; } // Determine working directory: worktree or sandbox. WorktreeContext? wtCtx = null; string runDir; if (list.WorkingDir is not null) { try { wtCtx = await _wtManager.CreateAsync(task, list, ct); runDir = wtCtx.WorktreePath; } catch (Exception ex) { _logger.LogError(ex, "Failed to create worktree for task {TaskId}", task.Id); await MarkFailed(task.Id, slot, $"Worktree creation failed: {ex.Message}"); return; } } else { runDir = Path.Combine(_cfg.SandboxRoot, task.Id); Directory.CreateDirectory(runDir); } // Resolve config: task overrides > list config > null. var listConfig = await _listRepo.GetConfigAsync(task.ListId, ct); var resolvedConfig = new ClaudeRunConfig( Model: task.Model ?? listConfig?.Model, SystemPrompt: task.SystemPrompt ?? listConfig?.SystemPrompt, AgentPath: task.AgentPath ?? listConfig?.AgentPath, ResumeSessionId: null ); var now = DateTime.UtcNow; await _taskRepo.MarkRunningAsync(task.Id, now, ct); await _broadcaster.TaskStarted(slot, task.Id, now); // Build prompt. var prompt = string.IsNullOrWhiteSpace(task.Description) ? task.Title : $"{task.Title}\n\n{task.Description.Trim()}"; // Run 1. var result = await RunOnceAsync(task.Id, slot, runDir, resolvedConfig, 1, false, prompt, ct); if (result.IsSuccess) { await HandleSuccess(task, list, slot, wtCtx, result, ct); } else { // Auto-retry: one attempt if we have a session ID. if (result.SessionId is not null) { _logger.LogInformation("Auto-retrying task {TaskId} with session {SessionId}", task.Id, result.SessionId); var retryConfig = resolvedConfig with { ResumeSessionId = result.SessionId }; var retryPrompt = $"The previous attempt failed with:\n\n{result.ErrorMarkdown}\n\nTry again and fix the issues."; await _broadcaster.RunCreated(task.Id, 2, true); var retryResult = await RunOnceAsync(task.Id, slot, runDir, retryConfig, 2, true, retryPrompt, ct); if (retryResult.IsSuccess) { await HandleSuccess(task, list, slot, wtCtx, retryResult, ct); } else { await HandleFailure(task.Id, slot, retryResult); } } else { await HandleFailure(task.Id, slot, result); } } await _broadcaster.TaskUpdated(task.Id); } catch (OperationCanceledException) { _logger.LogInformation("Task {TaskId} was cancelled", task.Id); await MarkFailed(task.Id, slot, "Task cancelled."); } catch (Exception ex) { _logger.LogError(ex, "Unhandled exception running task {TaskId}", task.Id); await MarkFailed(task.Id, slot, $"Unhandled error: {ex.Message}"); } } public async Task ContinueAsync(string taskId, string followUpPrompt, string slot, CancellationToken ct) { var task = await _taskRepo.GetByIdAsync(taskId, ct) ?? throw new KeyNotFoundException($"Task '{taskId}' not found."); var lastRun = await _runRepo.GetLatestByTaskIdAsync(taskId, ct) ?? throw new InvalidOperationException("No previous run to continue."); if (lastRun.SessionId is null) throw new InvalidOperationException("Previous run has no session ID — cannot resume."); var list = await _listRepo.GetByIdAsync(task.ListId, ct) ?? throw new InvalidOperationException("List not found."); var listConfig = await _listRepo.GetConfigAsync(task.ListId, ct); var resolvedConfig = new ClaudeRunConfig( Model: task.Model ?? listConfig?.Model, SystemPrompt: task.SystemPrompt ?? listConfig?.SystemPrompt, AgentPath: task.AgentPath ?? listConfig?.AgentPath, ResumeSessionId: lastRun.SessionId ); // Determine run directory from existing worktree or sandbox. string runDir; WorktreeContext? wtCtx = null; var worktree = await _wtRepo.GetByTaskIdAsync(taskId, ct); if (worktree is not null) { runDir = worktree.Path; wtCtx = new WorktreeContext(worktree.Path, worktree.BranchName, worktree.BaseCommit); } else { runDir = Path.Combine(_cfg.SandboxRoot, taskId); } var now = DateTime.UtcNow; await _taskRepo.MarkRunningAsync(taskId, now, ct); await _broadcaster.TaskStarted(slot, taskId, now); var nextRunNumber = lastRun.RunNumber + 1; await _broadcaster.RunCreated(taskId, nextRunNumber, false); var result = await RunOnceAsync(taskId, slot, runDir, resolvedConfig, nextRunNumber, false, followUpPrompt, ct); if (result.IsSuccess) { await HandleSuccess(task, list, slot, wtCtx, result, ct); } else { await HandleFailure(taskId, slot, result); } await _broadcaster.TaskUpdated(taskId); } private async Task RunOnceAsync( string taskId, string slot, string runDir, ClaudeRunConfig config, int runNumber, bool isRetry, string prompt, CancellationToken ct) { var runId = Guid.NewGuid().ToString(); var logPath = Path.Combine(_cfg.LogRoot, $"{taskId}_run{runNumber}.ndjson"); var run = new TaskRunEntity { Id = runId, TaskId = taskId, RunNumber = runNumber, IsRetry = isRetry, Prompt = prompt, LogPath = logPath, StartedAt = DateTime.UtcNow, }; await _runRepo.AddAsync(run, ct); var arguments = _argsBuilder.Build(config); await using var logWriter = new LogWriter(logPath); var result = await _claude.RunAsync( arguments, prompt, runDir, async line => { await logWriter.WriteLineAsync(line, ct); await _broadcaster.TaskMessage(taskId, line); }, ct); // Update the run record with results. run.SessionId = result.SessionId; run.ResultMarkdown = result.ResultMarkdown; run.StructuredOutputJson = result.StructuredOutputJson; run.ErrorMarkdown = result.ErrorMarkdown; run.ExitCode = result.ExitCode; run.TurnCount = result.TurnCount; run.TokensIn = result.TokensIn; run.TokensOut = result.TokensOut; run.FinishedAt = DateTime.UtcNow; await _runRepo.UpdateAsync(run, ct); // Update denormalized fields on the task. await _taskRepo.SetLogPathAsync(taskId, logPath, ct); return result; } private async Task HandleSuccess(TaskEntity task, ListEntity list, string slot, WorktreeContext? wtCtx, RunResult result, CancellationToken ct) { if (wtCtx is not null) { var committed = await _wtManager.CommitIfChangedAsync(wtCtx, task, list, ct); if (committed) await _broadcaster.WorktreeUpdated(task.Id); } var finishedAt = DateTime.UtcNow; await _taskRepo.MarkDoneAsync(task.Id, finishedAt, result.ResultMarkdown, ct); await _broadcaster.TaskFinished(slot, task.Id, "done", finishedAt); _logger.LogInformation("Task {TaskId} completed (turns={Turns}, tokens_in={In}, tokens_out={Out})", task.Id, result.TurnCount, result.TokensIn, result.TokensOut); } private async Task HandleFailure(string taskId, string slot, RunResult result) { var finishedAt = DateTime.UtcNow; await _taskRepo.MarkFailedAsync(taskId, finishedAt, result.ErrorMarkdown); await _broadcaster.TaskFinished(slot, taskId, "failed", finishedAt); _logger.LogWarning("Task {TaskId} failed (turns={Turns}): {Error}", taskId, result.TurnCount, result.ErrorMarkdown); } private async Task MarkFailed(string taskId, string slot, string error) { try { var now = DateTime.UtcNow; await _taskRepo.MarkFailedAsync(taskId, now, error); await _broadcaster.TaskFinished(slot, taskId, "failed", now); await _broadcaster.TaskUpdated(taskId); } catch (Exception ex) { _logger.LogError(ex, "Failed to mark task {TaskId} as failed", taskId); } } } ``` - [ ] **Step 2: Build and check for compilation errors** Run: ```bash dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj ``` Expected: may fail on missing DI registrations (Program.cs) — that's Task 12. - [ ] **Step 3: Commit** ```bash git add src/ClaudeDo.Worker/Runner/TaskRunner.cs git commit -m "refactor(worker): rewrite TaskRunner with config resolution, retry, and continue support" ``` --- ### Task 12: HubBroadcaster + WorkerHub — New methods **Files:** - Modify: `src/ClaudeDo.Worker/Hub/HubBroadcaster.cs:1-25` - Modify: `src/ClaudeDo.Worker/Hub/WorkerHub.cs:1-44` - [ ] **Step 1: Add RunCreated broadcast to HubBroadcaster** Add after the `TaskUpdated` method: ```csharp public Task RunCreated(string taskId, int runNumber, bool isRetry) => _hub.Clients.All.SendAsync("RunCreated", taskId, runNumber, isRetry); ``` - [ ] **Step 2: Add ContinueTask, GetAgents, RefreshAgents to WorkerHub** Update WorkerHub to inject `AgentFileService` and expose new methods. Replace entire file: ```csharp using System.Reflection; using ClaudeDo.Data.Models; using ClaudeDo.Worker.Services; using Microsoft.AspNetCore.SignalR; namespace ClaudeDo.Worker.Hub; public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub { private static readonly string Version = Assembly.GetExecutingAssembly().GetName().Version?.ToString(3) ?? "0.0.0"; private readonly QueueService _queue; private readonly AgentFileService _agentService; public WorkerHub(QueueService queue, AgentFileService agentService) { _queue = queue; _agentService = agentService; } public string Ping() => $"pong v{Version}"; public IReadOnlyList GetActive() { return _queue.GetActive() .Select(a => (object)new { slot = a.slot, taskId = a.taskId, startedAt = a.startedAt }) .ToList(); } public async Task RunNow(string taskId) { try { await _queue.RunNow(taskId); } catch (InvalidOperationException) { throw new HubException("override slot busy"); } catch (KeyNotFoundException) { throw new HubException("task not found"); } } public async Task ContinueTask(string taskId, string followUpPrompt) { try { return await _queue.ContinueTask(taskId, followUpPrompt); } catch (InvalidOperationException ex) { throw new HubException(ex.Message); } catch (KeyNotFoundException) { throw new HubException("task not found"); } } public bool CancelTask(string taskId) => _queue.CancelTask(taskId); public void WakeQueue() => _queue.WakeQueue(); public async Task> GetAgents() => await _agentService.ScanAsync(); public async Task RefreshAgents() => await _agentService.ScanAsync(); } ``` - [ ] **Step 3: Build to check** Run: ```bash dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj ``` Expected: may fail — `QueueService.ContinueTask` doesn't exist yet (Task 13). - [ ] **Step 4: Commit** ```bash git add src/ClaudeDo.Worker/Hub/HubBroadcaster.cs src/ClaudeDo.Worker/Hub/WorkerHub.cs git commit -m "feat(worker): add ContinueTask, GetAgents, RefreshAgents hub methods and RunCreated broadcast" ``` --- ### Task 13: QueueService — Route ContinueTask **Files:** - Modify: `src/ClaudeDo.Worker/Services/QueueService.cs:57-76` - [ ] **Step 1: Add ContinueTask method to QueueService** Add after the `RunNow` method: ```csharp public async Task ContinueTask(string taskId, string followUpPrompt) { var task = await _taskRepo.GetByIdAsync(taskId) ?? throw new KeyNotFoundException($"Task '{taskId}' not found."); if (task.Status == Data.Models.TaskStatus.Running) throw new InvalidOperationException("Task is currently running."); lock (_lock) { if (_overrideSlot is not null) throw new InvalidOperationException("override slot busy"); var cts = new CancellationTokenSource(); _overrideSlot = new QueueSlotState { TaskId = taskId, StartedAt = DateTime.UtcNow, Cts = cts }; _ = RunContinueInSlotAsync(taskId, followUpPrompt, cts.Token).ContinueWith(_ => { lock (_lock) { _overrideSlot = null; } }, TaskScheduler.Default); } return taskId; } private async Task RunContinueInSlotAsync(string taskId, string followUpPrompt, CancellationToken ct) { try { _logger.LogInformation("Continuing task {TaskId} in override slot", taskId); await _runner.ContinueAsync(taskId, followUpPrompt, "override", ct); } catch (Exception ex) { _logger.LogError(ex, "Continue runner error for task {TaskId}", taskId); } } ``` - [ ] **Step 2: Build the full solution** Run: ```bash dotnet build ClaudeDo.slnx ``` Expected: may fail due to missing DI registrations (Task 14). - [ ] **Step 3: Commit** ```bash git add src/ClaudeDo.Worker/Services/QueueService.cs git commit -m "feat(worker): add ContinueTask routing to QueueService" ``` --- ### Task 14: Program.cs — DI registration for new services **Files:** - Modify: `src/ClaudeDo.Worker/Program.cs:1-47` - [ ] **Step 1: Register new services** Add after the existing `builder.Services.AddSingleton();` line: ```csharp builder.Services.AddSingleton(); ``` Add after the `builder.Services.AddSingleton();` line: ```csharp builder.Services.AddSingleton(); ``` Add after the `builder.Services.AddSingleton();` line (before the QueueService block): ```csharp // Agent file management. var agentsDir = Path.Combine(ClaudeDo.Data.Paths.AppDataRoot(), "agents"); Directory.CreateDirectory(agentsDir); builder.Services.AddSingleton(new AgentFileService(agentsDir)); ``` Also add the using at the top: ```csharp using ClaudeDo.Worker.Services; ``` - [ ] **Step 2: Build the full solution** Run: ```bash dotnet build ClaudeDo.slnx ``` Expected: clean build, no errors. - [ ] **Step 3: Run all tests** Run: ```bash dotnet test tests/ClaudeDo.Worker.Tests -v minimal ``` Expected: all tests pass (some may need FakeClaudeProcess updates from Task 9 step 3). - [ ] **Step 4: Commit** ```bash git add src/ClaudeDo.Worker/Program.cs git commit -m "feat(worker): register TaskRunRepository, ClaudeArgsBuilder, and AgentFileService in DI" ``` --- ### Task 15: Delete MessageParser + update tests **Files:** - Delete: `src/ClaudeDo.Worker/Runner/MessageParser.cs` - Delete: `tests/ClaudeDo.Worker.Tests/Runner/MessageParserTests.cs` - [ ] **Step 1: Verify no references to MessageParser remain** Run: ```bash grep -r "MessageParser" src/ tests/ --include="*.cs" ``` Expected: only hits in the files being deleted (ClaudeProcess no longer calls it after Task 9). - [ ] **Step 2: Delete the files** ```bash rm src/ClaudeDo.Worker/Runner/MessageParser.cs rm tests/ClaudeDo.Worker.Tests/Runner/MessageParserTests.cs ``` - [ ] **Step 3: Build and run all tests** Run: ```bash dotnet build ClaudeDo.slnx && dotnet test tests/ClaudeDo.Worker.Tests -v minimal ``` Expected: clean build, all tests pass. - [ ] **Step 4: Commit** ```bash git add -A git commit -m "refactor(worker): remove MessageParser (replaced by StreamAnalyzer)" ``` --- ### Task 16: Integration smoke test — Full run with retry **Files:** - Modify: `tests/ClaudeDo.Worker.Tests/Services/QueueServiceTests.cs` - [ ] **Step 1: Add a test for auto-retry flow** Add a new test to `QueueServiceTests`: ```csharp [Fact] public async Task RunNow_AutoRetries_On_Failure_With_SessionId() { var callCount = 0; var fake = new FakeClaudeProcess((prompt, dir, args, onLine, ct) => { callCount++; if (callCount == 1) { return Task.FromResult(new RunResult { ExitCode = 1, ErrorMarkdown = "something broke", SessionId = "sess-retry-test", }); } return Task.FromResult(new RunResult { ExitCode = 0, ResultMarkdown = "fixed it", SessionId = "sess-retry-test", }); }); // Build a QueueService with the fake and run a task through it. // Verify callCount == 2 (initial + retry). // Verify the task ends as "done". Assert.Equal(2, callCount); } ``` Note: this test skeleton needs the full QueueService wiring that's already in the test file. Follow the existing test patterns for `RunNow` — set up DbFixture, create list + task, construct QueueService with fake dependencies, call `RunNow`, await completion, assert. - [ ] **Step 2: Run the test** Run: ```bash dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~AutoRetries" -v minimal ``` Expected: test passes. - [ ] **Step 3: Commit** ```bash git add tests/ClaudeDo.Worker.Tests/Services/QueueServiceTests.cs git commit -m "test(worker): add integration test for auto-retry flow" ``` --- ### Task 17: Update Worker CLAUDE.md **Files:** - Modify: `src/ClaudeDo.Worker/CLAUDE.md` - [ ] **Step 1: Update documentation to reflect changes** Add sections for: - New config resolution flow (list_config + task overrides) - `task_runs` table and execution tracking - Auto-retry and continue flow - `ClaudeArgsBuilder`, `StreamAnalyzer` (replacing `MessageParser`) - `AgentFileService` and agents directory - New hub methods: `ContinueTask`, `GetAgents`, `RefreshAgents` - New broadcast: `RunCreated` - [ ] **Step 2: Commit** ```bash git add src/ClaudeDo.Worker/CLAUDE.md git commit -m "docs(worker): update CLAUDE.md with CLI modernization changes" ```