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:
@@ -1,8 +1,8 @@
|
||||
# External MCP tool surface
|
||||
|
||||
> **Explore-note — verify before trusting.** Distilled map of a subsystem, not authoritative.
|
||||
> Last verified against commit `bdee731` (2026-08-05).
|
||||
> Drift check: `git log --oneline bdee731..HEAD -- src/ClaudeDo.Worker/External`
|
||||
> Last verified against commit `7cfe280` (2026-08-06).
|
||||
> Drift check: `git log --oneline 7cfe280..HEAD -- src/ClaudeDo.Worker/External`
|
||||
> 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
|
||||
@@ -71,6 +71,12 @@ Daily prep: `GetDailyPrepCandidates`, `SetMyDay`.
|
||||
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.
|
||||
|
||||
**`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
|
||||
`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
|
||||
|
||||
+10
-5
@@ -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 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 BatchCancelResult(string TaskId, bool Ok, bool Cancelled, 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? } " +
|
||||
"(model: haiku|sonnet|opus, blank = inherit list/global default). " +
|
||||
"queueImmediately enqueues every created task. " +
|
||||
"Returns one result per item: { index, title, ok, task, error }; task is a lean reference " +
|
||||
"(id, listId, title, status, sortOrder, isMyDay), not the description you just sent. Max 100 items.")]
|
||||
"Returns one result per item: { index, title, ok, task, possibleDuplicates, error }; task is " +
|
||||
"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(
|
||||
string listId,
|
||||
BatchAddTaskInput[] tasks,
|
||||
@@ -82,12 +87,12 @@ public sealed class BatchMcpTools
|
||||
var created = await _svc.AddTask(
|
||||
listId, item.Title, item.Description, createdBy,
|
||||
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 (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;
|
||||
|
||||
+87
-3
@@ -1,5 +1,8 @@
|
||||
using System.ComponentModel;
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using ClaudeDo.Data;
|
||||
using ClaudeDo.Data.Git;
|
||||
using ClaudeDo.Data.Models;
|
||||
@@ -47,6 +50,11 @@ public sealed record TaskRefDto(
|
||||
int SortOrder,
|
||||
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(
|
||||
string Path, string Branch, string HeadCommit, string BaseCommit,
|
||||
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, " +
|
||||
"'sonnet' for normal coding (the default), 'opus' only for complex or cross-cutting work. " +
|
||||
"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.")]
|
||||
public async Task<TaskRefDto> AddTask(
|
||||
"Returns { task, possibleDuplicates }: task is a lean reference (id, listId, title, status, " +
|
||||
"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 title,
|
||||
string? description = null,
|
||||
@@ -200,6 +212,8 @@ public sealed class ExternalMcpService
|
||||
var list = await _lists.GetByIdAsync(listId, cancellationToken)
|
||||
?? throw new InvalidOperationException($"List {listId} not found.");
|
||||
|
||||
var possibleDuplicates = await FindPossibleDuplicatesAsync(listId, title, cancellationToken);
|
||||
|
||||
var entity = new TaskEntity
|
||||
{
|
||||
Id = Guid.NewGuid().ToString(),
|
||||
@@ -225,7 +239,77 @@ public sealed class ExternalMcpService
|
||||
}
|
||||
|
||||
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(
|
||||
|
||||
@@ -142,6 +142,24 @@ public sealed class BatchMcpToolsTests : IDisposable
|
||||
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]
|
||||
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 loaded = await _tasks.GetByIdAsync(dto.Id);
|
||||
var loaded = await _tasks.GetByIdAsync(dto.Task.Id);
|
||||
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 loaded = await _tasks.GetByIdAsync(dto.Id);
|
||||
var loaded = await _tasks.GetByIdAsync(dto.Task.Id);
|
||||
Assert.Equal("haiku", loaded!.Model);
|
||||
}
|
||||
|
||||
@@ -1544,6 +1544,80 @@ public sealed class ExternalMcpServiceTests : IDisposable
|
||||
() => 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 ───────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
|
||||
Reference in New Issue
Block a user