Files
ClaudeDo/docs/superpowers/plans/2026-04-14-worker-cli-modernization.md

74 KiB

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:

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:

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):

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:

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
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

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
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
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):

public string? Model { get; set; }
public string? SystemPrompt { get; set; }
public string? AgentPath { get; set; }
  • Step 5: Build and verify

Run:

dotnet build src/ClaudeDo.Data/ClaudeDo.Data.csproj

Expected: clean build, no errors.

  • Step 6: Commit
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

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:

dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~TaskRunRepository" -v minimal

Expected: build error — TaskRunRepository does not exist.

  • Step 3: Implement TaskRunRepository
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<TaskRunEntity?> 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<List<TaskRunEntity>> 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<TaskRunEntity>();
        while (await reader.ReadAsync(ct))
            result.Add(ReadRun(reader));
        return result;
    }

    public async Task<TaskRunEntity?> 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:

dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~TaskRunRepository" -v minimal

Expected: 5 tests pass.

  • Step 5: Commit
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

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:

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):

public async Task<ListConfigEntity?> 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:

dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~ListRepositoryConfig" -v minimal

Expected: 3 tests pass.

  • Step 5: Commit
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:

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
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
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:

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:

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
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

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:

dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~ClaudeArgsBuilder" -v minimal

Expected: build error — ClaudeArgsBuilder and ClaudeRunConfig don't exist.

  • Step 3: Implement ClaudeArgsBuilder
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<string>
        {
            "-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:

dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~ClaudeArgsBuilder" -v minimal

Expected: 7 tests pass.

  • Step 5: Commit
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

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:

dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~StreamAnalyzer" -v minimal

Expected: build error — StreamAnalyzer and StreamResult don't exist.

  • Step 3: Create StreamResult
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
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:

dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~StreamAnalyzer" -v minimal

Expected: 7 tests pass.

  • Step 6: Commit
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:

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:

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
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:

namespace ClaudeDo.Worker.Runner;

public interface IClaudeProcess
{
    Task<RunResult> RunAsync(
        string arguments,
        string prompt,
        string workingDirectory,
        Func<string, Task> onStdoutLine,
        CancellationToken ct);
}
  • Step 2: Update ClaudeProcess

Replace entire file:

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<ClaudeProcess> _logger;

    public ClaudeProcess(WorkerConfig cfg, ILogger<ClaudeProcess> logger)
    {
        _cfg = cfg;
        _logger = logger;
    }

    public async Task<RunResult> RunAsync(
        string arguments,
        string prompt,
        string workingDirectory,
        Func<string, Task> 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:

internal sealed class FakeClaudeProcess : IClaudeProcess
{
    private readonly Func<string, string, string, Func<string, Task>, CancellationToken, Task<RunResult>> _handler;
    private int _callCount;

    public int CallCount => _callCount;

    public FakeClaudeProcess(
        Func<string, string, string, Func<string, Task>, CancellationToken, Task<RunResult>>? handler = null)
    {
        _handler = handler ?? ((_, _, _, _, _) =>
            Task.FromResult(new RunResult { ExitCode = 0, ResultMarkdown = "ok" }));
    }

    public async Task<RunResult> RunAsync(string arguments, string prompt, string workingDirectory,
        Func<string, Task> 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:

dotnet build ClaudeDo.slnx && dotnet test tests/ClaudeDo.Worker.Tests -v minimal

Expected: all tests pass.

  • Step 5: Commit
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

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:

dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~AgentFileService" -v minimal

Expected: build error — AgentFileService doesn't exist.

  • Step 3: Implement AgentFileService
using ClaudeDo.Data.Models;

namespace ClaudeDo.Worker.Services;

public sealed class AgentFileService
{
    private readonly string _agentsDir;

    public AgentFileService(string agentsDir)
    {
        _agentsDir = agentsDir;
    }

    public Task<List<AgentInfo>> ScanAsync(CancellationToken ct = default)
    {
        var agents = new List<AgentInfo>();

        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<string> 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:

dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~AgentFileService" -v minimal

Expected: 6 tests pass.

  • Step 5: Commit
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:

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<TaskRunner> _logger;

    public TaskRunner(
        IClaudeProcess claude,
        TaskRepository taskRepo,
        TaskRunRepository runRepo,
        ListRepository listRepo,
        WorktreeRepository wtRepo,
        HubBroadcaster broadcaster,
        WorktreeManager wtManager,
        ClaudeArgsBuilder argsBuilder,
        WorkerConfig cfg,
        ILogger<TaskRunner> 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<RunResult> 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:

dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj

Expected: may fail on missing DI registrations (Program.cs) — that's Task 12.

  • Step 3: Commit
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:

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:

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<object> 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<string> 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<List<AgentInfo>> GetAgents() => await _agentService.ScanAsync();

    public async Task RefreshAgents() => await _agentService.ScanAsync();
}
  • Step 3: Build to check

Run:

dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj

Expected: may fail — QueueService.ContinueTask doesn't exist yet (Task 13).

  • Step 4: Commit
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:

public async Task<string> 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:

dotnet build ClaudeDo.slnx

Expected: may fail due to missing DI registrations (Task 14).

  • Step 3: Commit
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<WorktreeRepository>(); line:

builder.Services.AddSingleton<TaskRunRepository>();

Add after the builder.Services.AddSingleton<WorktreeManager>(); line:

builder.Services.AddSingleton<ClaudeArgsBuilder>();

Add after the builder.Services.AddSingleton<TaskRunner>(); line (before the QueueService block):

// 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:

using ClaudeDo.Worker.Services;
  • Step 2: Build the full solution

Run:

dotnet build ClaudeDo.slnx

Expected: clean build, no errors.

  • Step 3: Run all tests

Run:

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
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:

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
rm src/ClaudeDo.Worker/Runner/MessageParser.cs
rm tests/ClaudeDo.Worker.Tests/Runner/MessageParserTests.cs
  • Step 3: Build and run all tests

Run:

dotnet build ClaudeDo.slnx && dotnet test tests/ClaudeDo.Worker.Tests -v minimal

Expected: clean build, all tests pass.

  • Step 4: Commit
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:

[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:

dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~AutoRetries" -v minimal

Expected: test passes.

  • Step 3: Commit
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

git add src/ClaudeDo.Worker/CLAUDE.md
git commit -m "docs(worker): update CLAUDE.md with CLI modernization changes"