diff --git a/src/ClaudeDo.Data/Git/GitService.cs b/src/ClaudeDo.Data/Git/GitService.cs index 1e2585bc..651796b4 100644 --- a/src/ClaudeDo.Data/Git/GitService.cs +++ b/src/ClaudeDo.Data/Git/GitService.cs @@ -26,6 +26,22 @@ public sealed class GitService return stdout.Trim(); } + /// + /// True if is an ancestor of (or equal to) , + /// via `git merge-base --is-ancestor`. Null means the answer can't be determined (e.g. the commit is + /// unknown in this repo) — callers must treat that as "unknown", never as "not an ancestor". + /// + public async Task IsAncestorAsync(string repoDir, string ancestorSha, string descendantSha, CancellationToken ct = default) + { + var (exitCode, _, _) = await RunGitAsync(repoDir, ["merge-base", "--is-ancestor", ancestorSha, descendantSha], ct); + return exitCode switch + { + 0 => true, + 1 => false, + _ => null, + }; + } + public async Task WorktreeAddAsync(string repoDir, string branchName, string worktreePath, string baseCommit, CancellationToken ct = default) { await WorktreeAddGate.WaitAsync(ct); diff --git a/src/ClaudeDo.Localization/locales/de.json b/src/ClaudeDo.Localization/locales/de.json index 1c24e254..e332a73b 100644 --- a/src/ClaudeDo.Localization/locales/de.json +++ b/src/ClaudeDo.Localization/locales/de.json @@ -629,6 +629,9 @@ "available": "Update verfügbar: v", "updateNow": "Jetzt aktualisieren", "dismiss": "Ausblenden" + }, + "staleWorker": { + "message": "Der Worker läuft auf einem älteren Stand als der gemergte main-Branch dieses Repos — Neustart nötig, damit gemergte Änderungen wirken." } }, "vm": { diff --git a/src/ClaudeDo.Localization/locales/en.json b/src/ClaudeDo.Localization/locales/en.json index b32e2859..8a0bf038 100644 --- a/src/ClaudeDo.Localization/locales/en.json +++ b/src/ClaudeDo.Localization/locales/en.json @@ -629,6 +629,9 @@ "available": "Update available: v", "updateNow": "Update now", "dismiss": "Dismiss" + }, + "staleWorker": { + "message": "The worker is running an older build than this repo's merged main — restart it so your merged changes take effect." } }, "vm": { diff --git a/src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs b/src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs index a74a9b8b..0a848c81 100644 --- a/src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs +++ b/src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs @@ -135,6 +135,9 @@ public interface IWorkerClient : INotifyPropertyChanged Task GetLastPrepLogAsync(); Task> GetRecentLogsAsync(); + /// Git SHA the running worker was built from (null when offline or the build isn't stamped). + Task GetWorkerBuildInfoAsync(); + Task> GetPrimeSchedulesAsync(); Task UpsertPrimeScheduleAsync(PrimeScheduleDto dto); Task DeletePrimeScheduleAsync(Guid id); diff --git a/src/ClaudeDo.Ui/Services/WorkerClient.cs b/src/ClaudeDo.Ui/Services/WorkerClient.cs index 553551d1..f4201a7c 100644 --- a/src/ClaudeDo.Ui/Services/WorkerClient.cs +++ b/src/ClaudeDo.Ui/Services/WorkerClient.cs @@ -423,6 +423,9 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC public async Task GetLastPrepLogAsync() => await TryInvokeAsync("GetLastPrepLog") ?? string.Empty; + public Task GetWorkerBuildInfoAsync() + => TryInvokeAsync("GetWorkerBuildInfo"); + public async Task> GetRecentLogsAsync() => await TryInvokeAsync>("GetRecentLogs") ?? new List(); @@ -693,6 +696,7 @@ public sealed record LaunchSpec( public sealed record ForceRemoveResultDto(bool Removed, string? Reason); public sealed record PendingQuestionDto(string TaskId, string QuestionId, string Question); +public sealed record WorkerBuildInfoDto(string? BuildSha); public sealed record OnlineInboxStateDto( bool Enabled, diff --git a/src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs b/src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs index a64631e4..9057ce1e 100644 --- a/src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs @@ -5,6 +5,7 @@ using System.Threading.Tasks; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using ClaudeDo.Data; +using ClaudeDo.Data.Git; using ClaudeDo.Data.Models; using ClaudeDo.Ui.Localization; using ClaudeDo.Ui.Services; @@ -34,6 +35,7 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable private readonly UpdateCheckService _updateCheck = null!; private readonly InstallerLocator _installerLocator = null!; private readonly WorkerLocator _workerLocator = null!; + private readonly GitService? _git; private readonly IDbContextFactory? _dbFactory; private readonly Func _worktreesOverviewVmFactory = () => null!; private readonly Func _weeklyReportVmFactory = () => null!; @@ -102,6 +104,11 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable [ObservableProperty] private string? _updateBannerLatestVersion; private bool _bannerDismissedThisSession; + // Persistent (not auto-clearing) banner: the running worker predates the selected list's + // merged HEAD, so "verified" claims made against the current process are stale. ClaudeDo-repo + // only — see RefreshStaleWorkerCheckAsync. + [ObservableProperty] private bool _isStaleWorkerBannerVisible; + [ObservableProperty] private double _windowWidth = 1280; @@ -212,7 +219,8 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable Func mergeVmFactory, Func repoImportVmFactory, MissionControlViewModel missionControl, - UsagePillViewModel usagePill) + UsagePillViewModel usagePill, + GitService? git = null) { Lists = lists; Tasks = tasks; Details = details; Worker = worker; MissionControl = missionControl; @@ -232,7 +240,9 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable _usageMonitorVmFactory = usageMonitorVmFactory; _mergeVmFactory = mergeVmFactory; _repoImportVmFactory = repoImportVmFactory; + _git = git; Lists.SelectionChanged += (_, _) => Tasks.LoadForList(Lists.SelectedList); + Lists.SelectionChanged += (_, _) => _ = RefreshStaleWorkerCheckAsync(); Tasks.SelectionChanged += (_, _) => Details.Bind(Tasks.SelectedTask); Tasks.NotesRequested += () => Details.ShowNotes(); Tasks.PrepRequested += () => Details.ShowPrep(); @@ -286,6 +296,7 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable } }; Worker.WorkerLogReceivedEvent += OnWorkerLogReceived; + Worker.ConnectionRestoredEvent += () => _ = RefreshStaleWorkerCheckAsync(); Worker.PlanningMergeConflictEvent += OnPlanningMergeConflict; Worker.PrimeFired += OnPrimeFired; _clearTimer.Elapsed += (_, _) => @@ -325,6 +336,8 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable _connectTimer.Dispose(); _primeStatusTimer.Stop(); _primeStatusTimer.Dispose(); + _staleWorkerCts?.Cancel(); + _staleWorkerCts?.Dispose(); } private void RefreshBannerFromStatus() @@ -344,6 +357,57 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable } } + private CancellationTokenSource? _staleWorkerCts; + + // Re-evaluates the stale-worker banner for the currently selected list. Cheap (one hub call + + // up to two git subprocesses) and only ever runs for a git-backed list, so it's fine to fire on + // every selection change / reconnect rather than caching. + private async Task RefreshStaleWorkerCheckAsync() + { + _staleWorkerCts?.Cancel(); + var cts = new CancellationTokenSource(); + _staleWorkerCts = cts; + + var stale = await ComputeIsStaleWorkerAsync(Lists?.SelectedList?.WorkingDir, cts.Token); + if (cts.IsCancellationRequested) return; + IsStaleWorkerBannerVisible = stale; + } + + private async Task ComputeIsStaleWorkerAsync(string? workingDir, CancellationToken ct) + { + if (_git is null || Worker is null || string.IsNullOrWhiteSpace(workingDir)) return false; + + try + { + var buildInfo = await Worker.GetWorkerBuildInfoAsync(); + var buildSha = buildInfo?.BuildSha; + if (string.IsNullOrWhiteSpace(buildSha)) return false; + if (!await _git.IsGitRepoAsync(workingDir, ct)) return false; + + var head = await _git.RevParseHeadAsync(workingDir, ct); + var isAncestor = string.Equals(buildSha, head, StringComparison.OrdinalIgnoreCase) + ? (bool?)false // equal — never "stale" on a match, and no need to ask git + : await _git.IsAncestorAsync(workingDir, buildSha, head, ct); + return ShouldShowStaleWorkerBanner(buildSha, head, isAncestor); + } + catch + { + // Worker offline, dir no longer a repo, etc. — unknown, so stay quiet. + return false; + } + } + + // Pure decision extracted for testability. isAncestor is the tri-state result of + // `git merge-base --is-ancestor buildSha head`: true = worker predates head (stale), false = + // equal or diverged (never claim "stale" on a match or an unrelated history), null = unknown + // (e.g. buildSha isn't a commit this repo knows about — never treat "unknown" as "stale"). + internal static bool ShouldShowStaleWorkerBanner(string? buildSha, string? headSha, bool? isAncestor) + { + if (string.IsNullOrWhiteSpace(buildSha) || string.IsNullOrWhiteSpace(headSha)) return false; + if (string.Equals(buildSha, headSha, StringComparison.OrdinalIgnoreCase)) return false; + return isAncestor == true; + } + [RelayCommand] private void OpenMissionControl() { diff --git a/src/ClaudeDo.Ui/Views/MainWindow.axaml b/src/ClaudeDo.Ui/Views/MainWindow.axaml index cfb59514..3331d241 100644 --- a/src/ClaudeDo.Ui/Views/MainWindow.axaml +++ b/src/ClaudeDo.Ui/Views/MainWindow.axaml @@ -258,5 +258,28 @@ + + + + + +