feat(worker): warn on near-duplicate titles in add_task/batch_add_tasks

add_task and batch_add_tasks now report up to 3 open (non-terminal)
tasks in the same list with a strongly overlapping title, so a
parallel agent can notice and mention a likely duplicate instead of
silently creating one. The task is always created regardless. Uses a
cheap normalized-word overlap heuristic (no embeddings/LLM call),
robust to German umlaut/digraph spelling variants. Breaking change:
AddTask now returns AddTaskResult { task, possibleDuplicates } instead
of a bare TaskRefDto; BatchAddTaskResult gained a PossibleDuplicates
field.
This commit is contained in:
mika kuns
2026-08-06 11:24:17 +02:00
parent 7cfe280a23
commit 3e07536ee9
5 changed files with 199 additions and 12 deletions
+8 -2
View File
@@ -1,8 +1,8 @@
# External MCP tool surface # External MCP tool surface
> **Explore-note — verify before trusting.** Distilled map of a subsystem, not authoritative. > **Explore-note — verify before trusting.** Distilled map of a subsystem, not authoritative.
> Last verified against commit `bdee731` (2026-08-05). > Last verified against commit `7cfe280` (2026-08-06).
> Drift check: `git log --oneline bdee731..HEAD -- src/ClaudeDo.Worker/External` > Drift check: `git log --oneline 7cfe280..HEAD -- src/ClaudeDo.Worker/External`
> Stable structure only (no line numbers). See docs/explore-notes/README.md. > Stable structure only (no line numbers). See docs/explore-notes/README.md.
Covers `src/ClaudeDo.Worker/External/` — the always-on MCP tools ClaudeDo exposes to general Covers `src/ClaudeDo.Worker/External/` — the always-on MCP tools ClaudeDo exposes to general
@@ -71,6 +71,12 @@ Daily prep: `GetDailyPrepCandidates`, `SetMyDay`.
UI's "set status freely" affordance uses) but is **refused** for a task with an active UI's "set status freely" affordance uses) but is **refused** for a task with an active
worktree, since that would skip `review_task`'s merge. worktree, since that would skip `review_task`'s merge.
**`AddTask`** — always creates the task; also returns `possibleDuplicates` (up to 3, id/title/status
only, no descriptions) — open (non-terminal) tasks in the *same list* whose normalized title
overlaps strongly with the new one. Cheap word-overlap heuristic (`ExternalMcpService`'s
`FindPossibleDuplicatesAsync`/`NormalizeTitleWords`), no embeddings/LLM call, no blocking —
the caller just gets a heads-up to relay. `BatchAddTasks` carries the same field per item.
**`ReviewTask`** — `approve` / `reject_rerun` / `reject_park` / `cancel` for a **`ReviewTask`** — `approve` / `reject_rerun` / `reject_park` / `cancel` for a
`WaitingForReview` task. Approve is review+merge exactly like the hub's `ApproveReview`: unit `WaitingForReview` task. Approve is review+merge exactly like the hub's `ApproveReview`: unit
merge for parents, worktree merge into optional `targetBranch` for childless tasks. Conflicts merge for parents, worktree merge into optional `targetBranch` for childless tasks. Conflicts
+10 -5
View File
@@ -7,7 +7,9 @@ public sealed record BatchAddTaskInput(string Title, string? Description = null,
public sealed record BatchSetMyDayInput(string TaskId, bool IsMyDay, int? SortOrder = null); public sealed record BatchSetMyDayInput(string TaskId, bool IsMyDay, int? SortOrder = null);
public sealed record BatchGetTaskResult(string Id, bool Found, TaskDto? Task, string? Error); public sealed record BatchGetTaskResult(string Id, bool Found, TaskDto? Task, string? Error);
public sealed record BatchAddTaskResult(int Index, string Title, bool Ok, TaskRefDto? Task, string? Error); public sealed record BatchAddTaskResult(
int Index, string Title, bool Ok, TaskRefDto? Task,
IReadOnlyList<PossibleDuplicateDto>? PossibleDuplicates, string? Error);
public sealed record BatchTaskResult(string TaskId, bool Ok, string? Error); public sealed record BatchTaskResult(string TaskId, bool Ok, string? Error);
public sealed record BatchCancelResult(string TaskId, bool Ok, bool Cancelled, string? Error); public sealed record BatchCancelResult(string TaskId, bool Ok, bool Cancelled, string? Error);
public sealed record BatchCleanupResult(string TaskId, bool Ok, bool Removed, bool BranchDeleted, string? Error); public sealed record BatchCleanupResult(string TaskId, bool Ok, bool Removed, bool BranchDeleted, string? Error);
@@ -62,8 +64,11 @@ public sealed class BatchMcpTools
"Create many tasks in one list at once. Each item: { title, description?, model? } " + "Create many tasks in one list at once. Each item: { title, description?, model? } " +
"(model: haiku|sonnet|opus, blank = inherit list/global default). " + "(model: haiku|sonnet|opus, blank = inherit list/global default). " +
"queueImmediately enqueues every created task. " + "queueImmediately enqueues every created task. " +
"Returns one result per item: { index, title, ok, task, error }; task is a lean reference " + "Returns one result per item: { index, title, ok, task, possibleDuplicates, error }; task is " +
"(id, listId, title, status, sortOrder, isMyDay), not the description you just sent. Max 100 items.")] "a lean reference (id, listId, title, status, sortOrder, isMyDay), not the description you just " +
"sent. Each item is always created — possibleDuplicates is a non-blocking heads-up (up to 3 open " +
"tasks in the same list with a strongly overlapping title, id/title/status only); check it and " +
"mention any hit to the caller, but do not treat it as an error. Max 100 items.")]
public async Task<IReadOnlyList<BatchAddTaskResult>> BatchAddTasks( public async Task<IReadOnlyList<BatchAddTaskResult>> BatchAddTasks(
string listId, string listId,
BatchAddTaskInput[] tasks, BatchAddTaskInput[] tasks,
@@ -82,12 +87,12 @@ public sealed class BatchMcpTools
var created = await _svc.AddTask( var created = await _svc.AddTask(
listId, item.Title, item.Description, createdBy, listId, item.Title, item.Description, createdBy,
queueImmediately, item.Model, cancellationToken); queueImmediately, item.Model, cancellationToken);
results.Add(new BatchAddTaskResult(i, item.Title, true, created, null)); results.Add(new BatchAddTaskResult(i, item.Title, true, created.Task, created.PossibleDuplicates, null));
} }
catch (OperationCanceledException) { throw; } catch (OperationCanceledException) { throw; }
catch (Exception ex) catch (Exception ex)
{ {
results.Add(new BatchAddTaskResult(i, item.Title, false, null, ex.Message)); results.Add(new BatchAddTaskResult(i, item.Title, false, null, null, ex.Message));
} }
} }
return results; return results;
+87 -3
View File
@@ -1,5 +1,8 @@
using System.ComponentModel; using System.ComponentModel;
using System.Diagnostics; using System.Diagnostics;
using System.Globalization;
using System.Text;
using System.Text.RegularExpressions;
using ClaudeDo.Data; using ClaudeDo.Data;
using ClaudeDo.Data.Git; using ClaudeDo.Data.Git;
using ClaudeDo.Data.Models; using ClaudeDo.Data.Models;
@@ -47,6 +50,11 @@ public sealed record TaskRefDto(
int SortOrder, int SortOrder,
bool IsMyDay); bool IsMyDay);
// Deliberately small: no descriptions, capped candidate count (see AddTask's duplicate check).
public sealed record PossibleDuplicateDto(string TaskId, string Title, string Status);
public sealed record AddTaskResult(TaskRefDto Task, IReadOnlyList<PossibleDuplicateDto> PossibleDuplicates);
public sealed record WorktreeInfoDto( public sealed record WorktreeInfoDto(
string Path, string Branch, string HeadCommit, string BaseCommit, string Path, string Branch, string HeadCommit, string BaseCommit,
int Ahead, int Behind, bool IsDirty, string? MergeCommit = null); int Ahead, int Behind, bool IsDirty, string? MergeCommit = null);
@@ -182,8 +190,12 @@ public sealed class ExternalMcpService
"Set model to the cheapest model that can do the task well — 'haiku' for trivial/mechanical work, " + "Set model to the cheapest model that can do the task well — 'haiku' for trivial/mechanical work, " +
"'sonnet' for normal coding (the default), 'opus' only for complex or cross-cutting work. " + "'sonnet' for normal coding (the default), 'opus' only for complex or cross-cutting work. " +
"Leave model null to inherit the list/global default. " + "Leave model null to inherit the list/global default. " +
"Returns a lean task reference (id, listId, title, status, sortOrder, isMyDay) — not the description you just sent.")] "Returns { task, possibleDuplicates }: task is a lean reference (id, listId, title, status, " +
public async Task<TaskRefDto> AddTask( "sortOrder, isMyDay) — not the description you just sent. The task is always created — " +
"possibleDuplicates is a non-blocking heads-up (up to 3 open tasks in the same list with a " +
"strongly overlapping title, id/title/status only); check it and mention any hit to the caller, " +
"but do not treat it as an error.")]
public async Task<AddTaskResult> AddTask(
string listId, string listId,
string title, string title,
string? description = null, string? description = null,
@@ -200,6 +212,8 @@ public sealed class ExternalMcpService
var list = await _lists.GetByIdAsync(listId, cancellationToken) var list = await _lists.GetByIdAsync(listId, cancellationToken)
?? throw new InvalidOperationException($"List {listId} not found."); ?? throw new InvalidOperationException($"List {listId} not found.");
var possibleDuplicates = await FindPossibleDuplicatesAsync(listId, title, cancellationToken);
var entity = new TaskEntity var entity = new TaskEntity
{ {
Id = Guid.NewGuid().ToString(), Id = Guid.NewGuid().ToString(),
@@ -225,7 +239,77 @@ public sealed class ExternalMcpService
} }
await _broadcaster.TaskUpdated(entity.Id); await _broadcaster.TaskUpdated(entity.Id);
return ToRefDto(entity); return new AddTaskResult(ToRefDto(entity), possibleDuplicates);
}
// Non-terminal: a task still open enough that a new, similarly-titled task might be a duplicate
// of it. Done/Failed/Cancelled tasks are retired and never flagged.
private static readonly TaskStatus[] NonTerminalStatuses =
[
TaskStatus.Idle, TaskStatus.Queued, TaskStatus.Running,
TaskStatus.WaitingForChildren, TaskStatus.WaitingForReview,
];
private const int MaxPossibleDuplicates = 3;
// Cheap, dependency-free "near-duplicate title" heuristic — no embeddings, no LLM call, just a
// normalized-word overlap coefficient (shared significant words / smaller word-set size, not
// Jaccard). Overlap-coefficient beats Jaccard here because titles in this project vary wildly in
// length (a terse one-liner vs. a fully-spelled-out German sentence) — dividing by the union
// would wash out a real match against a longer title. Threshold tuned against the pair that
// motivated this check: "MaxTurnsCeiling ohne Bedienoberflaeche im Settings-Modal" vs.
// "Settings: MaxTurnsCeiling editierbar machen" (>=2 shared significant words, >=50% overlap of
// the shorter title's words) without over-firing on titles that merely share one generic word.
private async Task<IReadOnlyList<PossibleDuplicateDto>> FindPossibleDuplicatesAsync(
string listId, string title, CancellationToken ct)
{
var newWords = NormalizeTitleWords(title);
if (newWords.Count == 0) return [];
var candidates = await _tasks.GetByListIdAsync(listId, ct);
return candidates
.Where(t => NonTerminalStatuses.Contains(t.Status))
.Select(t => (Task: t, Words: NormalizeTitleWords(t.Title)))
.Select(x => (x.Task, Shared: newWords.Count(x.Words.Contains), x.Words))
.Where(x => x.Shared >= 2 && x.Words.Count > 0
&& (double)x.Shared / Math.Min(newWords.Count, x.Words.Count) >= 0.5)
.OrderByDescending(x => (double)x.Shared / Math.Min(newWords.Count, x.Words.Count))
.ThenByDescending(x => x.Shared)
.Take(MaxPossibleDuplicates)
.Select(x => new PossibleDuplicateDto(x.Task.Id, x.Task.Title, x.Task.Status.ToString()))
.ToList();
}
private static readonly Regex NonWordChars = new(@"[^a-z0-9]+", RegexOptions.Compiled);
// Common structural filler that would otherwise dominate the overlap score across most titles
// in this project's lists without indicating a real duplicate (German articles/prepositions
// plus the recurring "mcp"/"task(s)"/"cleanup" nouns called out in the task write-up).
private static readonly HashSet<string> TitleStopWords = new(StringComparer.Ordinal)
{
"der", "die", "das", "und", "von", "auf", "mit", "fur", "ein", "eine",
"ist", "sind", "oder", "nicht", "mcp", "task", "tasks", "cleanup",
};
// Lowercases, transliterates German umlauts/ß to their digraph spelling (ä→ae, ß→ss, ...) so
// "Prüfung" and "Pruefung" normalize to the same token, strips remaining diacritics/punctuation,
// then drops short (<3 char) and stopword tokens.
private static HashSet<string> NormalizeTitleWords(string title)
{
var s = title.ToLowerInvariant()
.Replace("ä", "ae").Replace("ö", "oe").Replace("ü", "ue").Replace("ß", "ss");
s = s.Normalize(NormalizationForm.FormD);
var sb = new StringBuilder(s.Length);
foreach (var ch in s)
if (CharUnicodeInfo.GetUnicodeCategory(ch) != UnicodeCategory.NonSpacingMark)
sb.Append(ch);
var ascii = NonWordChars.Replace(sb.ToString(), " ");
return ascii.Split(' ', StringSplitOptions.RemoveEmptyEntries)
.Where(w => w.Length >= 3 && !TitleStopWords.Contains(w))
.ToHashSet(StringComparer.Ordinal);
} }
[McpServerTool, Description( [McpServerTool, Description(
@@ -142,6 +142,24 @@ public sealed class BatchMcpToolsTests : IDisposable
Assert.Equal(2, inList.Count); Assert.Equal(2, inList.Count);
} }
[Fact]
public async Task BatchAddTasks_SimilarTitleInSameList_ReportsPossibleDuplicate()
{
var listId = await SeedListAsync();
await SeedTaskAsync(listId, "MaxTurnsCeiling ohne Bedienoberflaeche im Settings-Modal", TaskStatus.Idle);
var sut = BuildSut();
var results = await sut.BatchAddTasks(listId, new[]
{
new BatchAddTaskInput("Settings: MaxTurnsCeiling editierbar machen"),
}, cancellationToken: CancellationToken.None);
var result = Assert.Single(results);
Assert.True(result.Ok);
Assert.NotNull(result.PossibleDuplicates);
Assert.Single(result.PossibleDuplicates!);
}
[Fact] [Fact]
public async Task BatchGetTasks_MissingId_IsFoundFalseNotError() public async Task BatchGetTasks_MissingId_IsFoundFalseNotError()
{ {
@@ -1518,7 +1518,7 @@ public sealed class ExternalMcpServiceTests : IDisposable
var dto = await sut.AddTask(listId, "t", cancellationToken: CancellationToken.None); var dto = await sut.AddTask(listId, "t", cancellationToken: CancellationToken.None);
var loaded = await _tasks.GetByIdAsync(dto.Id); var loaded = await _tasks.GetByIdAsync(dto.Task.Id);
Assert.Null(loaded!.Model); Assert.Null(loaded!.Model);
} }
@@ -1530,7 +1530,7 @@ public sealed class ExternalMcpServiceTests : IDisposable
var dto = await sut.AddTask(listId, "t", model: "HAIKU", cancellationToken: CancellationToken.None); var dto = await sut.AddTask(listId, "t", model: "HAIKU", cancellationToken: CancellationToken.None);
var loaded = await _tasks.GetByIdAsync(dto.Id); var loaded = await _tasks.GetByIdAsync(dto.Task.Id);
Assert.Equal("haiku", loaded!.Model); Assert.Equal("haiku", loaded!.Model);
} }
@@ -1544,6 +1544,80 @@ public sealed class ExternalMcpServiceTests : IDisposable
() => sut.AddTask(listId, "t", model: "gpt4", cancellationToken: CancellationToken.None)); () => sut.AddTask(listId, "t", model: "gpt4", cancellationToken: CancellationToken.None));
} }
// ── AddTask possible-duplicate check ────────────────────────────────────────
[Fact]
public async Task AddTask_SimilarOpenTitleInSameList_IsReportedAsPossibleDuplicate()
{
// The exact pair that motivated this check (bf4cd901 vs f3718cd8, see task write-up).
var listId = await SeedListAsync();
var existing = await SeedTaskAsync(
listId, "MaxTurnsCeiling ohne Bedienoberflaeche im Settings-Modal", TaskStatus.WaitingForReview);
var sut = NewService();
var result = await sut.AddTask(
listId, "Settings: MaxTurnsCeiling editierbar machen", cancellationToken: CancellationToken.None);
var dup = Assert.Single(result.PossibleDuplicates);
Assert.Equal(existing.Id, dup.TaskId);
Assert.Equal(existing.Title, dup.Title);
Assert.Equal("WaitingForReview", dup.Status);
}
[Fact]
public async Task AddTask_StillCreatesTheTask_EvenWhenAPossibleDuplicateIsFound()
{
var listId = await SeedListAsync();
await SeedTaskAsync(listId, "MaxTurnsCeiling ohne Bedienoberflaeche im Settings-Modal", TaskStatus.Idle);
var sut = NewService();
var result = await sut.AddTask(
listId, "Settings: MaxTurnsCeiling editierbar machen", cancellationToken: CancellationToken.None);
Assert.NotEmpty(result.PossibleDuplicates);
Assert.NotNull(await _tasks.GetByIdAsync(result.Task.Id));
}
[Fact]
public async Task AddTask_SimilarTitle_ButExistingTaskIsTerminal_IsNotReported()
{
var listId = await SeedListAsync();
await SeedTaskAsync(listId, "MaxTurnsCeiling ohne Bedienoberflaeche im Settings-Modal", TaskStatus.Done);
var sut = NewService();
var result = await sut.AddTask(
listId, "Settings: MaxTurnsCeiling editierbar machen", cancellationToken: CancellationToken.None);
Assert.Empty(result.PossibleDuplicates);
}
[Fact]
public async Task AddTask_ClearlyDifferentTitle_IsNotReported()
{
var listId = await SeedListAsync();
await SeedTaskAsync(listId, "Refactor GitService worktree cleanup", TaskStatus.Idle);
var sut = NewService();
var result = await sut.AddTask(
listId, "Add dark mode toggle to the settings page", cancellationToken: CancellationToken.None);
Assert.Empty(result.PossibleDuplicates);
}
[Fact]
public async Task AddTask_MoreThanThreeMatches_CapsPossibleDuplicatesAtThree()
{
var listId = await SeedListAsync();
for (var i = 0; i < 5; i++)
await SeedTaskAsync(listId, $"Fix flaky queue picker test number {i}", TaskStatus.Idle);
var sut = NewService();
var result = await sut.AddTask(
listId, "Fix flaky queue picker test number extra", cancellationToken: CancellationToken.None);
Assert.Equal(3, result.PossibleDuplicates.Count);
}
// ── ContinueTask validation ─────────────────────────────────────────────── // ── ContinueTask validation ───────────────────────────────────────────────
[Fact] [Fact]