feat(worker): render task descriptions into the list-handler brief
Phase 0 forced a batch_get_tasks full-fetch across every task just to see descriptions, which blew past the client's token limit on larger lists. brief.md lives on disk and has no such limit, so descriptions now render there directly (fenced with an extended backtick run, indented under the list bullet, so embedded headings/lists/code fences can't break the task list's own structure). Phase 0 now treats the brief as the primary source and only falls back to batch_get_tasks for fields it doesn't carry.
This commit is contained in:
@@ -236,7 +236,7 @@ public static class PromptFiles
|
|||||||
Work the five phases in order. Do not start a phase before the previous one is finished.
|
Work the five phases in order. Do not start a phase before the previous one is finished.
|
||||||
|
|
||||||
## Phase 0 — Read everything
|
## Phase 0 — Read everything
|
||||||
Call batch_get_tasks with every id from the brief and read each task's title, description, status and parent/child links. Do not act on any single task before you have read them all — Phase 1 needs the whole set in view.
|
The brief is the primary source: it already lists every task's title, id, status and full description. Read it in full before acting. Only call batch_get_tasks if you need something the brief does not carry for a specific task, e.g. parent/child links. Do not act on any single task before you have read them all — Phase 1 needs the whole set in view.
|
||||||
|
|
||||||
## Phase 1 — Dedupe
|
## Phase 1 — Dedupe
|
||||||
Compare the tasks pairwise for overlap: same goal stated twice, one task fully contained in another, two tasks that would edit the same thing for the same reason.
|
Compare the tasks pairwise for overlap: same goal stated twice, one task fully contained in another, two tasks that would edit the same thing for the same reason.
|
||||||
|
|||||||
@@ -206,7 +206,7 @@ public sealed class InteractiveLaunchSpecService : IInteractiveLaunchSpecService
|
|||||||
{
|
{
|
||||||
var task = await taskRepo.GetByIdAsync(id, ct)
|
var task = await taskRepo.GetByIdAsync(id, ct)
|
||||||
?? throw new KeyNotFoundException($"Task not found: {id}");
|
?? throw new KeyNotFoundException($"Task not found: {id}");
|
||||||
briefLines.Add($"- [{task.Status}] {task.Title} (id: {task.Id})");
|
briefLines.Add(RenderBriefEntry(task));
|
||||||
}
|
}
|
||||||
|
|
||||||
var sessionDir = Path.Combine(Paths.AppDataRoot(), "merge-helper-sessions", Guid.NewGuid().ToString());
|
var sessionDir = Path.Combine(Paths.AppDataRoot(), "merge-helper-sessions", Guid.NewGuid().ToString());
|
||||||
@@ -253,6 +253,39 @@ public sealed class InteractiveLaunchSpecService : IInteractiveLaunchSpecService
|
|||||||
return new LaunchSpec(repoDir, resolvedClaude, args, env);
|
return new LaunchSpec(repoDir, resolvedClaude, args, env);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Renders one task as a brief list item. A description can itself be arbitrary Markdown
|
||||||
|
// (headings, lists, fenced code) — those must not merge into the brief's own task list, so
|
||||||
|
// the description is placed in a fenced code block indented to the list item's continuation
|
||||||
|
// column (2 spaces, matching "- "). That keeps CommonMark parsing the fence as part of THIS
|
||||||
|
// bullet rather than breaking the list, while the code fence itself stops any inner heading
|
||||||
|
// or list syntax from being interpreted. The fence length is extended past the longest run of
|
||||||
|
// backticks already present in the description so an embedded ``` block can't prematurely
|
||||||
|
// close it.
|
||||||
|
private static string RenderBriefEntry(TaskEntity task)
|
||||||
|
{
|
||||||
|
var header = $"- [{task.Status}] {task.Title} (id: {task.Id})";
|
||||||
|
var description = task.Description?.Trim();
|
||||||
|
if (string.IsNullOrEmpty(description)) return header;
|
||||||
|
|
||||||
|
var fence = new string('`', Math.Max(3, LongestBacktickRun(description) + 1));
|
||||||
|
var lines = new List<string>(4) { header, $" {fence}" };
|
||||||
|
lines.AddRange(description.Replace("\r\n", "\n").Split('\n').Select(line => $" {line}"));
|
||||||
|
lines.Add($" {fence}");
|
||||||
|
return string.Join("\n", lines);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int LongestBacktickRun(string text)
|
||||||
|
{
|
||||||
|
var max = 0;
|
||||||
|
var current = 0;
|
||||||
|
foreach (var ch in text)
|
||||||
|
{
|
||||||
|
current = ch == '`' ? current + 1 : 0;
|
||||||
|
if (current > max) max = current;
|
||||||
|
}
|
||||||
|
return max;
|
||||||
|
}
|
||||||
|
|
||||||
// Creates the ClaudeDo task that hosts a list-handler run (Mission Control's "Let Claude
|
// Creates the ClaudeDo task that hosts a list-handler run (Mission Control's "Let Claude
|
||||||
// handle it") and stamps the list repo's current HEAD as the review range's base commit.
|
// handle it") and stamps the list repo's current HEAD as the review range's base commit.
|
||||||
// The handler never gets its own worktree -- it commits straight to the list's working
|
// The handler never gets its own worktree -- it commits straight to the list's working
|
||||||
|
|||||||
@@ -87,6 +87,15 @@ public class PromptFilesTests
|
|||||||
Assert.DoesNotContain("whenever you are unsure", d);
|
Assert.DoesNotContain("whenever you are unsure", d);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DefaultFor_merge_helper_phase0_treats_brief_as_primary_source()
|
||||||
|
{
|
||||||
|
var d = PromptFiles.DefaultFor(PromptKind.MergeHelper);
|
||||||
|
Assert.Contains("primary source", d, StringComparison.OrdinalIgnoreCase);
|
||||||
|
Assert.Contains("batch_get_tasks", d);
|
||||||
|
Assert.Contains("Do not act on any single task before you have read", d);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void DefaultFor_merge_helper_initial_has_repo_token()
|
public void DefaultFor_merge_helper_initial_has_repo_token()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -84,13 +84,13 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
|
|||||||
return listId;
|
return listId;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task SeedTaskAsync(string taskId, string listId, TaskStatus status, string? sessionSkillsJson = null, string title = "T")
|
private async Task SeedTaskAsync(string taskId, string listId, TaskStatus status, string? sessionSkillsJson = null, string title = "T", string? description = null)
|
||||||
{
|
{
|
||||||
using var ctx = _db.CreateContext();
|
using var ctx = _db.CreateContext();
|
||||||
await new TaskRepository(ctx).AddAsync(new TaskEntity
|
await new TaskRepository(ctx).AddAsync(new TaskEntity
|
||||||
{
|
{
|
||||||
Id = taskId, ListId = listId, Title = title, Status = status,
|
Id = taskId, ListId = listId, Title = title, Status = status,
|
||||||
CreatedAt = DateTime.UtcNow, SessionSkills = sessionSkillsJson,
|
CreatedAt = DateTime.UtcNow, SessionSkills = sessionSkillsJson, Description = description,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -478,6 +478,82 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
|
|||||||
Assert.Contains(t2, brief);
|
Assert.Contains(t2, brief);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task BuildForMergeHelperAsync_BriefIncludesTaskDescription()
|
||||||
|
{
|
||||||
|
var repo = Path.Combine(_tempDir, "repoDesc");
|
||||||
|
Directory.CreateDirectory(repo);
|
||||||
|
|
||||||
|
var listId = await SeedListAsync(workingDir: repo, name: "Alpha");
|
||||||
|
var t1 = Guid.NewGuid().ToString();
|
||||||
|
await SeedTaskAsync(t1, listId, TaskStatus.Idle, title: "First task",
|
||||||
|
description: "Do the thing carefully and report back.");
|
||||||
|
|
||||||
|
var svc = BuildService();
|
||||||
|
var spec = await svc.BuildForMergeHelperAsync(new[] { t1 }, listId, CancellationToken.None);
|
||||||
|
var sessionDir = TrackSessionDir(spec);
|
||||||
|
|
||||||
|
var brief = File.ReadAllText(Path.Combine(sessionDir, "brief.md"));
|
||||||
|
Assert.Contains("Do the thing carefully and report back.", brief);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task BuildForMergeHelperAsync_TaskWithoutDescription_RendersCleanly()
|
||||||
|
{
|
||||||
|
var repo = Path.Combine(_tempDir, "repoNoDesc");
|
||||||
|
Directory.CreateDirectory(repo);
|
||||||
|
|
||||||
|
var listId = await SeedListAsync(workingDir: repo, name: "Alpha");
|
||||||
|
var t1 = Guid.NewGuid().ToString();
|
||||||
|
await SeedTaskAsync(t1, listId, TaskStatus.Idle, title: "No description task", description: null);
|
||||||
|
|
||||||
|
var svc = BuildService();
|
||||||
|
var spec = await svc.BuildForMergeHelperAsync(new[] { t1 }, listId, CancellationToken.None);
|
||||||
|
var sessionDir = TrackSessionDir(spec);
|
||||||
|
|
||||||
|
var brief = File.ReadAllText(Path.Combine(sessionDir, "brief.md"));
|
||||||
|
var line = brief.Split('\n').Single(l => l.Contains("No description task"));
|
||||||
|
Assert.Equal($"- [Idle] No description task (id: {t1})", line.TrimEnd('\r'));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task BuildForMergeHelperAsync_BriefDescriptionWithCodeFenceAndHeadingsKeepsTaskListRecognizable()
|
||||||
|
{
|
||||||
|
var repo = Path.Combine(_tempDir, "repoMd");
|
||||||
|
Directory.CreateDirectory(repo);
|
||||||
|
|
||||||
|
var listId = await SeedListAsync(workingDir: repo, name: "Alpha");
|
||||||
|
var t1 = Guid.NewGuid().ToString();
|
||||||
|
var t2 = Guid.NewGuid().ToString();
|
||||||
|
var trickyDescription = """
|
||||||
|
# Heading inside description
|
||||||
|
|
||||||
|
- a nested list item
|
||||||
|
- another one
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
var x = "fenced code block";
|
||||||
|
```
|
||||||
|
""";
|
||||||
|
await SeedTaskAsync(t1, listId, TaskStatus.Idle, title: "First task", description: trickyDescription);
|
||||||
|
await SeedTaskAsync(t2, listId, TaskStatus.WaitingForReview, title: "Second task", description: "plain description");
|
||||||
|
|
||||||
|
var svc = BuildService();
|
||||||
|
var spec = await svc.BuildForMergeHelperAsync(new[] { t1, t2 }, listId, CancellationToken.None);
|
||||||
|
var sessionDir = TrackSessionDir(spec);
|
||||||
|
|
||||||
|
var brief = File.ReadAllText(Path.Combine(sessionDir, "brief.md"));
|
||||||
|
var lines = brief.Replace("\r\n", "\n").Split('\n');
|
||||||
|
|
||||||
|
// The two task bullets must still be recognizable top-level list items, unbroken
|
||||||
|
// by the embedded heading/list/code-fence in the first task's description.
|
||||||
|
Assert.Contains(lines, l => l == $"- [Idle] First task (id: {t1})");
|
||||||
|
Assert.Contains(lines, l => l == $"- [WaitingForReview] Second task (id: {t2})");
|
||||||
|
Assert.Contains("Heading inside description", brief);
|
||||||
|
Assert.Contains("fenced code block", brief);
|
||||||
|
Assert.Contains("var x = \"fenced code block\";", brief);
|
||||||
|
}
|
||||||
|
|
||||||
// ── CreateMergeHelperTaskAsync ──
|
// ── CreateMergeHelperTaskAsync ──
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
Reference in New Issue
Block a user