feat(ui): warn when the running worker predates the selected repo's merged HEAD
Stamps ClaudeDo.Worker's build with its exact git SHA (SourceRevisionId -> InformationalVersion) and exposes it via a new GetWorkerBuildInfo hub call. For the currently selected list, the shell compares that SHA against the list's git HEAD (GitService.IsAncestorAsync) and shows a persistent footer banner -- never auto-clearing, never shown on an unrelated repo or when the ancestry can't be determined -- so "verified against a merge" claims aren't silently made against a stale process. No auto-restart; the banner just offers the existing RestartWorkerCommand.
This commit is contained in:
@@ -26,6 +26,22 @@ public sealed class GitService
|
||||
return stdout.Trim();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True if <paramref name="ancestorSha"/> is an ancestor of (or equal to) <paramref name="descendantSha"/>,
|
||||
/// 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".
|
||||
/// </summary>
|
||||
public async Task<bool?> 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);
|
||||
|
||||
@@ -648,6 +648,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": {
|
||||
|
||||
@@ -648,6 +648,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": {
|
||||
|
||||
@@ -130,6 +130,9 @@ public interface IWorkerClient : INotifyPropertyChanged
|
||||
Task<string> GetLastPrepLogAsync();
|
||||
Task<IReadOnlyList<WorkerLogEntry>> GetRecentLogsAsync();
|
||||
|
||||
/// <summary>Git SHA the running worker was built from (null when offline or the build isn't stamped).</summary>
|
||||
Task<WorkerBuildInfoDto?> GetWorkerBuildInfoAsync();
|
||||
|
||||
Task<List<PrimeScheduleDto>> GetPrimeSchedulesAsync();
|
||||
Task<PrimeScheduleDto?> UpsertPrimeScheduleAsync(PrimeScheduleDto dto);
|
||||
Task DeletePrimeScheduleAsync(Guid id);
|
||||
|
||||
@@ -423,6 +423,9 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
|
||||
public async Task<string> GetLastPrepLogAsync()
|
||||
=> await TryInvokeAsync<string>("GetLastPrepLog") ?? string.Empty;
|
||||
|
||||
public Task<WorkerBuildInfoDto?> GetWorkerBuildInfoAsync()
|
||||
=> TryInvokeAsync<WorkerBuildInfoDto>("GetWorkerBuildInfo");
|
||||
|
||||
public async Task<IReadOnlyList<WorkerLogEntry>> GetRecentLogsAsync()
|
||||
=> await TryInvokeAsync<List<WorkerLogEntry>>("GetRecentLogs") ?? new List<WorkerLogEntry>();
|
||||
|
||||
@@ -687,6 +690,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,
|
||||
|
||||
@@ -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<ClaudeDoDbContext>? _dbFactory;
|
||||
private readonly Func<WorktreesOverviewModalViewModel> _worktreesOverviewVmFactory = () => null!;
|
||||
private readonly Func<WeeklyReportModalViewModel> _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<MergeModalViewModel> mergeVmFactory,
|
||||
Func<RepoImportModalViewModel> 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();
|
||||
@@ -285,6 +295,7 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
|
||||
}
|
||||
};
|
||||
Worker.WorkerLogReceivedEvent += OnWorkerLogReceived;
|
||||
Worker.ConnectionRestoredEvent += () => _ = RefreshStaleWorkerCheckAsync();
|
||||
Worker.PlanningMergeConflictEvent += OnPlanningMergeConflict;
|
||||
Worker.PrimeFired += OnPrimeFired;
|
||||
_clearTimer.Elapsed += (_, _) =>
|
||||
@@ -324,6 +335,8 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
|
||||
_connectTimer.Dispose();
|
||||
_primeStatusTimer.Stop();
|
||||
_primeStatusTimer.Dispose();
|
||||
_staleWorkerCts?.Cancel();
|
||||
_staleWorkerCts?.Dispose();
|
||||
}
|
||||
|
||||
private void RefreshBannerFromStatus()
|
||||
@@ -343,6 +356,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<bool> 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()
|
||||
{
|
||||
|
||||
@@ -258,5 +258,28 @@
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Stale-worker notice — bottom-right corner, above the update popup. Persistent
|
||||
(no auto-dismiss timer): the running worker predates the selected list's merged
|
||||
HEAD, so a restart is needed before "verified" claims against it are trustworthy. -->
|
||||
<Border Grid.Row="0" Grid.RowSpan="3"
|
||||
VerticalAlignment="Bottom" HorizontalAlignment="Right"
|
||||
Margin="0,0,16,100"
|
||||
ZIndex="100"
|
||||
IsVisible="{Binding IsStaleWorkerBannerVisible}"
|
||||
Background="{DynamicResource DeepBrush}"
|
||||
BorderBrush="{DynamicResource LineBrush}"
|
||||
BorderThickness="1"
|
||||
CornerRadius="6"
|
||||
Padding="14,10">
|
||||
<StackPanel Spacing="8" Width="260">
|
||||
<TextBlock Classes="body" Text="{loc:Tr shell.staleWorker.message}" TextWrapping="Wrap"/>
|
||||
<StackPanel Orientation="Horizontal" Spacing="8" HorizontalAlignment="Right">
|
||||
<Button Classes="btn"
|
||||
Content="{loc:Tr shell.menu.restartWorker}"
|
||||
Command="{Binding RestartWorkerCommand}"/>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
</Grid>
|
||||
</Window>
|
||||
|
||||
@@ -35,4 +35,18 @@
|
||||
<InternalsVisibleTo Include="ClaudeDo.Worker.Tests" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Stamps this worker's exact commit into AssemblyInformationalVersion as "+{sha}" so a
|
||||
running instance can report which commit it was built from (see WorkerHub.GetWorkerBuildInfo).
|
||||
Independent of MinVer: MinVerVersionOverride (release builds) replaces the base version but
|
||||
doesn't touch SourceRevisionId, and the SDK appends it to InformationalVersion regardless. -->
|
||||
<Target Name="SetBuildRevisionFromGit" BeforeTargets="GetAssemblyVersion" Condition="'$(SourceRevisionId)' == ''">
|
||||
<Exec Command="git rev-parse HEAD"
|
||||
WorkingDirectory="$(MSBuildProjectDirectory)"
|
||||
ConsoleToMSBuild="true"
|
||||
StandardOutputImportance="low"
|
||||
ContinueOnError="WarnAndContinue">
|
||||
<Output TaskParameter="ConsoleOutput" PropertyName="SourceRevisionId" />
|
||||
</Exec>
|
||||
</Target>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -29,6 +29,10 @@ namespace ClaudeDo.Worker.Hub;
|
||||
|
||||
public record ActiveTaskDto(string Slot, string TaskId, DateTime StartedAt);
|
||||
|
||||
/// <summary>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).</summary>
|
||||
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<AssemblyInformationalVersionAttribute>()?.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}";
|
||||
|
||||
/// <summary>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.</summary>
|
||||
public WorkerBuildInfoDto GetWorkerBuildInfo() => new(BuildSha);
|
||||
|
||||
public IReadOnlyList<ActiveTaskDto> GetActive()
|
||||
{
|
||||
return _queue.GetActive()
|
||||
|
||||
Reference in New Issue
Block a user