feat(data): add ExpectsNoDiff flag for verification/audit tasks

An empty diff from a review/audit/verification task looks identical to
one that silently failed to commit. ExpectsNoDiff (TaskEntity, add_task/
update_task, preview_merge_set, review_task via TaskRefDto) lets the
merge helper tell them apart instead of treating every 0-changed-files
result as suspicious.
This commit is contained in:
Mika Kuns
2026-09-02 13:13:13 +02:00
parent 56bc1651a8
commit 0b506f529a
8 changed files with 1065 additions and 19 deletions
+32 -14
View File
@@ -62,7 +62,8 @@ public sealed record TaskDto(
// planning-chain predecessor or DependsOnTaskId hasn't reached Done. See BlockedReason.
bool Blocked = false,
string? BlockedReason = null,
bool IsManual = false);
bool IsManual = false,
bool ExpectsNoDiff = false);
// Lean counterpart to TaskDto for writing/status-changing tools: echoes back what changed
// without re-sending Description/Result, which the caller just sent or already has.
@@ -85,7 +86,8 @@ public sealed record TaskRefDto(
bool Blocked = false,
string? BlockedReason = null,
DirtyBaseWarning? BaseDirty = null,
bool IsManual = false);
bool IsManual = false,
bool ExpectsNoDiff = false);
// tasks is populated when includeDescription=false (the default): lean references, no
// Description/Result. tasksFull is populated when includeDescription=true: full tasks incl.
@@ -150,7 +152,11 @@ public sealed record MergePreviewToolDto(
public sealed record MergePreviewSetEntryDto(
string TaskId, string Status, IReadOnlyList<string> ConflictFiles, int ChangedFileCount, int Behind, string? Error, bool IsEmpty = false,
int? VerifyExitCode = null, long? VerifyDurationMs = null, string? VerifyOutputTail = null,
IReadOnlyList<string>? StaleFiles = null, int? Number = null);
IReadOnlyList<string>? StaleFiles = null, int? Number = null,
// Mirrors TaskEntity.ExpectsNoDiff -- lets the merge helper tell a deliberately empty diff
// (verification/audit/research task) apart from a task that silently failed to commit, both
// of which otherwise show IsEmpty=true with no other distinguishing signal.
bool ExpectsNoDiff = false);
public sealed record FileOverlapDto(string File, IReadOnlyList<string> TaskIds, IReadOnlyList<int> Numbers);
@@ -375,6 +381,10 @@ public sealed class ExternalMcpService
"dependency cycle. If that predecessor instead ends up Failed or Cancelled, this task simply " +
"stays blocked rather than starving silently -- check get_task/list_tasks' blocked field.")]
string? dependsOnTaskId = null,
[Description("true: this task's correct outcome may be an empty diff (verification/audit/research work " +
"that doesn't change code) — the merge helper won't treat a 0-changed-files result as a red " +
"flag. false (default): an empty diff is treated as suspicious, not a clean success.")]
bool expectsNoDiff = false,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(listId))
@@ -402,6 +412,7 @@ public sealed class ExternalMcpService
Model = ModelRegistry.NormalizeAlias(model),
// A manual list holds reminders, so anything filed there starts out manual.
IsManual = list.IsManual,
ExpectsNoDiff = expectsNoDiff,
};
await _tasks.AddAsync(entity, cancellationToken);
@@ -497,8 +508,8 @@ public sealed class ExternalMcpService
}
[McpServerTool, Description(
"Update an existing task's title, description, commit type, dependsOn link, and/or manual flag. Pass " +
"null to leave a field unchanged." + McpToolDocs.NotWhileRunning + McpToolDocs.LeanTaskRef)]
"Update an existing task's title, description, commit type, dependsOn link, and/or manual/expects-no-diff " +
"flags. Pass null to leave a field unchanged." + McpToolDocs.NotWhileRunning + McpToolDocs.LeanTaskRef)]
public async Task<TaskRefDto> UpdateTask(
string taskId,
string? title = null,
@@ -512,6 +523,10 @@ public sealed class ExternalMcpService
"daily prep, and the list handler all skip it (a ConPTY session may still touch it). false: " +
"clear the flag. null (default): leave unchanged.")]
bool? isManual = null,
[Description("true: this task's correct outcome may be an empty diff (verification/audit/research work " +
"that doesn't change code) — the merge helper won't treat a 0-changed-files result as a red " +
"flag. false: clear the flag. null (default): leave unchanged.")]
bool? expectsNoDiff = null,
CancellationToken cancellationToken = default)
{
taskId = await TaskIdResolver.ResolveAsync(_tasks, taskId, cancellationToken);
@@ -524,6 +539,7 @@ public sealed class ExternalMcpService
if (description is not null) task.Description = description;
if (commitType is not null) task.CommitType = commitType;
if (isManual is not null) task.IsManual = isManual.Value;
if (expectsNoDiff is not null) task.ExpectsNoDiff = expectsNoDiff.Value;
await _tasks.UpdateAsync(task, cancellationToken);
if (dependsOnTaskId is not null)
@@ -1368,7 +1384,7 @@ public sealed class ExternalMcpService
IProgress<ProgressNotificationValue>? progress = null)
{
taskId = await TaskIdResolver.ResolveAsync(_tasks, taskId, cancellationToken);
var (preview, behind, _, isEmpty, staleFiles, _) = await PreviewMergeCoreAsync(taskId, targetBranch, runVerify: true, cancellationToken, progress);
var (preview, behind, _, isEmpty, staleFiles, _, _) = await PreviewMergeCoreAsync(taskId, targetBranch, runVerify: true, cancellationToken, progress);
return new MergePreviewToolDto(preview.Status, preview.ConflictFiles, preview.ChangedFileCount, behind, isEmpty,
preview.VerifyExitCode, preview.VerifyDurationMs, preview.VerifyOutputTail, staleFiles);
}
@@ -1410,10 +1426,10 @@ public sealed class ExternalMcpService
var taskId = taskIds[i];
try
{
var (preview, behind, changedFiles, isEmpty, staleFiles, number) = await PreviewMergeCoreAsync(taskId, targetBranch, runVerify, cancellationToken, progress);
var (preview, behind, changedFiles, isEmpty, staleFiles, number, expectsNoDiff) = await PreviewMergeCoreAsync(taskId, targetBranch, runVerify, cancellationToken, progress);
entries.Add(new MergePreviewSetEntryDto(
taskId, preview.Status, preview.ConflictFiles, preview.ChangedFileCount, behind, null, isEmpty,
preview.VerifyExitCode, preview.VerifyDurationMs, preview.VerifyOutputTail, staleFiles, number));
preview.VerifyExitCode, preview.VerifyDurationMs, preview.VerifyOutputTail, staleFiles, number, expectsNoDiff));
filesByTask[taskId] = changedFiles;
numbersByTask[taskId] = number;
}
@@ -1422,7 +1438,7 @@ public sealed class ExternalMcpService
var task = await _tasks.GetByIdAsync(taskId, cancellationToken);
entries.Add(new MergePreviewSetEntryDto(
taskId, TaskMergeService.PreviewUnavailable, Array.Empty<string>(), 0, 0, ex.Message,
Number: task?.Number));
Number: task?.Number, ExpectsNoDiff: task?.ExpectsNoDiff ?? false));
}
ProgressReporter.ReportItem(progress, "Previewing merges", i + 1, taskIds.Count);
@@ -1480,7 +1496,7 @@ public sealed class ExternalMcpService
// (its commits already sit on the list's working dir) — falls back to the fixed
// HandlerBaseCommit..HandlerHeadCommit range, reporting a synthetic "clean" preview of that
// range's own diff-stat instead of throwing "has no worktree".
private async Task<(MergePreviewResult Preview, int Behind, IReadOnlyList<string> ChangedFiles, bool IsEmpty, IReadOnlyList<string> StaleFiles, int Number)> PreviewMergeCoreAsync(
private async Task<(MergePreviewResult Preview, int Behind, IReadOnlyList<string> ChangedFiles, bool IsEmpty, IReadOnlyList<string> StaleFiles, int Number, bool ExpectsNoDiff)> PreviewMergeCoreAsync(
string taskId, string? targetBranch, bool runVerify, CancellationToken ct,
IProgress<ProgressNotificationValue>? progress = null)
{
@@ -1522,7 +1538,7 @@ public sealed class ExternalMcpService
.Intersect(targetChangedSinceFork, StringComparer.OrdinalIgnoreCase)
.ToList();
return (preview, behind, changedFiles, changedFiles.Count == 0, staleFiles, task.Number);
return (preview, behind, changedFiles, changedFiles.Count == 0, staleFiles, task.Number, task.ExpectsNoDiff);
}
if (task.HandlerBaseCommit is { Length: > 0 } handlerBase && task.HandlerHeadCommit is { Length: > 0 } handlerHead)
@@ -1539,7 +1555,7 @@ public sealed class ExternalMcpService
// No fork point to diff against: a handler task commits straight onto the list's
// working dir instead of a branch, so there is nothing else that could have "changed
// in the target since the fork".
return (preview, 0, changedFiles, isEmpty, Array.Empty<string>(), task.Number);
return (preview, 0, changedFiles, isEmpty, Array.Empty<string>(), task.Number, task.ExpectsNoDiff);
}
throw new InvalidOperationException($"Task {taskId} has no worktree.");
@@ -1846,7 +1862,8 @@ public sealed class ExternalMcpService
t.DependsOnTaskId,
blocked,
blockedReason,
t.IsManual);
t.IsManual,
t.ExpectsNoDiff);
private static TaskRefDto ToRefDto(TaskEntity t, bool blocked = false, string? blockedReason = null) => new(
t.Id,
@@ -1864,7 +1881,8 @@ public sealed class ExternalMcpService
blocked,
blockedReason,
BaseDirty: null,
IsManual: t.IsManual);
IsManual: t.IsManual,
ExpectsNoDiff: t.ExpectsNoDiff);
// "unknown" covers a Failed task that predates this field (never got a classified reason
// stamped) — a defined value rather than null so callers don't have to special-case it.