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_runstable and execution tracking -
Auto-retry and continue flow
-
ClaudeArgsBuilder,StreamAnalyzer(replacingMessageParser) -
AgentFileServiceand 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"