Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
31dfaffc8b | ||
|
|
09bcd696dd | ||
|
|
86cc309ba9 | ||
|
|
684b0115e5 | ||
|
|
1325ab9438 | ||
|
|
56161e4e9b | ||
|
|
4ff5383b35 | ||
|
|
5cdfb14ff1 | ||
|
|
7452f6cfe4 | ||
|
|
c874bf7e52 | ||
|
|
50b6b56f87 | ||
|
|
9a41bed0f5 | ||
|
|
103eb9a687 | ||
|
|
7ecb1fa0b5 | ||
|
|
18394dd6b2 |
@@ -1,5 +1,35 @@
|
||||
# Changelog
|
||||
|
||||
## v2.16.0 — 2026-09-16
|
||||
|
||||
### Features
|
||||
|
||||
- Bilder aus der Zwischenablage ins interaktive Terminal einfuegen (81d835c0)
|
||||
- preview_merge_set flags removed symbols and locale-key overlaps (fb7dddb3)
|
||||
- poll the OAuth usage endpoint as fast as its measured rate limit allows (6b6d9895)
|
||||
- dirty Ziel-Verzeichnis beim Oeffnen des Merge-Dialogs melden statt nach dem Fehlschlag (0805540a)
|
||||
|
||||
### Fixes
|
||||
|
||||
- depends_on-Nachfolger schon beim gelandeten Merge freigeben (21591a5d)
|
||||
- preview_merge braucht einen runVerify-Parameter wie preview_merge_set (d725e3f1)
|
||||
- Shutdown beendet die App auch mit offenen Nebenfenstern (d7898240)
|
||||
- Worker-Neustart trifft nun den tatsaechlich laufenden Prozess (c486b85b)
|
||||
- Ordner-Toggle in der Sidebar schluckt schnelle Klicks (0cd31411)
|
||||
- Diff-Viewer scrollt jetzt auch horizontal synchron (f4af0b98)
|
||||
- Worktrees-Uebersicht zeigt Task-Nummern, DIFF-Spalte trimmt statt abzuschneiden (62349c4f)
|
||||
- duplizierter Confirm-Dialog mit "Delete"-Knopf zusammengefuehrt und lokalisiert (a0ad4d0f)
|
||||
|
||||
## v2.15.0 — 2026-09-11
|
||||
|
||||
### Features
|
||||
|
||||
- Stash-Option fuer uncommittete Aenderungen im Ziel-Tree (251b70ce)
|
||||
|
||||
### Documentation
|
||||
|
||||
- update for v2.14.0 (8e2d37e6)
|
||||
|
||||
## v2.14.0 — 2026-09-10
|
||||
|
||||
### Features
|
||||
|
||||
@@ -184,9 +184,9 @@ detached-HEAD scratch worktree under the OS temp dir (`GitService.WorktreeAddDet
|
||||
is always removed afterward (`finally`, non-cancellable cleanup). No verify command configured, or
|
||||
`runVerify=false`, reproduces the pre-existing preview exactly — no delay, no scratch worktree.
|
||||
|
||||
Wired into `ExternalMcpService`: `preview_merge` (single task) always requests a verify run when the
|
||||
list has a command configured; `preview_merge_set` only does when its `runVerify` parameter is
|
||||
explicitly set (default `false`) — a set preview never starts N builds unasked. The Hub's
|
||||
Wired into `ExternalMcpService`: both `preview_merge` (single task) and `preview_merge_set` only
|
||||
request a verify run when their `runVerify` parameter is explicitly set (default `false`) — a
|
||||
preview never starts a build unasked. The Hub's
|
||||
`PreviewMerge` (the UI's live mergeability indicator) always passes `runVerify: false`, since
|
||||
running a build on every poll would be a bad regression; `MergePreviewDto` carries the
|
||||
verify fields anyway so a future UI entry point can opt in. Result fields: `VerifyExitCode` (null =
|
||||
|
||||
@@ -21,6 +21,11 @@ authenticated with the Bearer access token Claude Code itself keeps fresh at
|
||||
Because the endpoint is undocumented and can change without notice, **every consumer
|
||||
fails open**. That is the single most important invariant here.
|
||||
|
||||
**Measured 2026-09-16** (clean burst against the live endpoint, Worker otherwise idle): exactly
|
||||
5 requests succeed, the 6th 429s with `Retry-After: 300` — a flat 5-requests-per-5-minute quota,
|
||||
no `ratelimit-*`/`x-ratelimit-*` headers on either the 200s or the 429. The quota is per-account,
|
||||
not per-process, so a manual refresh or another consumer of the same OAuth token shares it.
|
||||
|
||||
## Components (`Usage/`)
|
||||
|
||||
| Type | Role |
|
||||
@@ -28,7 +33,7 @@ fails open**. That is the single most important invariant here.
|
||||
| `UsageModels` | `UsageBucket` / `UsageLimitRow` / `UsageSnapshot`. `UsageBucket.Utilization` is already a 0–100 percent — compare directly against thresholds, don't rescale. |
|
||||
| `ClaudeOAuthUsageClient` | Reads the token, calls the endpoint. Defensive parsing: missing/null buckets → null, missing `limits` → empty list. |
|
||||
| `UsageState` | Threadsafe singleton. A failed poll **never** overwrites the last good snapshot — it only sets `LastError`. |
|
||||
| `UsageMonitorService` | `BackgroundService`; polls on `usage_poll_interval_seconds` (default 60, clamped to min 15 on config load), one poll at startup. Logs a failure at most once per distinct error message. Broadcasts `HubBroadcaster.UsageUpdated` after **every** tick, success or failure. |
|
||||
| `UsageMonitorService` | `BackgroundService`; polls on `usage_poll_interval_active_seconds` / `usage_poll_interval_idle_seconds` (default 120 each, clamped to min 90 on config load), one poll at startup. Logs a failure at most once per distinct error message. Broadcasts `HubBroadcaster.UsageUpdated` after **every** tick, success or failure. |
|
||||
| `UsageSnapshotBuilder` | Builds the Hub-facing `UsageSnapshotDto` from `UsageState` + `IUsageGate` + `AppSettings`. The one shared place for stale/threshold/gate logic — `WorkerHub.GetUsageSnapshot` and `UsageMonitorService` must not diverge. |
|
||||
| `UsageGate` | Hard pause decision → `UsageGateDecision(IsBlocked, Reason)`. |
|
||||
| `UsageThrottle` | Pure static staging of parallelism ahead of the gate. |
|
||||
|
||||
@@ -42,6 +42,8 @@ sealed class Program
|
||||
ClaudeDoDbContext.MigrateAndConfigure(db);
|
||||
}
|
||||
|
||||
ClipboardImageTempStore.DeleteOlderThan(TimeSpan.FromHours(24), DateTime.UtcNow);
|
||||
|
||||
try
|
||||
{
|
||||
ConfigureAppBuilder(AppBuilder.Configure(() => new App(services)))
|
||||
|
||||
@@ -558,6 +558,16 @@ public sealed class GitService
|
||||
return exitCode == 0 ? stdout : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Content of <paramref name="path"/> at <paramref name="revision"/> via `git show revision:path`.
|
||||
/// Null when the path doesn't exist at that revision (new, deleted, or renamed file).
|
||||
/// </summary>
|
||||
public async Task<string?> ShowFileAsync(string repoDir, string revision, string path, CancellationToken ct = default)
|
||||
{
|
||||
var (exitCode, stdout, _) = await RunGitAsync(repoDir, ["show", $"{revision}:{path}"], ct, trimOutput: false);
|
||||
return exitCode == 0 ? stdout : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Non-destructive mergeability probe via `git merge-tree --write-tree`. Writes only
|
||||
/// loose objects — the working tree, index, and refs are left untouched.
|
||||
|
||||
@@ -523,7 +523,7 @@ public static class PromptFiles
|
||||
- Clean merge → mergeStatus "merged_verify_pending" (expected: the task deliberately stays WaitingForReview until verify_merges passes) or "merged" (list has no verify command; the task is Done). Either way, move on.
|
||||
- Conflict (markers left in the working tree, repoPath returned) → resolve it below.
|
||||
|
||||
After each merge, main has moved — a clean preview_merge_set result from before this merge is now stale for the remaining tasks. If you are unsure whether an earlier overlap warning still matters, call preview_merge for the next task again before merging it.
|
||||
After each merge, main has moved — a clean preview_merge_set result from before this merge is now stale for the remaining tasks. If you are unsure whether an earlier overlap warning still matters, call preview_merge for the next task again before merging it (default runVerify=false — this is just a conflict recheck, not a build; the verify command still runs once via verify_merges after the last merge).
|
||||
|
||||
Every branch in this run forked from the same base, so conflicts between them are the NORMAL case, not a failure. Resolve them and keep going; do not abandon the run because a merge conflicted.
|
||||
|
||||
|
||||
@@ -72,7 +72,8 @@ public record WorktreeOverviewDto(
|
||||
WorktreeState State,
|
||||
string? DiffStat,
|
||||
DateTime CreatedAt,
|
||||
bool PathExistsOnDisk);
|
||||
bool PathExistsOnDisk,
|
||||
int TaskNumber = 0);
|
||||
|
||||
public record ForceRemoveResultDto(bool Removed, string? Reason);
|
||||
|
||||
@@ -91,7 +92,8 @@ public record MergePreviewDto(
|
||||
int? VerifyExitCode = null, long? VerifyDurationMs = null, string? VerifyOutputTail = null);
|
||||
|
||||
public record MergeTargetsDto(
|
||||
string DefaultBranch, IReadOnlyList<string> LocalBranches, string DefaultCommitMessage);
|
||||
string DefaultBranch, IReadOnlyList<string> LocalBranches, string DefaultCommitMessage,
|
||||
bool TargetDirty = false);
|
||||
|
||||
public record MergeConflictDocumentsDto(string TaskId, IReadOnlyList<ConflictDocumentDto> Files);
|
||||
|
||||
|
||||
@@ -328,6 +328,7 @@
|
||||
"worktree": "Worktree",
|
||||
"reviewCombinedDiff": "Kombinierten Diff prüfen",
|
||||
"openDiffHint": "Diff öffnen, um Mergen freizuschalten",
|
||||
"targetDirty": "Ziel-Arbeitsverzeichnis hat uncommittete Änderungen — beim Mergen werden sie gestasht und danach wiederhergestellt.",
|
||||
"approveMerge": "Genehmigen & Mergen",
|
||||
"park": "Parken",
|
||||
"parkTip": "Zurückstellen — zurück auf Leerlauf, Worktree bleibt erhalten",
|
||||
@@ -805,6 +806,14 @@
|
||||
"done": "Fertig"
|
||||
}
|
||||
},
|
||||
"dialog": {
|
||||
"cancel": "Abbrechen",
|
||||
"confirm": "Bestätigen",
|
||||
"delete": "Löschen",
|
||||
"ok": "OK",
|
||||
"confirmTitle": "Bestätigen",
|
||||
"errorTitle": "Fehler"
|
||||
},
|
||||
"shell": {
|
||||
"menu": {
|
||||
"help": "Hilfe",
|
||||
@@ -851,7 +860,7 @@
|
||||
"tasksIsland": { "completedHeader": "ABGESCHLOSSEN", "completedHeaderCount": "ABGESCHLOSSEN · {0}", "statusPill": "{0} laufend · {1} Prüfung", "openTaskOne": "1 offene Aufgabe", "openTasks": "{0} offene Aufgaben", "planningResumeFailed": "Planungssitzung konnte nicht fortgesetzt werden: {0}", "approveFailed": "Genehmigen & Mergen fehlgeschlagen: {0}", "cancelReviewFailed": "Prüfung abbrechen fehlgeschlagen: {0}", "sendToQueueFailed": "In die Warteschlange stellen fehlgeschlagen: {0}", "queuePlanBlockedInteractive": "Plan kann nicht in die Warteschlange gestellt werden — {0} hat eine offene interaktive Sitzung und muss zuerst geschlossen werden.", "moveRunningRejected": "Ein laufender Task kann nicht in eine andere Liste verschoben werden.", "moveWorktreeRejected": "Verschieben nicht möglich — dieser Task hat einen aktiven Worktree, der auf sein aktuelles Repo zeigt.", "moveRepoConfirm": "Unterschiedliche Repos — {0} → {1}. Task trotzdem verschieben?", "moveConfirmUnavailable": "Verschieben nicht möglich — der Bestätigungsdialog ist nicht verfügbar.", "quickClaudeNoWorkingDir": "Für diese Liste ist kein Arbeitsverzeichnis konfiguriert.", "quickClaudeDirMissing": "Arbeitsverzeichnis existiert nicht mehr: {0}", "setStatusFailed": "Status konnte nicht aktualisiert werden: {0}", "cancelFailed": "Abbrechen fehlgeschlagen: {0}", "rejectToQueueFailed": "Zurückweisen in die Warteschlange fehlgeschlagen: {0}", "rejectToIdleFailed": "Zurückweisen fehlgeschlagen: {0}", "deleteTaskConfirm": "\"{0}\" löschen? Dies kann nicht rückgängig gemacht werden.", "deleteTaskFailed": "Löschen fehlgeschlagen: {0}", "toggleDoneFailed": "Erledigt-Status konnte nicht aktualisiert werden: {0}", "removeFromQueueFailed": "Aus der Warteschlange entfernen fehlgeschlagen: {0}", "openWorktreeFailed": "Worktree konnte nicht geöffnet werden: {0}" },
|
||||
"diff": { "loadFailed": "Diff konnte nicht geladen werden: {0}", "noChanges": "Keine Änderungen anzuzeigen.", "unavailable": "Diff nicht mehr verfügbar — Commit-Bereich unvollständig." },
|
||||
"planningDiff": { "hubError": "Kombinierte Vorschau konnte nicht erstellt werden (Hub-Fehler).", "conflict": "Kombinierte Vorschau nicht möglich: Teilaufgabe {0} steht im Konflikt mit einer früheren Teilaufgabe ({1} Dateien).", "buildFailed": "Kombinierte Vorschau konnte nicht erstellt werden: {0}" },
|
||||
"merge": { "commitMessage": "chore: merge {0}", "progressMerging": "Wird zusammengeführt…", "progressVerifying": "Verify-Kommando der Liste läuft… ({0})", "workerOfflineBranches": "Worker offline — Branches können nicht aufgelistet werden.", "loadBranchesFailed": "Branches konnten nicht geladen werden: {0}", "merged": "Zusammengeführt.", "blocked": "Blockiert: {0}", "verifyFailed": "Merge ist gelandet, aber das Verify-Kommando der Liste ist fehlgeschlagen — die Aufgabe wurde nicht auf Erledigt gesetzt.", "untrackedCollision": "Merge abgelehnt — er würde eine unversionierte Datei im Ziel-Arbeitsverzeichnis überschreiben.", "dirtyTarget": "Merge abgelehnt — das Ziel-Arbeitsverzeichnis hat uncommittete Änderungen.", "unknownStatus": "Unbekannter Status: {0}", "mergeFailed": "Merge fehlgeschlagen: {0}" },
|
||||
"merge": { "commitMessage": "chore: merge {0}", "progressMerging": "Wird zusammengeführt…", "progressVerifying": "Verify-Kommando der Liste läuft… ({0})", "workerOfflineBranches": "Worker offline — Branches können nicht aufgelistet werden.", "loadBranchesFailed": "Branches konnten nicht geladen werden: {0}", "merged": "Zusammengeführt.", "blocked": "Blockiert: {0}", "verifyFailed": "Merge ist gelandet, aber das Verify-Kommando der Liste ist fehlgeschlagen — die Aufgabe wurde nicht auf Erledigt gesetzt.", "untrackedCollision": "Merge abgelehnt — er würde eine unversionierte Datei im Ziel-Arbeitsverzeichnis überschreiben.", "dirtyTarget": "Merge abgelehnt — das Ziel-Arbeitsverzeichnis hat uncommittete Änderungen.", "targetDirty": "Das Ziel-Arbeitsverzeichnis hat bereits uncommittete Änderungen — stashen und mergen?", "unknownStatus": "Unbekannter Status: {0}", "mergeFailed": "Merge fehlgeschlagen: {0}" },
|
||||
"settingsModal": { "workerOffline": "Worker offline — Einstellungen schreibgeschützt.", "saveFailed": "Speichern fehlgeschlagen: {0}" },
|
||||
"onlineInbox": { "workerOffline": "Worker offline — Konfiguration kann nicht geladen werden.", "saved": "Konfiguration gespeichert.", "saveFailed": "Speichern fehlgeschlagen: {0}", "signedIn": "Erfolgreich angemeldet.", "signedInNoRole": "Angemeldet, aber diesem Konto fehlt die Rolle 'user' in Zitadel — die Online-Synchronisierung wird abgelehnt, bis die Rolle im ClaudeDo-Projekt zugewiesen wird.", "signInFailed": "Anmeldung fehlgeschlagen: {0}", "signedOut": "Abgemeldet.", "signOutFailed": "Abmeldung fehlgeschlagen: {0}" },
|
||||
"tickets": { "connectionOk": "Verbunden als {0}.", "connectionMissingScopes": "Verbunden als {0}, aber es fehlen Scopes: {1}", "tokenCleared": "Token entfernt.", "workerOffline": "Worker nicht erreichbar." },
|
||||
|
||||
@@ -328,6 +328,7 @@
|
||||
"worktree": "Worktree",
|
||||
"reviewCombinedDiff": "Review Combined Diff",
|
||||
"openDiffHint": "Open the diff to enable merge",
|
||||
"targetDirty": "Target working directory has uncommitted changes — merging will stash and restore them.",
|
||||
"approveMerge": "Approve & Merge",
|
||||
"park": "Park",
|
||||
"parkTip": "Set aside — back to Idle, keeps the worktree",
|
||||
@@ -805,6 +806,14 @@
|
||||
"done": "Done"
|
||||
}
|
||||
},
|
||||
"dialog": {
|
||||
"cancel": "Cancel",
|
||||
"confirm": "Confirm",
|
||||
"delete": "Delete",
|
||||
"ok": "OK",
|
||||
"confirmTitle": "Confirm",
|
||||
"errorTitle": "Error"
|
||||
},
|
||||
"shell": {
|
||||
"menu": {
|
||||
"help": "Help",
|
||||
@@ -851,7 +860,7 @@
|
||||
"tasksIsland": { "completedHeader": "COMPLETED", "completedHeaderCount": "COMPLETED · {0}", "statusPill": "{0} running · {1} review", "openTaskOne": "1 open task", "openTasks": "{0} open tasks", "planningResumeFailed": "Couldn't resume planning session: {0}", "approveFailed": "Approve & merge failed: {0}", "cancelReviewFailed": "Cancel review failed: {0}", "sendToQueueFailed": "Send to queue failed: {0}", "queuePlanBlockedInteractive": "Can't queue the plan — {0} has an open interactive session and must be closed first.", "moveRunningRejected": "Can't move a running task to another list.", "moveWorktreeRejected": "Can't move — this task has an active worktree pointing at its current repo.", "moveRepoConfirm": "Different repos — {0} → {1}. Move the task anyway?", "moveConfirmUnavailable": "Can't move — the confirmation dialog isn't available.", "quickClaudeNoWorkingDir": "This list has no working directory configured.", "quickClaudeDirMissing": "Working directory no longer exists: {0}", "setStatusFailed": "Failed to update status: {0}", "cancelFailed": "Cancel failed: {0}", "rejectToQueueFailed": "Reject to queue failed: {0}", "rejectToIdleFailed": "Reject failed: {0}", "deleteTaskConfirm": "Delete \"{0}\"? This cannot be undone.", "deleteTaskFailed": "Delete failed: {0}", "toggleDoneFailed": "Couldn't update done state: {0}", "removeFromQueueFailed": "Remove from queue failed: {0}", "openWorktreeFailed": "Couldn't open worktree: {0}" },
|
||||
"diff": { "loadFailed": "Failed to load diff: {0}", "noChanges": "No changes to show.", "unavailable": "Diff no longer available — commit range incomplete." },
|
||||
"planningDiff": { "hubError": "Could not build combined preview (hub error).", "conflict": "Cannot build combined preview: subtask {0} conflicts with an earlier subtask ({1} files).", "buildFailed": "Could not build combined preview: {0}" },
|
||||
"merge": { "commitMessage": "chore: merge {0}", "progressMerging": "Merging…", "progressVerifying": "Running the list's verify command… ({0})", "workerOfflineBranches": "Worker offline — cannot list branches.", "loadBranchesFailed": "Failed to load branches: {0}", "merged": "Merged.", "blocked": "Blocked: {0}", "verifyFailed": "Merge landed, but the list's verify command failed — the task was kept out of Done.", "untrackedCollision": "Merge refused — it would overwrite an untracked file in the target working directory.", "dirtyTarget": "Merge refused — the target working directory has uncommitted changes.", "unknownStatus": "Unknown status: {0}", "mergeFailed": "Merge failed: {0}" },
|
||||
"merge": { "commitMessage": "chore: merge {0}", "progressMerging": "Merging…", "progressVerifying": "Running the list's verify command… ({0})", "workerOfflineBranches": "Worker offline — cannot list branches.", "loadBranchesFailed": "Failed to load branches: {0}", "merged": "Merged.", "blocked": "Blocked: {0}", "verifyFailed": "Merge landed, but the list's verify command failed — the task was kept out of Done.", "untrackedCollision": "Merge refused — it would overwrite an untracked file in the target working directory.", "dirtyTarget": "Merge refused — the target working directory has uncommitted changes.", "targetDirty": "The target working directory already has uncommitted changes — stash them and merge?", "unknownStatus": "Unknown status: {0}", "mergeFailed": "Merge failed: {0}" },
|
||||
"settingsModal": { "workerOffline": "Worker offline — settings read-only.", "saveFailed": "Save failed: {0}" },
|
||||
"onlineInbox": { "workerOffline": "Worker offline — cannot load config.", "saved": "Config saved.", "saveFailed": "Save failed: {0}", "signedIn": "Signed in successfully.", "signedInNoRole": "Signed in, but this account is missing the 'user' role in Zitadel — online sync will be rejected until the role is granted in the ClaudeDo project.", "signInFailed": "Sign-in failed: {0}", "signedOut": "Signed out.", "signOutFailed": "Sign-out failed: {0}" },
|
||||
"tickets": { "connectionOk": "Connected as {0}.", "connectionMissingScopes": "Connected as {0}, but missing scopes: {1}", "tokenCleared": "Token removed.", "workerOffline": "Worker not reachable." },
|
||||
|
||||
@@ -92,7 +92,7 @@ single-task and planning unit-merge conflicts. Full detail →
|
||||
The two surfaces are **not** variants of one control (read-only 2-way renderer over aligned rows
|
||||
vs. 3-way editor over a writable document), but they share their AvaloniaEdit host plumbing via
|
||||
`Views/Controls/DiffEditorSetup.cs`: the process-wide TextMate `Registry`, `InstallHighlighting`,
|
||||
`ApplyGrammar`, the `Brush` resource fallback, the `Seg` segment, and `VerticalScrollSync`. Put
|
||||
`ApplyGrammar`, the `Brush` resource fallback, the `Seg` segment, and `ScrollSync`. Put
|
||||
new editor boilerplate there rather than copying it a third time.
|
||||
|
||||
## Services
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using ClaudeDo.Data;
|
||||
|
||||
namespace ClaudeDo.Ui.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Temp PNGs written for clipboard-image paste into the interactive terminal (see
|
||||
/// <see cref="ClaudeDo.Ui.Views.InteractiveTerminalView"/>) — dropped into the composer as a path,
|
||||
/// the same way a file drop is, then read by Claude like any other file.
|
||||
/// </summary>
|
||||
public static class ClipboardImageTempStore
|
||||
{
|
||||
public static string Directory => Path.Combine(Paths.AppDataRoot(), "tmp");
|
||||
|
||||
/// <summary>A fresh, collision-free path for one pasted image.</summary>
|
||||
public static string NewPngPath(DateTime utcNow) => NewPngPath(Directory, utcNow);
|
||||
|
||||
/// <summary>Deletes files older than <paramref name="maxAge"/> under the tmp dir — called once at
|
||||
/// app startup, not a running cleanup service.</summary>
|
||||
public static void DeleteOlderThan(TimeSpan maxAge, DateTime utcNow) => DeleteOlderThan(Directory, maxAge, utcNow);
|
||||
|
||||
/// <summary>Test seam for <see cref="NewPngPath(DateTime)"/> — takes the target dir explicitly
|
||||
/// instead of the real app data root.</summary>
|
||||
internal static string NewPngPath(string dir, DateTime utcNow) =>
|
||||
Path.Combine(dir, $"paste-{utcNow:yyyyMMdd-HHmmssfff}-{Guid.NewGuid():N}.png");
|
||||
|
||||
/// <summary>Test seam for <see cref="DeleteOlderThan(TimeSpan,DateTime)"/> — takes the target dir
|
||||
/// explicitly instead of the real app data root.</summary>
|
||||
internal static void DeleteOlderThan(string dir, TimeSpan maxAge, DateTime utcNow)
|
||||
{
|
||||
if (!System.IO.Directory.Exists(dir)) return;
|
||||
|
||||
foreach (var file in System.IO.Directory.EnumerateFiles(dir))
|
||||
{
|
||||
try
|
||||
{
|
||||
if (utcNow - File.GetLastWriteTimeUtc(file) > maxAge)
|
||||
File.Delete(file);
|
||||
}
|
||||
catch (IOException) { /* best effort */ }
|
||||
catch (UnauthorizedAccessException) { /* best effort */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -28,8 +28,9 @@ public interface IDialogService
|
||||
Task ShowConflictResolverAsync(ConflictResolverViewModel vm);
|
||||
Task ShowLogVisualizerAsync(LogVisualizerViewModel vm);
|
||||
|
||||
/// <summary>Modal yes/no confirmation. Returns true only when confirmed.</summary>
|
||||
Task<bool> ConfirmAsync(string message);
|
||||
/// <summary>Modal yes/no confirmation. Returns true only when confirmed. <paramref name="confirmLabel"/>
|
||||
/// overrides the default localized "Confirm" button text (e.g. a localized "Delete" for destructive actions).</summary>
|
||||
Task<bool> ConfirmAsync(string message, string? confirmLabel = null);
|
||||
|
||||
/// <summary>Modal error notice with a single dismiss button.</summary>
|
||||
Task ShowErrorAsync(string message);
|
||||
|
||||
@@ -291,11 +291,9 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
||||
// Set by shell so DeleteTaskCommand can remove from list
|
||||
public Func<TaskRowViewModel, System.Threading.Tasks.Task>? DeleteFromList { get; set; }
|
||||
|
||||
// Set by the view so DeleteTaskCommand can prompt yes/no before deleting
|
||||
public Func<string, System.Threading.Tasks.Task<bool>>? ConfirmAsync { get; set; }
|
||||
|
||||
// Set by the view so DeleteTaskCommand can show an error message
|
||||
public Func<string, System.Threading.Tasks.Task>? ShowErrorAsync { get; set; }
|
||||
// Set by the shell (mirrors ListsIslandViewModel.Dialogs) so confirm/error prompts go
|
||||
// through the one shared dialog implementation instead of a view-local copy.
|
||||
public IDialogService? Dialogs { get; set; }
|
||||
|
||||
// ── Review ──────────────────────────────────────────────────────────────
|
||||
[ObservableProperty]
|
||||
@@ -728,6 +726,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
||||
Merge.MergeTargetBranches.Clear();
|
||||
foreach (var b in targets.LocalBranches) Merge.MergeTargetBranches.Add(b);
|
||||
Merge.SelectedMergeTarget = targets.DefaultBranch;
|
||||
Merge.TargetDirty = targets.TargetDirty;
|
||||
}
|
||||
}
|
||||
await Merge.RefreshMergePreviewAsync();
|
||||
@@ -778,6 +777,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
||||
foreach (var b in targets.LocalBranches)
|
||||
Merge.MergeTargetBranches.Add(b);
|
||||
Merge.SelectedMergeTarget = targets.DefaultBranch;
|
||||
Merge.TargetDirty = targets.TargetDirty;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -824,6 +824,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
||||
foreach (var b in targets.LocalBranches)
|
||||
Merge.MergeTargetBranches.Add(b);
|
||||
Merge.SelectedMergeTarget = targets.DefaultBranch;
|
||||
Merge.TargetDirty = targets.TargetDirty;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1042,9 +1043,9 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
||||
{
|
||||
if (Task == null) return;
|
||||
var row = Task;
|
||||
if (ConfirmAsync != null)
|
||||
if (Dialogs != null)
|
||||
{
|
||||
var ok = await ConfirmAsync($"Delete \"{row.Title}\"? This cannot be undone.");
|
||||
var ok = await Dialogs.ConfirmAsync($"Delete \"{row.Title}\"? This cannot be undone.", Loc.T("dialog.delete"));
|
||||
if (!ok) return;
|
||||
}
|
||||
// Routed through the worker (mirrors the MCP delete_task tool) so a deleted child
|
||||
@@ -1060,14 +1061,14 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
||||
{
|
||||
// Belt and braces: the connection can drop between the CanExecute check and
|
||||
// this call, so a stray throw here must surface, not vanish silently.
|
||||
if (ShowErrorAsync != null)
|
||||
await ShowErrorAsync(ex.Message);
|
||||
if (Dialogs != null)
|
||||
await Dialogs.ShowErrorAsync(ex.Message);
|
||||
return;
|
||||
}
|
||||
if (!deleted)
|
||||
{
|
||||
if (ShowErrorAsync != null)
|
||||
await ShowErrorAsync(error ?? "Delete failed.");
|
||||
if (Dialogs != null)
|
||||
await Dialogs.ShowErrorAsync(error ?? "Delete failed.");
|
||||
return;
|
||||
}
|
||||
if (DeleteFromList != null)
|
||||
@@ -1216,10 +1217,10 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
||||
private async System.Threading.Tasks.Task ResetAndRetryAsync()
|
||||
{
|
||||
if (Task == null) return;
|
||||
if (ConfirmAsync == null) return;
|
||||
if (Dialogs == null) return;
|
||||
|
||||
var branchName = $"claudedo/{Task.Id.Replace("-", "")}";
|
||||
var ok = await ConfirmAsync(
|
||||
var ok = await Dialogs.ConfirmAsync(
|
||||
$"Reset and retry?\nThis discards branch {branchName} (and uncommitted changes), then queues the task to run from the beginning.");
|
||||
if (!ok) return;
|
||||
|
||||
@@ -1279,31 +1280,31 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
||||
// Nothing landed -- the target working dir has uncommitted changes (often another
|
||||
// session's WIP in the shared main checkout). Offer the one-shot stash retry; git
|
||||
// restores the changes itself once the merge is through.
|
||||
if (result?.Status == "dirty_target" && ConfirmAsync != null
|
||||
&& await ConfirmAsync(Loc.T("vm.detailsIsland.stashAndMergeConfirm")))
|
||||
if (result?.Status == "dirty_target" && Dialogs != null
|
||||
&& await Dialogs.ConfirmAsync(Loc.T("vm.detailsIsland.stashAndMergeConfirm")))
|
||||
{
|
||||
ApproveOp.Report(Loc.T("ops.merge.merging"));
|
||||
result = await _worker.ApproveReviewAsync(
|
||||
taskId, Merge.SelectedMergeTarget ?? "", autostash: true);
|
||||
}
|
||||
|
||||
if (result?.Status == "dirty_target" && ShowErrorAsync != null)
|
||||
await ShowErrorAsync(result.ErrorMessage ?? Loc.T("vm.detailsIsland.dirtyTarget"));
|
||||
if (result?.Status == "dirty_target" && Dialogs != null)
|
||||
await Dialogs.ShowErrorAsync(result.ErrorMessage ?? Loc.T("vm.detailsIsland.dirtyTarget"));
|
||||
else if (!hasChildren && result?.Status == "conflict")
|
||||
await _merge.ResolveConflictAsync(taskId, Merge.SelectedMergeTarget ?? "");
|
||||
// The merge itself already landed; the verify command failed, so the task stayed
|
||||
// out of Done. Surface that instead of silently looking like nothing happened.
|
||||
else if (!hasChildren && result?.Status == "verify_failed" && ShowErrorAsync != null)
|
||||
await ShowErrorAsync(result.ErrorMessage ?? Loc.T("vm.detailsIsland.verifyFailed"));
|
||||
else if (!hasChildren && result?.Status == "verify_failed" && Dialogs != null)
|
||||
await Dialogs.ShowErrorAsync(result.ErrorMessage ?? Loc.T("vm.detailsIsland.verifyFailed"));
|
||||
// Nothing landed -- the merge was refused before touching the repo because the
|
||||
// branch would have overwritten an untracked file in the target working directory.
|
||||
else if (!hasChildren && result?.Status == "untracked_collision" && ShowErrorAsync != null)
|
||||
await ShowErrorAsync(result.ErrorMessage ?? Loc.T("vm.detailsIsland.untrackedCollision"));
|
||||
else if (!hasChildren && result?.Status == "untracked_collision" && Dialogs != null)
|
||||
await Dialogs.ShowErrorAsync(result.ErrorMessage ?? Loc.T("vm.detailsIsland.untrackedCollision"));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (ShowErrorAsync != null)
|
||||
await ShowErrorAsync(ex.Message);
|
||||
if (Dialogs != null)
|
||||
await Dialogs.ShowErrorAsync(ex.Message);
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -1348,7 +1349,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
||||
try { await _worker.SubmitTaskForReviewAsync(Task.Id); }
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (ShowErrorAsync != null) await ShowErrorAsync(ex.Message);
|
||||
if (Dialogs != null) await Dialogs.ShowErrorAsync(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1379,9 +1380,9 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
||||
[RelayCommand]
|
||||
private async System.Threading.Tasks.Task ResetReviewAsync()
|
||||
{
|
||||
if (Task is null || !_worker.IsConnected || ConfirmAsync is null) return;
|
||||
if (Task is null || !_worker.IsConnected || Dialogs is null) return;
|
||||
var branchName = $"claudedo/{Task.Id.Replace("-", "")}";
|
||||
var ok = await ConfirmAsync(
|
||||
var ok = await Dialogs.ConfirmAsync(
|
||||
$"Reset working tree?\nThis discards branch {branchName} (and all changes) and returns the task to Idle.");
|
||||
if (!ok) return;
|
||||
try { await _worker.ResetTaskAsync(Task.Id); }
|
||||
|
||||
@@ -622,16 +622,33 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
|
||||
await MoveToFolderAsync(row, null);
|
||||
}
|
||||
|
||||
private readonly Dictionary<string, CancellationTokenSource> _folderCollapseCts = new();
|
||||
|
||||
// Synchronous on purpose: the fold is pure UI state and must never miss a click waiting on
|
||||
// the AsyncRelayCommand's own CanExecute lock while the DB write below is in flight. The
|
||||
// write is fire-and-forget with a per-folder cancellation token so a second toggle before the
|
||||
// first one lands cancels it — the DB always ends up with the last-clicked state, never a
|
||||
// stale one racing ahead of it.
|
||||
[RelayCommand]
|
||||
private async Task ToggleFolderAsync(ListFolderNavItemViewModel? folder)
|
||||
private void ToggleFolder(ListFolderNavItemViewModel? folder)
|
||||
{
|
||||
if (folder is null) return;
|
||||
folder.IsCollapsed = !folder.IsCollapsed;
|
||||
if (_folderCollapseCts.TryGetValue(folder.Id, out var previous)) previous.Cancel();
|
||||
var cts = new CancellationTokenSource();
|
||||
_folderCollapseCts[folder.Id] = cts;
|
||||
_ = PersistFolderCollapsedAsync(folder.Id, folder.IsCollapsed, cts.Token);
|
||||
}
|
||||
|
||||
private async Task PersistFolderCollapsedAsync(string folderId, bool isCollapsed, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var ctx = await _dbFactory.CreateDbContextAsync();
|
||||
await new ListRepository(ctx).SetFolderCollapsedAsync(folder.Id, folder.IsCollapsed);
|
||||
ct.ThrowIfCancellationRequested();
|
||||
await new ListRepository(ctx).SetFolderCollapsedAsync(folderId, isCollapsed);
|
||||
}
|
||||
catch (OperationCanceledException) { }
|
||||
catch (Exception ex)
|
||||
{
|
||||
// The fold itself already happened in the UI; only its persistence failed.
|
||||
|
||||
@@ -28,6 +28,10 @@ public sealed partial class MergeSectionViewModel : ViewModelBase
|
||||
[ObservableProperty] private ObservableCollection<string> _mergeTargetBranches = new();
|
||||
[ObservableProperty] private string? _selectedMergeTarget;
|
||||
|
||||
// Mirrors MergeTargetsDto.TargetDirty — surfaced next to Approve so a dirty target working
|
||||
// dir is visible before anyone clicks, not just after the merge modal refuses it.
|
||||
[ObservableProperty] private bool _targetDirty;
|
||||
|
||||
// No CanExecute to gate here — RefreshMergePreviewAsync is triggered from the Bind path,
|
||||
// not a [RelayCommand], so PreviewOp only drives the indicator, never locks a button.
|
||||
public OperationStatus PreviewOp { get; } = new();
|
||||
@@ -141,6 +145,7 @@ public sealed partial class MergeSectionViewModel : ViewModelBase
|
||||
MergePreviewText = "";
|
||||
MergeIsClean = false;
|
||||
MergeIsConflict = false;
|
||||
TargetDirty = false;
|
||||
SyncWorktree(null, null, null, null, null);
|
||||
SyncTaskContext(null, null, false);
|
||||
SyncChildOutcomes(false, 0);
|
||||
|
||||
@@ -769,12 +769,16 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
|
||||
|
||||
// A predecessor missing from both rowsById and the external cache counts as unmet -- mirrors
|
||||
// QueuePicker/Worker's TaskBlocking.Unmet: an edge pointing at an unresolved/vanished task
|
||||
// keeps blocking rather than being silently treated as satisfied.
|
||||
// keeps blocking rather than being silently treated as satisfied. A landed merge satisfies
|
||||
// the edge too (skipVerify parks a merged task in WaitingForReview until the batch verify);
|
||||
// the external cache carries no worktree state, so a cross-list predecessor still shows as
|
||||
// waiting until it reaches Done.
|
||||
private static bool IsUnmet(
|
||||
string id, Dictionary<string, TaskRowViewModel> rowsById,
|
||||
IReadOnlyDictionary<string, (int Number, string Title, TaskStatus Status)> external)
|
||||
{
|
||||
if (rowsById.TryGetValue(id, out var p)) return p.Status != TaskStatus.Done;
|
||||
if (rowsById.TryGetValue(id, out var p))
|
||||
return p.Status != TaskStatus.Done && p.WorktreeState != WorktreeState.Merged;
|
||||
if (external.TryGetValue(id, out var e)) return e.Status != TaskStatus.Done;
|
||||
return true;
|
||||
}
|
||||
@@ -1205,9 +1209,9 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
|
||||
// block removing a completed planning parent together with its done children.
|
||||
var toDelete = completed.OrderByDescending(r => r.IsChild).ToList();
|
||||
|
||||
if (ConfirmAsync is not null)
|
||||
if (Dialogs is not null)
|
||||
{
|
||||
var ok = await ConfirmAsync($"Clear {toDelete.Count} completed task(s)? This cannot be undone.");
|
||||
var ok = await Dialogs.ConfirmAsync($"Clear {toDelete.Count} completed task(s)? This cannot be undone.", Loc.T("dialog.delete"));
|
||||
if (!ok) return;
|
||||
}
|
||||
|
||||
@@ -1335,9 +1339,9 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
|
||||
private async Task DeleteTaskAsync(TaskRowViewModel? row)
|
||||
{
|
||||
if (row is null || row.HasInteractiveSession || _worker is null) return;
|
||||
if (ConfirmAsync is not null)
|
||||
if (Dialogs is not null)
|
||||
{
|
||||
var ok = await ConfirmAsync(Loc.T("vm.tasksIsland.deleteTaskConfirm", row.Title));
|
||||
var ok = await Dialogs.ConfirmAsync(Loc.T("vm.tasksIsland.deleteTaskConfirm", row.Title), Loc.T("dialog.delete"));
|
||||
if (!ok) return;
|
||||
}
|
||||
|
||||
@@ -1649,8 +1653,8 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
|
||||
|
||||
if (outcome.Result == DiscardPlanningResult.BlockedByQueuedChildren)
|
||||
{
|
||||
if (ConfirmAsync is null) return;
|
||||
var ok = await ConfirmAsync(
|
||||
if (Dialogs is null) return;
|
||||
var ok = await Dialogs.ConfirmAsync(
|
||||
$"{outcome.QueuedChildrenCount} child task(s) are queued.\n" +
|
||||
"Dequeue them and discard the planning session?");
|
||||
if (!ok) return;
|
||||
@@ -1659,18 +1663,13 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
|
||||
}
|
||||
else if (outcome.Result == DiscardPlanningResult.BlockedByRunningChildren)
|
||||
{
|
||||
if (ConfirmAsync is null) return;
|
||||
await ConfirmAsync(
|
||||
if (Dialogs is null) return;
|
||||
await Dialogs.ConfirmAsync(
|
||||
$"{outcome.RunningChildrenCount} child task(s) are still running.\n" +
|
||||
"Cancel them first, then try again.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wired by the view via <see cref="ShowConfirmAsync"/>. Returns true when the user confirms.
|
||||
/// </summary>
|
||||
public Func<string, Task<bool>>? ConfirmAsync { get; set; }
|
||||
|
||||
private bool CanRunPlanningOperation() => !PlanningOperation.IsRunning;
|
||||
|
||||
[RelayCommand(CanExecute = nameof(CanRunPlanningOperation))]
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Controls.ApplicationLifetimes;
|
||||
using Avalonia.Threading;
|
||||
using System;
|
||||
using System.Threading;
|
||||
@@ -66,7 +68,7 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
|
||||
// Set by MainWindow so a reveal can bring the main window to the foreground.
|
||||
public Action? BringToFront { get; set; }
|
||||
|
||||
// Single dialog seam (set by MainWindow); propagated to the lists island.
|
||||
// Single dialog seam (set by MainWindow); propagated to the islands.
|
||||
private IDialogService? _dialogs;
|
||||
public IDialogService? Dialogs
|
||||
{
|
||||
@@ -76,6 +78,7 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
|
||||
_dialogs = value;
|
||||
if (Lists is not null) Lists.Dialogs = value;
|
||||
if (Tasks is not null) Tasks.Dialogs = value;
|
||||
if (Details is not null) Details.Dialogs = value;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -655,7 +658,15 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
|
||||
|
||||
private void RestartWorkerService()
|
||||
{
|
||||
var exe = _workerLocator.Find();
|
||||
// Prefer the exe path of the worker that's actually running: _workerLocator.Find()
|
||||
// walks up from AppContext.BaseDirectory looking for install.json and falls back to
|
||||
// the registry's InstallLocation otherwise — on a debug build launched from the repo
|
||||
// there's no install.json above it, so it returns the *installed* worker's path. That
|
||||
// never matches the running debug process, so KillMatchingWorkerProcesses silently
|
||||
// skips it (it only kills a process whose path equals the given exe) and this either
|
||||
// launches a second worker that immediately dies on the already-bound port, or leaves
|
||||
// the real one running untouched while nothing visibly changes.
|
||||
var exe = RunningWorkerExePath() ?? _workerLocator.Find();
|
||||
if (exe is null) throw new InvalidOperationException("Worker executable not found.");
|
||||
|
||||
KillMatchingWorkerProcesses(exe);
|
||||
@@ -665,6 +676,20 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
|
||||
throw new InvalidOperationException($"worker process exited immediately (code {started.ExitCode})");
|
||||
}
|
||||
|
||||
private static string? RunningWorkerExePath()
|
||||
{
|
||||
foreach (var p in System.Diagnostics.Process.GetProcessesByName("ClaudeDo.Worker"))
|
||||
{
|
||||
try
|
||||
{
|
||||
if (p.MainModule?.FileName is { } path) return path;
|
||||
}
|
||||
catch { /* inaccessible */ }
|
||||
finally { p.Dispose(); }
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Only kill the worker belonging to THIS installation — not any other
|
||||
// ClaudeDo.Worker on the machine (e.g. a second install).
|
||||
private static void KillMatchingWorkerProcesses(string exe)
|
||||
@@ -686,10 +711,6 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
// Set by MainWindow: force-closes the window without re-triggering its own close
|
||||
// confirmation (the shutdown dialog above already confirmed).
|
||||
public Action? CloseWindow { get; set; }
|
||||
|
||||
// Test seam: overrides the real kill-worker + close-window body.
|
||||
internal Action? ShutdownOperation { get; set; }
|
||||
|
||||
@@ -706,7 +727,14 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
|
||||
{
|
||||
var exe = _workerLocator.Find();
|
||||
if (exe is not null) KillMatchingWorkerProcesses(exe);
|
||||
CloseWindow?.Invoke();
|
||||
|
||||
// Closing just the MainWindow relies on ShutdownMode.OnMainWindowClose, which asks
|
||||
// every open window to close and backs off if any of them cancels — and
|
||||
// MissionControlWindow always cancels its own close (it hides instead, see
|
||||
// MissionControlWindow.OnClosing), so the process never actually exited. Shutdown()
|
||||
// force-closes every window, ignoring that cancellation.
|
||||
if (Application.Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
||||
desktop.Shutdown();
|
||||
}
|
||||
|
||||
private bool _updatingClaudeCli;
|
||||
|
||||
@@ -76,6 +76,14 @@ public sealed partial class MergeModalViewModel : ViewModelBase
|
||||
SelectedBranch = Branches.Contains(targets.DefaultBranch)
|
||||
? targets.DefaultBranch
|
||||
: Branches.FirstOrDefault();
|
||||
|
||||
// Known dirty before anyone clicks Merge — offer the stash retry right away instead
|
||||
// of making the user hit the "dirty_target" refusal first.
|
||||
if (targets.TargetDirty)
|
||||
{
|
||||
WarningMessage = Loc.T("vm.merge.targetDirty");
|
||||
ShowStashRetry = true;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
@@ -16,6 +16,7 @@ public enum BatchMergeOutcome { None, Merging, Merged, Conflict, Blocked, Verify
|
||||
public sealed partial class WorktreeOverviewRowViewModel : ViewModelBase
|
||||
{
|
||||
[ObservableProperty] private string _taskId = "";
|
||||
[ObservableProperty] private int _taskNumber;
|
||||
[ObservableProperty] private string _taskTitle = "";
|
||||
[ObservableProperty][NotifyPropertyChangedFor(nameof(IsRunning))] private TaskStatus _taskStatus;
|
||||
[ObservableProperty] private string _listId = "";
|
||||
@@ -49,6 +50,9 @@ public sealed partial class WorktreeOverviewRowViewModel : ViewModelBase
|
||||
public bool IsKept => State == WorktreeState.Kept;
|
||||
public bool IsRunning => TaskStatus == TaskStatus.Running;
|
||||
|
||||
// 0 = row without an allocated number (test seeds); a bare "#0" would be meaningless.
|
||||
public bool ShowNumberBadge => TaskNumber > 0;
|
||||
|
||||
private static string FormatAge(TimeSpan ts)
|
||||
{
|
||||
if (ts.TotalDays >= 1) return $"{(int)ts.TotalDays}d ago";
|
||||
@@ -366,7 +370,7 @@ public sealed partial class WorktreesOverviewModalViewModel : ViewModelBase
|
||||
|
||||
private static WorktreeOverviewRowViewModel Map(WorktreeOverviewDto d) => new()
|
||||
{
|
||||
TaskId = d.TaskId, TaskTitle = d.TaskTitle, TaskStatus = d.TaskStatus,
|
||||
TaskId = d.TaskId, TaskNumber = d.TaskNumber, TaskTitle = d.TaskTitle, TaskStatus = d.TaskStatus,
|
||||
ListId = d.ListId, ListName = d.ListName,
|
||||
Path = d.Path, BranchName = d.BranchName, BaseCommit = d.BaseCommit, State = d.State,
|
||||
DiffStat = d.DiffStat, CreatedAt = d.CreatedAt, PathExistsOnDisk = d.PathExistsOnDisk,
|
||||
|
||||
@@ -78,8 +78,8 @@ public partial class ConflictResolverView : Window
|
||||
|
||||
// Panes are whole files here, not aligned rows, so raw pixel offsets line up and the
|
||||
// gutter buttons only have to be repositioned once per scroll.
|
||||
new VerticalScrollSync(new[] { OursEditor, ResultEditor, TheirsEditor },
|
||||
afterSync: PositionGutters);
|
||||
new ScrollSync(new[] { OursEditor, ResultEditor, TheirsEditor },
|
||||
afterSync: PositionGutters);
|
||||
|
||||
var conflict = DiffEditorSetup.Brush(this, "MergeConflictTintBrush", Color.Parse("#28C87060"));
|
||||
var resolved = DiffEditorSetup.Brush(this, "MergeResolvedTintBrush", Color.Parse("#206FA86B"));
|
||||
|
||||
@@ -75,9 +75,11 @@ internal static class DiffEditorSetup
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Keeps a set of editors scrolled to the same vertical position. Construction wires the panes;
|
||||
/// there is nothing to dispose, and nothing to keep a reference to — the event subscriptions on
|
||||
/// the text views own the instance.
|
||||
/// Keeps a set of editors scrolled to the same position, vertically always and horizontally
|
||||
/// whenever the panes share the same content width (no per-pane offset conversion, unlike
|
||||
/// <see cref="TargetOffset"/> for Y). Construction wires the panes; there is nothing to dispose,
|
||||
/// and nothing to keep a reference to — the event subscriptions on the text views own the
|
||||
/// instance.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
@@ -96,7 +98,7 @@ internal static class DiffEditorSetup
|
||||
/// leaves the ScrollViewer's own offset (and therefore the scrollbar thumb) behind.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed class VerticalScrollSync
|
||||
internal sealed class ScrollSync
|
||||
{
|
||||
private readonly TextEditor[] _editors;
|
||||
private readonly ScrollViewer?[] _viewers;
|
||||
@@ -108,8 +110,8 @@ internal sealed class VerticalScrollSync
|
||||
/// <param name="isActive">Gate for layouts where the panes are not side by side.</param>
|
||||
/// <param name="anchorByLine">True while lines wrap — see <see cref="TargetOffset"/>.</param>
|
||||
/// <param name="afterSync">Runs once per user scroll, not per echo.</param>
|
||||
public VerticalScrollSync(TextEditor[] editors, Func<bool>? isActive = null,
|
||||
Func<bool>? anchorByLine = null, Action? afterSync = null)
|
||||
public ScrollSync(TextEditor[] editors, Func<bool>? isActive = null,
|
||||
Func<bool>? anchorByLine = null, Action? afterSync = null)
|
||||
{
|
||||
_editors = editors;
|
||||
_viewers = new ScrollViewer?[editors.Length];
|
||||
@@ -136,15 +138,18 @@ internal sealed class VerticalScrollSync
|
||||
_syncing = true;
|
||||
try
|
||||
{
|
||||
var x = _editors[sourceIndex].TextArea.TextView.ScrollOffset.X;
|
||||
for (var i = 0; i < _editors.Length; i++)
|
||||
{
|
||||
if (i == sourceIndex) continue;
|
||||
if (Viewer(i) is not { } viewer) continue;
|
||||
if (TargetOffset(_editors[sourceIndex], _editors[i]) is not { } y) continue;
|
||||
// The echo from the target's own ScrollOffsetChanged lands after _syncing is
|
||||
// cleared again, so this comparison — not the flag — is what ends the loop.
|
||||
if (Math.Abs(viewer.Offset.Y - y) > 0.5)
|
||||
viewer.Offset = new Vector(viewer.Offset.X, y);
|
||||
// cleared again, so this comparison — not the flag — is what ends the loop. Both
|
||||
// axes must be checked: writing only when Y moved left X's own echo unguarded,
|
||||
// which is how it looped forever once X was written below.
|
||||
if (Math.Abs(viewer.Offset.X - x) > 0.5 || Math.Abs(viewer.Offset.Y - y) > 0.5)
|
||||
viewer.Offset = new Vector(x, y);
|
||||
}
|
||||
}
|
||||
finally { _syncing = false; }
|
||||
|
||||
@@ -85,8 +85,8 @@ public partial class DiffTextView : UserControl
|
||||
_leftTm = DiffEditorSetup.InstallHighlighting(LeftEditor);
|
||||
_rightTm = DiffEditorSetup.InstallHighlighting(RightEditor);
|
||||
ReloadFile();
|
||||
new VerticalScrollSync(new[] { LeftEditor, RightEditor },
|
||||
isActive: () => IsSplit, anchorByLine: () => WrapLines);
|
||||
new ScrollSync(new[] { LeftEditor, RightEditor },
|
||||
isActive: () => IsSplit, anchorByLine: () => WrapLines);
|
||||
}
|
||||
|
||||
/// Brushes and the mono typeface only resolve once the control is in the visual tree —
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Platform.Storage;
|
||||
using ClaudeDo.Ui.Services;
|
||||
using ClaudeDo.Ui.ViewModels;
|
||||
|
||||
namespace ClaudeDo.Ui.Views;
|
||||
@@ -43,13 +45,54 @@ public partial class InteractiveTerminalView : UserControl
|
||||
if (DataContext is not InteractiveTerminalViewModel vm) return;
|
||||
|
||||
e.Handled = true;
|
||||
_ = PasteAndReportAsync(vm);
|
||||
_ = PasteAndReportAsync(this, vm);
|
||||
}
|
||||
|
||||
private static async System.Threading.Tasks.Task PasteAndReportAsync(InteractiveTerminalViewModel vm)
|
||||
// A clipboard image (e.g. a screenshot) can't be typed into the child process, so it's written
|
||||
// to a temp PNG and its path is inserted the same way a dropped file is — Claude reads the file
|
||||
// itself. Plain-text clipboard content keeps going through the existing bracketed-paste flow.
|
||||
// Once a bitmap is confirmed present, any failure (dir/file write, send) is a real paste
|
||||
// failure — never silently fall back to a text paste of the same clipboard.
|
||||
private static async System.Threading.Tasks.Task PasteAndReportAsync(InteractiveTerminalView view, InteractiveTerminalViewModel vm)
|
||||
{
|
||||
if (!await vm.PasteAsync())
|
||||
Avalonia.Media.Imaging.Bitmap? bitmap = null;
|
||||
try
|
||||
{
|
||||
bitmap = await TryGetClipboardBitmapAsync(TopLevel.GetTopLevel(view));
|
||||
if (bitmap is null)
|
||||
{
|
||||
if (!await vm.PasteAsync()) vm.ReportSendFailed();
|
||||
return;
|
||||
}
|
||||
|
||||
var path = ClipboardImageTempStore.NewPngPath(DateTime.UtcNow);
|
||||
Directory.CreateDirectory(ClipboardImageTempStore.Directory);
|
||||
bitmap.Save(path);
|
||||
if (!await vm.SendTextAsync(FormatDroppedPaths(new[] { path })))
|
||||
vm.ReportSendFailed();
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
vm.ReportSendFailed();
|
||||
}
|
||||
finally
|
||||
{
|
||||
bitmap?.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private static async System.Threading.Tasks.Task<Avalonia.Media.Imaging.Bitmap?> TryGetClipboardBitmapAsync(TopLevel? topLevel)
|
||||
{
|
||||
if (topLevel?.Clipboard is not { } clipboard) return null;
|
||||
try
|
||||
{
|
||||
using var data = await clipboard.TryGetDataAsync();
|
||||
return data is null ? null : await Avalonia.Input.AsyncDataTransferExtensions.TryGetBitmapAsync(data);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsFilesDrop(DragEventArgs e) => e.DataTransfer?.Contains(DataFormat.File) == true;
|
||||
|
||||
@@ -327,6 +327,10 @@
|
||||
<ComboBox ItemsSource="{Binding Merge.MergeTargetBranches}"
|
||||
SelectedItem="{Binding Merge.SelectedMergeTarget, Mode=TwoWay}"
|
||||
HorizontalAlignment="Stretch" />
|
||||
<TextBlock Classes="meta" Text="{loc:Tr details.workConsole.targetDirty}"
|
||||
TextWrapping="Wrap"
|
||||
Foreground="{DynamicResource AmberBrush}"
|
||||
IsVisible="{Binding Merge.TargetDirty}" />
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Spacing="0">
|
||||
|
||||
@@ -173,8 +173,6 @@ public partial class DetailsIslandView : UserControl
|
||||
await modal.ShowDialog(owner);
|
||||
};
|
||||
|
||||
vm.ConfirmAsync = ShowConfirmAsync;
|
||||
vm.ShowErrorAsync = ShowErrorDialogAsync;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -213,87 +211,4 @@ public partial class DetailsIslandView : UserControl
|
||||
_descriptionHeights[id] = DetailBodyGrid.RowDefinitions[0].Height.Value;
|
||||
}
|
||||
|
||||
private async System.Threading.Tasks.Task ShowErrorDialogAsync(string message)
|
||||
{
|
||||
var owner = TopLevel.GetTopLevel(this) as Window;
|
||||
if (owner == null) return;
|
||||
|
||||
var ok = new Button { Content = "OK", MinWidth = 90 };
|
||||
|
||||
var dialog = new Window
|
||||
{
|
||||
Title = "Error",
|
||||
Width = 360,
|
||||
SizeToContent = SizeToContent.Height,
|
||||
CanResize = false,
|
||||
WindowStartupLocation = WindowStartupLocation.CenterOwner,
|
||||
ShowInTaskbar = false,
|
||||
Background = this.FindResource("SurfaceBrush") as IBrush,
|
||||
Content = new StackPanel
|
||||
{
|
||||
Spacing = 16,
|
||||
Margin = new Thickness(20),
|
||||
Children =
|
||||
{
|
||||
new TextBlock { Text = message, TextWrapping = TextWrapping.Wrap },
|
||||
new StackPanel
|
||||
{
|
||||
Orientation = Orientation.Horizontal,
|
||||
Spacing = 8,
|
||||
HorizontalAlignment = HorizontalAlignment.Right,
|
||||
Children = { ok }
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
ok.Click += (_, _) => dialog.Close();
|
||||
|
||||
await dialog.ShowDialog(owner);
|
||||
}
|
||||
|
||||
private async System.Threading.Tasks.Task<bool> ShowConfirmAsync(string message)
|
||||
{
|
||||
var owner = TopLevel.GetTopLevel(this) as Window;
|
||||
if (owner == null) return false;
|
||||
|
||||
var tcs = new TaskCompletionSource<bool>();
|
||||
|
||||
var cancel = new Button { Content = "Cancel", MinWidth = 90 };
|
||||
var confirm = new Button { Content = "Delete", MinWidth = 90, Classes = { "danger" } };
|
||||
|
||||
var dialog = new Window
|
||||
{
|
||||
Title = "Confirm",
|
||||
Width = 360,
|
||||
SizeToContent = SizeToContent.Height,
|
||||
CanResize = false,
|
||||
WindowStartupLocation = WindowStartupLocation.CenterOwner,
|
||||
ShowInTaskbar = false,
|
||||
Background = this.FindResource("SurfaceBrush") as IBrush,
|
||||
Content = new StackPanel
|
||||
{
|
||||
Spacing = 16,
|
||||
Margin = new Thickness(20),
|
||||
Children =
|
||||
{
|
||||
new TextBlock { Text = message, TextWrapping = TextWrapping.Wrap },
|
||||
new StackPanel
|
||||
{
|
||||
Orientation = Orientation.Horizontal,
|
||||
Spacing = 8,
|
||||
HorizontalAlignment = HorizontalAlignment.Right,
|
||||
Children = { cancel, confirm }
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
cancel.Click += (_, _) => { tcs.TrySetResult(false); dialog.Close(); };
|
||||
confirm.Click += (_, _) => { tcs.TrySetResult(true); dialog.Close(); };
|
||||
dialog.Closed += (_, _) => tcs.TrySetResult(false);
|
||||
|
||||
_ = dialog.ShowDialog(owner);
|
||||
return await tcs.Task;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,7 +55,6 @@ public partial class TasksIslandView : UserControl
|
||||
await modal.ShowDialog(owner);
|
||||
// ShowDialog completes once the window is closed (CloseAction or OS close).
|
||||
};
|
||||
vm.ConfirmAsync = ShowConfirmAsync;
|
||||
vm.SelectionChanged += (_, _) => ScrollSelectedIntoView();
|
||||
}
|
||||
};
|
||||
@@ -71,50 +70,6 @@ public partial class TasksIslandView : UserControl
|
||||
Dispatcher.UIThread.Post(() => RowsListBox.ScrollIntoView(target), DispatcherPriority.Background);
|
||||
}
|
||||
|
||||
private async System.Threading.Tasks.Task<bool> ShowConfirmAsync(string message)
|
||||
{
|
||||
var owner = TopLevel.GetTopLevel(this) as Window;
|
||||
if (owner is null) return false;
|
||||
|
||||
var tcs = new TaskCompletionSource<bool>();
|
||||
var cancel = new Button { Content = "Cancel", MinWidth = 90 };
|
||||
var confirm = new Button { Content = "Confirm", MinWidth = 90, Classes = { "danger" } };
|
||||
|
||||
var dialog = new Window
|
||||
{
|
||||
Title = "Confirm",
|
||||
Width = 380,
|
||||
SizeToContent = SizeToContent.Height,
|
||||
CanResize = false,
|
||||
WindowStartupLocation = WindowStartupLocation.CenterOwner,
|
||||
ShowInTaskbar = false,
|
||||
Background = this.FindResource("SurfaceBrush") as IBrush,
|
||||
Content = new StackPanel
|
||||
{
|
||||
Margin = new Thickness(20),
|
||||
Spacing = 16,
|
||||
Children =
|
||||
{
|
||||
new TextBlock { Text = message, TextWrapping = TextWrapping.Wrap },
|
||||
new StackPanel
|
||||
{
|
||||
Orientation = Orientation.Horizontal,
|
||||
HorizontalAlignment = HorizontalAlignment.Right,
|
||||
Spacing = 8,
|
||||
Children = { cancel, confirm },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
cancel.Click += (_, _) => { tcs.TrySetResult(false); dialog.Close(); };
|
||||
confirm.Click += (_, _) => { tcs.TrySetResult(true); dialog.Close(); };
|
||||
dialog.Closed += (_, _) => tcs.TrySetResult(false);
|
||||
|
||||
_ = dialog.ShowDialog(owner);
|
||||
return await tcs.Task;
|
||||
}
|
||||
|
||||
// ── Custom ghost drag ────────────────────────────────────────────────────
|
||||
// Replaces both the OLE DoDragDropAsync reorder and the OLE drop-to-queue path: a hand-built
|
||||
// drag (pointer capture + a transparent topmost ghost window) is the only way to get a
|
||||
|
||||
@@ -80,11 +80,6 @@ public partial class MainWindow : Window
|
||||
WindowState = WindowState.Normal;
|
||||
Activate();
|
||||
};
|
||||
vm.CloseWindow = () =>
|
||||
{
|
||||
_forceClose = true;
|
||||
Close();
|
||||
};
|
||||
if (vm.Tasks is not null)
|
||||
vm.Tasks.IsWindowVisible = WindowState != WindowState.Minimized;
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ using Avalonia.Layout;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Platform.Storage;
|
||||
using Avalonia.VisualTree;
|
||||
using ClaudeDo.Ui.Localization;
|
||||
using ClaudeDo.Ui.ViewModels;
|
||||
|
||||
namespace ClaudeDo.Ui.Views.MissionControl;
|
||||
@@ -73,20 +74,21 @@ public partial class MissionControlView : UserControl
|
||||
UpdateQueueColumn();
|
||||
}
|
||||
|
||||
// Abandon-run confirmation -- same TaskCompletionSource-backed plain Window as
|
||||
// TasksIslandView.ShowConfirmAsync (no shared control for this shape yet).
|
||||
// Abandon-run confirmation -- own plain-Window TaskCompletionSource dialog, not routed
|
||||
// through WindowDialogService: it's owned by the main window, so showing it while Mission
|
||||
// Control (a separate Window) is focused would put it behind Mission Control.
|
||||
private async Task<bool> ShowConfirmAsync(string message)
|
||||
{
|
||||
var owner = TopLevel.GetTopLevel(this) as Window;
|
||||
if (owner is null) return false;
|
||||
|
||||
var tcs = new TaskCompletionSource<bool>();
|
||||
var cancel = new Button { Content = "Cancel", MinWidth = 90 };
|
||||
var confirm = new Button { Content = "Confirm", MinWidth = 90, Classes = { "danger" } };
|
||||
var cancel = new Button { Content = Loc.T("dialog.cancel"), MinWidth = 90 };
|
||||
var confirm = new Button { Content = Loc.T("dialog.confirm"), MinWidth = 90, Classes = { "danger" } };
|
||||
|
||||
var dialog = new Window
|
||||
{
|
||||
Title = "Confirm",
|
||||
Title = Loc.T("dialog.confirmTitle"),
|
||||
Width = 380,
|
||||
SizeToContent = SizeToContent.Height,
|
||||
CanResize = false,
|
||||
|
||||
@@ -70,6 +70,11 @@
|
||||
TextWrapping="Wrap"
|
||||
IsVisible="{Binding ErrorMessage, Converter={x:Static ObjectConverters.IsNotNull}}" />
|
||||
|
||||
<TextBlock Text="{Binding WarningMessage}"
|
||||
Foreground="{DynamicResource AmberBrush}"
|
||||
TextWrapping="Wrap"
|
||||
IsVisible="{Binding WarningMessage, Converter={x:Static ObjectConverters.IsNotNull}}" />
|
||||
|
||||
<Button Classes="btn" HorizontalAlignment="Left"
|
||||
Content="{loc:Tr modals.merge.stashAndMerge}"
|
||||
Command="{Binding StashAndMergeCommand}"
|
||||
|
||||
@@ -58,13 +58,18 @@
|
||||
CommandParameter="{Binding}"/>
|
||||
</ContextMenu>
|
||||
</Border.ContextMenu>
|
||||
<Grid ColumnDefinitions="Auto,*,90,90,80,80">
|
||||
<Grid ColumnDefinitions="Auto,*,90,90,110,80">
|
||||
<CheckBox Grid.Column="0" VerticalAlignment="Center" Margin="0,0,8,0"
|
||||
IsChecked="{Binding IsChecked, Mode=TwoWay}"
|
||||
IsEnabled="{Binding IsActive}"
|
||||
IsVisible="{Binding IsActive}"/>
|
||||
<StackPanel Grid.Column="1" Orientation="Vertical" Spacing="2">
|
||||
<TextBlock Classes="title" Text="{Binding TaskTitle}"/>
|
||||
<Grid ColumnDefinitions="Auto,*">
|
||||
<TextBlock Grid.Column="0" Classes="meta" Text="{Binding TaskNumber, StringFormat='#{0}'}"
|
||||
IsVisible="{Binding ShowNumberBadge}"
|
||||
VerticalAlignment="Center" Margin="0,0,6,0"/>
|
||||
<TextBlock Grid.Column="1" Classes="title" Text="{Binding TaskTitle}"/>
|
||||
</Grid>
|
||||
<StackPanel Orientation="Horizontal" Spacing="4">
|
||||
<TextBlock Classes="meta" Text="{Binding TaskStatus}"/>
|
||||
<TextBlock Classes="meta" Text="•"
|
||||
@@ -85,7 +90,8 @@
|
||||
HorizontalAlignment="Left" VerticalAlignment="Center">
|
||||
<TextBlock Text="{Binding State}"/>
|
||||
</Border>
|
||||
<TextBlock Grid.Column="4" Classes="meta" Text="{Binding DiffStat}" VerticalAlignment="Center"/>
|
||||
<TextBlock Grid.Column="4" Classes="meta" Text="{Binding DiffStat}" VerticalAlignment="Center"
|
||||
TextTrimming="CharacterEllipsis" ToolTip.Tip="{Binding DiffStat}"/>
|
||||
<TextBlock Grid.Column="5" Classes="meta" Text="{Binding AgeText}" VerticalAlignment="Center"/>
|
||||
</Grid>
|
||||
</Border>
|
||||
@@ -146,8 +152,11 @@
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:WorktreeOverviewRowViewModel">
|
||||
<Grid ColumnDefinitions="*,Auto" Margin="0,2">
|
||||
<TextBlock Grid.Column="0" Classes="meta" VerticalAlignment="Center"
|
||||
Text="{Binding TaskTitle}"/>
|
||||
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="4" VerticalAlignment="Center">
|
||||
<TextBlock Classes="meta" Text="{Binding TaskNumber, StringFormat='#{0}'}"
|
||||
IsVisible="{Binding ShowNumberBadge}"/>
|
||||
<TextBlock Classes="meta" Text="{Binding TaskTitle}"/>
|
||||
</StackPanel>
|
||||
<Button Grid.Column="1" Classes="btn"
|
||||
Content="{loc:Tr modals.worktreesOverview.resolve}"
|
||||
Command="{Binding $parent[Window].((vm:WorktreesOverviewModalViewModel)DataContext).ResolveConflictCommand}"
|
||||
@@ -159,7 +168,7 @@
|
||||
</StackPanel>
|
||||
</Border>
|
||||
<!-- Column headers -->
|
||||
<Grid ColumnDefinitions="Auto,*,90,90,80,80" Margin="12,0,12,4">
|
||||
<Grid ColumnDefinitions="Auto,*,90,90,110,80" Margin="12,0,12,4">
|
||||
<TextBlock Grid.Column="1" Classes="eyebrow" Text="{loc:Tr modals.worktreesOverview.columnTask}"/>
|
||||
<TextBlock Grid.Column="2" Classes="eyebrow" Text="{loc:Tr modals.worktreesOverview.columnOutcome}"/>
|
||||
<TextBlock Grid.Column="3" Classes="eyebrow" Text="{loc:Tr modals.worktreesOverview.columnState}"/>
|
||||
|
||||
@@ -6,6 +6,7 @@ using Avalonia.Controls;
|
||||
using Avalonia.Controls.ApplicationLifetimes;
|
||||
using Avalonia.Layout;
|
||||
using Avalonia.Media;
|
||||
using ClaudeDo.Ui.Localization;
|
||||
using ClaudeDo.Ui.Services;
|
||||
using ClaudeDo.Ui.ViewModels;
|
||||
using ClaudeDo.Ui.ViewModels.Conflicts;
|
||||
@@ -71,7 +72,7 @@ public sealed class WindowDialogService : IDialogService
|
||||
|
||||
public async Task ShowSettingsAsync(SettingsModalViewModel vm)
|
||||
{
|
||||
vm.Worktrees.ConfirmAsync = ConfirmAsync;
|
||||
vm.Worktrees.ConfirmAsync = message => ConfirmAsync(message);
|
||||
var dlg = new SettingsModalView { DataContext = vm };
|
||||
vm.RequestRepoImport = () =>
|
||||
{
|
||||
@@ -90,7 +91,7 @@ public sealed class WindowDialogService : IDialogService
|
||||
{
|
||||
var dlg = new ListSettingsModalView { DataContext = vm };
|
||||
vm.CloseAction = () => dlg.Close();
|
||||
vm.ConfirmAsync = ConfirmAsync;
|
||||
vm.ConfirmAsync = message => ConfirmAsync(message);
|
||||
vm.ShowErrorAsync = ShowErrorAsync;
|
||||
await dlg.ShowDialog(_owner);
|
||||
}
|
||||
@@ -144,7 +145,7 @@ public sealed class WindowDialogService : IDialogService
|
||||
_diffViewerWindow = diffDlg;
|
||||
diffDlg.Show(_owner);
|
||||
};
|
||||
vm.ConfirmAction = ConfirmAsync;
|
||||
vm.ConfirmAction = message => ConfirmAsync(message);
|
||||
if (Shell is { } shell)
|
||||
{
|
||||
vm.ResolveMergeVm = shell.ResolveMergeVm;
|
||||
@@ -206,13 +207,13 @@ public sealed class WindowDialogService : IDialogService
|
||||
_cheatSheets.Activate();
|
||||
}
|
||||
|
||||
public Task<bool> ConfirmAsync(string message)
|
||||
public Task<bool> ConfirmAsync(string message, string? confirmLabel = null)
|
||||
{
|
||||
var tcs = new TaskCompletionSource<bool>();
|
||||
var cancel = new Button { Content = "Cancel", MinWidth = 90 };
|
||||
var confirm = new Button { Content = "Confirm", MinWidth = 90, Classes = { "danger" } };
|
||||
var cancel = new Button { Content = Loc.T("dialog.cancel"), MinWidth = 90 };
|
||||
var confirm = new Button { Content = confirmLabel ?? Loc.T("dialog.confirm"), MinWidth = 90, Classes = { "danger" } };
|
||||
|
||||
var dialog = NoticeWindow("Confirm", 380, message,
|
||||
var dialog = NoticeWindow(Loc.T("dialog.confirmTitle"), 380, message,
|
||||
new StackPanel
|
||||
{
|
||||
Orientation = Orientation.Horizontal,
|
||||
@@ -231,8 +232,8 @@ public sealed class WindowDialogService : IDialogService
|
||||
|
||||
public Task ShowErrorAsync(string message)
|
||||
{
|
||||
var ok = new Button { Content = "OK", MinWidth = 90 };
|
||||
var dialog = NoticeWindow("Error", 360, message,
|
||||
var ok = new Button { Content = Loc.T("dialog.ok"), MinWidth = 90 };
|
||||
var dialog = NoticeWindow(Loc.T("dialog.errorTitle"), 360, message,
|
||||
new StackPanel
|
||||
{
|
||||
Orientation = Orientation.Horizontal,
|
||||
|
||||
@@ -51,7 +51,7 @@ subfolder within their area; the namespace stays the area namespace.
|
||||
|
||||
- **Program.cs** — loads config, inits schema, registers DI, configures SignalR on `/hub`, binds to `127.0.0.1:47821`
|
||||
- **TaskStateService** — the **only** component that writes `Status`, `PlanningPhase`, `BlockedByTaskId`, and the `task_dependencies` edges. All transitions return a `TransitionResult` (no exceptions on invalid moves). Wakes the queue and advances the planning chain on child terminal transitions. `SubmitForChildrenAsync` (Running → WaitingForChildren) also chains and immediately queues a normal parent's own Idle/Queued children via `PlanningChainCoordinator.SetupChainAsync(enqueue: true)` — unlike a planning parent (`PlanningPhase != None`), whose children stay untouched here and wait for the user's explicit "Queue plan" (`QueuePlanAsync`); a child already `Running` makes `SetupChainAsync` throw, which is caught and logged rather than failing the parent's own transition. Every transition broadcasts via a private `NotifyAsync(taskId)` — never call `_broadcaster.TaskUpdated` directly from this class, `NotifyAsync` also drives `TicketStatusSync.SyncAsync` (see Tickets below) and a direct call skips that transition's ticket write-back. `SetDependsOnAsync` replaces the task's whole dependency **set** (empty list clears it) and rejects a self-reference, an unknown dependency id, or any link that would create a cycle — a BFS over the whole `task_dependencies` edge set from every proposed predecessor, so a multi-edge loop (A→B→C→A) is caught too, and one bad edge leaves the entire set unwritten.
|
||||
- **IQueueWaker / IQueuePicker / QueueService** — waker is a singleton `SemaphoreSlim`; picker performs the atomic `Queued → Running` claim filtered by `BlockedByTaskId IS NULL`, `is_manual = 0`, schedule, and "no `task_dependencies` edge whose predecessor isn't `done`" (doubly negated on purpose: zero edges stays eligible, all edges must be done, and an edge pointing at a vanished task keeps blocking); QueueService is a thin `BackgroundService` looping on the waker, dispatching via `TaskRunner`. Per tick it also applies the usage throttle and gate → [usage-monitoring](../../docs/explore-notes/usage-monitoring.md).
|
||||
- **IQueueWaker / IQueuePicker / QueueService** — waker is a singleton `SemaphoreSlim`; picker performs the atomic `Queued → Running` claim filtered by `BlockedByTaskId IS NULL`, `is_manual = 0`, schedule, and "no `task_dependencies` edge whose predecessor isn't satisfied" — satisfied = `status = 'done'` **or** a landed merge (`worktrees.state = 'merged'`), so a predecessor merged with `skipVerify` and parked in `WaitingForReview` until the batch `verify_merges` no longer stalls its successors (doubly negated on purpose: zero edges stays eligible, all edges must be satisfied, and an edge pointing at a vanished task keeps blocking); QueueService is a thin `BackgroundService` looping on the waker, dispatching via `TaskRunner`. Per tick it also applies the usage throttle and gate → [usage-monitoring](../../docs/explore-notes/usage-monitoring.md).
|
||||
- **RunCancellationRegistry** — taskId → running-run CTS. Lets `TaskStateService.CancelAsync` kill a cancelled task's process without a DI cycle.
|
||||
- **OverrideSlotService** — owns `RunNow` / `ContinueTask`; goes through `TaskStateService.StartRunningAsync` (caller-driven, serialized by slot lock).
|
||||
- **StaleTaskRecovery** — startup-only; calls `TaskStateService.RecoverStaleRunningAsync` to flip orphaned `Running` rows to `Failed`.
|
||||
@@ -67,7 +67,7 @@ not conflated.
|
||||
| `Status` | `Idle`, `Queued`, `Running`, `WaitingForChildren`, `WaitingForReview`, `Done`, `Failed`, `Cancelled` | Lifecycle only. `WaitingForChildren` = parent's own work done, waiting on children. |
|
||||
| `PlanningPhase` | `None`, `Active`, `Finalized` | Parent-only marker. `Active` ≈ legacy `Planning`; `Finalized` ≈ legacy `Planned`. |
|
||||
| `BlockedByTaskId` | nullable FK | Replaces legacy `Waiting`. A queued row with a non-null value is skipped by the picker. Internal to `PlanningChainCoordinator` — resolves (or cascades) on ANY terminal state of the predecessor. Because it self-resolves, `wait_for_task_change` deliberately keeps **waiting** through it instead of reporting `"Blocked"`. |
|
||||
| `Dependencies` | 0..n rows in `task_dependencies` | User/MCP-declared predecessors (`add_task`/`update_task`), separate from `BlockedByTaskId` because the semantics differ: the picker skips a queued row until **every** dependency reached `Done` -- a Failed/Cancelled dependency does **not** cascade or auto-resolve, the dependent just stays blocked (see `QueuePicker`, `TaskStateService.SetDependsOnAsync`). `get_task`/`list_tasks`/`batch_get_tasks` surface this as `dependsOnTaskIds` + `blocked`/`blockedReason` (the reason names **all** open predecessors, via `External/TaskBlocking`); `wait_for_task_change` reports `"Blocked"` instead of silently running out its timeout. |
|
||||
| `Dependencies` | 0..n rows in `task_dependencies` | User/MCP-declared predecessors (`add_task`/`update_task`), separate from `BlockedByTaskId` because the semantics differ: the picker skips a queued row until **every** dependency's merge has landed — `Done`, or `worktrees.state = 'merged'` while the batch `verify_merges` is still pending. A Failed/Cancelled dependency does **not** cascade or auto-resolve, the dependent just stays blocked; `revert_merge`/`abort_merge` put the worktree back to `Active` and re-block the successors; a failing `verify_merges` does **not** (the code is on the target branch either way). See `QueuePicker`, `TaskStateService.SetDependsOnAsync`. `get_task`/`list_tasks`/`batch_get_tasks` surface this as `dependsOnTaskIds` + `blocked`/`blockedReason` (the reason names **all** open predecessors, via `External/TaskBlocking`); `wait_for_task_change` reports `"Blocked"` instead of silently running out its timeout. |
|
||||
| `IsManual` | bool | Reminder only the user can do. `EnqueueAsync`/`StartRunningAsync` refuse it, the picker skips it, `GetDailyPrepCandidates` never offers it. An interactive ConPTY session is still allowed. |
|
||||
| `ReviewFeedback` | nullable string | Reviewer's rejection comment; consumed and cleared by `QueueService` on the next re-run. |
|
||||
|
||||
@@ -254,7 +254,10 @@ non-obvious, behaviour-changing. A fixed bug is git history, not a finding.
|
||||
Both edit the DI-singleton `WorkerConfig` in place *and* persist via their own `SaveKey` call
|
||||
(`SaveClaudeBin`/`SaveTicketApiBaseUrl`), so each applies without a restart. Every other field
|
||||
here is hand-edit only.
|
||||
- `usage_poll_interval_seconds` (60, clamped to min 15 on load)
|
||||
- `usage_poll_interval_active_seconds` / `usage_poll_interval_idle_seconds` (120 each, clamped to
|
||||
min 90 on load) — measured 2026-09-16: the endpoint allows exactly 5 requests per 5-minute
|
||||
window, then 429s with `Retry-After: 300`; 120s leaves margin for manual refreshes and other
|
||||
consumers of the same OAuth token, 60s eats the whole quota (see [usage-monitoring](../../docs/explore-notes/usage-monitoring.md)).
|
||||
- `online_inbox` — `enabled` (false by default; when false the entire `Online/` stack is not registered), `api_base_url` (must be HTTPS or loopback, validated at startup), `poll_interval_seconds` (60), `zitadel.authority`/`client_id`/`scopes`. The refresh token is **not** in this file — DPAPI-encrypted at `~/.claudeDo/online-inbox.token`.
|
||||
|
||||
Per-list config (`list_config` in DB) provides defaults for `model`, `system_prompt`,
|
||||
|
||||
@@ -46,16 +46,25 @@ public sealed class WorkerConfig
|
||||
|
||||
/// <summary>
|
||||
/// Usage-monitor poll interval while at least one task is Running. Clamped to a minimum
|
||||
/// of 60s on load — the endpoint rate-limits (429) on tighter polling.
|
||||
/// of 90s on load. Measured 2026-09-16 against the live endpoint: a clean burst allows
|
||||
/// exactly 5 requests before a 429, with <c>Retry-After: 300</c> (a flat 5-req/5-min quota,
|
||||
/// no <c>ratelimit-*</c> headers). 120s leaves ~2.5 requests of headroom per 5-minute window
|
||||
/// for manual refreshes and any other consumer of the same OAuth token — 60s consumes the
|
||||
/// whole quota by itself with zero margin, which is what made the old flat-60s cadence 429
|
||||
/// (see commit 2700c3d8). Same default as <see cref="UsagePollIntervalIdleSeconds"/>: the
|
||||
/// quota is a flat per-account limit, not tied to ClaudeDo's own activity, so an active/idle
|
||||
/// split buys nothing safety-wise. The two config keys stay separate for compatibility.
|
||||
/// </summary>
|
||||
[JsonPropertyName("usage_poll_interval_active_seconds")]
|
||||
public int UsagePollIntervalActiveSeconds { get; set; } = 300;
|
||||
public int UsagePollIntervalActiveSeconds { get; set; } = 120;
|
||||
|
||||
/// <summary>
|
||||
/// Usage-monitor poll interval while nothing is running. Clamped to a minimum of 60s on load.
|
||||
/// Usage-monitor poll interval while nothing is running. Clamped to a minimum of 90s on
|
||||
/// load. Same default as <see cref="UsagePollIntervalActiveSeconds"/> — see that doc for the
|
||||
/// measured rate limit this is sized against.
|
||||
/// </summary>
|
||||
[JsonPropertyName("usage_poll_interval_idle_seconds")]
|
||||
public int UsagePollIntervalIdleSeconds { get; set; } = 900;
|
||||
public int UsagePollIntervalIdleSeconds { get; set; } = 120;
|
||||
|
||||
/// <summary>Base URL of the Bandel ticket-system API. Null = ticket integration off entirely.</summary>
|
||||
[JsonPropertyName("ticket_api_base_url")]
|
||||
@@ -88,8 +97,8 @@ public sealed class WorkerConfig
|
||||
cfg.SandboxRoot = Paths.Expand(cfg.SandboxRoot);
|
||||
cfg.LogRoot = Paths.Expand(cfg.LogRoot);
|
||||
cfg.CentralWorktreeRoot = Paths.Expand(cfg.CentralWorktreeRoot);
|
||||
cfg.UsagePollIntervalActiveSeconds = Math.Max(60, cfg.UsagePollIntervalActiveSeconds);
|
||||
cfg.UsagePollIntervalIdleSeconds = Math.Max(60, cfg.UsagePollIntervalIdleSeconds);
|
||||
cfg.UsagePollIntervalActiveSeconds = Math.Max(90, cfg.UsagePollIntervalActiveSeconds);
|
||||
cfg.UsagePollIntervalIdleSeconds = Math.Max(90, cfg.UsagePollIntervalIdleSeconds);
|
||||
|
||||
return cfg;
|
||||
}
|
||||
|
||||
+17
-10
@@ -216,14 +216,19 @@ public sealed class ExternalMcpService
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
var dependencyStatuses = new Dictionary<string, TaskStatus>();
|
||||
var predecessors = new Dictionary<string, (TaskStatus Status, bool Merged)>();
|
||||
if (dependencyIds.Count > 0)
|
||||
{
|
||||
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
|
||||
dependencyStatuses = await ctx.Tasks.AsNoTracking()
|
||||
predecessors = await ctx.Tasks.AsNoTracking()
|
||||
.Where(t => dependencyIds.Contains(t.Id))
|
||||
.Select(t => new { t.Id, t.Status })
|
||||
.ToDictionaryAsync(t => t.Id, t => t.Status, ct);
|
||||
.Select(t => new
|
||||
{
|
||||
t.Id,
|
||||
t.Status,
|
||||
Merged = t.Worktree != null && t.Worktree.State == WorktreeState.Merged,
|
||||
})
|
||||
.ToDictionaryAsync(t => t.Id, t => (t.Status, t.Merged), ct);
|
||||
}
|
||||
|
||||
var result = new Dictionary<string, (bool Blocked, string? Reason)>();
|
||||
@@ -239,7 +244,7 @@ public sealed class ExternalMcpService
|
||||
}
|
||||
else
|
||||
{
|
||||
var unmet = TaskBlocking.Unmet(t.DependsOnTaskIds, dependencyStatuses);
|
||||
var unmet = TaskBlocking.Unmet(t.DependsOnTaskIds, predecessors);
|
||||
result[t.Id] = unmet.Count == 0 ? (false, null) : (true, TaskBlocking.Describe(unmet));
|
||||
}
|
||||
}
|
||||
@@ -290,13 +295,14 @@ public sealed class ExternalMcpService
|
||||
"for normal coding, 'opus' only for complex or cross-cutting work. null inherits the " +
|
||||
"list/global default (normally sonnet).")]
|
||||
string? model = null,
|
||||
[Description("Id of a task that must reach Done (i.e. be merged) before the picker will claim this one, " +
|
||||
"even once queued. Shorthand for a one-element dependsOnTaskIds; ignored when that is given. " +
|
||||
[Description("Id of a task whose merge must have landed before the picker will claim this one, even once " +
|
||||
"queued (Done, or merged and still awaiting the batch verify). Shorthand for a " +
|
||||
"one-element dependsOnTaskIds; ignored when that is given. " +
|
||||
"Rejected if it doesn't exist, is this task's own id, or would create a " +
|
||||
"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("Ids of ALL tasks that must reach Done before the picker will claim this one. Use this when a " +
|
||||
[Description("Ids of ALL tasks whose merges must have landed before the picker will claim this one. Use this when a " +
|
||||
"task wires together, or verifies, the results of several independent tasks -- one waiting on " +
|
||||
"A and B is the normal shape of a wave-style plan. Same rejection rules as dependsOnTaskId.")]
|
||||
string[]? dependsOnTaskIds = null,
|
||||
@@ -460,12 +466,13 @@ public sealed class ExternalMcpService
|
||||
string? title = null,
|
||||
string? description = null,
|
||||
string? commitType = null,
|
||||
[Description("Id of a task that must reach Done before the picker will claim this one. Shorthand for a " +
|
||||
[Description("Id of a task whose merge must have landed (Done, or merged and still awaiting the batch " +
|
||||
"verify) before the picker will claim this one. Shorthand for a " +
|
||||
"one-element dependsOnTaskIds; ignored when that is given. Pass an empty " +
|
||||
"string to clear all existing links; null leaves them unchanged. Rejected if it doesn't exist, " +
|
||||
"is this task's own id, or would create a dependency cycle.")]
|
||||
string? dependsOnTaskId = null,
|
||||
[Description("Ids of ALL tasks that must reach Done before the picker will claim this one, replacing the " +
|
||||
[Description("Ids of ALL tasks whose merges must have landed before the picker will claim this one, replacing the " +
|
||||
"task's current set. Pass an empty array to clear it; null leaves it unchanged. Use this when " +
|
||||
"a task wires together, or verifies, the results of several independent tasks.")]
|
||||
string[]? dependsOnTaskIds = null,
|
||||
|
||||
+309
-43
@@ -1,6 +1,8 @@
|
||||
using System.ComponentModel;
|
||||
using System.Diagnostics;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using ClaudeDo.Data;
|
||||
using ClaudeDo.Data.Git;
|
||||
using ClaudeDo.Data.Models;
|
||||
@@ -61,7 +63,14 @@ public sealed record MergePreviewSetEntryDto(
|
||||
// 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);
|
||||
bool ExpectsNoDiff = false,
|
||||
// Top-level C# declarations (methods/properties/fields/types/events) this task's diff removes
|
||||
// -- a line-pattern scan over `-` lines, not a parser: false positives are possible, a missed
|
||||
// removal is not the intent. Skips .axaml/.json. Null for an entry with Error set. A name
|
||||
// another task in the same set still touches on a `+` line surfaces in SymbolWarnings below.
|
||||
IReadOnlyList<RemovedSymbolDto>? RemovedSymbols = null);
|
||||
|
||||
public sealed record RemovedSymbolDto(string Name, string File);
|
||||
|
||||
public sealed record FileOverlapDto(string File, IReadOnlyList<string> TaskIds, IReadOnlyList<int> Numbers);
|
||||
|
||||
@@ -71,9 +80,16 @@ public sealed record FileOverlapDto(string File, IReadOnlyList<string> TaskIds,
|
||||
// signal that TaskId's work may already be entirely covered by SupersetTaskId.
|
||||
public sealed record SubsetRelationDto(string TaskId, int Number, string SupersetTaskId, int SupersetNumber);
|
||||
|
||||
// RemovedByTaskId's diff removes Symbol (in RemovedInFile); ReferencedByTaskId's diff still touches
|
||||
// that name on a `+` line elsewhere -- no textual file conflict, so Overlaps alone misses it (e.g.
|
||||
// one task deletes a method, a sibling task's edit in a different file still calls it).
|
||||
public sealed record SymbolReferenceWarningDto(
|
||||
string Symbol, string RemovedInFile, string RemovedByTaskId, int RemovedByNumber,
|
||||
string ReferencedByTaskId, int ReferencedByNumber, string ReferencedInFile);
|
||||
|
||||
public sealed record MergePreviewSetResultDto(
|
||||
IReadOnlyList<MergePreviewSetEntryDto> Tasks, IReadOnlyList<FileOverlapDto> Overlaps,
|
||||
IReadOnlyList<SubsetRelationDto> Subsets);
|
||||
IReadOnlyList<SubsetRelationDto> Subsets, IReadOnlyList<SymbolReferenceWarningDto> SymbolWarnings);
|
||||
|
||||
public sealed record RevertMergeResultDto(
|
||||
bool Reverted, string? RevertCommit, IReadOnlyList<string> Conflicts, string? Message);
|
||||
@@ -446,26 +462,30 @@ public sealed class MergeMcpTools
|
||||
"untracked, in the target working directory — merge-tree can't see the working tree at all, so this is the " +
|
||||
"only way to catch it before a real merge either refuses or, if that path became tracked in the meantime, " +
|
||||
"silently overwrites it); behind counts commits on targetBranch not yet on this branch, which flags a stale " +
|
||||
"branch even when the preview is clean. If the list has a verify command configured, a clean preview is " +
|
||||
"additionally built/tested in a scratch worktree (still without touching the real working tree) — " +
|
||||
"verifyExitCode 0 means it built clean, non-zero or -1 (timeout/failed to start) means it doesn't, with the " +
|
||||
"tail of its output in verifyOutputTail; verifyExitCode stays null when no verify command is configured. " +
|
||||
"isEmpty=true means the task's review range contributed nothing; check that flag rather than reading a " +
|
||||
"small changedFileCount as empty. staleFiles lists files this branch touches that the target branch ALSO " +
|
||||
"changed since this branch's fork point — a more honest staleness signal than `behind` alone, since a " +
|
||||
"branch can be far behind yet touch nothing the target changed, or barely behind yet collide on the one " +
|
||||
"file that matters (always empty for a worktree-less handler task, which has no fork point). Throws if the " +
|
||||
"task has neither an active worktree nor a handler commit range, or the list's working directory is missing " +
|
||||
"from disk." + McpToolDocs.ProgressHint)]
|
||||
"branch even when the preview is clean. runVerify=false (default) never builds. runVerify=true additionally " +
|
||||
"builds/tests a clean preview in a scratch worktree (still without touching the real working tree), if the " +
|
||||
"list has a verify command configured — verifyExitCode 0 means it built clean, non-zero or -1 " +
|
||||
"(timeout/failed to start) means it doesn't, with the tail of its output in verifyOutputTail; " +
|
||||
"verifyExitCode/verifyDurationMs/verifyOutputTail stay null without runVerify=true or without a configured " +
|
||||
"verify command. isEmpty=true means the task's review range contributed nothing; check that flag rather " +
|
||||
"than reading a small changedFileCount as empty. staleFiles lists files this branch touches that the " +
|
||||
"target branch ALSO changed since this branch's fork point — a more honest staleness signal than `behind` " +
|
||||
"alone, since a branch can be far behind yet touch nothing the target changed, or barely behind yet " +
|
||||
"collide on the one file that matters (always empty for a worktree-less handler task, which has no fork " +
|
||||
"point). Throws if the task has neither an active worktree nor a handler commit range, or the list's " +
|
||||
"working directory is missing from disk." + McpToolDocs.ProgressHint)]
|
||||
public async Task<MergePreviewToolDto> PreviewMerge(
|
||||
string taskId,
|
||||
[Description("Branch to preview against; defaults to the repo's current branch.")]
|
||||
string? targetBranch = null,
|
||||
[Description("true: also run the verify command (if configured), building in a scratch worktree. " +
|
||||
"false (default): no build.")]
|
||||
bool runVerify = false,
|
||||
CancellationToken cancellationToken = default,
|
||||
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, cancellationToken, progress);
|
||||
return new MergePreviewToolDto(preview.Status, preview.ConflictFiles, preview.ChangedFileCount, behind, isEmpty,
|
||||
preview.VerifyExitCode, preview.VerifyDurationMs, preview.VerifyOutputTail, staleFiles);
|
||||
}
|
||||
@@ -473,16 +493,21 @@ public sealed class MergeMcpTools
|
||||
[McpServerTool, Description(
|
||||
"Plan a batch merge: preview_merge for several tasks against the same targetBranch, plus a file-overlap " +
|
||||
"check between them. Per entry you get preview_merge's fields, or error instead when that task could not " +
|
||||
"be previewed (it is then left out of the overlap/subset computation). overlaps names, for each file " +
|
||||
"touched by MORE THAN ONE of the given tasks, which tasks touch it — a single taskId always yields no " +
|
||||
"overlaps. subsets flags a stronger relation: TaskId's changed files are a PROPER subset of " +
|
||||
"be previewed (it is then left out of the overlap/subset/symbol computation). overlaps names, for each " +
|
||||
"file touched by MORE THAN ONE of the given tasks, which tasks touch it — a single taskId always yields " +
|
||||
"no overlaps; for a .json file this is narrowed to nested-key-path level (parsing both the base commit " +
|
||||
"and the task branch's head), so two tasks adding different keys to the same locale file are not flagged " +
|
||||
"and only a genuinely shared key is — unparsable JSON on either side falls back to the plain file-level " +
|
||||
"check. subsets flags a stronger relation: TaskId's changed files are a PROPER subset of " +
|
||||
"SupersetTaskId's — the strongest hint you get post-hoc that TaskId may be redundant with SupersetTaskId, " +
|
||||
"worth checking before merging both. IMPORTANT: neither overlap nor subset is a safety guarantee — two " +
|
||||
"tasks touching entirely different files (one deleting a symbol, another still referencing it) can still " +
|
||||
"collide unflagged, and as with preview_merge a clean result does not mean the merge builds. " +
|
||||
"runVerify=false (default) never builds — set it true to also run each task's list's verify command in a " +
|
||||
"scratch worktree per entry (same fields as preview_merge); this can take a long time across many tasks, " +
|
||||
"since builds run one at a time." + McpToolDocs.ProgressHint)]
|
||||
"worth checking before merging both. removedSymbols (per task entry) lists C# methods/properties/fields/" +
|
||||
"types/events that task's diff deletes (a line-pattern scan, not a parser — false positives possible, " +
|
||||
"skips .axaml/.json); symbolWarnings then cross-references those names against every OTHER task's added " +
|
||||
"lines in the same set, catching a same-symbol collision even when the two tasks touch entirely different " +
|
||||
"files. IMPORTANT: none of overlaps/subsets/symbolWarnings is a safety guarantee, and as with preview_merge " +
|
||||
"a clean result does not mean the merge builds. runVerify=false (default) never builds — set it true to " +
|
||||
"also run each task's list's verify command in a scratch worktree per entry (same fields as preview_merge); " +
|
||||
"this can take a long time across many tasks, since builds run one at a time." + McpToolDocs.ProgressHint)]
|
||||
public async Task<MergePreviewSetResultDto> PreviewMergeSet(
|
||||
IReadOnlyList<string> taskIds,
|
||||
[Description("Branch to preview every task against; defaults to the repo's current branch.")]
|
||||
@@ -501,18 +526,37 @@ public sealed class MergeMcpTools
|
||||
var entries = new List<MergePreviewSetEntryDto>();
|
||||
var filesByTask = new Dictionary<string, IReadOnlyList<string>>();
|
||||
var numbersByTask = new Dictionary<string, int>();
|
||||
var repoPathByTask = new Dictionary<string, string>();
|
||||
var baseRefByTask = new Dictionary<string, string>();
|
||||
var headRefByTask = new Dictionary<string, string?>();
|
||||
var diffLinesByTask = new Dictionary<string, List<DiffLine>>();
|
||||
var removedSymbolsByTask = new Dictionary<string, IReadOnlyList<RemovedSymbolDto>>();
|
||||
|
||||
for (var i = 0; i < taskIds.Count; i++)
|
||||
{
|
||||
var taskId = taskIds[i];
|
||||
try
|
||||
{
|
||||
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, expectsNoDiff));
|
||||
var (preview, behind, changedFiles, isEmpty, staleFiles, number, expectsNoDiff, repoPath, baseRef, headRef) =
|
||||
await PreviewMergeCoreAsync(taskId, targetBranch, runVerify, cancellationToken, progress);
|
||||
filesByTask[taskId] = changedFiles;
|
||||
numbersByTask[taskId] = number;
|
||||
repoPathByTask[taskId] = repoPath;
|
||||
baseRefByTask[taskId] = baseRef;
|
||||
headRefByTask[taskId] = headRef;
|
||||
|
||||
var diff = headRef is null
|
||||
? await _git.GetBranchDiffAsync(repoPath, baseRef, ct: cancellationToken)
|
||||
: await _git.GetCommitRangeDiffAsync(repoPath, baseRef, headRef, ct: cancellationToken);
|
||||
var diffLines = ParseDiffLines(diff);
|
||||
diffLinesByTask[taskId] = diffLines;
|
||||
var removedSymbols = ExtractRemovedSymbols(diffLines);
|
||||
removedSymbolsByTask[taskId] = removedSymbols;
|
||||
|
||||
entries.Add(new MergePreviewSetEntryDto(
|
||||
taskId, preview.Status, preview.ConflictFiles, preview.ChangedFileCount, behind, null, isEmpty,
|
||||
preview.VerifyExitCode, preview.VerifyDurationMs, preview.VerifyOutputTail, staleFiles, number,
|
||||
expectsNoDiff, removedSymbols));
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
@@ -525,23 +569,243 @@ public sealed class MergeMcpTools
|
||||
ProgressReporter.ReportItem(progress, "Previewing merges", i + 1, taskIds.Count);
|
||||
}
|
||||
|
||||
var overlaps = filesByTask
|
||||
.SelectMany(kv => kv.Value.Select(f => (File: f, TaskId: kv.Key)))
|
||||
.GroupBy(x => x.File, StringComparer.OrdinalIgnoreCase)
|
||||
.Where(g => g.Select(x => x.TaskId).Distinct().Count() > 1)
|
||||
.Select(g =>
|
||||
{
|
||||
var ids = g.Select(x => x.TaskId).Distinct().ToList();
|
||||
return new FileOverlapDto(g.Key, ids, ids.Select(id => numbersByTask[id]).ToList());
|
||||
})
|
||||
.OrderBy(o => o.File, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
var overlaps = await ComputeOverlapsAsync(filesByTask, numbersByTask, repoPathByTask, baseRefByTask, headRefByTask, cancellationToken);
|
||||
var subsets = FindSubsetRelations(filesByTask, numbersByTask);
|
||||
var symbolWarnings = FindSymbolWarnings(removedSymbolsByTask, numbersByTask, diffLinesByTask);
|
||||
|
||||
return new MergePreviewSetResultDto(entries, overlaps, subsets);
|
||||
return new MergePreviewSetResultDto(entries, overlaps, subsets, symbolWarnings);
|
||||
}
|
||||
|
||||
// Cross-references each task's removed symbols (Teil 1) against every OTHER task's added
|
||||
// lines in the same set -- a same-named symbol collision that overlaps/subsets (both purely
|
||||
// file-based) can never catch, since the two tasks may not share a single file.
|
||||
private static List<SymbolReferenceWarningDto> FindSymbolWarnings(
|
||||
Dictionary<string, IReadOnlyList<RemovedSymbolDto>> removedSymbolsByTask,
|
||||
Dictionary<string, int> numbersByTask,
|
||||
Dictionary<string, List<DiffLine>> diffLinesByTask)
|
||||
{
|
||||
var warnings = new List<SymbolReferenceWarningDto>();
|
||||
foreach (var (taskId, removed) in removedSymbolsByTask)
|
||||
{
|
||||
foreach (var symbol in removed)
|
||||
{
|
||||
foreach (var (otherTaskId, diffLines) in diffLinesByTask)
|
||||
{
|
||||
if (otherTaskId == taskId) continue;
|
||||
var hit = diffLines.FirstOrDefault(l => l.Sign == '+' && ContainsWord(l.Text, symbol.Name));
|
||||
if (hit.File is not null)
|
||||
warnings.Add(new SymbolReferenceWarningDto(
|
||||
symbol.Name, symbol.File, taskId, numbersByTask[taskId],
|
||||
otherTaskId, numbersByTask[otherTaskId], hit.File));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return warnings
|
||||
.OrderBy(w => w.Symbol, StringComparer.Ordinal)
|
||||
.ThenBy(w => w.RemovedByTaskId, StringComparer.Ordinal)
|
||||
.ThenBy(w => w.ReferencedByTaskId, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
// File-level overlap for everything except .json; for .json, narrows to nested-key-path
|
||||
// collisions between the tasks that touch it (Teil 2) -- unless either side fails to parse,
|
||||
// in which case it falls back to the plain file-level check for that file.
|
||||
private async Task<List<FileOverlapDto>> ComputeOverlapsAsync(
|
||||
Dictionary<string, IReadOnlyList<string>> filesByTask, Dictionary<string, int> numbersByTask,
|
||||
Dictionary<string, string> repoPathByTask, Dictionary<string, string> baseRefByTask,
|
||||
Dictionary<string, string?> headRefByTask, CancellationToken ct)
|
||||
{
|
||||
var overlaps = new List<FileOverlapDto>();
|
||||
|
||||
var byFile = filesByTask
|
||||
.SelectMany(kv => kv.Value.Select(f => (File: f, TaskId: kv.Key)))
|
||||
.GroupBy(x => x.File, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var group in byFile)
|
||||
{
|
||||
var taskIds = group.Select(x => x.TaskId).Distinct().ToList();
|
||||
if (taskIds.Count <= 1) continue;
|
||||
|
||||
if (!group.Key.EndsWith(".json", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
overlaps.Add(MakeOverlap(group.Key, taskIds, numbersByTask));
|
||||
continue;
|
||||
}
|
||||
|
||||
var colliding = await FindJsonKeyCollisionTasksAsync(
|
||||
group.Key, taskIds, repoPathByTask, baseRefByTask, headRefByTask, ct);
|
||||
if (colliding is null)
|
||||
overlaps.Add(MakeOverlap(group.Key, taskIds, numbersByTask)); // unparsable -- fall back
|
||||
else if (colliding.Count > 1)
|
||||
overlaps.Add(MakeOverlap(group.Key, colliding, numbersByTask));
|
||||
}
|
||||
|
||||
return overlaps.OrderBy(o => o.File, StringComparer.OrdinalIgnoreCase).ToList();
|
||||
}
|
||||
|
||||
private static FileOverlapDto MakeOverlap(string file, IReadOnlyList<string> taskIds, Dictionary<string, int> numbersByTask)
|
||||
=> new(file, taskIds, taskIds.Select(id => numbersByTask[id]).ToList());
|
||||
|
||||
// Null = give up and fall back to file-level (JSON unparsable on some side for some task).
|
||||
// Otherwise: the subset of taskIds whose changed/added/removed key paths in `file` intersect
|
||||
// with at least one other task's -- two tasks touching disjoint keys in the same file never appear.
|
||||
private async Task<List<string>?> FindJsonKeyCollisionTasksAsync(
|
||||
string file, List<string> taskIds,
|
||||
Dictionary<string, string> repoPathByTask, Dictionary<string, string> baseRefByTask,
|
||||
Dictionary<string, string?> headRefByTask, CancellationToken ct)
|
||||
{
|
||||
var keysByTask = new Dictionary<string, HashSet<string>>();
|
||||
foreach (var taskId in taskIds)
|
||||
{
|
||||
var baseContent = await ReadFileContentAsync(repoPathByTask[taskId], baseRefByTask[taskId], file, ct);
|
||||
var headContent = await ReadFileContentAsync(repoPathByTask[taskId], headRefByTask[taskId], file, ct);
|
||||
if (!TryDiffJsonKeyPaths(baseContent, headContent, out var keys))
|
||||
return null;
|
||||
keysByTask[taskId] = keys;
|
||||
}
|
||||
|
||||
var colliding = new HashSet<string>();
|
||||
foreach (var a in taskIds)
|
||||
foreach (var b in taskIds)
|
||||
{
|
||||
if (a == b) continue;
|
||||
if (keysByTask[a].Overlaps(keysByTask[b]))
|
||||
{
|
||||
colliding.Add(a);
|
||||
colliding.Add(b);
|
||||
}
|
||||
}
|
||||
|
||||
return taskIds.Where(colliding.Contains).ToList();
|
||||
}
|
||||
|
||||
// gitRef null = read the live working tree off disk (mirrors GetBranchDiffAsync's own base-vs-
|
||||
// working-tree comparison); non-null = `git show ref:path` (handler task's fixed commit range).
|
||||
private async Task<string?> ReadFileContentAsync(string repoPath, string? gitRef, string relativePath, CancellationToken ct)
|
||||
{
|
||||
if (gitRef is null)
|
||||
{
|
||||
var full = Path.Combine(repoPath, relativePath);
|
||||
return File.Exists(full) ? await File.ReadAllTextAsync(full, ct) : null;
|
||||
}
|
||||
return await _git.ShowFileAsync(repoPath, gitRef, relativePath, ct);
|
||||
}
|
||||
|
||||
private static bool TryDiffJsonKeyPaths(string? baseContent, string? headContent, out HashSet<string> changedPaths)
|
||||
{
|
||||
changedPaths = new HashSet<string>(StringComparer.Ordinal);
|
||||
if (!TryFlattenJson(baseContent, out var baseMap) || !TryFlattenJson(headContent, out var headMap))
|
||||
return false;
|
||||
|
||||
foreach (var (path, value) in headMap)
|
||||
if (!baseMap.TryGetValue(path, out var baseValue) || baseValue != value)
|
||||
changedPaths.Add(path);
|
||||
foreach (var path in baseMap.Keys)
|
||||
if (!headMap.ContainsKey(path))
|
||||
changedPaths.Add(path);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Missing content (new/deleted file, either side) is an empty object, not a parse failure --
|
||||
// only actually-malformed JSON triggers the file-level fallback.
|
||||
private static bool TryFlattenJson(string? content, out Dictionary<string, string> flattened)
|
||||
{
|
||||
flattened = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
if (string.IsNullOrWhiteSpace(content))
|
||||
return true;
|
||||
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(content);
|
||||
FlattenJson(doc.RootElement, "", flattened);
|
||||
return true;
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static void FlattenJson(JsonElement element, string prefix, Dictionary<string, string> into)
|
||||
{
|
||||
if (element.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
foreach (var prop in element.EnumerateObject())
|
||||
FlattenJson(prop.Value, prefix.Length == 0 ? prop.Name : $"{prefix}.{prop.Name}", into);
|
||||
}
|
||||
else
|
||||
{
|
||||
into[prefix] = element.GetRawText();
|
||||
}
|
||||
}
|
||||
|
||||
// One line of a unified diff, tagged with the file it belongs to ('-'/'+' only; context lines
|
||||
// are dropped). File is set from the `diff --git a/<path> b/<path>` header, which (unlike
|
||||
// `+++ b/...`) is present and names the real path even for a deleted file (`+++ /dev/null`).
|
||||
private readonly record struct DiffLine(string? File, char Sign, string Text);
|
||||
|
||||
private static List<DiffLine> ParseDiffLines(string diff)
|
||||
{
|
||||
var result = new List<DiffLine>();
|
||||
string? currentFile = null;
|
||||
foreach (var raw in diff.Split('\n'))
|
||||
{
|
||||
var line = raw.TrimEnd('\r');
|
||||
if (line.StartsWith("diff --git a/", StringComparison.Ordinal))
|
||||
{
|
||||
var rest = line["diff --git a/".Length..];
|
||||
var sep = rest.IndexOf(" b/", StringComparison.Ordinal);
|
||||
currentFile = sep >= 0 ? rest[..sep] : rest;
|
||||
continue;
|
||||
}
|
||||
if (currentFile is null) continue;
|
||||
if (line.StartsWith("---", StringComparison.Ordinal) || line.StartsWith("+++", StringComparison.Ordinal) ||
|
||||
line.StartsWith("@@", StringComparison.Ordinal) || line.StartsWith("index ", StringComparison.Ordinal))
|
||||
continue;
|
||||
|
||||
if (line.Length > 0 && line[0] == '-')
|
||||
result.Add(new DiffLine(currentFile, '-', line[1..]));
|
||||
else if (line.Length > 0 && line[0] == '+')
|
||||
result.Add(new DiffLine(currentFile, '+', line[1..]));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// Deliberately a line-pattern scan, not a Roslyn parse: requires an explicit access modifier
|
||||
// (this codebase's style never omits one on a real member/type), which is also what keeps a
|
||||
// removed local variable or statement inside a method body from matching.
|
||||
private static readonly Regex RemovedDeclarationRegex = new(
|
||||
@"^\s*(?:\[[^\]]*\]\s*)*(?:public|private|protected|internal)(?:\s+(?:static|readonly|sealed|abstract|virtual|override|async|partial|const|new|extern|unsafe|required))*\s+" +
|
||||
@"(?:(?:class|struct|interface|enum|record)\s+(?<name>[A-Za-z_]\w*)" +
|
||||
@"|event\s+[\w<>\[\],.?]+\s+(?<name>[A-Za-z_]\w*)" +
|
||||
@"|[\w<>\[\],.?]+\??\s+(?<name>[A-Za-z_]\w*)\s*(?:\(|\{|=>|;|=))",
|
||||
RegexOptions.Compiled);
|
||||
|
||||
private static List<RemovedSymbolDto> ExtractRemovedSymbols(List<DiffLine> diffLines)
|
||||
{
|
||||
var seen = new HashSet<(string Name, string File)>();
|
||||
var result = new List<RemovedSymbolDto>();
|
||||
foreach (var line in diffLines)
|
||||
{
|
||||
if (line.Sign != '-' || line.File is null) continue;
|
||||
if (line.File.EndsWith(".axaml", StringComparison.OrdinalIgnoreCase) ||
|
||||
line.File.EndsWith(".json", StringComparison.OrdinalIgnoreCase))
|
||||
continue;
|
||||
|
||||
var match = RemovedDeclarationRegex.Match(line.Text);
|
||||
if (!match.Success) continue;
|
||||
|
||||
if (seen.Add((match.Groups["name"].Value, line.File)))
|
||||
result.Add(new RemovedSymbolDto(match.Groups["name"].Value, line.File));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static bool ContainsWord(string text, string word)
|
||||
=> Regex.IsMatch(text, $@"\b{Regex.Escape(word)}\b");
|
||||
|
||||
// A task's changed-file set is flagged only against another task's STRICTLY LARGER set that
|
||||
// contains every one of its files -- an empty set (isEmpty task) never qualifies, since that
|
||||
// case is already surfaced via IsEmpty and would otherwise match every other task trivially.
|
||||
@@ -577,7 +841,7 @@ public sealed class MergeMcpTools
|
||||
// (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, bool ExpectsNoDiff)> PreviewMergeCoreAsync(
|
||||
private async Task<(MergePreviewResult Preview, int Behind, IReadOnlyList<string> ChangedFiles, bool IsEmpty, IReadOnlyList<string> StaleFiles, int Number, bool ExpectsNoDiff, string RepoPath, string BaseRef, string? HeadRef)> PreviewMergeCoreAsync(
|
||||
string taskId, string? targetBranch, bool runVerify, CancellationToken ct,
|
||||
IProgress<ProgressNotificationValue>? progress = null)
|
||||
{
|
||||
@@ -619,7 +883,8 @@ public sealed class MergeMcpTools
|
||||
.Intersect(targetChangedSinceFork, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
return (preview, behind, changedFiles, changedFiles.Count == 0, staleFiles, task.Number, task.ExpectsNoDiff);
|
||||
return (preview, behind, changedFiles, changedFiles.Count == 0, staleFiles, task.Number, task.ExpectsNoDiff,
|
||||
wt.Path, wt.BaseCommit, null);
|
||||
}
|
||||
|
||||
if (task.HandlerBaseCommit is { Length: > 0 } handlerBase && task.HandlerHeadCommit is { Length: > 0 } handlerHead)
|
||||
@@ -636,7 +901,8 @@ public sealed class MergeMcpTools
|
||||
// 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, task.ExpectsNoDiff);
|
||||
return (preview, 0, changedFiles, isEmpty, Array.Empty<string>(), task.Number, task.ExpectsNoDiff,
|
||||
list.WorkingDir, handlerBase, handlerHead);
|
||||
}
|
||||
|
||||
throw new InvalidOperationException($"Task {taskId} has no worktree.");
|
||||
|
||||
+9
-6
@@ -10,19 +10,22 @@ namespace ClaudeDo.Worker.External;
|
||||
internal static class TaskBlocking
|
||||
{
|
||||
/// <summary>
|
||||
/// The predecessors of <paramref name="dependencyIds"/> that have not reached Done — an id
|
||||
/// missing from <paramref name="statuses"/> counts as unmet (the picker treats a dangling
|
||||
/// edge as blocking too).
|
||||
/// The predecessors of <paramref name="dependencyIds"/> the picker would still skip on: not
|
||||
/// Done <i>and</i> without a landed merge. Mirrors <c>QueuePicker</c>'s SQL predicate — a
|
||||
/// task merged with <c>skipVerify</c> sits in WaitingForReview until the batch verify runs,
|
||||
/// yet its code is on the target branch, so it no longer blocks. An id missing from
|
||||
/// <paramref name="predecessors"/> counts as unmet (a dangling edge blocks there too).
|
||||
/// </summary>
|
||||
public static List<(string Id, TaskStatus? Status)> Unmet(
|
||||
IEnumerable<string> dependencyIds, IReadOnlyDictionary<string, TaskStatus> statuses)
|
||||
IEnumerable<string> dependencyIds,
|
||||
IReadOnlyDictionary<string, (TaskStatus Status, bool Merged)> predecessors)
|
||||
{
|
||||
var unmet = new List<(string Id, TaskStatus? Status)>();
|
||||
foreach (var id in dependencyIds)
|
||||
{
|
||||
if (statuses.TryGetValue(id, out var status))
|
||||
if (predecessors.TryGetValue(id, out var p))
|
||||
{
|
||||
if (status != TaskStatus.Done) unmet.Add((id, status));
|
||||
if (p.Status != TaskStatus.Done && !p.Merged) unmet.Add((id, p.Status));
|
||||
}
|
||||
else
|
||||
{
|
||||
|
||||
+11
-5
@@ -1,5 +1,6 @@
|
||||
using System.ComponentModel;
|
||||
using ClaudeDo.Data;
|
||||
using ClaudeDo.Data.Models;
|
||||
using ClaudeDo.Data.Repositories;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ModelContextProtocol;
|
||||
@@ -152,12 +153,17 @@ public sealed class TaskWaitMcpTools
|
||||
.SelectMany(r => r.DependsOnTaskIds)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
var dependencyStatuses = dependencyIds.Count == 0
|
||||
? new Dictionary<string, TaskStatus>()
|
||||
var predecessors = dependencyIds.Count == 0
|
||||
? new Dictionary<string, (TaskStatus Status, bool Merged)>()
|
||||
: await ctx.Tasks.AsNoTracking()
|
||||
.Where(t => dependencyIds.Contains(t.Id))
|
||||
.Select(t => new { t.Id, t.Status })
|
||||
.ToDictionaryAsync(t => t.Id, t => t.Status, ct);
|
||||
.Select(t => new
|
||||
{
|
||||
t.Id,
|
||||
t.Status,
|
||||
Merged = t.Worktree != null && t.Worktree.State == WorktreeState.Merged,
|
||||
})
|
||||
.ToDictionaryAsync(t => t.Id, t => (t.Status, t.Merged), ct);
|
||||
|
||||
var result = new List<TaskStatusChangeDto>();
|
||||
foreach (var id in taskIds)
|
||||
@@ -175,7 +181,7 @@ public sealed class TaskWaitMcpTools
|
||||
// every chained child and turn the wait into a one-turn-per-poll busy loop.
|
||||
if (row.Status == TaskStatus.Queued && row.BlockedByTaskId is null)
|
||||
{
|
||||
var unmet = TaskBlocking.Unmet(row.DependsOnTaskIds, dependencyStatuses);
|
||||
var unmet = TaskBlocking.Unmet(row.DependsOnTaskIds, predecessors);
|
||||
if (unmet.Count > 0)
|
||||
{
|
||||
result.Add(new TaskStatusChangeDto(
|
||||
|
||||
@@ -449,7 +449,8 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
||||
var rows = await _wtMaintenance.GetOverviewAsync(listId, Context.ConnectionAborted);
|
||||
return rows.Select(r => new WorktreeOverviewDto(
|
||||
r.TaskId, r.TaskTitle, r.TaskStatus, r.ListId, r.ListName,
|
||||
r.Path, r.BranchName, r.BaseCommit, r.State, r.DiffStat, r.CreatedAt, r.PathExistsOnDisk)).ToList();
|
||||
r.Path, r.BranchName, r.BaseCommit, r.State, r.DiffStat, r.CreatedAt, r.PathExistsOnDisk,
|
||||
r.TaskNumber)).ToList();
|
||||
}
|
||||
|
||||
public async Task<bool> SetWorktreeState(string taskId, WorktreeState newState)
|
||||
@@ -498,7 +499,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
||||
=> HubGuard(async () =>
|
||||
{
|
||||
var t = await _mergeService.GetTargetsAsync(taskId, CancellationToken.None);
|
||||
return new MergeTargetsDto(t.DefaultBranch, t.LocalBranches, t.DefaultCommitMessage);
|
||||
return new MergeTargetsDto(t.DefaultBranch, t.LocalBranches, t.DefaultCommitMessage, t.TargetDirty);
|
||||
});
|
||||
|
||||
public Task<MergePreviewDto> PreviewMerge(string taskId, string targetBranch)
|
||||
@@ -774,6 +775,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
||||
{
|
||||
if (_interactiveLaunchSpec is null)
|
||||
throw new InvalidOperationException("Interactive launch spec service is not configured.");
|
||||
_broadcaster.WorktreeUpdated(taskId);
|
||||
return _interactiveLaunchSpec.BuildForTaskAsync(taskId, Context.ConnectionAborted);
|
||||
});
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ using ClaudeDo.Data;
|
||||
using ClaudeDo.Data.Git;
|
||||
using ClaudeDo.Data.Models;
|
||||
using ClaudeDo.Data.Repositories;
|
||||
using ClaudeDo.Worker.Hub;
|
||||
using ClaudeDo.Worker.Runner;
|
||||
using ClaudeDo.Worker.State;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@@ -20,17 +21,20 @@ public sealed class InteractiveReviewSubmissionService
|
||||
private readonly GitService _git;
|
||||
private readonly ITaskStateService _state;
|
||||
private readonly WorktreeManager? _worktreeManager;
|
||||
private readonly HubBroadcaster? _broadcaster;
|
||||
|
||||
public InteractiveReviewSubmissionService(
|
||||
IDbContextFactory<ClaudeDoDbContext> dbFactory,
|
||||
GitService git,
|
||||
ITaskStateService state,
|
||||
WorktreeManager? worktreeManager = null)
|
||||
WorktreeManager? worktreeManager = null,
|
||||
HubBroadcaster? broadcaster = null)
|
||||
{
|
||||
_dbFactory = dbFactory;
|
||||
_git = git;
|
||||
_state = state;
|
||||
_worktreeManager = worktreeManager;
|
||||
_broadcaster = broadcaster;
|
||||
}
|
||||
|
||||
public async Task SubmitAsync(string taskId, CancellationToken ct)
|
||||
@@ -86,5 +90,14 @@ public sealed class InteractiveReviewSubmissionService
|
||||
var result = await _state.SubmitInteractiveForReviewAsync(taskId, DateTime.UtcNow, ct);
|
||||
if (!result.Ok)
|
||||
throw new InvalidOperationException(result.Reason ?? "Could not submit for review.");
|
||||
|
||||
// The transition itself only broadcasts TaskUpdated, and the detail pane answers that by
|
||||
// refreshing the task row alone -- its whole REVIEW/MERGE block (Open diff, branch picker,
|
||||
// change summary) is gated on the worktree it last read, refreshed only on WorktreeUpdated.
|
||||
// An interactive session creates its worktree at launch and gets it committed HERE, both
|
||||
// outside the autonomous pipeline that would normally announce it, so without this the task
|
||||
// arrives in WaitingForReview with the diff button missing until it is re-selected.
|
||||
if (_broadcaster is not null)
|
||||
await _broadcaster.WorktreeUpdated(taskId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,8 @@ public sealed record MergeResult(
|
||||
public sealed record MergeTargets(
|
||||
string DefaultBranch,
|
||||
IReadOnlyList<string> LocalBranches,
|
||||
string DefaultCommitMessage);
|
||||
string DefaultCommitMessage,
|
||||
bool TargetDirty = false);
|
||||
|
||||
// VerifyExitCode/VerifyDurationMs/VerifyOutputTail are only populated when the caller asked for
|
||||
// a verify run (runVerify=true) AND the list has a verify command configured AND the preview came
|
||||
@@ -875,7 +876,8 @@ public sealed class TaskMergeService
|
||||
|
||||
var current = await _git.GetCurrentBranchAsync(list.WorkingDir, ct);
|
||||
var branches = await _git.ListLocalBranchesAsync(list.WorkingDir, ct);
|
||||
return new MergeTargets(current, branches, defaultMessage);
|
||||
var dirty = await _git.HasChangesAsync(list.WorkingDir, includeUntracked: false, ct);
|
||||
return new MergeTargets(current, branches, defaultMessage, dirty);
|
||||
}
|
||||
|
||||
/// The commit message a merge uses when the caller passes none. Built here rather than in the
|
||||
|
||||
@@ -21,11 +21,19 @@ public sealed class QueuePicker : IQueuePicker
|
||||
// Atomic queue claim: UPDATE + RETURNING in a single statement prevents TOCTOU races.
|
||||
// Raw SQL because EF cannot express UPDATE...RETURNING.
|
||||
// Eligible task must be Queued, unblocked (chain and depends-on), not manual, and due
|
||||
// (or unscheduled). A task_dependencies edge only gates on the dependency's Status='done'
|
||||
// -- unlike blocked_by_task_id there is no cascade on the dependency failing, so a task
|
||||
// with a Failed dependency simply stays here, skipped, until someone intervenes. The
|
||||
// depends-on clause is doubly negated on purpose: "no edge whose predecessor isn't done"
|
||||
// means a task with zero edges is eligible, ALL edges must be done, and an edge pointing
|
||||
// (or unscheduled). A task_dependencies edge is satisfied once the predecessor is 'done'
|
||||
// OR its merge has landed (worktrees.state = 'merged'), because review_task(skipVerify)
|
||||
// deliberately parks a successfully merged task in WaitingForReview until the batch
|
||||
// verify_merges runs -- gating on 'done' alone stalled every successor for the whole
|
||||
// batch. Consequences, all deliberate: a failing verify_merges leaves the merges on the
|
||||
// target branch, so successors do run (the predecessor's code is there either way);
|
||||
// revert_merge/abort_merge flip the worktree back to 'active' and re-block; a rejected
|
||||
// task (reject_rerun/reject_park) never reached 'merged' and stays unmet; a task with no
|
||||
// worktree (handler task, empty diff) is only ever satisfied via 'done'. Unlike
|
||||
// blocked_by_task_id there is no cascade on the dependency failing, so a task with a
|
||||
// Failed dependency simply stays here, skipped, until someone intervenes. The depends-on
|
||||
// clause is doubly negated on purpose: "no edge whose predecessor isn't satisfied" means
|
||||
// a task with zero edges is eligible, ALL edges must be satisfied, and an edge pointing
|
||||
// at a vanished task keeps blocking (same as the old single-column form did).
|
||||
// EF SQLite stores DateTime as "yyyy-MM-dd HH:mm:ss.fffffff" — same format used here for comparison.
|
||||
var rows = await ctx.Tasks.FromSqlRaw("""
|
||||
@@ -41,7 +49,11 @@ public sealed class QueuePicker : IQueuePicker
|
||||
WHERE td.task_id = t.id
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM tasks d
|
||||
WHERE d.id = td.depends_on_task_id AND d.status = 'done'
|
||||
WHERE d.id = td.depends_on_task_id
|
||||
AND (d.status = 'done' OR EXISTS (
|
||||
SELECT 1 FROM worktrees w
|
||||
WHERE w.task_id = d.id AND w.state = 'merged'
|
||||
))
|
||||
)
|
||||
)
|
||||
ORDER BY t.sort_order ASC, t.created_at ASC
|
||||
|
||||
@@ -44,7 +44,7 @@ public class IslandsShellViewModelShutdownTests
|
||||
|
||||
private sealed class FakeDialogService(bool confirmResult) : IDialogService
|
||||
{
|
||||
public Task<bool> ConfirmAsync(string message) => Task.FromResult(confirmResult);
|
||||
public Task<bool> ConfirmAsync(string message, string? confirmLabel = null) => Task.FromResult(confirmResult);
|
||||
|
||||
public Task ShowAboutAsync(AboutModalViewModel vm) => throw new NotImplementedException();
|
||||
public Task ShowWeeklyReportAsync(WeeklyReportModalViewModel vm) => throw new NotImplementedException();
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
using ClaudeDo.Ui.Services;
|
||||
|
||||
namespace ClaudeDo.Ui.Tests.Services;
|
||||
|
||||
public class ClipboardImageTempStoreTests : IDisposable
|
||||
{
|
||||
private readonly string _dir;
|
||||
|
||||
public ClipboardImageTempStoreTests()
|
||||
{
|
||||
_dir = Path.Combine(Path.GetTempPath(), "ClaudeDo.Ui.Tests-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(_dir);
|
||||
}
|
||||
|
||||
public void Dispose() { try { Directory.Delete(_dir, true); } catch { } }
|
||||
|
||||
[Fact]
|
||||
public void NewPngPath_IsUniqueAndUnderTheGivenDir()
|
||||
{
|
||||
var now = new DateTime(2026, 9, 16, 10, 30, 0, DateTimeKind.Utc);
|
||||
|
||||
var a = ClipboardImageTempStore.NewPngPath(_dir, now);
|
||||
var b = ClipboardImageTempStore.NewPngPath(_dir, now);
|
||||
|
||||
Assert.NotEqual(a, b);
|
||||
Assert.Equal(_dir, Path.GetDirectoryName(a));
|
||||
Assert.Equal(".png", Path.GetExtension(a));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeleteOlderThan_RemovesOnlyFilesPastTheMaxAge()
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var oldFile = Path.Combine(_dir, "old.png");
|
||||
var freshFile = Path.Combine(_dir, "fresh.png");
|
||||
File.WriteAllText(oldFile, "x");
|
||||
File.WriteAllText(freshFile, "x");
|
||||
File.SetLastWriteTimeUtc(oldFile, now - TimeSpan.FromHours(25));
|
||||
File.SetLastWriteTimeUtc(freshFile, now - TimeSpan.FromHours(1));
|
||||
|
||||
ClipboardImageTempStore.DeleteOlderThan(_dir, TimeSpan.FromHours(24), now);
|
||||
|
||||
Assert.False(File.Exists(oldFile));
|
||||
Assert.True(File.Exists(freshFile));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeleteOlderThan_NonExistentDir_DoesNotThrow()
|
||||
{
|
||||
ClipboardImageTempStore.DeleteOlderThan(Path.Combine(_dir, "missing"), TimeSpan.FromHours(24), DateTime.UtcNow);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
using ClaudeDo.Data;
|
||||
using ClaudeDo.Ui.Services;
|
||||
using ClaudeDo.Ui.Services.Interfaces;
|
||||
using ClaudeDo.Ui.ViewModels;
|
||||
using ClaudeDo.Ui.ViewModels.Conflicts;
|
||||
using ClaudeDo.Ui.ViewModels.Modals;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ClaudeDo.Ui.Tests;
|
||||
@@ -31,3 +34,43 @@ public sealed class StubNotesApi : INotesApi
|
||||
|
||||
/// <summary><see cref="StubWorkerClient"/> is abstract — this is the concrete no-op subclass.</summary>
|
||||
public sealed class DefaultStub : StubWorkerClient { }
|
||||
|
||||
/// <summary>
|
||||
/// Records the message/label of the last Confirm/Error call and returns a configurable
|
||||
/// Confirm result. Covers the ViewModels (DetailsIsland, TasksIsland, ...) that take their
|
||||
/// dialog seam via a settable <c>Dialogs</c> property instead of a per-call constructor arg.
|
||||
/// </summary>
|
||||
public sealed class RecordingDialogService : IDialogService
|
||||
{
|
||||
public bool ConfirmResult { get; set; } = true;
|
||||
public string? LastConfirmMessage { get; private set; }
|
||||
public string? LastConfirmLabel { get; private set; }
|
||||
public string? LastErrorMessage { get; private set; }
|
||||
|
||||
public Task<bool> ConfirmAsync(string message, string? confirmLabel = null)
|
||||
{
|
||||
LastConfirmMessage = message;
|
||||
LastConfirmLabel = confirmLabel;
|
||||
return Task.FromResult(ConfirmResult);
|
||||
}
|
||||
|
||||
public Task ShowErrorAsync(string message)
|
||||
{
|
||||
LastErrorMessage = message;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task ShowAboutAsync(AboutModalViewModel vm) => throw new NotImplementedException();
|
||||
public Task ShowWeeklyReportAsync(WeeklyReportModalViewModel vm) => throw new NotImplementedException();
|
||||
public Task ShowUsageMonitorAsync(UsageMonitorModalViewModel vm) => throw new NotImplementedException();
|
||||
public Task ShowSettingsAsync(SettingsModalViewModel vm) => throw new NotImplementedException();
|
||||
public Task ShowListSettingsAsync(ListSettingsModalViewModel vm) => throw new NotImplementedException();
|
||||
public Task ShowRepoImportAsync(RepoImportModalViewModel vm) => throw new NotImplementedException();
|
||||
public Task ShowWorktreesOverviewAsync(WorktreesOverviewModalViewModel vm) => throw new NotImplementedException();
|
||||
public Task<IReadOnlyList<string>?> ShowMergeHelperSelectionAsync(MergeHelperSelectionModalViewModel vm) => throw new NotImplementedException();
|
||||
public Task ShowWorkerConnectionAsync(WorkerConnectionModalViewModel vm) => throw new NotImplementedException();
|
||||
public Task ShowConflictResolverAsync(ConflictResolverViewModel vm) => throw new NotImplementedException();
|
||||
public Task ShowLogVisualizerAsync(LogVisualizerViewModel vm) => throw new NotImplementedException();
|
||||
public void ShowMissionControl(MissionControlViewModel vm) => throw new NotImplementedException();
|
||||
public void ShowCheatSheets(CheatSheetsViewModel vm) => throw new NotImplementedException();
|
||||
}
|
||||
|
||||
@@ -90,12 +90,12 @@ public class DetailsIslandDeleteTaskTests : IDisposable
|
||||
vm.DeleteFromList = _ => { deleteFromListCalled = true; return Task.CompletedTask; };
|
||||
var closed = false;
|
||||
vm.CloseDetail = () => closed = true;
|
||||
string? reportedError = null;
|
||||
vm.ShowErrorAsync = msg => { reportedError = msg; return Task.CompletedTask; };
|
||||
var dialogs = new RecordingDialogService();
|
||||
vm.Dialogs = dialogs;
|
||||
|
||||
await vm.DeleteTaskCommand.ExecuteAsync(null);
|
||||
|
||||
Assert.Equal(fkMessage, reportedError);
|
||||
Assert.Equal(fkMessage, dialogs.LastErrorMessage);
|
||||
Assert.False(deleteFromListCalled);
|
||||
Assert.False(closed);
|
||||
}
|
||||
@@ -136,12 +136,12 @@ public class DetailsIslandDeleteTaskTests : IDisposable
|
||||
vm.DeleteFromList = _ => { deleteFromListCalled = true; return Task.CompletedTask; };
|
||||
var closed = false;
|
||||
vm.CloseDetail = () => closed = true;
|
||||
string? reportedError = null;
|
||||
vm.ShowErrorAsync = msg => { reportedError = msg; return Task.CompletedTask; };
|
||||
var dialogs = new RecordingDialogService();
|
||||
vm.Dialogs = dialogs;
|
||||
|
||||
await vm.DeleteTaskCommand.ExecuteAsync(null);
|
||||
|
||||
Assert.Equal("Connection is not active.", reportedError);
|
||||
Assert.Equal("Connection is not active.", dialogs.LastErrorMessage);
|
||||
Assert.False(deleteFromListCalled);
|
||||
Assert.False(closed);
|
||||
}
|
||||
|
||||
@@ -164,7 +164,7 @@ public class DetailsIslandErrorFeedbackTests : IDisposable
|
||||
var vm = BuildVm(worker);
|
||||
vm.Bind(new TaskRowViewModel { Id = "task-reset-throw", Status = TaskStatus.Failed });
|
||||
vm.Monitor.ApplyState(TaskStatus.Failed);
|
||||
vm.ConfirmAsync = _ => Task.FromResult(true);
|
||||
vm.Dialogs = new RecordingDialogService { ConfirmResult = true };
|
||||
|
||||
string? reportedError = null;
|
||||
vm.ErrorReported += msg => reportedError = msg;
|
||||
|
||||
@@ -153,12 +153,12 @@ public class DetailsIslandReviewActionsTests : IDisposable
|
||||
vm.Bind(new TaskRowViewModel { Id = "task-err-1", Status = TaskStatus.WaitingForReview });
|
||||
vm.Monitor.ApplyState(TaskStatus.WaitingForReview);
|
||||
|
||||
string? reportedError = null;
|
||||
vm.ShowErrorAsync = msg => { reportedError = msg; return Task.CompletedTask; };
|
||||
var dialogs = new RecordingDialogService();
|
||||
vm.Dialogs = dialogs;
|
||||
|
||||
await vm.ApproveReviewCommand.ExecuteAsync(null);
|
||||
|
||||
Assert.Equal(worker.ExceptionMessage, reportedError);
|
||||
Assert.Equal(worker.ExceptionMessage, dialogs.LastErrorMessage);
|
||||
Assert.False(vm.ApproveOp.IsRunning);
|
||||
Assert.True(vm.ApproveReviewCommand.CanExecute(null));
|
||||
}
|
||||
|
||||
@@ -62,7 +62,7 @@ public class ListsIslandCreateListCancelTests : IDisposable
|
||||
public Task ShowWorkerConnectionAsync(WorkerConnectionModalViewModel vm) => throw new NotImplementedException();
|
||||
public Task ShowConflictResolverAsync(ConflictResolverViewModel vm) => throw new NotImplementedException();
|
||||
public Task ShowLogVisualizerAsync(LogVisualizerViewModel vm) => throw new NotImplementedException();
|
||||
public Task<bool> ConfirmAsync(string message) => Task.FromResult(true);
|
||||
public Task<bool> ConfirmAsync(string message, string? confirmLabel = null) => Task.FromResult(true);
|
||||
public Task ShowErrorAsync(string message) => Task.CompletedTask;
|
||||
public void ShowMissionControl(MissionControlViewModel vm) => throw new NotImplementedException();
|
||||
public void ShowCheatSheets(CheatSheetsViewModel vm) => throw new NotImplementedException();
|
||||
|
||||
@@ -263,7 +263,8 @@ public class ListsIslandFolderTests : IDisposable
|
||||
var folder = vm.Folders[0];
|
||||
Assert.False(folder.IsCollapsed);
|
||||
|
||||
await vm.ToggleFolderCommand.ExecuteAsync(folder);
|
||||
vm.ToggleFolderCommand.Execute(folder);
|
||||
await Task.Delay(100);
|
||||
|
||||
Assert.True(folder.IsCollapsed);
|
||||
await using var db = NewContext();
|
||||
@@ -275,6 +276,31 @@ public class ListsIslandFolderTests : IDisposable
|
||||
Assert.True(reloaded.Folders[0].IsCollapsed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ToggleFolder_rapid_double_click_both_toggles_land()
|
||||
{
|
||||
// Bug F1 (docs/test-session-2026-09-16.md): the command used to be an async
|
||||
// [RelayCommand], so AsyncRelayCommand's own CanExecute lock swallowed the second click
|
||||
// while the first one's DB write was still in flight. Firing two toggles back-to-back
|
||||
// (no await between them) reproduces exactly that window.
|
||||
await SeedAsync("f1", ("inside", "Inside", 0));
|
||||
|
||||
var vm = NewVm();
|
||||
await vm.LoadAsync();
|
||||
var folder = vm.Folders[0];
|
||||
Assert.False(folder.IsCollapsed);
|
||||
|
||||
vm.ToggleFolderCommand.Execute(folder);
|
||||
vm.ToggleFolderCommand.Execute(folder);
|
||||
await Task.Delay(100);
|
||||
|
||||
// Two flips land back on the original (visible) state...
|
||||
Assert.False(folder.IsCollapsed);
|
||||
// ...and the DB ends up agreeing with it, not with the stale mid-flight value.
|
||||
await using var db = NewContext();
|
||||
Assert.False((await db.ListFolders.AsNoTracking().FirstAsync(f => f.Id == "f1")).IsCollapsed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RemoveFromFolder_command_is_the_context_menu_route_out()
|
||||
{
|
||||
|
||||
@@ -69,6 +69,30 @@ public class MergeModalViewModelTests
|
||||
Assert.NotEqual("", vm.CommitMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Initialize_flags_a_dirty_target_up_front()
|
||||
{
|
||||
var (vm, worker) = Build();
|
||||
worker.Targets = new MergeTargetsDto("main", new[] { "main", "dev" }, "fix(my-list): merge t", TargetDirty: true);
|
||||
|
||||
await vm.InitializeAsync("task-1", "do the thing");
|
||||
|
||||
Assert.True(vm.ShowStashRetry);
|
||||
Assert.False(string.IsNullOrWhiteSpace(vm.WarningMessage));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Initialize_does_not_flag_a_clean_target()
|
||||
{
|
||||
var (vm, worker) = Build();
|
||||
worker.Targets = new MergeTargetsDto("main", new[] { "main", "dev" }, "fix(my-list): merge t", TargetDirty: false);
|
||||
|
||||
await vm.InitializeAsync("task-1", "do the thing");
|
||||
|
||||
Assert.False(vm.ShowStashRetry);
|
||||
Assert.Null(vm.WarningMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Submit_shows_a_phase_while_the_merge_is_pending_and_clears_it_after()
|
||||
{
|
||||
|
||||
@@ -43,7 +43,7 @@ public class TasksIslandMoveToListTests : IDisposable
|
||||
public int ConfirmCalls;
|
||||
public string? LastMessage;
|
||||
|
||||
public Task<bool> ConfirmAsync(string message)
|
||||
public Task<bool> ConfirmAsync(string message, string? confirmLabel = null)
|
||||
{
|
||||
ConfirmCalls++;
|
||||
LastMessage = message;
|
||||
|
||||
@@ -114,6 +114,32 @@ public class WorktreesOverviewReconcileTickTests
|
||||
Assert.Equal(1, vm.SelectedCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Map_carries_the_task_number_and_hides_the_badge_when_zero()
|
||||
{
|
||||
var worker = new FakeWorker
|
||||
{
|
||||
Worktrees =
|
||||
{
|
||||
Wt("a") with { TaskNumber = 42 },
|
||||
Wt("b") with { TaskNumber = 0 },
|
||||
},
|
||||
};
|
||||
var vm = new WorktreesOverviewModalViewModel(
|
||||
worker, () => throw new InvalidOperationException("no diff vm in this test"),
|
||||
new ClaudeDo.Ui.Services.MergeCoordinator());
|
||||
vm.Configure("L1", "Work");
|
||||
await vm.LoadAsync();
|
||||
|
||||
var withNumber = vm.Rows.Single(r => r.TaskId == "a");
|
||||
var withoutNumber = vm.Rows.Single(r => r.TaskId == "b");
|
||||
|
||||
Assert.Equal(42, withNumber.TaskNumber);
|
||||
Assert.True(withNumber.ShowNumberBadge);
|
||||
Assert.Equal(0, withoutNumber.TaskNumber);
|
||||
Assert.False(withoutNumber.ShowNumberBadge);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("refresh")]
|
||||
[InlineData("cleanup")]
|
||||
|
||||
@@ -472,6 +472,34 @@ public sealed class ExternalMcpServiceTests : IDisposable
|
||||
Assert.Null(dto.BlockedReason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetTask_QueuedWithMergedButUnverifiedDependsOn_ReportsBlockedFalse()
|
||||
{
|
||||
// Same rule as QueuePicker: a predecessor merged with skipVerify sits in
|
||||
// WaitingForReview until the batch verify, but the picker already claims the successor --
|
||||
// get_task must not contradict that by still calling it blocked.
|
||||
var listId = await SeedListAsync();
|
||||
var predecessor = await SeedTaskAsync(listId, status: TaskStatus.WaitingForReview);
|
||||
_ctx.Worktrees.Add(new WorktreeEntity
|
||||
{
|
||||
TaskId = predecessor.Id,
|
||||
Path = $"/tmp/{predecessor.Id}",
|
||||
BranchName = $"claudedo/{predecessor.Id}",
|
||||
BaseCommit = "abc123",
|
||||
State = WorktreeState.Merged,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
});
|
||||
await _ctx.SaveChangesAsync();
|
||||
var task = await SeedTaskAsync(listId, status: TaskStatus.Queued);
|
||||
await _ctx.SeedDependsOnAsync(task.Id, predecessor.Id);
|
||||
var sut = BuildSut(CreateQueue());
|
||||
|
||||
var dto = await sut.GetTask(task.Id, CancellationToken.None);
|
||||
|
||||
Assert.False(dto.Blocked);
|
||||
Assert.Null(dto.BlockedReason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetTask_IdleWithDependsOnTaskId_ReportsBlockedFalse_NotYetQueued()
|
||||
{
|
||||
|
||||
+177
-14
@@ -163,12 +163,13 @@ public sealed class MergeMcpToolsTests : IDisposable
|
||||
}
|
||||
|
||||
private async Task<string> CreateActiveWorktreeAsync(
|
||||
GitRepoFixture repo, string taskId, string fileName, string content)
|
||||
GitRepoFixture repo, string taskId, string fileName, string content, string? baseCommit = null)
|
||||
{
|
||||
baseCommit ??= repo.BaseCommit;
|
||||
var wtPath = Path.Combine(Path.GetTempPath(), $"wt_{Guid.NewGuid():N}");
|
||||
_worktreeCleanups.Add((repo.RepoDir, wtPath));
|
||||
var branch = $"claudedo/{taskId[..8]}";
|
||||
GitRepoFixture.RunGit(repo.RepoDir, "worktree", "add", "-b", branch, wtPath, repo.BaseCommit);
|
||||
GitRepoFixture.RunGit(repo.RepoDir, "worktree", "add", "-b", branch, wtPath, baseCommit);
|
||||
File.WriteAllText(Path.Combine(wtPath, fileName), content);
|
||||
GitRepoFixture.RunGit(wtPath, "add", fileName);
|
||||
GitRepoFixture.RunGit(wtPath, "commit", "-m", $"edit {fileName}");
|
||||
@@ -177,7 +178,7 @@ public sealed class MergeMcpToolsTests : IDisposable
|
||||
ctx.Worktrees.Add(new WorktreeEntity
|
||||
{
|
||||
TaskId = taskId, Path = wtPath, BranchName = branch,
|
||||
BaseCommit = repo.BaseCommit,
|
||||
BaseCommit = baseCommit,
|
||||
HeadCommit = GitRepoFixture.RunGit(wtPath, "rev-parse", "HEAD").Trim(),
|
||||
State = WorktreeState.Active, CreatedAt = DateTime.UtcNow,
|
||||
});
|
||||
@@ -793,7 +794,7 @@ public sealed class MergeMcpToolsTests : IDisposable
|
||||
var headBefore = await git.RevParseHeadAsync(list.WorkingDir!, CancellationToken.None);
|
||||
var sut = BuildSut(CreateQueue());
|
||||
|
||||
var result = await sut.PreviewMerge(task.Id, target, CancellationToken.None);
|
||||
var result = await sut.PreviewMerge(task.Id, target, cancellationToken: CancellationToken.None);
|
||||
|
||||
Assert.Equal(TaskMergeService.PreviewClean, result.Status);
|
||||
Assert.Empty(result.ConflictFiles);
|
||||
@@ -815,7 +816,7 @@ public sealed class MergeMcpToolsTests : IDisposable
|
||||
var target = await new GitService().GetCurrentBranchAsync(list.WorkingDir!, CancellationToken.None);
|
||||
var sut = BuildSut(CreateQueue());
|
||||
|
||||
var result = await sut.PreviewMerge(task.Id, target, CancellationToken.None);
|
||||
var result = await sut.PreviewMerge(task.Id, target, cancellationToken: CancellationToken.None);
|
||||
|
||||
Assert.Equal(TaskMergeService.PreviewClean, result.Status);
|
||||
Assert.Equal(0, result.ChangedFileCount);
|
||||
@@ -843,7 +844,7 @@ public sealed class MergeMcpToolsTests : IDisposable
|
||||
}
|
||||
|
||||
var sut = BuildSut(CreateQueue());
|
||||
var result = await sut.PreviewMerge(task.Id, null, CancellationToken.None);
|
||||
var result = await sut.PreviewMerge(task.Id, null, cancellationToken: CancellationToken.None);
|
||||
|
||||
Assert.Equal(TaskMergeService.PreviewClean, result.Status);
|
||||
Assert.Equal(0, result.ChangedFileCount);
|
||||
@@ -875,7 +876,7 @@ public sealed class MergeMcpToolsTests : IDisposable
|
||||
}
|
||||
|
||||
var sut = BuildSut(CreateQueue());
|
||||
var result = await sut.PreviewMerge(task.Id, null, CancellationToken.None);
|
||||
var result = await sut.PreviewMerge(task.Id, null, cancellationToken: CancellationToken.None);
|
||||
|
||||
Assert.Equal(TaskMergeService.PreviewClean, result.Status);
|
||||
Assert.Equal(1, result.ChangedFileCount);
|
||||
@@ -893,7 +894,7 @@ public sealed class MergeMcpToolsTests : IDisposable
|
||||
var headBefore = await git.RevParseHeadAsync(list.WorkingDir!, CancellationToken.None);
|
||||
var sut = BuildSut(CreateQueue());
|
||||
|
||||
var result = await sut.PreviewMerge(task.Id, target, CancellationToken.None);
|
||||
var result = await sut.PreviewMerge(task.Id, target, cancellationToken: CancellationToken.None);
|
||||
|
||||
Assert.Equal(TaskMergeService.PreviewConflict, result.Status);
|
||||
Assert.Contains("README.md", result.ConflictFiles);
|
||||
@@ -910,7 +911,7 @@ public sealed class MergeMcpToolsTests : IDisposable
|
||||
var sut = BuildSut(CreateQueue());
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => sut.PreviewMerge(task.Id, null, CancellationToken.None));
|
||||
() => sut.PreviewMerge(task.Id, null, cancellationToken: CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -928,7 +929,7 @@ public sealed class MergeMcpToolsTests : IDisposable
|
||||
|
||||
var sut = BuildSut(CreateQueue());
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => sut.PreviewMerge(task.Id, null, CancellationToken.None));
|
||||
() => sut.PreviewMerge(task.Id, null, cancellationToken: CancellationToken.None));
|
||||
Assert.Contains("working directory", ex.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
@@ -952,7 +953,7 @@ public sealed class MergeMcpToolsTests : IDisposable
|
||||
var fakeVerify = new FakeVerifyCommandRunner { Result = new VerifyCommandResult(0, false, "ok") };
|
||||
var sut = BuildSut(CreateQueue(), fakeVerify);
|
||||
|
||||
var result = await sut.PreviewMerge(task.Id, target, CancellationToken.None);
|
||||
var result = await sut.PreviewMerge(task.Id, target, runVerify: true, cancellationToken: CancellationToken.None);
|
||||
|
||||
Assert.Equal(TaskMergeService.PreviewClean, result.Status);
|
||||
Assert.Equal(0, result.VerifyExitCode);
|
||||
@@ -962,6 +963,34 @@ public sealed class MergeMcpToolsTests : IDisposable
|
||||
Assert.False(await git.HasChangesAsync(list.WorkingDir!, CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PreviewMerge_DefaultRunVerifyFalse_NeverInvokesRunnerEvenWithCommandConfigured()
|
||||
{
|
||||
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
||||
|
||||
var (task, list, wt) = await SeedWorktreeAsync(TaskStatus.WaitingForReview);
|
||||
File.WriteAllText(Path.Combine(wt.WorktreePath, "added.txt"), "content");
|
||||
var cfg = new WorkerConfig { WorktreeRootStrategy = "sibling" };
|
||||
var mgr = new WorktreeManager(new GitService(), _db.CreateFactory(), cfg, NullLogger<WorktreeManager>.Instance);
|
||||
await mgr.CommitIfChangedAsync(wt, task, list, CancellationToken.None);
|
||||
|
||||
using (var ctx = _db.CreateContext())
|
||||
await new ListRepository(ctx).SetConfigAsync(new ListConfigEntity { ListId = list.Id, VerifyCommand = "dotnet build" });
|
||||
|
||||
var git = new GitService();
|
||||
var target = await git.GetCurrentBranchAsync(list.WorkingDir!, CancellationToken.None);
|
||||
var fakeVerify = new FakeVerifyCommandRunner { Result = new VerifyCommandResult(0, false, "ok") };
|
||||
var sut = BuildSut(CreateQueue(), fakeVerify);
|
||||
|
||||
var result = await sut.PreviewMerge(task.Id, target, cancellationToken: CancellationToken.None);
|
||||
|
||||
Assert.Equal(TaskMergeService.PreviewClean, result.Status);
|
||||
Assert.Null(result.VerifyExitCode);
|
||||
Assert.Null(result.VerifyDurationMs);
|
||||
Assert.Null(result.VerifyOutputTail);
|
||||
Assert.Null(fakeVerify.CapturedCommand);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PreviewMerge_TargetChangedSameFileSinceFork_ReportsStaleFiles()
|
||||
{
|
||||
@@ -982,7 +1011,7 @@ public sealed class MergeMcpToolsTests : IDisposable
|
||||
GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "main also touched shared.txt");
|
||||
|
||||
var sut = BuildSut(CreateQueue());
|
||||
var result = await sut.PreviewMerge(task.Id, "main", CancellationToken.None);
|
||||
var result = await sut.PreviewMerge(task.Id, "main", cancellationToken: CancellationToken.None);
|
||||
|
||||
Assert.Equal(1, result.Behind);
|
||||
Assert.NotNull(result.StaleFiles);
|
||||
@@ -1010,7 +1039,7 @@ public sealed class MergeMcpToolsTests : IDisposable
|
||||
GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "main touched something else");
|
||||
|
||||
var sut = BuildSut(CreateQueue());
|
||||
var result = await sut.PreviewMerge(task.Id, "main", CancellationToken.None);
|
||||
var result = await sut.PreviewMerge(task.Id, "main", cancellationToken: CancellationToken.None);
|
||||
|
||||
Assert.Equal(1, result.Behind);
|
||||
Assert.NotNull(result.StaleFiles);
|
||||
@@ -1042,7 +1071,7 @@ public sealed class MergeMcpToolsTests : IDisposable
|
||||
}
|
||||
|
||||
var sut = BuildSut(CreateQueue());
|
||||
var result = await sut.PreviewMerge(task.Id, null, CancellationToken.None);
|
||||
var result = await sut.PreviewMerge(task.Id, null, cancellationToken: CancellationToken.None);
|
||||
|
||||
Assert.NotNull(result.StaleFiles);
|
||||
Assert.Empty(result.StaleFiles!);
|
||||
@@ -1289,4 +1318,138 @@ public sealed class MergeMcpToolsTests : IDisposable
|
||||
Assert.Equal(0, result.Tasks.Single().VerifyExitCode);
|
||||
Assert.Equal("dotnet build", fakeVerify.CapturedCommand);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PreviewMergeSet_RemovedSymbolReferencedByAnotherTask_ReportsSymbolWarning()
|
||||
{
|
||||
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
||||
|
||||
var repo = new GitRepoFixture();
|
||||
_repos.Add(repo);
|
||||
GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main");
|
||||
|
||||
File.WriteAllText(Path.Combine(repo.RepoDir, "Foo.cs"),
|
||||
"public class Foo\n{\n private void RestartWorkerStatus()\n {\n }\n}\n");
|
||||
File.WriteAllText(Path.Combine(repo.RepoDir, "Bar.cs"), "public class Bar\n{\n}\n");
|
||||
GitRepoFixture.RunGit(repo.RepoDir, "add", "-A");
|
||||
GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "add Foo/Bar");
|
||||
var baseCommit = GitRepoFixture.RunGit(repo.RepoDir, "rev-parse", "HEAD").Trim();
|
||||
|
||||
var listId = Guid.NewGuid().ToString();
|
||||
await _lists.AddAsync(new ListEntity { Id = listId, Name = "L", WorkingDir = repo.RepoDir, CreatedAt = DateTime.UtcNow });
|
||||
var removerTask = await SeedTaskAsync(listId, "removes", TaskStatus.WaitingForReview);
|
||||
var referencerTask = await SeedTaskAsync(listId, "references", TaskStatus.WaitingForReview);
|
||||
|
||||
await CreateActiveWorktreeAsync(repo, removerTask.Id, "Foo.cs", "public class Foo\n{\n}\n", baseCommit);
|
||||
await CreateActiveWorktreeAsync(repo, referencerTask.Id, "Bar.cs",
|
||||
"public class Bar\n{\n private void Call()\n {\n RestartWorkerStatus();\n }\n}\n", baseCommit);
|
||||
|
||||
var sut = BuildSut(CreateQueue());
|
||||
var result = await sut.PreviewMergeSet(
|
||||
[removerTask.Id, referencerTask.Id], "main", cancellationToken: CancellationToken.None);
|
||||
|
||||
var removerEntry = result.Tasks.Single(t => t.TaskId == removerTask.Id);
|
||||
Assert.Contains(removerEntry.RemovedSymbols!, s => s.Name == "RestartWorkerStatus" && s.File == "Foo.cs");
|
||||
|
||||
var warning = Assert.Single(result.SymbolWarnings);
|
||||
Assert.Equal("RestartWorkerStatus", warning.Symbol);
|
||||
Assert.Equal("Foo.cs", warning.RemovedInFile);
|
||||
Assert.Equal(removerTask.Id, warning.RemovedByTaskId);
|
||||
Assert.Equal(referencerTask.Id, warning.ReferencedByTaskId);
|
||||
Assert.Equal("Bar.cs", warning.ReferencedInFile);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PreviewMergeSet_TwoTasksAddDifferentJsonKeys_ReportsNoOverlap()
|
||||
{
|
||||
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
||||
|
||||
var repo = new GitRepoFixture();
|
||||
_repos.Add(repo);
|
||||
GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main");
|
||||
|
||||
File.WriteAllText(Path.Combine(repo.RepoDir, "en.json"), "{}\n");
|
||||
GitRepoFixture.RunGit(repo.RepoDir, "add", "-A");
|
||||
GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "add en.json");
|
||||
var baseCommit = GitRepoFixture.RunGit(repo.RepoDir, "rev-parse", "HEAD").Trim();
|
||||
|
||||
var listId = Guid.NewGuid().ToString();
|
||||
await _lists.AddAsync(new ListEntity { Id = listId, Name = "L", WorkingDir = repo.RepoDir, CreatedAt = DateTime.UtcNow });
|
||||
var taskA = await SeedTaskAsync(listId, "a", TaskStatus.WaitingForReview);
|
||||
var taskB = await SeedTaskAsync(listId, "b", TaskStatus.WaitingForReview);
|
||||
|
||||
await CreateActiveWorktreeAsync(repo, taskA.Id, "en.json", "{\"a\": {\"one\": \"1\"}}\n", baseCommit);
|
||||
await CreateActiveWorktreeAsync(repo, taskB.Id, "en.json", "{\"a\": {\"two\": \"2\"}}\n", baseCommit);
|
||||
|
||||
var sut = BuildSut(CreateQueue());
|
||||
var result = await sut.PreviewMergeSet([taskA.Id, taskB.Id], "main", cancellationToken: CancellationToken.None);
|
||||
|
||||
Assert.Empty(result.Overlaps);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PreviewMergeSet_TwoTasksChangeSameJsonKey_ReportsOverlap()
|
||||
{
|
||||
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
||||
|
||||
var repo = new GitRepoFixture();
|
||||
_repos.Add(repo);
|
||||
GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main");
|
||||
|
||||
File.WriteAllText(Path.Combine(repo.RepoDir, "en.json"), "{}\n");
|
||||
GitRepoFixture.RunGit(repo.RepoDir, "add", "-A");
|
||||
GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "add en.json");
|
||||
var baseCommit = GitRepoFixture.RunGit(repo.RepoDir, "rev-parse", "HEAD").Trim();
|
||||
|
||||
var listId = Guid.NewGuid().ToString();
|
||||
await _lists.AddAsync(new ListEntity { Id = listId, Name = "L", WorkingDir = repo.RepoDir, CreatedAt = DateTime.UtcNow });
|
||||
var taskA = await SeedTaskAsync(listId, "a", TaskStatus.WaitingForReview);
|
||||
var taskB = await SeedTaskAsync(listId, "b", TaskStatus.WaitingForReview);
|
||||
|
||||
await CreateActiveWorktreeAsync(repo, taskA.Id, "en.json", "{\"a\": {\"one\": \"1\"}}\n", baseCommit);
|
||||
await CreateActiveWorktreeAsync(repo, taskB.Id, "en.json", "{\"a\": {\"one\": \"2\"}}\n", baseCommit);
|
||||
|
||||
var sut = BuildSut(CreateQueue());
|
||||
var result = await sut.PreviewMergeSet([taskA.Id, taskB.Id], "main", cancellationToken: CancellationToken.None);
|
||||
|
||||
var overlap = Assert.Single(result.Overlaps);
|
||||
Assert.Equal("en.json", overlap.File);
|
||||
Assert.Equal(
|
||||
new[] { taskA.Id, taskB.Id }.OrderBy(x => x),
|
||||
overlap.TaskIds.OrderBy(x => x));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PreviewMergeSet_UnparsableJsonOnOneSide_FallsBackToFileOverlap()
|
||||
{
|
||||
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
||||
|
||||
var repo = new GitRepoFixture();
|
||||
_repos.Add(repo);
|
||||
GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main");
|
||||
|
||||
File.WriteAllText(Path.Combine(repo.RepoDir, "en.json"), "{}\n");
|
||||
GitRepoFixture.RunGit(repo.RepoDir, "add", "-A");
|
||||
GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "add en.json");
|
||||
var baseCommit = GitRepoFixture.RunGit(repo.RepoDir, "rev-parse", "HEAD").Trim();
|
||||
|
||||
var listId = Guid.NewGuid().ToString();
|
||||
await _lists.AddAsync(new ListEntity { Id = listId, Name = "L", WorkingDir = repo.RepoDir, CreatedAt = DateTime.UtcNow });
|
||||
var taskA = await SeedTaskAsync(listId, "a", TaskStatus.WaitingForReview);
|
||||
var taskB = await SeedTaskAsync(listId, "b", TaskStatus.WaitingForReview);
|
||||
|
||||
// taskA leaves the file syntactically broken -- both sides must still be flagged since
|
||||
// there's no way to tell whether the keys actually collide.
|
||||
await CreateActiveWorktreeAsync(repo, taskA.Id, "en.json", "{ not valid json", baseCommit);
|
||||
await CreateActiveWorktreeAsync(repo, taskB.Id, "en.json", "{\"a\": {\"one\": \"2\"}}\n", baseCommit);
|
||||
|
||||
var sut = BuildSut(CreateQueue());
|
||||
var result = await sut.PreviewMergeSet([taskA.Id, taskB.Id], "main", cancellationToken: CancellationToken.None);
|
||||
|
||||
var overlap = Assert.Single(result.Overlaps);
|
||||
Assert.Equal("en.json", overlap.File);
|
||||
Assert.Equal(
|
||||
new[] { taskA.Id, taskB.Id }.OrderBy(x => x),
|
||||
overlap.TaskIds.OrderBy(x => x));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,6 +138,74 @@ public sealed class QueuePickerTests : IDisposable
|
||||
Assert.Equal(dependent.Id, picked!.Id);
|
||||
}
|
||||
|
||||
private async Task SeedWorktreeAsync(string taskId, WorktreeState state)
|
||||
{
|
||||
_ctx.Worktrees.Add(new WorktreeEntity
|
||||
{
|
||||
TaskId = taskId,
|
||||
Path = $"/tmp/{taskId}",
|
||||
BranchName = $"claudedo/{taskId}",
|
||||
BaseCommit = "abc123",
|
||||
State = state,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
});
|
||||
await _ctx.SaveChangesAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ClaimNextAsync_Claims_TaskWhoseDependsOnIsMergedButAwaitingBatchVerify()
|
||||
{
|
||||
// review_task(skipVerify: true) leaves a successfully merged task in WaitingForReview
|
||||
// until verify_merges runs once for the whole batch. Gating on Done alone stalled every
|
||||
// successor for the entire batch, so a landed merge satisfies the edge.
|
||||
var listId = await CreateListAsync();
|
||||
var dependency = await SeedAsync(listId, status: TaskStatus.WaitingForReview);
|
||||
await SeedWorktreeAsync(dependency.Id, WorktreeState.Merged);
|
||||
var dependent = await SeedAsync(listId, dependsOn: [dependency.Id]);
|
||||
|
||||
var picked = await _picker.ClaimNextAsync(DateTime.UtcNow, CancellationToken.None);
|
||||
|
||||
Assert.NotNull(picked);
|
||||
Assert.Equal(dependent.Id, picked!.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ClaimNextAsync_Skips_TaskWhoseDependsOnWasRejected()
|
||||
{
|
||||
// reject_rerun/reject_park never merge, so the worktree stays Active -- the successor
|
||||
// must keep waiting even though the predecessor left WaitingForReview.
|
||||
var listId = await CreateListAsync();
|
||||
var dependency = await SeedAsync(listId, status: TaskStatus.Idle);
|
||||
await SeedWorktreeAsync(dependency.Id, WorktreeState.Active);
|
||||
await SeedAsync(listId, dependsOn: [dependency.Id]);
|
||||
|
||||
Assert.Null(await _picker.ClaimNextAsync(DateTime.UtcNow, CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ClaimNextAsync_Skips_TaskWhoseDependsOnMergeWasReverted()
|
||||
{
|
||||
// revert_merge / abort_merge put the worktree back to Active -- the edge is unmet again.
|
||||
var listId = await CreateListAsync();
|
||||
var dependency = await SeedAsync(listId, status: TaskStatus.WaitingForReview);
|
||||
await SeedWorktreeAsync(dependency.Id, WorktreeState.Active);
|
||||
await SeedAsync(listId, dependsOn: [dependency.Id]);
|
||||
|
||||
Assert.Null(await _picker.ClaimNextAsync(DateTime.UtcNow, CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ClaimNextAsync_Skips_TaskWhoseWorktreelessDependsOnIsNotDone()
|
||||
{
|
||||
// A handler task or an empty-diff run has no worktrees row at all: only 'done' can
|
||||
// satisfy that edge, and the merged-branch must not accidentally match a missing row.
|
||||
var listId = await CreateListAsync();
|
||||
var dependency = await SeedAsync(listId, status: TaskStatus.WaitingForReview);
|
||||
await SeedAsync(listId, dependsOn: [dependency.Id]);
|
||||
|
||||
Assert.Null(await _picker.ClaimNextAsync(DateTime.UtcNow, CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ClaimNextAsync_Skips_TaskWhoseDependsOnFailed_ButKeepsClaimingOthers()
|
||||
{
|
||||
|
||||
@@ -374,6 +374,38 @@ public class TaskMergeServiceTests : IDisposable
|
||||
Assert.Contains(targets.DefaultBranch, targets.LocalBranches);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetTargetsAsync_UncommittedChangeInTarget_ReportsTargetDirty()
|
||||
{
|
||||
if (!GitRepoFixture.IsGitAvailable()) return;
|
||||
var repo = NewRepo();
|
||||
var db = NewDb();
|
||||
var (_, task) = await SeedListAndTask(db, workingDir: repo.RepoDir, status: TaskStatus.Done);
|
||||
|
||||
File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "dirty\n");
|
||||
|
||||
var (svc, _) = BuildService(db);
|
||||
var targets = await svc.GetTargetsAsync(task.Id, CancellationToken.None);
|
||||
|
||||
Assert.True(targets.TargetDirty);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetTargetsAsync_OnlyUntrackedFileInTarget_DoesNotReportTargetDirty()
|
||||
{
|
||||
if (!GitRepoFixture.IsGitAvailable()) return;
|
||||
var repo = NewRepo();
|
||||
var db = NewDb();
|
||||
var (_, task) = await SeedListAndTask(db, workingDir: repo.RepoDir, status: TaskStatus.Done);
|
||||
|
||||
File.WriteAllText(Path.Combine(repo.RepoDir, "untracked.txt"), "stray\n");
|
||||
|
||||
var (svc, _) = BuildService(db);
|
||||
var targets = await svc.GetTargetsAsync(task.Id, CancellationToken.None);
|
||||
|
||||
Assert.False(targets.TargetDirty);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MergeAsync_DirtyWorkingTree_ReturnsBlocked()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user