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 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/src/ClaudeDo.Worker/ClaudeDo.Worker.csproj b/src/ClaudeDo.Worker/ClaudeDo.Worker.csproj
index 3f86b580..15cd6b7d 100644
--- a/src/ClaudeDo.Worker/ClaudeDo.Worker.csproj
+++ b/src/ClaudeDo.Worker/ClaudeDo.Worker.csproj
@@ -35,4 +35,18 @@
+
+
+
+
+
+
+
diff --git a/src/ClaudeDo.Worker/Hub/WorkerHub.cs b/src/ClaudeDo.Worker/Hub/WorkerHub.cs
index 15959702..ad40fb56 100644
--- a/src/ClaudeDo.Worker/Hub/WorkerHub.cs
+++ b/src/ClaudeDo.Worker/Hub/WorkerHub.cs
@@ -29,6 +29,10 @@ namespace ClaudeDo.Worker.Hub;
public record ActiveTaskDto(string Slot, string TaskId, DateTime StartedAt);
+/// Git SHA the running worker was built from. Null when the build wasn't stamped
+/// (e.g. a local dev build without the SourceRevisionId target).
+public record WorkerBuildInfoDto(string? BuildSha);
+
public record AppSettingsDto(
string DefaultClaudeInstructions,
string DefaultModel,
@@ -159,6 +163,20 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
private static readonly string Version =
Assembly.GetExecutingAssembly().GetName().Version?.ToString(3) ?? "0.0.0";
+ // SourceRevisionId (set by ClaudeDo.Worker.csproj's git-rev-parse build target) is appended
+ // by the SDK to InformationalVersion as "+{sha}" -- take the segment after the LAST '+' so
+ // this stays correct even when MinVer's own pre-release metadata already contains a '+'.
+ private static readonly string? BuildSha = ParseBuildSha(
+ Assembly.GetExecutingAssembly().GetCustomAttribute()?.InformationalVersion);
+
+ internal static string? ParseBuildSha(string? informationalVersion)
+ {
+ if (string.IsNullOrEmpty(informationalVersion)) return null;
+ var plusIndex = informationalVersion.LastIndexOf('+');
+ if (plusIndex < 0 || plusIndex == informationalVersion.Length - 1) return null;
+ return informationalVersion[(plusIndex + 1)..];
+ }
+
private readonly QueueService _queue;
private readonly IQueueWaker _waker;
private readonly AgentFileService _agentService;
@@ -325,6 +343,11 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
public string Ping() => $"pong v{Version}";
+ /// Lets the UI detect a worker that's still running an older build than the code it's
+ /// comparing against (e.g. a just-merged main) — a separate diagnostic rather than extending
+ /// Ping's "pong vX.Y.Z" text, since that format isn't ours to break for any existing caller.
+ public WorkerBuildInfoDto GetWorkerBuildInfo() => new(BuildSha);
+
public IReadOnlyList GetActive()
{
return _queue.GetActive()
diff --git a/tests/ClaudeDo.Ui.Tests/StaleWorkerBannerDecisionTests.cs b/tests/ClaudeDo.Ui.Tests/StaleWorkerBannerDecisionTests.cs
new file mode 100644
index 00000000..46e00430
--- /dev/null
+++ b/tests/ClaudeDo.Ui.Tests/StaleWorkerBannerDecisionTests.cs
@@ -0,0 +1,47 @@
+using ClaudeDo.Ui.ViewModels;
+using Xunit;
+
+namespace ClaudeDo.Ui.Tests;
+
+public class StaleWorkerBannerDecisionTests
+{
+ private const string ShaA = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
+ private const string ShaB = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
+
+ [Fact]
+ public void True_when_build_is_a_strict_ancestor_of_head()
+ {
+ Assert.True(IslandsShellViewModel.ShouldShowStaleWorkerBanner(ShaA, ShaB, isAncestor: true));
+ }
+
+ [Fact]
+ public void False_when_build_equals_head_even_if_ancestor_check_says_true()
+ {
+ Assert.False(IslandsShellViewModel.ShouldShowStaleWorkerBanner(ShaA, ShaA, isAncestor: true));
+ }
+
+ [Fact]
+ public void False_when_ancestor_check_says_no()
+ {
+ Assert.False(IslandsShellViewModel.ShouldShowStaleWorkerBanner(ShaA, ShaB, isAncestor: false));
+ }
+
+ [Fact]
+ public void False_when_ancestor_check_is_unknown()
+ {
+ // Unknown must never be treated as stale.
+ Assert.False(IslandsShellViewModel.ShouldShowStaleWorkerBanner(ShaA, ShaB, isAncestor: null));
+ }
+
+ [Fact]
+ public void False_when_build_sha_missing()
+ {
+ Assert.False(IslandsShellViewModel.ShouldShowStaleWorkerBanner(null, ShaB, isAncestor: true));
+ }
+
+ [Fact]
+ public void False_when_head_sha_missing()
+ {
+ Assert.False(IslandsShellViewModel.ShouldShowStaleWorkerBanner(ShaA, null, isAncestor: true));
+ }
+}
diff --git a/tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs b/tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs
index e00957b3..511b78ae 100644
--- a/tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs
+++ b/tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs
@@ -152,6 +152,8 @@ public abstract class StubWorkerClient : IWorkerClient
public virtual Task DeleteDailyNoteAsync(string id) => Task.CompletedTask;
public string LastPrepLog = "";
public virtual Task GetLastPrepLogAsync() => Task.FromResult(LastPrepLog);
+ public WorkerBuildInfoDto? WorkerBuildInfo;
+ public virtual Task GetWorkerBuildInfoAsync() => Task.FromResult(WorkerBuildInfo);
public virtual Task RefineTaskAsync(string taskId) => Task.CompletedTask;
public virtual Task GetOnlineInboxStateAsync() => Task.FromResult(null);
diff --git a/tests/ClaudeDo.Worker.Tests/Hub/WorkerBuildInfoHubTests.cs b/tests/ClaudeDo.Worker.Tests/Hub/WorkerBuildInfoHubTests.cs
new file mode 100644
index 00000000..64624422
--- /dev/null
+++ b/tests/ClaudeDo.Worker.Tests/Hub/WorkerBuildInfoHubTests.cs
@@ -0,0 +1,36 @@
+using ClaudeDo.Worker.Hub;
+using Xunit;
+
+namespace ClaudeDo.Worker.Tests.Hub;
+
+public sealed class WorkerBuildInfoHubTests
+{
+ [Theory]
+ [InlineData(null, null)]
+ [InlineData("", null)]
+ [InlineData("1.2.3", null)]
+ [InlineData("1.2.3+", null)]
+ [InlineData("1.2.3+abc1234", "abc1234")]
+ // MinVer's own pre-release metadata can already contain a '+' (e.g. "+devsha"); our
+ // SourceRevisionId is appended last, so only the segment after the LAST '+' is authoritative.
+ [InlineData("0.0.0-alpha.0.4+devsha+f5b1a2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9", "f5b1a2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9")]
+ public void ParseBuildSha_ExtractsSegmentAfterLastPlus(string? informationalVersion, string? expected)
+ {
+ Assert.Equal(expected, WorkerHub.ParseBuildSha(informationalVersion));
+ }
+
+ [Fact]
+ public void GetWorkerBuildInfo_ReturnsDto()
+ {
+ var hub = new WorkerHub(
+ null!, null!, null!, null!, null!, null!,
+ null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!,
+ null!, new ClaudeDo.Worker.Online.OnlineInboxConfig(), new ClaudeDo.Worker.Online.OnlineTokenStore(),
+ new ClaudeDo.Worker.Runner.PendingQuestionRegistry(), null!);
+
+ // No assertion on the actual SHA value (depends on the build environment) — just that
+ // the call doesn't throw and always returns a DTO, never null.
+ var info = hub.GetWorkerBuildInfo();
+ Assert.NotNull(info);
+ }
+}
diff --git a/tests/ClaudeDo.Worker.Tests/Runner/GitServiceMergeTests.cs b/tests/ClaudeDo.Worker.Tests/Runner/GitServiceMergeTests.cs
index f01c3cb7..8acfd4b1 100644
--- a/tests/ClaudeDo.Worker.Tests/Runner/GitServiceMergeTests.cs
+++ b/tests/ClaudeDo.Worker.Tests/Runner/GitServiceMergeTests.cs
@@ -317,4 +317,45 @@ public class GitServiceMergeTests : IDisposable
Assert.Equal(headBefore, GitRepoFixture.RunGit(repo.RepoDir, "rev-parse", "HEAD").Trim());
Assert.True(string.IsNullOrWhiteSpace(GitRepoFixture.RunGit(repo.RepoDir, "status", "--porcelain")));
}
+
+ [Fact]
+ public async Task IsAncestorAsync_EarlierCommit_ReturnsTrue()
+ {
+ if (!GitRepoFixture.IsGitAvailable()) return;
+ var repo = NewRepo();
+ var first = repo.BaseCommit;
+
+ File.WriteAllText(Path.Combine(repo.RepoDir, "second.txt"), "second\n");
+ GitRepoFixture.RunGit(repo.RepoDir, "add", "-A");
+ GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "chore: second commit");
+
+ var git = new GitService();
+ var head = (await git.RevParseHeadAsync(repo.RepoDir)).Trim();
+
+ Assert.True(await git.IsAncestorAsync(repo.RepoDir, first, head));
+ Assert.False(await git.IsAncestorAsync(repo.RepoDir, head, first));
+ }
+
+ [Fact]
+ public async Task IsAncestorAsync_SameCommit_ReturnsTrue()
+ {
+ if (!GitRepoFixture.IsGitAvailable()) return;
+ var repo = NewRepo();
+ var git = new GitService();
+ var head = (await git.RevParseHeadAsync(repo.RepoDir)).Trim();
+
+ Assert.True(await git.IsAncestorAsync(repo.RepoDir, head, head));
+ }
+
+ [Fact]
+ public async Task IsAncestorAsync_UnknownCommit_ReturnsNull()
+ {
+ if (!GitRepoFixture.IsGitAvailable()) return;
+ var repo = NewRepo();
+ var git = new GitService();
+ var head = (await git.RevParseHeadAsync(repo.RepoDir)).Trim();
+ var bogus = new string('a', 40);
+
+ Assert.Null(await git.IsAncestorAsync(repo.RepoDir, bogus, head));
+ }
}
diff --git a/tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs b/tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs
index 818059fa..be3a7c1f 100644
--- a/tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs
+++ b/tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs
@@ -143,6 +143,7 @@ sealed class FakeWorkerClient : IWorkerClient
public Task UpdateDailyNoteAsync(string id, string text) => Task.CompletedTask;
public Task DeleteDailyNoteAsync(string id) => Task.CompletedTask;
public Task GetLastPrepLogAsync() => Task.FromResult(string.Empty);
+ public Task GetWorkerBuildInfoAsync() => Task.FromResult(null);
public Task RefineTaskAsync(string taskId) => Task.CompletedTask;
public Task GetOnlineInboxStateAsync() => Task.FromResult(null);
public Task SetOnlineInboxConfigAsync(OnlineInboxConfigInputDto input) => Task.CompletedTask;