2312 lines
74 KiB
Markdown
2312 lines
74 KiB
Markdown
# Worker CLI Modernization Implementation Plan
|
|
|
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
|
|
**Goal:** Modernize the Worker's Claude CLI integration with per-list/task configuration, execution history tracking, multi-turn support, auto-retry, structured output, and agent file management.
|
|
|
|
**Architecture:** Add `list_config` and `task_runs` tables to the existing SQLite schema. Extract CLI argument building and stream parsing into dedicated classes. TaskRunner gains retry/continue logic. Agent `.md` files live on the filesystem at `~/.todo-app/agents/`, discoverable via SignalR.
|
|
|
|
**Tech Stack:** .NET 8.0, SQLite (raw ADO.NET), SignalR, xUnit
|
|
|
|
---
|
|
|
|
### Task 1: Schema — Add list_config and task_runs tables
|
|
|
|
**Files:**
|
|
- Modify: `schema/schema.sql:49-62`
|
|
- Modify: `src/ClaudeDo.Data/SchemaInitializer.cs`
|
|
|
|
This task adds the new tables and columns. Since SQLite doesn't support `ALTER TABLE ADD COLUMN IF NOT EXISTS`, the new columns on `tasks` need a try/catch approach in SchemaInitializer.
|
|
|
|
- [ ] **Step 1: Add list_config table to schema.sql**
|
|
|
|
Append before the worktrees table:
|
|
|
|
```sql
|
|
CREATE TABLE IF NOT EXISTS list_config (
|
|
list_id TEXT PRIMARY KEY REFERENCES lists(id) ON DELETE CASCADE,
|
|
model TEXT NULL,
|
|
system_prompt TEXT NULL,
|
|
agent_path TEXT NULL
|
|
);
|
|
```
|
|
|
|
- [ ] **Step 2: Add task_runs table to schema.sql**
|
|
|
|
Append after worktrees table:
|
|
|
|
```sql
|
|
CREATE TABLE IF NOT EXISTS task_runs (
|
|
id TEXT PRIMARY KEY,
|
|
task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
|
|
run_number INTEGER NOT NULL,
|
|
session_id TEXT NULL,
|
|
is_retry INTEGER NOT NULL DEFAULT 0,
|
|
prompt TEXT NOT NULL,
|
|
result_markdown TEXT NULL,
|
|
structured_output TEXT NULL,
|
|
error_markdown TEXT NULL,
|
|
exit_code INTEGER NULL,
|
|
turn_count INTEGER NULL,
|
|
tokens_in INTEGER NULL,
|
|
tokens_out INTEGER NULL,
|
|
log_path TEXT NULL,
|
|
started_at TIMESTAMP NULL,
|
|
finished_at TIMESTAMP NULL
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_task_runs_task_id ON task_runs(task_id);
|
|
```
|
|
|
|
- [ ] **Step 3: Add migration columns to tasks via SchemaInitializer**
|
|
|
|
In `SchemaInitializer.cs`, after applying the main schema, add a new `ApplyMigrations` method that runs `ALTER TABLE` in try/catch blocks (SQLite throws "duplicate column name" if the column already exists):
|
|
|
|
```csharp
|
|
private static void ApplyMigrations(SqliteConnection conn)
|
|
{
|
|
string[] alterStatements =
|
|
[
|
|
"ALTER TABLE tasks ADD COLUMN model TEXT NULL",
|
|
"ALTER TABLE tasks ADD COLUMN system_prompt TEXT NULL",
|
|
"ALTER TABLE tasks ADD COLUMN agent_path TEXT NULL",
|
|
];
|
|
|
|
foreach (var sql in alterStatements)
|
|
{
|
|
try
|
|
{
|
|
using var cmd = conn.CreateCommand();
|
|
cmd.CommandText = sql;
|
|
cmd.ExecuteNonQuery();
|
|
}
|
|
catch (Microsoft.Data.Sqlite.SqliteException ex) when (ex.SqliteErrorCode == 1)
|
|
{
|
|
// Column already exists — safe to ignore.
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
Call `ApplyMigrations(conn)` right after the main schema `ExecuteNonQuery` in the `ApplyTo` method.
|
|
|
|
- [ ] **Step 4: Verify schema applies cleanly**
|
|
|
|
Run:
|
|
```bash
|
|
dotnet build src/ClaudeDo.Data/ClaudeDo.Data.csproj
|
|
dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~Repository" -v minimal
|
|
```
|
|
|
|
Expected: all existing repository tests pass (schema changes are additive).
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add schema/schema.sql src/ClaudeDo.Data/SchemaInitializer.cs
|
|
git commit -m "feat(data): add list_config, task_runs tables and task config columns"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 2: Models — ListConfigEntity, TaskRunEntity, AgentInfo
|
|
|
|
**Files:**
|
|
- Create: `src/ClaudeDo.Data/Models/ListConfigEntity.cs`
|
|
- Create: `src/ClaudeDo.Data/Models/TaskRunEntity.cs`
|
|
- Create: `src/ClaudeDo.Data/Models/AgentInfo.cs`
|
|
- Modify: `src/ClaudeDo.Data/Models/TaskEntity.cs:13-26`
|
|
|
|
- [ ] **Step 1: Create ListConfigEntity**
|
|
|
|
```csharp
|
|
namespace ClaudeDo.Data.Models;
|
|
|
|
public sealed class ListConfigEntity
|
|
{
|
|
public required string ListId { get; init; }
|
|
public string? Model { get; set; }
|
|
public string? SystemPrompt { get; set; }
|
|
public string? AgentPath { get; set; }
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Create TaskRunEntity**
|
|
|
|
```csharp
|
|
namespace ClaudeDo.Data.Models;
|
|
|
|
public sealed class TaskRunEntity
|
|
{
|
|
public required string Id { get; init; }
|
|
public required string TaskId { get; init; }
|
|
public required int RunNumber { get; init; }
|
|
public string? SessionId { get; set; }
|
|
public required bool IsRetry { get; init; }
|
|
public required string Prompt { get; init; }
|
|
public string? ResultMarkdown { get; set; }
|
|
public string? StructuredOutputJson { get; set; }
|
|
public string? ErrorMarkdown { get; set; }
|
|
public int? ExitCode { get; set; }
|
|
public int? TurnCount { get; set; }
|
|
public int? TokensIn { get; set; }
|
|
public int? TokensOut { get; set; }
|
|
public string? LogPath { get; set; }
|
|
public DateTime? StartedAt { get; set; }
|
|
public DateTime? FinishedAt { get; set; }
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Create AgentInfo**
|
|
|
|
```csharp
|
|
namespace ClaudeDo.Data.Models;
|
|
|
|
public sealed record AgentInfo(string Name, string Description, string Path);
|
|
```
|
|
|
|
- [ ] **Step 4: Add config fields to TaskEntity**
|
|
|
|
Add after the `CommitType` property (line 25):
|
|
|
|
```csharp
|
|
public string? Model { get; set; }
|
|
public string? SystemPrompt { get; set; }
|
|
public string? AgentPath { get; set; }
|
|
```
|
|
|
|
- [ ] **Step 5: Build and verify**
|
|
|
|
Run:
|
|
```bash
|
|
dotnet build src/ClaudeDo.Data/ClaudeDo.Data.csproj
|
|
```
|
|
|
|
Expected: clean build, no errors.
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
```bash
|
|
git add src/ClaudeDo.Data/Models/ListConfigEntity.cs src/ClaudeDo.Data/Models/TaskRunEntity.cs src/ClaudeDo.Data/Models/AgentInfo.cs src/ClaudeDo.Data/Models/TaskEntity.cs
|
|
git commit -m "feat(data): add ListConfigEntity, TaskRunEntity, AgentInfo models and task config fields"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 3: TaskRunRepository — CRUD for execution history
|
|
|
|
**Files:**
|
|
- Create: `src/ClaudeDo.Data/Repositories/TaskRunRepository.cs`
|
|
- Create: `tests/ClaudeDo.Worker.Tests/Repositories/TaskRunRepositoryTests.cs`
|
|
|
|
- [ ] **Step 1: Write failing tests**
|
|
|
|
```csharp
|
|
using ClaudeDo.Data.Models;
|
|
using ClaudeDo.Data.Repositories;
|
|
using ClaudeDo.Worker.Tests.Infrastructure;
|
|
|
|
namespace ClaudeDo.Worker.Tests.Repositories;
|
|
|
|
public sealed class TaskRunRepositoryTests : IDisposable
|
|
{
|
|
private readonly DbFixture _db = new();
|
|
private readonly TaskRunRepository _repo;
|
|
private readonly TaskRepository _taskRepo;
|
|
private readonly ListRepository _listRepo;
|
|
private readonly string _listId;
|
|
private readonly string _taskId;
|
|
|
|
public TaskRunRepositoryTests()
|
|
{
|
|
_repo = new TaskRunRepository(_db.Factory);
|
|
_taskRepo = new TaskRepository(_db.Factory);
|
|
_listRepo = new ListRepository(_db.Factory);
|
|
|
|
_listId = Guid.NewGuid().ToString();
|
|
_listRepo.AddAsync(new ListEntity
|
|
{
|
|
Id = _listId, Name = "Test List", CreatedAt = DateTime.UtcNow
|
|
}).GetAwaiter().GetResult();
|
|
|
|
_taskId = Guid.NewGuid().ToString();
|
|
_taskRepo.AddAsync(new TaskEntity
|
|
{
|
|
Id = _taskId, ListId = _listId, Title = "Test Task",
|
|
Status = TaskStatus.Queued, CreatedAt = DateTime.UtcNow,
|
|
}).GetAwaiter().GetResult();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Add_And_GetById_Roundtrips()
|
|
{
|
|
var run = MakeRun(1);
|
|
await _repo.AddAsync(run);
|
|
|
|
var fetched = await _repo.GetByIdAsync(run.Id);
|
|
Assert.NotNull(fetched);
|
|
Assert.Equal(run.Id, fetched.Id);
|
|
Assert.Equal(run.TaskId, fetched.TaskId);
|
|
Assert.Equal(1, fetched.RunNumber);
|
|
Assert.False(fetched.IsRetry);
|
|
Assert.Equal("do the thing", fetched.Prompt);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetByTaskId_Returns_Ordered_By_RunNumber()
|
|
{
|
|
await _repo.AddAsync(MakeRun(1));
|
|
await _repo.AddAsync(MakeRun(2, isRetry: true));
|
|
await _repo.AddAsync(MakeRun(3));
|
|
|
|
var runs = await _repo.GetByTaskIdAsync(_taskId);
|
|
Assert.Equal(3, runs.Count);
|
|
Assert.Equal(1, runs[0].RunNumber);
|
|
Assert.Equal(2, runs[1].RunNumber);
|
|
Assert.Equal(3, runs[2].RunNumber);
|
|
Assert.True(runs[1].IsRetry);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetLatestByTaskId_Returns_Highest_RunNumber()
|
|
{
|
|
await _repo.AddAsync(MakeRun(1));
|
|
var run2 = MakeRun(2);
|
|
run2.SessionId = "sess-abc";
|
|
await _repo.AddAsync(run2);
|
|
|
|
var latest = await _repo.GetLatestByTaskIdAsync(_taskId);
|
|
Assert.NotNull(latest);
|
|
Assert.Equal(2, latest.RunNumber);
|
|
Assert.Equal("sess-abc", latest.SessionId);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Update_Persists_Completion_Fields()
|
|
{
|
|
var run = MakeRun(1);
|
|
await _repo.AddAsync(run);
|
|
|
|
run.SessionId = "sess-123";
|
|
run.ResultMarkdown = "## Done";
|
|
run.StructuredOutputJson = """{"summary":"all good"}""";
|
|
run.ExitCode = 0;
|
|
run.TurnCount = 5;
|
|
run.TokensIn = 1200;
|
|
run.TokensOut = 800;
|
|
run.FinishedAt = DateTime.UtcNow;
|
|
await _repo.UpdateAsync(run);
|
|
|
|
var fetched = await _repo.GetByIdAsync(run.Id);
|
|
Assert.NotNull(fetched);
|
|
Assert.Equal("sess-123", fetched.SessionId);
|
|
Assert.Equal("## Done", fetched.ResultMarkdown);
|
|
Assert.Equal("""{"summary":"all good"}""", fetched.StructuredOutputJson);
|
|
Assert.Equal(0, fetched.ExitCode);
|
|
Assert.Equal(5, fetched.TurnCount);
|
|
Assert.Equal(1200, fetched.TokensIn);
|
|
Assert.Equal(800, fetched.TokensOut);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetLatestByTaskId_Returns_Null_When_No_Runs()
|
|
{
|
|
var latest = await _repo.GetLatestByTaskIdAsync(_taskId);
|
|
Assert.Null(latest);
|
|
}
|
|
|
|
private TaskRunEntity MakeRun(int runNumber, bool isRetry = false) => new()
|
|
{
|
|
Id = Guid.NewGuid().ToString(),
|
|
TaskId = _taskId,
|
|
RunNumber = runNumber,
|
|
IsRetry = isRetry,
|
|
Prompt = "do the thing",
|
|
StartedAt = DateTime.UtcNow,
|
|
};
|
|
|
|
public void Dispose() => _db.Dispose();
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Run tests to verify they fail**
|
|
|
|
Run:
|
|
```bash
|
|
dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~TaskRunRepository" -v minimal
|
|
```
|
|
|
|
Expected: build error — `TaskRunRepository` does not exist.
|
|
|
|
- [ ] **Step 3: Implement TaskRunRepository**
|
|
|
|
```csharp
|
|
using ClaudeDo.Data.Models;
|
|
using Microsoft.Data.Sqlite;
|
|
|
|
namespace ClaudeDo.Data.Repositories;
|
|
|
|
public sealed class TaskRunRepository
|
|
{
|
|
private readonly SqliteConnectionFactory _factory;
|
|
|
|
public TaskRunRepository(SqliteConnectionFactory factory) => _factory = factory;
|
|
|
|
public async Task AddAsync(TaskRunEntity entity, CancellationToken ct = default)
|
|
{
|
|
await using var conn = _factory.Open();
|
|
await using var cmd = conn.CreateCommand();
|
|
cmd.CommandText = """
|
|
INSERT INTO task_runs (id, task_id, run_number, session_id, is_retry, prompt,
|
|
result_markdown, structured_output, error_markdown,
|
|
exit_code, turn_count, tokens_in, tokens_out,
|
|
log_path, started_at, finished_at)
|
|
VALUES (@id, @task_id, @run_number, @session_id, @is_retry, @prompt,
|
|
@result_markdown, @structured_output, @error_markdown,
|
|
@exit_code, @turn_count, @tokens_in, @tokens_out,
|
|
@log_path, @started_at, @finished_at)
|
|
""";
|
|
BindRun(cmd, entity);
|
|
await cmd.ExecuteNonQueryAsync(ct);
|
|
}
|
|
|
|
public async Task UpdateAsync(TaskRunEntity entity, CancellationToken ct = default)
|
|
{
|
|
await using var conn = _factory.Open();
|
|
await using var cmd = conn.CreateCommand();
|
|
cmd.CommandText = """
|
|
UPDATE task_runs SET session_id = @session_id, result_markdown = @result_markdown,
|
|
structured_output = @structured_output, error_markdown = @error_markdown,
|
|
exit_code = @exit_code, turn_count = @turn_count,
|
|
tokens_in = @tokens_in, tokens_out = @tokens_out,
|
|
log_path = @log_path, finished_at = @finished_at
|
|
WHERE id = @id
|
|
""";
|
|
cmd.Parameters.AddWithValue("@id", entity.Id);
|
|
cmd.Parameters.AddWithValue("@session_id", (object?)entity.SessionId ?? DBNull.Value);
|
|
cmd.Parameters.AddWithValue("@result_markdown", (object?)entity.ResultMarkdown ?? DBNull.Value);
|
|
cmd.Parameters.AddWithValue("@structured_output", (object?)entity.StructuredOutputJson ?? DBNull.Value);
|
|
cmd.Parameters.AddWithValue("@error_markdown", (object?)entity.ErrorMarkdown ?? DBNull.Value);
|
|
cmd.Parameters.AddWithValue("@exit_code", entity.ExitCode.HasValue ? entity.ExitCode.Value : DBNull.Value);
|
|
cmd.Parameters.AddWithValue("@turn_count", entity.TurnCount.HasValue ? entity.TurnCount.Value : DBNull.Value);
|
|
cmd.Parameters.AddWithValue("@tokens_in", entity.TokensIn.HasValue ? entity.TokensIn.Value : DBNull.Value);
|
|
cmd.Parameters.AddWithValue("@tokens_out", entity.TokensOut.HasValue ? entity.TokensOut.Value : DBNull.Value);
|
|
cmd.Parameters.AddWithValue("@log_path", (object?)entity.LogPath ?? DBNull.Value);
|
|
cmd.Parameters.AddWithValue("@finished_at", entity.FinishedAt.HasValue ? entity.FinishedAt.Value.ToString("o") : DBNull.Value);
|
|
await cmd.ExecuteNonQueryAsync(ct);
|
|
}
|
|
|
|
public async Task<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:
|
|
```bash
|
|
dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~TaskRunRepository" -v minimal
|
|
```
|
|
|
|
Expected: 5 tests pass.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add src/ClaudeDo.Data/Repositories/TaskRunRepository.cs tests/ClaudeDo.Worker.Tests/Repositories/TaskRunRepositoryTests.cs
|
|
git commit -m "feat(data): add TaskRunRepository with CRUD and query methods"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 4: ListRepository — Config methods
|
|
|
|
**Files:**
|
|
- Modify: `src/ClaudeDo.Data/Repositories/ListRepository.cs:116-124`
|
|
- Create: `tests/ClaudeDo.Worker.Tests/Repositories/ListRepositoryConfigTests.cs`
|
|
|
|
- [ ] **Step 1: Write failing tests**
|
|
|
|
```csharp
|
|
using ClaudeDo.Data.Models;
|
|
using ClaudeDo.Data.Repositories;
|
|
using ClaudeDo.Worker.Tests.Infrastructure;
|
|
|
|
namespace ClaudeDo.Worker.Tests.Repositories;
|
|
|
|
public sealed class ListRepositoryConfigTests : IDisposable
|
|
{
|
|
private readonly DbFixture _db = new();
|
|
private readonly ListRepository _repo;
|
|
private readonly string _listId;
|
|
|
|
public ListRepositoryConfigTests()
|
|
{
|
|
_repo = new ListRepository(_db.Factory);
|
|
_listId = Guid.NewGuid().ToString();
|
|
_repo.AddAsync(new ListEntity
|
|
{
|
|
Id = _listId, Name = "Test", CreatedAt = DateTime.UtcNow
|
|
}).GetAwaiter().GetResult();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetConfig_Returns_Null_When_No_Config()
|
|
{
|
|
var config = await _repo.GetConfigAsync(_listId);
|
|
Assert.Null(config);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SetConfig_And_GetConfig_Roundtrips()
|
|
{
|
|
var config = new ListConfigEntity
|
|
{
|
|
ListId = _listId,
|
|
Model = "sonnet-4-6",
|
|
SystemPrompt = "You are helpful.",
|
|
AgentPath = "/home/user/.todo-app/agents/dev.md",
|
|
};
|
|
await _repo.SetConfigAsync(config);
|
|
|
|
var fetched = await _repo.GetConfigAsync(_listId);
|
|
Assert.NotNull(fetched);
|
|
Assert.Equal("sonnet-4-6", fetched.Model);
|
|
Assert.Equal("You are helpful.", fetched.SystemPrompt);
|
|
Assert.Equal("/home/user/.todo-app/agents/dev.md", fetched.AgentPath);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SetConfig_Upserts_On_Duplicate()
|
|
{
|
|
await _repo.SetConfigAsync(new ListConfigEntity
|
|
{
|
|
ListId = _listId, Model = "opus-4-6"
|
|
});
|
|
await _repo.SetConfigAsync(new ListConfigEntity
|
|
{
|
|
ListId = _listId, Model = "haiku-4-5"
|
|
});
|
|
|
|
var fetched = await _repo.GetConfigAsync(_listId);
|
|
Assert.NotNull(fetched);
|
|
Assert.Equal("haiku-4-5", fetched.Model);
|
|
}
|
|
|
|
public void Dispose() => _db.Dispose();
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Run tests to verify they fail**
|
|
|
|
Run:
|
|
```bash
|
|
dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~ListRepositoryConfig" -v minimal
|
|
```
|
|
|
|
Expected: build error — methods don't exist on `ListRepository`.
|
|
|
|
- [ ] **Step 3: Add GetConfigAsync and SetConfigAsync to ListRepository**
|
|
|
|
Add after the `RemoveTagAsync` method (before the `ReadList` helper):
|
|
|
|
```csharp
|
|
public async Task<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:
|
|
```bash
|
|
dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~ListRepositoryConfig" -v minimal
|
|
```
|
|
|
|
Expected: 3 tests pass.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add src/ClaudeDo.Data/Repositories/ListRepository.cs tests/ClaudeDo.Worker.Tests/Repositories/ListRepositoryConfigTests.cs
|
|
git commit -m "feat(data): add GetConfigAsync and SetConfigAsync to ListRepository"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 5: TaskRepository — Read/write config columns
|
|
|
|
**Files:**
|
|
- Modify: `src/ClaudeDo.Data/Repositories/TaskRepository.cs:39-51,53-66,77-87,89-101,266-280,282-296`
|
|
|
|
The `AddAsync`, `UpdateAsync`, `GetByIdAsync`, `GetByListAsync`, `BindTask`, and `ReadTask` methods must include the three new columns (`model`, `system_prompt`, `agent_path`).
|
|
|
|
- [ ] **Step 1: Update BindTask to include new columns**
|
|
|
|
Replace the `BindTask` method:
|
|
|
|
```csharp
|
|
private static void BindTask(SqliteCommand cmd, TaskEntity e)
|
|
{
|
|
cmd.Parameters.AddWithValue("@id", e.Id);
|
|
cmd.Parameters.AddWithValue("@list_id", e.ListId);
|
|
cmd.Parameters.AddWithValue("@title", e.Title);
|
|
cmd.Parameters.AddWithValue("@description", (object?)e.Description ?? DBNull.Value);
|
|
cmd.Parameters.AddWithValue("@status", ToDb(e.Status));
|
|
cmd.Parameters.AddWithValue("@scheduled_for", e.ScheduledFor.HasValue ? e.ScheduledFor.Value.ToString("o") : DBNull.Value);
|
|
cmd.Parameters.AddWithValue("@result", (object?)e.Result ?? DBNull.Value);
|
|
cmd.Parameters.AddWithValue("@log_path", (object?)e.LogPath ?? DBNull.Value);
|
|
cmd.Parameters.AddWithValue("@created_at", e.CreatedAt.ToString("o"));
|
|
cmd.Parameters.AddWithValue("@started_at", e.StartedAt.HasValue ? e.StartedAt.Value.ToString("o") : DBNull.Value);
|
|
cmd.Parameters.AddWithValue("@finished_at", e.FinishedAt.HasValue ? e.FinishedAt.Value.ToString("o") : DBNull.Value);
|
|
cmd.Parameters.AddWithValue("@commit_type", e.CommitType);
|
|
cmd.Parameters.AddWithValue("@model", (object?)e.Model ?? DBNull.Value);
|
|
cmd.Parameters.AddWithValue("@system_prompt", (object?)e.SystemPrompt ?? DBNull.Value);
|
|
cmd.Parameters.AddWithValue("@agent_path", (object?)e.AgentPath ?? DBNull.Value);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Update AddAsync SQL to include new columns**
|
|
|
|
```csharp
|
|
cmd.CommandText = """
|
|
INSERT INTO tasks (id, list_id, title, description, status, scheduled_for,
|
|
result, log_path, created_at, started_at, finished_at, commit_type,
|
|
model, system_prompt, agent_path)
|
|
VALUES (@id, @list_id, @title, @description, @status, @scheduled_for,
|
|
@result, @log_path, @created_at, @started_at, @finished_at, @commit_type,
|
|
@model, @system_prompt, @agent_path)
|
|
""";
|
|
```
|
|
|
|
- [ ] **Step 3: Update UpdateAsync SQL to include new columns**
|
|
|
|
```csharp
|
|
cmd.CommandText = """
|
|
UPDATE tasks SET list_id = @list_id, title = @title, description = @description,
|
|
status = @status, scheduled_for = @scheduled_for, result = @result,
|
|
log_path = @log_path, started_at = @started_at,
|
|
finished_at = @finished_at, commit_type = @commit_type,
|
|
model = @model, system_prompt = @system_prompt, agent_path = @agent_path
|
|
WHERE id = @id
|
|
""";
|
|
```
|
|
|
|
- [ ] **Step 4: Update all SELECT statements and ReadTask**
|
|
|
|
Every `SELECT` in `GetByIdAsync`, `GetByListAsync`, and `GetNextQueuedAgentTaskAsync` must add the three new columns. Update the column list to:
|
|
|
|
```
|
|
SELECT id, list_id, title, description, status, scheduled_for, result, log_path, created_at, started_at, finished_at, commit_type, model, system_prompt, agent_path FROM tasks ...
|
|
```
|
|
|
|
Update `ReadTask`:
|
|
|
|
```csharp
|
|
private static TaskEntity ReadTask(SqliteDataReader r) => new()
|
|
{
|
|
Id = r.GetString(0),
|
|
ListId = r.GetString(1),
|
|
Title = r.GetString(2),
|
|
Description = r.IsDBNull(3) ? null : r.GetString(3),
|
|
Status = FromDb(r.GetString(4)),
|
|
ScheduledFor = r.IsDBNull(5) ? null : DateTime.Parse(r.GetString(5)),
|
|
Result = r.IsDBNull(6) ? null : r.GetString(6),
|
|
LogPath = r.IsDBNull(7) ? null : r.GetString(7),
|
|
CreatedAt = DateTime.Parse(r.GetString(8)),
|
|
StartedAt = r.IsDBNull(9) ? null : DateTime.Parse(r.GetString(9)),
|
|
FinishedAt = r.IsDBNull(10) ? null : DateTime.Parse(r.GetString(10)),
|
|
CommitType = r.GetString(11),
|
|
Model = r.IsDBNull(12) ? null : r.GetString(12),
|
|
SystemPrompt = r.IsDBNull(13) ? null : r.GetString(13),
|
|
AgentPath = r.IsDBNull(14) ? null : r.GetString(14),
|
|
};
|
|
```
|
|
|
|
- [ ] **Step 5: Run all existing TaskRepository tests**
|
|
|
|
Run:
|
|
```bash
|
|
dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~TaskRepositoryTests" -v minimal
|
|
```
|
|
|
|
Expected: all existing tests still pass (new columns are nullable, backward compatible).
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
```bash
|
|
git add src/ClaudeDo.Data/Repositories/TaskRepository.cs
|
|
git commit -m "feat(data): extend TaskRepository with model, system_prompt, agent_path columns"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 6: ClaudeArgsBuilder — CLI argument construction
|
|
|
|
**Files:**
|
|
- Create: `src/ClaudeDo.Worker/Runner/ClaudeArgsBuilder.cs`
|
|
- Create: `tests/ClaudeDo.Worker.Tests/Runner/ClaudeArgsBuilderTests.cs`
|
|
|
|
- [ ] **Step 1: Write failing tests**
|
|
|
|
```csharp
|
|
using ClaudeDo.Worker.Runner;
|
|
|
|
namespace ClaudeDo.Worker.Tests.Runner;
|
|
|
|
public sealed class ClaudeArgsBuilderTests
|
|
{
|
|
private readonly ClaudeArgsBuilder _builder = new();
|
|
|
|
[Fact]
|
|
public void Default_Config_Produces_Base_Args()
|
|
{
|
|
var args = _builder.Build(new ClaudeRunConfig(null, null, null, null));
|
|
Assert.Contains("-p", args);
|
|
Assert.Contains("--output-format stream-json", args);
|
|
Assert.Contains("--verbose", args);
|
|
Assert.Contains("--dangerously-skip-permissions", args);
|
|
Assert.Contains("--json-schema", args);
|
|
Assert.DoesNotContain("--model", args);
|
|
Assert.DoesNotContain("--append-system-prompt", args);
|
|
Assert.DoesNotContain("--agents", args);
|
|
Assert.DoesNotContain("--resume", args);
|
|
}
|
|
|
|
[Fact]
|
|
public void Model_Adds_Model_Flag()
|
|
{
|
|
var args = _builder.Build(new ClaudeRunConfig("sonnet-4-6", null, null, null));
|
|
Assert.Contains("--model sonnet-4-6", args);
|
|
}
|
|
|
|
[Fact]
|
|
public void SystemPrompt_Adds_Append_System_Prompt_Flag()
|
|
{
|
|
var args = _builder.Build(new ClaudeRunConfig(null, "Be concise.", null, null));
|
|
Assert.Contains("--append-system-prompt", args);
|
|
Assert.Contains("Be concise.", args);
|
|
}
|
|
|
|
[Fact]
|
|
public void AgentPath_Adds_Agents_Flag_As_Json()
|
|
{
|
|
var args = _builder.Build(new ClaudeRunConfig(null, null, "/path/to/agent.md", null));
|
|
Assert.Contains("--agents", args);
|
|
Assert.Contains("/path/to/agent.md", args);
|
|
}
|
|
|
|
[Fact]
|
|
public void ResumeSessionId_Adds_Resume_Flag()
|
|
{
|
|
var args = _builder.Build(new ClaudeRunConfig(null, null, null, "sess-abc-123"));
|
|
Assert.Contains("--resume sess-abc-123", args);
|
|
}
|
|
|
|
[Fact]
|
|
public void All_Options_Set_Includes_All_Flags()
|
|
{
|
|
var args = _builder.Build(new ClaudeRunConfig("opus-4-6", "Be thorough.", "/agents/dev.md", "sess-xyz"));
|
|
Assert.Contains("--model opus-4-6", args);
|
|
Assert.Contains("--append-system-prompt", args);
|
|
Assert.Contains("--agents", args);
|
|
Assert.Contains("--resume sess-xyz", args);
|
|
Assert.Contains("--json-schema", args);
|
|
}
|
|
|
|
[Fact]
|
|
public void SystemPrompt_With_Quotes_Is_Escaped()
|
|
{
|
|
var args = _builder.Build(new ClaudeRunConfig(null, """Don't say "hello".""", null, null));
|
|
// Should not break argument parsing — the prompt is passed as a single argument
|
|
Assert.Contains("--append-system-prompt", args);
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Run tests to verify they fail**
|
|
|
|
Run:
|
|
```bash
|
|
dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~ClaudeArgsBuilder" -v minimal
|
|
```
|
|
|
|
Expected: build error — `ClaudeArgsBuilder` and `ClaudeRunConfig` don't exist.
|
|
|
|
- [ ] **Step 3: Implement ClaudeArgsBuilder**
|
|
|
|
```csharp
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
|
|
namespace ClaudeDo.Worker.Runner;
|
|
|
|
public sealed record ClaudeRunConfig(
|
|
string? Model,
|
|
string? SystemPrompt,
|
|
string? AgentPath,
|
|
string? ResumeSessionId
|
|
);
|
|
|
|
public sealed class ClaudeArgsBuilder
|
|
{
|
|
private static readonly string ResultSchema = JsonSerializer.Serialize(new
|
|
{
|
|
type = "object",
|
|
properties = new
|
|
{
|
|
summary = new { type = "string" },
|
|
files_changed = new { type = "array", items = new { type = "string" } },
|
|
commit_type = new { type = "string" },
|
|
},
|
|
required = new[] { "summary" },
|
|
});
|
|
|
|
public string Build(ClaudeRunConfig config)
|
|
{
|
|
var args = new List<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:
|
|
```bash
|
|
dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~ClaudeArgsBuilder" -v minimal
|
|
```
|
|
|
|
Expected: 7 tests pass.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add src/ClaudeDo.Worker/Runner/ClaudeArgsBuilder.cs tests/ClaudeDo.Worker.Tests/Runner/ClaudeArgsBuilderTests.cs
|
|
git commit -m "feat(worker): add ClaudeArgsBuilder for dynamic CLI argument construction"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 7: StreamAnalyzer — Rich NDJSON stream parsing
|
|
|
|
**Files:**
|
|
- Create: `src/ClaudeDo.Worker/Runner/StreamAnalyzer.cs`
|
|
- Create: `src/ClaudeDo.Worker/Runner/StreamResult.cs`
|
|
- Create: `tests/ClaudeDo.Worker.Tests/Runner/StreamAnalyzerTests.cs`
|
|
|
|
- [ ] **Step 1: Write failing tests**
|
|
|
|
```csharp
|
|
using ClaudeDo.Worker.Runner;
|
|
|
|
namespace ClaudeDo.Worker.Tests.Runner;
|
|
|
|
public sealed class StreamAnalyzerTests
|
|
{
|
|
[Fact]
|
|
public void Extracts_Result_Markdown()
|
|
{
|
|
var analyzer = new StreamAnalyzer();
|
|
analyzer.ProcessLine("""{"type":"result","result":"## Done","session_id":"sess-1"}""");
|
|
|
|
var result = analyzer.GetResult();
|
|
Assert.Equal("## Done", result.ResultMarkdown);
|
|
Assert.Equal("sess-1", result.SessionId);
|
|
}
|
|
|
|
[Fact]
|
|
public void Extracts_Structured_Output()
|
|
{
|
|
var analyzer = new StreamAnalyzer();
|
|
analyzer.ProcessLine("""{"type":"result","result":"ok","structured_output":{"summary":"all good"},"session_id":"s1"}""");
|
|
|
|
var result = analyzer.GetResult();
|
|
Assert.Equal("ok", result.ResultMarkdown);
|
|
Assert.Contains("all good", result.StructuredOutputJson);
|
|
}
|
|
|
|
[Fact]
|
|
public void Counts_Assistant_Turns()
|
|
{
|
|
var analyzer = new StreamAnalyzer();
|
|
analyzer.ProcessLine("""{"type":"assistant","message":"hi"}""");
|
|
analyzer.ProcessLine("""{"type":"assistant","message":"working on it"}""");
|
|
analyzer.ProcessLine("""{"type":"result","result":"done","session_id":"s1"}""");
|
|
|
|
var result = analyzer.GetResult();
|
|
Assert.Equal(2, result.TurnCount);
|
|
}
|
|
|
|
[Fact]
|
|
public void Accumulates_Token_Usage()
|
|
{
|
|
var analyzer = new StreamAnalyzer();
|
|
analyzer.ProcessLine("""{"type":"stream_event","event":{"type":"message_start","message":{"usage":{"input_tokens":100,"output_tokens":50}}}}""");
|
|
analyzer.ProcessLine("""{"type":"stream_event","event":{"type":"message_start","message":{"usage":{"input_tokens":200,"output_tokens":80}}}}""");
|
|
analyzer.ProcessLine("""{"type":"result","result":"done","session_id":"s1"}""");
|
|
|
|
var result = analyzer.GetResult();
|
|
Assert.Equal(300, result.TokensIn);
|
|
Assert.Equal(130, result.TokensOut);
|
|
}
|
|
|
|
[Fact]
|
|
public void Counts_Api_Retry_Events()
|
|
{
|
|
var analyzer = new StreamAnalyzer();
|
|
analyzer.ProcessLine("""{"type":"system","subtype":"api_retry","attempt":1,"error":"rate_limit"}""");
|
|
analyzer.ProcessLine("""{"type":"system","subtype":"api_retry","attempt":2,"error":"rate_limit"}""");
|
|
analyzer.ProcessLine("""{"type":"result","result":"done","session_id":"s1"}""");
|
|
|
|
var result = analyzer.GetResult();
|
|
Assert.Equal(2, result.ApiRetryCount);
|
|
}
|
|
|
|
[Fact]
|
|
public void Malformed_Json_Is_Ignored()
|
|
{
|
|
var analyzer = new StreamAnalyzer();
|
|
analyzer.ProcessLine("not json {{{");
|
|
analyzer.ProcessLine("");
|
|
analyzer.ProcessLine(" ");
|
|
|
|
var result = analyzer.GetResult();
|
|
Assert.Null(result.ResultMarkdown);
|
|
Assert.Equal(0, result.TurnCount);
|
|
}
|
|
|
|
[Fact]
|
|
public void No_Result_Event_Returns_Null_Fields()
|
|
{
|
|
var analyzer = new StreamAnalyzer();
|
|
analyzer.ProcessLine("""{"type":"assistant","message":"hi"}""");
|
|
|
|
var result = analyzer.GetResult();
|
|
Assert.Null(result.ResultMarkdown);
|
|
Assert.Null(result.SessionId);
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Run tests to verify they fail**
|
|
|
|
Run:
|
|
```bash
|
|
dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~StreamAnalyzer" -v minimal
|
|
```
|
|
|
|
Expected: build error — `StreamAnalyzer` and `StreamResult` don't exist.
|
|
|
|
- [ ] **Step 3: Create StreamResult**
|
|
|
|
```csharp
|
|
namespace ClaudeDo.Worker.Runner;
|
|
|
|
public sealed class StreamResult
|
|
{
|
|
public string? ResultMarkdown { get; set; }
|
|
public string? StructuredOutputJson { get; set; }
|
|
public string? SessionId { get; set; }
|
|
public int TurnCount { get; set; }
|
|
public int TokensIn { get; set; }
|
|
public int TokensOut { get; set; }
|
|
public int ApiRetryCount { get; set; }
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Implement StreamAnalyzer**
|
|
|
|
```csharp
|
|
using System.Text.Json;
|
|
|
|
namespace ClaudeDo.Worker.Runner;
|
|
|
|
public sealed class StreamAnalyzer
|
|
{
|
|
private string? _resultMarkdown;
|
|
private string? _structuredOutputJson;
|
|
private string? _sessionId;
|
|
private int _turnCount;
|
|
private int _tokensIn;
|
|
private int _tokensOut;
|
|
private int _apiRetryCount;
|
|
|
|
public void ProcessLine(string ndjsonLine)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(ndjsonLine))
|
|
return;
|
|
|
|
try
|
|
{
|
|
using var doc = JsonDocument.Parse(ndjsonLine);
|
|
var root = doc.RootElement;
|
|
|
|
if (!root.TryGetProperty("type", out var typeProp))
|
|
return;
|
|
|
|
var type = typeProp.GetString();
|
|
|
|
switch (type)
|
|
{
|
|
case "result":
|
|
if (root.TryGetProperty("result", out var resultProp))
|
|
_resultMarkdown = resultProp.GetString();
|
|
if (root.TryGetProperty("structured_output", out var structuredProp))
|
|
_structuredOutputJson = structuredProp.ToString();
|
|
if (root.TryGetProperty("session_id", out var sessionProp))
|
|
_sessionId = sessionProp.GetString();
|
|
break;
|
|
|
|
case "assistant":
|
|
_turnCount++;
|
|
break;
|
|
|
|
case "system":
|
|
if (root.TryGetProperty("subtype", out var subtypeProp) &&
|
|
subtypeProp.GetString() == "api_retry")
|
|
_apiRetryCount++;
|
|
break;
|
|
|
|
case "stream_event":
|
|
TryAccumulateUsage(root);
|
|
break;
|
|
}
|
|
}
|
|
catch (JsonException)
|
|
{
|
|
// Malformed JSON — skip.
|
|
}
|
|
}
|
|
|
|
public StreamResult GetResult() => new()
|
|
{
|
|
ResultMarkdown = _resultMarkdown,
|
|
StructuredOutputJson = _structuredOutputJson,
|
|
SessionId = _sessionId,
|
|
TurnCount = _turnCount,
|
|
TokensIn = _tokensIn,
|
|
TokensOut = _tokensOut,
|
|
ApiRetryCount = _apiRetryCount,
|
|
};
|
|
|
|
private void TryAccumulateUsage(JsonElement root)
|
|
{
|
|
// Path: .event.message.usage.input_tokens / .event.message.usage.output_tokens
|
|
// Also: .event.delta.usage for delta events
|
|
if (!root.TryGetProperty("event", out var eventProp))
|
|
return;
|
|
|
|
if (eventProp.TryGetProperty("message", out var msgProp) &&
|
|
msgProp.TryGetProperty("usage", out var usageProp))
|
|
{
|
|
AccumulateUsage(usageProp);
|
|
}
|
|
}
|
|
|
|
private void AccumulateUsage(JsonElement usage)
|
|
{
|
|
if (usage.TryGetProperty("input_tokens", out var inp))
|
|
_tokensIn += inp.GetInt32();
|
|
if (usage.TryGetProperty("output_tokens", out var outp))
|
|
_tokensOut += outp.GetInt32();
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 5: Run tests to verify they pass**
|
|
|
|
Run:
|
|
```bash
|
|
dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~StreamAnalyzer" -v minimal
|
|
```
|
|
|
|
Expected: 7 tests pass.
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
```bash
|
|
git add src/ClaudeDo.Worker/Runner/StreamResult.cs src/ClaudeDo.Worker/Runner/StreamAnalyzer.cs tests/ClaudeDo.Worker.Tests/Runner/StreamAnalyzerTests.cs
|
|
git commit -m "feat(worker): add StreamAnalyzer for rich NDJSON stream parsing"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 8: RunResult — Extend with stream metrics
|
|
|
|
**Files:**
|
|
- Modify: `src/ClaudeDo.Worker/Runner/RunResult.cs:1-11`
|
|
|
|
- [ ] **Step 1: Update RunResult**
|
|
|
|
Replace entire file:
|
|
|
|
```csharp
|
|
namespace ClaudeDo.Worker.Runner;
|
|
|
|
public sealed class RunResult
|
|
{
|
|
public required int ExitCode { get; init; }
|
|
public string? ResultMarkdown { get; init; }
|
|
public string? ErrorMarkdown { get; init; }
|
|
public string? StructuredOutputJson { get; init; }
|
|
public string? SessionId { get; init; }
|
|
public int TurnCount { get; init; }
|
|
public int TokensIn { get; init; }
|
|
public int TokensOut { get; init; }
|
|
|
|
public bool IsSuccess => ExitCode == 0 && ResultMarkdown is not null;
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Build to check for downstream breakage**
|
|
|
|
Run:
|
|
```bash
|
|
dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj
|
|
```
|
|
|
|
Expected: build succeeds — new properties are optional (init-only with defaults). The existing `new RunResult { ExitCode = ..., ResultMarkdown = ... }` sites still compile.
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add src/ClaudeDo.Worker/Runner/RunResult.cs
|
|
git commit -m "feat(worker): extend RunResult with structured output, session ID, and token metrics"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 9: IClaudeProcess + ClaudeProcess — Simplified interface
|
|
|
|
**Files:**
|
|
- Modify: `src/ClaudeDo.Worker/Runner/IClaudeProcess.cs:1-12`
|
|
- Modify: `src/ClaudeDo.Worker/Runner/ClaudeProcess.cs:1-96`
|
|
|
|
- [ ] **Step 1: Update IClaudeProcess**
|
|
|
|
Replace entire file:
|
|
|
|
```csharp
|
|
namespace ClaudeDo.Worker.Runner;
|
|
|
|
public interface IClaudeProcess
|
|
{
|
|
Task<RunResult> RunAsync(
|
|
string arguments,
|
|
string prompt,
|
|
string workingDirectory,
|
|
Func<string, Task> onStdoutLine,
|
|
CancellationToken ct);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Update ClaudeProcess**
|
|
|
|
Replace entire file:
|
|
|
|
```csharp
|
|
using System.Diagnostics;
|
|
using System.Text;
|
|
using ClaudeDo.Worker.Config;
|
|
|
|
namespace ClaudeDo.Worker.Runner;
|
|
|
|
public sealed class ClaudeProcess : IClaudeProcess
|
|
{
|
|
private readonly WorkerConfig _cfg;
|
|
private readonly ILogger<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:
|
|
|
|
```csharp
|
|
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:
|
|
```bash
|
|
dotnet build ClaudeDo.slnx && dotnet test tests/ClaudeDo.Worker.Tests -v minimal
|
|
```
|
|
|
|
Expected: all tests pass.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add src/ClaudeDo.Worker/Runner/IClaudeProcess.cs src/ClaudeDo.Worker/Runner/ClaudeProcess.cs tests/ClaudeDo.Worker.Tests/Services/QueueServiceTests.cs
|
|
git commit -m "refactor(worker): simplify ClaudeProcess to accept pre-built args and use StreamAnalyzer"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 10: AgentFileService — Filesystem agent management
|
|
|
|
**Files:**
|
|
- Create: `src/ClaudeDo.Worker/Services/AgentFileService.cs`
|
|
- Create: `tests/ClaudeDo.Worker.Tests/Services/AgentFileServiceTests.cs`
|
|
|
|
- [ ] **Step 1: Write failing tests**
|
|
|
|
```csharp
|
|
using ClaudeDo.Worker.Services;
|
|
|
|
namespace ClaudeDo.Worker.Tests.Services;
|
|
|
|
public sealed class AgentFileServiceTests : IDisposable
|
|
{
|
|
private readonly string _agentDir;
|
|
private readonly AgentFileService _service;
|
|
|
|
public AgentFileServiceTests()
|
|
{
|
|
_agentDir = Path.Combine(Path.GetTempPath(), $"claudedo_agents_test_{Guid.NewGuid():N}");
|
|
Directory.CreateDirectory(_agentDir);
|
|
_service = new AgentFileService(_agentDir);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Scan_Returns_Empty_For_Empty_Directory()
|
|
{
|
|
var agents = await _service.ScanAsync();
|
|
Assert.Empty(agents);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Scan_Parses_Frontmatter()
|
|
{
|
|
var content = """
|
|
---
|
|
name: Test Agent
|
|
description: A test agent for unit tests
|
|
---
|
|
|
|
You are a test agent.
|
|
""";
|
|
await File.WriteAllTextAsync(Path.Combine(_agentDir, "test.md"), content);
|
|
|
|
var agents = await _service.ScanAsync();
|
|
Assert.Single(agents);
|
|
Assert.Equal("Test Agent", agents[0].Name);
|
|
Assert.Equal("A test agent for unit tests", agents[0].Description);
|
|
Assert.EndsWith("test.md", agents[0].Path);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Scan_Uses_Filename_When_No_Frontmatter()
|
|
{
|
|
await File.WriteAllTextAsync(Path.Combine(_agentDir, "simple.md"), "Just instructions.");
|
|
|
|
var agents = await _service.ScanAsync();
|
|
Assert.Single(agents);
|
|
Assert.Equal("simple", agents[0].Name);
|
|
Assert.Equal("", agents[0].Description);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Write_And_Read_Roundtrips()
|
|
{
|
|
var path = Path.Combine(_agentDir, "new-agent.md");
|
|
var content = "---\nname: New\ndescription: Desc\n---\nBody";
|
|
await _service.WriteAsync(path, content);
|
|
|
|
var read = await _service.ReadAsync(path);
|
|
Assert.Equal(content, read);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Delete_Removes_File()
|
|
{
|
|
var path = Path.Combine(_agentDir, "to-delete.md");
|
|
await File.WriteAllTextAsync(path, "temp");
|
|
|
|
await _service.DeleteAsync(path);
|
|
Assert.False(File.Exists(path));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Scan_Ignores_Non_Md_Files()
|
|
{
|
|
await File.WriteAllTextAsync(Path.Combine(_agentDir, "notes.txt"), "not an agent");
|
|
await File.WriteAllTextAsync(Path.Combine(_agentDir, "agent.md"), "---\nname: Real\ndescription: Yes\n---\nBody");
|
|
|
|
var agents = await _service.ScanAsync();
|
|
Assert.Single(agents);
|
|
Assert.Equal("Real", agents[0].Name);
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
try { Directory.Delete(_agentDir, true); } catch { }
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Run tests to verify they fail**
|
|
|
|
Run:
|
|
```bash
|
|
dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~AgentFileService" -v minimal
|
|
```
|
|
|
|
Expected: build error — `AgentFileService` doesn't exist.
|
|
|
|
- [ ] **Step 3: Implement AgentFileService**
|
|
|
|
```csharp
|
|
using ClaudeDo.Data.Models;
|
|
|
|
namespace ClaudeDo.Worker.Services;
|
|
|
|
public sealed class AgentFileService
|
|
{
|
|
private readonly string _agentsDir;
|
|
|
|
public AgentFileService(string agentsDir)
|
|
{
|
|
_agentsDir = agentsDir;
|
|
}
|
|
|
|
public Task<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:
|
|
```bash
|
|
dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~AgentFileService" -v minimal
|
|
```
|
|
|
|
Expected: 6 tests pass.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add src/ClaudeDo.Worker/Services/AgentFileService.cs tests/ClaudeDo.Worker.Tests/Services/AgentFileServiceTests.cs
|
|
git commit -m "feat(worker): add AgentFileService for filesystem agent management"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 11: TaskRunner — Refactor with retry/continue and config resolution
|
|
|
|
**Files:**
|
|
- Modify: `src/ClaudeDo.Worker/Runner/TaskRunner.cs:1-149`
|
|
|
|
This is the largest change. TaskRunner gains config resolution, task_runs tracking, auto-retry, and a new `ContinueAsync` method.
|
|
|
|
- [ ] **Step 1: Rewrite TaskRunner**
|
|
|
|
Replace entire file:
|
|
|
|
```csharp
|
|
using ClaudeDo.Data.Models;
|
|
using ClaudeDo.Data.Repositories;
|
|
using ClaudeDo.Worker.Config;
|
|
using ClaudeDo.Worker.Hub;
|
|
|
|
namespace ClaudeDo.Worker.Runner;
|
|
|
|
public sealed class TaskRunner
|
|
{
|
|
private readonly IClaudeProcess _claude;
|
|
private readonly TaskRepository _taskRepo;
|
|
private readonly TaskRunRepository _runRepo;
|
|
private readonly ListRepository _listRepo;
|
|
private readonly WorktreeRepository _wtRepo; // for ContinueAsync worktree lookup
|
|
private readonly HubBroadcaster _broadcaster;
|
|
private readonly WorktreeManager _wtManager;
|
|
private readonly ClaudeArgsBuilder _argsBuilder;
|
|
private readonly WorkerConfig _cfg;
|
|
private readonly ILogger<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:
|
|
```bash
|
|
dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj
|
|
```
|
|
|
|
Expected: may fail on missing DI registrations (Program.cs) — that's Task 12.
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add src/ClaudeDo.Worker/Runner/TaskRunner.cs
|
|
git commit -m "refactor(worker): rewrite TaskRunner with config resolution, retry, and continue support"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 12: HubBroadcaster + WorkerHub — New methods
|
|
|
|
**Files:**
|
|
- Modify: `src/ClaudeDo.Worker/Hub/HubBroadcaster.cs:1-25`
|
|
- Modify: `src/ClaudeDo.Worker/Hub/WorkerHub.cs:1-44`
|
|
|
|
- [ ] **Step 1: Add RunCreated broadcast to HubBroadcaster**
|
|
|
|
Add after the `TaskUpdated` method:
|
|
|
|
```csharp
|
|
public Task RunCreated(string taskId, int runNumber, bool isRetry) =>
|
|
_hub.Clients.All.SendAsync("RunCreated", taskId, runNumber, isRetry);
|
|
```
|
|
|
|
- [ ] **Step 2: Add ContinueTask, GetAgents, RefreshAgents to WorkerHub**
|
|
|
|
Update WorkerHub to inject `AgentFileService` and expose new methods. Replace entire file:
|
|
|
|
```csharp
|
|
using System.Reflection;
|
|
using ClaudeDo.Data.Models;
|
|
using ClaudeDo.Worker.Services;
|
|
using Microsoft.AspNetCore.SignalR;
|
|
|
|
namespace ClaudeDo.Worker.Hub;
|
|
|
|
public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
|
{
|
|
private static readonly string Version =
|
|
Assembly.GetExecutingAssembly().GetName().Version?.ToString(3) ?? "0.0.0";
|
|
|
|
private readonly QueueService _queue;
|
|
private readonly AgentFileService _agentService;
|
|
|
|
public WorkerHub(QueueService queue, AgentFileService agentService)
|
|
{
|
|
_queue = queue;
|
|
_agentService = agentService;
|
|
}
|
|
|
|
public string Ping() => $"pong v{Version}";
|
|
|
|
public IReadOnlyList<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:
|
|
```bash
|
|
dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj
|
|
```
|
|
|
|
Expected: may fail — `QueueService.ContinueTask` doesn't exist yet (Task 13).
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add src/ClaudeDo.Worker/Hub/HubBroadcaster.cs src/ClaudeDo.Worker/Hub/WorkerHub.cs
|
|
git commit -m "feat(worker): add ContinueTask, GetAgents, RefreshAgents hub methods and RunCreated broadcast"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 13: QueueService — Route ContinueTask
|
|
|
|
**Files:**
|
|
- Modify: `src/ClaudeDo.Worker/Services/QueueService.cs:57-76`
|
|
|
|
- [ ] **Step 1: Add ContinueTask method to QueueService**
|
|
|
|
Add after the `RunNow` method:
|
|
|
|
```csharp
|
|
public async Task<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:
|
|
```bash
|
|
dotnet build ClaudeDo.slnx
|
|
```
|
|
|
|
Expected: may fail due to missing DI registrations (Task 14).
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add src/ClaudeDo.Worker/Services/QueueService.cs
|
|
git commit -m "feat(worker): add ContinueTask routing to QueueService"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 14: Program.cs — DI registration for new services
|
|
|
|
**Files:**
|
|
- Modify: `src/ClaudeDo.Worker/Program.cs:1-47`
|
|
|
|
- [ ] **Step 1: Register new services**
|
|
|
|
Add after the existing `builder.Services.AddSingleton<WorktreeRepository>();` line:
|
|
|
|
```csharp
|
|
builder.Services.AddSingleton<TaskRunRepository>();
|
|
```
|
|
|
|
Add after the `builder.Services.AddSingleton<WorktreeManager>();` line:
|
|
|
|
```csharp
|
|
builder.Services.AddSingleton<ClaudeArgsBuilder>();
|
|
```
|
|
|
|
Add after the `builder.Services.AddSingleton<TaskRunner>();` line (before the QueueService block):
|
|
|
|
```csharp
|
|
// Agent file management.
|
|
var agentsDir = Path.Combine(ClaudeDo.Data.Paths.AppDataRoot(), "agents");
|
|
Directory.CreateDirectory(agentsDir);
|
|
builder.Services.AddSingleton(new AgentFileService(agentsDir));
|
|
```
|
|
|
|
Also add the using at the top:
|
|
|
|
```csharp
|
|
using ClaudeDo.Worker.Services;
|
|
```
|
|
|
|
- [ ] **Step 2: Build the full solution**
|
|
|
|
Run:
|
|
```bash
|
|
dotnet build ClaudeDo.slnx
|
|
```
|
|
|
|
Expected: clean build, no errors.
|
|
|
|
- [ ] **Step 3: Run all tests**
|
|
|
|
Run:
|
|
```bash
|
|
dotnet test tests/ClaudeDo.Worker.Tests -v minimal
|
|
```
|
|
|
|
Expected: all tests pass (some may need FakeClaudeProcess updates from Task 9 step 3).
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add src/ClaudeDo.Worker/Program.cs
|
|
git commit -m "feat(worker): register TaskRunRepository, ClaudeArgsBuilder, and AgentFileService in DI"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 15: Delete MessageParser + update tests
|
|
|
|
**Files:**
|
|
- Delete: `src/ClaudeDo.Worker/Runner/MessageParser.cs`
|
|
- Delete: `tests/ClaudeDo.Worker.Tests/Runner/MessageParserTests.cs`
|
|
|
|
- [ ] **Step 1: Verify no references to MessageParser remain**
|
|
|
|
Run:
|
|
```bash
|
|
grep -r "MessageParser" src/ tests/ --include="*.cs"
|
|
```
|
|
|
|
Expected: only hits in the files being deleted (ClaudeProcess no longer calls it after Task 9).
|
|
|
|
- [ ] **Step 2: Delete the files**
|
|
|
|
```bash
|
|
rm src/ClaudeDo.Worker/Runner/MessageParser.cs
|
|
rm tests/ClaudeDo.Worker.Tests/Runner/MessageParserTests.cs
|
|
```
|
|
|
|
- [ ] **Step 3: Build and run all tests**
|
|
|
|
Run:
|
|
```bash
|
|
dotnet build ClaudeDo.slnx && dotnet test tests/ClaudeDo.Worker.Tests -v minimal
|
|
```
|
|
|
|
Expected: clean build, all tests pass.
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add -A
|
|
git commit -m "refactor(worker): remove MessageParser (replaced by StreamAnalyzer)"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 16: Integration smoke test — Full run with retry
|
|
|
|
**Files:**
|
|
- Modify: `tests/ClaudeDo.Worker.Tests/Services/QueueServiceTests.cs`
|
|
|
|
- [ ] **Step 1: Add a test for auto-retry flow**
|
|
|
|
Add a new test to `QueueServiceTests`:
|
|
|
|
```csharp
|
|
[Fact]
|
|
public async Task RunNow_AutoRetries_On_Failure_With_SessionId()
|
|
{
|
|
var callCount = 0;
|
|
var fake = new FakeClaudeProcess((prompt, dir, args, onLine, ct) =>
|
|
{
|
|
callCount++;
|
|
if (callCount == 1)
|
|
{
|
|
return Task.FromResult(new RunResult
|
|
{
|
|
ExitCode = 1,
|
|
ErrorMarkdown = "something broke",
|
|
SessionId = "sess-retry-test",
|
|
});
|
|
}
|
|
return Task.FromResult(new RunResult
|
|
{
|
|
ExitCode = 0,
|
|
ResultMarkdown = "fixed it",
|
|
SessionId = "sess-retry-test",
|
|
});
|
|
});
|
|
|
|
// Build a QueueService with the fake and run a task through it.
|
|
// Verify callCount == 2 (initial + retry).
|
|
// Verify the task ends as "done".
|
|
Assert.Equal(2, callCount);
|
|
}
|
|
```
|
|
|
|
Note: this test skeleton needs the full QueueService wiring that's already in the test file. Follow the existing test patterns for `RunNow` — set up DbFixture, create list + task, construct QueueService with fake dependencies, call `RunNow`, await completion, assert.
|
|
|
|
- [ ] **Step 2: Run the test**
|
|
|
|
Run:
|
|
```bash
|
|
dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~AutoRetries" -v minimal
|
|
```
|
|
|
|
Expected: test passes.
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add tests/ClaudeDo.Worker.Tests/Services/QueueServiceTests.cs
|
|
git commit -m "test(worker): add integration test for auto-retry flow"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 17: Update Worker CLAUDE.md
|
|
|
|
**Files:**
|
|
- Modify: `src/ClaudeDo.Worker/CLAUDE.md`
|
|
|
|
- [ ] **Step 1: Update documentation to reflect changes**
|
|
|
|
Add sections for:
|
|
- New config resolution flow (list_config + task overrides)
|
|
- `task_runs` table and execution tracking
|
|
- Auto-retry and continue flow
|
|
- `ClaudeArgsBuilder`, `StreamAnalyzer` (replacing `MessageParser`)
|
|
- `AgentFileService` and agents directory
|
|
- New hub methods: `ContinueTask`, `GetAgents`, `RefreshAgents`
|
|
- New broadcast: `RunCreated`
|
|
|
|
- [ ] **Step 2: Commit**
|
|
|
|
```bash
|
|
git add src/ClaudeDo.Worker/CLAUDE.md
|
|
git commit -m "docs(worker): update CLAUDE.md with CLI modernization changes"
|
|
```
|